Skip to content

Commit dd4e6db

Browse files
chargomeclaude
andcommitted
feat(remix): Add Remix 3 browser SDK with a bundled client entry
Remix 3 has no bundler, so the browser gets whatever module graph the client entry reaches, and nothing can be eliminated. The entry is a named export list rather than a wildcard re-export, and it is bundled once at publish time into a single tree shaken file. Unbundled it costs an app 255 requests and about 364 KB gzipped; bundled it is 2 requests and 54 KB. The size limit budget is in this change rather than a later one because a single wildcard re-export silently undoes it. Navigation tracing is the SDK's own, because `remix/ui` intercepts links through the Navigation API and never touches History, so the upstream handler would emit nothing. Page loads are ordinary document loads and stay with the upstream integration. Component render errors need their own listener. The runtime funnels them into the event target `run()` returns and does not rethrow, so the global handlers never see them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent af071b7 commit dd4e6db

15 files changed

Lines changed: 821 additions & 11 deletions

File tree

‎.size-limit.js‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,20 @@ module.exports = [
225225
limit: '35 KB',
226226
disablePlugins: ['@size-limit/esbuild'],
227227
},
228+
// Remix 3 browser SDK (ESM)
229+
{
230+
// The whole published file, not a named import: Remix 3 has no bundler, so an app cannot tree shake
231+
// this and ships every byte of it. This budget is what keeps `src/v3/index.client.ts` a named list.
232+
// Adding a single `export * from '@sentry/browser'` there measures 134 KB here, and more than that
233+
// on the wire, because a real Remix 3 app has no bundler to shake the file at all.
234+
name: '@sentry/remix (Remix 3 client bundle)',
235+
path: 'packages/remix/build/esm/v3/client-bundle.js',
236+
// Every export, because the app cannot drop any of them.
237+
import: '*',
238+
gzip: true,
239+
limit: '56 KB',
240+
disablePlugins: ['@size-limit/esbuild'],
241+
},
228242
// Browser CDN bundles
229243
{
230244
name: 'CDN Bundle',

‎dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@ function HomePage(handle: Handle<Record<string, never>>) {
1717
</head>
1818
<body>
1919
<h1 id="home">Sentry Remix 3</h1>
20+
{/* No `data-rmx-document`, so the runtime intercepts this through the Navigation API. */}
21+
<a id="to-user" href="/users/12345">
22+
User
23+
</a>
24+
<button type="button" id="component-error">
25+
Component error
26+
</button>
27+
</body>
28+
</html>
29+
);
30+
}
31+
32+
function UserPage(handle: Handle<{ id?: string }>) {
33+
return () => (
34+
<html lang="en">
35+
<head>
36+
<meta charSet="utf-8" />
37+
<title>User</title>
38+
</head>
39+
<body>
40+
<h1 id="user">User {handle.props.id}</h1>
2041
</body>
2142
</html>
2243
);
@@ -31,7 +52,7 @@ export default createController(routes, {
3152
return context.render(<HomePage />);
3253
},
3354
user(context) {
34-
return Response.json({ id: context.params.id });
55+
return context.render(<UserPage id={context.params.id} />);
3556
},
3657
teapot() {
3758
return new Response("I'm a teapot", { status: 418 });
Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,35 @@
1-
import { run } from 'remix/ui';
1+
import * as Sentry from '@sentry/remix/v3/client';
2+
import { createElement, run } from 'remix/ui';
3+
4+
// Not Node: the asset server substitutes this when it compiles the module, from the `define` map in
5+
// `app/assets.ts`.
6+
declare const process: { env: Record<string, string | undefined> };
7+
8+
Sentry.init({
9+
dsn: process.env.E2E_TEST_DSN,
10+
tunnel: 'http://localhost:3061/',
11+
tracesSampleRate: 1.0,
12+
});
213

3-
// No Sentry here yet: `@sentry/remix/v3/client` does not export `init` until the browser SDK lands.
414
export const app = run({
515
async loadModule(moduleUrl, exportName) {
6-
let mod = await import(moduleUrl);
16+
const mod = await import(moduleUrl);
717
return mod[exportName];
818
},
919
});
20+
21+
// The runtime routes a render error to the event target `run()` returns and does not rethrow it, so
22+
// `window.onerror` never sees it. This is the only way the SDK can learn about it.
23+
Sentry.captureRuntimeErrors(app);
24+
25+
function Boom(): () => never {
26+
return () => {
27+
throw new Error('Component render failed');
28+
};
29+
}
30+
31+
document.addEventListener('click', event => {
32+
if ((event.target as HTMLElement | null)?.id === 'component-error') {
33+
void app.frames.top.replace(createElement(Boom, {}));
34+
}
35+
});

‎dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ export const assets = createAssetServer({
77
allowPackages: ['remix', '@sentry/remix'],
88
minify: true,
99
watch: false,
10+
scripts: {
11+
// There is no bundler, so no build time env inlining either. `define` is the only way to get
12+
// configuration into a browser module: the asset server substitutes these when it compiles it.
13+
define: {
14+
'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN),
15+
},
16+
},
1017
});
1118

1219
const entry = 'app/actions/public/entry.ts';
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect, test } from '@playwright/test';
2+
import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
3+
4+
const APP_NAME = 'remix-v3';
5+
6+
test('sends a pageload span', async ({ page }) => {
7+
const spanPromise = waitForStreamedSpan(APP_NAME, span => getSpanOp(span) === 'pageload' && span.is_segment === true);
8+
9+
await page.goto('/');
10+
11+
const span = await spanPromise;
12+
// Page loads are ordinary document loads, so this one comes from the upstream integration and takes
13+
// the low cardinality streaming name.
14+
expect(span.name).toBe('Pageload');
15+
expect(span.attributes?.['sentry.origin']?.value).toBe('auto.pageload.browser');
16+
});
17+
18+
test('sends a navigation span for a link the runtime intercepts', async ({ page }) => {
19+
await page.goto('/');
20+
21+
// Selected by origin, not just by op: `remix/ui` never touches History, so a navigation span from
22+
// the upstream handler would mean this SDK is not the one that produced it.
23+
const spanPromise = waitForStreamedSpan(
24+
APP_NAME,
25+
span =>
26+
getSpanOp(span) === 'navigation' && span.attributes?.['sentry.origin']?.value === 'auto.navigation.remix_v3',
27+
);
28+
29+
await page.locator('#to-user').click();
30+
await expect(page.locator('#user')).toBeVisible();
31+
32+
const span = await spanPromise;
33+
expect(span.is_segment).toBe(true);
34+
expect(span.name).toBe('Navigation');
35+
});
36+
37+
test('captures a component render error the runtime never rethrows', async ({ page }) => {
38+
await page.goto('/');
39+
40+
const errorPromise = waitForError(APP_NAME, event => {
41+
return !event.type && event.exception?.values?.[0]?.value === 'Component render failed';
42+
});
43+
44+
await page.locator('#component-error').click();
45+
46+
const error = await errorPromise;
47+
expect(error.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.ui.remix_v3' });
48+
});

‎packages/remix/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"./v3/client": {
6262
"import": {
6363
"types": "./build/types/v3/index.client.d.ts",
64-
"default": "./build/esm/v3/index.client.js"
64+
"default": "./build/esm/v3/client-bundle.js"
6565
}
6666
},
6767
"./v3/node": {

‎packages/remix/rollup.npm.config.mjs‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable import/no-named-as-default */
2+
import nodeResolve from '@rollup/plugin-node-resolve';
13
import { defineConfig } from 'rollup';
24
import { makeBaseNPMConfig, makeNPMConfigVariants, makeOrchestrionLoader } from '@sentry-internal/rollup-utils';
35

@@ -8,6 +10,44 @@ const v3NodeEntry = defineConfig({
810
output: { format: 'esm', file: 'build/v3-node.mjs' },
911
});
1012

13+
/**
14+
* The Remix 3 browser entry, bundled into a single self contained ES module.
15+
*
16+
* Remix 3 has no bundler: its asset server serves one HTTP request per module and can never eliminate
17+
* dead code, because nothing it does is cross module. Shipping the ordinary `preserveModules` output
18+
* costs a Remix 3 app 255 requests and about 364 KB gzipped of `@sentry/*`, against about 53 KB for the
19+
* same SDK behind a normal bundler. Tree shaking once here, at publish time, is the only place that
20+
* reduction can happen.
21+
*
22+
* Runs over `build/esm/v3/index.client.js` rather than the TypeScript source, so it inherits the
23+
* transpilation the main config already did instead of duplicating that plugin setup. It therefore has
24+
* to come last in this array.
25+
*
26+
* Deliberately unminified and without a source map: the asset server minifies what it serves and
27+
* composes maps across its own pipeline, so shipping a pre-minified file with its own map would put two
28+
* map chains in sequence for no gain.
29+
*/
30+
const v3ClientBundle = defineConfig({
31+
input: 'build/esm/v3/index.client.js',
32+
// The channel shim MUST stay external. Orchestrion's browser transform injects an import of that
33+
// file by URL into every instrumented module, so an inlined copy would leave the page holding two
34+
// shims with two separate subscriber registries, and instrumentation would silently do nothing.
35+
external: id => /diagnosticsChannelShim/.test(id) || id === 'remix' || id.startsWith('@remix-run/'),
36+
treeshake: { moduleSideEffects: false, propertyReadSideEffects: false },
37+
plugins: [nodeResolve({ browser: true, exportConditions: ['browser', 'import', 'default'] })],
38+
// Emitted inside `build/esm/v3/`, not at `build/`: the one import it keeps is the relative path to
39+
// the channel shim, which only resolves from there.
40+
output: { file: 'build/esm/v3/client-bundle.js', format: 'esm' },
41+
onwarn(warning, warn) {
42+
// `this` is undefined in the bundled output of some dependencies, and the graph has cycles. Both
43+
// are harmless here and would otherwise bury real warnings.
44+
if (warning.code === 'THIS_IS_UNDEFINED' || warning.code === 'CIRCULAR_DEPENDENCY') {
45+
return;
46+
}
47+
warn(warning);
48+
},
49+
});
50+
1151
// We rely on esbuild's defaults for JSX (`jsx: 'transform'` = classic runtime, no
1252
// __self/__source attributes). React 19 prefers the new automatic transform, but switching
1353
// to it would break React 17 support — so we intentionally stay on classic for now.
@@ -39,4 +79,5 @@ export default [
3979
}),
4080
),
4181
...makeOrchestrionLoader('./build'),
82+
v3ClientBundle,
4283
];
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {
2+
browserTracingIntegration as originalBrowserTracingIntegration,
3+
startBrowserTracingNavigationSpan,
4+
WINDOW,
5+
} from '@sentry/browser';
6+
import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes';
7+
import {
8+
type Client,
9+
hasSpanStreamingEnabled,
10+
type Integration,
11+
NAVIGATION_SPAN_NAME_FALLBACK,
12+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
13+
} from '@sentry/core';
14+
15+
type Options = Parameters<typeof originalBrowserTracingIntegration>[0];
16+
17+
/**
18+
* Browser tracing for Remix 3.
19+
*
20+
* Page loads are ordinary document loads, so those stay with the upstream integration. Navigations do
21+
* not: `remix/ui` intercepts links and form submissions through the Navigation API and never touches
22+
* History, so the upstream handler would emit nothing at all.
23+
*/
24+
export function browserTracingIntegration(options: Options = {}): Integration {
25+
const integration = originalBrowserTracingIntegration({ ...options, instrumentNavigation: false });
26+
27+
return {
28+
...integration,
29+
afterAllSetup(client) {
30+
integration.afterAllSetup(client);
31+
32+
if (options.instrumentNavigation !== false) {
33+
instrumentNavigationApi(client);
34+
}
35+
},
36+
};
37+
}
38+
39+
function instrumentNavigationApi(client: Client): void {
40+
const navigation = (WINDOW as WindowWithNavigation).navigation;
41+
if (!navigation) {
42+
// Without the Navigation API the runtime falls back to full document loads, which already show up
43+
// as page loads.
44+
return;
45+
}
46+
47+
navigation.addEventListener('navigate', event => {
48+
const url = event.destination?.url;
49+
if (!url || !isRuntimeNavigation(event, url)) {
50+
return;
51+
}
52+
53+
startBrowserTracingNavigationSpan(
54+
client,
55+
{
56+
// The browser has no route patterns in Remix 3: routes and their matcher live on the server
57+
// only, so there is nothing to parameterize a name with here.
58+
name: hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathnameOf(url) || '/',
59+
attributes: {
60+
[SENTRY_SEGMENT_NAME_SOURCE]: 'url',
61+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.remix_v3',
62+
},
63+
},
64+
// Passing the destination is what lets the span carry the URL being navigated to. `location`
65+
// still points at the previous page while the navigation is in flight.
66+
{ url },
67+
);
68+
});
69+
}
70+
71+
/**
72+
* Whether the runtime will keep this navigation inside the current document.
73+
*
74+
* These are the same three conditions `startNavigationListener` in `@remix-run/ui` applies before it
75+
* intercepts. Anything it declines becomes a fresh document load, which produces a page load span, so
76+
* a navigation span here would double count the same user action.
77+
*/
78+
function isRuntimeNavigation(event: NavigateEventLike, url: string): boolean {
79+
// `'remix-document-reload'` is the `info` value the runtime tags its own document reloads with.
80+
return event.canIntercept && event.info !== 'remix-document-reload' && isSameOrigin(url);
81+
}
82+
83+
function isSameOrigin(url: string): boolean {
84+
try {
85+
return new URL(url).origin === WINDOW.location?.origin;
86+
} catch {
87+
return false;
88+
}
89+
}
90+
91+
function pathnameOf(url: string): string | undefined {
92+
try {
93+
return new URL(url).pathname;
94+
} catch {
95+
return undefined;
96+
}
97+
}
98+
99+
interface NavigateEventLike extends Event {
100+
canIntercept: boolean;
101+
info?: unknown;
102+
destination?: { url: string };
103+
}
104+
105+
type WindowWithNavigation = typeof WINDOW & {
106+
navigation?: { addEventListener(type: 'navigate', listener: (event: NavigateEventLike) => void): void };
107+
};

0 commit comments

Comments
 (0)