Skip to content

Commit 78f8a55

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 cd5e136 commit 78f8a55

12 files changed

Lines changed: 787 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+
// `load` fires once every module script and modulepreload has executed, which is what is collected.
19+
await page.goto('/', { waitUntil: 'load' });
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: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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+
const MAX_STAMPED_BODIES = 2000;
47+
48+
let instrumented = false;
49+
50+
/**
51+
* Makes every Remix 3 asset server created from now on serve browser modules that carry debug IDs.
52+
*
53+
* Source maps follow the other meta framework SDKs:
54+
* - `sourceMaps: false` keeps them off, with a warning that stack traces stay minified.
55+
* - `'inline'` or `'external'` is kept as the app configured it.
56+
* - Left out, they are generated but hidden: modules do not reference them and `.map` requests are
57+
* not served, so the source only reaches Sentry.
58+
*
59+
* Has to run before the app's first `createAssetServer()` call, which usually happens while its
60+
* modules are imported. `Sentry.init()` is too late for that, so the `@sentry/remix/v3/node` entry
61+
* calls this.
62+
*/
63+
export function instrumentAssetServer(): void {
64+
if (instrumented) {
65+
return;
66+
}
67+
instrumented = true;
68+
69+
// Node rethrows anything a channel subscriber throws as an uncaught exception, which would kill an
70+
// app that runs fine without Sentry. Frozen options or an unexpected server shape reach here, so
71+
// the asset server is left as it was instead.
72+
diagnosticsChannel.tracingChannel(remixV3Channels.REMIX_V3_CREATE_ASSET_SERVER).subscribe({
73+
start(data) {
74+
try {
75+
const context = data as CreateAssetServerContext;
76+
const options = context.arguments[0] as AssetServerOptions | undefined;
77+
context._sentryHideSourceMaps = options?.sourceMaps === undefined;
78+
context.arguments[0] = withDebugIdOptions(options);
79+
} catch {
80+
// Ignored on purpose.
81+
}
82+
},
83+
end(data) {
84+
try {
85+
const { result, _sentryHideSourceMaps } = data as CreateAssetServerContext;
86+
if (result) {
87+
stampServedAssets(result as AssetServer, { hideSourceMaps: Boolean(_sentryHideSourceMaps) });
88+
}
89+
} catch {
90+
// Ignored on purpose.
91+
}
92+
},
93+
asyncStart() {},
94+
asyncEnd() {},
95+
error() {},
96+
});
97+
}
98+
99+
/**
100+
* Injects the debug ID snippet into every module the asset server compiles.
101+
*
102+
* Loaders run after the TypeScript transform and before minification, so the snippet is minified
103+
* along with the module, and its ID is a hash of the compiled source. The module URL is part of the
104+
* hash so two identical files get IDs of their own, since their source maps differ.
105+
*/
106+
export const debugIdLoader: ModuleLoader = (url, context, nextLoad) => {
107+
const result = nextLoad(url, context);
108+
if (result.format !== 'module' || typeof result.source !== 'string') {
109+
return result;
110+
}
111+
112+
const debugId = getDebugId(`${context.moduleUrl ?? url}\n${result.source}`);
113+
return { ...result, source: injectDebugIdSnippet(result.source, debugId) };
114+
};
115+
116+
function withDebugIdOptions(options: AssetServerOptions | undefined): AssetServerOptions | undefined {
117+
if (!options) {
118+
return options;
119+
}
120+
121+
if (options.sourceMaps === false) {
122+
consoleSandbox(() => {
123+
// oxlint-disable-next-line no-console
124+
console.warn(
125+
'[Sentry] Source maps are disabled in your asset server (`sourceMaps: false`). Sentry will not override this, so client stack traces stay minified.',
126+
);
127+
});
128+
}
129+
130+
const loaders = options.scripts?.loaders ?? [];
131+
132+
return {
133+
...options,
134+
// Hidden unless the app chose otherwise, see `stampServedAssets`.
135+
sourceMaps: options.sourceMaps ?? 'external',
136+
scripts: {
137+
...options.scripts,
138+
// Last, so the ID also covers whatever the app's own loaders changed.
139+
loaders: loaders.includes(debugIdLoader) ? loaders : [...loaders, debugIdLoader],
140+
},
141+
};
142+
}
143+
144+
/**
145+
* The minifier drops comments and the asset server rebuilds source maps after the loaders ran, so
146+
* the `//# debugId=` comment and the source map's `debugId` field, which is what `sentry-cli` reads,
147+
* are added to the served response instead.
148+
*/
149+
function stampServedAssets(server: AssetServer, { hideSourceMaps }: { hideSourceMaps: boolean }): void {
150+
const fetchAsset = server.fetch;
151+
// The asset server memoizes compiled modules and identifies each version by its ETag, so the
152+
// stamped body is memoized the same way. Without this every hit copied the module body again.
153+
const stamped = new Map<string, string>();
154+
155+
server.fetch = async request => {
156+
const response = await fetchAsset(request);
157+
if (response?.status !== 200 || request.method !== 'GET') {
158+
return response;
159+
}
160+
161+
const url = new URL(request.url);
162+
const contentType = response.headers.get('content-type') ?? '';
163+
const etag = response.headers.get('etag');
164+
165+
if (contentType.includes('javascript')) {
166+
return withBody(response, await remember(etag, async () => stampModule(await response.text())));
167+
}
168+
169+
if (url.pathname.endsWith('.map')) {
170+
if (hideSourceMaps) {
171+
// What the asset server returns for a path it does not serve.
172+
return null;
173+
}
174+
175+
const body = await remember(etag, async () => {
176+
// A source map does not contain the ID of its module, so it is read from the module itself.
177+
url.pathname = url.pathname.slice(0, -'.map'.length);
178+
const moduleResponse = await fetchAsset(new Request(url));
179+
const debugId = moduleResponse?.ok ? findDebugId(await moduleResponse.text()) : undefined;
180+
const map = await response.text();
181+
return debugId ? addDebugIdToSourceMap(map, debugId) : map;
182+
});
183+
return withBody(response, body);
184+
}
185+
186+
return response;
187+
};
188+
189+
function stampModule(served: string): string {
190+
const code = hideSourceMaps ? served.replace(SOURCE_MAPPING_URL_REGEX, '') : served;
191+
const debugId = findDebugId(code);
192+
return debugId ? `${code}\n//# debugId=${debugId}` : code;
193+
}
194+
195+
async function remember(etag: string | null, compute: () => Promise<string>): Promise<string> {
196+
if (etag === null) {
197+
return compute();
198+
}
199+
const cached = stamped.get(etag);
200+
if (cached !== undefined) {
201+
return cached;
202+
}
203+
const body = await compute();
204+
// Bounded, because in watch mode every edit is a new ETag.
205+
if (stamped.size >= MAX_STAMPED_BODIES) {
206+
stamped.delete(stamped.keys().next().value as string);
207+
}
208+
stamped.set(etag, body);
209+
return body;
210+
}
211+
}
212+
213+
function withBody(response: Response, body: string): Response {
214+
const headers = new Headers(response.headers);
215+
headers.delete('content-length');
216+
return new Response(body, { status: response.status, statusText: response.statusText, headers });
217+
}

0 commit comments

Comments
 (0)