Skip to content

Commit 453d1c4

Browse files
committed
Do not capture ErrorResponses in ErrorBoundary
1 parent 1b5f701 commit 453d1c4

File tree

9 files changed

+141
-91
lines changed

9 files changed

+141
-91
lines changed

packages/remix/src/client/errors.tsx

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: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
dynamicSamplingContextToSentryBaggageHeader,
99
fill,
1010
isNodeEnv,
11+
isPrimitive,
1112
loadModule,
1213
logger,
1314
objectify,
@@ -80,15 +81,16 @@ async function extractResponseError(response: Response): Promise<unknown> {
8081
* export const handleError = Sentry.wrapRemixHandleError
8182
*/
8283
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
84+
// We are skipping thrown responses here as they are handled by
85+
// `captureRemixServerException` at loader / action level
8686
// We don't want to capture them twice.
87+
// This function if only for capturing unhandled server-side exceptions.
8788
// https://remix.run/docs/en/main/file-conventions/entry.server#thrown-responses
8889
// https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders
8990
if (isResponse(err) || isRouteErrorResponse(err)) {
9091
return;
9192
}
93+
9294
void captureRemixServerException(err, 'remix.server.handleError', request);
9395
}
9496

@@ -162,7 +164,7 @@ export async function captureRemixServerException(err: unknown, name: string, re
162164
});
163165
}
164166

165-
function makeWrappedDocumentRequestFunction(remixVersion: number) {
167+
function makeWrappedDocumentRequestFunction(remixVersion?: number) {
166168
return function (origDocumentRequestFunction: HandleDocumentRequestFunction): HandleDocumentRequestFunction {
167169
return async function (
168170
this: unknown,
@@ -172,28 +174,42 @@ function makeWrappedDocumentRequestFunction(remixVersion: number) {
172174
context: EntryContext,
173175
loadContext?: Record<string, unknown>,
174176
): Promise<Response> {
177+
let res: Response;
175178
const activeTransaction = getActiveTransaction();
176179

177-
const span = activeTransaction?.startChild({
178-
op: 'function.remix.document_request',
179-
origin: 'auto.function.remix',
180-
description: activeTransaction.name,
181-
tags: {
182-
method: request.method,
183-
url: request.url,
184-
},
185-
});
186-
187-
const res = await origDocumentRequestFunction.call(
188-
this,
189-
request,
190-
responseStatusCode,
191-
responseHeaders,
192-
context,
193-
loadContext,
194-
);
180+
try {
181+
const span = activeTransaction?.startChild({
182+
op: 'function.remix.document_request',
183+
origin: 'auto.function.remix',
184+
description: activeTransaction.name,
185+
tags: {
186+
method: request.method,
187+
url: request.url,
188+
},
189+
});
190+
191+
res = await origDocumentRequestFunction.call(
192+
this,
193+
request,
194+
responseStatusCode,
195+
responseHeaders,
196+
context,
197+
loadContext,
198+
);
199+
200+
span?.finish();
201+
} catch (err) {
202+
const isRemixV1 = !FUTURE_FLAGS?.v2_errorBoundary && remixVersion !== 2;
203+
204+
// This exists to capture the server-side rendering errors on Remix v1
205+
// On Remix v2, we capture SSR errors at `handleError`
206+
// We also skip primitives here, as we can't dedupe them, and also we don't expect any primitive SSR errors.
207+
if (isRemixV1 && !isPrimitive(err)) {
208+
await captureRemixServerException(err, 'documentRequest', request);
209+
}
195210

196-
span?.finish();
211+
throw err;
212+
}
197213

198214
return res;
199215
};
@@ -231,7 +247,12 @@ function makeWrappedDataFunction(
231247
currentScope.setSpan(activeTransaction);
232248
span?.finish();
233249
} catch (err) {
234-
if (!FUTURE_FLAGS?.v2_errorBoundary && remixVersion !== 2) {
250+
const isRemixV2 = FUTURE_FLAGS?.v2_errorBoundary || remixVersion === 2;
251+
252+
// On Remix v2, we capture all unexpected errors (except the `Route Error Response`s / Thrown Responses) in `handleError` function.
253+
// This is both for consistency and also avoid duplicates such as primitives like `string` or `number` being captured twice.
254+
// Remix v1 does not have a `handleError` function, so we capture all errors here.
255+
if (isRemixV2 ? isResponse(err) : true) {
235256
await captureRemixServerException(err, name, args.request);
236257
}
237258

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+
const useV2 = process.env.REMIX_VERSION === '2';
4+
5+
describe('Server Side Rendering', () => {
6+
it('correctly reports a server side rendering error', async () => {
7+
const env = await RemixTestEnv.init('builtin');
8+
const url = `${env.url}/ssr-error`;
9+
const envelopes = await env.getMultipleEnvelopeRequest({ url, count: 2, envelopeType: ['transaction', 'event'] });
10+
const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction');
11+
const [event] = envelopes.filter(envelope => envelope[1].type === 'event');
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: useV2 ? 'remix.server.handleError' : 'documentRequest',
37+
},
38+
handled: false,
39+
type: 'instrument',
40+
},
41+
},
42+
],
43+
},
44+
});
45+
});
46+
});

0 commit comments

Comments
 (0)