Skip to content

Commit 189fc86

Browse files
authored
test(express): Error handler tests (#23724)
The `shouldHandleError`  option is going to be removed from `setupExpressErrorHandler`. This PR just adds some tests to split up the PR a bit. Two tests are marked `it.fails`. Both cover behaviour @isaacs raised on #23464. - The first shows the dedup marker is keyed on the request, not the error, so only the first error per request is captured. A 4xx that `shouldHandleError` skips still marks the request, so a later 500 is lost. - The second shows `res.sentry` is undefined whenever the integration captures first, because only the deprecated middleware sets it. re: #23464
1 parent cd362b1 commit 189fc86

1 file changed

Lines changed: 171 additions & 1 deletion

File tree

‎packages/server-utils/test/integrations/express-error-handler.test.ts‎

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
import * as SentryCore from '@sentry/core';
22
import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest';
3+
import { isExpressErrorHandled } from '../../src/integrations/express/error-handled';
4+
// oxlint-disable-next-line typescript/no-deprecated
5+
import { expressErrorHandler } from '../../src/integrations/express/error-handler';
36
import { captureLayerError } from '../../src/integrations/express/instrumentation';
4-
import type { HandleChannelContext } from '../../src/integrations/express/types';
7+
import type { ExpressRequest, ExpressResponse, HandleChannelContext } from '../../src/integrations/express/types';
58

69
function makeErrorData(error: unknown, span?: unknown): HandleChannelContext {
710
return { error, _sentrySpan: span } as unknown as HandleChannelContext;
811
}
912

13+
/** Express hands every layer `[req, res, next]`. Sentry reads the request from there and marks it as handled. */
14+
function makeLayerErrorData(error: unknown, request: ExpressRequest, span?: unknown): HandleChannelContext {
15+
return { error, _sentrySpan: span, arguments: [request] } as unknown as HandleChannelContext;
16+
}
17+
18+
function makeRequest(): ExpressRequest {
19+
return {
20+
method: 'GET',
21+
originalUrl: '/users/42?include=profile',
22+
headers: { host: 'api.example.com', 'user-agent': 'vitest' },
23+
} as unknown as ExpressRequest;
24+
}
25+
1026
describe('captureLayerError', () => {
1127
let captureExceptionSpy: MockInstance;
1228

@@ -110,4 +126,158 @@ describe('captureLayerError', () => {
110126
expect(withActiveSpanSpy).not.toHaveBeenCalled();
111127
expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
112128
});
129+
130+
describe('per-request dedup marker', () => {
131+
it('captures once when the same error bubbles through several layers', () => {
132+
const request = makeRequest();
133+
const error = Object.assign(new Error('boom'), { statusCode: 500 });
134+
135+
captureLayerError(makeLayerErrorData(error, request), undefined);
136+
captureLayerError(makeLayerErrorData(error, request), undefined);
137+
captureLayerError(makeLayerErrorData(error, request), undefined);
138+
139+
expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
140+
});
141+
142+
it('marks the request when the error is skipped, so the deprecated middleware defers', () => {
143+
const request = makeRequest();
144+
const error = Object.assign(new Error('bad request'), { statusCode: 400 });
145+
146+
captureLayerError(makeLayerErrorData(error, request), undefined);
147+
148+
expect(captureExceptionSpy).not.toHaveBeenCalled();
149+
expect(isExpressErrorHandled(request)).toBe(true);
150+
});
151+
152+
it('marks the request when shouldHandleError is false', () => {
153+
const request = makeRequest();
154+
const error = Object.assign(new Error('boom'), { statusCode: 500 });
155+
156+
captureLayerError(makeLayerErrorData(error, request), false);
157+
158+
expect(captureExceptionSpy).not.toHaveBeenCalled();
159+
expect(isExpressErrorHandled(request)).toBe(true);
160+
});
161+
162+
it('leaves the request unmarked when there is no error', () => {
163+
const request = makeRequest();
164+
165+
captureLayerError(makeLayerErrorData(undefined, request), undefined);
166+
167+
expect(isExpressErrorHandled(request)).toBe(false);
168+
});
169+
170+
// No request means nothing to mark, so every layer captures again. Express always passes one, so this cannot happen in practice.
171+
it('captures on every layer when Express passes no request', () => {
172+
const error = Object.assign(new Error('boom'), { statusCode: 500 });
173+
174+
captureLayerError(makeErrorData(error), undefined);
175+
captureLayerError(makeErrorData(error), undefined);
176+
177+
expect(captureExceptionSpy).toHaveBeenCalledTimes(2);
178+
});
179+
180+
// 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.
181+
it.fails('captures a second, distinct error raised on the same request', () => {
182+
const request = makeRequest();
183+
const firstError = Object.assign(new Error('first failure'), { statusCode: 400 });
184+
const secondError = Object.assign(new Error('second failure'), { statusCode: 500 });
185+
186+
captureLayerError(makeLayerErrorData(firstError, request), undefined);
187+
captureLayerError(makeLayerErrorData(secondError, request), undefined);
188+
189+
expect(captureExceptionSpy).toHaveBeenCalledWith(secondError, {
190+
mechanism: { type: 'auto.http.express', handled: false },
191+
});
192+
});
193+
});
194+
});
195+
196+
describe('expressErrorHandler', () => {
197+
let captureExceptionSpy: MockInstance;
198+
199+
beforeEach(() => {
200+
captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => 'event-id');
201+
});
202+
203+
afterEach(() => {
204+
vi.restoreAllMocks();
205+
});
206+
207+
function makeResponse(): ExpressResponse & { sentry?: string } {
208+
return { once: () => undefined, removeListener: () => undefined } as unknown as ExpressResponse & {
209+
sentry?: string;
210+
};
211+
}
212+
213+
// A request whose error the integration already captured, before this middleware runs.
214+
function makeHandledRequest(): ExpressRequest {
215+
const request = makeRequest();
216+
captureLayerError(makeLayerErrorData(new Error('captured by the integration'), request), undefined);
217+
return request;
218+
}
219+
220+
it('captures a 5xx error and exposes the event id on the response', () => {
221+
const res = makeResponse();
222+
const error = Object.assign(new Error('boom'), { statusCode: 500 });
223+
224+
expressErrorHandler()(error, makeRequest(), res, vi.fn());
225+
226+
expect(captureExceptionSpy).toHaveBeenCalledWith(error, {
227+
mechanism: { type: 'auto.middleware.express', handled: false },
228+
});
229+
expect(res.sentry).toBe('event-id');
230+
});
231+
232+
it('does not capture a 4xx error', () => {
233+
const res = makeResponse();
234+
const error = Object.assign(new Error('bad request'), { statusCode: 400 });
235+
236+
expressErrorHandler()(error, makeRequest(), res, vi.fn());
237+
238+
expect(captureExceptionSpy).not.toHaveBeenCalled();
239+
expect(res.sentry).toBeUndefined();
240+
});
241+
242+
it.each([
243+
['captured', 500],
244+
['skipped', 400],
245+
])('forwards the error to next when %s', (_case, statusCode) => {
246+
const next = vi.fn();
247+
const error = Object.assign(new Error('boom'), { statusCode });
248+
249+
expressErrorHandler()(error, makeRequest(), makeResponse(), next);
250+
251+
expect(next).toHaveBeenCalledExactlyOnceWith(error);
252+
});
253+
254+
it('defers to the integration once the request is marked', () => {
255+
const request = makeHandledRequest();
256+
captureExceptionSpy.mockClear();
257+
const error = Object.assign(new Error('boom'), { statusCode: 500 });
258+
259+
expressErrorHandler()(error, request, makeResponse(), vi.fn());
260+
261+
expect(captureExceptionSpy).not.toHaveBeenCalled();
262+
});
263+
264+
it('forwards the error to next even when it defers', () => {
265+
const next = vi.fn();
266+
const error = Object.assign(new Error('boom'), { statusCode: 500 });
267+
268+
expressErrorHandler()(error, makeHandledRequest(), makeResponse(), next);
269+
270+
expect(next).toHaveBeenCalledExactlyOnceWith(error);
271+
});
272+
273+
// TODO: `res.sentry` carries the captured event id, but only this middleware sets it.
274+
// Once the integration captures first, apps reading `res.sentry` get undefined instead of the id.
275+
it.fails('exposes the event id on the response when the integration captured the error', () => {
276+
const res = makeResponse();
277+
const error = Object.assign(new Error('boom'), { statusCode: 500 });
278+
279+
expressErrorHandler()(error, makeHandledRequest(), res, vi.fn());
280+
281+
expect(res.sentry).toBe('event-id');
282+
});
113283
});

0 commit comments

Comments
 (0)