diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx index 59d6567f28bb..8b6dd2cf9214 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx @@ -24,6 +24,9 @@ function HomePage(handle: Handle>) { + ); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts index f558bda2b048..d9b03fe3d561 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts @@ -1,6 +1,8 @@ import * as Sentry from '@sentry/remix/v3/client'; import { createElement, run } from 'remix/ui'; +import { throwError } from './throw-error.ts'; + // 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 }; @@ -29,7 +31,11 @@ function Boom(): () => never { } document.addEventListener('click', event => { - if ((event.target as HTMLElement | null)?.id === 'component-error') { + const id = (event.target as HTMLElement | null)?.id; + if (id === 'component-error') { void app.frames.top.replace(createElement(Boom, {})); } + if (id === 'throw-error') { + throwError(); + } }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts new file mode 100644 index 000000000000..d8540df85001 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts @@ -0,0 +1,3 @@ +export function throwError(): never { + throw new Error('Remix 3 client error'); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts index 7c89e32313c7..3cb55a9ab90d 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts @@ -6,6 +6,9 @@ export const assets = createAssetServer({ allowFiles: ['app/routes.ts', 'app/**/public/**'], allowPackages: ['remix', '@sentry/remix'], minify: true, + scripts: { + define: { 'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN) }, + }, watch: false, scripts: { // No bundler means no build time env inlining, so `define` is the only way to get configuration diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts new file mode 100644 index 000000000000..b91955111279 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts @@ -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, + }); +}); diff --git a/packages/remix/src/v3/assetServer.ts b/packages/remix/src/v3/assetServer.ts new file mode 100644 index 000000000000..3e7580854f64 --- /dev/null +++ b/packages/remix/src/v3/assetServer.ts @@ -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) => 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; +} + +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. + } + }, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); +} + +/** + * 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(); + + 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): Promise { + 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 }); +} diff --git a/packages/remix/src/v3/debugId.ts b/packages/remix/src/v3/debugId.ts new file mode 100644 index 000000000000..92094a9fef4c --- /dev/null +++ b/packages/remix/src/v3/debugId.ts @@ -0,0 +1,135 @@ +import { createHash } from 'node:crypto'; + +// Mirrors `stringToUUID` and `getDebugIdSnippet` in `@sentry/bundler-plugins/core`. Importing that +// entry at runtime would load the whole build plugin (a native parser, the Sentry CLI) into the server. + +const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +const DEBUG_ID_IDENTIFIER_REGEX = /sentry-dbid-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/; + +const INLINE_SOURCE_MAP_REGEX = /\n?\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)\s*$/; + +interface SourceMap { + mappings: string; + sources?: string[]; + [key: string]: unknown; +} + +/** + * Hashes `source` into a UUID shaped ID. It has to be deterministic, because the source map upload + * runs in a different process than the server and must arrive at the same ID. + */ +export function getDebugId(source: string): string { + const hash = createHash('sha256').update(source).digest('hex'); + // RFC 4122 section 4.4: the variant nibble is one of 8, 9, a, b. + const variant = ['8', '9', 'a', 'b'][hash.charCodeAt(16) % 4] as string; + + return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-${variant}${hash.slice(17, 20)}-${hash.slice(20, 32)}`; +} + +/** The snippet that registers a module's debug ID in `globalThis._sentryDebugIds`, keyed by its stack. */ +export function getDebugIdSnippet(debugId: string): string { + return `!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{};var n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}",e._sentryDebugIdIdentifier="sentry-dbid-${debugId}");}catch(e){}}();`; +} + +/** Reads back the debug ID that {@link getDebugIdSnippet} put into a module, which survives minification. */ +export function findDebugId(code: string): string | undefined { + return code.match(DEBUG_ID_IDENTIFIER_REGEX)?.[1]; +} + +/** + * Puts the debug ID snippet on its own line at the top of `source`, so it runs before the module + * body and registers the ID even when that body throws. + * + * `source` may end in an inline source map, which is how the asset server hands a module to its + * loaders. The returned map then maps back to `source` and the asset server composes it with its + * own, so it only has to shift every line down by one. + */ +export function injectDebugIdSnippet(source: string, debugId: string): string { + const snippet = getDebugIdSnippet(debugId); + const inlineSourceMap = source.match(INLINE_SOURCE_MAP_REGEX); + + if (!inlineSourceMap?.[1]) { + return `${snippet}\n${source}`; + } + + const code = source.slice(0, inlineSourceMap.index); + const map = JSON.parse(Buffer.from(inlineSourceMap[1], 'base64').toString('utf8')) as SourceMap; + const shiftedMap = { + version: 3, + sources: map.sources?.slice(0, 1) ?? [], + names: [], + mappings: getShiftedIdentityMappings(map.mappings), + }; + + return `${snippet}\n${code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(shiftedMap)).toString('base64')}`; +} + +/** Adds the debug ID to a source map, under both field names the upload tooling reads. */ +export function addDebugIdToSourceMap(sourceMap: string, debugId: string): string { + const map = JSON.parse(sourceMap) as SourceMap; + map.debug_id = debugId; + map.debugId = debugId; + return JSON.stringify(map); +} + +/** + * Maps line `n + 1` of the output to line `n` of the input, at exactly the columns the input map + * has segments for. Composition only keeps positions both maps know about, so a coarser identity + * map would lose precision. + */ +function getShiftedIdentityMappings(mappings: string): string { + let previousLine = 0; + let previousColumn = 0; + + const lines = mappings.split(';').map((line, lineIndex) => { + let column = 0; + let previousGeneratedColumn = 0; + + return line + .split(',') + .filter(Boolean) + .map(segment => { + column += decodeFirstVlq(segment); + const encoded = `${encodeVlq(column - previousGeneratedColumn)}A${encodeVlq(lineIndex - previousLine)}${encodeVlq(column - previousColumn)}`; + previousGeneratedColumn = column; + previousLine = lineIndex; + previousColumn = column; + return encoded; + }) + .join(','); + }); + + return `;${lines.join(';')}`; +} + +// A segment's first field is its generated column, relative to the previous segment on the line. +function decodeFirstVlq(segment: string): number { + let value = 0; + let factor = 1; + let index = 0; + let digit: number; + + do { + digit = BASE64_CHARS.indexOf(segment.charAt(index++)); + value += (digit % 32) * factor; + factor *= 32; + } while (digit >= 32); + + // The lowest bit is the sign. + return value % 2 ? -(value - 1) / 2 : value / 2; +} + +function encodeVlq(value: number): string { + let vlq = value < 0 ? -value * 2 + 1 : value * 2; + let encoded = ''; + + do { + const digit = vlq % 32; + vlq = Math.floor(vlq / 32); + // Sets the continuation bit. + encoded += BASE64_CHARS.charAt(vlq > 0 ? digit + 32 : digit); + } while (vlq > 0); + + return encoded; +} diff --git a/packages/remix/src/v3/index.server.ts b/packages/remix/src/v3/index.server.ts index 870ab16899cb..4765039207d4 100644 --- a/packages/remix/src/v3/index.server.ts +++ b/packages/remix/src/v3/index.server.ts @@ -4,3 +4,4 @@ export { getDefaultIntegrations, init } from './server/sdk'; export { remixV3Integration } from './server/integration'; export { sentryRemixMiddleware } from './server/middleware'; export { instrumentRemixV3 } from './server/instrument'; +export { instrumentAssetServer } from './assetServer'; diff --git a/packages/remix/src/v3/node.mjs b/packages/remix/src/v3/node.mjs index 59d77f99fd19..df700b229103 100644 --- a/packages/remix/src/v3/node.mjs +++ b/packages/remix/src/v3/node.mjs @@ -1,8 +1,9 @@ // Replaces `--import remix/node-tsx` rather than adding a second flag. // // This has to happen here, not in `Sentry.init()`: the module hook must be in place before -// `@remix-run/fetch-router` is imported, and the subscription before `createRouter()` runs, which is -// while the app's own modules are still being imported. +// `@remix-run/fetch-router` and `@remix-run/assets` are imported, and the subscriptions before +// `createRouter()` and `createAssetServer()` run, which is while the app's own modules are still +// being imported. import { registerDiagnosticsChannelInjection } from '@sentry/server-runtime-injection/register'; registerDiagnosticsChannelInjection(); @@ -10,8 +11,9 @@ registerDiagnosticsChannelInjection(); // A failure to load the SDK must not stop the app: this runs before anything of the app has, and an // uncaught error here means Remix never starts. try { - const { instrumentRemixV3 } = await import('@sentry/remix/v3'); + const { instrumentRemixV3, instrumentAssetServer } = await import('@sentry/remix/v3'); instrumentRemixV3(); + instrumentAssetServer(); } catch (error) { // oxlint-disable-next-line no-console console.warn('[Sentry] Could not load @sentry/remix/v3. The app starts without Sentry instrumentation.', error); diff --git a/packages/remix/test/v3/assetServer.test.ts b/packages/remix/test/v3/assetServer.test.ts new file mode 100644 index 000000000000..7a57c53e2df0 --- /dev/null +++ b/packages/remix/test/v3/assetServer.test.ts @@ -0,0 +1,241 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { debugIdLoader, instrumentAssetServer } from '../../src/v3/assetServer'; +import { getDebugId, getDebugIdSnippet } from '../../src/v3/debugId'; + +type Options = Record; +type FakeFetch = (request: Request) => Promise; + +const channel = diagnosticsChannel.tracingChannel(remixV3Channels.REMIX_V3_CREATE_ASSET_SERVER); + +// Stands in for orchestrion's transform, which calls the original with the channel context's +// `arguments`, so subscribers can replace them. +function createAssetServer(options: Options, fetch: FakeFetch = async () => null) { + const context = { arguments: [options] as unknown[] }; + let receivedOptions: Options | undefined; + + const server = channel.traceSync(() => { + receivedOptions = context.arguments[0] as Options; + return { fetch }; + }, context); + + return { server, receivedOptions: receivedOptions as Options }; +} + +function javascript(body: string): Response { + return new Response(body, { headers: { 'content-type': 'application/javascript; charset=utf-8', etag: 'W/"a"' } }); +} + +describe('instrumentAssetServer', () => { + beforeAll(() => { + instrumentAssetServer(); + }); + + describe('options', () => { + it('generates source maps when the app did not configure them', () => { + const { receivedOptions } = createAssetServer({ basePath: '/assets' }); + + expect(receivedOptions.sourceMaps).toBe('external'); + }); + + it('keeps source maps the app configured', () => { + const { receivedOptions } = createAssetServer({ basePath: '/assets', sourceMaps: 'inline' }); + + expect(receivedOptions.sourceMaps).toBe('inline'); + }); + + it('keeps source maps off when the app disabled them, and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { receivedOptions } = createAssetServer({ basePath: '/assets', sourceMaps: false }); + + expect(receivedOptions.sourceMaps).toBe(false); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Source maps are disabled in your asset server')); + warn.mockRestore(); + }); + + it('adds the debug ID loader after the app loaders', () => { + const appLoader = vi.fn(); + const { receivedOptions } = createAssetServer({ scripts: { loaders: [appLoader], define: { a: 'b' } } }); + + expect(receivedOptions.scripts).toEqual({ loaders: [appLoader, debugIdLoader], define: { a: 'b' } }); + }); + + it('does not mutate the options the app passed', () => { + const options = { basePath: '/assets' }; + createAssetServer(options); + + expect(options).toEqual({ basePath: '/assets' }); + }); + + it('adds the loader once when the same options are reused', () => { + const first = createAssetServer({}).receivedOptions; + const { receivedOptions } = createAssetServer(first); + + expect(receivedOptions.scripts.loaders).toEqual([debugIdLoader]); + }); + }); + + describe('debugIdLoader', () => { + it('injects a snippet whose ID hashes the module URL and its compiled source', () => { + const result = debugIdLoader('file:///app/entry.ts', { moduleUrl: '/assets/app/entry.ts' }, () => ({ + format: 'module', + source: 'export const a = 1;', + })); + + const debugId = getDebugId('/assets/app/entry.ts\nexport const a = 1;'); + expect(result.source).toBe(`${getDebugIdSnippet(debugId)}\nexport const a = 1;`); + }); + + it('gives identical modules at different URLs different IDs', () => { + const load = () => ({ format: 'module', source: 'export const a = 1;' }); + + const first = debugIdLoader('file:///app/a.ts', { moduleUrl: '/assets/app/a.ts' }, load); + const second = debugIdLoader('file:///app/b.ts', { moduleUrl: '/assets/app/b.ts' }, load); + + expect(first.source).not.toBe(second.source); + }); + }); + + describe('served assets', () => { + const debugId = getDebugId('module'); + const moduleCode = `${getDebugIdSnippet(debugId)}export const a=1;\n//# sourceMappingURL=/assets/app/entry.ts.map`; + + function createServer(options: Options = { sourceMaps: 'external' }) { + const fetch = vi.fn(async request => { + const { pathname } = new URL(request.url); + if (pathname === '/assets/app/entry.ts') { + return javascript(moduleCode); + } + if (pathname === '/assets/app/entry.ts.map') { + return new Response('{"version":3,"mappings":"AAAA"}', { headers: { 'content-type': 'application/json' } }); + } + if (pathname === '/assets/app/plain.js') { + return javascript('export const b=1;'); + } + return null; + }); + + return { server: createAssetServer(options, fetch).server, fetch }; + } + + it('appends the debugId comment to modules', async () => { + const { server } = createServer(); + + const response = await server.fetch(new Request('http://localhost/assets/app/entry.ts')); + + expect(await response?.text()).toBe(`${moduleCode}\n//# debugId=${debugId}`); + expect(response?.headers.get('etag')).toBe('W/"a"'); + }); + + it('adds the debug ID of the module to its source map', async () => { + const { server } = createServer(); + + const response = await server.fetch(new Request('http://localhost/assets/app/entry.ts.map')); + + expect(await response?.json()).toEqual({ version: 3, mappings: 'AAAA', debugId, debug_id: debugId }); + }); + + describe('when the app did not configure source maps', () => { + it('does not reference the source map from modules', async () => { + const { server } = createServer({}); + + const response = await server.fetch(new Request('http://localhost/assets/app/entry.ts')); + + expect(await response?.text()).toBe(`${getDebugIdSnippet(debugId)}export const a=1;\n//# debugId=${debugId}`); + }); + + it('does not serve source maps', async () => { + const { server } = createServer({}); + + expect(await server.fetch(new Request('http://localhost/assets/app/entry.ts.map'))).toBeNull(); + }); + }); + + describe('memoizes by ETag', () => { + // The asset server serves the same compiled module under the same ETag until the file changes, + // so the stamped body is computed once per version, not copied on every hit. + function createRecordingServer(bodies: string[] = [moduleCode, moduleCode]) { + const served: Response[] = []; + let n = 0; + const fetch: FakeFetch = async request => { + const { pathname } = new URL(request.url); + if (pathname === '/assets/app/entry.ts') { + const body = bodies[Math.min(n++, bodies.length - 1)] as string; + // As the real server does: a new body gets a new ETag, the same body keeps its ETag. + const response = new Response(body, { + headers: { 'content-type': 'application/javascript', etag: `W/"${bodies.indexOf(body)}"` }, + }); + served.push(response); + return response; + } + if (pathname === '/assets/app/entry.ts.map') { + return new Response('{"version":3,"mappings":"AAAA"}', { + headers: { 'content-type': 'application/json', etag: 'W/"m"' }, + }); + } + return null; + }; + const fetchSpy = vi.fn(fetch); + return { server: createAssetServer({ sourceMaps: 'external' }, fetchSpy).server, served, fetchSpy }; + } + + it('reads a module body once per ETag', async () => { + const { server, served } = createRecordingServer(); + + const first = await (await server.fetch(new Request('http://localhost/assets/app/entry.ts')))?.text(); + const second = await (await server.fetch(new Request('http://localhost/assets/app/entry.ts')))?.text(); + + expect(second).toBe(first); + expect(served.map(response => response.bodyUsed)).toEqual([true, false]); + }); + + it('reads the module once for a source map served under the same ETag', async () => { + const { server, fetchSpy } = createRecordingServer(); + + await server.fetch(new Request('http://localhost/assets/app/entry.ts.map')); + await server.fetch(new Request('http://localhost/assets/app/entry.ts.map')); + + // Two map requests, and the module looked up only for the first. + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it('stamps a changed module again', async () => { + const changedId = getDebugId('changed'); + const changed = `${getDebugIdSnippet(changedId)}export const a=2;`; + const { server, served } = createRecordingServer([moduleCode, changed]); + + await server.fetch(new Request('http://localhost/assets/app/entry.ts')); + const second = await server.fetch(new Request('http://localhost/assets/app/entry.ts')); + + expect(await second?.text()).toBe(`${changed}\n//# debugId=${changedId}`); + expect(served.map(response => response.bodyUsed)).toEqual([true, true]); + }); + }); + + it('leaves modules without a snippet unchanged', async () => { + const { server } = createServer(); + + const response = await server.fetch(new Request('http://localhost/assets/app/plain.js')); + + expect(await response?.text()).toBe('export const b=1;'); + }); + + it('passes through requests the asset server does not handle', async () => { + const { server } = createServer(); + + expect(await server.fetch(new Request('http://localhost/other'))).toBeNull(); + }); + + it('leaves HEAD requests alone', async () => { + const { server, fetch } = createServer(); + const request = new Request('http://localhost/assets/app/entry.ts', { method: 'HEAD' }); + + await server.fetch(request); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(request); + }); + }); +}); diff --git a/packages/remix/test/v3/debugId.test.ts b/packages/remix/test/v3/debugId.test.ts new file mode 100644 index 000000000000..6c813590b835 --- /dev/null +++ b/packages/remix/test/v3/debugId.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { addDebugIdToSourceMap, findDebugId, getDebugId, injectDebugIdSnippet } from '../../src/v3/debugId'; + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function withInlineSourceMap(code: string, map: object): string { + return `${code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(map)).toString('base64')}`; +} + +function readInlineSourceMap(source: string): Record { + const encoded = source.match(/sourceMappingURL=data:application\/json;base64,(.+)$/)?.[1] ?? ''; + return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')); +} + +describe('getDebugId', () => { + it('returns a UUID v4 shaped ID', () => { + expect(getDebugId('export const a = 1;')).toMatch(UUID_REGEX); + }); + + it('is derived from the source only', () => { + expect(getDebugId('export const a = 1;')).toBe(getDebugId('export const a = 1;')); + expect(getDebugId('export const a = 1;')).not.toBe(getDebugId('export const a = 2;')); + }); + + it('matches the ID the bundler plugins derive from the same input', () => { + // `stringToUUID('')` in `@sentry/bundler-plugins/core`. + expect(getDebugId('')).toBe('e3b0c442-98fc-4c14-9afb-f4c8996fb924'); + }); +}); + +describe('injectDebugIdSnippet', () => { + const debugId = getDebugId('module'); + + it('puts the snippet on the first line', () => { + const output = injectDebugIdSnippet('export const a = 1;', debugId); + + expect(output.split('\n')).toEqual([expect.stringContaining(`sentry-dbid-${debugId}`), 'export const a = 1;']); + }); + + it('registers the ID in `_sentryDebugIds` keyed by the stack', () => { + const globalObject = globalThis as { _sentryDebugIds?: Record }; + delete globalObject._sentryDebugIds; + + // oxlint-disable-next-line typescript/no-implied-eval + new Function(injectDebugIdSnippet('', debugId))(); + + expect(Object.values(globalObject._sentryDebugIds ?? {})).toEqual([debugId]); + delete globalObject._sentryDebugIds; + }); + + it('returns a source map that shifts the incoming positions down one line', () => { + const input = withInlineSourceMap('import a from "a";\nexport const b = a;', { + version: 3, + sources: ['/assets/app/entry.ts'], + names: [], + // Line 0 has segments at columns 0 and 4, line 1 at columns 0 and 6. + mappings: 'AAAA,IAAI;AACA,MAAM', + }); + + const output = injectDebugIdSnippet(input, debugId); + + expect(output.split('\n').slice(1, 3)).toEqual(['import a from "a";', 'export const b = a;']); + expect(readInlineSourceMap(output)).toEqual({ + version: 3, + sources: ['/assets/app/entry.ts'], + names: [], + // Identity at the same columns, one line lower. The first segment of the second line steps + // back from column 4 to 0, hence `J` (-4). + mappings: ';AAAA,IAAI;AACJ,MAAM', + }); + }); +}); + +describe('findDebugId', () => { + it('reads the ID back out of minified code', () => { + const debugId = getDebugId('module'); + const minified = injectDebugIdSnippet('', debugId).replace(/"/g, '`'); + + expect(findDebugId(minified)).toBe(debugId); + }); + + it('returns undefined for code without a snippet', () => { + expect(findDebugId('export const a = 1;')).toBeUndefined(); + }); +}); + +describe('addDebugIdToSourceMap', () => { + it('sets both field names and keeps the rest of the map', () => { + const map = JSON.parse(addDebugIdToSourceMap('{"version":3,"mappings":"AAAA"}', 'abc')); + + expect(map).toEqual({ version: 3, mappings: 'AAAA', debugId: 'abc', debug_id: 'abc' }); + }); +}); diff --git a/packages/server-utils/src/orchestrion/config/remix-v3.ts b/packages/server-utils/src/orchestrion/config/remix-v3.ts index ee30e3ffdcf7..831232dcd276 100644 --- a/packages/server-utils/src/orchestrion/config/remix-v3.ts +++ b/packages/server-utils/src/orchestrion/config/remix-v3.ts @@ -19,8 +19,16 @@ export const remixV3Config: InstrumentationConfig[] = [ }, functionQuery: { functionName: 'createRouter', kind: 'Sync' }, }, + // The subscriber rewrites the options before the asset server reads them and wraps the server it + // returns, so browser modules carry debug IDs without any config from the app. + { + channelName: 'createAssetServer', + module: { name: '@remix-run/assets', versionRange: '>=0.6.0 <1', filePath: 'dist/lib/asset-server.js' }, + functionQuery: { functionName: 'createAssetServer', kind: 'Sync' }, + }, ]; export const remixV3Channels = { REMIX_V3_CREATE_ROUTER: 'orchestrion:@remix-run/fetch-router:createRouter', + REMIX_V3_CREATE_ASSET_SERVER: 'orchestrion:@remix-run/assets:createAssetServer', } as const;