Skip to content
Draft
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 @@ -9,3 +9,4 @@ src/routeTree.gen.ts
.tmp_chunks
.tmp_build_stdout
.tmp_build_stderr
.tmp_debug_ids_without_source_map
Original file line number Diff line number Diff line change
Expand Up @@ -24,35 +24,20 @@ const SERVER_OUTPUT = path.join('.output', 'server');
const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT';
const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER';

const DEBUG_ID_PATTERN = /sentry-dbid-([\da-f-]{36})/gi;

function readDebugIds(file: string): string[] {
return [...fs.readFileSync(file, 'utf8').matchAll(DEBUG_ID_PATTERN)].map(match => (match[1] as string).toLowerCase());
}

function emittedJsFiles(dir: string): string[] {
return fs
.readdirSync(dir, { recursive: true, withFileTypes: true })
.filter(entry => entry.isFile() && /\.m?js$/.test(entry.name) && !entry.parentPath.includes('node_modules'))
.map(entry => path.join(entry.parentPath, entry.name));
}

/** In debug mode, the Sentry plugin logs each file it deletes after the upload. */
const deletedFiles = new Set(
[...fs.readFileSync('.tmp_build_stdout', 'utf8').matchAll(/Deleting asset after upload: (.+)$/gm)].map(match =>
path.relative(fs.realpathSync('.'), match[1] as string),
),
/** Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.ts`. */
const debugIdsWithoutSourceMap = new Set(
fs.existsSync('.tmp_debug_ids_without_source_map')
? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean)
: [],
);

/** A chunk without mappable source, such as Nitro's runtime, gets a debug ID but no source map to upload. */
function debugIdsOfChunksWithoutSourceMap(dir: string): Set<string> {
return new Set(
emittedJsFiles(dir)
.filter(file => !fs.existsSync(`${file}.map`) && !deletedFiles.has(`${file}.map`))
.flatMap(readDebugIds),
);
}

function sourceMapsIn(dir: string): string[] {
return fs
.readdirSync(dir, { recursive: true, withFileTypes: true })
Expand Down Expand Up @@ -116,8 +101,9 @@ for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) {
const injectedDebugIds = findInjectedDebugIds({ outputDir });
assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`);

const exemptDebugIds = debugIdsOfChunksWithoutSourceMap(outputDir);
const unuploaded = injectedDebugIds.filter(debugId => !uploadedDebugIds.has(debugId) && !exemptDebugIds.has(debugId));
const unuploaded = injectedDebugIds.filter(
debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId),
);
assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded source map`);

// A file of Nitro's output can carry the debug ID of the SSR chunk it was built from.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as assert from 'assert/strict';
import * as path from 'path';
import { findSourceMapFiles, findSourceMappingUrlComments } from '@sentry-internal/test-utils';

// Without an auth token nothing is uploaded, but the SDK still asks the bundler plugin to remove the
// source maps it turned on for the client and the Nitro server.
for (const outputDir of [path.join('.output', 'public'), path.join('.output', 'server')]) {
assert.deepEqual(findSourceMapFiles({ outputDir }), [], `Expected no source maps in ${outputDir}`);
assert.deepEqual(
findSourceMappingUrlComments({ outputDir }),
[],
`Expected no sourceMappingURL comments in ${outputDir}`,
);
}

console.log('All source map assertions passed!');
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"build": "node start-mock-sentry-server.mjs & vite build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT",
"clean": "npx rimraf node_modules pnpm-lock.yaml .output",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm ts-node --script-mode assert-build.ts"
"test:build:no-upload": "pnpm install && E2E_NO_UPLOAD=1 pnpm build",
"test:assert": "pnpm ts-node --script-mode assert-build.ts",
"test:assert:no-upload": "pnpm ts-node --script-mode assert-no-upload.ts"
},
"dependencies": {
"@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz",
Expand All @@ -29,6 +31,13 @@
"vite": "^8.3.0"
},
"sentryTest": {
"optional": true
"optional": true,
"optionalVariants": [
{
"build-command": "pnpm test:build:no-upload",
"assert-command": "pnpm test:assert:no-upload",
"label": "tanstackstart-react-sourcemaps (no upload)"
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,40 @@ import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite';
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import viteReact from '@vitejs/plugin-react-swc';
import { nitro } from 'nitro/vite';
import * as fs from 'node:fs';
import { defineConfig } from 'vite';

// A chunk without mappable source, such as Nitro's runtime, gets a debug ID but no source map to
// upload. Recorded before the Sentry plugin removes the maps in its own `generateBundle` hook.
function recordChunksWithoutSourceMap() {
return {
name: 'record-chunks-without-source-map',
apply: 'build' as const,
generateBundle: {
order: 'pre' as const,
handler(_options: unknown, bundle: Record<string, { type: string; fileName: string; code?: string }>) {
for (const output of Object.values(bundle)) {
if (output.type !== 'chunk' || !output.code || bundle[`${output.fileName}.map`]) {
continue;
}
for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) {
fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1]!.toLowerCase()}\n`);
}
}
},
},
};
}

export default defineConfig({
plugins: [
tanstackStart(),
nitro(),
viteReact(),
recordChunksWithoutSourceMap(),
sentryTanstackStart({
sentryUrl: 'http://localhost:3032',
authToken: 'fake-auth-token',
authToken: process.env.E2E_NO_UPLOAD ? undefined : 'fake-auth-token',
org: 'test-org',
project: 'test-project',
release: { name: 'test-release' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,25 @@ import { nitro } from 'nitro/vite';
import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite';

// Rolldown emits no map for chunks without mappable source (e.g. Vite's preload helper), so their debug
// IDs can't be uploaded. Recorded here because the SDK deletes all maps after the upload.
// IDs can't be uploaded. Recorded here because the Sentry plugin removes the maps from the bundle in its
// own `generateBundle` hook.
function recordChunksWithoutSourceMap() {
return {
name: 'record-chunks-without-source-map',
apply: 'build' as const,
enforce: 'post' as const,
writeBundle(_options: unknown, bundle: Record<string, { type: string; fileName: string; code?: string }>) {
for (const output of Object.values(bundle)) {
if (output.type !== 'chunk' || !output.code || bundle[`${output.fileName}.map`]) {
continue;
generateBundle: {
order: 'pre' as const,
handler(_options: unknown, bundle: Record<string, { type: string; fileName: string; code?: string }>) {
for (const output of Object.values(bundle)) {
if (output.type !== 'chunk' || !output.code || bundle[`${output.fileName}.map`]) {
continue;
}
for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) {
fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1]!.toLowerCase()}\n`);
}
}
for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) {
fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1]!.toLowerCase()}\n`);
}
}
},
},
};
}
Expand Down
32 changes: 15 additions & 17 deletions packages/tanstackstart-react/src/vite/nitroSourceMaps.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { readFile } from 'node:fs/promises';
import * as path from 'node:path';
import type { BuildEnvironmentOptions, Plugin, ResolvedConfig } from 'vite';
import type { SourceMapState } from './sourceMaps';

/** `nitro/vite` builds the final server in an environment with this name. */
const NITRO_ENVIRONMENT = 'nitro';
export const NITRO_ENVIRONMENT = 'nitro';

/** The environment Vite passes to `applyToEnvironment`. */
export type PluginEnvironment = Parameters<NonNullable<Plugin['applyToEnvironment']>>[0];
Expand All @@ -27,12 +28,6 @@ type Nitro = {
};
};

/**
* Tells the plugin that deletes source maps after the upload whether the source maps of an environment are only
* there because Sentry enabled them.
*/
export type SourceMapsEnablerApi = { hasSentryEnabledSourceMaps(environmentName: string): boolean };

/**
* Whether Nitro bundles the output of this environment again. With `nitro/vite`, every server environment besides
* `nitro` is a Nitro service, such as TanStack Start's `ssr` environment.
Expand All @@ -49,22 +44,21 @@ export function isNitroService(environment: PluginEnvironment): boolean {
* Keeps the sources in the source maps of Nitro's server build. Enables these source maps, unless a Nitro config
* source or `build.rolldownOptions.output.sourcemap` of the Vite config sets them, or the Vite config disables them.
*/
export function makeEnableNitroSourceMapsPlugin({ debug }: { debug?: boolean }): Plugin & {
api: SourceMapsEnablerApi;
export function makeEnableNitroSourceMapsPlugin(
{ debug }: { debug?: boolean },
state: SourceMapState,
): Plugin & {
nitro: { name: string; setup: (nitro: Nitro) => void };
} {
let enabledSourceMaps = false;
let viteConfigPreventsSourceMaps = false;

return {
name: 'sentry-tanstackstart-nitro-source-maps',
apply: 'build',
// The `config` hook must run before the one of `nitro/vite`, which runs the Nitro modules.
enforce: 'pre',
api: {
hasSentryEnabledSourceMaps: environmentName => enabledSourceMaps && environmentName === NITRO_ENVIRONMENT,
},
config(viteConfig) {
state.sdkEnabledNitroSourceMaps = false;
// Nitro overrides `build.sourcemap` for its environments, but `false` shows that the user wants no source maps.
viteConfigPreventsSourceMaps =
viteConfig.build?.sourcemap === false || hasOutputSourceMapSetting(viteConfig.build);
Expand All @@ -84,7 +78,7 @@ export function makeEnableNitroSourceMapsPlugin({ debug }: { debug?: boolean }):
}

nitro.options.sourcemap = 'hidden';
enabledSourceMaps = true;
state.sdkEnabledNitroSourceMaps = true;
logDebug(debug, "Enabled source map generation for the Nitro server build with `nitro.sourcemap: 'hidden'`.");
},
},
Expand Down Expand Up @@ -118,7 +112,7 @@ export function makeComposeNitroServiceSourceMapsPlugin(): Plugin {
}

/** The output directories of the environments that Nitro bundles again. Empty in a build without Nitro. */
export function getNitroServiceOutDirs(config: ResolvedConfig): string[] {
function getNitroServiceOutDirs(config: ResolvedConfig): string[] {
if (!(NITRO_ENVIRONMENT in config.environments)) {
return [];
}
Expand All @@ -140,9 +134,13 @@ function isInDirectory(file: string, directory: string): boolean {

/** `output.sourcemap` of the bundler config overrides `build.sourcemap` in the build output. */
export function hasOutputSourceMapSetting(
build: Pick<BuildEnvironmentOptions, 'rolldownOptions'> | undefined,
build: Pick<BuildEnvironmentOptions, 'rolldownOptions' | 'rollupOptions'> | undefined,
): boolean {
return [build?.rolldownOptions?.output ?? []].flat().some(output => output.sourcemap !== undefined);
// Vite 8 still accepts `rollupOptions` in the user config.
// oxlint-disable-next-line typescript/no-deprecated
return [build?.rolldownOptions?.output ?? [], build?.rollupOptions?.output ?? []]
.flat()
.some(output => output.sourcemap !== undefined);
}

/** In a Nitro config, `output.sourcemap` of the bundler config overrides `sourcemap` in the build output. */
Expand Down
21 changes: 13 additions & 8 deletions packages/tanstackstart-react/src/vite/sentryTanstackStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware';
import { makeComposeNitroServiceSourceMapsPlugin, makeEnableNitroSourceMapsPlugin } from './nitroSourceMaps';
import { makeRoutePatternPlugin } from './routePatterns';
import { makeServerInstrumentationPlugin } from './serverInstrumentation';
import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps';
import { createSourceMapState, makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps';
import type { TunnelRouteOptions } from './tunnelRoute';
import { makeServerEntryTunnelPlugin, makeTunnelBasePathPlugin, makeTunnelRoutePlugin } from './tunnelRoute';

Expand Down Expand Up @@ -115,18 +115,23 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P
// (CommonJS) deps via `ssr.noExternal`, which the `vite dev` SSR module runner can't evaluate.
plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation }));

plugins.push(...makeAddSentryVitePlugin(options), makeComposeNitroServiceSourceMapsPlugin());
// The bundler plugin decides on the source map setting in its own `config` hook, so the plugins that enable source
// maps come first.
const sourceMapState = createSourceMapState();
const sourceMapsDisabled = options.sourcemaps?.disable === true || options.sourcemaps?.disable === 'disable-upload';
if (!sourceMapsDisabled) {
plugins.push(
...makeEnableSourceMapsVitePlugin(options, sourceMapState),
makeEnableNitroSourceMapsPlugin(options, sourceMapState),
);
}

plugins.push(...makeAddSentryVitePlugin(options, sourceMapState), makeComposeNitroServiceSourceMapsPlugin());

// middleware auto-instrumentation
if (options.autoInstrumentMiddleware !== false) {
plugins.push(makeAutoInstrumentMiddlewarePlugin({ enabled: true, debug: options.debug }));
}

// source maps
const sourceMapsDisabled = options.sourcemaps?.disable === true || options.sourcemaps?.disable === 'disable-upload';
if (!sourceMapsDisabled) {
plugins.push(...makeEnableSourceMapsVitePlugin(options), makeEnableNitroSourceMapsPlugin(options));
}

return plugins;
}
Loading
Loading