Skip to content

Commit 5bdf152

Browse files
chargomeclaude
andcommitted
feat(remix): Add source map upload for Remix 3
Remix 3 has no build step and no output directory, so there is nothing on disk for a source map upload to read. `emitAssets()` rebuilds that artifact set by driving the app's own asset server: `getPreloads()` forces the browser module graph, and each compiled module and its source map are written out. Because the debug IDs are hashed from the compiled source, the emitted IDs match the ones the running server serves. Source maps are hidden by default on the instrumented server, so the emitter reads them through the asset server's own fetch, which the stamping wrapper now keeps reachable. The `sentry-remix-v3-upload-sourcemaps` bin is separate from the Remix 2 `sentry-upload-sourcemaps`, which takes a build directory Remix 3 never produces. It must run under `--import @sentry/remix/v3/node`, which provides the TypeScript loader and patches the asset server; without it the emitted modules carry no debug IDs, and the command fails loudly on that rather than uploading an unusable set. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent b88a5b5 commit 5bdf152

7 files changed

Lines changed: 392 additions & 1 deletion

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { execFileSync } from 'node:child_process';
2+
import * as fs from 'node:fs';
3+
import { createRequire } from 'node:module';
4+
import * as os from 'node:os';
5+
import * as path from 'node:path';
6+
import { expect, test } from '@playwright/test';
7+
8+
const DEBUG_ID = /\/\/# debugId=([0-9a-f-]{36})/;
9+
10+
// Not a listed subpath, so it is reached from the package root.
11+
const script = path.join(
12+
path.dirname(createRequire(import.meta.url).resolve('@sentry/remix/package.json')),
13+
'scripts',
14+
'sentry-remix-v3-upload-sourcemaps.mjs',
15+
);
16+
17+
test('emits the module graph with one map per module, all sharing the served debug IDs', async ({ baseURL }) => {
18+
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'remix-v3-upload-'));
19+
20+
const output = execFileSync(
21+
'node',
22+
[
23+
'--import',
24+
'@sentry/remix/v3/node',
25+
script,
26+
'--entry',
27+
'app/actions/public/entry.ts',
28+
'--dry-run',
29+
'--out-dir',
30+
outDir,
31+
],
32+
{ cwd: process.cwd(), env: { ...process.env, NODE_ENV: 'production' }, encoding: 'utf8' },
33+
);
34+
expect(output).toMatch(/emitted \d+ modules/);
35+
36+
const modules = walk(outDir).filter(file => !file.endsWith('.map'));
37+
expect(modules.length).toBeGreaterThan(1);
38+
39+
for (const file of modules) {
40+
const debugId = fs.readFileSync(file, 'utf8').match(DEBUG_ID)?.[1];
41+
expect(debugId, `${file} has no debug ID`).toBeDefined();
42+
const map = JSON.parse(fs.readFileSync(`${file}.map`, 'utf8')) as { debugId?: string };
43+
expect(map.debugId, `${file}.map does not match its module`).toBe(debugId);
44+
}
45+
46+
// The IDs are hashed from the compiled source, so the emitted entry must match what the server
47+
// serves right now. That is what lets an upload made in CI symbolicate a running app.
48+
const emittedEntry = modules.find(file => file.endsWith(path.join('app', 'actions', 'public', 'entry.ts')));
49+
const served = await (await fetch(`${baseURL}/assets/app/actions/public/entry.ts`)).text();
50+
expect(fs.readFileSync(emittedEntry as string, 'utf8').match(DEBUG_ID)?.[1]).toBe(served.match(DEBUG_ID)?.[1]);
51+
52+
fs.rmSync(outDir, { recursive: true, force: true });
53+
});
54+
55+
function walk(dir: string): string[] {
56+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
57+
const file = path.join(dir, entry.name);
58+
return entry.isDirectory() ? walk(file) : [file];
59+
});
60+
}

‎packages/remix/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"author": "Sentry",
88
"license": "MIT",
99
"bin": {
10-
"sentry-upload-sourcemaps": "scripts/sentry-upload-sourcemaps.js"
10+
"sentry-upload-sourcemaps": "scripts/sentry-upload-sourcemaps.js",
11+
"sentry-remix-v3-upload-sourcemaps": "scripts/sentry-remix-v3-upload-sourcemaps.mjs"
1112
},
1213
"engines": {
1314
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#!/usr/bin/env node
2+
/* eslint-disable no-console */
3+
// Emits a Remix 3 app's browser module graph to a directory and uploads it to Sentry.
4+
//
5+
// Must run under the Sentry entry, which provides Remix's TypeScript loader and patches the asset
6+
// server so the emitted modules carry debug IDs:
7+
//
8+
// node --import @sentry/remix/v3/node ./node_modules/.bin/sentry-remix-v3-upload-sourcemaps \
9+
// --entry app/actions/public/entry.ts --release "$RELEASE"
10+
import * as fs from 'node:fs';
11+
import * as os from 'node:os';
12+
import * as path from 'node:path';
13+
import { pathToFileURL } from 'node:url';
14+
import { parseArgs } from 'node:util';
15+
16+
class UsageError extends Error {}
17+
18+
let outDir;
19+
let keepOutput = false;
20+
21+
try {
22+
const values = parseOptions();
23+
keepOutput = Boolean(values['keep-output'] || values['out-dir']);
24+
25+
const { emitAssets } = await import('@sentry/remix/v3');
26+
27+
let assetsModule;
28+
try {
29+
assetsModule = await import(pathToFileURL(path.resolve(values['assets-module'])).href);
30+
} catch (error) {
31+
if (error?.code === 'ERR_UNKNOWN_FILE_EXTENSION') {
32+
fail(`could not load ${values['assets-module']}. Run this command with \`node --import @sentry/remix/v3/node\`.`);
33+
}
34+
throw error;
35+
}
36+
const assets = assetsModule[values['assets-export']];
37+
if (typeof assets?.getPreloads !== 'function' || typeof assets?.fetch !== 'function') {
38+
fail(`${values['assets-module']} does not export an asset server named "${values['assets-export']}"`);
39+
}
40+
41+
outDir = values['out-dir'] ?? fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-remix-v3-assets-'));
42+
const emitted = await emitAssets({ assets, entries: values.entry, outDir });
43+
const maps = emitted.filter(asset => asset.mapFile).length;
44+
const debugIds = emitted.filter(asset => asset.debugId).length;
45+
console.log(
46+
`[sentry] emitted ${emitted.length} modules, ${maps} source maps, ${debugIds} debug IDs${keepOutput ? ` to ${outDir}` : ''}`,
47+
);
48+
49+
if (emitted.length > 0 && debugIds === 0) {
50+
// The one failure that is otherwise silent: an unpatched asset server emits plain modules.
51+
fail(
52+
'no debug IDs were emitted. Run this command with `node --import @sentry/remix/v3/node` so the asset server is instrumented.',
53+
);
54+
}
55+
56+
if (values['dry-run']) {
57+
console.log('[sentry] --dry-run, skipping upload');
58+
} else {
59+
const { createSentrySDK } = await import('sentry');
60+
const sentry = createSentrySDK({ url: values.url, org: values.org, project: values.project });
61+
const release = values.release ?? (await sentry.release['propose-version']()).version;
62+
await sentry.release.create({ orgVersion: release });
63+
// Modules keep their served names (`.ts`, `.tsx`), which the upload skips by default.
64+
const ext = [...new Set(emitted.map(asset => path.extname(asset.file)))].join(',');
65+
await sentry.sourcemap.upload({ directory: outDir, release, ext });
66+
await sentry.release.finalize({ orgVersion: release });
67+
console.log(`[sentry] uploaded release ${release}`);
68+
}
69+
} catch (error) {
70+
if (!(error instanceof UsageError)) {
71+
throw error;
72+
}
73+
console.error(`[sentry] ${error.message}`);
74+
process.exitCode = 1;
75+
} finally {
76+
if (outDir && !keepOutput) {
77+
fs.rmSync(outDir, { recursive: true, force: true });
78+
}
79+
}
80+
81+
function parseOptions() {
82+
let values;
83+
try {
84+
({ values } = parseArgs({
85+
options: {
86+
entry: { type: 'string', multiple: true },
87+
'assets-module': { type: 'string', default: './app/assets.ts' },
88+
'assets-export': { type: 'string', default: 'assets' },
89+
release: { type: 'string' },
90+
org: { type: 'string' },
91+
project: { type: 'string' },
92+
url: { type: 'string' },
93+
'out-dir': { type: 'string' },
94+
'keep-output': { type: 'boolean', default: false },
95+
'dry-run': { type: 'boolean', default: false },
96+
},
97+
}));
98+
} catch (error) {
99+
fail(error.message);
100+
}
101+
if (!values.entry?.length) {
102+
fail('at least one --entry is required, e.g. --entry app/actions/public/entry.ts');
103+
}
104+
return values;
105+
}
106+
107+
function fail(message) {
108+
throw new UsageError(message);
109+
}

‎packages/remix/src/v3/assetServer.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,16 @@ function withDebugIdOptions(options: AssetServerOptions | undefined): AssetServe
179179
* the `//# debugId=` comment and the source map's `debugId` field, which is what `sentry-cli` reads,
180180
* are added to the served response instead.
181181
*/
182+
/**
183+
* The asset server's own `fetch`, before stamping. The emitter needs it because stamping hides source
184+
* maps by default, and the upload must still get them.
185+
* @internal
186+
*/
187+
export const UNSTAMPED_FETCH: unique symbol = Symbol.for('sentry.remix.unstampedFetch');
188+
182189
function stampServedAssets(server: AssetServer, { hideSourceMaps }: { hideSourceMaps: boolean }): void {
183190
const fetchAsset = server.fetch;
191+
(server as AssetServer & { [UNSTAMPED_FETCH]?: AssetServer['fetch'] })[UNSTAMPED_FETCH] = fetchAsset;
184192
// The asset server memoizes compiled modules and identifies each version by its ETag, so the
185193
// stamped body is memoized the same way. Without this every hit copied the module body again.
186194
const stamped = new Map<string, string>();
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
4+
import { UNSTAMPED_FETCH } from './assetServer';
5+
import { addDebugIdToSourceMap, findDebugId } from './debugId';
6+
7+
/** The subset of `@remix-run/assets`' `AssetServer` this needs. */
8+
export interface AssetServerLike {
9+
fetch(request: Request): Promise<Response | null>;
10+
getPreloads(filePath: string | readonly string[]): Promise<string[]>;
11+
[UNSTAMPED_FETCH]?: (request: Request) => Promise<Response | null>;
12+
}
13+
14+
export interface EmitAssetsOptions {
15+
/** The app's own asset server. Driving the real one is what makes the debug IDs match production. */
16+
assets: AssetServerLike;
17+
/** Browser entry paths, e.g. `['app/actions/public/entry.ts']`. */
18+
entries: readonly string[];
19+
outDir: string;
20+
}
21+
22+
export interface EmittedAsset {
23+
/** Public URL the module is served from. */
24+
url: string;
25+
file: string;
26+
/** Written when the asset server produced a source map for the module. */
27+
mapFile?: string;
28+
debugId?: string;
29+
}
30+
31+
/**
32+
* Compile a Remix 3 app's browser module graph to disk, for a source map upload.
33+
*
34+
* Remix 3 has no build step, so there is no output directory to upload. This drives the app's own
35+
* asset server instead: same config, so the same compiled output and the same debug IDs the running
36+
* server serves. `getPreloads()` is what forces compilation; the server compiles lazily, so without
37+
* it only the modules something happened to request would exist.
38+
*/
39+
export async function emitAssets(options: EmitAssetsOptions): Promise<EmittedAsset[]> {
40+
const { assets, entries, outDir } = options;
41+
// Source maps are hidden by default, so they have to be read from the unstamped server.
42+
const fetchMap: AssetServerLike['fetch'] = assets[UNSTAMPED_FETCH] ?? (request => assets.fetch(request));
43+
44+
const emitted: EmittedAsset[] = [];
45+
46+
for (const url of await assets.getPreloads([...entries])) {
47+
const response = await assets.fetch(new Request(`http://asset-server${url}`));
48+
if (response?.status !== 200) {
49+
continue;
50+
}
51+
52+
const source = await response.text();
53+
const debugId = findDebugId(source);
54+
const file = path.join(outDir, toRelativePath(url));
55+
fs.mkdirSync(path.dirname(file), { recursive: true });
56+
fs.writeFileSync(file, source);
57+
58+
const asset: EmittedAsset = { url, file, debugId };
59+
60+
const mapResponse = await fetchMap(new Request(`http://asset-server${toMapUrl(url)}`));
61+
if (mapResponse?.status === 200) {
62+
const map = await mapResponse.text();
63+
asset.mapFile = `${file}.map`;
64+
fs.writeFileSync(asset.mapFile, debugId ? addDebugIdToSourceMap(map, debugId) : map);
65+
}
66+
67+
emitted.push(asset);
68+
}
69+
70+
return emitted;
71+
}
72+
73+
// The server resolves the map from the pathname, so `.map` goes before any query.
74+
function toMapUrl(url: string): string {
75+
const queryIndex = url.indexOf('?');
76+
return queryIndex === -1 ? `${url}.map` : `${url.slice(0, queryIndex)}.map${url.slice(queryIndex)}`;
77+
}
78+
79+
// Decoded, so the tree on disk mirrors the module layout rather than the asset server's encoding.
80+
// Debug ID matching does not depend on these paths. `..` is dropped so the file stays under `outDir`.
81+
function toRelativePath(url: string): string {
82+
const pathname = url.split('?')[0] ?? url;
83+
return path.join(
84+
...pathname
85+
.replace(/^\//, '')
86+
.split('/')
87+
.map(segment => decodeURIComponent(segment).replace(/[/\\]/g, '_'))
88+
.filter(segment => segment !== '..'),
89+
);
90+
}

‎packages/remix/src/v3/index.server.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@ export { remixV3Integration } from './server/integration';
55
export { sentryRemixMiddleware } from './server/middleware';
66
export { instrumentRemixV3 } from './server/instrument';
77
export { instrumentAssetServer } from './assetServer';
8+
export { emitAssets } from './emitAssets';
9+
export type { EmitAssetsOptions, EmittedAsset } from './emitAssets';
810
export { defaultShouldHandleError } from './server/errorFilter';
911
export type { ShouldHandleError } from './server/errorFilter';

0 commit comments

Comments
 (0)