From 4c9aac34ce8f61215ebb75cb75813bc69acb23bd Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 20 Aug 2026 16:23:25 +0200 Subject: [PATCH 1/3] feat(node): Capture Express errors automatically via `expressIntegration` `expressIntegration()` now captures errors thrown from route handlers on its own, at the throw site (before user error-handling middleware runs), gated by a new `shouldHandleError` option. Passing `shouldHandleError: false` opts out entirely. This moves Express error capture into `@sentry/server-utils` (alongside the channel-based tracing) and deprecates the now-superseded core Express exports (`setupExpressErrorHandler`, `expressErrorHandler`, `patchExpressModule` and the related types), to be removed in the next major. Co-Authored-By: Claude Opus 4.8 (1M context) --- MIGRATION.md | 10 +++ .../tests/errors.test.ts | 2 +- .../node-express/tests/errors.test.ts | 2 +- .../instrument-should-handle-error.mjs | 16 ++++ .../scenario-should-handle-error.mjs | 8 +- .../suites/express/handle-error/test.ts | 61 +++++++------- packages/astro/src/index.server.ts | 2 + packages/aws-serverless/src/index.ts | 2 + packages/bun/src/index.ts | 2 + .../core/src/integrations/express/index.ts | 14 ++++ .../src/integrations/express/patch-layer.ts | 4 + .../core/src/integrations/express/types.ts | 16 ++++ .../core/src/integrations/express/utils.ts | 4 + packages/core/src/server-exports.ts | 3 +- packages/elysia/src/index.ts | 2 + packages/google-cloud-serverless/src/index.ts | 2 + packages/node/src/index.ts | 1 + .../node/src/integrations/tracing/express.ts | 11 +++ packages/remix/src/server/index.ts | 2 + .../integrations/express/instrumentation.ts | 45 ++++++++++- .../src/integrations/express/types.ts | 40 ++++++++++ .../src/integrations/express/utils.ts | 15 ++++ .../express-error-handler.test.ts | 80 +++++++++++++++++++ packages/solidstart/src/server/index.ts | 2 + packages/sveltekit/src/server/index.ts | 2 + 25 files changed, 309 insertions(+), 39 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/express/handle-error/instrument-should-handle-error.mjs create mode 100644 packages/server-utils/src/integrations/express/utils.ts create mode 100644 packages/server-utils/test/integrations/tracing-channel/express-error-handler.test.ts diff --git a/MIGRATION.md b/MIGRATION.md index 5c3984abae46..63f02127c78b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -616,6 +616,16 @@ 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. +### Express: errors are captured automatically + +Affected SDKs: All server-side SDKs that support Express. + +`expressIntegration()` now captures errors thrown from your route handlers automatically, so calling `setupExpressErrorHandler(app)` is no longer necessary — the call can be removed. It is deprecated and will be removed in the next major version. To customize which errors are captured, pass `shouldHandleError` to `expressIntegration()` (by default, 5xx errors and errors without a resolvable status are captured, while 3xx/4xx errors are not). + +If you prefer to capture errors yourself, set `shouldHandleError: false` on `expressIntegration()` to opt out of automatic capture entirely, and call `Sentry.captureException` from your own error-handling middleware. + +The `expressErrorHandler` and `patchExpressModule` exports are deprecated for the same reason and will be removed in the next major version. + ### `@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. diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts index 628a48c56456..9000ac533b55 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts @@ -14,7 +14,7 @@ test('Sends correct error event', async ({ baseURL }) => { const exception = errorEvent.exception?.values?.[0]; expect(exception?.value).toBe('This is an exception with id 123'); expect(exception?.mechanism).toEqual({ - type: 'auto.middleware.express', + type: 'auto.http.express', handled: false, }); diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts index 3a3c821a927d..f06c02df2d45 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts @@ -14,7 +14,7 @@ test('Sends correct error event', async ({ baseURL }) => { const exception = errorEvent.exception?.values?.[0]; expect(exception?.value).toBe('This is an exception with id 123'); expect(exception?.mechanism).toEqual({ - type: 'auto.middleware.express', + type: 'auto.http.express', handled: false, }); diff --git a/dev-packages/node-integration-tests/suites/express/handle-error/instrument-should-handle-error.mjs b/dev-packages/node-integration-tests/suites/express/handle-error/instrument-should-handle-error.mjs new file mode 100644 index 000000000000..db661c5bf89b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/handle-error/instrument-should-handle-error.mjs @@ -0,0 +1,16 @@ +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.expressIntegration({ + shouldHandleError: error => { + return error.message === 'error_2'; + }, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/express/handle-error/scenario-should-handle-error.mjs b/dev-packages/node-integration-tests/suites/express/handle-error/scenario-should-handle-error.mjs index 335e89107e58..dfbec70cdea2 100644 --- a/dev-packages/node-integration-tests/suites/express/handle-error/scenario-should-handle-error.mjs +++ b/dev-packages/node-integration-tests/suites/express/handle-error/scenario-should-handle-error.mjs @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/node'; import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; import cors from 'cors'; import express from 'express'; @@ -15,10 +14,7 @@ app.get('/test2', (_req, _res) => { throw new Error('error_2'); }); -Sentry.setupExpressErrorHandler(app, { - shouldHandleError: error => { - return error.message === 'error_2'; - }, -}); +// `shouldHandleError` is configured on `expressIntegration` (see the instrument file); no +// error handler needs to be registered on the app anymore. startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/handle-error/test.ts b/dev-packages/node-integration-tests/suites/express/handle-error/test.ts index 5819a322e0c6..1e6879d281d1 100644 --- a/dev-packages/node-integration-tests/suites/express/handle-error/test.ts +++ b/dev-packages/node-integration-tests/suites/express/handle-error/test.ts @@ -31,7 +31,7 @@ describe('express error handling', () => { values: [ { mechanism: { - type: 'auto.middleware.express', + type: 'auto.http.express', handled: false, }, type: 'Error', @@ -68,7 +68,7 @@ describe('express error handling', () => { values: [ { mechanism: { - type: 'auto.middleware.express', + type: 'auto.http.express', handled: false, }, type: 'Error', @@ -106,7 +106,7 @@ describe('express error handling', () => { values: [ { mechanism: { - type: 'auto.middleware.express', + type: 'auto.http.express', handled: false, }, type: 'Error', @@ -148,7 +148,7 @@ describe('express error handling', () => { values: [ { mechanism: { - type: 'auto.middleware.express', + type: 'auto.http.express', handled: false, }, type: 'Error', @@ -187,7 +187,7 @@ describe('express error handling', () => { values: [ { mechanism: { - type: 'auto.middleware.express', + type: 'auto.http.express', handled: false, }, type: 'Error', @@ -235,7 +235,7 @@ describe('express error handling', () => { values: [ { mechanism: { - type: 'auto.middleware.express', + type: 'auto.http.express', handled: false, }, type: 'Error', @@ -265,30 +265,35 @@ describe('express error handling', () => { }); }); - describe('setupExpressErrorHandler options', () => { - createCjsTests(__dirname, 'scenario-should-handle-error.mjs', 'instrument-no-tracing.mjs', (createRunner, test) => { - test('allows to pass options to setupExpressErrorHandler', async () => { - const runner = createRunner() - .expect({ - event: { - exception: { - values: [ - { - value: 'error_2', - }, - ], + describe('expressIntegration shouldHandleError option', () => { + createCjsTests( + __dirname, + 'scenario-should-handle-error.mjs', + 'instrument-should-handle-error.mjs', + (createRunner, test) => { + test('captures only errors for which shouldHandleError returns true', async () => { + const runner = createRunner() + .expect({ + event: { + exception: { + values: [ + { + value: 'error_2', + }, + ], + }, }, - }, - }) - .start(); + }) + .start(); - // this error is filtered & ignored - runner.makeRequest('get', '/test1', { expectError: true }); - // this error is actually captured - runner.makeRequest('get', '/test2', { expectError: true }); + // this error is filtered & ignored + runner.makeRequest('get', '/test1', { expectError: true }); + // this error is actually captured + runner.makeRequest('get', '/test2', { expectError: true }); - await runner.completed(); - }); - }); + await runner.completed(); + }); + }, + ); }); }); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index e5d78a233dec..6214764823ad 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -37,6 +37,7 @@ export { dedupeIntegration, defaultStackParser, endSession, + // oxlint-disable-next-line typescript/no-deprecated expressErrorHandler, expressIntegration, extraErrorDataIntegration, @@ -123,6 +124,7 @@ export { setTags, setAttribute, setAttributes, + // oxlint-disable-next-line typescript/no-deprecated setupExpressErrorHandler, setupHapiErrorHandler, setupKoaErrorHandler, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index aa392eb3ec4d..7b65ac1881e5 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -93,7 +93,9 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, dataloaderIntegration, expressIntegration, + // oxlint-disable-next-line typescript/no-deprecated expressErrorHandler, + // oxlint-disable-next-line typescript/no-deprecated setupExpressErrorHandler, koaIntegration, setupKoaErrorHandler, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index b54adc166a21..fec4657d9d51 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -114,7 +114,9 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, dataloaderIntegration, expressIntegration, + // oxlint-disable-next-line typescript/no-deprecated expressErrorHandler, + // oxlint-disable-next-line typescript/no-deprecated setupExpressErrorHandler, fastifyIntegration, setupFastifyErrorHandler, diff --git a/packages/core/src/integrations/express/index.ts b/packages/core/src/integrations/express/index.ts index af6e72f8fd26..ce17912fe1d5 100644 --- a/packages/core/src/integrations/express/index.ts +++ b/packages/core/src/integrations/express/index.ts @@ -27,6 +27,10 @@ * limitations under the License. */ +// This whole module backs the deprecated Express exports (superseded by `expressIntegration()`), so it +// references its own deprecated types/functions throughout. +/* oxlint-disable typescript/no-deprecated */ + import { debug } from '../../utils/debug-logger'; import { captureException } from '../../exports'; import { DEBUG_BUILD } from '../../debug-build'; @@ -67,6 +71,9 @@ import { getDefaultExport } from '../../utils/get-default-export'; * * Sentry.patchExpressModule(express, () => ({})); * ``` + * + * @deprecated Express is now instrumented automatically via `expressIntegration()`. This export is + * no longer used and will be removed in the next major version. */ export function patchExpressModule( moduleExports: ExpressModuleExport, @@ -160,6 +167,9 @@ export function patchExpressModule( /** * An Express-compatible error handler, used by setupExpressErrorHandler + * + * @deprecated `expressIntegration()` now captures errors automatically. This export is deprecated + * and will be removed in the next major version. */ export function expressErrorHandler(options?: ExpressHandlerOptions): ExpressErrorMiddleware { return function sentryErrorMiddleware( @@ -208,6 +218,10 @@ export function expressErrorHandler(options?: ExpressHandlerOptions): ExpressErr * * app.listen(3000); * ``` + * + * @deprecated `expressIntegration()` now captures errors automatically, so calling this is no longer + * necessary. To customize which errors are captured, pass `shouldHandleError` to `expressIntegration()`. + * This export is deprecated and will be removed in the next major version. */ export function setupExpressErrorHandler( app: { diff --git a/packages/core/src/integrations/express/patch-layer.ts b/packages/core/src/integrations/express/patch-layer.ts index cff2853e673e..bf7dcc64b3da 100644 --- a/packages/core/src/integrations/express/patch-layer.ts +++ b/packages/core/src/integrations/express/patch-layer.ts @@ -27,6 +27,10 @@ * limitations under the License. */ +// This module backs the deprecated Express exports (superseded by `expressIntegration()`), so it +// references the deprecated `ExpressIntegrationOptions` type. +/* oxlint-disable typescript/no-deprecated */ + import { SENTRY_OP } from '@sentry/conventions/attributes'; import { WEB_SERVER_MIDDLEWARE_SPAN_OP } from '@sentry/conventions/op'; import { DEBUG_BUILD } from '../../debug-build'; diff --git a/packages/core/src/integrations/express/types.ts b/packages/core/src/integrations/express/types.ts index fbc2f1563359..3affb2d51284 100644 --- a/packages/core/src/integrations/express/types.ts +++ b/packages/core/src/integrations/express/types.ts @@ -135,6 +135,10 @@ export type ExpressRouter = { export type IgnoreMatcher = string | RegExp | ((name: string) => boolean); +/** + * @deprecated The core Express integration is superseded by `expressIntegration()`. This type is + * deprecated and will be removed in the next major version. + */ export type ExpressIntegrationOptions = { /** Ignore specific based on their name */ ignoreLayers?: IgnoreMatcher[]; @@ -167,8 +171,16 @@ export interface MiddlewareError extends Error { }; } +/** + * @deprecated `expressIntegration()` captures errors automatically. This type is deprecated and will + * be removed in the next major version. + */ export type ExpressMiddleware = (req: ExpressRequest, res: ExpressResponse, next: () => void) => void; +/** + * @deprecated `expressIntegration()` captures errors automatically. This type is deprecated and will + * be removed in the next major version. + */ export type ExpressErrorMiddleware = ( error: MiddlewareError, req: ExpressRequest, @@ -176,6 +188,10 @@ export type ExpressErrorMiddleware = ( next: (error: MiddlewareError) => void, ) => void; +/** + * @deprecated `expressIntegration()` captures errors automatically; pass `shouldHandleError` to it to + * customize capture. This type is deprecated and will be removed in the next major version. + */ export interface ExpressHandlerOptions { /** * Callback method deciding whether error should be captured and sent to Sentry diff --git a/packages/core/src/integrations/express/utils.ts b/packages/core/src/integrations/express/utils.ts index 55a3325ad172..80dc13af7c34 100644 --- a/packages/core/src/integrations/express/utils.ts +++ b/packages/core/src/integrations/express/utils.ts @@ -27,6 +27,10 @@ * limitations under the License. */ +// This module backs the deprecated Express exports (superseded by `expressIntegration()`), so it +// references the deprecated `ExpressIntegrationOptions` type. +/* oxlint-disable typescript/no-deprecated */ + import type { SpanAttributes } from '../../types/span'; import { getStoredLayers } from './request-layer-store'; import type { diff --git a/packages/core/src/server-exports.ts b/packages/core/src/server-exports.ts index 21469232c101..6f6a242ab413 100644 --- a/packages/core/src/server-exports.ts +++ b/packages/core/src/server-exports.ts @@ -15,7 +15,7 @@ export { vercelWaitUntil } from './utils/vercelWaitUntil'; export { flushIfServerless } from './utils/flushIfServerless'; export { callFrameToStackFrame, watchdogTimer } from './utils/anr'; export { safeUnref as _INTERNAL_safeUnref } from './utils/timer'; -// eslint-disable-next-line typescript/no-deprecated +/* oxlint-disable typescript/no-deprecated -- deprecated Express exports, kept until the next major */ export { patchExpressModule, setupExpressErrorHandler, expressErrorHandler } from './integrations/express/index'; export type { ExpressIntegrationOptions, @@ -23,6 +23,7 @@ export type { ExpressMiddleware, ExpressErrorMiddleware, } from './integrations/express/types'; +/* oxlint-enable typescript/no-deprecated */ export { instrumentPostgresJsSql, _sanitizeSqlQuery as _INTERNAL_sanitizeSqlQuery, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 8786ab737f17..d915aac9bf46 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -93,7 +93,9 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, dataloaderIntegration, expressIntegration, + // oxlint-disable-next-line typescript/no-deprecated expressErrorHandler, + // oxlint-disable-next-line typescript/no-deprecated setupExpressErrorHandler, fastifyIntegration, setupFastifyErrorHandler, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 17fcf89c8f16..be78619aed6a 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -94,7 +94,9 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, dataloaderIntegration, expressIntegration, + // oxlint-disable-next-line typescript/no-deprecated expressErrorHandler, + // oxlint-disable-next-line typescript/no-deprecated setupExpressErrorHandler, koaIntegration, setupKoaErrorHandler, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 3483dbff8163..3ff665f093b9 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -1,6 +1,7 @@ export { httpIntegration } from './integrations/http'; export { nativeNodeFetchIntegration } from './integrations/node-fetch'; export { fsIntegration } from './integrations/fs'; +// oxlint-disable-next-line typescript/no-deprecated export { expressErrorHandler, setupExpressErrorHandler } from './integrations/tracing/express'; export { fastifyIntegration, setupFastifyErrorHandler } from './integrations/tracing/fastify'; export { diff --git a/packages/node/src/integrations/tracing/express.ts b/packages/node/src/integrations/tracing/express.ts index 7590c8c6bfd6..cdcb142195a7 100644 --- a/packages/node/src/integrations/tracing/express.ts +++ b/packages/node/src/integrations/tracing/express.ts @@ -1,10 +1,21 @@ +// oxlint-disable-next-line typescript/no-deprecated import { setupExpressErrorHandler as coreSetupExpressErrorHandler, type ExpressHandlerOptions } from '@sentry/core'; +// oxlint-disable-next-line typescript/no-deprecated export { expressErrorHandler } from '@sentry/core'; +/** + * Add an Express error handler to capture errors to Sentry. + * + * @deprecated `expressIntegration()` now captures errors automatically, so calling this is no longer + * necessary. To customize which errors are captured, pass `shouldHandleError` to `expressIntegration()`. + * This export is deprecated and will be removed in the next major version. + */ export function setupExpressErrorHandler( //oxlint-disable-next-line no-explicit-any app: { use: (middleware: any) => unknown }, + // oxlint-disable-next-line typescript/no-deprecated options?: ExpressHandlerOptions, ): void { + // oxlint-disable-next-line typescript/no-deprecated coreSetupExpressErrorHandler(app, options); } diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index c68743fa0dd5..4cca780f8d60 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -29,6 +29,7 @@ export { dedupeIntegration, defaultStackParser, endSession, + // oxlint-disable-next-line typescript/no-deprecated expressErrorHandler, expressIntegration, extraErrorDataIntegration, @@ -95,6 +96,7 @@ export { setTags, setAttribute, setAttributes, + // oxlint-disable-next-line typescript/no-deprecated setupExpressErrorHandler, setupHapiErrorHandler, setupKoaErrorHandler, diff --git a/packages/server-utils/src/integrations/express/instrumentation.ts b/packages/server-utils/src/integrations/express/instrumentation.ts index 74c7f7a5e1d5..afc58ccb4831 100644 --- a/packages/server-utils/src/integrations/express/instrumentation.ts +++ b/packages/server-utils/src/integrations/express/instrumentation.ts @@ -3,6 +3,7 @@ import { HTTP_ROUTE, SENTRY_OP } from '@sentry/conventions/attributes'; import { WEB_SERVER_MIDDLEWARE_SPAN_OP } from '@sentry/conventions/op'; import type { Span } from '@sentry/core'; import { + captureException, debug, getActiveSpan, getDefaultIsolationScope, @@ -29,9 +30,12 @@ import type { ExpressLayerType, ExpressRequest, ExpressResponse, + ExpressShouldHandleError, HandleChannelContext, + MiddlewareError, RegistrationChannelContext, } from './types'; +import { defaultShouldHandleError } from './utils'; import { setHttpServerSpanRouteAttribute } from '../../utils/setHttpServerSpanRouteAttribute'; const ORIGIN = 'auto.http.express'; @@ -88,17 +92,54 @@ export function instrumentExpress( // Pop the layer path when the layer hands off via `next`. `asyncStart` fires // when `next` is called and *before* the downstream layer runs, so the // per-request path chain reflects only the current chain when each layer - // reconstructs its route. Only `asyncStart` is relevant here. + // reconstructs its route. The `error` event captures throws at the layer + // level (see `captureLayerError`), before any user error-handling middleware. channel.subscribe({ start: NOOP, asyncEnd: NOOP, end: NOOP, - error: NOOP, + error: data => captureLayerError(data, options.shouldHandleError), asyncStart: popLayerPathForLayer, }); } } +/** + * Capture an error surfaced on a layer's `handle_request` channel — the throw + * site, which runs before any user error-handling middleware. Duplicate captures + * (the error bubbling through parent layers, or a user also calling + * `setupExpressErrorHandler`) are collapsed by `captureException`'s per-object + * dedup, so only the first send survives. + * + * `shouldHandleError` is the raw integration option: `false` disables capture + * entirely, a function customizes the gate, and `undefined` falls back to + * {@link defaultShouldHandleError}. + */ +export function captureLayerError( + data: HandleChannelContext, + shouldHandleError: ExpressShouldHandleError | undefined, +): void { + if (shouldHandleError === false) { + return; + } + + const error = (data as { error?: unknown }).error; + + // `next('route')` / `next('router')` are Express control-flow signals, not errors. + if (!error || error === 'route' || error === 'router') { + return; + } + + if ((shouldHandleError ?? defaultShouldHandleError)(error as MiddlewareError)) { + captureException(error, { + mechanism: { + type: 'auto.http.express', + handled: false, + }, + }); + } +} + /** Record the freshly-registered layer's path pattern from a `route`/`use` call. */ function captureRegisteredLayerPath(data: RegistrationChannelContext): void { const stack = data.self?.stack; diff --git a/packages/server-utils/src/integrations/express/types.ts b/packages/server-utils/src/integrations/express/types.ts index 1cd56b104083..9116ba951dd2 100644 --- a/packages/server-utils/src/integrations/express/types.ts +++ b/packages/server-utils/src/integrations/express/types.ts @@ -54,10 +54,50 @@ export interface RegistrationChannelContext { arguments?: unknown[]; } +/** An Express error carrying an optional HTTP status, in the various shapes middleware use. */ +export interface MiddlewareError extends Error { + status?: number | string; + statusCode?: number | string; + status_code?: number | string; + output?: { + statusCode?: number | string; + }; +} + +/** Callback deciding whether an error should be captured; `false` disables capture entirely. */ +export type ExpressShouldHandleError = ((error: MiddlewareError) => boolean) | false; + type IgnoreMatcher = string | RegExp | ((name: string) => boolean); export interface ExpressIntegrationOptions { /** Ignore specific based on their name */ ignoreLayers?: IgnoreMatcher[]; /** Ignore specific layers based on their type */ ignoreLayersType?: ExpressLayerType[]; + /** + * Callback deciding whether an error thrown from a route handler should be + * captured and sent to Sentry. + * + * By default, 5xx errors (and errors without a resolvable status) are sent, + * while 3xx and 4xx errors are not. Errors are captured as soon as they are + * thrown — before any user error-handling middleware runs. + * + * Set to `false` to disable Sentry's automatic error capture entirely; you can + * then capture errors yourself from your own error handler via + * `Sentry.captureException`. + * + * @example + * + * ```javascript + * Sentry.init({ + * integrations: [ + * Sentry.expressIntegration({ + * shouldHandleError(error) { + * return (error.statusCode ?? 500) >= 500; + * }, + * }), + * ], + * }); + * ``` + */ + shouldHandleError?: ExpressShouldHandleError; } diff --git a/packages/server-utils/src/integrations/express/utils.ts b/packages/server-utils/src/integrations/express/utils.ts new file mode 100644 index 000000000000..a76d0b4abf61 --- /dev/null +++ b/packages/server-utils/src/integrations/express/utils.ts @@ -0,0 +1,15 @@ +import type { MiddlewareError } from './types'; + +function getStatusCodeFromResponse(error: MiddlewareError): number { + const statusCode = error.status || error.statusCode || error.status_code || error.output?.statusCode; + return statusCode ? parseInt(statusCode as string, 10) : 500; +} + +/** + * Default function deciding whether an error should be sent to Sentry: captures + * 5xx errors, and treats an error without a resolvable status as a 500. Errors + * carrying a 3xx/4xx status are skipped (client errors / redirects). + */ +export function defaultShouldHandleError(error: MiddlewareError): boolean { + return getStatusCodeFromResponse(error) >= 500; +} diff --git a/packages/server-utils/test/integrations/tracing-channel/express-error-handler.test.ts b/packages/server-utils/test/integrations/tracing-channel/express-error-handler.test.ts new file mode 100644 index 000000000000..33fc542cd431 --- /dev/null +++ b/packages/server-utils/test/integrations/tracing-channel/express-error-handler.test.ts @@ -0,0 +1,80 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { captureLayerError } from '../../../src/integrations/express/instrumentation'; +import type { HandleChannelContext } from '../../../src/integrations/express/types'; + +function makeErrorData(error: unknown): HandleChannelContext { + return { error } as unknown as HandleChannelContext; +} + +describe('captureLayerError', () => { + let captureExceptionSpy: MockInstance; + + beforeEach(() => { + captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => 'id'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('captures a 5xx error by default', () => { + const error = Object.assign(new Error('boom'), { statusCode: 500 }); + + captureLayerError(makeErrorData(error), undefined); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.http.express', handled: false }, + }); + }); + + it('captures an error without a resolvable status by default', () => { + const error = new Error('boom'); + + captureLayerError(makeErrorData(error), undefined); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.http.express', handled: false }, + }); + }); + + it('does not capture a 4xx error by default', () => { + const error = Object.assign(new Error('bad request'), { statusCode: 400 }); + + captureLayerError(makeErrorData(error), undefined); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it.each(['route', 'router'])('ignores the Express `next(%s)` control signal', signal => { + captureLayerError(makeErrorData(signal), undefined); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('does not capture when there is no error', () => { + captureLayerError(makeErrorData(undefined), undefined); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('honors a custom shouldHandleError', () => { + const shouldHandleError = vi.fn().mockReturnValue(true); + const error = Object.assign(new Error('teapot'), { statusCode: 418 }); + + captureLayerError(makeErrorData(error), shouldHandleError); + + expect(shouldHandleError).toHaveBeenCalledWith(error); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.http.express', handled: false }, + }); + }); + + it('captures nothing when shouldHandleError is false', () => { + const error = Object.assign(new Error('boom'), { statusCode: 500 }); + + captureLayerError(makeErrorData(error), false); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 72cbcf7ad78c..a91500bb4432 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -32,6 +32,7 @@ export { dedupeIntegration, defaultStackParser, endSession, + // oxlint-disable-next-line typescript/no-deprecated expressErrorHandler, expressIntegration, extraErrorDataIntegration, @@ -99,6 +100,7 @@ export { setTags, setAttribute, setAttributes, + // oxlint-disable-next-line typescript/no-deprecated setupExpressErrorHandler, setupHapiErrorHandler, setupKoaErrorHandler, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 9d9040bfb9b4..457cbea4d748 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -30,6 +30,7 @@ export { dedupeIntegration, defaultStackParser, endSession, + // oxlint-disable-next-line typescript/no-deprecated expressErrorHandler, expressIntegration, extraErrorDataIntegration, @@ -96,6 +97,7 @@ export { setTags, setAttribute, setAttributes, + // oxlint-disable-next-line typescript/no-deprecated setupExpressErrorHandler, setupHapiErrorHandler, setupKoaErrorHandler, From 69797e8a21cddd11001578e46e32ead4a465490f Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 20 Aug 2026 16:46:50 +0200 Subject: [PATCH 2/3] fix(node): Parent captured Express errors to the layer span The channel `error` event runs outside the layer span's async context, so `captureException` was recording events with no `parent_span_id`. Re-activate the span bound by `bindTracingChannelToSpan` (now typed on `HandleChannelContext`) around the capture so the error event is parented to the request's trace. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../integrations/express/instrumentation.ts | 17 +++++++++-- .../src/integrations/express/types.ts | 8 ++++- .../express-error-handler.test.ts | 29 +++++++++++++++++-- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/server-utils/src/integrations/express/instrumentation.ts b/packages/server-utils/src/integrations/express/instrumentation.ts index afc58ccb4831..6cd83d53cfd1 100644 --- a/packages/server-utils/src/integrations/express/instrumentation.ts +++ b/packages/server-utils/src/integrations/express/instrumentation.ts @@ -11,6 +11,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, stringMatchesSomePattern, + withActiveSpan, } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; @@ -123,20 +124,32 @@ export function captureLayerError( return; } - const error = (data as { error?: unknown }).error; + const error = data.error; // `next('route')` / `next('router')` are Express control-flow signals, not errors. if (!error || error === 'route' || error === 'router') { return; } - if ((shouldHandleError ?? defaultShouldHandleError)(error as MiddlewareError)) { + if (!(shouldHandleError ?? defaultShouldHandleError)(error as MiddlewareError)) { + return; + } + + const capture = (): string => captureException(error, { mechanism: { type: 'auto.http.express', handled: false, }, }); + + // The channel's `error` event runs outside the layer span's async context, so + // re-activate the bound span (when present) to parent the error event to the + // request's trace instead of capturing it context-free. + if (data._sentrySpan) { + withActiveSpan(data._sentrySpan, capture); + } else { + capture(); } } diff --git a/packages/server-utils/src/integrations/express/types.ts b/packages/server-utils/src/integrations/express/types.ts index 9116ba951dd2..5536f524e849 100644 --- a/packages/server-utils/src/integrations/express/types.ts +++ b/packages/server-utils/src/integrations/express/types.ts @@ -1,3 +1,5 @@ +import type { Span } from '@sentry/core'; + export type ExpressLayerType = 'router' | 'middleware' | 'request_handler'; /** @@ -34,13 +36,17 @@ export interface ExpressResponse { * `_sentryCleanup` is ours: a teardown for the `res.on('finish')` listener we * register, invoked from `beforeSpanEnd` when the span ends via `next()`. * `_sentryStoredLayer` marks that this invocation pushed a layer path (so the - * matching pop on `asyncStart` stays symmetric). + * matching pop on `asyncStart` stays symmetric). `_sentrySpan` is the span bound + * for this layer by `bindTracingChannelToSpan`, and `error` is present on the + * channel's `error` event. */ export interface HandleChannelContext { self?: ExpressLayer; arguments?: unknown[]; _sentryCleanup?: () => void; _sentryStoredLayer?: boolean; + _sentrySpan?: Span; + error?: unknown; } /** diff --git a/packages/server-utils/test/integrations/tracing-channel/express-error-handler.test.ts b/packages/server-utils/test/integrations/tracing-channel/express-error-handler.test.ts index 33fc542cd431..05c81b35fcc8 100644 --- a/packages/server-utils/test/integrations/tracing-channel/express-error-handler.test.ts +++ b/packages/server-utils/test/integrations/tracing-channel/express-error-handler.test.ts @@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } fr import { captureLayerError } from '../../../src/integrations/express/instrumentation'; import type { HandleChannelContext } from '../../../src/integrations/express/types'; -function makeErrorData(error: unknown): HandleChannelContext { - return { error } as unknown as HandleChannelContext; +function makeErrorData(error: unknown, span?: unknown): HandleChannelContext { + return { error, _sentrySpan: span } as unknown as HandleChannelContext; } describe('captureLayerError', () => { @@ -77,4 +77,29 @@ describe('captureLayerError', () => { expect(captureExceptionSpy).not.toHaveBeenCalled(); }); + + it('re-activates the bound layer span so the event is parented to the trace', () => { + const withActiveSpanSpy = vi + .spyOn(SentryCore, 'withActiveSpan') + .mockImplementation((_span, fn) => (fn as () => unknown)(undefined as never) as never); + const span = { id: 'layer-span' }; + const error = Object.assign(new Error('boom'), { statusCode: 500 }); + + captureLayerError(makeErrorData(error, span), undefined); + + expect(withActiveSpanSpy).toHaveBeenCalledWith(span, expect.any(Function)); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.http.express', handled: false }, + }); + }); + + it('captures without a span when none is bound (e.g. unsampled request)', () => { + const withActiveSpanSpy = vi.spyOn(SentryCore, 'withActiveSpan'); + const error = Object.assign(new Error('boom'), { statusCode: 500 }); + + captureLayerError(makeErrorData(error), undefined); + + expect(withActiveSpanSpy).not.toHaveBeenCalled(); + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + }); }); From f44e7bab68857ce95776fd8a9f3ffa3d3670a68a Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 21 Aug 2026 08:53:44 +0200 Subject: [PATCH 3/3] fix tests --- .../node-express-streaming/tests/errors.test.ts | 1 + .../test-applications/node-express-v5/tests/errors.test.ts | 1 + .../test-applications/node-express/tests/errors.test.ts | 1 + .../e2e-tests/test-applications/tsx-express/tests/errors.test.ts | 1 + 4 files changed, 4 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts index 9000ac533b55..5415d8fa1e14 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/tests/errors.test.ts @@ -30,6 +30,7 @@ test('Sends correct error event', async ({ baseURL }) => { expect(errorEvent.contexts?.trace).toEqual({ 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}/), }); }); diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/errors.test.ts index 56b4f51d228d..376a312ca2da 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/errors.test.ts @@ -25,6 +25,7 @@ test('Sends correct error event', async ({ baseURL }) => { expect(errorEvent.contexts?.trace).toEqual({ 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}/), }); }); diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts index f06c02df2d45..6a1e2a468231 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts @@ -30,6 +30,7 @@ test('Sends correct error event', async ({ baseURL }) => { expect(errorEvent.contexts?.trace).toEqual({ 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}/), }); }); diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/tsx-express/tests/errors.test.ts index 5d596b9b8226..b06a49a996f4 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/tsx-express/tests/errors.test.ts @@ -25,5 +25,6 @@ test('Sends correct error event', async ({ baseURL }) => { expect(errorEvent.contexts?.trace).toEqual({ 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}/), }); });