diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx index 4999bd4127cc..59d6567f28bb 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx @@ -21,6 +21,9 @@ function HomePage(handle: Handle>) { User + ); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts index d4cc91b051d5..f558bda2b048 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/remix/v3/client'; -import { run } from 'remix/ui'; +import { createElement, run } from 'remix/ui'; // Not Node. The asset server substitutes this when it compiles the module, from the `define` map in // `app/assets.ts`. @@ -17,3 +17,19 @@ export const app = run({ return mod[exportName]; }, }); + +// The runtime sends a render error to the event target `run()` returns and does not rethrow, so +// `window.onerror` never sees it. This listener is the only way the SDK learns about it. +Sentry.captureRuntimeErrors(app); + +function Boom(): () => never { + return () => { + throw new Error('Component render failed'); + }; +} + +document.addEventListener('click', event => { + if ((event.target as HTMLElement | null)?.id === 'component-error') { + void app.frames.top.replace(createElement(Boom, {})); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts index 60cc59e24379..79d36cf682c0 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; const APP_NAME = 'remix-v3'; @@ -33,3 +33,16 @@ test('sends a navigation span for a link the runtime intercepts', async ({ page expect(span.is_segment).toBe(true); expect(span.name).toBe('Navigation'); }); + +test('captures a component render error the runtime never rethrows', async ({ page }) => { + await page.goto('/'); + + const errorPromise = waitForError(APP_NAME, event => { + return !event.type && event.exception?.values?.[0]?.value === 'Component render failed'; + }); + + await page.locator('#component-error').click(); + + const error = await errorPromise; + expect(error.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.ui.remix_v3' }); +}); diff --git a/packages/remix/rollup.npm.config.mjs b/packages/remix/rollup.npm.config.mjs index e4938499f1ec..0abd34e17818 100644 --- a/packages/remix/rollup.npm.config.mjs +++ b/packages/remix/rollup.npm.config.mjs @@ -27,7 +27,10 @@ const v3NodeEntry = defineConfig({ */ const v3ClientBundle = defineConfig({ input: 'build/esm/v3/index.client.js', - external: id => id === 'remix' || id.startsWith('@remix-run/'), + // The channel shim has to stay external. Orchestrion's browser transform imports that file by URL + // into every instrumented module, so an inlined copy would leave the page with two shims and two + // separate subscriber registries, and instrumentation would quietly do nothing. + external: id => /diagnosticsChannelShim/.test(id) || id === 'remix' || id.startsWith('@remix-run/'), treeshake: { moduleSideEffects: false, propertyReadSideEffects: false }, plugins: [nodeResolve({ browser: true, exportConditions: ['browser', 'import', 'default'] })], // Emitted inside `build/esm/v3/`, not at `build/`: the one import it keeps is the relative path to diff --git a/packages/remix/src/v3/client/diagnosticsChannelShim.ts b/packages/remix/src/v3/client/diagnosticsChannelShim.ts new file mode 100644 index 000000000000..410447e8054d --- /dev/null +++ b/packages/remix/src/v3/client/diagnosticsChannelShim.ts @@ -0,0 +1,113 @@ +/** + * A browser stand-in for `node:diagnostics_channel`. + * + * Orchestrion's transform injects `import dc from ''` 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(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>; + +export interface BrowserTracingChannel extends Record { + subscribe(subscribers: TracingChannelSubscribers): void; + unsubscribe(subscribers: TracingChannelSubscribers): void; +} + +function createChannel(): Channel { + const handlers = new Set(); + + 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(); + +/** 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; + + 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 }; diff --git a/packages/remix/src/v3/client/errors.ts b/packages/remix/src/v3/client/errors.ts new file mode 100644 index 000000000000..fd636cacba27 --- /dev/null +++ b/packages/remix/src/v3/client/errors.ts @@ -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(); + +// `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; +} diff --git a/packages/remix/src/v3/client/sdk.ts b/packages/remix/src/v3/client/sdk.ts index 463de70e504b..cbf5560c1296 100644 --- a/packages/remix/src/v3/client/sdk.ts +++ b/packages/remix/src/v3/client/sdk.ts @@ -3,6 +3,7 @@ import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as browse import { applySdkMetadata, type Client, type Integration } from '@sentry/core'; import { browserTracingIntegration } from './browserTracingIntegration'; +import { instrumentClientRuntime } from './errors'; /** * Default integrations for the Remix 3 client SDK. @@ -24,5 +25,11 @@ export function init(options: BrowserOptions): Client | undefined { applySdkMetadata(opts, 'remix', ['remix', 'browser']); - return browserInit(opts); + const client = browserInit(opts); + + // Subscribed before the app calls `run()`, so an app served through an instrumented asset server + // reports component errors without writing any Sentry code itself. + instrumentClientRuntime(); + + return client; } diff --git a/packages/remix/src/v3/index.client.ts b/packages/remix/src/v3/index.client.ts index 761d76bd0e54..ffe5c04d3175 100644 --- a/packages/remix/src/v3/index.client.ts +++ b/packages/remix/src/v3/index.client.ts @@ -74,3 +74,4 @@ export type { BrowserOptions } from '@sentry/browser'; export { getDefaultIntegrations, init } from './client/sdk'; export { browserTracingIntegration } from './client/browserTracingIntegration'; +export { captureRuntimeErrors, instrumentClientRuntime } from './client/errors'; diff --git a/packages/remix/test/v3/errors.test.ts b/packages/remix/test/v3/errors.test.ts new file mode 100644 index 000000000000..c5224d7fd77e --- /dev/null +++ b/packages/remix/test/v3/errors.test.ts @@ -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()); + }); +});