Skip to content
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 @@ -21,6 +21,9 @@ function HomePage(handle: Handle<Record<string, never>>) {
<a id="to-user" href="/users/12345">
User
</a>
<button type="button" id="component-error">
Component error
</button>
</body>
</html>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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`.
Expand All @@ -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, {}));
}
});
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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' });
});
5 changes: 4 additions & 1 deletion packages/remix/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions packages/remix/src/v3/client/diagnosticsChannelShim.ts
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];
Comment thread
chargome marked this conversation as resolved.

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 };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not do a named export?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do both, no specific reason here

74 changes: 74 additions & 0 deletions packages/remix/src/v3/client/errors.ts
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The null check in readComponentError is incorrect for cross-origin errors, where error can be null. This causes loss of error context when reporting.
Severity: MEDIUM

Suggested Fix

Update the condition in readComponentError to handle both null and undefined values for the error property. Changing the check from error !== undefined to error != null will ensure that the function correctly falls back to returning the event object itself in cases of cross-origin errors.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/remix/src/v3/client/errors.ts#L71-L74

Potential issue: The `readComponentError` function checks for an error object on an
event using `error !== undefined`. For cross-origin script errors, browsers often set
the `error` property to `null`. The current check incorrectly passes this `null` value
to `captureException`, which then creates a generic error report with the message
"null". This results in the loss of valuable context from the original event object,
hindering debugging efforts for cross-origin errors.

Did we get this right? 👍 / 👎 to inform future reviews.

9 changes: 8 additions & 1 deletion packages/remix/src/v3/client/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}
1 change: 1 addition & 0 deletions packages/remix/src/v3/index.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
63 changes: 63 additions & 0 deletions packages/remix/test/v3/errors.test.ts
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());
});
});
Loading