-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat(remix): Complete Remix 3 server error handling #24878
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { waitForError } from '@sentry-internal/test-utils'; | ||
|
|
||
| const APP_NAME = 'remix-v3'; | ||
|
|
||
| test('reports an error thrown in a route handler, with its route', async ({ baseURL }) => { | ||
| const errorPromise = waitForError(APP_NAME, event => event.exception?.values?.[0]?.value === 'Route handler failed'); | ||
|
|
||
| await fetch(`${baseURL}/boom`); | ||
|
|
||
| const event = await errorPromise; | ||
| expect(event.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.http.remix_v3.middleware' }); | ||
| expect(event.transaction).toBe('GET /boom'); | ||
| }); | ||
|
|
||
| test('reports an error from a fetch handler that is not the router', async ({ baseURL }) => { | ||
| const errorPromise = waitForError(APP_NAME, event => event.exception?.values?.[0]?.value === 'Plain handler failed'); | ||
|
|
||
| const response = await fetch(`${baseURL}/plain-throw`); | ||
|
|
||
| expect(response.status).toBe(500); | ||
| const event = await errorPromise; | ||
| expect(event.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.http.remix_v3.on_error' }); | ||
| }); | ||
|
|
||
| test('does not report a request the client aborted', async ({ baseURL }) => { | ||
| const seen: string[] = []; | ||
| void waitForError(APP_NAME, event => { | ||
| seen.push(event.exception?.values?.[0]?.value ?? ''); | ||
| return false; | ||
| }); | ||
|
|
||
| const controller = new AbortController(); | ||
| const slow = fetch(`${baseURL}/slow`, { signal: controller.signal }).catch(() => undefined); | ||
| await expect.poll(async () => (await fetch(`${baseURL}/slow-started`)).text()).toBe('1'); | ||
| controller.abort(); | ||
| await slow; | ||
|
|
||
| // A later real error proves the pipeline is live, so an abort error would have arrived before it. | ||
| const sentinel = waitForError(APP_NAME, event => event.exception?.values?.[0]?.value === 'Route handler failed'); | ||
| await fetch(`${baseURL}/boom`); | ||
| await sentinel; | ||
|
|
||
| expect(seen.filter(value => value !== 'Route handler failed')).toEqual([]); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { captureException } from '@sentry/core'; | ||
|
|
||
| /** Decides whether a thrown value should become a Sentry issue. */ | ||
| export type ShouldHandleError = (error: unknown) => boolean; | ||
|
chargome marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Skip 3xx and 4xx errors, capture everything else. A numeric `status` in that range is an expected | ||
| * outcome, not a fault, and the request still produces a span. | ||
| */ | ||
| export function defaultShouldHandleError(error: unknown): boolean { | ||
| if (typeof error !== 'object' || error === null) { | ||
| return true; | ||
| } | ||
|
|
||
| const status = (error as { status?: unknown }).status; | ||
|
|
||
| return !(typeof status === 'number' && status >= 300 && status < 500); | ||
| } | ||
|
|
||
| // Configured at `Sentry.init()`, but read by subscribers installed earlier by the `--import` entry, so | ||
| // it lives in module scope rather than being captured before it exists. | ||
| let shouldHandleError: ShouldHandleError = defaultShouldHandleError; | ||
|
|
||
| /** @internal Set by the integration during `Sentry.init()`. */ | ||
| export function setShouldHandleError(filter: ShouldHandleError | undefined): void { | ||
| shouldHandleError = filter ?? defaultShouldHandleError; | ||
| } | ||
|
|
||
| /** | ||
| * Report an error unless it is an aborted request or filtered out. | ||
| * | ||
| * A router failure can arrive twice, from the middleware and again from `onError`. `captureException` | ||
| * marks the object `__sentry_captured__` and drops the second report; only a thrown primitive cannot | ||
| * be marked and may be reported twice. | ||
| */ | ||
| export function captureRequestError(error: unknown, request: Request | undefined, mechanism: string): boolean { | ||
| // The app's filter can throw. Letting that out would replace the request's own error and skip the | ||
| // app's `onError`, so reporting is best effort. | ||
| try { | ||
| if (request && isRequestAbort(error, request)) { | ||
| return false; | ||
| } | ||
| if (!shouldHandleError(error)) { | ||
| return false; | ||
| } | ||
| captureException(error, { mechanism: { handled: false, type: mechanism } }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Whether a rejection is the client giving up rather than the app failing. The router rejects with | ||
| * `signal.reason` when the connection drops; without this check every user navigating away mid | ||
| * request creates an issue. | ||
| */ | ||
| export function isRequestAbort(error: unknown, request: Request): boolean { | ||
| return request.signal.aborted && error === request.signal.reason; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,8 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; | |
| import { createMultiMatcher } from 'remix/route-pattern/match'; | ||
| import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; | ||
|
|
||
| import type { MatcherLike, RouterOptionsLike } from '../types'; | ||
| import type { MatcherLike, RequestListenerOptionsLike, RouterOptionsLike } from '../types'; | ||
| import { captureRequestError } from './errorFilter'; | ||
| import { sentryRemixMiddleware } from './middleware'; | ||
|
|
||
| const NOOP = (): void => {}; | ||
|
|
@@ -34,6 +35,11 @@ export function instrumentRemixV3(): void { | |
| } | ||
| subscribed = true; | ||
|
|
||
| subscribeToCreateRouter(); | ||
| subscribeToCreateRequestListener(); | ||
| } | ||
|
|
||
| function subscribeToCreateRouter(): void { | ||
| diagnosticsChannel.tracingChannel<ChannelContext, ChannelContext>(remixV3Channels.REMIX_V3_CREATE_ROUTER).subscribe({ | ||
| start(data) { | ||
| // Node rethrows anything this handler throws as an uncaught exception, which would kill an app | ||
|
|
@@ -52,32 +58,82 @@ export function instrumentRemixV3(): void { | |
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Report errors from any fetch handler, router or not. The middleware only sees failures inside a | ||
| * router; this is the only hook that covers a plain handler. No abort guard is needed, because the | ||
| * listener drops aborted requests before calling `onError`. | ||
| */ | ||
| function subscribeToCreateRequestListener(): void { | ||
| diagnosticsChannel | ||
| .tracingChannel<ChannelContext, ChannelContext>(remixV3Channels.REMIX_V3_CREATE_REQUEST_LISTENER) | ||
| .subscribe({ | ||
| start(data) { | ||
| try { | ||
| injectOnError(ensureOptions(data.arguments, 1)); | ||
| } catch { | ||
| // Ignored on purpose. | ||
| } | ||
| }, | ||
| end: NOOP, | ||
| asyncStart: NOOP, | ||
| asyncEnd: NOOP, | ||
| error: NOOP, | ||
| }); | ||
| } | ||
|
|
||
| function injectOnError(raw: Record<string, unknown> | undefined): void { | ||
| if (!raw || markInjected(raw)) { | ||
| return; | ||
| } | ||
|
|
||
| const options = raw as RequestListenerOptionsLike; | ||
| const appOnError = options.onError; | ||
|
|
||
| options.onError = error => { | ||
| captureRequestError(error, undefined, 'auto.http.remix_v3.on_error'); | ||
|
|
||
| if (appOnError) { | ||
| // Chained so the app keeps its own response. | ||
| return appOnError(error); | ||
| } | ||
|
|
||
| // Setting `onError` replaced the listener's default handler, which logs the error. | ||
| // oxlint-disable-next-line no-console | ||
| console.error(error); | ||
| return undefined; | ||
| }; | ||
|
cursor[bot] marked this conversation as resolved.
Comment on lines
+92
to
+104
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When overwriting the error handler, we would remove their default error handling, no?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nice, updated |
||
| } | ||
|
|
||
| /** | ||
| * The options object, created when the caller omitted it. `undefined` when the caller passed something | ||
| * that is not an options object, which must not be overwritten. | ||
| */ | ||
| function ensureOptions(args: unknown[]): Record<string, unknown> | undefined { | ||
| const existing = args[0]; | ||
| function ensureOptions(args: unknown[], index = 0): Record<string, unknown> | undefined { | ||
| const existing = args[index]; | ||
|
|
||
| if (existing === undefined || existing === null) { | ||
| const created: Record<string, unknown> = {}; | ||
| args[0] = created; | ||
| args[index] = created; | ||
| return created; | ||
| } | ||
|
|
||
| return typeof existing === 'object' ? (existing as Record<string, unknown>) : undefined; | ||
| } | ||
|
chargome marked this conversation as resolved.
|
||
|
|
||
| function injectRouterMiddleware(raw: Record<string, unknown> | undefined): void { | ||
| if (!raw) { | ||
| return; | ||
| } | ||
|
|
||
| /** Whether this object was already injected into, marking it when it was not. */ | ||
| function markInjected(raw: Record<string, unknown>): boolean { | ||
| const marker = raw as { [INJECTED]?: boolean }; | ||
| if (marker[INJECTED]) { | ||
| return; | ||
| return true; | ||
| } | ||
| marker[INJECTED] = true; | ||
| return false; | ||
| } | ||
|
|
||
| function injectRouterMiddleware(raw: Record<string, unknown> | undefined): void { | ||
| if (!raw || markInjected(raw)) { | ||
| return; | ||
| } | ||
|
|
||
| const options = raw as RouterOptionsLike; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ import { | |
| } from '@sentry/core'; | ||
|
|
||
| import type { MatcherLike, MiddlewareLike, NextFunctionLike, RequestContextLike } from '../types'; | ||
| import { captureRequestError } from './errorFilter'; | ||
| import { resolveRoutePattern } from './route'; | ||
|
|
||
| /** | ||
|
|
@@ -28,7 +29,16 @@ export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike { | |
| // Applied before `next()` so anything captured while the handler runs already carries the route. | ||
| applyRoute(isolationScope, matcher, context); | ||
|
|
||
| const response = await next(); | ||
| let response; | ||
| try { | ||
| response = await next(); | ||
| } catch (error) { | ||
| // Captured here as well as in `onError`, so the event carries the route this middleware just put | ||
| // on the scope. | ||
| captureRequestError(error, context.request, 'auto.http.remix_v3.middleware'); | ||
| throw error; | ||
| } | ||
|
|
||
| setResponseStatus(response); | ||
|
Comment on lines
+38
to
42
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Primitive values thrown from Remix route handlers are reported twice because the de-duplication logic only works for Error objects, causing both the middleware and Suggested FixThe error handling logic should be updated to prevent double reporting for all thrown types. One possible solution is to wrap any captured primitive in an Prompt for AI AgentAlso affects:
Did we get this right? 👍 / 👎 to inform future reviews. |
||
|
|
||
| return response; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.