Skip to content

Commit 4044447

Browse files
authored
ref(remix): Rework Error Handling (#9725)
1 parent 8744898 commit 4044447

File tree

18 files changed

+305
-92
lines changed

18 files changed

+305
-92
lines changed

packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,7 @@ Sentry.init({
2424
tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
2525
});
2626

27-
export function handleError(error: unknown, { request }: DataFunctionArgs): void {
28-
Sentry.captureRemixServerException(error, 'remix.server', request);
29-
}
27+
export const handleError = Sentry.wrapRemixHandleError;
3028

3129
export default function handleRequest(
3230
request: Request,

packages/e2e-tests/test-applications/create-remix-app/app/entry.server.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ Sentry.init({
2121
tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
2222
});
2323

24-
export function handleError(error: unknown, { request }: DataFunctionArgs): void {
25-
Sentry.captureRemixServerException(error, 'remix.server', request);
26-
}
24+
export const handleError = Sentry.wrapRemixHandleError;
2725

2826
export default function handleRequest(
2927
request: Request,

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/index.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export {
5959
// Keeping the `*` exports for backwards compatibility and types
6060
export * from '@sentry/node';
6161

62-
export { captureRemixServerException } from './utils/instrumentServer';
62+
export { captureRemixServerException, wrapRemixHandleError } from './utils/instrumentServer';
6363
export { ErrorBoundary, withErrorBoundary } from '@sentry/react';
6464
export { remixRouterInstrumentation, withSentry } from './client/performance';
6565
export { captureRemixErrorBoundaryError } from './client/errors';

packages/remix/src/utils/instrumentServer.ts

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import {
88
dynamicSamplingContextToSentryBaggageHeader,
99
fill,
1010
isNodeEnv,
11+
isPrimitive,
1112
loadModule,
1213
logger,
14+
objectify,
1315
tracingContextFromHeaders,
1416
} from '@sentry/utils';
1517

@@ -70,6 +72,28 @@ async function extractResponseError(response: Response): Promise<unknown> {
7072
return responseData;
7173
}
7274

75+
/**
76+
* Sentry utility to be used in place of `handleError` function of Remix v2
77+
* Remix Docs: https://remix.run/docs/en/main/file-conventions/entry.server#handleerror
78+
*
79+
* Should be used in `entry.server` like:
80+
*
81+
* export const handleError = Sentry.wrapRemixHandleError
82+
*/
83+
export function wrapRemixHandleError(err: unknown, { request }: DataFunctionArgs): void {
84+
// We are skipping thrown responses here as they are handled by
85+
// `captureRemixServerException` at loader / action level
86+
// We don't want to capture them twice.
87+
// This function if only for capturing unhandled server-side exceptions.
88+
// https://remix.run/docs/en/main/file-conventions/entry.server#thrown-responses
89+
// https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders
90+
if (isResponse(err) || isRouteErrorResponse(err)) {
91+
return;
92+
}
93+
94+
void captureRemixServerException(err, 'remix.server.handleError', request);
95+
}
96+
7397
/**
7498
* Captures an exception happened in the Remix server.
7599
*
@@ -107,7 +131,9 @@ export async function captureRemixServerException(err: unknown, name: string, re
107131
DEBUG_BUILD && logger.warn('Failed to normalize Remix request');
108132
}
109133

110-
captureException(isResponse(err) ? await extractResponseError(err) : err, scope => {
134+
const objectifiedErr = objectify(err);
135+
136+
captureException(isResponse(objectifiedErr) ? await extractResponseError(objectifiedErr) : objectifiedErr, scope => {
111137
const activeTransactionName = getActiveTransaction()?.name;
112138

113139
scope.setSDKProcessingMetadata({
@@ -138,7 +164,7 @@ export async function captureRemixServerException(err: unknown, name: string, re
138164
});
139165
}
140166

141-
function makeWrappedDocumentRequestFunction(remixVersion: number) {
167+
function makeWrappedDocumentRequestFunction(remixVersion?: number) {
142168
return function (origDocumentRequestFunction: HandleDocumentRequestFunction): HandleDocumentRequestFunction {
143169
return async function (
144170
this: unknown,
@@ -149,7 +175,6 @@ function makeWrappedDocumentRequestFunction(remixVersion: number) {
149175
loadContext?: Record<string, unknown>,
150176
): Promise<Response> {
151177
let res: Response;
152-
153178
const activeTransaction = getActiveTransaction();
154179

155180
try {
@@ -174,7 +199,12 @@ function makeWrappedDocumentRequestFunction(remixVersion: number) {
174199

175200
span?.finish();
176201
} catch (err) {
177-
if (!FUTURE_FLAGS?.v2_errorBoundary && remixVersion !== 2) {
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)) {
178208
await captureRemixServerException(err, 'documentRequest', request);
179209
}
180210

@@ -217,7 +247,12 @@ function makeWrappedDataFunction(
217247
currentScope.setSpan(activeTransaction);
218248
span?.finish();
219249
} catch (err) {
220-
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) {
221256
await captureRemixServerException(err, name, args.request);
222257
}
223258

@@ -240,7 +275,10 @@ const makeWrappedLoader =
240275
return makeWrappedDataFunction(origLoader, id, 'loader', remixVersion);
241276
};
242277

243-
function getTraceAndBaggage(): { sentryTrace?: string; sentryBaggage?: string } {
278+
function getTraceAndBaggage(): {
279+
sentryTrace?: string;
280+
sentryBaggage?: string;
281+
} {
244282
const transaction = getActiveTransaction();
245283
const currentScope = getCurrentHub().getScope();
246284

@@ -287,7 +325,11 @@ function makeWrappedRootLoader(remixVersion: number) {
287325
if (typeof data === 'object') {
288326
return json(
289327
{ ...data, ...traceAndBaggage, remixVersion },
290-
{ headers: res.headers, statusText: res.statusText, status: res.status },
328+
{
329+
headers: res.headers,
330+
statusText: res.statusText,
331+
status: res.status,
332+
},
291333
);
292334
} else {
293335
DEBUG_BUILD && logger.warn('Skipping injection of trace and baggage as the response body is not an object');
@@ -498,7 +540,9 @@ function makeWrappedCreateRequestHandler(
498540
* which Remix Adapters (https://remix.run/docs/en/v1/api/remix) use underneath.
499541
*/
500542
export function instrumentServer(): void {
501-
const pkg = loadModule<{ createRequestHandler: CreateRequestHandlerFunction }>('@remix-run/server-runtime');
543+
const pkg = loadModule<{
544+
createRequestHandler: CreateRequestHandlerFunction;
545+
}>('@remix-run/server-runtime');
502546

503547
if (!pkg) {
504548
DEBUG_BUILD && logger.warn('Remix SDK was unable to require `@remix-run/server-runtime` package.');

packages/remix/src/utils/vendor/types.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,12 @@ export type DeferredData = {
108108
};
109109

110110
export interface MetaFunction {
111-
(args: { data: AppData; parentsData: RouteData; params: Params; location: Location }): HtmlMetaDescriptor;
111+
(args: {
112+
data: AppData;
113+
parentsData: RouteData;
114+
params: Params;
115+
location: Location;
116+
}): HtmlMetaDescriptor;
112117
}
113118

114119
export interface HtmlMetaDescriptor {
@@ -143,7 +148,11 @@ export interface LoaderFunction {
143148
}
144149

145150
export interface HeadersFunction {
146-
(args: { loaderHeaders: Headers; parentHeaders: Headers; actionHeaders: Headers }): Headers | HeadersInit;
151+
(args: {
152+
loaderHeaders: Headers;
153+
parentHeaders: Headers;
154+
actionHeaders: Headers;
155+
}): Headers | HeadersInit;
147156
}
148157

149158
export interface ServerRouteModule extends EntryRouteModule {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from '../../../common/routes/server-side-unexpected-errors.$id';
2+
export { default } from '../../../common/routes/server-side-unexpected-errors.$id';
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';

packages/remix/test/integration/app_v2/entry.server.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ Sentry.init({
1111
autoSessionTracking: false,
1212
});
1313

14-
export function handleError(error: unknown, { request }: DataFunctionArgs): void {
15-
Sentry.captureRemixServerException(error, 'remix.server', request);
16-
}
14+
export const handleError = Sentry.wrapRemixHandleError;
1715

1816
export default function handleRequest(
1917
request: Request,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from '../../common/routes/server-side-unexpected-errors.$id';
2+
export { default } from '../../common/routes/server-side-unexpected-errors.$id';
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';

packages/remix/test/integration/common/routes/action-json-response.$id.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ActionFunction, LoaderFunction, json, redirect } from '@remix-run/node'
22
import { useActionData } from '@remix-run/react';
33

44
export const loader: LoaderFunction = async ({ params: { id } }) => {
5-
if (id === '-1') {
5+
if (id === '-100') {
66
throw new Error('Unexpected Server Error');
77
}
88

@@ -16,7 +16,7 @@ export const action: ActionFunction = async ({ params: { id } }) => {
1616

1717
if (id === '-2') {
1818
// Note: This GET request triggers the `Loader` of the URL, not the `Action`.
19-
throw redirect('/action-json-response/-1');
19+
throw redirect('/action-json-response/-100');
2020
}
2121

2222
if (id === '-3') {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { ActionFunction, LoaderFunction, json, redirect } from '@remix-run/node';
2+
import { useActionData } from '@remix-run/react';
3+
4+
export const action: ActionFunction = async ({ params: { id } }) => {
5+
// Throw string
6+
if (id === '-1') {
7+
throw 'Thrown String Error';
8+
}
9+
10+
// Throw object
11+
if (id === '-2') {
12+
throw {
13+
message: 'Thrown Object Error',
14+
statusCode: 500,
15+
};
16+
}
17+
};
18+
19+
export default function ActionJSONResponse() {
20+
const data = useActionData();
21+
22+
return (
23+
<div>
24+
<h1>{data && data.test ? data.test : 'Not Found'}</h1>
25+
</div>
26+
);
27+
}
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+
});

0 commit comments

Comments
 (0)