Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(node): Add fastify shouldHandleError #15771

Merged
merged 3 commits into from
Mar 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-fastify/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ app.get('/test-error', async function (req, res) {
res.send({ exceptionId });
});

app.get('/test-4xx-error', async function (req, res) {
res.code(400);
throw new Error('This is a 4xx error');
});

app.get('/test-5xx-error', async function (req, res) {
res.code(500);
throw new Error('This is a 5xx error');
});

app.get<{ Params: { id: string } }>('/test-exception/:id', async function (req, res) {
throw new Error(`This is an exception with id ${req.params.id}`);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,25 @@ test('Sends correct error event', async ({ baseURL }) => {
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
});
});

test('Does not send 4xx errors by default', async ({ baseURL }) => {
// Define our test approach: we'll send both a 5xx and a 4xx request
// We should only see the 5xx error captured due to shouldHandleError's default behavior

// Create a promise to wait for the 500 error
const serverErrorPromise = waitForError('node-fastify', event => {
// Looking for a 500 error that should be captured
return !!event.exception?.values?.[0]?.value?.includes('This is a 5xx error');
});

// Make a request that will trigger a 400 error
const notFoundResponse = await fetch(`${baseURL}/test-4xx-error`);
expect(notFoundResponse.status).toBe(400);

// Make a request that will trigger a 500 error
await fetch(`${baseURL}/test-5xx-error`);

// Verify we receive the 500 error
const errorEvent = await serverErrorPromise;
expect(errorEvent.exception?.values?.[0]?.value).toContain('This is a 5xx error');
});
103 changes: 86 additions & 17 deletions packages/node/src/integrations/tracing/fastify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,14 @@ import type { IntegrationFn, Span } from '@sentry/core';
import { generateInstrumentOnce } from '../../otel/instrument';
import { ensureIsWrapped } from '../../utils/ensureIsWrapped';

// We inline the types we care about here
interface Fastify {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
register: (plugin: any) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
addHook: (hook: string, handler: (request: any, reply: any, error: Error) => void) => void;
}

/**
* Minimal request type containing properties around route information.
* Works for Fastify 3, 4 and presumably 5.
*
* Based on https://github.com/fastify/fastify/blob/ce3811f5f718be278bbcd4392c615d64230065a6/types/request.d.ts
*/
interface FastifyRequestRouteInfo {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface MinimalFastifyRequest extends Record<string, any> {
method?: string;
// since [email protected]
routeOptions?: {
Expand All @@ -33,6 +28,66 @@ interface FastifyRequestRouteInfo {
routerPath?: string;
}

/**
* Minimal reply type containing properties needed for error handling.
*
* Based on https://github.com/fastify/fastify/blob/ce3811f5f718be278bbcd4392c615d64230065a6/types/reply.d.ts
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface MinimalFastifyReply extends Record<string, any> {
statusCode: number;
}

// We inline the types we care about here
interface Fastify {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
register: (plugin: any) => void;
addHook: (hook: string, handler: (...params: unknown[]) => void) => void;
}

interface FastifyWithHooks extends Omit<Fastify, 'addHook'> {
addHook(
hook: 'onError',
handler: (request: MinimalFastifyRequest, reply: MinimalFastifyReply, error: Error) => void,
): void;
addHook(hook: 'onRequest', handler: (request: MinimalFastifyRequest, reply: MinimalFastifyReply) => void): void;
}

interface FastifyHandlerOptions {
/**
* Callback method deciding whether error should be captured and sent to Sentry
*
* @param error Captured Fastify error
* @param request Fastify request (or any object containing at least method, routeOptions.url, and routerPath)
* @param reply Fastify reply (or any object containing at least statusCode)
*
* @example
*
* ```javascript
* setupFastifyErrorHandler(app, {
* shouldHandleError(_error, _request, reply) {
* return reply.statusCode >= 400;
* },
* });
* ```
*
* If using TypeScript, you can cast the request and reply to get full type safety.
*
* ```typescript
* import type { FastifyRequest, FastifyReply } from 'fastify';
*
* setupFastifyErrorHandler(app, {
* shouldHandleError(error, minimalRequest, minimalReply) {
* const request = minimalRequest as FastifyRequest;
* const reply = minimalReply as FastifyReply;
* return reply.statusCode >= 500;
* },
* });
* ```
*/
shouldHandleError: (error: Error, request: MinimalFastifyRequest, reply: MinimalFastifyReply) => boolean;
}

const INTEGRATION_NAME = 'Fastify';

export const instrumentFastify = generateInstrumentOnce(
Expand Down Expand Up @@ -73,10 +128,22 @@ const _fastifyIntegration = (() => {
*/
export const fastifyIntegration = defineIntegration(_fastifyIntegration);

/**
* Default function to determine if an error should be sent to Sentry
*
* 3xx and 4xx errors are not sent by default.
*/
function defaultShouldHandleError(_error: Error, _request: MinimalFastifyRequest, reply: MinimalFastifyReply): boolean {
const statusCode = reply.statusCode;
// 3xx and 4xx errors are not sent by default.
return statusCode >= 500 || statusCode <= 299;
}

/**
* Add an Fastify error handler to capture errors to Sentry.
*
* @param fastify The Fastify instance to which to add the error handler
* @param options Configuration options for the handler
*
* @example
* ```javascript
Expand All @@ -92,23 +159,25 @@ export const fastifyIntegration = defineIntegration(_fastifyIntegration);
* app.listen({ port: 3000 });
* ```
*/
export function setupFastifyErrorHandler(fastify: Fastify): void {
export function setupFastifyErrorHandler(fastify: Fastify, options?: Partial<FastifyHandlerOptions>): void {
const shouldHandleError = options?.shouldHandleError || defaultShouldHandleError;

const plugin = Object.assign(
function (fastify: Fastify, _options: unknown, done: () => void): void {
fastify.addHook('onError', async (_request, _reply, error) => {
captureException(error);
function (fastify: FastifyWithHooks, _options: unknown, done: () => void): void {
fastify.addHook('onError', async (request, reply, error) => {
if (shouldHandleError(error, request, reply)) {
captureException(error);
}
});

// registering `onRequest` hook here instead of using Otel `onRequest` callback b/c `onRequest` hook
// is ironically called in the fastify `preHandler` hook which is called later in the lifecycle:
// https://fastify.dev/docs/latest/Reference/Lifecycle/
fastify.addHook('onRequest', async (request, _reply) => {
const reqWithRouteInfo = request as FastifyRequestRouteInfo;

// Taken from Otel Fastify instrumentation:
// https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/plugins/node/opentelemetry-instrumentation-fastify/src/instrumentation.ts#L94-L96
const routeName = reqWithRouteInfo.routeOptions?.url || reqWithRouteInfo.routerPath;
const method = reqWithRouteInfo.method || 'GET';
const routeName = request.routeOptions?.url || request.routerPath;
const method = request.method || 'GET';

getIsolationScope().setTransactionName(`${method} ${routeName}`);
});
Expand Down
Loading