diff --git a/MIGRATION.md b/MIGRATION.md index f2af8d53b6f5..74f23a91bb01 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -287,6 +287,12 @@ Affected SDKs: `@sentry/node` and all dependents. The new channel-based instrumentations (using `orchestrion` instead of `import-in-the-middle`) are now the default. They were available opt-in in v10. This unlocks instrumenting at run and build time, which enables instrumentation at deployment targets like Vercel and Netlify, as well as using instrumentations on non-Node runtimes like Cloudflare, Bun and Deno. For most users this requires no changes. +### `setupKoaErrorHandler` is deprecated (Koa errors are captured automatically) + +Affected SDKs: `@sentry/node` and all dependents that re-export it (e.g. `@sentry/aws-serverless`, `@sentry/google-cloud-serverless`, `@sentry/astro`, `@sentry/remix`, `@sentry/solidstart`, `@sentry/sveltekit`, `@sentry/bun`, `@sentry/elysia`). + +The Koa error handler is now registered automatically when your app starts, so you no longer need to call `setupKoaErrorHandler`. The function is deprecated and will be removed in a future major version; you should no longer call it. + ### Initializing via `--require` is no longer supported Affected SDKs: `@sentry/node` and all dependents. diff --git a/dev-packages/e2e-tests/test-applications/node-koa/index.js b/dev-packages/e2e-tests/test-applications/node-koa/index.js index ab5516192de5..87f14af82681 100644 --- a/dev-packages/e2e-tests/test-applications/node-koa/index.js +++ b/dev-packages/e2e-tests/test-applications/node-koa/index.js @@ -22,8 +22,6 @@ const http = require('http'); const app1 = new Koa(); app1.use(bodyParser()); -Sentry.setupKoaErrorHandler(app1); - const router1 = new Router(); router1.get('/test-success', ctx => { diff --git a/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts index 57523b480fb6..60b406eab79c 100644 --- a/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts @@ -66,7 +66,9 @@ test('Sends an API route transaction', async ({ baseURL }) => { }, }); - expect(transactionEvent.spans).toEqual([ + const spans = transactionEvent.spans || []; + + expect(spans).toEqual([ { data: { 'koa.name': 'bodyParser', @@ -85,24 +87,6 @@ test('Sends an API route transaction', async ({ baseURL }) => { timestamp: expect.any(Number), trace_id: expect.stringMatching(/[a-f0-9]{32}/), }, - { - data: { - 'koa.name': 'middleware', - 'code.function.name': 'middleware', - 'koa.type': 'middleware', - 'sentry.origin': 'auto.http.koa', - 'sentry.op': 'middleware', - }, - op: 'middleware', - origin: 'auto.http.koa', - description: 'middleware', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, { data: { 'http.route': '/test-transaction', diff --git a/dev-packages/node-integration-tests/suites/tracing/koa/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/koa/scenario.mjs index 945f90feb39a..b395137c4c46 100644 --- a/dev-packages/node-integration-tests/suites/tracing/koa/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/koa/scenario.mjs @@ -1,5 +1,4 @@ import Router from '@koa/router'; -import * as Sentry from '@sentry/node'; import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; import Koa from 'koa'; @@ -7,8 +6,8 @@ const port = 5698; const app = new Koa(); -// Registered first so it wraps every downstream middleware/route in its try/catch. -Sentry.setupKoaErrorHandler(app); +// The error handler is auto-registered by the koa instrumentation on app start, +// so `setupKoaErrorHandler` is intentionally not called here. // Plain middleware -> produces a `middleware` span named after the function. app.use(async function simpleMiddleware(ctx, next) { diff --git a/dev-packages/node-integration-tests/suites/tracing/koa/test.ts b/dev-packages/node-integration-tests/suites/tracing/koa/test.ts index d7f2af770358..05f826554ab2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/koa/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/koa/test.ts @@ -9,6 +9,16 @@ describe('koa auto-instrumentation', () => { const origin = 'auto.http.koa'; const EXPECTED_ERROR_EVENT = { + // The error is captured within the request's koa span, so it keeps its trace + // linkage (a `parent_span_id`) even though koa emits `error` after the + // middleware chain has unwound. + 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}/), + }, + }, exception: { values: [ { diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 16a6986007a8..0053d66501e9 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -126,6 +126,7 @@ export { setAttributes, setupExpressErrorHandler, setupHapiErrorHandler, + // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, setUser, spanToBaggageHeader, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index e89f4228a0eb..4e17556429b3 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -96,6 +96,7 @@ export { expressErrorHandler, setupExpressErrorHandler, koaIntegration, + // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, fastifyIntegration, firebaseIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index d7cca9dd6a98..54eb736a643f 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -120,6 +120,7 @@ export { setupFastifyErrorHandler, firebaseIntegration, koaIntegration, + // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, genericPoolIntegration, graphqlIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 3ff0aecd8af1..30dc2366b639 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -99,6 +99,7 @@ export { setupFastifyErrorHandler, firebaseIntegration, koaIntegration, + // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, genericPoolIntegration, graphqlIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 30a4a472297f..7e881f230cc0 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -97,6 +97,7 @@ export { expressErrorHandler, setupExpressErrorHandler, koaIntegration, + // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, fastifyIntegration, firebaseIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 2d6fdfc007a1..2cce01ee3335 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -43,6 +43,7 @@ export { instrumentStateGraphCompile, } from '@sentry/server-utils'; export { setupHapiErrorHandler } from './integrations/tracing/hapi'; +// oxlint-disable-next-line typescript/no-deprecated -- deprecated but still re-exported for backwards compatibility export { setupKoaErrorHandler } from './integrations/tracing/koa'; export { launchDarklyIntegration, diff --git a/packages/node/src/integrations/tracing/koa.ts b/packages/node/src/integrations/tracing/koa.ts new file mode 100644 index 000000000000..54ebc44bcbbf --- /dev/null +++ b/packages/node/src/integrations/tracing/koa.ts @@ -0,0 +1,33 @@ +import { attachKoaErrorHandler } from '@sentry/server-utils'; + +/** + * Add a Koa error handler to capture errors to Sentry. + * + * @deprecated The error handler is now registered automatically when the Koa app + * starts (via the orchestrion `koa` instrumentation), so calling this is no + * longer necessary. It remains a safe, idempotent operation, and is kept for + * setups where auto-registration is unavailable. This will be removed in a + * future major version. + * + * @param app The Koa app instance + * + * @example + * ```javascript + * const Sentry = require('@sentry/node'); + * const Koa = require("koa"); + * + * const app = new Koa(); + * + * // Add your routes, etc. + * + * app.listen(3000); + * ``` + */ + +export const setupKoaErrorHandler = (app: { + // oxlint-disable-next-line no-explicit-any + on: (event: 'error', listener: (...args: any[]) => void) => unknown; +}): void => { + // oxlint-disable-next-line typescript/no-deprecated -- internal delegation to the shared implementation + attachKoaErrorHandler(app); +}; diff --git a/packages/node/src/integrations/tracing/koa/index.ts b/packages/node/src/integrations/tracing/koa/index.ts deleted file mode 100644 index 88532ffe331b..000000000000 --- a/packages/node/src/integrations/tracing/koa/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { captureException } from '@sentry/core'; - -/** - * Add an Koa error handler to capture errors to Sentry. - * - * The error handler must be before any other middleware and after all controllers. - * - * @param app The Express instances - * @param options {ExpressHandlerOptions} Configuration options for the handler - * - * @example - * ```javascript - * const Sentry = require('@sentry/node'); - * const Koa = require("koa"); - * - * const app = new Koa(); - * - * Sentry.setupKoaErrorHandler(app); - * - * // Add your routes, etc. - * - * app.listen(3000); - * ``` - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const setupKoaErrorHandler = (app: { use: (arg0: (ctx: any, next: any) => Promise) => void }): void => { - app.use(async (ctx, next) => { - try { - await next(); - } catch (error) { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.middleware.koa', - }, - }); - throw error; - } - }); -}; diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 2ca9dd356a47..df993e07978b 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -97,6 +97,7 @@ export { setAttributes, setupExpressErrorHandler, setupHapiErrorHandler, + // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, setUser, spanToBaggageHeader, diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 4309a6f1b859..35240de5aaef 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -2,6 +2,8 @@ export * from './exports'; // Exports using diagnostics channels export { detectOrchestrionSetup } from './orchestrion/detect'; +// oxlint-disable-next-line typescript/no-deprecated -- re-exported so the deprecated `setupKoaErrorHandler` can delegate +export { attachKoaErrorHandler } from './integrations/koa/koa-error-handler'; export { bindTracingChannelToSpan } from './tracing-channel'; export type { TracingChannelPayloadWithSpan } from './tracing-channel'; export type { InstrumentationConfig } from './orchestrion/apmTypes'; diff --git a/packages/server-utils/src/integrations/koa.ts b/packages/server-utils/src/integrations/koa/index.ts similarity index 82% rename from packages/server-utils/src/integrations/koa.ts rename to packages/server-utils/src/integrations/koa/index.ts index c891305df964..574eda120978 100644 --- a/packages/server-utils/src/integrations/koa.ts +++ b/packages/server-utils/src/integrations/koa/index.ts @@ -1,6 +1,7 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; import { + addNonEnumerableProperty, debug, defineIntegration, getActiveSpan, @@ -15,11 +16,12 @@ import { // oxlint-disable-next-line typescript/no-deprecated import { CODE_FUNCTION_NAME, HTTP_ROUTE, KOA_NAME, KOA_TYPE, SENTRY_OP } from '@sentry/conventions/attributes'; import { MIDDLEWARE } from '@sentry/conventions/op'; -import { DEBUG_BUILD } from '../debug-build'; -import { CHANNELS } from '../orchestrion/channels'; -import { koaModuleNames } from '../orchestrion/config/koa'; -import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; -import { setHttpServerSpanRouteAttribute } from '../utils/setHttpServerSpanRouteAttribute'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; +import { koaModuleNames } from '../../orchestrion/config/koa'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { setHttpServerSpanRouteAttribute } from '../../utils/setHttpServerSpanRouteAttribute'; +import { attachKoaErrorHandler, KOA_CONTEXT_SPAN, type KoaApp } from './koa-error-handler'; // Same name as the OTel integration. When enabled, the OTel 'Koa' integration is omitted from the default set. const INTEGRATION_NAME = 'Koa' as const; @@ -70,6 +72,11 @@ interface KoaUseContext { arguments: unknown[]; } +/** The `callback` channel `context` shape: `self` is the live app to attach the error listener to. */ +interface KoaCallbackContext { + self?: KoaApp; +} + export interface KoaIntegrationOptions { /** Ignore layers of the specified types (`'middleware'` and/or `'router'`). */ ignoreLayersType?: Array<'middleware' | 'router'>; @@ -98,6 +105,24 @@ function instrumentKoa(ignoreLayersType: KoaLayerType[]): void { asyncEnd() {}, error() {}, }); + + // Auto-register the error handler once the app boots. + // We act on `end` (after `callback()` ran) so + // koa's default `error` listener is already in place; `attachKoaErrorHandler` + // is idempotent, so repeated `callback()` calls add at most one listener. + diagnosticsChannel.tracingChannel(CHANNELS.KOA_CALLBACK).subscribe({ + start() {}, + end(rawCtx) { + const app = (rawCtx as KoaCallbackContext).self; + if (app) { + // oxlint-disable-next-line typescript/no-deprecated -- internal auto-registration entrypoint + attachKoaErrorHandler(app); + } + }, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); } function handleUse(ctx: KoaUseContext, ignoreLayersType: KoaLayerType[]): void { @@ -194,6 +219,16 @@ function patchLayer( }, }, () => { + // Stash the outermost koa span (first layer wins) on the koa `ctx`, so the + // error listener can capture within it — koa emits its `error` event after + // the middleware chain (and its spans) have unwound, when no span is active. + if (!context[KOA_CONTEXT_SPAN]) { + const activeSpan = getActiveSpan(); + if (activeSpan) { + addNonEnumerableProperty(context, KOA_CONTEXT_SPAN, activeSpan); + } + } + const route = metadata.attributes[HTTP_ROUTE]; if (getIsolationScope() === getDefaultIsolationScope()) { DEBUG_BUILD && debug.warn('Isolation scope is default isolation scope - skipping setting transactionName'); diff --git a/packages/server-utils/src/integrations/koa/koa-error-handler.ts b/packages/server-utils/src/integrations/koa/koa-error-handler.ts new file mode 100644 index 000000000000..b78384ae616a --- /dev/null +++ b/packages/server-utils/src/integrations/koa/koa-error-handler.ts @@ -0,0 +1,68 @@ +import type { Span } from '@sentry/core'; +import { addNonEnumerableProperty, captureException, withActiveSpan } from '@sentry/core'; + +// Marks a koa app as already carrying the Sentry error listener, so repeat +// attachments only ever register a single listener — whether reached via the +// `callback` channel or a lingering manual `setupKoaErrorHandler` call. +const ERROR_HANDLER_ATTACHED = '__SENTRY_KOA_ERROR_HANDLER_ATTACHED__'; + +/** + * Key under which the koa instrumentation stashes the request's active span on + * the koa `ctx`. Koa emits its `error` event from `handleRequest`'s `.catch()`, + * *after* the middleware chain has unwound and no span is active — so we capture + * within this stashed span to keep the error linked to the request's trace. + */ +export const KOA_CONTEXT_SPAN = '__SENTRY_KOA_SPAN__'; + +/** The subset of a koa `Application` the error handler needs (it extends `EventEmitter`). */ +export interface KoaApp { + on(event: 'error', listener: (error: unknown, context?: unknown) => void): unknown; + [key: string]: unknown; +} + +type MarkedKoaApp = KoaApp & { [ERROR_HANDLER_ATTACHED]?: boolean }; + +/** + * Attach a Sentry error listener to a koa app's `error` event. + * + * Koa emits `'error'` for every request error that bubbles up unhandled, so a + * single `app.on('error')` listener captures the same errors a top-level + * try/catch middleware would — without depending on middleware order. The error + * is captured within the request's koa span (stashed on the koa `ctx` under + * {@link KOA_CONTEXT_SPAN}) so it keeps its trace linkage, since koa emits the + * event after the middleware spans have already ended. + * + * Idempotent — the app is marked so auto-registration (via the `callback` + * channel) and any explicit `setupKoaErrorHandler` call never stack up multiple + * listeners. + * + * @deprecated Internal. The error handler is registered automatically by the koa + * instrumentation; there is no need to call this directly. It is exported only + * so the deprecated `setupKoaErrorHandler` can delegate to it, and will be + * removed in a future major version. + */ +export function attachKoaErrorHandler(app: KoaApp): void { + const markedApp = app as MarkedKoaApp; + if (!markedApp || typeof markedApp.on !== 'function' || markedApp[ERROR_HANDLER_ATTACHED]) { + return; + } + addNonEnumerableProperty(markedApp, ERROR_HANDLER_ATTACHED, true); + + markedApp.on('error', (error: unknown, context?: unknown) => { + const span = (context as { [KOA_CONTEXT_SPAN]?: Span } | undefined)?.[KOA_CONTEXT_SPAN]; + const capture = (): void => { + captureException(error, { + mechanism: { + type: 'auto.middleware.koa', + handled: false, + }, + }); + }; + + if (span) { + withActiveSpan(span, capture); + } else { + capture(); + } + }); +} diff --git a/packages/server-utils/src/orchestrion/config/koa.ts b/packages/server-utils/src/orchestrion/config/koa.ts index 932983a04ca3..877e997b07e7 100644 --- a/packages/server-utils/src/orchestrion/config/koa.ts +++ b/packages/server-utils/src/orchestrion/config/koa.ts @@ -7,10 +7,22 @@ export const koaConfig = [ module: { name: 'koa', versionRange: '>=2.0.0 <4', filePath: 'lib/application.js' }, functionQuery: { className: 'Application', methodName: 'use', kind: 'Sync' }, }, + // `callback()` gives us the live app via `ctx.self` so we can auto-register the + // error listener. We act on the channel's `end` (after the method body runs): + // koa registers its own default `error` listener inside `callback()` only when + // none exist yet, so attaching before that would suppress koa's default error + // logging. `app.listen()` funnels through `callback()`, so this covers both + // `app.listen()` and `http.createServer(app.callback())`. + { + channelName: 'callback', + module: { name: 'koa', versionRange: '>=2.0.0 <4', filePath: 'lib/application.js' }, + functionQuery: { className: 'Application', methodName: 'callback', kind: 'Sync' }, + }, ] satisfies InstrumentationConfig[]; export const koaModuleNames = getModuleNames(koaConfig); export const koaChannels = { KOA_USE: 'orchestrion:koa:use', + KOA_CALLBACK: 'orchestrion:koa:callback', } as const; diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 69a289422403..8bd3f34a3a53 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -101,6 +101,7 @@ export { setAttributes, setupExpressErrorHandler, setupHapiErrorHandler, + // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, setUser, spanToBaggageHeader, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index ecff2143254c..3f9aa1236ff0 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -98,6 +98,7 @@ export { setAttributes, setupExpressErrorHandler, setupHapiErrorHandler, + // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, setUser, spanToBaggageHeader,