Skip to content

Commit 22bb2d8

Browse files
chargomeclaude
andcommitted
feat(remix): Inject debug IDs through the Remix 3 asset server
Patches createAssetServer so every browser module it serves carries a debug ID, with no config from the app. Source maps follow the other meta framework SDKs: generated but hidden unless the app configures them. Fixes #24667 Refs JS-3770 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f33fbed commit 22bb2d8

12 files changed

Lines changed: 701 additions & 4 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ function HomePage(handle: Handle<Record<string, never>>) {
2424
<button type="button" id="component-error">
2525
Component error
2626
</button>
27+
<button id="throw-error" type="button">
28+
Throw error
29+
</button>
2730
</body>
2831
</html>
2932
);

‎dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import * as Sentry from '@sentry/remix/v3/client';
22
import { createElement, run } from 'remix/ui';
33

4+
import { throwError } from './throw-error.ts';
5+
46
// Not Node. The asset server substitutes this when it compiles the module, from the `define` map in
57
// `app/assets.ts`.
68
declare const process: { env: Record<string, string | undefined> };
@@ -29,7 +31,11 @@ function Boom(): () => never {
2931
}
3032

3133
document.addEventListener('click', event => {
32-
if ((event.target as HTMLElement | null)?.id === 'component-error') {
34+
const id = (event.target as HTMLElement | null)?.id;
35+
if (id === 'component-error') {
3336
void app.frames.top.replace(createElement(Boom, {}));
3437
}
38+
if (id === 'throw-error') {
39+
throwError();
40+
}
3541
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function throwError(): never {
2+
throw new Error('Remix 3 client error');
3+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ export const assets = createAssetServer({
66
allowFiles: ['app/routes.ts', 'app/**/public/**'],
77
allowPackages: ['remix', '@sentry/remix'],
88
minify: true,
9+
scripts: {
10+
define: { 'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN) },
11+
},
912
watch: false,
1013
scripts: {
1114
// No bundler means no build time env inlining, so `define` is the only way to get configuration
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError } from '@sentry-internal/test-utils';
3+
4+
const DEBUG_ID = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
5+
6+
function getDebugId(code: string): string | undefined {
7+
return code.match(new RegExp(`\\n//# debugId=(${DEBUG_ID})$`))?.[1];
8+
}
9+
10+
test('every served browser module carries a debug ID', async ({ page, baseURL }) => {
11+
const moduleUrls: string[] = [];
12+
page.on('response', response => {
13+
if (response.request().resourceType() === 'script' && response.url().startsWith(`${baseURL}/assets/`)) {
14+
moduleUrls.push(response.url());
15+
}
16+
});
17+
18+
await page.goto('/');
19+
await page.waitForLoadState('networkidle');
20+
21+
expect(moduleUrls).toContainEqual(expect.stringContaining('/assets/app/actions/public/entry.ts'));
22+
23+
for (const url of moduleUrls) {
24+
const code = await (await fetch(url)).text();
25+
const debugId = getDebugId(code);
26+
27+
expect(debugId, `${url} has no debugId comment`).toBeDefined();
28+
expect(code).toContain(`sentry-dbid-${debugId}`);
29+
}
30+
});
31+
32+
// The app does not configure source maps, so they are hidden, like the other meta framework SDKs do.
33+
test('source maps are not exposed when the app did not ask for them', async ({ baseURL }) => {
34+
const url = `${baseURL}/assets/app/actions/public/entry.ts`;
35+
36+
const code = await (await fetch(url)).text();
37+
const sourceMapResponse = await fetch(`${url}.map`);
38+
39+
expect(code).not.toContain('//# sourceMappingURL=');
40+
expect(sourceMapResponse.status).toBe(404);
41+
});
42+
43+
test('the debug ID of a module is stable across requests', async ({ baseURL }) => {
44+
const url = `${baseURL}/assets/app/actions/public/throw-error.ts`;
45+
46+
const first = getDebugId(await (await fetch(url)).text());
47+
const second = getDebugId(await (await fetch(url)).text());
48+
49+
expect(first).toMatch(new RegExp(`^${DEBUG_ID}$`));
50+
expect(second).toBe(first);
51+
});
52+
53+
test('a client error carries the debug IDs of the modules in its stack trace', async ({ page, baseURL }) => {
54+
const errorPromise = waitForError('remix-v3', event => {
55+
return !event.type && event.exception?.values?.[0]?.value === 'Remix 3 client error';
56+
});
57+
58+
await page.goto('/');
59+
await page.locator('#throw-error').click();
60+
61+
const errorEvent = await errorPromise;
62+
63+
const moduleUrl = `${baseURL}/assets/app/actions/public/throw-error.ts`;
64+
const debugId = getDebugId(await (await fetch(moduleUrl)).text());
65+
66+
expect(errorEvent.debug_meta?.images).toContainEqual({
67+
type: 'sourcemap',
68+
code_file: moduleUrl,
69+
debug_id: debugId,
70+
});
71+
});
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import * as diagnosticsChannel from 'node:diagnostics_channel';
2+
import { consoleSandbox } from '@sentry/core';
3+
import { remixV3Channels } from '@sentry/server-utils/orchestrion/config';
4+
import { addDebugIdToSourceMap, findDebugId, getDebugId, injectDebugIdSnippet } from './debugId';
5+
6+
// The subset of `@remix-run/assets` types used here. `remix` is an optional peer dependency, so
7+
// they are restated rather than imported.
8+
interface ModuleLoadContext {
9+
moduleUrl?: string;
10+
[key: string]: unknown;
11+
}
12+
13+
interface ModuleLoadResult {
14+
format: string | null | undefined;
15+
shortCircuit?: boolean;
16+
source?: string | ArrayBuffer | ArrayBufferView;
17+
}
18+
19+
type ModuleLoader = (
20+
url: string,
21+
context: ModuleLoadContext,
22+
nextLoad: (url: string, context?: Partial<ModuleLoadContext>) => ModuleLoadResult,
23+
) => ModuleLoadResult;
24+
25+
interface AssetServerOptions {
26+
// `false` is not in Remix's type, but is how an app tells Sentry it wants no source maps at all,
27+
// since leaving the option out now means hidden source maps.
28+
sourceMaps?: 'inline' | 'external' | false;
29+
scripts?: { loaders?: readonly ModuleLoader[]; [key: string]: unknown };
30+
[key: string]: unknown;
31+
}
32+
33+
interface AssetServer {
34+
fetch: (request: Request) => Promise<Response | null>;
35+
}
36+
37+
interface CreateAssetServerContext {
38+
arguments: unknown[];
39+
result?: unknown;
40+
_sentryHideSourceMaps?: boolean;
41+
}
42+
43+
// The asset server appends it after minification, so it is always the last line.
44+
const SOURCE_MAPPING_URL_REGEX = /\n\/\/# sourceMappingURL=\S+\s*$/;
45+
46+
let instrumented = false;
47+
48+
/**
49+
* Makes every Remix 3 asset server created from now on serve browser modules that carry debug IDs.
50+
*
51+
* Source maps follow the other meta framework SDKs:
52+
* - `sourceMaps: false` keeps them off, with a warning that stack traces stay minified.
53+
* - `'inline'` or `'external'` is kept as the app configured it.
54+
* - Left out, they are generated but hidden: modules do not reference them and `.map` requests are
55+
* not served, so the source only reaches Sentry.
56+
*
57+
* Has to run before the app's first `createAssetServer()` call, which usually happens while its
58+
* modules are imported. `Sentry.init()` is too late for that, so the `@sentry/remix/v3/node` entry
59+
* calls this.
60+
*/
61+
export function instrumentAssetServer(): void {
62+
if (instrumented) {
63+
return;
64+
}
65+
instrumented = true;
66+
67+
// Node rethrows anything a channel subscriber throws as an uncaught exception, which would kill an
68+
// app that runs fine without Sentry. Frozen options or an unexpected server shape reach here, so
69+
// the asset server is left as it was instead.
70+
diagnosticsChannel.tracingChannel(remixV3Channels.REMIX_V3_CREATE_ASSET_SERVER).subscribe({
71+
start(data) {
72+
try {
73+
const context = data as CreateAssetServerContext;
74+
const options = context.arguments[0] as AssetServerOptions | undefined;
75+
context._sentryHideSourceMaps = options?.sourceMaps === undefined;
76+
context.arguments[0] = withDebugIdOptions(options);
77+
} catch {
78+
// Ignored on purpose.
79+
}
80+
},
81+
end(data) {
82+
try {
83+
const { result, _sentryHideSourceMaps } = data as CreateAssetServerContext;
84+
if (result) {
85+
stampServedAssets(result as AssetServer, { hideSourceMaps: Boolean(_sentryHideSourceMaps) });
86+
}
87+
} catch {
88+
// Ignored on purpose.
89+
}
90+
},
91+
asyncStart() {},
92+
asyncEnd() {},
93+
error() {},
94+
});
95+
}
96+
97+
/**
98+
* Injects the debug ID snippet into every module the asset server compiles.
99+
*
100+
* Loaders run after the TypeScript transform and before minification, so the snippet is minified
101+
* along with the module, and its ID is a hash of the compiled source. The module URL is part of the
102+
* hash so two identical files get IDs of their own, since their source maps differ.
103+
*/
104+
export const debugIdLoader: ModuleLoader = (url, context, nextLoad) => {
105+
const result = nextLoad(url, context);
106+
if (result.format !== 'module' || typeof result.source !== 'string') {
107+
return result;
108+
}
109+
110+
const debugId = getDebugId(`${context.moduleUrl ?? url}\n${result.source}`);
111+
return { ...result, source: injectDebugIdSnippet(result.source, debugId) };
112+
};
113+
114+
function withDebugIdOptions(options: AssetServerOptions | undefined): AssetServerOptions | undefined {
115+
if (!options) {
116+
return options;
117+
}
118+
119+
if (options.sourceMaps === false) {
120+
consoleSandbox(() => {
121+
// oxlint-disable-next-line no-console
122+
console.warn(
123+
'[Sentry] Source maps are disabled in your asset server (`sourceMaps: false`). Sentry will not override this, so client stack traces stay minified.',
124+
);
125+
});
126+
}
127+
128+
const loaders = options.scripts?.loaders ?? [];
129+
130+
return {
131+
...options,
132+
// Hidden unless the app chose otherwise, see `stampServedAssets`.
133+
sourceMaps: options.sourceMaps ?? 'external',
134+
scripts: {
135+
...options.scripts,
136+
// Last, so the ID also covers whatever the app's own loaders changed.
137+
loaders: loaders.includes(debugIdLoader) ? loaders : [...loaders, debugIdLoader],
138+
},
139+
};
140+
}
141+
142+
/**
143+
* The minifier drops comments and the asset server rebuilds source maps after the loaders ran, so
144+
* the `//# debugId=` comment and the source map's `debugId` field, which is what `sentry-cli` reads,
145+
* are added to the served response instead.
146+
*/
147+
function stampServedAssets(server: AssetServer, { hideSourceMaps }: { hideSourceMaps: boolean }): void {
148+
const fetchAsset = server.fetch;
149+
150+
server.fetch = async request => {
151+
const response = await fetchAsset(request);
152+
if (response?.status !== 200 || request.method !== 'GET') {
153+
return response;
154+
}
155+
156+
const url = new URL(request.url);
157+
const contentType = response.headers.get('content-type') ?? '';
158+
159+
if (contentType.includes('javascript')) {
160+
let code = await response.text();
161+
if (hideSourceMaps) {
162+
code = code.replace(SOURCE_MAPPING_URL_REGEX, '');
163+
}
164+
const debugId = findDebugId(code);
165+
return withBody(response, debugId ? `${code}\n//# debugId=${debugId}` : code);
166+
}
167+
168+
if (url.pathname.endsWith('.map')) {
169+
if (hideSourceMaps) {
170+
// What the asset server returns for a path it does not serve.
171+
return null;
172+
}
173+
174+
// A source map does not contain the ID of its module, so it is read from the module itself.
175+
url.pathname = url.pathname.slice(0, -'.map'.length);
176+
const moduleResponse = await fetchAsset(new Request(url));
177+
const debugId = moduleResponse?.ok ? findDebugId(await moduleResponse.text()) : undefined;
178+
if (!debugId) {
179+
return response;
180+
}
181+
return withBody(response, addDebugIdToSourceMap(await response.text(), debugId));
182+
}
183+
184+
return response;
185+
};
186+
}
187+
188+
function withBody(response: Response, body: string): Response {
189+
const headers = new Headers(response.headers);
190+
headers.delete('content-length');
191+
return new Response(body, { status: response.status, statusText: response.statusText, headers });
192+
}

0 commit comments

Comments
 (0)