Skip to content

feat(v9/react-router): Add createSentryHandleError #17244

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,4 @@ const handleRequest = Sentry.createSentryHandleRequest({

export default handleRequest;

export const handleError: HandleErrorFunction = (error, { request }) => {
// React Router may abort some interrupted requests, don't log those
if (!request.signal.aborted) {
Sentry.captureException(error);

// make sure to still log the error so you can see it
console.error(error);
}
};
export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true });
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ test.describe('server-side errors', () => {
type: 'Error',
value: errorMessage,
mechanism: {
handled: true,
handled: false,
type: 'react-router',
},
},
],
Expand Down Expand Up @@ -67,7 +68,8 @@ test.describe('server-side errors', () => {
type: 'Error',
value: errorMessage,
mechanism: {
handled: true,
handled: false,
type: 'react-router',
},
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,4 @@ const handleRequest = Sentry.createSentryHandleRequest({

export default handleRequest;

export const handleError: HandleErrorFunction = (error, { request }) => {
// React Router may abort some interrupted requests, don't log those
if (!request.signal.aborted) {
Sentry.captureException(error);

// make sure to still log the error so you can see it
console.error(error);
}
};
export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true });
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ test.describe('server-side errors', () => {
type: 'Error',
value: errorMessage,
mechanism: {
handled: true,
handled: false,
type: 'react-router',
},
},
],
Expand Down Expand Up @@ -67,7 +68,8 @@ test.describe('server-side errors', () => {
type: 'Error',
value: errorMessage,
mechanism: {
handled: true,
handled: false,
type: 'react-router',
},
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,4 @@ const handleRequest = Sentry.createSentryHandleRequest({

export default handleRequest;

export const handleError: HandleErrorFunction = (error, { request }) => {
// React Router may abort some interrupted requests, don't log those
if (!request.signal.aborted) {
Sentry.captureException(error);

// make sure to still log the error so you can see it
console.error(error);
}
};
export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true });
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ test.describe('server-side errors', () => {
type: 'Error',
value: errorMessage,
mechanism: {
handled: true,
handled: false,
type: 'react-router',
},
},
],
Expand Down Expand Up @@ -67,7 +68,8 @@ test.describe('server-side errors', () => {
type: 'Error',
value: errorMessage,
mechanism: {
handled: true,
handled: false,
type: 'react-router',
},
},
],
Expand Down
39 changes: 39 additions & 0 deletions packages/react-router/src/server/createSentryHandleError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { captureException, flushIfServerless } from '@sentry/core';
import type { ActionFunctionArgs, HandleErrorFunction, LoaderFunctionArgs } from 'react-router';

export type SentryHandleErrorOptions = {
logErrors?: boolean;
};

/**
* A complete Sentry-instrumented handleError implementation that handles error reporting
*
* @returns A Sentry-instrumented handleError function
*/
export function createSentryHandleError({ logErrors = false }: SentryHandleErrorOptions): HandleErrorFunction {
const handleError = async function handleError(
error: unknown,
args: LoaderFunctionArgs | ActionFunctionArgs,
): Promise<void> {
// React Router may abort some interrupted requests, don't report those
if (!args.request.signal.aborted) {
captureException(error, {
mechanism: {
type: 'react-router',
handled: false,
},
});
if (logErrors) {
// eslint-disable-next-line no-console
console.error(error);
}
try {
await flushIfServerless();
} catch {
// Ignore flush errors to ensure error handling completes gracefully
}
}
};

return handleError;
}
1 change: 1 addition & 0 deletions packages/react-router/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export { wrapSentryHandleRequest, sentryHandleRequest, getMetaTagTransformer } f
export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest';
export { wrapServerAction } from './wrapServerAction';
export { wrapServerLoader } from './wrapServerLoader';
export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError';
198 changes: 198 additions & 0 deletions packages/react-router/test/server/createSentryHandleError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import * as core from '@sentry/core';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSentryHandleError } from '../../src/server/createSentryHandleError';

vi.mock('@sentry/core', () => ({
captureException: vi.fn(),
flushIfServerless: vi.fn().mockResolvedValue(undefined),
}));

const mechanism = {
handled: false,
type: 'react-router',
};

describe('createSentryHandleError', () => {
const mockCaptureException = vi.mocked(core.captureException);
const mockFlushIfServerless = vi.mocked(core.flushIfServerless);
const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {});

const mockError = new Error('Test error');

beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
mockConsoleError.mockClear();
});

const createMockArgs = (aborted: boolean): LoaderFunctionArgs => {
const controller = new AbortController();
if (aborted) {
controller.abort();
}

const request = {
signal: controller.signal,
} as Request;

return { request } as LoaderFunctionArgs;
};

describe('with default options', () => {
it('should create a handle error function with logErrors disabled by default', async () => {
const handleError = createSentryHandleError({});

expect(typeof handleError).toBe('function');
});

it('should capture exception and flush when request is not aborted', async () => {
const handleError = createSentryHandleError({});
const mockArgs = createMockArgs(false);

await handleError(mockError, mockArgs);

expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism });
expect(mockFlushIfServerless).toHaveBeenCalled();
expect(mockConsoleError).not.toHaveBeenCalled();
});

it('should not capture exception when request is aborted', async () => {
const handleError = createSentryHandleError({});
const mockArgs = createMockArgs(true);

await handleError(mockError, mockArgs);

expect(mockCaptureException).not.toHaveBeenCalled();
expect(mockFlushIfServerless).not.toHaveBeenCalled();
expect(mockConsoleError).not.toHaveBeenCalled();
});
});

describe('with logErrors enabled', () => {
it('should log errors to console when logErrors is true', async () => {
const handleError = createSentryHandleError({ logErrors: true });
const mockArgs = createMockArgs(false);

await handleError(mockError, mockArgs);

expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism });
expect(mockFlushIfServerless).toHaveBeenCalled();
expect(mockConsoleError).toHaveBeenCalledWith(mockError);
});

it('should not log errors to console when request is aborted even with logErrors enabled', async () => {
const handleError = createSentryHandleError({ logErrors: true });
const mockArgs = createMockArgs(true);

await handleError(mockError, mockArgs);

expect(mockCaptureException).not.toHaveBeenCalled();
expect(mockFlushIfServerless).not.toHaveBeenCalled();
expect(mockConsoleError).not.toHaveBeenCalled();
});
});

describe('with logErrors disabled explicitly', () => {
it('should not log errors to console when logErrors is false', async () => {
const handleError = createSentryHandleError({ logErrors: false });
const mockArgs = createMockArgs(false);

await handleError(mockError, mockArgs);

expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism });
expect(mockFlushIfServerless).toHaveBeenCalled();
expect(mockConsoleError).not.toHaveBeenCalled();
});
});

describe('with different error types', () => {
it('should handle string errors', async () => {
const handleError = createSentryHandleError({});
const stringError = 'String error message';
const mockArgs = createMockArgs(false);

await handleError(stringError, mockArgs);

expect(mockCaptureException).toHaveBeenCalledWith(stringError, { mechanism });
expect(mockFlushIfServerless).toHaveBeenCalled();
});

it('should handle null/undefined errors', async () => {
const handleError = createSentryHandleError({});
const mockArgs = createMockArgs(false);

await handleError(null, mockArgs);

expect(mockCaptureException).toHaveBeenCalledWith(null, { mechanism });
expect(mockFlushIfServerless).toHaveBeenCalled();
});

it('should handle custom error objects', async () => {
const handleError = createSentryHandleError({});
const customError = { message: 'Custom error', code: 500 };
const mockArgs = createMockArgs(false);

await handleError(customError, mockArgs);

expect(mockCaptureException).toHaveBeenCalledWith(customError, { mechanism });
expect(mockFlushIfServerless).toHaveBeenCalled();
});
});

describe('with ActionFunctionArgs', () => {
it('should work with ActionFunctionArgs instead of LoaderFunctionArgs', async () => {
const handleError = createSentryHandleError({ logErrors: true });
const mockArgs = createMockArgs(false) as ActionFunctionArgs;

await handleError(mockError, mockArgs);

expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism });
expect(mockFlushIfServerless).toHaveBeenCalled();
expect(mockConsoleError).toHaveBeenCalledWith(mockError);
});
});

describe('flushIfServerless behavior', () => {
it('should wait for flushIfServerless to complete', async () => {
const handleError = createSentryHandleError({});

let resolveFlush: () => void;
const flushPromise = new Promise<void>(resolve => {
resolveFlush = resolve;
});

mockFlushIfServerless.mockReturnValueOnce(flushPromise);

const mockArgs = createMockArgs(false);

const startTime = Date.now();

const handleErrorPromise = handleError(mockError, mockArgs);

setTimeout(() => resolveFlush(), 10);

await handleErrorPromise;
const endTime = Date.now();

expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism });
expect(mockFlushIfServerless).toHaveBeenCalled();
expect(endTime - startTime).toBeGreaterThanOrEqual(10);
});

it('should handle flushIfServerless rejection gracefully', async () => {
const handleError = createSentryHandleError({});

mockFlushIfServerless.mockRejectedValueOnce(new Error('Flush failed'));

const mockArgs = createMockArgs(false);

await expect(handleError(mockError, mockArgs)).resolves.toBeUndefined();

expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism });
expect(mockFlushIfServerless).toHaveBeenCalled();
});
});
});
Loading