Skip to content

Commit 50d2514

Browse files
authored
feat(node): Add fastify shouldHandleError (#15771)
Supercedes #13198 resolves #13197 Aligns fastify error handler with the express one. 1. Adds `shouldHandleError` to allow users to configure if errors should be captured 2. Makes sure the default `shouldHandleError` does not capture errors for 4xx and 3xx status codes. ## Usage ```js setupFastifyErrorHandler(app, { shouldHandleError(_error, _request, reply) { return statusCode >= 500 || statusCode <= 399; }, }); ```
1 parent ba5993c commit 50d2514

File tree

3 files changed

+118
-17
lines changed

3 files changed

+118
-17
lines changed

dev-packages/e2e-tests/test-applications/node-fastify/src/app.ts

+10
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ app.get('/test-error', async function (req, res) {
8181
res.send({ exceptionId });
8282
});
8383

84+
app.get('/test-4xx-error', async function (req, res) {
85+
res.code(400);
86+
throw new Error('This is a 4xx error');
87+
});
88+
89+
app.get('/test-5xx-error', async function (req, res) {
90+
res.code(500);
91+
throw new Error('This is a 5xx error');
92+
});
93+
8494
app.get<{ Params: { id: string } }>('/test-exception/:id', async function (req, res) {
8595
throw new Error(`This is an exception with id ${req.params.id}`);
8696
});

dev-packages/e2e-tests/test-applications/node-fastify/tests/errors.test.ts

+22
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,25 @@ test('Sends correct error event', async ({ baseURL }) => {
2828
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
2929
});
3030
});
31+
32+
test('Does not send 4xx errors by default', async ({ baseURL }) => {
33+
// Define our test approach: we'll send both a 5xx and a 4xx request
34+
// We should only see the 5xx error captured due to shouldHandleError's default behavior
35+
36+
// Create a promise to wait for the 500 error
37+
const serverErrorPromise = waitForError('node-fastify', event => {
38+
// Looking for a 500 error that should be captured
39+
return !!event.exception?.values?.[0]?.value?.includes('This is a 5xx error');
40+
});
41+
42+
// Make a request that will trigger a 400 error
43+
const notFoundResponse = await fetch(`${baseURL}/test-4xx-error`);
44+
expect(notFoundResponse.status).toBe(400);
45+
46+
// Make a request that will trigger a 500 error
47+
await fetch(`${baseURL}/test-5xx-error`);
48+
49+
// Verify we receive the 500 error
50+
const errorEvent = await serverErrorPromise;
51+
expect(errorEvent.exception?.values?.[0]?.value).toContain('This is a 5xx error');
52+
});

packages/node/src/integrations/tracing/fastify.ts

+86-17
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,14 @@ import type { IntegrationFn, Span } from '@sentry/core';
1212
import { generateInstrumentOnce } from '../../otel/instrument';
1313
import { ensureIsWrapped } from '../../utils/ensureIsWrapped';
1414

15-
// We inline the types we care about here
16-
interface Fastify {
17-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
18-
register: (plugin: any) => void;
19-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
20-
addHook: (hook: string, handler: (request: any, reply: any, error: Error) => void) => void;
21-
}
22-
2315
/**
2416
* Minimal request type containing properties around route information.
2517
* Works for Fastify 3, 4 and presumably 5.
18+
*
19+
* Based on https://github.com/fastify/fastify/blob/ce3811f5f718be278bbcd4392c615d64230065a6/types/request.d.ts
2620
*/
27-
interface FastifyRequestRouteInfo {
21+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22+
interface MinimalFastifyRequest extends Record<string, any> {
2823
method?: string;
2924
3025
routeOptions?: {
@@ -33,6 +28,66 @@ interface FastifyRequestRouteInfo {
3328
routerPath?: string;
3429
}
3530

31+
/**
32+
* Minimal reply type containing properties needed for error handling.
33+
*
34+
* Based on https://github.com/fastify/fastify/blob/ce3811f5f718be278bbcd4392c615d64230065a6/types/reply.d.ts
35+
*/
36+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
37+
interface MinimalFastifyReply extends Record<string, any> {
38+
statusCode: number;
39+
}
40+
41+
// We inline the types we care about here
42+
interface Fastify {
43+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
44+
register: (plugin: any) => void;
45+
addHook: (hook: string, handler: (...params: unknown[]) => void) => void;
46+
}
47+
48+
interface FastifyWithHooks extends Omit<Fastify, 'addHook'> {
49+
addHook(
50+
hook: 'onError',
51+
handler: (request: MinimalFastifyRequest, reply: MinimalFastifyReply, error: Error) => void,
52+
): void;
53+
addHook(hook: 'onRequest', handler: (request: MinimalFastifyRequest, reply: MinimalFastifyReply) => void): void;
54+
}
55+
56+
interface FastifyHandlerOptions {
57+
/**
58+
* Callback method deciding whether error should be captured and sent to Sentry
59+
*
60+
* @param error Captured Fastify error
61+
* @param request Fastify request (or any object containing at least method, routeOptions.url, and routerPath)
62+
* @param reply Fastify reply (or any object containing at least statusCode)
63+
*
64+
* @example
65+
*
66+
* ```javascript
67+
* setupFastifyErrorHandler(app, {
68+
* shouldHandleError(_error, _request, reply) {
69+
* return reply.statusCode >= 400;
70+
* },
71+
* });
72+
* ```
73+
*
74+
* If using TypeScript, you can cast the request and reply to get full type safety.
75+
*
76+
* ```typescript
77+
* import type { FastifyRequest, FastifyReply } from 'fastify';
78+
*
79+
* setupFastifyErrorHandler(app, {
80+
* shouldHandleError(error, minimalRequest, minimalReply) {
81+
* const request = minimalRequest as FastifyRequest;
82+
* const reply = minimalReply as FastifyReply;
83+
* return reply.statusCode >= 500;
84+
* },
85+
* });
86+
* ```
87+
*/
88+
shouldHandleError: (error: Error, request: MinimalFastifyRequest, reply: MinimalFastifyReply) => boolean;
89+
}
90+
3691
const INTEGRATION_NAME = 'Fastify';
3792

3893
export const instrumentFastify = generateInstrumentOnce(
@@ -73,10 +128,22 @@ const _fastifyIntegration = (() => {
73128
*/
74129
export const fastifyIntegration = defineIntegration(_fastifyIntegration);
75130

131+
/**
132+
* Default function to determine if an error should be sent to Sentry
133+
*
134+
* 3xx and 4xx errors are not sent by default.
135+
*/
136+
function defaultShouldHandleError(_error: Error, _request: MinimalFastifyRequest, reply: MinimalFastifyReply): boolean {
137+
const statusCode = reply.statusCode;
138+
// 3xx and 4xx errors are not sent by default.
139+
return statusCode >= 500 || statusCode <= 299;
140+
}
141+
76142
/**
77143
* Add an Fastify error handler to capture errors to Sentry.
78144
*
79145
* @param fastify The Fastify instance to which to add the error handler
146+
* @param options Configuration options for the handler
80147
*
81148
* @example
82149
* ```javascript
@@ -92,23 +159,25 @@ export const fastifyIntegration = defineIntegration(_fastifyIntegration);
92159
* app.listen({ port: 3000 });
93160
* ```
94161
*/
95-
export function setupFastifyErrorHandler(fastify: Fastify): void {
162+
export function setupFastifyErrorHandler(fastify: Fastify, options?: Partial<FastifyHandlerOptions>): void {
163+
const shouldHandleError = options?.shouldHandleError || defaultShouldHandleError;
164+
96165
const plugin = Object.assign(
97-
function (fastify: Fastify, _options: unknown, done: () => void): void {
98-
fastify.addHook('onError', async (_request, _reply, error) => {
99-
captureException(error);
166+
function (fastify: FastifyWithHooks, _options: unknown, done: () => void): void {
167+
fastify.addHook('onError', async (request, reply, error) => {
168+
if (shouldHandleError(error, request, reply)) {
169+
captureException(error);
170+
}
100171
});
101172

102173
// registering `onRequest` hook here instead of using Otel `onRequest` callback b/c `onRequest` hook
103174
// is ironically called in the fastify `preHandler` hook which is called later in the lifecycle:
104175
// https://fastify.dev/docs/latest/Reference/Lifecycle/
105176
fastify.addHook('onRequest', async (request, _reply) => {
106-
const reqWithRouteInfo = request as FastifyRequestRouteInfo;
107-
108177
// Taken from Otel Fastify instrumentation:
109178
// https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/plugins/node/opentelemetry-instrumentation-fastify/src/instrumentation.ts#L94-L96
110-
const routeName = reqWithRouteInfo.routeOptions?.url || reqWithRouteInfo.routerPath;
111-
const method = reqWithRouteInfo.method || 'GET';
179+
const routeName = request.routeOptions?.url || request.routerPath;
180+
const method = request.method || 'GET';
112181

113182
getIsolationScope().setTransactionName(`${method} ${routeName}`);
114183
});

0 commit comments

Comments
 (0)