diff --git a/MIGRATION.md b/MIGRATION.md index 5c3984abae46..75af825f2861 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -285,6 +285,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 4749feefddb1..bb30848565c3 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 @@ -61,7 +61,9 @@ test('Sends an API route transaction', async ({ baseURL }) => { }, }); - expect(transactionEvent.spans).toEqual([ + const spans = transactionEvent.spans || []; + + expect(spans).toEqual([ { data: { 'koa.name': 'bodyParser', @@ -80,24 +82,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 a6653e7d23cf..ad0fd8b23d9c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/koa/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/koa/test.ts @@ -14,6 +14,16 @@ describe('koa auto-instrumentation', () => { const origin = isOrchestrionEnabled() ? 'auto.http.koa' : 'auto.http.otel.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 e5d78a233dec..12964f6fe90e 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -125,6 +125,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 aa392eb3ec4d..0ea31fd99138 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 b54adc166a21..a5142ec535fe 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 8786ab737f17..3c3e15c46b34 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 17fcf89c8f16..049fdae4cc84 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 3483dbff8163..e3ff7a17f0ab 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 c68743fa0dd5..4e6ae6de2dbc 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 237693d45c14..5f4145ea2772 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 { prismaIntegration } from './prisma'; +// 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'; diff --git a/packages/server-utils/src/integrations/koa.ts b/packages/server-utils/src/integrations/koa/index.ts similarity index 81% rename from packages/server-utils/src/integrations/koa.ts rename to packages/server-utils/src/integrations/koa/index.ts index 77bb6bbec64d..1b28bfb3537c 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, @@ -12,11 +13,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 { WEB_SERVER_MIDDLEWARE_SPAN_OP } 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; @@ -67,6 +69,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'>; @@ -95,6 +102,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 { @@ -186,6 +211,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/server-utils/test/integrations/koa/koa-error-handler.test.ts b/packages/server-utils/test/integrations/koa/koa-error-handler.test.ts new file mode 100644 index 000000000000..48d70bc658b7 --- /dev/null +++ b/packages/server-utils/test/integrations/koa/koa-error-handler.test.ts @@ -0,0 +1,68 @@ +// oxlint-disable typescript/no-deprecated -- exercising the deprecated-but-internal error handler +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { attachKoaErrorHandler, type KoaApp } from '../../../src/integrations/koa/koa-error-handler'; + +type ErrorListener = (error: unknown, context?: unknown) => void; + +interface FakeApp { + app: KoaApp; + onSpy: MockInstance; + getListener: () => ErrorListener | undefined; +} + +function makeApp(): FakeApp { + let listener: ErrorListener | undefined; + const onSpy = vi.fn((_event: string, cb: ErrorListener) => { + listener = cb; + }); + const app = { on: onSpy } as unknown as KoaApp; + return { app, onSpy, getListener: () => listener }; +} + +describe('attachKoaErrorHandler', () => { + let captureExceptionSpy: MockInstance; + + beforeEach(() => { + captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => 'id'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('registers a single `error` listener', () => { + const { app, onSpy } = makeApp(); + + attachKoaErrorHandler(app); + + expect(onSpy).toHaveBeenCalledTimes(1); + expect(onSpy).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + it('is idempotent across repeat calls on the same app', () => { + const { app, onSpy } = makeApp(); + + attachKoaErrorHandler(app); + attachKoaErrorHandler(app); + + expect(onSpy).toHaveBeenCalledTimes(1); + }); + + it('does not throw when the app has no `on` method', () => { + expect(() => attachKoaErrorHandler({} as KoaApp)).not.toThrow(); + expect(() => attachKoaErrorHandler(undefined as unknown as KoaApp)).not.toThrow(); + }); + + it('captures the emitted error as an unhandled koa middleware error', () => { + const { app, getListener } = makeApp(); + attachKoaErrorHandler(app); + const error = new Error('boom'); + + getListener()?.(error); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.middleware.koa', handled: false }, + }); + }); +}); diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 72cbcf7ad78c..d9216945f3ce 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 9d9040bfb9b4..b7b6e0ff1514 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,