Skip to content

Commit 2c3066e

Browse files
authored
feat(remix): Add Remix v2 support (#8415)
Adds support for new error handling utilities of Remix v2. ([ErrorBoundary](https://remix.run/docs/en/main/route/error-boundary-v2), [handleError](https://github.com/remix-run/remix/releases/tag/remix%401.17.0)) ## `ErrorBoundary` v2 Remix's `ErrorBoundary` captures all client / server / SSR errors and shows a customizable error page. In v1, to capture client-side errors we were wrapping the whole Remix application with `@sentry/react`s `ErrorBoundary` which caused inconsistencies in error pages. (See: #5762) v2 implementation does not wrap user's application with `@sentry/react`'s ErrorBoundary, instead it exports a capturing utility to be used inside the Remix application's `ErrorBoundary` function. Can be used like: ```typescript import { captureRemixErrorBoundaryError } from '@sentry/remix'; export const ErrorBoundary: V2_ErrorBoundaryComponent = () => { const error = useRouteError(); captureRemixErrorBoundaryError(error); return <div> ... </div>; }; ``` It also requires `v2_errorBoundary` [future flag](https://remix.run/docs/en/1.18.0/pages/api-development-strategy#current-future-flags) to be enabled. ## `handleError` For server-side errors apart from 'Error Responses' (thrown responses are handled in `ErrorBoundary`), this implementation exports another utility to be used in `handleError` function. The errors we capture in `handleError` also appear on `ErrorBoundary` functions but stacktraces are not available. So, we skip those errors in `captureRemixErrorBoundaryError` function. `handleError` can be instrumented as below: ```typescript export function handleError(error: unknown, { request }: DataFunctionArgs): void { if (error instanceof Error) { Sentry.captureRemixServerException(error, 'remix.server', request); } else { // Optionally Sentry.captureException(error); } ```
1 parent 4ba98e2 commit 2c3066e

17 files changed

+225
-35
lines changed

packages/remix/src/client/errors.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { captureException, withScope } from '@sentry/core';
2+
import { addExceptionMechanism, isNodeEnv, isString } from '@sentry/utils';
3+
4+
import type { ErrorResponse } from '../utils/vendor/types';
5+
6+
/**
7+
* Checks whether the given error is an ErrorResponse.
8+
* ErrorResponse is when users throw a response from their loader or action functions.
9+
* This is in fact a server-side error that we capture on the client.
10+
*
11+
* @param error The error to check.
12+
* @returns boolean
13+
*/
14+
function isErrorResponse(error: unknown): error is ErrorResponse {
15+
return typeof error === 'object' && error !== null && 'status' in error && 'statusText' in error;
16+
}
17+
18+
/**
19+
* Captures an error that is thrown inside a Remix ErrorBoundary.
20+
*
21+
* @param error The error to capture.
22+
* @returns void
23+
*/
24+
export function captureRemixErrorBoundaryError(error: unknown): void {
25+
const isClientSideRuntimeError = !isNodeEnv() && error instanceof Error;
26+
const isRemixErrorResponse = isErrorResponse(error);
27+
// Server-side errors apart from `ErrorResponse`s also appear here without their stacktraces.
28+
// So, we only capture:
29+
// 1. `ErrorResponse`s
30+
// 2. Client-side runtime errors here,
31+
// And other server - side errors in `handleError` function where stacktraces are available.
32+
if (isRemixErrorResponse || isClientSideRuntimeError) {
33+
const eventData = isRemixErrorResponse
34+
? {
35+
function: 'ErrorResponse',
36+
...error.data,
37+
}
38+
: {
39+
function: 'ReactError',
40+
};
41+
42+
withScope(scope => {
43+
scope.addEventProcessor(event => {
44+
addExceptionMechanism(event, {
45+
type: 'instrument',
46+
handled: true,
47+
data: eventData,
48+
});
49+
return event;
50+
});
51+
52+
if (isRemixErrorResponse) {
53+
if (isString(error.data)) {
54+
captureException(error.data);
55+
} else if (error.statusText) {
56+
captureException(error.statusText);
57+
} else {
58+
captureException(error);
59+
}
60+
} else {
61+
captureException(error);
62+
}
63+
});
64+
}
65+
}

packages/remix/src/performance/client.tsx renamed to packages/remix/src/client/performance.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { Transaction, TransactionContext } from '@sentry/types';
44
import { isNodeEnv, logger } from '@sentry/utils';
55
import * as React from 'react';
66

7+
import { getFutureFlagsBrowser } from '../utils/futureFlags';
8+
79
const DEFAULT_TAGS = {
810
'routing.instrumentation': 'remix-router',
911
} as const;
@@ -93,7 +95,8 @@ export function withSentry<P extends Record<string, unknown>, R extends React.FC
9395
wrapWithErrorBoundary?: boolean;
9496
errorBoundaryOptions?: ErrorBoundaryProps;
9597
} = {
96-
wrapWithErrorBoundary: true,
98+
// We don't want to wrap application with Sentry's ErrorBoundary by default for Remix v2
99+
wrapWithErrorBoundary: getFutureFlagsBrowser()?.v2_errorBoundary ? false : true,
97100
errorBoundaryOptions: {},
98101
},
99102
): R {

packages/remix/src/index.client.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { configureScope, init as reactInit } from '@sentry/react';
33

44
import { buildMetadata } from './utils/metadata';
55
import type { RemixOptions } from './utils/remixOptions';
6-
export { remixRouterInstrumentation, withSentry } from './performance/client';
6+
export { remixRouterInstrumentation, withSentry } from './client/performance';
7+
export { captureRemixErrorBoundaryError } from './client/errors';
78
export * from '@sentry/react';
89

910
export function init(options: RemixOptions): void {

packages/remix/src/index.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,10 @@ export {
5454
// Keeping the `*` exports for backwards compatibility and types
5555
export * from '@sentry/node';
5656

57+
export { captureRemixServerException } from './utils/instrumentServer';
5758
export { ErrorBoundary, withErrorBoundary } from '@sentry/react';
58-
export { remixRouterInstrumentation, withSentry } from './performance/client';
59+
export { remixRouterInstrumentation, withSentry } from './client/performance';
60+
export { captureRemixErrorBoundaryError } from './client/errors';
5961
export { wrapExpressCreateRequestHandler } from './utils/serverAdapters/express';
6062

6163
function sdkAlreadyInitialized(): boolean {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { GLOBAL_OBJ } from '@sentry/utils';
2+
3+
import type { FutureConfig, ServerBuild } from './vendor/types';
4+
5+
export type EnhancedGlobal = typeof GLOBAL_OBJ & {
6+
__remixContext?: {
7+
future?: FutureConfig;
8+
};
9+
};
10+
11+
/**
12+
* Get the future flags from the Remix browser context
13+
*
14+
* @returns The future flags
15+
*/
16+
export function getFutureFlagsBrowser(): FutureConfig | undefined {
17+
const window = GLOBAL_OBJ as EnhancedGlobal;
18+
19+
if (!window.__remixContext) {
20+
return;
21+
}
22+
23+
return window.__remixContext.future;
24+
}
25+
26+
/**
27+
* Get the future flags from the Remix server build
28+
*
29+
* @param build The Remix server build
30+
*
31+
* @returns The future flags
32+
*/
33+
export function getFutureFlagsServer(build: ServerBuild): FutureConfig | undefined {
34+
return build.future;
35+
}

packages/remix/src/utils/instrumentServer.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,27 @@ import {
1313
tracingContextFromHeaders,
1414
} from '@sentry/utils';
1515

16+
import { getFutureFlagsServer } from './futureFlags';
17+
import { extractData, getRequestMatch, isDeferredData, isResponse, json, matchServerRoutes } from './vendor/response';
1618
import type {
1719
AppData,
1820
CreateRequestHandlerFunction,
1921
DataFunction,
2022
DataFunctionArgs,
2123
EntryContext,
24+
FutureConfig,
2225
HandleDocumentRequestFunction,
2326
ReactRouterDomPkg,
2427
RemixRequest,
2528
RequestHandler,
2629
ServerBuild,
2730
ServerRoute,
2831
ServerRouteManifest,
29-
} from './types';
30-
import { extractData, getRequestMatch, isDeferredData, isResponse, json, matchServerRoutes } from './vendor/response';
32+
} from './vendor/types';
3133
import { normalizeRemixRequest } from './web-fetch';
3234

35+
let FUTURE_FLAGS: FutureConfig | undefined;
36+
3337
// Flag to track if the core request handler is instrumented.
3438
export let isRequestHandlerWrapped = false;
3539

@@ -56,7 +60,16 @@ async function extractResponseError(response: Response): Promise<unknown> {
5660
return responseData;
5761
}
5862

59-
async function captureRemixServerException(err: unknown, name: string, request: Request): Promise<void> {
63+
/**
64+
* Captures an exception happened in the Remix server.
65+
*
66+
* @param err The error to capture.
67+
* @param name The name of the origin function.
68+
* @param request The request object.
69+
*
70+
* @returns A promise that resolves when the exception is captured.
71+
*/
72+
export async function captureRemixServerException(err: unknown, name: string, request: Request): Promise<void> {
6073
// Skip capturing if the thrown error is not a 5xx response
6174
// https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders
6275
if (isResponse(err) && err.status < 500) {
@@ -145,7 +158,10 @@ function makeWrappedDocumentRequestFunction(
145158

146159
span?.finish();
147160
} catch (err) {
148-
await captureRemixServerException(err, 'documentRequest', request);
161+
if (!FUTURE_FLAGS?.v2_errorBoundary) {
162+
await captureRemixServerException(err, 'documentRequest', request);
163+
}
164+
149165
throw err;
150166
}
151167

@@ -182,7 +198,10 @@ function makeWrappedDataFunction(origFn: DataFunction, id: string, name: 'action
182198
currentScope.setSpan(activeTransaction);
183199
span?.finish();
184200
} catch (err) {
185-
await captureRemixServerException(err, name, args.request);
201+
if (!FUTURE_FLAGS?.v2_errorBoundary) {
202+
await captureRemixServerException(err, name, args.request);
203+
}
204+
186205
throw err;
187206
}
188207

@@ -439,6 +458,7 @@ function makeWrappedCreateRequestHandler(
439458
isRequestHandlerWrapped = true;
440459

441460
return function (this: unknown, build: ServerBuild, ...args: unknown[]): RequestHandler {
461+
FUTURE_FLAGS = getFutureFlagsServer(build);
442462
const newBuild = instrumentBuild(build);
443463
const requestHandler = origCreateRequestHandler.call(this, newBuild, ...args);
444464

packages/remix/src/utils/serverAdapters/express.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type {
2020
ExpressResponse,
2121
ReactRouterDomPkg,
2222
ServerBuild,
23-
} from '../types';
23+
} from '../vendor/types';
2424

2525
let pkg: ReactRouterDomPkg;
2626

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
//
77
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
88

9-
import type { DeferredData, ReactRouterDomPkg, RouteMatch, ServerRoute } from '../types';
9+
import type { DeferredData, ReactRouterDomPkg, RouteMatch, ServerRoute } from './types';
1010

1111
/**
1212
* Based on Remix Implementation

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,42 @@ import type * as Express from 'express';
1414
import type { Agent } from 'https';
1515
import type { ComponentType } from 'react';
1616

17+
type Dev = {
18+
command?: string;
19+
scheme?: string;
20+
host?: string;
21+
port?: number;
22+
restart?: boolean;
23+
tlsKey?: string;
24+
tlsCert?: string;
25+
};
26+
27+
export interface FutureConfig {
28+
unstable_dev: boolean | Dev;
29+
/** @deprecated Use the `postcss` config option instead */
30+
unstable_postcss: boolean;
31+
/** @deprecated Use the `tailwind` config option instead */
32+
unstable_tailwind: boolean;
33+
v2_errorBoundary: boolean;
34+
v2_headers: boolean;
35+
v2_meta: boolean;
36+
v2_normalizeFormMethod: boolean;
37+
v2_routeConvention: boolean;
38+
}
39+
40+
export interface RemixConfig {
41+
[key: string]: any;
42+
future: FutureConfig;
43+
}
44+
45+
export interface ErrorResponse {
46+
status: number;
47+
statusText: string;
48+
data: any;
49+
error?: Error;
50+
internal: boolean;
51+
}
52+
1753
export type RemixRequestState = {
1854
method: string;
1955
redirect: RequestRedirect;
@@ -133,6 +169,7 @@ export interface ServerBuild {
133169
assets: AssetsManifest;
134170
publicPath?: string;
135171
assetsBuildDirectory?: string;
172+
future?: FutureConfig;
136173
}
137174

138175
export interface HandleDocumentRequestFunction {

packages/remix/src/utils/web-fetch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424

2525
import { logger } from '@sentry/utils';
2626

27-
import type { RemixRequest } from './types';
2827
import { getClientIPAddress } from './vendor/getIpAddress';
28+
import type { RemixRequest } from './vendor/types';
2929

3030
/*
3131
* Symbol extractor utility to be able to access internal fields of Remix requests.

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { EntryContext } from '@remix-run/node';
1+
import type { EntryContext, DataFunctionArgs } from '@remix-run/node';
22
import { RemixServer } from '@remix-run/react';
33
import { renderToString } from 'react-dom/server';
44
import * as Sentry from '@sentry/remix';
@@ -11,6 +11,14 @@ Sentry.init({
1111
autoSessionTracking: false,
1212
});
1313

14+
export function handleError(error: unknown, { request }: DataFunctionArgs): void {
15+
if (error instanceof Error) {
16+
Sentry.captureRemixServerException(error, 'remix.server', request);
17+
} else {
18+
Sentry.captureException(error);
19+
}
20+
}
21+
1422
export default function handleRequest(
1523
request: Request,
1624
responseStatusCode: number,

packages/remix/test/integration/app_v2/root.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { V2_MetaFunction, LoaderFunction, json, defer, redirect } from '@remix-run/node';
2-
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';
3-
import { withSentry } from '@sentry/remix';
2+
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, useRouteError } from '@remix-run/react';
3+
import { V2_ErrorBoundaryComponent } from '@remix-run/react/dist/routeModules';
4+
import { captureRemixErrorBoundaryError, withSentry } from '@sentry/remix';
5+
6+
export const ErrorBoundary: V2_ErrorBoundaryComponent = () => {
7+
const error = useRouteError();
8+
9+
captureRemixErrorBoundaryError(error);
10+
11+
return <div>error</div>;
12+
};
413

514
export const meta: V2_MetaFunction = ({ data }) => [
615
{ charset: 'utf-8' },

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { useActionData } from '@remix-run/react';
33

44
export const loader: LoaderFunction = async ({ params: { id } }) => {
55
if (id === '-1') {
6-
throw new Error('Unexpected Server Error from Loader');
6+
throw new Error('Unexpected Server Error');
77
}
8+
9+
return null;
810
};
911

1012
export const action: ActionFunction = async ({ params: { id } }) => {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ type LoaderData = { id: string };
55

66
export const loader: LoaderFunction = async ({ params: { id } }) => {
77
if (id === '-2') {
8-
throw new Error('Unexpected Server Error from Loader');
8+
throw new Error('Unexpected Server Error');
99
}
1010

1111
if (id === '-1') {

packages/remix/test/integration/test/client/errorboundary.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,20 @@ test('should capture React component errors.', async ({ page }) => {
2121
expect(errorEnvelope.level).toBe('error');
2222
expect(errorEnvelope.sdk?.name).toBe('sentry.javascript.remix');
2323
expect(errorEnvelope.exception?.values).toMatchObject([
24-
{
25-
type: 'React ErrorBoundary Error',
26-
value: 'Sentry React Component Error',
27-
stacktrace: { frames: expect.any(Array) },
28-
},
24+
...(!useV2
25+
? [
26+
{
27+
type: 'React ErrorBoundary Error',
28+
value: 'Sentry React Component Error',
29+
stacktrace: { frames: expect.any(Array) },
30+
},
31+
]
32+
: []),
2933
{
3034
type: 'Error',
3135
value: 'Sentry React Component Error',
3236
stacktrace: { frames: expect.any(Array) },
33-
mechanism: { type: 'generic', handled: true },
37+
mechanism: { type: useV2 ? 'instrument' : 'generic', handled: true },
3438
},
3539
]);
3640
});

0 commit comments

Comments
 (0)