diff --git a/MIGRATION.md b/MIGRATION.md index 6274f86f640d..3ed60fcf9eae 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -829,6 +829,27 @@ If you prefer to capture errors yourself, set `expressIntegration({ shouldHandle The `expressErrorHandler` and `patchExpressModule` exports are deprecated for the same reason and will be removed in the next major version. The export of `expressErrorHandler` and `setupExpressErrorHandler` is moved from `@sentry/core` to `@sentry/server-utils`. +The `setupExpressErrorHandler` and `expressErrorHandler` no longer accept a `shouldHandleError` option, and the `ExpressHandlerOptions` type was removed. Set the callback on `expressIntegration()` instead: + +```diff + Sentry.init({ +- integrations: [Sentry.expressIntegration()], ++ integrations: [ ++ Sentry.expressIntegration({ ++ shouldHandleError(error) { ++ return (error.statusCode ?? 500) >= 400; ++ }, ++ }), ++ ], + }); + +-Sentry.setupExpressErrorHandler(app, { +- shouldHandleError(error) { +- return (error.statusCode ?? 500) >= 400; +- }, +-}); +``` + ### Span name changes Affected SDKs: All SDKs. @@ -1241,6 +1262,7 @@ The `idleTimeout`, `finalTimeout` and `childSpanTimeout` options of interaction - (AWS Lambda) The deprecated `startTrace` option was removed. It no longer had any effect; to disable tracing, set `tracesSampleRate` to `0`. - (AWS Lambda) The deprecated `tryPatchHandler` function was removed. It was no longer used. - (Express) The deprecated `patchExpressModule(options)` signature was removed. Use `patchExpressModule(moduleExports, getOptions)` instead. +- (Express) The `shouldHandleError` option was removed from `setupExpressErrorHandler` and `expressErrorHandler`, along with the `ExpressHandlerOptions` type. Configure it on `expressIntegration()` instead. See [Express: errors are captured automatically](#express-errors-are-captured-automatically). - (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/node-integration-tests/suites/express/handle-error/scenario-setup-error-handler-fallback.mjs b/dev-packages/node-integration-tests/suites/express/handle-error/scenario-setup-error-handler-fallback.mjs index e0ea1168ee0f..5608632c4958 100644 --- a/dev-packages/node-integration-tests/suites/express/handle-error/scenario-setup-error-handler-fallback.mjs +++ b/dev-packages/node-integration-tests/suites/express/handle-error/scenario-setup-error-handler-fallback.mjs @@ -8,7 +8,10 @@ const app = express(); app.use(cors()); app.get('/test1', (_req, _res) => { - throw new Error('error_1'); + // 4xx errors are skipped by the default predicate + const error = new Error('error_1'); + error.statusCode = 404; + throw error; }); app.get('/test2', (_req, _res) => { @@ -16,9 +19,8 @@ app.get('/test2', (_req, _res) => { }); // With `expressIntegration` disabled (see the instrument file), the deprecated middleware is the sole -// capturer and its own `shouldHandleError` applies. -Sentry.setupExpressErrorHandler(app, { - shouldHandleError: error => error.message === 'error_2', -}); +// capturer. It has no `shouldHandleError` of its own, so the default predicate applies: 5xx and +// status-less errors are captured, 3xx/4xx are not. +Sentry.setupExpressErrorHandler(app); 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 3e6f8ce1feac..78ee20e6a3bc 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 @@ -336,14 +336,15 @@ describe('express error handling', () => { }, ); - // Fallback: with `expressIntegration` disabled, the deprecated middleware is the sole capturer and - // its own `shouldHandleError` applies (mechanism `auto.middleware.express`). + // Fallback: with `expressIntegration` disabled, the deprecated middleware is the sole capturer + // (mechanism `auto.middleware.express`). It applies the default predicate — `shouldHandleError` + // is configured on `expressIntegration` only. createCjsTests( __dirname, 'scenario-setup-error-handler-fallback.mjs', 'instrument-setup-error-handler.mjs', (createRunner, test) => { - test('deprecated handler captures with its own shouldHandleError when expressIntegration is disabled', async () => { + test('deprecated handler captures with the default predicate when expressIntegration is disabled', async () => { const runner = createRunner() .expect({ event: { @@ -362,9 +363,9 @@ describe('express error handling', () => { }) .start(); - // this error is filtered & ignored + // 4xx: skipped by the default predicate runner.makeRequest('get', '/test1', { expectError: true }); - // this error is actually captured + // no status: treated as 5xx and captured runner.makeRequest('get', '/test2', { expectError: true }); await runner.completed(); diff --git a/packages/core/src/integrations/express/types.ts b/packages/core/src/integrations/express/types.ts index 3affb2d51284..5bf33e578ad0 100644 --- a/packages/core/src/integrations/express/types.ts +++ b/packages/core/src/integrations/express/types.ts @@ -187,15 +187,3 @@ export type ExpressErrorMiddleware = ( res: ExpressResponse, 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 - * @param error Captured middleware error - */ - shouldHandleError?(this: void, error: MiddlewareError): boolean; -} diff --git a/packages/core/src/server-exports.ts b/packages/core/src/server-exports.ts index 5037dc9cb784..e6d8e95bd68e 100644 --- a/packages/core/src/server-exports.ts +++ b/packages/core/src/server-exports.ts @@ -22,7 +22,6 @@ export { safeUnref as _INTERNAL_safeUnref } from './utils/timer'; export { patchExpressModule } from './integrations/express/index'; export type { ExpressIntegrationOptions, - ExpressHandlerOptions, ExpressMiddleware, ExpressErrorMiddleware, } from './integrations/express/types'; diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 88af16be373e..23ca1098ef84 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -53,7 +53,7 @@ export { vercelAIIntegration } from './integrations/vercel-ai'; export { expressIntegration } from './integrations/express'; /* oxlint-disable typescript/no-deprecated -- deprecated Express error-handler exports, kept until the next major */ export { expressErrorHandler, setupExpressErrorHandler } from './integrations/express/error-handler'; -export type { ExpressHandlerOptions } from './integrations/express/types'; +export type { ExpressIntegrationOptions } from './integrations/express/types'; /* oxlint-enable typescript/no-deprecated */ export { firebaseIntegration } from './integrations/firebase'; diff --git a/packages/server-utils/src/integrations/express/error-handler.ts b/packages/server-utils/src/integrations/express/error-handler.ts index b081bd207402..887f9328c647 100644 --- a/packages/server-utils/src/integrations/express/error-handler.ts +++ b/packages/server-utils/src/integrations/express/error-handler.ts @@ -1,7 +1,7 @@ -import { captureException, getIsolationScope, httpRequestToRequestData } from '@sentry/core'; +import { captureException, getClient, getIsolationScope, httpRequestToRequestData } from '@sentry/core'; import { isExpressErrorHandled } from './error-handled'; -import type { ExpressHandlerOptions, ExpressRequest, ExpressResponse, MiddlewareError } from './types'; -import { defaultShouldHandleError } from './utils'; +import type { ExpressIntegration, ExpressRequest, ExpressResponse, MiddlewareError } from './types'; +import { INTEGRATION_NAME, shouldCaptureError } from './utils'; type ExpressErrorMiddleware = ( error: MiddlewareError, @@ -12,6 +12,10 @@ type ExpressErrorMiddleware = ( type ExpressMiddleware = (request: ExpressRequest, res: ExpressResponse, next: () => void) => void; +function getExpressIntegration(): ExpressIntegration | undefined { + return getClient()?.getIntegrationByName(INTEGRATION_NAME); +} + /** * Set request data on the isolation scope so a captured error carries request context. Mirrors the * request handler middleware, which does not run once an error short-circuits the middleware chain. @@ -30,7 +34,7 @@ function setSDKProcessingMetadata(request: ExpressRequest): void { * @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 { +export function expressErrorHandler(): ExpressErrorMiddleware { return function sentryErrorMiddleware(error, request, res, next): void { // When an error happens, the request handler middleware does not run, so we set it here too. setSDKProcessingMetadata(request); @@ -45,9 +49,9 @@ export function expressErrorHandler(options?: ExpressHandlerOptions): ExpressErr return; } - const shouldHandleError = options?.shouldHandleError || defaultShouldHandleError; - - if (shouldHandleError(error)) { + // `shouldHandleError` is configured on `expressIntegration()` only. Without the integration + // registered, the default predicate applies. + if (shouldCaptureError(getExpressIntegration()?.getShouldHandleError(), error)) { const eventId = captureException(error, { mechanism: { type: 'auto.middleware.express', handled: false }, }); @@ -71,20 +75,16 @@ function expressRequestHandler(): ExpressMiddleware { * The error handler must be before any other middleware and after all controllers. * * @param app The Express instance - * @param options {ExpressHandlerOptions} Configuration options for the handler * * @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: { - // oxlint-disable-next-line no-explicit-any - use: (middleware: any) => unknown; - }, - options?: ExpressHandlerOptions, -): void { +export function setupExpressErrorHandler(app: { + // oxlint-disable-next-line no-explicit-any + use: (middleware: any) => unknown; +}): void { app.use(expressRequestHandler()); // oxlint-disable-next-line typescript/no-deprecated - app.use(expressErrorHandler(options)); + app.use(expressErrorHandler()); } diff --git a/packages/server-utils/src/integrations/express/index.ts b/packages/server-utils/src/integrations/express/index.ts index 52598014408d..b01d24b40a2e 100644 --- a/packages/server-utils/src/integrations/express/index.ts +++ b/packages/server-utils/src/integrations/express/index.ts @@ -3,12 +3,9 @@ import type { IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import { expressModuleNames } from '../../orchestrion/config/express'; import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; -import type { ExpressIntegrationOptions } from './types'; +import type { ExpressIntegration, ExpressIntegrationOptions } from './types'; import { instrumentExpress } from './instrumentation'; - -// NOTE: this uses the same name as the OTel integration by design. -// When enabled, the OTel 'Express' integration is omitted from the default set. -const INTEGRATION_NAME = 'Express' as const; +import { INTEGRATION_NAME } from './utils'; const _expressIntegration = ((options: ExpressIntegrationOptions = {}) => { return { @@ -19,7 +16,12 @@ const _expressIntegration = ((options: ExpressIntegrationOptions = {}) => { diagnosticsChannel.tracingChannel, ]); }, - }; + // Read by the deprecated `expressErrorHandler`, which captures only when this integration + // could not (no orchestrion transform), so both paths use the same callback. + getShouldHandleError() { + return options.shouldHandleError; + }, + } satisfies ExpressIntegration; }) satisfies IntegrationFn; /** diff --git a/packages/server-utils/src/integrations/express/instrumentation.ts b/packages/server-utils/src/integrations/express/instrumentation.ts index 59b7f7fdfb98..59aadc6023e0 100644 --- a/packages/server-utils/src/integrations/express/instrumentation.ts +++ b/packages/server-utils/src/integrations/express/instrumentation.ts @@ -39,7 +39,7 @@ import type { MiddlewareError, RegistrationChannelContext, } from './types'; -import { defaultShouldHandleError } from './utils'; +import { shouldCaptureError } from './utils'; import { isExpressErrorHandled, markExpressErrorHandled } from './error-handled'; import { setHttpServerSpanRouteAttribute } from '../../utils/setHttpServerSpanRouteAttribute'; @@ -150,11 +150,7 @@ export function captureLayerError( markExpressErrorHandled(request); } - if (shouldHandleError === false) { - return; - } - - if (!(shouldHandleError ?? defaultShouldHandleError)(error as MiddlewareError)) { + if (!shouldCaptureError(shouldHandleError, error as MiddlewareError)) { return; } diff --git a/packages/server-utils/src/integrations/express/types.ts b/packages/server-utils/src/integrations/express/types.ts index c5c038ba243f..824a522be61a 100644 --- a/packages/server-utils/src/integrations/express/types.ts +++ b/packages/server-utils/src/integrations/express/types.ts @@ -1,4 +1,4 @@ -import type { Span } from '@sentry/core'; +import type { Integration, Span } from '@sentry/core'; export type ExpressLayerType = 'router' | 'middleware' | 'request_handler'; @@ -73,12 +73,6 @@ export interface MiddlewareError extends Error { /** Callback deciding whether an error should be captured; `false` disables capture entirely. */ export type ExpressShouldHandleError = ((error: MiddlewareError) => boolean) | false; -/** Options for the deprecated `setupExpressErrorHandler` / `expressErrorHandler`. */ -export interface ExpressHandlerOptions { - /** Callback deciding whether an error should be captured and sent to Sentry. */ - shouldHandleError?: (error: MiddlewareError) => boolean; -} - type IgnoreMatcher = string | RegExp | ((name: string) => boolean); export interface ExpressIntegrationOptions { /** Ignore specific based on their name */ @@ -113,3 +107,7 @@ export interface ExpressIntegrationOptions { */ shouldHandleError?: ExpressShouldHandleError; } + +export interface ExpressIntegration extends Integration { + getShouldHandleError: () => ExpressShouldHandleError | undefined; +} diff --git a/packages/server-utils/src/integrations/express/utils.ts b/packages/server-utils/src/integrations/express/utils.ts index a76d0b4abf61..fdc9a4ee8af5 100644 --- a/packages/server-utils/src/integrations/express/utils.ts +++ b/packages/server-utils/src/integrations/express/utils.ts @@ -1,4 +1,8 @@ -import type { MiddlewareError } from './types'; +import type { ExpressShouldHandleError, MiddlewareError } from './types'; + +// NOTE: this uses the same name as the OTel integration by design. +// When enabled, the OTel 'Express' integration is omitted from the default set. +export const INTEGRATION_NAME = 'Express' as const; function getStatusCodeFromResponse(error: MiddlewareError): number { const statusCode = error.status || error.statusCode || error.status_code || error.output?.statusCode; @@ -13,3 +17,19 @@ function getStatusCodeFromResponse(error: MiddlewareError): number { export function defaultShouldHandleError(error: MiddlewareError): boolean { return getStatusCodeFromResponse(error) >= 500; } + +/** + * Apply the configured `shouldHandleError`: `false` turns capture off entirely, a function replaces + * the gate, and `undefined` falls back to {@link defaultShouldHandleError}. Both the integration and + * the deprecated middleware go through here, so they always agree. + */ +export function shouldCaptureError( + shouldHandleError: ExpressShouldHandleError | undefined, + error: MiddlewareError, +): boolean { + if (shouldHandleError === false) { + return false; + } + + return (shouldHandleError ?? defaultShouldHandleError)(error); +} diff --git a/packages/server-utils/test/integrations/express-error-handler.test.ts b/packages/server-utils/test/integrations/express-error-handler.test.ts index e64a847d4867..0f7d729ae122 100644 --- a/packages/server-utils/test/integrations/express-error-handler.test.ts +++ b/packages/server-utils/test/integrations/express-error-handler.test.ts @@ -1,12 +1,33 @@ import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { isExpressErrorHandled } from '../../src/integrations/express/error-handled'; +// oxlint-disable-next-line typescript/no-deprecated +import { expressErrorHandler } from '../../src/integrations/express/error-handler'; import { captureLayerError } from '../../src/integrations/express/instrumentation'; -import type { HandleChannelContext } from '../../src/integrations/express/types'; +import type { + ExpressRequest, + ExpressResponse, + ExpressShouldHandleError, + HandleChannelContext, +} from '../../src/integrations/express/types'; function makeErrorData(error: unknown, span?: unknown): HandleChannelContext { return { error, _sentrySpan: span } as unknown as HandleChannelContext; } +/** Express hands every layer `[req, res, next]`. Sentry reads the request from there and marks it as handled. */ +function makeLayerErrorData(error: unknown, request: ExpressRequest, span?: unknown): HandleChannelContext { + return { error, _sentrySpan: span, arguments: [request] } as unknown as HandleChannelContext; +} + +function makeRequest(): ExpressRequest { + return { + method: 'GET', + originalUrl: '/users/42?include=profile', + headers: { host: 'api.example.com', 'user-agent': 'vitest' }, + } as unknown as ExpressRequest; +} + describe('captureLayerError', () => { let captureExceptionSpy: MockInstance; @@ -110,4 +131,195 @@ describe('captureLayerError', () => { expect(withActiveSpanSpy).not.toHaveBeenCalled(); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); }); + + describe('per-request dedup marker', () => { + it('captures once when the same error bubbles through several layers', () => { + const request = makeRequest(); + const error = Object.assign(new Error('boom'), { statusCode: 500 }); + + captureLayerError(makeLayerErrorData(error, request), undefined); + captureLayerError(makeLayerErrorData(error, request), undefined); + captureLayerError(makeLayerErrorData(error, request), undefined); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + }); + + it('marks the request when the error is skipped, so the deprecated middleware defers', () => { + const request = makeRequest(); + const error = Object.assign(new Error('bad request'), { statusCode: 400 }); + + captureLayerError(makeLayerErrorData(error, request), undefined); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(isExpressErrorHandled(request)).toBe(true); + }); + + it('marks the request when shouldHandleError is false', () => { + const request = makeRequest(); + const error = Object.assign(new Error('boom'), { statusCode: 500 }); + + captureLayerError(makeLayerErrorData(error, request), false); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(isExpressErrorHandled(request)).toBe(true); + }); + + it('leaves the request unmarked when there is no error', () => { + const request = makeRequest(); + + captureLayerError(makeLayerErrorData(undefined, request), undefined); + + expect(isExpressErrorHandled(request)).toBe(false); + }); + + // No request means nothing to mark, so every layer captures again. Express always passes one, so this cannot happen in practice. + it('captures on every layer when Express passes no request', () => { + const error = Object.assign(new Error('boom'), { statusCode: 500 }); + + captureLayerError(makeErrorData(error), undefined); + captureLayerError(makeErrorData(error), undefined); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(2); + }); + + // TODO: Sentry marks the request, not the error, so only the first error per request is captured. Later errors on the same request are lost. + it.fails('captures a second, distinct error raised on the same request', () => { + const request = makeRequest(); + const firstError = Object.assign(new Error('first failure'), { statusCode: 400 }); + const secondError = Object.assign(new Error('second failure'), { statusCode: 500 }); + + captureLayerError(makeLayerErrorData(firstError, request), undefined); + captureLayerError(makeLayerErrorData(secondError, request), undefined); + + expect(captureExceptionSpy).toHaveBeenCalledWith(secondError, { + mechanism: { type: 'auto.http.express', handled: false }, + }); + }); + }); +}); + +describe('expressErrorHandler', () => { + let captureExceptionSpy: MockInstance; + + beforeEach(() => { + captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => 'event-id'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function makeResponse(): ExpressResponse & { sentry?: string } { + return { once: () => undefined, removeListener: () => undefined } as unknown as ExpressResponse & { + sentry?: string; + }; + } + + /** Registers an `expressIntegration()` carrying the given `shouldHandleError`. */ + function registerIntegration(shouldHandleError: ExpressShouldHandleError | undefined): void { + const integration = { name: 'Express', getShouldHandleError: () => shouldHandleError }; + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getIntegrationByName: (name: string) => (name === 'Express' ? integration : undefined), + } as unknown as ReturnType); + } + + // A request whose error the integration already captured, before this middleware runs. + function makeHandledRequest(): ExpressRequest { + const request = makeRequest(); + captureLayerError(makeLayerErrorData(new Error('captured by the integration'), request), undefined); + return request; + } + + it('captures a 5xx error and exposes the event id on the response', () => { + const res = makeResponse(); + const error = Object.assign(new Error('boom'), { statusCode: 500 }); + + expressErrorHandler()(error, makeRequest(), res, vi.fn()); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.middleware.express', handled: false }, + }); + expect(res.sentry).toBe('event-id'); + }); + + it('captures a 4xx error when expressIntegration widens the gate', () => { + registerIntegration(error => (error.statusCode as number) >= 400); + const error = Object.assign(new Error('teapot'), { statusCode: 418 }); + + expressErrorHandler()(error, makeRequest(), makeResponse(), vi.fn()); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.middleware.express', handled: false }, + }); + }); + + it('skips a 5xx error when expressIntegration narrows the gate', () => { + registerIntegration(() => false); + const error = Object.assign(new Error('boom'), { statusCode: 500 }); + + expressErrorHandler()(error, makeRequest(), makeResponse(), vi.fn()); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('captures nothing when expressIntegration sets shouldHandleError to false', () => { + registerIntegration(false); + const error = Object.assign(new Error('boom'), { statusCode: 500 }); + + expressErrorHandler()(error, makeRequest(), makeResponse(), vi.fn()); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('does not capture a 4xx error', () => { + const res = makeResponse(); + const error = Object.assign(new Error('bad request'), { statusCode: 400 }); + + expressErrorHandler()(error, makeRequest(), res, vi.fn()); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(res.sentry).toBeUndefined(); + }); + + it.each([ + ['captured', 500], + ['skipped', 400], + ])('forwards the error to next when %s', (_case, statusCode) => { + const next = vi.fn(); + const error = Object.assign(new Error('boom'), { statusCode }); + + expressErrorHandler()(error, makeRequest(), makeResponse(), next); + + expect(next).toHaveBeenCalledExactlyOnceWith(error); + }); + + it('defers to the integration once the request is marked', () => { + const request = makeHandledRequest(); + captureExceptionSpy.mockClear(); + const error = Object.assign(new Error('boom'), { statusCode: 500 }); + + expressErrorHandler()(error, request, makeResponse(), vi.fn()); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('forwards the error to next even when it defers', () => { + const next = vi.fn(); + const error = Object.assign(new Error('boom'), { statusCode: 500 }); + + expressErrorHandler()(error, makeHandledRequest(), makeResponse(), next); + + expect(next).toHaveBeenCalledExactlyOnceWith(error); + }); + + // TODO: `res.sentry` carries the captured event id, but only this middleware sets it. + // Once the integration captures first, apps reading `res.sentry` get undefined instead of the id. + it.fails('exposes the event id on the response when the integration captured the error', () => { + const res = makeResponse(); + const error = Object.assign(new Error('boom'), { statusCode: 500 }); + + expressErrorHandler()(error, makeHandledRequest(), res, vi.fn()); + + expect(res.sentry).toBe('event-id'); + }); });