-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat(remix): Capture Remix 3 component render errors #24873
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,113 @@ | ||
| /** | ||
| * A browser stand-in for `node:diagnostics_channel`. | ||
| * | ||
| * Orchestrion's transform injects `import dc from '<dcModule>'` into every instrumented module and | ||
| * drives it through Node's `tracingChannel` API. The browser has no `node:diagnostics_channel`, so | ||
| * without this shim the transform cannot run on browser code. | ||
| * | ||
| * This implements exactly what the injected code calls, which is not the `traceSync` helpers but the | ||
| * per-event channels under them: | ||
| * | ||
| * ```js | ||
| * // The gate is true when ANY of the five event channels has a subscriber, so subscribing to `end` | ||
| * // alone is enough to get the call wrapped. | ||
| * if (!(ch.start.hasSubscribers || ch.end.hasSubscribers || ch.asyncStart.hasSubscribers | ||
| * || ch.asyncEnd.hasSubscribers || ch.error.hasSubscribers)) return traced(); | ||
| * return ch.start.runStores(ctx, () => { | ||
| * try { ctx.result = traced(); } | ||
| * catch (err) { ctx.error = err; ch.error.publish(ctx); throw err; } | ||
| * finally { ch.end.publish(ctx); } | ||
| * }); | ||
| * ``` | ||
| * | ||
| * The browser has no `AsyncLocalStorage`, so `runStores` publishes and then calls the callback directly | ||
| * instead of entering a store. The injected code does not need a store: it passes state along on the | ||
| * `context` object. | ||
| */ | ||
|
|
||
| type Handler = (context: unknown) => void; | ||
|
|
||
| interface Channel { | ||
| readonly hasSubscribers: boolean; | ||
| publish(context: unknown): void; | ||
| runStores<T>(context: unknown, fn: (...args: unknown[]) => T, thisArg?: unknown, ...args: unknown[]): T; | ||
| subscribe(handler: Handler): void; | ||
| unsubscribe(handler: Handler): void; | ||
| } | ||
|
|
||
| const EVENTS = ['start', 'end', 'asyncStart', 'asyncEnd', 'error'] as const; | ||
| type EventName = (typeof EVENTS)[number]; | ||
|
|
||
| export type TracingChannelSubscribers = Partial<Record<EventName, Handler>>; | ||
|
|
||
| export interface BrowserTracingChannel extends Record<EventName, Channel> { | ||
| subscribe(subscribers: TracingChannelSubscribers): void; | ||
| unsubscribe(subscribers: TracingChannelSubscribers): void; | ||
| } | ||
|
|
||
| function createChannel(): Channel { | ||
| const handlers = new Set<Handler>(); | ||
|
|
||
| return { | ||
| get hasSubscribers(): boolean { | ||
| return handlers.size > 0; | ||
| }, | ||
| publish(context) { | ||
| // Snapshot: a subscriber may unsubscribe itself while being notified. | ||
| for (const handler of Array.from(handlers)) { | ||
| try { | ||
| handler(context); | ||
| } catch { | ||
| // A throwing subscriber must never break the instrumented library. | ||
| } | ||
| } | ||
| }, | ||
| runStores(context, fn, thisArg, ...args) { | ||
| this.publish(context); | ||
| return fn.apply(thisArg, args); | ||
| }, | ||
| subscribe(handler) { | ||
| handlers.add(handler); | ||
| }, | ||
| unsubscribe(handler) { | ||
| handlers.delete(handler); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| const registry = new Map<string, BrowserTracingChannel>(); | ||
|
|
||
| /** Create (or look up) a tracing channel by name. */ | ||
| export function tracingChannel(name: string): BrowserTracingChannel { | ||
| const existing = registry.get(name); | ||
| if (existing) { | ||
| return existing; | ||
| } | ||
|
|
||
| const channels = Object.fromEntries(EVENTS.map(event => [event, createChannel()])) as Record<EventName, Channel>; | ||
|
|
||
| const channel: BrowserTracingChannel = { | ||
| ...channels, | ||
| subscribe(subscribers) { | ||
| for (const event of EVENTS) { | ||
| const handler = subscribers[event]; | ||
| if (handler) { | ||
| channels[event].subscribe(handler); | ||
| } | ||
| } | ||
| }, | ||
| unsubscribe(subscribers) { | ||
| for (const event of EVENTS) { | ||
| const handler = subscribers[event]; | ||
| if (handler) { | ||
| channels[event].unsubscribe(handler); | ||
| } | ||
| } | ||
| }, | ||
| }; | ||
|
|
||
| registry.set(name, channel); | ||
| return channel; | ||
| } | ||
|
|
||
| export default { tracingChannel }; | ||
|
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. Why not do a named export?
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. We do both, no specific reason here |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { captureException } from '@sentry/browser'; | ||
| import { tracingChannel } from './diagnosticsChannelShim'; | ||
|
|
||
| /** The `AppRuntime` returned by `run()` from `remix/ui`, an EventTarget emitting `error`. */ | ||
| export interface AppRuntimeLike { | ||
| addEventListener(type: 'error', listener: (event: Event) => void): void; | ||
| } | ||
|
|
||
| const MECHANISM_TYPE = 'auto.ui.remix_v3'; | ||
|
|
||
| // The channel orchestrion will inject into `@remix-run/ui`'s `run()` once the asset server transforms | ||
| // browser modules. Nothing publishes to it yet, so the subscription below is dormant. The name is | ||
| // written out because the channel names live in `@sentry/server-utils`, which must not reach the | ||
| // browser. | ||
| const UI_RUN_CHANNEL = 'orchestrion:@remix-run/ui:run'; | ||
|
|
||
| const attached = new WeakSet<object>(); | ||
|
|
||
| // `subscribe()` takes a fresh object literal and the channel keys handlers by identity, so a second | ||
| // call would add a second handler rather than replace the first. | ||
| let subscribed = false; | ||
|
|
||
| /** | ||
| * Report a Remix 3 client runtime's errors to Sentry. | ||
| * | ||
| * The runtime sends every render, scheduler, frame and hydration error to the event target `run()` | ||
| * returns. Dispatching an event does not rethrow, so `window.onerror` and `unhandledrejection` never | ||
| * see them, and without this listener the SDK sees nothing from the component layer. | ||
| * | ||
| * Called by {@link instrumentClientRuntime}, and exported for apps that do not serve their browser | ||
| * modules through an instrumented asset server. | ||
| */ | ||
| export function captureRuntimeErrors(app: AppRuntimeLike): void { | ||
| if (attached.has(app)) { | ||
| return; | ||
| } | ||
| attached.add(app); | ||
|
|
||
| app.addEventListener('error', event => { | ||
| captureException(readComponentError(event), { | ||
| mechanism: { handled: false, type: MECHANISM_TYPE }, | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Attach to every `run()` call automatically, so the app never has to call `captureRuntimeErrors()` | ||
| * itself. A no-op when the browser module was not transformed, because the channel never fires. | ||
| */ | ||
| export function instrumentClientRuntime(): void { | ||
| if (subscribed) { | ||
| return; | ||
| } | ||
| subscribed = true; | ||
|
|
||
| tracingChannel(UI_RUN_CHANNEL).subscribe({ | ||
| end(context) { | ||
| const app = (context as { result?: unknown }).result; | ||
| if (isAppRuntime(app)) { | ||
| captureRuntimeErrors(app); | ||
| } | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| function isAppRuntime(value: unknown): value is AppRuntimeLike { | ||
| return typeof (value as AppRuntimeLike | undefined)?.addEventListener === 'function'; | ||
| } | ||
|
|
||
| // The runtime puts the thrown value on `error`, and it may be any value, not just an `Error`. | ||
| function readComponentError(event: Event): unknown { | ||
| const error = (event as ErrorEvent).error; | ||
| return error !== undefined ? error : event; | ||
| } | ||
|
Comment on lines
+71
to
+74
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: The null check in Suggested FixUpdate the condition in Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { beforeAll, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| const captureException = vi.fn(); | ||
| vi.mock('@sentry/browser', () => ({ captureException: (...args: unknown[]) => captureException(...args) })); | ||
|
|
||
| const { captureRuntimeErrors, instrumentClientRuntime } = await import('../../src/v3/client/errors'); | ||
| const { tracingChannel } = await import('../../src/v3/client/diagnosticsChannelShim'); | ||
|
|
||
| /** Stands in for the `AppRuntime` that `run()` returns. */ | ||
| function fakeApp(): EventTarget { | ||
| return new EventTarget(); | ||
| } | ||
|
|
||
| // There is no `ErrorEvent` in this environment. The SDK only reads `.error` off the event, which is | ||
| // where the runtime puts the thrown value. | ||
| function errorEvent(error: unknown): Event { | ||
| return Object.assign(new Event('error'), { error }); | ||
| } | ||
|
|
||
| describe('captureRuntimeErrors', () => { | ||
| beforeAll(() => { | ||
| // Called twice because `init()` may run more than once. Two guards make that safe, the subscribe | ||
| // once flag and the `attached` WeakSet; this pins the outcome, not either one on its own. | ||
| instrumentClientRuntime(); | ||
| instrumentClientRuntime(); | ||
| }); | ||
|
|
||
| it('reports one error per dispatch, however often instrumentation was set up', () => { | ||
| captureException.mockClear(); | ||
| const app = fakeApp(); | ||
| tracingChannel('orchestrion:@remix-run/ui:run').end.publish({ result: app }); | ||
|
|
||
| const error = new Error('render failed'); | ||
| app.dispatchEvent(errorEvent(error)); | ||
|
|
||
| expect(captureException).toHaveBeenCalledTimes(1); | ||
| expect(captureException).toHaveBeenCalledWith(error, { | ||
| mechanism: { handled: false, type: 'auto.ui.remix_v3' }, | ||
| }); | ||
| }); | ||
|
|
||
| it('attaches only once to the same app', () => { | ||
| captureException.mockClear(); | ||
| const app = fakeApp(); | ||
| captureRuntimeErrors(app); | ||
| captureRuntimeErrors(app); | ||
|
|
||
| app.dispatchEvent(errorEvent(new Error('render failed'))); | ||
|
|
||
| expect(captureException).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('falls back to the event when it carries no error', () => { | ||
| captureException.mockClear(); | ||
| const app = fakeApp(); | ||
| captureRuntimeErrors(app); | ||
|
|
||
| const event = new Event('error'); | ||
| app.dispatchEvent(event); | ||
|
|
||
| expect(captureException).toHaveBeenCalledWith(event, expect.anything()); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.