Skip to content

Commit 8c7636e

Browse files
committed
feat(remix): Add v2 support.
1 parent 7de917e commit 8c7636e

File tree

14 files changed

+220
-30
lines changed

14 files changed

+220
-30
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/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
@@ -6,8 +6,10 @@ import { instrumentServer } from './utils/instrumentServer';
66
import { buildMetadata } from './utils/metadata';
77
import type { RemixOptions } from './utils/remixOptions';
88

9+
export { captureRemixServerException } from './utils/instrumentServer';
910
export { ErrorBoundary, withErrorBoundary } from '@sentry/react';
10-
export { remixRouterInstrumentation, withSentry } from './performance/client';
11+
export { remixRouterInstrumentation, withSentry } from './client/performance';
12+
export { captureRemixErrorBoundaryError } from './client/errors';
1113
export * from '@sentry/node';
1214
export { wrapExpressCreateRequestHandler } from './utils/serverAdapters/express';
1315

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 './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: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ import {
1414
logger,
1515
} from '@sentry/utils';
1616

17+
import { getFutureFlagsServer } from './futureFlags';
1718
import type {
1819
AppData,
1920
CreateRequestHandlerFunction,
2021
DataFunction,
2122
DataFunctionArgs,
2223
EntryContext,
24+
FutureConfig,
2325
HandleDocumentRequestFunction,
2426
ReactRouterDomPkg,
2527
RemixRequest,
@@ -31,6 +33,8 @@ import type {
3133
import { extractData, getRequestMatch, isDeferredData, isResponse, json, matchServerRoutes } from './vendor/response';
3234
import { normalizeRemixRequest } from './web-fetch';
3335

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

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

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

147160
span?.finish();
148161
} catch (err) {
149-
await captureRemixServerException(err, 'documentRequest', request);
162+
if (!FUTURE_FLAGS?.v2_errorBoundary) {
163+
await captureRemixServerException(err, 'documentRequest', request);
164+
}
165+
150166
throw err;
151167
}
152168

@@ -183,7 +199,10 @@ function makeWrappedDataFunction(origFn: DataFunction, id: string, name: 'action
183199
currentScope.setSpan(activeTransaction);
184200
span?.finish();
185201
} catch (err) {
186-
await captureRemixServerException(err, name, args.request);
202+
if (!FUTURE_FLAGS?.v2_errorBoundary) {
203+
await captureRemixServerException(err, name, args.request);
204+
}
205+
187206
throw err;
188207
}
189208

@@ -430,6 +449,7 @@ function makeWrappedCreateRequestHandler(
430449
isRequestHandlerWrapped = true;
431450

432451
return function (this: unknown, build: ServerBuild, ...args: unknown[]): RequestHandler {
452+
FUTURE_FLAGS = getFutureFlagsServer(build);
433453
const newBuild = instrumentBuild(build);
434454
const requestHandler = origCreateRequestHandler.call(this, newBuild, ...args);
435455

packages/remix/src/utils/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/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';
@@ -10,6 +10,14 @@ Sentry.init({
1010
autoSessionTracking: false,
1111
});
1212

13+
export function handleError(error: unknown, { request }: DataFunctionArgs): void {
14+
if (error instanceof Error) {
15+
Sentry.captureRemixServerException(error, 'remix.server', request);
16+
} else {
17+
Sentry.captureException(error);
18+
}
19+
}
20+
1321
export default function handleRequest(
1422
request: Request,
1523
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)