-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat(remix): Inject debug IDs through the Remix 3 asset server #24761
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
chargome
merged 1 commit into
charlygomez/js-3768-remix-3-client-error-capture
from
charlygomez/js-3770-integrate-with-the-remix-3-asset-server-for-debug-ids
Oct 1, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
3 changes: 3 additions & 0 deletions
3
dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export function throwError(): never { | ||
| throw new Error('Remix 3 client error'); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
71 changes: 71 additions & 0 deletions
71
dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
| import { waitForError } from '@sentry-internal/test-utils'; | ||
|
|
||
| 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}'; | ||
|
|
||
| function getDebugId(code: string): string | undefined { | ||
| return code.match(new RegExp(`\\n//# debugId=(${DEBUG_ID})$`))?.[1]; | ||
| } | ||
|
|
||
| test('every served browser module carries a debug ID', async ({ page, baseURL }) => { | ||
| const moduleUrls: string[] = []; | ||
| page.on('response', response => { | ||
| if (response.request().resourceType() === 'script' && response.url().startsWith(`${baseURL}/assets/`)) { | ||
| moduleUrls.push(response.url()); | ||
| } | ||
| }); | ||
|
|
||
| // `load` fires once every module script and modulepreload has executed, which is what is collected. | ||
| await page.goto('/', { waitUntil: 'load' }); | ||
|
|
||
| expect(moduleUrls).toContainEqual(expect.stringContaining('/assets/app/actions/public/entry.ts')); | ||
|
|
||
| for (const url of moduleUrls) { | ||
| const code = await (await fetch(url)).text(); | ||
| const debugId = getDebugId(code); | ||
|
|
||
| expect(debugId, `${url} has no debugId comment`).toBeDefined(); | ||
| expect(code).toContain(`sentry-dbid-${debugId}`); | ||
| } | ||
| }); | ||
|
|
||
| // The app does not configure source maps, so they are hidden, like the other meta framework SDKs do. | ||
| test('source maps are not exposed when the app did not ask for them', async ({ baseURL }) => { | ||
| const url = `${baseURL}/assets/app/actions/public/entry.ts`; | ||
|
|
||
| const code = await (await fetch(url)).text(); | ||
| const sourceMapResponse = await fetch(`${url}.map`); | ||
|
|
||
| expect(code).not.toContain('//# sourceMappingURL='); | ||
| expect(sourceMapResponse.status).toBe(404); | ||
| }); | ||
|
|
||
| test('the debug ID of a module is stable across requests', async ({ baseURL }) => { | ||
| const url = `${baseURL}/assets/app/actions/public/throw-error.ts`; | ||
|
|
||
| const first = getDebugId(await (await fetch(url)).text()); | ||
| const second = getDebugId(await (await fetch(url)).text()); | ||
|
|
||
| expect(first).toMatch(new RegExp(`^${DEBUG_ID}$`)); | ||
| expect(second).toBe(first); | ||
| }); | ||
|
|
||
| test('a client error carries the debug IDs of the modules in its stack trace', async ({ page, baseURL }) => { | ||
| const errorPromise = waitForError('remix-v3', event => { | ||
| return !event.type && event.exception?.values?.[0]?.value === 'Remix 3 client error'; | ||
| }); | ||
|
|
||
| await page.goto('/'); | ||
| await page.locator('#throw-error').click(); | ||
|
|
||
| const errorEvent = await errorPromise; | ||
|
|
||
| const moduleUrl = `${baseURL}/assets/app/actions/public/throw-error.ts`; | ||
| const debugId = getDebugId(await (await fetch(moduleUrl)).text()); | ||
|
|
||
| expect(errorEvent.debug_meta?.images).toContainEqual({ | ||
| type: 'sourcemap', | ||
| code_file: moduleUrl, | ||
| debug_id: debugId, | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| import * as diagnosticsChannel from 'node:diagnostics_channel'; | ||
| import { consoleSandbox } from '@sentry/core'; | ||
| import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; | ||
| import { addDebugIdToSourceMap, findDebugId, getDebugId, injectDebugIdSnippet } from './debugId'; | ||
|
|
||
| // The subset of `@remix-run/assets` types used here. `remix` is an optional peer dependency, so | ||
| // they are restated rather than imported. | ||
| interface ModuleLoadContext { | ||
| moduleUrl?: string; | ||
| [key: string]: unknown; | ||
| } | ||
|
|
||
| interface ModuleLoadResult { | ||
| format: string | null | undefined; | ||
| shortCircuit?: boolean; | ||
| source?: string | ArrayBuffer | ArrayBufferView; | ||
| } | ||
|
|
||
| type ModuleLoader = ( | ||
| url: string, | ||
| context: ModuleLoadContext, | ||
| nextLoad: (url: string, context?: Partial<ModuleLoadContext>) => ModuleLoadResult, | ||
| ) => ModuleLoadResult; | ||
|
|
||
| interface AssetServerOptions { | ||
| // `false` is not in Remix's type, but is how an app tells Sentry it wants no source maps at all, | ||
| // since leaving the option out now means hidden source maps. | ||
| sourceMaps?: 'inline' | 'external' | false; | ||
| scripts?: { loaders?: readonly ModuleLoader[]; [key: string]: unknown }; | ||
| [key: string]: unknown; | ||
| } | ||
|
|
||
| interface AssetServer { | ||
| fetch: (request: Request) => Promise<Response | null>; | ||
| } | ||
|
|
||
| interface CreateAssetServerContext { | ||
| arguments: unknown[]; | ||
| result?: unknown; | ||
| _sentryHideSourceMaps?: boolean; | ||
| } | ||
|
|
||
| // The asset server appends it after minification, so it is always the last line. | ||
| const SOURCE_MAPPING_URL_REGEX = /\n\/\/# sourceMappingURL=\S+\s*$/; | ||
|
|
||
| const MAX_STAMPED_BODIES = 2000; | ||
|
|
||
| let instrumented = false; | ||
|
|
||
| /** | ||
| * Makes every Remix 3 asset server created from now on serve browser modules that carry debug IDs. | ||
| * | ||
| * Source maps follow the other meta framework SDKs: | ||
| * - `sourceMaps: false` keeps them off, with a warning that stack traces stay minified. | ||
| * - `'inline'` or `'external'` is kept as the app configured it. | ||
| * - Left out, they are generated but hidden: modules do not reference them and `.map` requests are | ||
| * not served, so the source only reaches Sentry. | ||
| * | ||
| * Has to run before the app's first `createAssetServer()` call, which usually happens while its | ||
| * modules are imported. `Sentry.init()` is too late for that, so the `@sentry/remix/v3/node` entry | ||
| * calls this. | ||
| */ | ||
| export function instrumentAssetServer(): void { | ||
| if (instrumented) { | ||
| return; | ||
| } | ||
| instrumented = true; | ||
|
|
||
| // Node rethrows anything a channel subscriber throws as an uncaught exception, which would kill an | ||
| // app that runs fine without Sentry. Frozen options or an unexpected server shape reach here, so | ||
| // the asset server is left as it was instead. | ||
| diagnosticsChannel.tracingChannel(remixV3Channels.REMIX_V3_CREATE_ASSET_SERVER).subscribe({ | ||
| start(data) { | ||
| try { | ||
| const context = data as CreateAssetServerContext; | ||
| const options = context.arguments[0] as AssetServerOptions | undefined; | ||
| context._sentryHideSourceMaps = options?.sourceMaps === undefined; | ||
| context.arguments[0] = withDebugIdOptions(options); | ||
| } catch { | ||
| // Ignored on purpose. | ||
| } | ||
| }, | ||
| end(data) { | ||
| try { | ||
| const { result, _sentryHideSourceMaps } = data as CreateAssetServerContext; | ||
| if (result) { | ||
| stampServedAssets(result as AssetServer, { hideSourceMaps: Boolean(_sentryHideSourceMaps) }); | ||
| } | ||
| } catch { | ||
| // Ignored on purpose. | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| }, | ||
| asyncStart() {}, | ||
| asyncEnd() {}, | ||
| error() {}, | ||
|
Comment on lines
+93
to
+95
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can those be removed? Check if this is optional |
||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Injects the debug ID snippet into every module the asset server compiles. | ||
| * | ||
| * Loaders run after the TypeScript transform and before minification, so the snippet is minified | ||
| * along with the module, and its ID is a hash of the compiled source. The module URL is part of the | ||
| * hash so two identical files get IDs of their own, since their source maps differ. | ||
| */ | ||
| export const debugIdLoader: ModuleLoader = (url, context, nextLoad) => { | ||
| const result = nextLoad(url, context); | ||
| if (result.format !== 'module' || typeof result.source !== 'string') { | ||
| return result; | ||
| } | ||
|
|
||
| const debugId = getDebugId(`${context.moduleUrl ?? url}\n${result.source}`); | ||
| return { ...result, source: injectDebugIdSnippet(result.source, debugId) }; | ||
| }; | ||
|
|
||
| function withDebugIdOptions(options: AssetServerOptions | undefined): AssetServerOptions | undefined { | ||
| if (!options) { | ||
| return options; | ||
| } | ||
|
|
||
| if (options.sourceMaps === false) { | ||
| consoleSandbox(() => { | ||
| // oxlint-disable-next-line no-console | ||
| console.warn( | ||
| '[Sentry] Source maps are disabled in your asset server (`sourceMaps: false`). Sentry will not override this, so client stack traces stay minified.', | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| const loaders = options.scripts?.loaders ?? []; | ||
|
|
||
| return { | ||
| ...options, | ||
| // Hidden unless the app chose otherwise, see `stampServedAssets`. | ||
| sourceMaps: options.sourceMaps ?? 'external', | ||
| scripts: { | ||
| ...options.scripts, | ||
| // Last, so the ID also covers whatever the app's own loaders changed. | ||
| loaders: loaders.includes(debugIdLoader) ? loaders : [...loaders, debugIdLoader], | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * The minifier drops comments and the asset server rebuilds source maps after the loaders ran, so | ||
| * the `//# debugId=` comment and the source map's `debugId` field, which is what `sentry-cli` reads, | ||
| * are added to the served response instead. | ||
| */ | ||
| function stampServedAssets(server: AssetServer, { hideSourceMaps }: { hideSourceMaps: boolean }): void { | ||
| const fetchAsset = server.fetch; | ||
| // The asset server memoizes compiled modules and identifies each version by its ETag, so the | ||
| // stamped body is memoized the same way. Without this every hit copied the module body again. | ||
| const stamped = new Map<string, string>(); | ||
|
|
||
| server.fetch = async request => { | ||
| const response = await fetchAsset(request); | ||
| if (response?.status !== 200 || request.method !== 'GET') { | ||
| return response; | ||
| } | ||
|
|
||
| const url = new URL(request.url); | ||
| const contentType = response.headers.get('content-type') ?? ''; | ||
| const etag = response.headers.get('etag'); | ||
|
|
||
| if (contentType.includes('javascript')) { | ||
| return withBody(response, await remember(etag, async () => stampModule(await response.text()))); | ||
| } | ||
|
|
||
| if (url.pathname.endsWith('.map')) { | ||
| if (hideSourceMaps) { | ||
| // What the asset server returns for a path it does not serve. | ||
| return null; | ||
| } | ||
|
|
||
| const body = await remember(etag, async () => { | ||
| // A source map does not contain the ID of its module, so it is read from the module itself. | ||
| url.pathname = url.pathname.slice(0, -'.map'.length); | ||
| const moduleResponse = await fetchAsset(new Request(url)); | ||
| const debugId = moduleResponse?.ok ? findDebugId(await moduleResponse.text()) : undefined; | ||
| const map = await response.text(); | ||
| return debugId ? addDebugIdToSourceMap(map, debugId) : map; | ||
| }); | ||
| return withBody(response, body); | ||
| } | ||
|
|
||
| return response; | ||
| }; | ||
|
|
||
| function stampModule(served: string): string { | ||
| const code = hideSourceMaps ? served.replace(SOURCE_MAPPING_URL_REGEX, '') : served; | ||
| const debugId = findDebugId(code); | ||
| return debugId ? `${code}\n//# debugId=${debugId}` : code; | ||
| } | ||
|
|
||
| async function remember(etag: string | null, compute: () => Promise<string>): Promise<string> { | ||
| if (etag === null) { | ||
| return compute(); | ||
| } | ||
| const cached = stamped.get(etag); | ||
| if (cached !== undefined) { | ||
| return cached; | ||
| } | ||
| const body = await compute(); | ||
| // Bounded, because in watch mode every edit is a new ETag. | ||
| if (stamped.size >= MAX_STAMPED_BODIES) { | ||
| stamped.delete(stamped.keys().next().value as string); | ||
| } | ||
| stamped.set(etag, body); | ||
| return body; | ||
| } | ||
| } | ||
|
|
||
| function withBody(response: Response, body: string): Response { | ||
| const headers = new Headers(response.headers); | ||
| headers.delete('content-length'); | ||
| return new Response(body, { status: response.status, statusText: response.statusText, headers }); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you'll get an error here but there are two
scriptkeys