Skip to content

Commit e758d65

Browse files
committed
feat(node): Add Fastify integration
1 parent 5c2546f commit e758d65

File tree

9 files changed

+563
-10
lines changed

9 files changed

+563
-10
lines changed

packages/node-experimental/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,51 @@ All of these are auto-discovered, you don't need to configure anything for perfo
130130
You still need to register middlewares etc. for error capturing.
131131
Other, non-performance integrations from `@sentry/node` are also available (except for Undici).
132132

133+
## Fully supported Frameworks
134+
135+
### Express
136+
137+
```js
138+
const Sentry = require('@sentry/node-experimental');
139+
140+
Sentry.init({
141+
dsn: '...',
142+
tracesSampleRate: 1,
143+
});
144+
145+
// Ensure `init` was called _before_ you require express!
146+
const express = require('express');
147+
const app = express();
148+
149+
// Add your routes
150+
151+
// The error handler must be before any other error middleware and after all controllers
152+
app.use(Sentry.Handlers.errorHandler());
153+
154+
app.listen(3000);
155+
```
156+
157+
### Fastify
158+
159+
```js
160+
const Sentry = require('@sentry/node-experimental');
161+
162+
Sentry.init({
163+
dsn: '...',
164+
tracesSampleRate: 1,
165+
});
166+
167+
// Ensure `init` was called _before_ you require fastify!
168+
const fastify = require('fastify');
169+
const app = fastify();
170+
171+
app.register(Sentry.SentryFastifyErrorPlugin);
172+
173+
// Add your routes
174+
175+
app.listen();
176+
```
177+
133178
## Links
134179

135180
- [Official SDK Docs](https://docs.sentry.io/quickstart/)

packages/node-experimental/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export {
5151
trace,
5252
withScope,
5353
captureCheckIn,
54+
SentryFastifyPlugin,
5455
} from '@sentry/node';
5556

5657
export type {

packages/node/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@types/lru-cache": "^5.1.0",
3939
"@types/node": "~10.17.0",
4040
"express": "^4.17.1",
41+
"fastify": "^4.23.2",
4142
"nock": "^13.0.5",
4243
"undici": "^5.21.0"
4344
},
@@ -61,8 +62,9 @@
6162
"lint": "run-s lint:prettier lint:eslint",
6263
"lint:eslint": "eslint . --format stylish",
6364
"lint:prettier": "prettier --check \"{src,test,scripts}/**/**.ts\"",
64-
"test": "run-s test:jest test:express test:webpack test:release-health",
65+
"test": "run-s test:jest test:express test:fastify test:webpack test:release-health",
6566
"test:express": "node test/manual/express-scope-separation/start.js",
67+
"test:fastify": "node test/manual/fastify-scope-separation/start.js",
6668
"test:jest": "jest",
6769
"test:release-health": "node test/manual/release-health/runner.js",
6870
"test:webpack": "cd test/manual/webpack-async-context/ && yarn --silent && node npm-build.js",

packages/node/src/handlers.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,10 @@ export function requestHandler(
207207
const client = currentHub.getClient<NodeClient>();
208208
if (isAutoSessionTrackingEnabled(client)) {
209209
setImmediate(() => {
210-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
211-
if (client && (client as any)._captureRequestSession) {
210+
if (client && client['_captureRequestSession']) {
212211
// Calling _captureRequestSession to capture request session at the end of the request by incrementing
213212
// the correct SessionAggregates bucket i.e. crashed, errored or exited
214-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
215-
(client as any)._captureRequestSession();
213+
client['_captureRequestSession']();
216214
}
217215
});
218216
}

packages/node/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,4 @@ const INTEGRATIONS = {
8686
};
8787

8888
export { INTEGRATIONS as Integrations, Handlers };
89+
export { SentryFastifyErrorPlugin } from './integrations/fastify';
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { captureException, getCurrentHub, runWithAsyncContext } from '@sentry/core';
2+
import type { Integration } from '@sentry/types';
3+
import { logger } from '@sentry/utils';
4+
import type * as http from 'http';
5+
6+
import type { NodeClient } from '../client';
7+
import { isAutoSessionTrackingEnabled } from '../sdk';
8+
9+
// We do not want to have fastify as a dependency, so we mock the type here
10+
11+
type RequestHookHandler = (
12+
this: FastifyInstance,
13+
request: http.IncomingMessage,
14+
reply: unknown,
15+
done: () => void,
16+
) => void;
17+
interface FastifyInstance {
18+
register: (plugin: typeof SentryFastifyErrorPlugin | typeof SentryFastifyRequestPlugin) => void;
19+
20+
/**
21+
* `onRequest` is the first hook to be executed in the request lifecycle. There was no previous hook, the next hook will be `preParsing`.
22+
* Notice: in the `onRequest` hook, request.body will always be null, because the body parsing happens before the `preHandler` hook.
23+
*/
24+
/**
25+
* `onResponse` is the seventh and last hook in the request hook lifecycle. The previous hook was `onSend`, there is no next hook.
26+
* The onResponse hook is executed when a response has been sent, so you will not be able to send more data to the client. It can however be useful for sending data to external services, for example to gather statistics.
27+
*/
28+
addHook(name: 'onRequest' | 'onResponse', hook: RequestHookHandler): void;
29+
30+
/**
31+
* This hook is useful if you need to do some custom error logging or add some specific header in case of error.
32+
* It is not intended for changing the error, and calling reply.send will throw an exception.
33+
* This hook will be executed only after the customErrorHandler has been executed, and only if the customErrorHandler sends an error back to the user (Note that the default customErrorHandler always sends the error back to the user).
34+
* Notice: unlike the other hooks, pass an error to the done function is not supported.
35+
*/
36+
addHook(
37+
name: 'onError',
38+
hook: (
39+
this: FastifyInstance,
40+
request: http.IncomingMessage,
41+
reply: unknown,
42+
error: Error,
43+
done: () => void,
44+
) => void,
45+
): void;
46+
}
47+
48+
const SKIP_OVERRIDE = Symbol.for('skip-override');
49+
const FASTIFY_DISPLAY_NAME = Symbol.for('fastify.display-name');
50+
51+
interface FastifyOptions {
52+
fastify: FastifyInstance;
53+
}
54+
55+
const SentryFastifyRequestPlugin = Object.assign(
56+
(fastify: FastifyInstance, _options: unknown, pluginDone: () => void) => {
57+
fastify.addHook('onRequest', (request, _reply, done) => {
58+
runWithAsyncContext(() => {
59+
const currentHub = getCurrentHub();
60+
currentHub.configureScope(scope => {
61+
scope.setSDKProcessingMetadata({
62+
request,
63+
});
64+
65+
const client = currentHub.getClient<NodeClient>();
66+
if (isAutoSessionTrackingEnabled(client)) {
67+
const scope = currentHub.getScope();
68+
// Set `status` of `RequestSession` to Ok, at the beginning of the request
69+
scope.setRequestSession({ status: 'ok' });
70+
}
71+
});
72+
73+
done();
74+
});
75+
});
76+
77+
fastify.addHook('onResponse', (_request, _reply, done) => {
78+
const client = getCurrentHub().getClient<NodeClient>();
79+
if (isAutoSessionTrackingEnabled(client)) {
80+
setImmediate(() => {
81+
if (client && client['_captureRequestSession']) {
82+
// Calling _captureRequestSession to capture request session at the end of the request by incrementing
83+
// the correct SessionAggregates bucket i.e. crashed, errored or exited
84+
client['_captureRequestSession']();
85+
}
86+
});
87+
}
88+
89+
done();
90+
});
91+
92+
pluginDone();
93+
},
94+
{
95+
[SKIP_OVERRIDE]: true,
96+
[FASTIFY_DISPLAY_NAME]: 'SentryFastifyRequestPlugin',
97+
},
98+
);
99+
100+
export const SentryFastifyErrorPlugin = Object.assign(
101+
(fastify: FastifyInstance, _options: unknown, pluginDone: () => void) => {
102+
fastify.addHook('onError', (_request, _reply, error, done) => {
103+
captureException(error);
104+
done();
105+
});
106+
107+
pluginDone();
108+
},
109+
{
110+
[SKIP_OVERRIDE]: true,
111+
[FASTIFY_DISPLAY_NAME]: 'SentryFastifyErrorPlugin',
112+
},
113+
);
114+
115+
/** Capture errors for your fastify app. */
116+
export class Fastify implements Integration {
117+
public static id: string = 'Fastify';
118+
public name: string = Fastify.id;
119+
120+
private _fastify?: FastifyInstance;
121+
122+
public constructor(options?: FastifyOptions) {
123+
const fastify = options?.fastify;
124+
this._fastify = fastify && typeof fastify.register === 'function' ? fastify : undefined;
125+
126+
if (__DEBUG_BUILD__ && !this._fastify) {
127+
logger.warn('The Fastify integration expects a fastify instance to be passed. No errors will be captured.');
128+
}
129+
}
130+
131+
/**
132+
* @inheritDoc
133+
*/
134+
public setupOnce(): void {
135+
if (!this._fastify) {
136+
return;
137+
}
138+
139+
void this._fastify.register(SentryFastifyErrorPlugin);
140+
void this._fastify.register(SentryFastifyRequestPlugin);
141+
}
142+
}

packages/node/src/integrations/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export { Context } from './context';
99
export { RequestData } from './requestdata';
1010
export { LocalVariables } from './localvariables';
1111
export { Undici } from './undici';
12+
export { Fastify } from './fastify';
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
const http = require('http');
2+
const fastify = require('fastify');
3+
const app = fastify();
4+
const Sentry = require('../../../build/cjs');
5+
const { colorize } = require('../colorize');
6+
const { TextEncoder } = require('util');
7+
8+
// don't log the test errors we're going to throw, so at a quick glance it doesn't look like the test itself has failed
9+
global.console.error = () => null;
10+
11+
function assertTags(actual, expected) {
12+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
13+
console.log(colorize('FAILED: Scope contains incorrect tags\n', 'red'));
14+
console.log(colorize(`Got: ${JSON.stringify(actual)}\n`, 'red'));
15+
console.log(colorize(`Expected: ${JSON.stringify(expected)}\n`, 'red'));
16+
process.exit(1);
17+
}
18+
}
19+
20+
let remaining = 3;
21+
22+
function makeDummyTransport() {
23+
return Sentry.createTransport({ recordDroppedEvent: () => undefined, textEncoder: new TextEncoder() }, req => {
24+
--remaining;
25+
26+
if (!remaining) {
27+
console.log(colorize('PASSED: All scopes contain correct tags\n', 'green'));
28+
app.close();
29+
process.exit(0);
30+
}
31+
32+
return Promise.resolve({
33+
statusCode: 200,
34+
});
35+
});
36+
}
37+
38+
Sentry.init({
39+
dsn: 'http://[email protected]/1337',
40+
transport: makeDummyTransport,
41+
integrations: [new Sentry.Integrations.Fastify({ fastify: app })],
42+
beforeSend(event) {
43+
if (event.transaction === 'GET /foo') {
44+
assertTags(event.tags, {
45+
global: 'wat',
46+
foo: 'wat',
47+
});
48+
} else if (event.transaction === 'GET /bar') {
49+
assertTags(event.tags, {
50+
global: 'wat',
51+
bar: 'wat',
52+
});
53+
} else if (event.transaction === 'GET /baz') {
54+
assertTags(event.tags, {
55+
global: 'wat',
56+
baz: 'wat',
57+
});
58+
} else {
59+
assertTags(event.tags, {
60+
global: 'wat',
61+
});
62+
}
63+
64+
return event;
65+
},
66+
});
67+
68+
Sentry.configureScope(scope => {
69+
scope.setTag('global', 'wat');
70+
});
71+
72+
app.get('/foo', req => {
73+
Sentry.configureScope(scope => {
74+
scope.setTag('foo', 'wat');
75+
});
76+
77+
throw new Error('foo');
78+
});
79+
80+
app.get('/bar', req => {
81+
Sentry.configureScope(scope => {
82+
scope.setTag('bar', 'wat');
83+
});
84+
85+
throw new Error('bar');
86+
});
87+
88+
app.get('/baz', async req => {
89+
Sentry.configureScope(scope => {
90+
scope.setTag('baz', 'wat');
91+
});
92+
93+
await new Promise(resolve => setTimeout(resolve, 10));
94+
95+
throw new Error('baz');
96+
});
97+
98+
app.listen({ port: 0 }, (err, address) => {
99+
http.get(`${address}/foo`);
100+
http.get(`${address}/bar`);
101+
http.get(`${address}/baz`);
102+
});

0 commit comments

Comments
 (0)