Skip to content
This repository was archived by the owner on Jun 21, 2023. It is now read-only.

Commit 4a656b1

Browse files
authored
Merge pull request #31 from AckeeCZ/feat/28-add-sentry-logging
Add sentry logging
2 parents 9470836 + da6e5dc commit 4a656b1

9 files changed

Lines changed: 210 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
### Added
44
- parent logger name inheritance
5+
- logging to sentry
56

67
### Changed
78
- `pino.pretty` replaced with `util.inspect`

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,30 @@ All loglevels up to warning (exclusive) - trace, debug and info - are logged to
8484

8585
All loglevels from warning up (inclusive) - warning, error, fatal - are logged to `stderr` **only**.
8686

87+
## Using Sentry
88+
89+
Cosmas logs every message to [Sentry](https://sentry.io/) for you, when configured. This feature is disabled by default.
90+
91+
Sentry SDK `@sentry/node` is a peer dependency. If you want cosmas to use it, install it in your project.
92+
93+
```js
94+
// (1) Let cosmas initialize sentry with provided DSN
95+
const myLogger = logger({ sentry: 'https://<key>@sentry.io/<project>' })
96+
97+
// (2) Configure sentry yourself and let cosmas use it
98+
Sentry.init({/*...*/})
99+
const myLogger = logger({ sentry: true })
100+
101+
// (3) Disable sentry (default, no need to send false option)
102+
const myLogger = logger({ sentry: false })
103+
```
104+
105+
When configured, cosmas (additionally to standard logging) captures all logs via Sentry SDK. Logs containing `stack` are logged as exceptions via `captureException` (preserves stack trace) and all other messages via `captureMessage`.
106+
107+
Either way, scope is appropriately set, as well as all payload is passed on in scope's metadata.
108+
109+
110+
87111
## Express middleware
88112

89113
`cosmas` contains an express middleware which you can use to log all requests and responses of your express application.

package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
]
2323
},
2424
"author": "Michal Vlasák <michal.vlasak@ackee.cz>",
25+
"contributors": [
26+
"Michal Vlasák <michal.vlasak@ackee.cz>",
27+
"Jaroslav Šmolík <grissius@gmail.com>"
28+
],
2529
"license": "ISC",
2630
"engines": {
2731
"node": ">=6"
@@ -44,7 +48,11 @@
4448
"pino": "^5.13.2",
4549
"pino-multi-stream": "^4.2.0"
4650
},
51+
"peerDependencies": {
52+
"@sentry/node": "^5.9.0"
53+
},
4754
"devDependencies": {
55+
"@sentry/node": "^5.9.0",
4856
"@types/jest": "^24.0.13",
4957
"@types/lodash.foreach": "^4.5.4",
5058
"@types/lodash.isempty": "^4.4.4",

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ const defaultLogger = (options: AckeeLoggerOptions & { loggerName?: string } = {
6969
serializers.disablePaths(options.disableFields);
7070
serializers.enablePaths(options.enableFields);
7171

72+
if (options.sentry) {
73+
const sentry = require('@sentry/node');
74+
if (typeof options.sentry === 'string') {
75+
sentry.init({ dsn: options.sentry });
76+
}
77+
}
78+
7279
const isTesting = process.env.NODE_ENV === 'test';
7380
const defaultLevel: Level = options.defaultLevel || (isTesting ? 'silent' : 'debug');
7481
const messageKey = 'message'; // best option for Google Stackdriver,

src/interfaces.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,6 @@ export interface AckeeLoggerOptions {
2121
ignoredHttpMethods?: string[];
2222
config?: LoggerOptions;
2323
pretty?: boolean;
24+
sentry?: string | boolean;
2425
skip?: (req: Request, res?: Response) => boolean;
2526
}

src/sentry.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { captureException, captureMessage, Severity, withScope } from '@sentry/node';
2+
import { Transform, TransformCallback } from 'stream';
3+
4+
const reportToSentry = (obj: any) => {
5+
if (!obj.stack) {
6+
return captureMessage(obj.message || obj);
7+
}
8+
const error = new Error(obj.message);
9+
error.message = obj.message;
10+
error.stack = obj.stack;
11+
error.name = obj.name;
12+
return captureException(error);
13+
};
14+
15+
const PINO_TO_SENTRY: { [key: number]: Severity } = {
16+
10: Severity.Debug,
17+
20: Severity.Debug,
18+
30: Severity.Info,
19+
40: Severity.Warning,
20+
50: Severity.Error,
21+
60: Severity.Critical,
22+
};
23+
24+
class SentryTransformStream extends Transform {
25+
// tslint:disable-next-line:function-name
26+
public _transform(chunk: any, _encoding: string, callback: TransformCallback) {
27+
const obj = JSON.parse(chunk);
28+
withScope(scope => {
29+
scope.setLevel(PINO_TO_SENTRY[obj.level]);
30+
scope.setExtras(obj);
31+
reportToSentry(obj);
32+
});
33+
this.push(chunk);
34+
callback();
35+
}
36+
}
37+
export { SentryTransformStream };

src/streams.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as util from 'util';
66
import { loggerNameKey, pkgVersionKey } from '.';
77
import { AckeeLoggerOptions, AckeeLoggerStream } from './interfaces';
88
import { levels } from './levels';
9+
import { SentryTransformStream } from './sentry';
910
import { StackDriverFormatStream } from './stackdriver';
1011

1112
const pkgJson = JSON.parse(fs.readFileSync(path.resolve(path.join(__dirname, '..', 'package.json')), 'utf8'));
@@ -71,6 +72,11 @@ const initLoggerStreams = (
7172
}
7273

7374
streams = decorateStreams(streams, getDefaultTransformStream(options));
75+
76+
if (options.sentry) {
77+
streams = decorateStreams(streams, SentryTransformStream);
78+
}
79+
7480
return streams;
7581
};
7682

src/tests/sentry-mocked.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
let loggerFactory;
2+
const scope: any = {};
3+
const withScope = jest.fn(fn =>
4+
fn({
5+
setContext: (key: string, val: any) => {
6+
scope.context = { [key]: val };
7+
},
8+
setExtras: (val: any) => {
9+
scope.extras = val;
10+
},
11+
setLevel: (level: any) => {
12+
scope.level = level;
13+
},
14+
})
15+
);
16+
17+
const createCapture = (cb = () => {}) => data => {
18+
cb();
19+
return { data, scope };
20+
};
21+
22+
const captureException = jest.fn(createCapture());
23+
const captureMessage = jest.fn(createCapture());
24+
const init = jest.fn();
25+
26+
describe('sentry mocked', () => {
27+
beforeAll(() => {
28+
jest.mock('@sentry/node', () => {
29+
return {
30+
captureException,
31+
captureMessage,
32+
withScope,
33+
init,
34+
Severity: {
35+
Debug: 'debug',
36+
Info: 'info',
37+
Warning: 'warning',
38+
Error: 'error',
39+
Critical: 'critical',
40+
},
41+
};
42+
});
43+
loggerFactory = require('..').default;
44+
});
45+
beforeEach(() => {
46+
captureException.mockReset();
47+
captureMessage.mockReset();
48+
});
49+
test('can create logger with options', () => {
50+
expect(() => loggerFactory()).not.toThrowError();
51+
expect(() => loggerFactory({ sentry: true })).not.toThrowError();
52+
expect(init).not.toHaveBeenCalled();
53+
expect(() => loggerFactory({ sentry: 'dummy' })).not.toThrowError();
54+
expect(init.mock.calls[0]).toMatchInlineSnapshot(`
55+
Array [
56+
Object {
57+
"dsn": "dummy",
58+
},
59+
]
60+
`);
61+
});
62+
63+
test('sentry captureMessage is called with correct scope', async () => {
64+
await new Promise((resolve, reject) => {
65+
const logger = loggerFactory({
66+
sentry: 'DSN',
67+
});
68+
captureMessage.mockImplementation(createCapture(resolve));
69+
logger.info('Foo');
70+
});
71+
expect(captureMessage).toHaveBeenCalledTimes(1);
72+
expect(captureException).not.toHaveBeenCalled();
73+
expect(captureMessage.mock.calls[0]).toMatchInlineSnapshot(`
74+
Array [
75+
"Foo",
76+
]
77+
`);
78+
expect(captureMessage.mock.results[0].value).toMatchInlineSnapshot(`
79+
Object {
80+
"data": "Foo",
81+
"scope": Object {
82+
"extras": Object {
83+
"level": 30,
84+
"message": "Foo",
85+
"v": 1,
86+
},
87+
"level": "info",
88+
},
89+
}
90+
`);
91+
});
92+
93+
test('sentry captureException with stack and correct levels', async () => {
94+
await new Promise((resolve, reject) => {
95+
const logger = loggerFactory({
96+
sentry: 'DSN',
97+
});
98+
captureException.mockReset();
99+
captureException.mockImplementation(createCapture(resolve));
100+
logger.error(new Error());
101+
});
102+
expect(captureException).toHaveBeenCalledTimes(1);
103+
expect(captureMessage).not.toHaveBeenCalled();
104+
expect(captureException.mock.results[0].value).toMatchObject({
105+
data: expect.any(Error),
106+
scope: {
107+
level: 'error',
108+
},
109+
});
110+
});
111+
});

src/tests/sentry.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import loggerFactory from '..';
2+
3+
describe('sentry not available', () => {
4+
beforeAll(() => {
5+
jest.mock('@sentry/node', () => {
6+
throw new Error("Cannot find module '@sentry/node' from 'index.ts'");
7+
});
8+
});
9+
test('without sentry lib works by default, but crashes on provided', () => {
10+
expect(() => loggerFactory()).not.toThrowError();
11+
expect(() => loggerFactory({ sentry: 'DSN' })).toThrowErrorMatchingInlineSnapshot(
12+
`"Cannot find module '@sentry/node' from 'index.ts'"`
13+
);
14+
});
15+
});

0 commit comments

Comments
 (0)