Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ function HomePage(handle: Handle<Record<string, never>>) {
<button type="button" id="component-error">
Component error
</button>
<button id="throw-error" type="button">
Throw error
</button>
</body>
</html>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> };
Expand Down Expand Up @@ -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();
}
});
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');
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Comment on lines +9 to 13

Copy link
Copy Markdown
Member

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 script keys

// No bundler means no build time env inlining, so `define` is the only way to get configuration
Expand Down
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,
});
});
217 changes: 217 additions & 0 deletions packages/remix/src/v3/assetServer.ts
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.
}
Comment thread
cursor[bot] marked this conversation as resolved.
},
asyncStart() {},
asyncEnd() {},
error() {},
Comment on lines +93 to +95

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 });
}
Loading
Loading