Skip to content

Commit b563a26

Browse files
committed
add createSentryHandleError
1 parent 12ac49a commit b563a26

File tree

6 files changed

+238
-27
lines changed

6 files changed

+238
-27
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-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/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 });
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
if (logErrors) {
22+
// eslint-disable-next-line no-console
23+
console.error(error);
24+
}
25+
try {
26+
await flushIfServerless();
27+
} catch {
28+
// Ignore flush errors to ensure error handling completes gracefully
29+
}
30+
}
31+
};
32+
33+
return handleError;
34+
}

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

0 commit comments

Comments
 (0)