diff --git a/MIGRATION.md b/MIGRATION.md index 5c3984abae46..2e5cf5be0066 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -616,6 +616,12 @@ Affected SDKs: All server-side SDKs. The LangGraph instrumentation no longer emits `gen_ai.create_agent` spans when a graph is compiled. `gen_ai.invoke_agent` and `gen_ai.execute_tool` spans are unaffected. If you reference `create_agent` spans in dashboards or alerts, update them accordingly. +### Fastify: `setupFastifyErrorHandler` is deprecated + +Affected SDKs: All server-side SDKs. + +`fastifyIntegration` is now a single, channel-based plugin that instruments Fastify v3.21 through v5, including error capture. Calling `setupFastifyErrorHandler(app)` is no longer required — errors are captured automatically once the integration is added `setupFastifyErrorHandler` is therefore deprecated and will be removed in the next major. + ### `@sentry/nextjs` **Tracing removed from generated templates:** Tracing was removed from the generated Pages Router API handler, Edge API handler, and Middleware wrapper templates. Route handlers and middleware are still instrumented automatically, so no action is required for most users. @@ -806,6 +812,7 @@ Sentry.init({ - (Remix) The `@sentry/remix/loader` entry point was removed. Use `node --import @sentry/remix/import` instead. - (TanStack Start) The `@sentry/tanstackstart-react/loader` entry point was removed. Use `node --import @sentry/tanstackstart-react/import` instead. - (Express) The deprecated `patchExpressModule(options)` signature was removed. Use `patchExpressModule(moduleExports, getOptions)` instead. +- (Fastify) The deprecated `instrumentFastify` and `handleFastifyError` exports were removed. `fastifyIntegration` now instruments Fastify (v3.21–v5) and captures errors on its own, so neither export is needed. See [Fastify: `setupFastifyErrorHandler` is deprecated](#fastify-setupfastifyerrorhandler-is-deprecated). - The `@sentry/node-core/light/otlp` entry point was removed, along with its optional `@opentelemetry/exporter-trace-otlp-http` peer dependency. `otlpIntegration` is now exported directly from every server-side SDK, so `Sentry.otlpIntegration()` needs no extra import or install. - The `otlpIntegration` options `setupOtlpTracesExporter` and `collectorUrl` were removed, and the integration no longer sets up a span exporter, span processor, or tracer provider. Configure your own exporter and point it at `Sentry.getOtlpTracesEndpoint(dsn)`, or at your collector's URL if you route through one. See [Connecting Sentry to your OpenTelemetry traces](#connecting-sentry-to-your-opentelemetry-traces). diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-3/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-3/package.json index 3fa36adbbbd5..f23e7be2be53 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-3/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-3/package.json @@ -4,13 +4,11 @@ "private": true, "scripts": { "start": "ts-node src/app.ts", - "start:override": "ts-node src/app-handle-error-override.ts", "test": "playwright test", - "test:override": "playwright test --config playwright.override.config.mjs", "clean": "npx rimraf node_modules pnpm-lock.yaml", "typecheck": "tsc", "test:build": "pnpm install && pnpm run typecheck", - "test:assert": "pnpm test && pnpm test:override" + "test:assert": "pnpm test" }, "dependencies": { "@sentry/node": "file:../../packed/sentry-node-packed.tgz", diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-3/src/app-handle-error-override.ts b/dev-packages/e2e-tests/test-applications/node-fastify-3/src/app-handle-error-override.ts deleted file mode 100644 index 7f0890002772..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-fastify-3/src/app-handle-error-override.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type * as S from '@sentry/node'; -const Sentry = require('@sentry/node') as typeof S; - -// We wrap console.warn to find out if a warning is incorrectly logged -console.warn = new Proxy(console.warn, { - apply: function (target, thisArg, argumentsList) { - const msg = argumentsList[0]; - if (typeof msg === 'string' && msg.startsWith('[Sentry]')) { - console.error(`Sentry warning was triggered: ${msg}`); - process.exit(1); - } - - return target.apply(thisArg, argumentsList); - }, -}); - -Sentry.init({ - traceLifecycle: 'static', - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.E2E_TEST_DSN, - integrations: [ - Sentry.fastifyIntegration({ - shouldHandleError: (error, _request, _reply) => { - return true; - }, - }), - ], - tracesSampleRate: 1, - tunnel: 'http://localhost:3031/', // proxy server - tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], -}); - -import type * as H from 'http'; -import type * as F from 'fastify'; - -// Make sure fastify is imported after Sentry is initialized -const { fastify } = require('fastify') as typeof F; -const http = require('http') as typeof H; - -const app = fastify(); -const port = 3030; -const port2 = 3040; - -Sentry.setupFastifyErrorHandler(app, { - shouldHandleError: (error, _request, _reply) => { - // @ts-ignore // Fastify V3 is not typed correctly - if (_request.url?.includes('/test-error-not-captured')) { - // Errors from this path will not be captured by Sentry - return false; - } - - return true; - }, -}); - -app.get('/test-success', function (_req, res) { - res.send({ version: 'v1' }); -}); - -app.get<{ Params: { param: string } }>('/test-param/:param', function (req, res) { - res.send({ paramWas: req.params.param }); -}); - -app.get<{ Params: { id: string } }>('/test-inbound-headers/:id', function (req, res) { - const headers = req.headers; - - res.send({ headers, id: req.params.id }); -}); - -app.get<{ Params: { id: string } }>('/test-outgoing-http/:id', async function (req, res) { - const id = req.params.id; - const data = await makeHttpRequest(`http://localhost:3030/test-inbound-headers/${id}`); - - res.send(data); -}); - -app.get<{ Params: { id: string } }>('/test-outgoing-fetch/:id', async function (req, res) { - const id = req.params.id; - const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); - const data = await response.json(); - - res.send(data); -}); - -app.get('/test-transaction', async function (req, res) { - Sentry.startSpan({ name: 'test-span' }, () => { - Sentry.startSpan({ name: 'child-span' }, () => {}); - }); - - res.send({}); -}); - -app.get('/test-error', async function (req, res) { - const exceptionId = Sentry.captureException(new Error('This is an error')); - - await Sentry.flush(2000); - - res.send({ exceptionId }); -}); - -app.get('/test-error-not-captured', async function () { - // This error will not be captured by Sentry - throw new Error('This is an error that will not be captured'); -}); - -app.get<{ Params: { id: string } }>('/test-exception/:id', async function (req, res) { - throw new Error(`This is an exception with id ${req.params.id}`); -}); - -app.get('/test-outgoing-fetch-external-allowed', async function (req, res) { - const fetchResponse = await fetch(`http://localhost:${port2}/external-allowed`); - const data = await fetchResponse.json(); - - res.send(data); -}); - -app.get('/test-outgoing-fetch-external-disallowed', async function (req, res) { - const fetchResponse = await fetch(`http://localhost:${port2}/external-disallowed`); - const data = await fetchResponse.json(); - - res.send(data); -}); - -app.get('/test-outgoing-http-external-allowed', async function (req, res) { - const data = await makeHttpRequest(`http://localhost:${port2}/external-allowed`); - res.send(data); -}); - -app.get('/test-outgoing-http-external-disallowed', async function (req, res) { - const data = await makeHttpRequest(`http://localhost:${port2}/external-disallowed`); - res.send(data); -}); - -app.post('/test-post', function (req, res) { - res.send({ status: 'ok', body: req.body }); -}); - -app.listen({ port: port }); - -// A second app so we can test header propagation between external URLs -const app2 = fastify(); -app2.get('/external-allowed', function (req, res) { - const headers = req.headers; - - res.send({ headers, route: '/external-allowed' }); -}); - -app2.get('/external-disallowed', function (req, res) { - const headers = req.headers; - - res.send({ headers, route: '/external-disallowed' }); -}); - -app2.listen({ port: port2 }); - -function makeHttpRequest(url: string) { - return new Promise(resolve => { - const data: any[] = []; - - http - .request(url, httpRes => { - httpRes.on('data', chunk => { - data.push(chunk); - }); - httpRes.on('error', error => { - resolve({ error: error.message, url }); - }); - httpRes.on('end', () => { - try { - const json = JSON.parse(Buffer.concat(data).toString()); - resolve(json); - } catch { - resolve({ data: Buffer.concat(data).toString(), url }); - } - }); - }) - .end(); - }); -} diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-3/src/app.ts b/dev-packages/e2e-tests/test-applications/node-fastify-3/src/app.ts index c16ca946da86..2725ed19b3a7 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-3/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-3/src/app.ts @@ -47,8 +47,6 @@ const app = fastify(); const port = 3030; const port2 = 3040; -Sentry.setupFastifyErrorHandler(app); - app.get('/test-success', function (_req, res) { res.send({ version: 'v1' }); }); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json index 086ec85fac7a..5b26e3e12582 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json @@ -4,13 +4,11 @@ "private": true, "scripts": { "start": "ts-node src/app.ts", - "start:override": "ts-node src/app-handle-error-override.ts", "test": "playwright test", - "test:override": "playwright test --config playwright.override.config.mjs", "clean": "npx rimraf node_modules pnpm-lock.yaml", "typecheck": "tsc", "test:build": "pnpm install && pnpm run typecheck", - "test:assert": "pnpm test && pnpm test:override" + "test:assert": "pnpm test" }, "dependencies": { "@sentry/node": "file:../../packed/sentry-node-packed.tgz", diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/src/app-handle-error-override.ts b/dev-packages/e2e-tests/test-applications/node-fastify-4/src/app-handle-error-override.ts deleted file mode 100644 index 5552e765b98b..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/src/app-handle-error-override.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type * as S from '@sentry/node'; -const Sentry = require('@sentry/node') as typeof S; - -// We wrap console.warn to find out if a warning is incorrectly logged -console.warn = new Proxy(console.warn, { - apply: function (target, thisArg, argumentsList) { - const msg = argumentsList[0]; - if (typeof msg === 'string' && msg.startsWith('[Sentry]')) { - console.error(`Sentry warning was triggered: ${msg}`); - process.exit(1); - } - - return target.apply(thisArg, argumentsList); - }, -}); - -Sentry.init({ - traceLifecycle: 'static', - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.E2E_TEST_DSN, - integrations: [ - Sentry.fastifyIntegration({ - shouldHandleError: (error, _request, _reply) => { - return true; - }, - }), - ], - tracesSampleRate: 1, - tunnel: 'http://localhost:3031/', // proxy server - tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], -}); - -import type * as H from 'http'; -import type * as F from 'fastify'; - -// Make sure fastify is imported after Sentry is initialized -const { fastify } = require('fastify') as typeof F; -const http = require('http') as typeof H; - -const app = fastify(); -const port = 3030; -const port2 = 3040; - -Sentry.setupFastifyErrorHandler(app, { - shouldHandleError: (error, _request, _reply) => { - if (_request.routeOptions?.url?.includes('/test-error-not-captured')) { - // Errors from this path will not be captured by Sentry - return false; - } - - return true; - }, -}); - -app.get('/test-success', function (_req, res) { - res.send({ version: 'v1' }); -}); - -app.get<{ Params: { param: string } }>('/test-param/:param', function (req, res) { - res.send({ paramWas: req.params.param }); -}); - -app.get<{ Params: { id: string } }>('/test-inbound-headers/:id', function (req, res) { - const headers = req.headers; - - res.send({ headers, id: req.params.id }); -}); - -app.get<{ Params: { id: string } }>('/test-outgoing-http/:id', async function (req, res) { - const id = req.params.id; - const data = await makeHttpRequest(`http://localhost:3030/test-inbound-headers/${id}`); - - res.send(data); -}); - -app.get<{ Params: { id: string } }>('/test-outgoing-fetch/:id', async function (req, res) { - const id = req.params.id; - const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); - const data = await response.json(); - - res.send(data); -}); - -app.get('/test-transaction', async function (req, res) { - Sentry.startSpan({ name: 'test-span' }, () => { - Sentry.startSpan({ name: 'child-span' }, () => {}); - }); - - res.send({}); -}); - -app.get('/test-error', async function (req, res) { - const exceptionId = Sentry.captureException(new Error('This is an error')); - - await Sentry.flush(2000); - - res.send({ exceptionId }); -}); - -app.get('/test-error-not-captured', async function () { - // This error will not be captured by Sentry - throw new Error('This is an error that will not be captured'); -}); - -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}`); -}); - -app.get('/test-outgoing-fetch-external-allowed', async function (req, res) { - const fetchResponse = await fetch(`http://localhost:${port2}/external-allowed`); - const data = await fetchResponse.json(); - - res.send(data); -}); - -app.get('/test-outgoing-fetch-external-disallowed', async function (req, res) { - const fetchResponse = await fetch(`http://localhost:${port2}/external-disallowed`); - const data = await fetchResponse.json(); - - res.send(data); -}); - -app.get('/test-outgoing-http-external-allowed', async function (req, res) { - const data = await makeHttpRequest(`http://localhost:${port2}/external-allowed`); - res.send(data); -}); - -app.get('/test-outgoing-http-external-disallowed', async function (req, res) { - const data = await makeHttpRequest(`http://localhost:${port2}/external-disallowed`); - res.send(data); -}); - -app.post('/test-post', function (req, res) { - res.send({ status: 'ok', body: req.body }); -}); - -app.listen({ port: port }); - -// A second app so we can test header propagation between external URLs -const app2 = fastify(); -app2.get('/external-allowed', function (req, res) { - const headers = req.headers; - - res.send({ headers, route: '/external-allowed' }); -}); - -app2.get('/external-disallowed', function (req, res) { - const headers = req.headers; - - res.send({ headers, route: '/external-disallowed' }); -}); - -app2.listen({ port: port2 }); - -function makeHttpRequest(url: string) { - return new Promise(resolve => { - const data: any[] = []; - - http - .request(url, httpRes => { - httpRes.on('data', chunk => { - data.push(chunk); - }); - httpRes.on('error', error => { - resolve({ error: error.message, url }); - }); - httpRes.on('end', () => { - try { - const json = JSON.parse(Buffer.concat(data).toString()); - resolve(json); - } catch { - resolve({ data: Buffer.concat(data).toString(), url }); - } - }); - }) - .end(); - }); -} diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/src/app.ts b/dev-packages/e2e-tests/test-applications/node-fastify-4/src/app.ts index b519d42ae647..cbb38b3f0850 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/src/app.ts @@ -20,8 +20,8 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, integrations: [ Sentry.fastifyIntegration({ - shouldHandleError: (error, _request, _reply) => { - if (_request.routeOptions?.url?.includes('/test-error-not-captured')) { + shouldHandleError: (_error, request, _reply) => { + if (request.routeOptions?.url?.includes('/test-error-not-captured')) { // Errors from this path will not be captured by Sentry return false; } @@ -46,8 +46,6 @@ const app = fastify(); const port = 3030; const port2 = 3040; -Sentry.setupFastifyErrorHandler(app); - app.get('/test-success', function (_req, res) { res.send({ version: 'v1' }); }); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts index 662ece5f348a..4f5a6143dbab 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts @@ -69,11 +69,11 @@ test('Sends an API route transaction', async ({ baseURL }) => { data: { 'fastify.type': 'hook', 'hook.callback.name': 'anonymous', - 'hook.name': 'fastify -> @sentry/instrumentation-fastify - onRequest', + 'hook.name': 'fastify -> @sentry/instrumentation-fastify -> sentry-fastify-error-handler - onRequest', 'sentry.op': 'middleware', 'sentry.origin': 'auto.http.otel.fastify', }, - description: '@sentry/instrumentation-fastify - onRequest', + description: 'sentry-fastify-error-handler - onRequest', op: 'middleware', parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json index 2668e22c4d01..fb96c9d178be 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json @@ -4,13 +4,11 @@ "private": true, "scripts": { "start": "ts-node src/app.ts", - "start:override": "ts-node src/app-handle-error-override.ts", "test": "playwright test", - "test:override": "playwright test --config playwright.override.config.mjs", "clean": "npx rimraf node_modules pnpm-lock.yaml", "typecheck": "tsc", "test:build": "pnpm install && pnpm run typecheck", - "test:assert": "pnpm test && pnpm test:override" + "test:assert": "pnpm test" }, "dependencies": { "@sentry/node": "file:../../packed/sentry-node-packed.tgz", diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app-handle-error-override.ts b/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app-handle-error-override.ts deleted file mode 100644 index 30dbbc6cdca0..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app-handle-error-override.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type * as S from '@sentry/node'; -const Sentry = require('@sentry/node') as typeof S; - -// We wrap console.warn to find out if a warning is incorrectly logged -console.warn = new Proxy(console.warn, { - apply: function (target, thisArg, argumentsList) { - const msg = argumentsList[0]; - if (typeof msg === 'string' && msg.startsWith('[Sentry]')) { - console.error(`Sentry warning was triggered: ${msg}`); - process.exit(1); - } - - return target.apply(thisArg, argumentsList); - }, -}); - -Sentry.init({ - traceLifecycle: 'static', - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.E2E_TEST_DSN, - integrations: [ - Sentry.fastifyIntegration({ - shouldHandleError: (error, _request, _reply) => { - return true; - }, - }), - ], - tracesSampleRate: 1, - tunnel: 'http://localhost:3031/', // proxy server - tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], -}); - -import type * as H from 'http'; -import type * as F from 'fastify'; - -// Make sure fastify is imported after Sentry is initialized -const { fastify } = require('fastify') as typeof F; -const http = require('http') as typeof H; - -const app = fastify(); -const port = 3030; -const port2 = 3040; - -Sentry.setupFastifyErrorHandler(app, { - shouldHandleError: (error, _request, _reply) => { - // @ts-ignore // Fastify V5 is not typed correctly - if (_request.routeOptions?.url?.includes('/test-error-not-captured')) { - // Errors from this path will not be captured by Sentry - return false; - } - - // @ts-ignore // Fastify V5 is not typed correctly - if (_request.routeOptions?.url?.includes('/test-error-ignored') && _reply.statusCode === 500) { - return false; - } - - return true; - }, -}); - -app.get('/test-success', function (_req, res) { - res.send({ version: 'v1' }); -}); - -app.get<{ Params: { param: string } }>('/test-param/:param', function (req, res) { - res.send({ paramWas: req.params.param }); -}); - -app.get<{ Params: { id: string } }>('/test-inbound-headers/:id', function (req, res) { - const headers = req.headers; - - res.send({ headers, id: req.params.id }); -}); - -app.get<{ Params: { id: string } }>('/test-outgoing-http/:id', async function (req, res) { - const id = req.params.id; - const data = await makeHttpRequest(`http://localhost:3030/test-inbound-headers/${id}`); - - res.send(data); -}); - -app.get<{ Params: { id: string } }>('/test-outgoing-fetch/:id', async function (req, res) { - const id = req.params.id; - const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); - const data = await response.json(); - - res.send(data); -}); - -app.get('/test-transaction', async function (req, res) { - Sentry.startSpan({ name: 'test-span' }, () => { - Sentry.startSpan({ name: 'child-span' }, () => {}); - }); - - res.send({}); -}); - -app.get('/test-error', async function (req, res) { - const exceptionId = Sentry.captureException(new Error('This is an error')); - - await Sentry.flush(2000); - - res.send({ exceptionId }); -}); - -app.get('/test-error-not-captured', async function () { - // This error will not be captured by Sentry - throw new Error('This is an error that will not be captured'); -}); - -app.get<{ Params: { id: string } }>('/test-exception/:id', async function (req, res) { - throw new Error(`This is an exception with id ${req.params.id}`); -}); - -app.get('/test-outgoing-fetch-external-allowed', async function (req, res) { - const fetchResponse = await fetch(`http://localhost:${port2}/external-allowed`); - const data = await fetchResponse.json(); - - res.send(data); -}); - -app.get('/test-outgoing-fetch-external-disallowed', async function (req, res) { - const fetchResponse = await fetch(`http://localhost:${port2}/external-disallowed`); - const data = await fetchResponse.json(); - - res.send(data); -}); - -app.get('/test-outgoing-http-external-allowed', async function (req, res) { - const data = await makeHttpRequest(`http://localhost:${port2}/external-allowed`); - res.send(data); -}); - -app.get('/test-outgoing-http-external-disallowed', async function (req, res) { - const data = await makeHttpRequest(`http://localhost:${port2}/external-disallowed`); - res.send(data); -}); - -// Regression test for https://github.com/fastify/fastify/issues/6409 -// The error diagnostic channel was always sending 200 unless explicitly changed. -// This was fixed in Fastify 5.7.0 -app.register((childApp: F.FastifyInstance, _options: F.FastifyPluginOptions, next: (err?: Error) => void) => { - childApp.setErrorHandler((error: Error, _request: F.FastifyRequest, reply: F.FastifyReply) => { - reply.send({ ok: false }); - }); - - childApp.get('/test-error-ignored', async function () { - throw new Error('This is an error that will not be captured'); - }); - - next(); -}); - -app.post('/test-post', function (req, res) { - res.send({ status: 'ok', body: req.body }); -}); - -app.get('/flush', async function (_req, res) { - await Sentry.flush(); - res.send({ ok: true }); -}); - -app.listen({ port: port }); - -// A second app so we can test header propagation between external URLs -const app2 = fastify(); -app2.get('/external-allowed', function (req, res) { - const headers = req.headers; - - res.send({ headers, route: '/external-allowed' }); -}); - -app2.get('/external-disallowed', function (req, res) { - const headers = req.headers; - - res.send({ headers, route: '/external-disallowed' }); -}); - -app2.listen({ port: port2 }); - -function makeHttpRequest(url: string) { - return new Promise(resolve => { - const data: any[] = []; - - http - .request(url, httpRes => { - httpRes.on('data', chunk => { - data.push(chunk); - }); - httpRes.on('error', error => { - resolve({ error: error.message, url }); - }); - httpRes.on('end', () => { - try { - const json = JSON.parse(Buffer.concat(data).toString()); - resolve(json); - } catch { - resolve({ data: Buffer.concat(data).toString(), url }); - } - }); - }) - .end(); - }); -} diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app.ts b/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app.ts index e3841d1eefe8..7055d0ed259e 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app.ts @@ -20,14 +20,13 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, integrations: [ Sentry.fastifyIntegration({ - shouldHandleError: (error, _request, _reply) => { - if (_request.routeOptions?.url?.includes('/test-error-not-captured')) { + shouldHandleError: (_error, request, _reply) => { + if (request.routeOptions?.url?.includes('/test-error-not-captured')) { // Errors from this path will not be captured by Sentry return false; } - // @ts-ignore // Fastify V5 is not typed correctly - if (_request.routeOptions?.url?.includes('/test-error-ignored') && _reply.statusCode === 500) { + if (request.routeOptions?.url?.includes('/test-error-ignored') && _reply.statusCode === 500) { return false; } diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts index ec871f134b42..5d0d5fdd0d12 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts @@ -69,11 +69,11 @@ test('Sends an API route transaction', async ({ baseURL }) => { data: { 'fastify.type': 'hook', 'hook.callback.name': 'anonymous', - 'hook.name': 'fastify -> @sentry/instrumentation-fastify - onRequest', + 'hook.name': 'fastify -> @sentry/instrumentation-fastify -> sentry-fastify-error-handler - onRequest', 'sentry.op': 'middleware', 'sentry.origin': 'auto.http.otel.fastify', }, - description: '@sentry/instrumentation-fastify - onRequest', + description: 'sentry-fastify-error-handler - onRequest', op: 'middleware', parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/node-integration-tests/suites/tracing/fastify/instrument-no-tracing.mjs b/dev-packages/node-integration-tests/suites/tracing/fastify/instrument-no-tracing.mjs new file mode 100644 index 000000000000..03ea9ecc571a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/fastify/instrument-no-tracing.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + integrations: [Sentry.fastifyIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/fastify/scenario-error-handler.mjs b/dev-packages/node-integration-tests/suites/tracing/fastify/scenario-error-handler.mjs new file mode 100644 index 000000000000..7044edd2e53e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/fastify/scenario-error-handler.mjs @@ -0,0 +1,34 @@ +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import Fastify from 'fastify'; + +const app = Fastify(); + +let port; + +app.get('/test-exception/:id', async request => { + throw new Error(`This is an exception with id ${request.params.id}`); +}); + +app.get('/test-error-not-captured', async () => { + throw new Error('This is an error that will not be captured'); +}); + +Sentry.setupFastifyErrorHandler(app, { + shouldHandleError: (_error, request, _reply) => { + if (request.routeOptions?.url?.includes('/test-error-not-captured')) { + // Errors from this path will not be captured by Sentry + return false; + } + + return true; + }, +}); + +const run = async () => { + await app.listen({ port: 0, host: 'localhost' }); + port = app.server.address().port; + sendPortToRunner(port); +}; + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/fastify/test.ts b/dev-packages/node-integration-tests/suites/tracing/fastify/test.ts index 445aed9abb9f..d44c8293ad12 100644 --- a/dev-packages/node-integration-tests/suites/tracing/fastify/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/fastify/test.ts @@ -1,101 +1,162 @@ import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; -describe('fastify auto-instrumentation', () => { +describe('fastify v5 auto-instrumentation', () => { afterAll(() => { cleanupChildProcesses(); }); - describe('fastify v5', () => { - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - test('creates transaction with fastify hook, request-handler and manual spans', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'GET /test-transaction', - spans: expect.arrayContaining([ - expect.objectContaining({ - op: 'middleware', - origin: 'auto.http.otel.fastify', - data: expect.objectContaining({ - 'fastify.type': 'hook', - 'sentry.op': 'middleware', - 'sentry.origin': 'auto.http.otel.fastify', - }), + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates transaction with fastify hook, request-handler and manual spans', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /test-transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + op: 'middleware', + origin: 'auto.http.otel.fastify', + data: expect.objectContaining({ + 'fastify.type': 'hook', + 'sentry.op': 'middleware', + 'sentry.origin': 'auto.http.otel.fastify', }), - expect.objectContaining({ - op: 'handler', - origin: 'auto.http.otel.fastify', - data: expect.objectContaining({ - 'sentry.op': 'handler', - 'sentry.origin': 'auto.http.otel.fastify', - }), + }), + expect.objectContaining({ + op: 'handler', + origin: 'auto.http.otel.fastify', + data: expect.objectContaining({ + 'sentry.op': 'handler', + 'sentry.origin': 'auto.http.otel.fastify', }), - expect.objectContaining({ - description: 'test-span', - origin: 'manual', - }), - expect.objectContaining({ - description: 'child-span', - origin: 'manual', - }), - ]), - }, - }) - .start(); - runner.makeRequest('get', '/test-transaction'); - await runner.completed(); - }); + }), + expect.objectContaining({ + description: 'test-span', + origin: 'manual', + }), + expect.objectContaining({ + description: 'child-span', + origin: 'manual', + }), + ]), + }, + }) + .start(); + runner.makeRequest('get', '/test-transaction'); + await runner.completed(); + }); - // Fastify v5 only publishes the `tracing:fastify.request.handler:error` diagnostics channel when - // `tracingChannel(...).hasSubscribers` is truthy, which is what enables automatic error capture - // without `setupFastifyErrorHandler`. - describe('error capture via diagnostics channel', () => { - test('captures errors thrown in route handlers', async () => { - const runner = createRunner() - .ignore('transaction') - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'This is an exception with id 123', - mechanism: { - type: 'auto.function.fastify', - handled: false, - }, - }, - ], + test('captures errors thrown in route handlers', async () => { + const runner = createRunner() + .ignore('transaction') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'This is an exception with id 123', + mechanism: { + type: 'auto.function.fastify', + handled: false, + }, }, - transaction: 'GET /test-exception/:id', - // The error must be parented to the fastify request span (not the root `http.server` span), - // so the trace context carries a `parent_span_id`. - contexts: { - trace: { - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + ], + }, + transaction: 'GET /test-exception/:id', + // The error must be parented to the fastify request span (not the root `http.server` span), + // so the trace context carries a `parent_span_id`. + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + }, + }, + }, + }) + .start(); + runner.makeRequest('get', '/test-exception/123', { expectError: true }); + await runner.completed(); + }); + + test('propagates trace data to outgoing requests within a request handler', async () => { + const runner = createRunner().start(); + const response = await runner.makeRequest<{ headers: Record }>('get', '/test-outgoing-fetch/123'); + + expect(response?.headers?.['sentry-trace']).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-1$/); + expect(response?.headers?.['baggage']).toEqual(expect.any(String)); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-error-handler.mjs', 'instrument.mjs', (createRunner, test) => { + test('shouldHandleError override works', async () => { + const runner = createRunner() + .ignore('transaction') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'This is an exception with id 123', + mechanism: { + type: 'auto.function.fastify', + handled: false, }, }, + ], + }, + transaction: 'GET /test-exception/:id', + // The error must be parented to the fastify request span (not the root `http.server` span), + // so the trace context carries a `parent_span_id`. + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), }, - }) - .start(); - runner.makeRequest('get', '/test-exception/123', { expectError: true }); - await runner.completed(); - }); - }); + }, + }, + }) + .start(); + await runner.makeRequest('get', '/test-error-not-captured', { expectError: true }); + await runner.makeRequest('get', '/test-exception/123', { expectError: true }); - test('propagates trace data to outgoing requests within a request handler', async () => { - const runner = createRunner().start(); - const response = await runner.makeRequest<{ headers: Record }>( - 'get', - '/test-outgoing-fetch/123', - ); + await runner.completed(); + }); + }); - expect(response?.headers?.['sentry-trace']).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-1$/); - expect(response?.headers?.['baggage']).toEqual(expect.any(String)); - }); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-no-tracing.mjs', (createRunner, test) => { + test('captures errors thrown in route handlers without tracing', async () => { + const runner = createRunner() + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'This is an exception with id 456', + mechanism: { + type: 'auto.function.fastify', + handled: false, + }, + }, + ], + }, + transaction: 'GET /test-exception/:id', + // Has no parent_span_id because tracing is disabled + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }, + }, + }, + }) + .start(); + runner.makeRequest('get', '/test-exception/456', { expectError: true }); + await runner.completed(); }); }); }); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index b54adc166a21..c10c8adf810a 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -117,6 +117,7 @@ export { expressErrorHandler, setupExpressErrorHandler, fastifyIntegration, + // oxlint-disable-next-line typescript/no-deprecated setupFastifyErrorHandler, firebaseIntegration, koaIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 8786ab737f17..6b2eb050e0b2 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -96,6 +96,7 @@ export { expressErrorHandler, setupExpressErrorHandler, fastifyIntegration, + // oxlint-disable-next-line typescript/no-deprecated setupFastifyErrorHandler, firebaseIntegration, koaIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 3483dbff8163..629ce5fdeaff 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -2,7 +2,6 @@ export { httpIntegration } from './integrations/http'; export { nativeNodeFetchIntegration } from './integrations/node-fetch'; export { fsIntegration } from './integrations/fs'; export { expressErrorHandler, setupExpressErrorHandler } from './integrations/tracing/express'; -export { fastifyIntegration, setupFastifyErrorHandler } from './integrations/tracing/fastify'; export { amqplibIntegration, anthropicAIIntegration, @@ -41,6 +40,9 @@ export { instrumentLangChainEmbeddings, instrumentStateGraph, instrumentStateGraphCompile, + fastifyIntegration, + // oxlint-disable-next-line typescript/no-deprecated + setupFastifyErrorHandler, } from '@sentry/server-utils'; export { setupHapiErrorHandler } from './integrations/tracing/hapi'; export { setupKoaErrorHandler } from './integrations/tracing/koa'; diff --git a/packages/node/src/integrations/tracing/fastify/index.ts b/packages/node/src/integrations/tracing/fastify/index.ts deleted file mode 100644 index 3b4977416908..000000000000 --- a/packages/node/src/integrations/tracing/fastify/index.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { Integration } from '@sentry/core'; -import { defineIntegration, getClient } from '@sentry/core'; -import type { FastifyInstance, FastifyMinimal, FastifyReply, FastifyRequest } from './types'; -import { - fastifyIntegration as serverUtilsFastifyIntegration, - instrumentFastify, - handleFastifyError, -} from '@sentry/server-utils'; - -interface FastifyIntegration extends Integration { - getShouldHandleError: () => (error: Error, request: FastifyRequest, reply: FastifyReply) => boolean; - // todo(v11): Remove this - setShouldHandleError: ( - shouldHandleError: (error: Error, request: FastifyRequest, reply: FastifyReply) => boolean, - ) => void; -} - -// oxlint-disable-next-line typescript/no-deprecated -export { instrumentFastify }; - -/** - * Options for the Fastify integration. - * - * `shouldHandleError` - Callback method deciding whether error should be captured and sent to Sentry - * This is used on Fastify v5 where Sentry handles errors in the diagnostics channel. - * Fastify v3 and v4 use `setupFastifyErrorHandler` instead. - * - * @example - * - * ```javascript - * Sentry.init({ - * integrations: [ - * Sentry.fastifyIntegration({ - * shouldHandleError(_error, _request, reply) { - * return reply.statusCode >= 500; - * }, - * }); - * }, - * }); - * ``` - * - */ -interface FastifyIntegrationOptions { - /** - * Callback method deciding whether error should be captured and sent to Sentry - * This is used on Fastify v5 where Sentry handles errors in the diagnostics channel. - * Fastify v3 and v4 use `setupFastifyErrorHandler` instead. - * - * @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) - */ - shouldHandleError: (error: Error, request: FastifyRequest, reply: FastifyReply) => boolean; -} - -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: FastifyRequest, reply: FastifyReply) => boolean; -} - -const INTEGRATION_NAME = 'Fastify' as const; - -function getFastifyIntegration(): FastifyIntegration | undefined { - const client = getClient(); - if (!client) { - return undefined; - } else { - return client.getIntegrationByName(INTEGRATION_NAME); - } -} - -/** - * Adds Sentry tracing instrumentation for [Fastify](https://fastify.dev/). - * - * If you also want to capture errors, you need to call `setupFastifyErrorHandler(app)` after you set up your Fastify server. - * - * For more information, see the [fastify documentation](https://docs.sentry.io/platforms/javascript/guides/fastify/). - * - * @example - * ```javascript - * const Sentry = require('@sentry/node'); - * - * Sentry.init({ - * integrations: [Sentry.fastifyIntegration()], - * }) - * ``` - */ -export const fastifyIntegration = defineIntegration((options: Partial = {}) => - serverUtilsFastifyIntegration(options), -); - -/** - * 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 - * const Sentry = require('@sentry/node'); - * const Fastify = require("fastify"); - * - * const app = Fastify(); - * - * Sentry.setupFastifyErrorHandler(app); - * - * // Add your routes, etc. - * - * app.listen({ port: 3000 }); - * ``` - */ -export function setupFastifyErrorHandler(fastify: FastifyMinimal, options?: Partial): void { - if (options?.shouldHandleError) { - getFastifyIntegration()?.setShouldHandleError(options.shouldHandleError); - } - - const plugin = Object.assign( - function (fastify: FastifyInstance, _options: unknown, done: () => void): void { - fastify.addHook('onError', async (request, reply, error) => { - // oxlint-disable-next-line typescript/no-deprecated - handleFastifyError.call(handleFastifyError, error, request, reply, 'onError-hook'); - }); - done(); - }, - { - [Symbol.for('skip-override')]: true, - [Symbol.for('fastify.display-name')]: 'sentry-fastify-error-handler', - }, - ); - - fastify.register(plugin); -} diff --git a/packages/node/src/integrations/tracing/fastify/types.ts b/packages/node/src/integrations/tracing/fastify/types.ts deleted file mode 100644 index 7068afabadb0..000000000000 --- a/packages/node/src/integrations/tracing/fastify/types.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -export type HandlerOriginal = - | ((request: FastifyRequest, reply: FastifyReply, done: HookHandlerDoneFunction) => Promise) - | ((request: FastifyRequest, reply: FastifyReply, done: HookHandlerDoneFunction) => void); - -export type FastifyError = any; - -export type HookHandlerDoneFunction = (err?: TError) => void; - -export type FastifyErrorCodes = any; - -export type FastifyPlugin = ( - instance: FastifyInstance, - opts: any, - done: HookHandlerDoneFunction, -) => unknown | Promise; - -export interface FastifyInstance { - version: string; - register: (plugin: any) => FastifyInstance; - after: (listener?: (err: Error) => void) => FastifyInstance; - addHook(hook: string, handler: HandlerOriginal): FastifyInstance; - addHook( - hook: 'onError', - handler: (request: FastifyRequest, reply: FastifyReply, error: Error) => void, - ): FastifyInstance; - addHook(hook: 'onRequest', handler: (request: FastifyRequest, reply: FastifyReply) => void): FastifyInstance; -} - -/** - * Minimal type for `setupFastifyErrorHandler` parameter. - * Uses structural typing without overloads to avoid exactOptionalPropertyTypes issues. - * https://github.com/getsentry/sentry-javascript/issues/18619 - */ -export type FastifyMinimal = { - register: (plugin: (instance: any, opts: any, done: () => void) => void) => unknown; -}; - -export interface FastifyReply { - send: () => FastifyReply; - statusCode: number; -} - -export interface FastifyRequest { - method?: string; - // since fastify@4.10.0 - routeOptions?: { - url?: string; - }; - routerPath?: string; -} diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index 2d7274326a40..ce17411905b1 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { prismaIntegration } from '@sentry/server-utils'; +import { prismaIntegration, fastifyIntegration } from '@sentry/server-utils'; import { amqplibIntegration, anthropicAIIntegration, @@ -25,7 +25,6 @@ import { tediousIntegration, vercelAIIntegration, } from '@sentry/server-utils/orchestrion'; -import { fastifyIntegration } from './fastify'; export function getAutoPerformanceIntegrations(): Integration[] { return [ diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 237693d45c14..93288611f8dc 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -10,7 +10,5 @@ export { vercelAIIntegration, type VercelAiOptions } from './vercel-ai'; export { fastifyIntegration, // oxlint-disable-next-line typescript/no-deprecated - handleFastifyError, - // oxlint-disable-next-line typescript/no-deprecated - instrumentFastify, + setupFastifyErrorHandler, } from './integrations/fastify'; diff --git a/packages/server-utils/src/integrations/fastify/errors.ts b/packages/server-utils/src/integrations/fastify/errors.ts index 490e960fa213..c9fd1c48ce55 100644 --- a/packages/server-utils/src/integrations/fastify/errors.ts +++ b/packages/server-utils/src/integrations/fastify/errors.ts @@ -1,12 +1,11 @@ import type { FastifyIntegration, FastifyReply, FastifyRequest } from './types'; import * as diagnosticsChannel from 'node:diagnostics_channel'; -import { DEBUG_BUILD } from '../../debug-build'; -import { getClient, debug, captureException } from '@sentry/core'; +import { captureException, getClient } from '@sentry/core'; import { defaultShouldHandleError, INTEGRATION_NAME } from './utils'; function getFastifyIntegration(): FastifyIntegration | undefined { const client = getClient(); - return client?.getIntegrationByName(INTEGRATION_NAME); + return client?.getIntegrationByName(INTEGRATION_NAME) as FastifyIntegration | undefined; } /** @@ -20,38 +19,22 @@ export function subscribeToFastifyErrorChannel(): void { reply: FastifyReply; }; - handleFastifyError.call(handleFastifyError, error, request, reply, 'diagnostics-channel'); + handleFastifyError(error, request, reply); }); } /** * Handle a Fastify error, and possibly send it to Sentry. + * + * On Fastify v5 a route handler error surfaces on both the diagnostics channel + * and the `onError` hook, so this runs twice for the same error. That's fine: + * `captureException` deduplicates by object identity (`__sentry_captured__`), so + * only the first call sends an event. Errors that reach only one path (e.g. + * thrown in an `onRequest` hook, or on Fastify v3/v4 which has no channel) are + * captured once. */ -export function handleFastifyError( - this: { - diagnosticsChannelExists?: boolean; - }, - error: Error, - request: FastifyRequest, - reply: FastifyReply, - handlerOrigin: 'diagnostics-channel' | 'onError-hook', -): void { +export function handleFastifyError(error: Error, request: FastifyRequest, reply: FastifyReply): void { const shouldHandleError = getFastifyIntegration()?.getShouldHandleError() || defaultShouldHandleError; - // Diagnostics channel runs before the onError hook, so we can use it to check if the handler was already registered - if (handlerOrigin === 'diagnostics-channel') { - this.diagnosticsChannelExists = true; - } - - if (this.diagnosticsChannelExists && handlerOrigin === 'onError-hook') { - DEBUG_BUILD && - debug.warn( - 'Fastify error handler was already registered via diagnostics channel.', - 'You can safely remove `setupFastifyErrorHandler` call and set `shouldHandleError` on the integration options.', - ); - - // If the diagnostics channel already exists, we don't need to handle the error again - return; - } if (shouldHandleError(error, request, reply)) { captureException(error, { diff --git a/packages/server-utils/src/integrations/fastify/index.ts b/packages/server-utils/src/integrations/fastify/index.ts index c690c4ce7407..ee4c9b997bb7 100644 --- a/packages/server-utils/src/integrations/fastify/index.ts +++ b/packages/server-utils/src/integrations/fastify/index.ts @@ -1,9 +1,9 @@ import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; +import { defineIntegration, getClient } from '@sentry/core'; import type { FastifyIntegration, FastifyReply, FastifyRequest } from './types'; -import { instrumentFastify as _instrumentFastify } from './instrumentation'; +import { instrumentFastify } from './instrumentation'; import { defaultShouldHandleError, INTEGRATION_NAME } from './utils'; -import { subscribeToFastifyErrorChannel, handleFastifyError as _handleFastifyError } from './errors'; +import { subscribeToFastifyErrorChannel } from './errors'; /** * Options for the Fastify integration. @@ -30,9 +30,6 @@ import { subscribeToFastifyErrorChannel, handleFastifyError as _handleFastifyErr interface FastifyIntegrationOptions { /** * Callback method deciding whether error should be captured and sent to Sentry - * This is used on Fastify v5 where Sentry handles errors in the diagnostics channel. - * Fastify v3 and v4 use `setupFastifyErrorHandler` instead. - * * @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) @@ -49,7 +46,7 @@ const _fastifyIntegration = (({ shouldHandleError }: Partial boolean) { _shouldHandleError = shouldHandleError; }, - } satisfies FastifyIntegration; + }; }) satisfies IntegrationFn; /** * Adds Sentry tracing instrumentation for [Fastify](https://fastify.dev/). - * This integration supports Fastify v5 only. + * This integration supports Fastify v3.21.0-v5.0.0. * * For more information, see the [fastify documentation](https://docs.sentry.io/platforms/javascript/guides/fastify/). * @@ -78,11 +75,14 @@ const _fastifyIntegration = (({ shouldHandleError }: Partial): void { + if (options?.shouldHandleError) { + const integration = getClient()?.getIntegrationByName(INTEGRATION_NAME) as FastifyIntegration | undefined; + integration?.setShouldHandleError(options.shouldHandleError); + } +} diff --git a/packages/server-utils/src/integrations/fastify/instrumentation.ts b/packages/server-utils/src/integrations/fastify/instrumentation.ts index d9a400a23f25..3c8580215a8b 100644 --- a/packages/server-utils/src/integrations/fastify/instrumentation.ts +++ b/packages/server-utils/src/integrations/fastify/instrumentation.ts @@ -26,7 +26,6 @@ import { WEB_SERVER_MIDDLEWARE_SPAN_OP } from '@sentry/conventions/op'; import type { Span } from '@sentry/core'; import { isObjectLike, - debug, getIsolationScope, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, @@ -35,8 +34,8 @@ import { withActiveSpan, } from '@sentry/core'; import type { FastifyInstance, FastifyRequest } from './types'; -import { DEBUG_BUILD } from '../../debug-build'; import { setHttpServerSpanRouteAttribute } from '../../utils/setHttpServerSpanRouteAttribute'; +import { handleFastifyError } from './errors'; const PACKAGE_NAME = '@sentry/instrumentation-fastify'; const SUPPORTED_VERSIONS = '>=3.21.0 <6'; @@ -101,7 +100,7 @@ function isFastifyRequest(arg: any): boolean { * The Fastify plugin that wires up the request/hook/handler spans. It is registered on every Fastify * instance via the `fastify.initialization` diagnostics channel. */ -function fastifyOtelPlugin(this: unknown, instance: any, _opts: unknown, done: () => void): void { +function fastifyTracingPlugin(this: unknown, instance: any, _opts: unknown, done: () => void): void { instance.decorate(kAddHookOriginal, instance.addHook); instance.decorate(kSetNotFoundOriginal, instance.setNotFoundHandler); instance.decorateRequest('opentelemetry', function opentelemetry(this: any) { @@ -109,7 +108,7 @@ function fastifyOtelPlugin(this: unknown, instance: any, _opts: unknown, done: ( }); instance.decorateRequest(kRequestSpan, null); - instance.addHook('onRoute', otelWireRoute); + instance.addHook('onRoute', onRoute); instance.addHook('onRequest', startRequestSpanHook); instance.addHook('onResponse', finalizeNotFoundSpanHook); @@ -119,7 +118,7 @@ function fastifyOtelPlugin(this: unknown, instance: any, _opts: unknown, done: ( done(); } -const pluginSymbols = fastifyOtelPlugin as unknown as Record; +const pluginSymbols = fastifyTracingPlugin as unknown as Record; pluginSymbols[Symbol.for('skip-override')] = true; pluginSymbols[Symbol.for('fastify.display-name')] = PACKAGE_NAME; pluginSymbols[Symbol.for('plugin-meta')] = { @@ -127,7 +126,7 @@ pluginSymbols[Symbol.for('plugin-meta')] = { name: PACKAGE_NAME, }; -function otelWireRoute(this: any, routeOptions: any): void { +function onRoute(this: any, routeOptions: any): void { if (routeOptions.config?.otel === false) { return; } @@ -375,22 +374,11 @@ function stripFastifyPrefix(hookName = ''): string { .replace(/^@sentry\/instrumentation-fastify -> /, ''); } -function instrumentOnRequest(fastify: FastifyInstance): void { - fastify.addHook('onRequest', async (request: FastifyRequest, _reply) => { - const routeName = getRequestRouteUrl(request); - const method = request.method || 'GET'; - - getIsolationScope().setTransactionName(`${method} ${routeName}`); - }); -} - let _isInstrumented = false; /** * Set up the Fastify (>= 3.21.0 < 6) instrumentation by subscribing to the `fastify.initialization` - * diagnostics channel and registering the span-creating plugin on every Fastify instance. - * - * Idempotent and exposes an `id` so it can participate in the OpenTelemetry preload list. + * diagnostics channel and registering the span-creating & error handler plugin on every Fastify instance. */ export const instrumentFastify = Object.assign( function instrumentFastify(): void { @@ -402,14 +390,30 @@ export const instrumentFastify = Object.assign( diagnosticsChannel.subscribe('fastify.initialization', message => { const fastifyInstance = (message as { fastify?: FastifyInstance }).fastify; - fastifyInstance?.register(fastifyOtelPlugin).after(err => { - if (err) { - DEBUG_BUILD && debug.error('Failed to setup Fastify instrumentation', err); - } else if (fastifyInstance) { - instrumentOnRequest(fastifyInstance); - } - }); + fastifyInstance?.register(fastifyTracingPlugin); + fastifyInstance?.register(fastifyErrorHandlerPlugin); }); }, { id: 'Fastify.v5' }, ); + +const fastifyErrorHandlerPlugin = Object.assign( + function (fastify: FastifyInstance, _options: unknown, done: () => void): void { + fastify.addHook('onRequest', async (request: FastifyRequest, _reply) => { + const routeName = getRequestRouteUrl(request); + const method = request.method || 'GET'; + + getIsolationScope().setTransactionName(`${method} ${routeName}`); + }); + + fastify.addHook('onError', async (request, reply, error) => { + handleFastifyError(error, request, reply); + }); + + done(); + }, + { + [Symbol.for('skip-override')]: true, + [Symbol.for('fastify.display-name')]: 'sentry-fastify-error-handler', + }, +);