Skip to content

Commit a6ddb66

Browse files
chargomeclaude
andauthored
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. Unbundled that costs an app 255 requests and about 364 KB gzipped; bundled it is 2 requests and 54 KB. The size budget is here rather than in a later pull request because one wildcard re-export silently undoes all of 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 upstream. Client error capture is a separate pull request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b7ba580 commit a6ddb66

12 files changed

Lines changed: 521 additions & 10 deletions

File tree

‎.size-limit.js‎

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

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,24 @@ 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+
</body>
25+
</html>
26+
);
27+
}
28+
29+
function UserPage(handle: Handle<{ id?: string }>) {
30+
return () => (
31+
<html lang="en">
32+
<head>
33+
<meta charSet="utf-8" />
34+
<title>User</title>
35+
</head>
36+
<body>
37+
<h1 id="user">User {handle.props.id}</h1>
2038
</body>
2139
</html>
2240
);
@@ -31,7 +49,7 @@ export default createController(routes, {
3149
return context.render(<HomePage />);
3250
},
3351
user(context) {
34-
return Response.json({ id: context.params.id });
52+
return context.render(<UserPage id={context.params.id} />);
3553
},
3654
teapot() {
3755
return new Response("I'm a teapot", { status: 418 });
Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1+
import * as Sentry from '@sentry/remix/v3/client';
12
import { run } from 'remix/ui';
23

3-
// No Sentry here yet: `@sentry/remix/v3/client` does not export `init` until the browser SDK lands.
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+
});
13+
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
});

‎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+
// No bundler means no build time env inlining, so `define` is the only way to get configuration
12+
// into a browser module. The asset server substitutes these when it compiles.
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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { expect, test } from '@playwright/test';
2+
import { getSpanOp, 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+
// Ordinary document load, so this span comes from the upstream integration and takes the low
13+
// 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 op. `remix/ui` never touches History, so a span from the upstream
22+
// handler would mean this SDK did not produce 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+
});

‎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: 36 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,39 @@ 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 never drops dead
17+
* code, because it only ever looks at one module at a time. The ordinary `preserveModules` output
18+
* therefore costs an app 255 requests and about 364 KB gzipped of `@sentry/*`; bundled here it is 2
19+
* requests and 54 KB. Publish time is the only place that reduction can happen.
20+
*
21+
* Runs over `build/esm/v3/index.client.js` rather than the TypeScript source, to reuse the transpilation
22+
* the main config already did. That is why it has to come last in this array.
23+
*
24+
* Left unminified and without a source map on purpose. The asset server minifies what it serves and
25+
* builds its own map chain, so a pre-minified file with its own map would add a second chain for
26+
* nothing.
27+
*/
28+
const v3ClientBundle = defineConfig({
29+
input: 'build/esm/v3/index.client.js',
30+
external: id => id === 'remix' || id.startsWith('@remix-run/'),
31+
treeshake: { moduleSideEffects: false, propertyReadSideEffects: false },
32+
plugins: [nodeResolve({ browser: true, exportConditions: ['browser', 'import', 'default'] })],
33+
// Emitted inside `build/esm/v3/`, not at `build/`: the one import it keeps is the relative path to
34+
// the channel shim, which only resolves from there.
35+
output: { file: 'build/esm/v3/client-bundle.js', format: 'esm' },
36+
onwarn(warning, warn) {
37+
// `this` is undefined in the bundled output of some dependencies, and the graph has cycles. Both
38+
// are harmless here and would otherwise bury real warnings.
39+
if (warning.code === 'THIS_IS_UNDEFINED' || warning.code === 'CIRCULAR_DEPENDENCY') {
40+
return;
41+
}
42+
warn(warning);
43+
},
44+
});
45+
1146
// We rely on esbuild's defaults for JSX (`jsx: 'transform'` = classic runtime, no
1247
// __self/__source attributes). React 19 prefers the new automatic transform, but switching
1348
// to it would break React 17 support — so we intentionally stay on classic for now.
@@ -39,4 +74,5 @@ export default [
3974
}),
4075
),
4176
...makeOrchestrionLoader('./build'),
77+
v3ClientBundle,
4278
];
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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 stay with the upstream integration, because they are ordinary document loads. Navigations
21+
* do not: `remix/ui` intercepts links and form submissions through the Navigation API and never touches
22+
* History, so the upstream handler never fires.
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 does full document loads, which are page loads already.
43+
return;
44+
}
45+
46+
navigation.addEventListener('navigate', event => {
47+
const url = event.destination?.url;
48+
if (!url || !isRuntimeNavigation(event, url)) {
49+
return;
50+
}
51+
52+
startBrowserTracingNavigationSpan(
53+
client,
54+
{
55+
// Remix 3 gives the browser no route to name this after: `remix/ui` exposes no matched route
56+
// and never matches client side. Passing the server's pattern down is tracked in (#24872).
57+
name: hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathnameOf(url) || '/',
58+
attributes: {
59+
[SENTRY_SEGMENT_NAME_SOURCE]: 'url',
60+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.remix_v3',
61+
},
62+
},
63+
// The span needs the destination: `location` still points at the previous page until the
64+
// navigation finishes.
65+
{ url },
66+
);
67+
});
68+
}
69+
70+
/**
71+
* Whether the runtime will keep this navigation inside the current document.
72+
*
73+
* The same three conditions `startNavigationListener` in `@remix-run/ui` checks before intercepting.
74+
* What it declines becomes a new document load, which already gets a page load span, so a navigation
75+
* span here would count the same click twice.
76+
*/
77+
function isRuntimeNavigation(event: NavigateEventLike, url: string): boolean {
78+
// `'remix-document-reload'` is the `info` value the runtime tags its own document reloads with.
79+
return event.canIntercept && event.info !== 'remix-document-reload' && isSameOrigin(url);
80+
}
81+
82+
function isSameOrigin(url: string): boolean {
83+
try {
84+
return new URL(url).origin === WINDOW.location?.origin;
85+
} catch {
86+
return false;
87+
}
88+
}
89+
90+
function pathnameOf(url: string): string | undefined {
91+
try {
92+
return new URL(url).pathname;
93+
} catch {
94+
return undefined;
95+
}
96+
}
97+
98+
interface NavigateEventLike extends Event {
99+
canIntercept: boolean;
100+
info?: unknown;
101+
destination?: { url: string };
102+
}
103+
104+
type WindowWithNavigation = typeof WINDOW & {
105+
navigation?: { addEventListener(type: 'navigate', listener: (event: NavigateEventLike) => void): void };
106+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { BrowserOptions } from '@sentry/browser';
2+
import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as browserInit } from '@sentry/browser';
3+
import { applySdkMetadata, type Client, type Integration } from '@sentry/core';
4+
5+
import { browserTracingIntegration } from './browserTracingIntegration';
6+
7+
/**
8+
* Default integrations for the Remix 3 client SDK.
9+
*
10+
* Browser tracing is added here rather than left to the app, because the Navigation API variant is the
11+
* only one that reports anything in Remix 3. Everything else is plain `@sentry/browser`. Nothing from
12+
* `@sentry/react` applies: `remix/ui` is its own runtime, with no React and no reconciler to hook.
13+
*/
14+
export function getDefaultIntegrations(options: BrowserOptions): Integration[] {
15+
return [...getBrowserDefaultIntegrations(options), browserTracingIntegration()];
16+
}
17+
18+
/** Initialize the Sentry Remix 3 SDK in the browser. */
19+
export function init(options: BrowserOptions): Client | undefined {
20+
const opts = {
21+
...options,
22+
defaultIntegrations: options.defaultIntegrations ?? getDefaultIntegrations(options),
23+
};
24+
25+
applySdkMetadata(opts, 'remix', ['remix', 'browser']);
26+
27+
return browserInit(opts);
28+
}

0 commit comments

Comments
 (0)