Skip to content

Commit 2cbe188

Browse files
chargomeclaude
andauthored
feat(remix): Inject debug IDs through the Remix 3 asset server (#24761)
Remix 3 has no build step, so there is no bundler plugin to inject debug IDs. The asset server compiles each module on request, and this patches `createAssetServer` to add a loader that injects the debug ID snippet into every compiled module. The ID is a hash of the compiled source and the module URL, so it is stable across requests and the source map upload can arrive at the same ID in another process. The minifier drops comments and the asset server rebuilds source maps after the loaders ran. So the `//# debugId=` comment and the `debugId` field in the map, which is what `sentry-cli` reads, are added to the served response instead. Source maps follow the other meta framework SDKs. Left out, they are generated but hidden: modules do not reference them and `.map` requests are not served. `sourceMaps: false` keeps them off, with a warning that stack traces stay minified. `getDebugId` and the snippet mirror `@sentry/bundler-plugins/core` rather than importing it, because that entry loads the whole build plugin into the server. Fixes #24667 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 563e333 commit 2cbe188

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)