Skip to content

Commit 0b6f74c

Browse files
authored
feat(v9/react-router): Add createSentryHandleError (#17244)
1 parent c58e1c6 commit 0b6f74c

File tree

9 files changed

+253
-33
lines changed

9 files changed

+253
-33
lines changed

dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.server.tsx

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,4 @@ const handleRequest = Sentry.createSentryHandleRequest({
1515

1616
export default handleRequest;
1717

18-
export const handleError: HandleErrorFunction = (error, { request }) => {
19-
// React Router may abort some interrupted requests, don't log those
20-
if (!request.signal.aborted) {
21-
Sentry.captureException(error);
22-
23-
// make sure to still log the error so you can see it
24-
console.error(error);
25-
}
26-
};
18+
export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true });

dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.server.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ test.describe('server-side errors', () => {
2020
type: 'Error',
2121
value: errorMessage,
2222
mechanism: {
23-
handled: true,
23+
handled: false,
24+
type: 'react-router',
2425
},
2526
},
2627
],
@@ -67,7 +68,8 @@ test.describe('server-side errors', () => {
6768
type: 'Error',
6869
value: errorMessage,
6970
mechanism: {
70-
handled: true,
71+
handled: false,
72+
type: 'react-router',
7173
},
7274
},
7375
],

dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/app/entry.server.tsx

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,4 @@ const handleRequest = Sentry.createSentryHandleRequest({
1515

1616
export default handleRequest;
1717

18-
export const handleError: HandleErrorFunction = (error, { request }) => {
19-
// React Router may abort some interrupted requests, don't log those
20-
if (!request.signal.aborted) {
21-
Sentry.captureException(error);
22-
23-
// make sure to still log the error so you can see it
24-
console.error(error);
25-
}
26-
};
18+
export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true });

dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/errors/errors.server.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ test.describe('server-side errors', () => {
2020
type: 'Error',
2121
value: errorMessage,
2222
mechanism: {
23-
handled: true,
23+
handled: false,
24+
type: 'react-router',
2425
},
2526
},
2627
],
@@ -67,7 +68,8 @@ test.describe('server-side errors', () => {
6768
type: 'Error',
6869
value: errorMessage,
6970
mechanism: {
70-
handled: true,
71+
handled: false,
72+
type: 'react-router',
7173
},
7274
},
7375
],

dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,4 @@ const handleRequest = Sentry.createSentryHandleRequest({
1515

1616
export default handleRequest;
1717

18-
export const handleError: HandleErrorFunction = (error, { request }) => {
19-
// React Router may abort some interrupted requests, don't log those
20-
if (!request.signal.aborted) {
21-
Sentry.captureException(error);
22-
23-
// make sure to still log the error so you can see it
24-
console.error(error);
25-
}
26-
};
18+
export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true });

dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.server.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ test.describe('server-side errors', () => {
2020
type: 'Error',
2121
value: errorMessage,
2222
mechanism: {
23-
handled: true,
23+
handled: false,
24+
type: 'react-router',
2425
},
2526
},
2627
],
@@ -67,7 +68,8 @@ test.describe('server-side errors', () => {
6768
type: 'Error',
6869
value: errorMessage,
6970
mechanism: {
70-
handled: true,
71+
handled: false,
72+
type: 'react-router',
7173
},
7274
},
7375
],
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { captureException, flushIfServerless } from '@sentry/core';
2+
import type { ActionFunctionArgs, HandleErrorFunction, LoaderFunctionArgs } from 'react-router';
3+
4+
export type SentryHandleErrorOptions = {
5+
logErrors?: boolean;
6+
};
7+
8+
/**
9+
* A complete Sentry-instrumented handleError implementation that handles error reporting
10+
*
11+
* @returns A Sentry-instrumented handleError function
12+
*/
13+
export function createSentryHandleError({ logErrors = false }: SentryHandleErrorOptions): HandleErrorFunction {
14+
const handleError = async function handleError(
15+
error: unknown,
16+
args: LoaderFunctionArgs | ActionFunctionArgs,
17+
): Promise<void> {
18+
// React Router may abort some interrupted requests, don't report those
19+
if (!args.request.signal.aborted) {
20+
captureException(error, {
21+
mechanism: {
22+
type: 'react-router',
23+
handled: false,
24+
},
25+
});
26+
if (logErrors) {
27+
// eslint-disable-next-line no-console
28+
console.error(error);
29+
}
30+
try {
31+
await flushIfServerless();
32+
} catch {
33+
// Ignore flush errors to ensure error handling completes gracefully
34+
}
35+
}
36+
};
37+
38+
return handleError;
39+
}

packages/react-router/src/server/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export { wrapSentryHandleRequest, sentryHandleRequest, getMetaTagTransformer } f
66
export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest';
77
export { wrapServerAction } from './wrapServerAction';
88
export { wrapServerLoader } from './wrapServerLoader';
9+
export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError';
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import * as core from '@sentry/core';
2+
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { createSentryHandleError } from '../../src/server/createSentryHandleError';
5+
6+
vi.mock('@sentry/core', () => ({
7+
captureException: vi.fn(),
8+
flushIfServerless: vi.fn().mockResolvedValue(undefined),
9+
}));
10+
11+
const mechanism = {
12+
handled: false,
13+
type: 'react-router',
14+
};
15+
16+
describe('createSentryHandleError', () => {
17+
const mockCaptureException = vi.mocked(core.captureException);
18+
const mockFlushIfServerless = vi.mocked(core.flushIfServerless);
19+
const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
20+
21+
const mockError = new Error('Test error');
22+
23+
beforeEach(() => {
24+
vi.clearAllMocks();
25+
});
26+
27+
afterEach(() => {
28+
mockConsoleError.mockClear();
29+
});
30+
31+
const createMockArgs = (aborted: boolean): LoaderFunctionArgs => {
32+
const controller = new AbortController();
33+
if (aborted) {
34+
controller.abort();
35+
}
36+
37+
const request = {
38+
signal: controller.signal,
39+
} as Request;
40+
41+
return { request } as LoaderFunctionArgs;
42+
};
43+
44+
describe('with default options', () => {
45+
it('should create a handle error function with logErrors disabled by default', async () => {
46+
const handleError = createSentryHandleError({});
47+
48+
expect(typeof handleError).toBe('function');
49+
});
50+
51+
it('should capture exception and flush when request is not aborted', async () => {
52+
const handleError = createSentryHandleError({});
53+
const mockArgs = createMockArgs(false);
54+
55+
await handleError(mockError, mockArgs);
56+
57+
expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism });
58+
expect(mockFlushIfServerless).toHaveBeenCalled();
59+
expect(mockConsoleError).not.toHaveBeenCalled();
60+
});
61+
62+
it('should not capture exception when request is aborted', async () => {
63+
const handleError = createSentryHandleError({});
64+
const mockArgs = createMockArgs(true);
65+
66+
await handleError(mockError, mockArgs);
67+
68+
expect(mockCaptureException).not.toHaveBeenCalled();
69+
expect(mockFlushIfServerless).not.toHaveBeenCalled();
70+
expect(mockConsoleError).not.toHaveBeenCalled();
71+
});
72+
});
73+
74+
describe('with logErrors enabled', () => {
75+
it('should log errors to console when logErrors is true', async () => {
76+
const handleError = createSentryHandleError({ logErrors: true });
77+
const mockArgs = createMockArgs(false);
78+
79+
await handleError(mockError, mockArgs);
80+
81+
expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism });
82+
expect(mockFlushIfServerless).toHaveBeenCalled();
83+
expect(mockConsoleError).toHaveBeenCalledWith(mockError);
84+
});
85+
86+
it('should not log errors to console when request is aborted even with logErrors enabled', async () => {
87+
const handleError = createSentryHandleError({ logErrors: true });
88+
const mockArgs = createMockArgs(true);
89+
90+
await handleError(mockError, mockArgs);
91+
92+
expect(mockCaptureException).not.toHaveBeenCalled();
93+
expect(mockFlushIfServerless).not.toHaveBeenCalled();
94+
expect(mockConsoleError).not.toHaveBeenCalled();
95+
});
96+
});
97+
98+
describe('with logErrors disabled explicitly', () => {
99+
it('should not log errors to console when logErrors is false', async () => {
100+
const handleError = createSentryHandleError({ logErrors: false });
101+
const mockArgs = createMockArgs(false);
102+
103+
await handleError(mockError, mockArgs);
104+
105+
expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism });
106+
expect(mockFlushIfServerless).toHaveBeenCalled();
107+
expect(mockConsoleError).not.toHaveBeenCalled();
108+
});
109+
});
110+
111+
describe('with different error types', () => {
112+
it('should handle string errors', async () => {
113+
const handleError = createSentryHandleError({});
114+
const stringError = 'String error message';
115+
const mockArgs = createMockArgs(false);
116+
117+
await handleError(stringError, mockArgs);
118+
119+
expect(mockCaptureException).toHaveBeenCalledWith(stringError, { mechanism });
120+
expect(mockFlushIfServerless).toHaveBeenCalled();
121+
});
122+
123+
it('should handle null/undefined errors', async () => {
124+
const handleError = createSentryHandleError({});
125+
const mockArgs = createMockArgs(false);
126+
127+
await handleError(null, mockArgs);
128+
129+
expect(mockCaptureException).toHaveBeenCalledWith(null, { mechanism });
130+
expect(mockFlushIfServerless).toHaveBeenCalled();
131+
});
132+
133+
it('should handle custom error objects', async () => {
134+
const handleError = createSentryHandleError({});
135+
const customError = { message: 'Custom error', code: 500 };
136+
const mockArgs = createMockArgs(false);
137+
138+
await handleError(customError, mockArgs);
139+
140+
expect(mockCaptureException).toHaveBeenCalledWith(customError, { mechanism });
141+
expect(mockFlushIfServerless).toHaveBeenCalled();
142+
});
143+
});
144+
145+
describe('with ActionFunctionArgs', () => {
146+
it('should work with ActionFunctionArgs instead of LoaderFunctionArgs', async () => {
147+
const handleError = createSentryHandleError({ logErrors: true });
148+
const mockArgs = createMockArgs(false) as ActionFunctionArgs;
149+
150+
await handleError(mockError, mockArgs);
151+
152+
expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism });
153+
expect(mockFlushIfServerless).toHaveBeenCalled();
154+
expect(mockConsoleError).toHaveBeenCalledWith(mockError);
155+
});
156+
});
157+
158+
describe('flushIfServerless behavior', () => {
159+
it('should wait for flushIfServerless to complete', async () => {
160+
const handleError = createSentryHandleError({});
161+
162+
let resolveFlush: () => void;
163+
const flushPromise = new Promise<void>(resolve => {
164+
resolveFlush = resolve;
165+
});
166+
167+
mockFlushIfServerless.mockReturnValueOnce(flushPromise);
168+
169+
const mockArgs = createMockArgs(false);
170+
171+
const startTime = Date.now();
172+
173+
const handleErrorPromise = handleError(mockError, mockArgs);
174+
175+
setTimeout(() => resolveFlush(), 10);
176+
177+
await handleErrorPromise;
178+
const endTime = Date.now();
179+
180+
expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism });
181+
expect(mockFlushIfServerless).toHaveBeenCalled();
182+
expect(endTime - startTime).toBeGreaterThanOrEqual(10);
183+
});
184+
185+
it('should handle flushIfServerless rejection gracefully', async () => {
186+
const handleError = createSentryHandleError({});
187+
188+
mockFlushIfServerless.mockRejectedValueOnce(new Error('Flush failed'));
189+
190+
const mockArgs = createMockArgs(false);
191+
192+
await expect(handleError(mockError, mockArgs)).resolves.toBeUndefined();
193+
194+
expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism });
195+
expect(mockFlushIfServerless).toHaveBeenCalled();
196+
});
197+
});
198+
});

0 commit comments

Comments
 (0)