Skip to content

Commit 1155a1b

Browse files
committed
Do not capture ErrorResponses in ErrorBoundary
1 parent 5f41dcb commit 1155a1b

File tree

9 files changed

+108
-73
lines changed

9 files changed

+108
-73
lines changed
Lines changed: 22 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { captureException } from '@sentry/core';
2-
import { isNodeEnv, isString } from '@sentry/utils';
2+
import { isNodeEnv } from '@sentry/utils';
33

4-
import { isRouteErrorResponse } from '../utils/vendor/response';
5-
import type { ErrorResponse } from '../utils/vendor/types';
4+
import { isResponse } from '../utils/vendor/response';
65

76
/**
87
* Captures an error that is thrown inside a Remix ErrorBoundary.
@@ -11,57 +10,26 @@ import type { ErrorResponse } from '../utils/vendor/types';
1110
* @returns void
1211
*/
1312
export function captureRemixErrorBoundaryError(error: unknown): string | undefined {
14-
let eventId: string | undefined;
15-
const isClientSideRuntimeError = !isNodeEnv() && error instanceof Error;
16-
// We only capture `ErrorResponse`s that are 5xx errors.
17-
const isRemixErrorResponse = isRouteErrorResponse(error) && error.status >= 500;
18-
// Server-side errors apart from `ErrorResponse`s also appear here without their stacktraces.
19-
// So, we only capture:
20-
// 1. `ErrorResponse`s
21-
// 2. Client-side runtime errors here,
22-
// And other server-side errors captured in `handleError` function where stacktraces are available.
23-
if (isRemixErrorResponse || isClientSideRuntimeError) {
24-
const eventData = isRemixErrorResponse
25-
? {
26-
function: 'ErrorResponse',
27-
...getErrorData(error),
28-
}
29-
: {
30-
function: 'ReactError',
31-
};
32-
33-
const actualError = isRemixErrorResponse ? getExceptionToCapture(error) : error;
34-
35-
eventId = captureException(actualError, {
36-
mechanism: {
37-
type: 'instrument',
38-
handled: false,
39-
data: eventData,
40-
},
41-
});
42-
}
43-
44-
return eventId;
45-
}
46-
47-
function getErrorData(error: ErrorResponse): object {
48-
if (isString(error.data)) {
49-
return {
50-
error: error.data,
51-
};
13+
// Server-side errors also appear here without their stacktraces.
14+
// So, we only capture client-side runtime errors here.
15+
// ErrorResponses that are 5xx errors captured at loader / action level by `captureRemixRouteError` function,
16+
// And other server-side errors captured in `handleError` function where stacktraces are available.
17+
//
18+
// We don't want to capture:
19+
// - Response Errors / Objects [They are originated and handled on the server-side]
20+
// - SSR Errors [They are originated and handled on the server-side]
21+
// - Anything without a stacktrace [Remix trims the stacktrace of the errors that are thrown on the server-side]
22+
if (isResponse(error) || isNodeEnv() || !(error instanceof Error)) {
23+
return;
5224
}
5325

54-
return error.data;
55-
}
56-
57-
function getExceptionToCapture(error: ErrorResponse): string | ErrorResponse {
58-
if (isString(error.data)) {
59-
return error.data;
60-
}
61-
62-
if (error.statusText) {
63-
return error.statusText;
64-
}
65-
66-
return error;
26+
return captureException(error, {
27+
mechanism: {
28+
type: 'instrument',
29+
handled: false,
30+
data: {
31+
function: 'ReactError',
32+
},
33+
},
34+
});
6735
}

packages/remix/src/utils/instrumentServer.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,16 @@ async function extractResponseError(response: Response): Promise<unknown> {
8080
* export const handleError = Sentry.wrapRemixHandleError
8181
*/
8282
export function wrapRemixHandleError(err: unknown, { request }: DataFunctionArgs): void {
83-
// We are skipping thrown responses here as they are either handled:
84-
// - Remix v1: by captureRemixServerException at loader / action
85-
// - Remix v2: by ErrorBoundary
83+
// We are skipping thrown responses here as they are handled by
84+
// `captureRemixServerException` at loader / action level
8685
// We don't want to capture them twice.
86+
// This function if only for capturing unhandled server-side exceptions.
8787
// https://remix.run/docs/en/main/file-conventions/entry.server#thrown-responses
8888
// https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders
8989
if (isResponse(err) || isRouteErrorResponse(err)) {
9090
return;
9191
}
92+
9293
void captureRemixServerException(err, 'remix.server.handleError', request);
9394
}
9495

@@ -162,7 +163,7 @@ export async function captureRemixServerException(err: unknown, name: string, re
162163
});
163164
}
164165

165-
function makeWrappedDocumentRequestFunction(remixVersion: number) {
166+
function makeWrappedDocumentRequestFunction() {
166167
return function (origDocumentRequestFunction: HandleDocumentRequestFunction): HandleDocumentRequestFunction {
167168
return async function (
168169
this: unknown,
@@ -231,7 +232,12 @@ function makeWrappedDataFunction(
231232
currentScope.setSpan(activeTransaction);
232233
span?.finish();
233234
} catch (err) {
234-
if (!FUTURE_FLAGS?.v2_errorBoundary && remixVersion !== 2) {
235+
const isRemixV2 = FUTURE_FLAGS?.v2_errorBoundary || remixVersion === 2;
236+
237+
// On Remix v2, we capture all unexpected errors (except the `Route Error Response`s / Thrown Responses) in `handleError` function.
238+
// This is both for consistency and also avoid duplicates such as primitives like `string` or `number` being captured twice.
239+
// Remix v1 does not have a `handleError` function, so we capture all errors here.
240+
if (isRemixV2 ? isResponse(err) : true) {
235241
await captureRemixServerException(err, name, args.request);
236242
}
237243

@@ -465,7 +471,7 @@ export function instrumentBuild(build: ServerBuild): ServerBuild {
465471
// We should be able to wrap them, as they may not be wrapped before.
466472
const defaultExport = wrappedEntry.module.default as undefined | WrappedFunction;
467473
if (defaultExport && !defaultExport.__sentry_original__) {
468-
fill(wrappedEntry.module, 'default', makeWrappedDocumentRequestFunction(remixVersion));
474+
fill(wrappedEntry.module, 'default', makeWrappedDocumentRequestFunction());
469475
}
470476

471477
for (const [id, route] of Object.entries(build.routes)) {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from '../../common/routes/ssr-error';
2+
export { default } from '../../common/routes/ssr-error';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from '../../common/routes/ssr-error';
2+
export { default } from '../../common/routes/ssr-error';
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function SSRError() {
2+
const data = ['err'].map(err => {
3+
throw new Error('Sentry SSR Test Error');
4+
});
5+
6+
return <div>{data}</div>;
7+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { expect, test } from '@playwright/test';
2+
import { countEnvelopes } from './utils/helpers';
3+
4+
test('should not report an SSR error on client side.', async ({ page }) => {
5+
const count = await countEnvelopes(page, { url: '/ssr-error', envelopeType: 'event' });
6+
7+
expect(count).toBe(0);
8+
});

packages/remix/test/integration/test/server/action.test.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
140140
});
141141
});
142142

143-
it('handles a thrown 500 response', async () => {
143+
it('handles an error-throwing redirection target', async () => {
144144
const env = await RemixTestEnv.init(adapter);
145145
const url = `${env.url}/action-json-response/-2`;
146146

@@ -254,7 +254,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
254254
stacktrace: expect.any(Object),
255255
mechanism: {
256256
data: {
257-
function: useV2 ? 'ErrorResponse' : 'action',
257+
function: 'action',
258258
},
259259
handled: false,
260260
type: 'instrument',
@@ -303,13 +303,11 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
303303
values: [
304304
{
305305
type: 'Error',
306-
value: useV2
307-
? 'Object captured as exception with keys: data, internal, status, statusText'
308-
: 'Object captured as exception with keys: data',
306+
value: 'Object captured as exception with keys: data',
309307
stacktrace: expect.any(Object),
310308
mechanism: {
311309
data: {
312-
function: useV2 ? 'ErrorResponse' : 'action',
310+
function: 'action',
313311
},
314312
handled: false,
315313
type: 'instrument',
@@ -362,7 +360,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
362360
stacktrace: expect.any(Object),
363361
mechanism: {
364362
data: {
365-
function: useV2 ? 'ErrorResponse' : 'action',
363+
function: 'action',
366364
},
367365
handled: false,
368366
type: 'instrument',
@@ -411,13 +409,11 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
411409
values: [
412410
{
413411
type: 'Error',
414-
value: useV2
415-
? 'Object captured as exception with keys: data, internal, status, statusText'
416-
: 'Object captured as exception with keys: [object has no keys]',
412+
value: 'Object captured as exception with keys: [object has no keys]',
417413
stacktrace: expect.any(Object),
418414
mechanism: {
419415
data: {
420-
function: useV2 ? 'ErrorResponse' : 'action',
416+
function: 'action',
421417
},
422418
handled: false,
423419
type: 'instrument',
@@ -428,7 +424,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada
428424
});
429425
});
430426

431-
it('handles thrown string from an action', async () => {
427+
it('handles thrown string (primitive) from an action', async () => {
432428
const env = await RemixTestEnv.init(adapter);
433429
const url = `${env.url}/server-side-unexpected-errors/-1`;
434430

packages/remix/test/integration/test/server/loader.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada
7878
});
7979
});
8080

81-
it('handles a thrown 500 response', async () => {
81+
it('handles an error-throwing redirection target', async () => {
8282
const env = await RemixTestEnv.init(adapter);
8383
const url = `${env.url}/loader-json-response/-1`;
8484

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { RemixTestEnv, assertSentryEvent, assertSentryTransaction } from './utils/helpers';
2+
3+
describe('Server Side Rendering', () => {
4+
it('correctly reports a server side rendering error', async () => {
5+
const env = await RemixTestEnv.init('builtin');
6+
const url = `${env.url}/ssr-error`;
7+
const envelopes = await env.getMultipleEnvelopeRequest({ url, count: 2, envelopeType: ['transaction', 'event'] });
8+
const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
9+
const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
10+
11+
console.debug('envelopes', envelopes);
12+
13+
assertSentryTransaction(transaction[2], {
14+
contexts: {
15+
trace: {
16+
status: 'internal_error',
17+
tags: {
18+
'http.status_code': '500',
19+
},
20+
data: {
21+
'http.response.status_code': 500,
22+
},
23+
},
24+
},
25+
});
26+
27+
assertSentryEvent(event[2], {
28+
exception: {
29+
values: [
30+
{
31+
type: 'Error',
32+
value: 'Sentry SSR Test Error',
33+
stacktrace: expect.any(Object),
34+
mechanism: {
35+
data: {
36+
function: 'remix.server.handleError',
37+
},
38+
handled: false,
39+
type: 'instrument',
40+
},
41+
},
42+
],
43+
},
44+
});
45+
});
46+
});

0 commit comments

Comments
 (0)