Skip to content
Draft
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
14 changes: 14 additions & 0 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,20 @@ module.exports = [
limit: '35 KB',
disablePlugins: ['@size-limit/esbuild'],
},
// Remix 3 browser SDK (ESM)
{
// The whole published file, not a named import: Remix 3 has no bundler, so an app cannot tree shake
// this and ships every byte of it. This budget is what keeps `src/v3/index.client.ts` a named list.
// Adding a single `export * from '@sentry/browser'` there measures 134 KB here, and more than that
// on the wire, because a real Remix 3 app has no bundler to shake the file at all.
name: '@sentry/remix (Remix 3 client bundle)',
path: 'packages/remix/build/esm/v3/client-bundle.js',
// Every export, because the app cannot drop any of them.
import: '*',
gzip: true,
limit: '56 KB',
disablePlugins: ['@size-limit/esbuild'],
},
// Browser CDN bundles
{
name: 'CDN Bundle',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@ function HomePage(handle: Handle<Record<string, never>>) {
</head>
<body>
<h1 id="home">Sentry Remix 3</h1>
{/* No `data-rmx-document`, so the runtime intercepts this through the Navigation API. */}
<a id="to-user" href="/users/12345">
User
</a>
<button type="button" id="component-error">
Component error
</button>
</body>
</html>
);
}

function UserPage(handle: Handle<{ id?: string }>) {
return () => (
<html lang="en">
<head>
<meta charSet="utf-8" />
<title>User</title>
</head>
<body>
<h1 id="user">User {handle.props.id}</h1>
</body>
</html>
);
Expand All @@ -31,7 +52,7 @@ export default createController(routes, {
return context.render(<HomePage />);
},
user(context) {
return Response.json({ id: context.params.id });
return context.render(<UserPage id={context.params.id} />);
},
teapot() {
return new Response("I'm a teapot", { status: 418 });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
import { run } from 'remix/ui';
import * as Sentry from '@sentry/remix/v3/client';
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`.
declare const process: { env: Record<string, string | undefined> };

Sentry.init({
dsn: process.env.E2E_TEST_DSN,
tunnel: 'http://localhost:3061/',
tracesSampleRate: 1.0,
});

// No Sentry here yet: `@sentry/remix/v3/client` does not export `init` until the browser SDK lands.
export const app = run({
async loadModule(moduleUrl, exportName) {
let mod = await import(moduleUrl);
const mod = await import(moduleUrl);
return mod[exportName];
},
});

// The runtime routes a render error to the event target `run()` returns and does not rethrow it, so
// `window.onerror` never sees it. This is the only way the SDK can learn 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
Expand Up @@ -7,6 +7,13 @@ export const assets = createAssetServer({
allowPackages: ['remix', '@sentry/remix'],
minify: true,
watch: false,
scripts: {
// There is no bundler, so no build time env inlining either. `define` is the only way to get
// configuration into a browser module: the asset server substitutes these when it compiles it.
define: {
'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN),
},
},
});

const entry = 'app/actions/public/entry.ts';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { expect, test } from '@playwright/test';
import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';

const APP_NAME = 'remix-v3';

test('sends a pageload span', async ({ page }) => {
const spanPromise = waitForStreamedSpan(APP_NAME, span => getSpanOp(span) === 'pageload' && span.is_segment === true);

await page.goto('/');

const span = await spanPromise;
// Page loads are ordinary document loads, so this one comes from the upstream integration and takes
// the low cardinality streaming name.
expect(span.name).toBe('Pageload');
expect(span.attributes?.['sentry.origin']?.value).toBe('auto.pageload.browser');
});

test('sends a navigation span for a link the runtime intercepts', async ({ page }) => {
await page.goto('/');

// Selected by origin, not just by op: `remix/ui` never touches History, so a navigation span from
// the upstream handler would mean this SDK is not the one that produced it.
const spanPromise = waitForStreamedSpan(
APP_NAME,
span =>
getSpanOp(span) === 'navigation' && span.attributes?.['sentry.origin']?.value === 'auto.navigation.remix_v3',
);

await page.locator('#to-user').click();
await expect(page.locator('#user')).toBeVisible();

const span = await spanPromise;
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' });
});
2 changes: 1 addition & 1 deletion packages/remix/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"./v3/client": {
"import": {
"types": "./build/types/v3/index.client.d.ts",
"default": "./build/esm/v3/index.client.js"
"default": "./build/esm/v3/client-bundle.js"
}
},
"./v3/node": {
Expand Down
41 changes: 41 additions & 0 deletions packages/remix/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* eslint-disable import/no-named-as-default */
import nodeResolve from '@rollup/plugin-node-resolve';
import { defineConfig } from 'rollup';
import { makeBaseNPMConfig, makeNPMConfigVariants, makeOrchestrionLoader } from '@sentry-internal/rollup-utils';

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

/**
* The Remix 3 browser entry, bundled into a single self contained ES module.
*
* Remix 3 has no bundler: its asset server serves one HTTP request per module and can never eliminate
* dead code, because nothing it does is cross module. Shipping the ordinary `preserveModules` output
* costs a Remix 3 app 255 requests and about 364 KB gzipped of `@sentry/*`, against about 53 KB for the
* same SDK behind a normal bundler. Tree shaking once here, at publish time, is the only place that
* reduction can happen.
*
* Runs over `build/esm/v3/index.client.js` rather than the TypeScript source, so it inherits the
* transpilation the main config already did instead of duplicating that plugin setup. It therefore has
* to come last in this array.
*
* Deliberately unminified and without a source map: the asset server minifies what it serves and
* composes maps across its own pipeline, so shipping a pre-minified file with its own map would put two
* map chains in sequence for no gain.
*/
const v3ClientBundle = defineConfig({
input: 'build/esm/v3/index.client.js',
// The channel shim MUST stay external. Orchestrion's browser transform injects an import of that
// file by URL into every instrumented module, so an inlined copy would leave the page holding two
// shims with two separate subscriber registries, and instrumentation would silently 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
// the channel shim, which only resolves from there.
output: { file: 'build/esm/v3/client-bundle.js', format: 'esm' },
onwarn(warning, warn) {
// `this` is undefined in the bundled output of some dependencies, and the graph has cycles. Both
// are harmless here and would otherwise bury real warnings.
if (warning.code === 'THIS_IS_UNDEFINED' || warning.code === 'CIRCULAR_DEPENDENCY') {
return;
}
warn(warning);
},
});

// We rely on esbuild's defaults for JSX (`jsx: 'transform'` = classic runtime, no
// __self/__source attributes). React 19 prefers the new automatic transform, but switching
// to it would break React 17 support — so we intentionally stay on classic for now.
Expand Down Expand Up @@ -39,4 +79,5 @@ export default [
}),
),
...makeOrchestrionLoader('./build'),
v3ClientBundle,
];
107 changes: 107 additions & 0 deletions packages/remix/src/v3/client/browserTracingIntegration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
browserTracingIntegration as originalBrowserTracingIntegration,
startBrowserTracingNavigationSpan,
WINDOW,
} from '@sentry/browser';
import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes';
import {
type Client,
hasSpanStreamingEnabled,
type Integration,
NAVIGATION_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
} from '@sentry/core';

type Options = Parameters<typeof originalBrowserTracingIntegration>[0];

/**
* Browser tracing for Remix 3.
*
* Page loads are ordinary document loads, so those stay with the upstream integration. Navigations do
* not: `remix/ui` intercepts links and form submissions through the Navigation API and never touches
* History, so the upstream handler would emit nothing at all.
*/
export function browserTracingIntegration(options: Options = {}): Integration {
const integration = originalBrowserTracingIntegration({ ...options, instrumentNavigation: false });

return {
...integration,
afterAllSetup(client) {
integration.afterAllSetup(client);

if (options.instrumentNavigation !== false) {
instrumentNavigationApi(client);
}
},
};
}

function instrumentNavigationApi(client: Client): void {
const navigation = (WINDOW as WindowWithNavigation).navigation;
if (!navigation) {
// Without the Navigation API the runtime falls back to full document loads, which already show up
// as page loads.
return;
}

navigation.addEventListener('navigate', event => {
const url = event.destination?.url;
if (!url || !isRuntimeNavigation(event, url)) {
return;
}

startBrowserTracingNavigationSpan(
client,
{
// The browser has no route patterns in Remix 3: routes and their matcher live on the server
// only, so there is nothing to parameterize a name with here.
name: hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathnameOf(url) || '/',
attributes: {
[SENTRY_SEGMENT_NAME_SOURCE]: 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.remix_v3',
},
},
// Passing the destination is what lets the span carry the URL being navigated to. `location`
// still points at the previous page while the navigation is in flight.
{ url },
);
});
}

/**
* Whether the runtime will keep this navigation inside the current document.
*
* These are the same three conditions `startNavigationListener` in `@remix-run/ui` applies before it
* intercepts. Anything it declines becomes a fresh document load, which produces a page load span, so
* a navigation span here would double count the same user action.
*/
function isRuntimeNavigation(event: NavigateEventLike, url: string): boolean {
// `'remix-document-reload'` is the `info` value the runtime tags its own document reloads with.
return event.canIntercept && event.info !== 'remix-document-reload' && isSameOrigin(url);
}

function isSameOrigin(url: string): boolean {
try {
return new URL(url).origin === WINDOW.location?.origin;
} catch {
return false;
}
}

function pathnameOf(url: string): string | undefined {
try {
return new URL(url).pathname;
} catch {
return undefined;
}
}

interface NavigateEventLike extends Event {
canIntercept: boolean;
info?: unknown;
destination?: { url: string };
}

type WindowWithNavigation = typeof WINDOW & {
navigation?: { addEventListener(type: 'navigate', listener: (event: NavigateEventLike) => void): void };
};
Loading
Loading