Skip to content
Closed
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 @@ -14,13 +14,15 @@
"test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build",
"test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build",
"test:build:hoisted": "pnpm install --config.node-linker=hoisted && pnpm build",
"test:build:init-in-server-entry": "pnpm install && E2E_TEST_INIT_IN_SERVER_ENTRY=1 pnpm build",
"test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build",
"test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest nitro@latest vite@latest @vitejs/plugin-react-swc@latest vite-tsconfig-paths@latest && pnpm install && pnpm build",
"test:assert:proxy": "pnpm test && TEST_ENV=development pnpm playwright test db-drivers",
"test:assert": "node assert-build.ts && pnpm test:assert:proxy",
"test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test",
"test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test",
"test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test",
"test:assert:init-in-server-entry": "E2E_TEST_INIT_IN_SERVER_ENTRY=1 pnpm test init-in-server-entry",
"test:assert:tunnel-basepath": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test tunnel",
"test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development pnpm test tunnel"
},
Expand All @@ -46,6 +48,7 @@
"vite": "8.3.4",
"vite-tsconfig-paths": "^5.1.4",
"@playwright/test": "1.64.1",
"@sentry/conventions": "0.30.0",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz"
},
"sentryTest": {
Expand All @@ -69,6 +72,11 @@
"build-command": "pnpm test:build:tunnel-custom",
"assert-command": "pnpm test:assert:tunnel-custom"
},
{
"label": "tanstackstart-react (init-in-server-entry)",
"build-command": "pnpm test:build:init-in-server-entry",
"assert-command": "pnpm test:assert:init-in-server-entry"
},
{
"label": "tanstackstart-react (tunnel-basepath)",
"build-command": "pnpm test:build:tunnel-basepath",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

// The DB driver tests only run in the default (proxy) variant, so the tunnel-route variants don't
// The DB driver tests only run in the default (proxy) variant, so the other variants don't
// need MySQL/Redis — skip Docker there instead of booting the containers once per variant.
const usesManagedTunnelRoute =
(process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1';
const needsDocker = !usesManagedTunnelRoute && process.env.E2E_TEST_INIT_IN_SERVER_ENTRY !== '1';

// The dev server has no build output, so `--import` points at the app's own instrumentation file.
// `vite dev` ignores PORT, so the port goes on the command.
// The init-in-server-entry test starts a server of its own.
const startCommand =
process.env.TEST_ENV === 'development'
? `NODE_OPTIONS='--import ./instrument.server.mjs' pnpm dev --port 3000`
: `pnpm start`;
: process.env.E2E_TEST_INIT_IN_SERVER_ENTRY === '1'
? undefined
: `pnpm start`;

const config = getPlaywrightConfig({
startCommand,
port: 3000,
});

export default usesManagedTunnelRoute
? config
: {
export default needsDocker
? {
...config,
globalSetup: './global-setup.mjs',
globalTeardown: './global-teardown.mjs',
};
}
: config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { type ChildProcess, spawn } from 'node:child_process';
import { expect, test } from '@playwright/test';
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
import { URL_FULL, URL_PATH } from '@sentry/conventions/attributes';
import { HTTP_SERVER } from '@sentry/conventions/op';

test.skip(process.env.E2E_TEST_INIT_IN_SERVER_ENTRY !== '1', 'Runs only in the init-in-server-entry variant');

// A server of its own, so the request below is the first one it handles. Nitro imports the server entry, and so the
// SDK init at its top, lazily on the first request.
const PORT = 3001;

let server: ChildProcess | undefined;

// Playwright skips `finally` blocks of a test that times out.
test.afterEach(() => {
server?.kill();
});

test('sends an http.server span for the first request of a server started without --import', async () => {
const serverProcess = spawn('node', ['.output/server/index.mjs'], {
env: { ...process.env, PORT: String(PORT), NITRO_PORT: String(PORT) },
stdio: ['ignore', 'pipe', 'pipe'],
});
server = serverProcess;

let output = '';
await new Promise<void>((resolve, reject) => {
const onData = (data: Buffer): void => {
output += data.toString();
if (output.includes('Listening on')) {
resolve();
}
};
serverProcess.stdout.on('data', onData);
serverProcess.stderr.on('data', onData);
serverProcess.once('exit', code => reject(new Error(`Server exited with code ${code}:\n${output}`)));
});

const spanPromise = waitForStreamedSpan(
'tanstackstart-react',
span =>
!!span.is_segment &&
getSpanOp(span) === HTTP_SERVER &&
String(span.attributes[URL_FULL]?.value ?? '').includes(`localhost:${PORT}/`),
);

// The preload timing out would still initialize the SDK before the server listens, so the test would pass anyway.
expect(output).not.toContain('The first request may not be traced');

const response = await fetch(`http://localhost:${PORT}/`);
expect(response.status).toBe(200);

const span = await spanPromise;
expect(span.attributes[URL_PATH]?.value).toBe('/');
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ function recordChunksWithoutSourceMap() {
};
}

// Hosts without `node --import`, such as Vercel, initialize the SDK with an import at the top of the server entry.
function initInServerEntry() {
return {
name: 'init-in-server-entry',
enforce: 'pre' as const,
transform(code: string, id: string) {
// On the first line, so that the server source maps stay correct without a map for this change.
return id.endsWith('/src/server.ts')
? { code: `import '../instrument.server.mjs';${code}`, map: null }
: undefined;
},
};
}

const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off';
const useManagedTunnelRoute = tunnelRouteMode !== 'off';
const useCustomTunnelRoute = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1';
Expand Down Expand Up @@ -81,5 +95,6 @@ export default defineConfig({
tunnelRoute,
}),
recordChunksWithoutSourceMap(),
process.env.E2E_TEST_INIT_IN_SERVER_ENTRY === '1' && initInServerEntry(),
],
});
169 changes: 169 additions & 0 deletions packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import type { Plugin, Rollup } from 'vite';
import { logDebug, NITRO_ENVIRONMENT, type NitroOptions } from './nitroSourceMaps';

const PRELOAD_MODULE_ID = '#sentry/tanstackstart-react/preload-server-entry';
const RESOLVED_PRELOAD_MODULE_ID = `\0${PRELOAD_MODULE_ID}`;

// The module that imports the service entries lazily. Nitro renamed it, so this lists the names, newest first.
const NITRO_SERVICES_MODULE_IDS = ['#nitro/virtual/vite-services', '#nitro-vite-setup'];

// Replaced with the services to await once the module graph is complete. Left as is, no service is awaited.
const AWAITED_SERVICES_PLACEHOLDER = '__SENTRY_PRELOAD_AWAITED_SERVICES__';

// A safety net against an import cycle that the build could not detect, which would otherwise never settle.
const PRELOAD_TIMEOUT_MS = 3000;

type Nitro = { options: Pick<NitroOptions, 'dev' | 'node' | 'plugins'> };

type ModuleGraph = Pick<Rollup.PluginContext, 'getModuleInfo'>;

/**
* Nitro imports the entry of a Vite service, such as TanStack Start's server entry, on the first request. An
* `instrument.server` import at the top of the server entry so only initializes the SDK during that request, too late
* for the HTTP instrumentation to see it. This adds a Nitro plugin that imports the service entries at server startup.
*/
export function makePreloadNitroServerEntryPlugin({ debug }: { debug?: boolean }): Plugin & {
nitro: { name: string; setup: (nitro: Nitro) => void };
} {
let serviceEntryIds: string[] = [];

return {
name: 'sentry-tanstackstart-nitro-preload-server-entry',
apply: 'build',
enforce: 'pre',
applyToEnvironment: environment => environment.name === NITRO_ENVIRONMENT,
resolveId(id) {
return id === PRELOAD_MODULE_ID ? RESOLVED_PRELOAD_MODULE_ID : null;
},
async load(id) {
if (id !== RESOLVED_PRELOAD_MODULE_ID) {
return null;
}

const entries = await loadServiceEntries(this);
serviceEntryIds = await Promise.all(entries.map(async entry => (await this.resolve(entry, id))?.id ?? entry));
logDebug(
debug,
entries.length > 0
? `Loading the Nitro service entries at server startup: ${entries.join(', ')}`
: 'Found no Nitro service entries to load at server startup.',
);
return renderPreloadModule(entries, debug);
},
renderChunk(code, chunk) {
if (!chunk.moduleIds.includes(RESOLVED_PRELOAD_MODULE_ID)) {
return null;
}

const awaited = serviceEntryIds.map(entryId => !importsModule(this, entryId, RESOLVED_PRELOAD_MODULE_ID));
serviceEntryIds.forEach((entryId, index) => {
if (!awaited[index]) {
logDebug(
debug,
`Not waiting for ${entryId} at server startup, as it imports the Nitro runtime. The first request may not be traced on serverless platforms.`,
);
}
});
// Only the rest of the placeholder's line moves, so the source map can stay as is.
return { code: setAwaitedServices(code, awaited), map: null };
},
nitro: {
name: 'sentry-tanstackstart-nitro-preload-server-entry',
setup(nitro) {
// Workers have no Node.js HTTP server to instrument, and in dev Nitro loads the services through Vite.
if (nitro.options.dev || nitro.options.node === false) {
logDebug(debug, 'Not loading the Nitro service entries at server startup in dev or without Node.js.');
return;
}
nitro.options.plugins.push(PRELOAD_MODULE_ID);
},
},
};
}

/**
* Reads the service entries from the module that Nitro generates to import them lazily. Importing the very same
* specifiers makes the bundler load the same modules, rather than a second copy of the SDK. If the module changes
* shape, nothing is preloaded.
*/
async function loadServiceEntries(context: Pick<Rollup.PluginContext, 'resolve' | 'load'>): Promise<string[]> {
for (const id of NITRO_SERVICES_MODULE_IDS) {
const resolved = await context.resolve(id);
if (resolved && !resolved.external) {
const { code } = await context.load(resolved);
return parseServiceEntries(code ?? '');
}
}
return [];
}

export function parseServiceEntries(code: string): string[] {
const entries = [];
for (const [, entry] of code.matchAll(/\]: lazyService\(\(\) => import\(("(?:[^"\\]|\\.)*")\)\)/g)) {
if (entry) {
entries.push(JSON.parse(entry) as string);
}
}
return entries;
}

/**
* Whether a module statically imports the target, directly or through other modules. A service entry that imports the
* preload module would wait for it at startup, while the preload module waits for the entry.
*/
export function importsModule(graph: ModuleGraph, moduleId: string, targetId: string): boolean {
const seen = new Set<string>();
const queue = [moduleId];
for (let id = queue.shift(); id !== undefined; id = queue.shift()) {
if (id === targetId) {
return true;
}
if (seen.has(id)) {
continue;
}
seen.add(id);

const info = graph.getModuleInfo(id);
if (!info) {
// Without the imports of a module, assume the worst.
return true;
}
queue.push(...info.importedIds);
}
return false;
}

/**
* Serverless platforms send the first request right after loading the Nitro server module, so the preload has to
* finish before the module does, with top-level `await`. Which services are awaited is only known after the build.
*/
export function renderPreloadModule(entries: string[], debug?: boolean): string {
if (entries.length === 0) {
return 'export default function sentryPreloadServerEntry() {}';
}

// Errors surface when Nitro imports the entry for the first request, as without the preload.
const onError = debug
? `error => console.warn(${JSON.stringify('[Sentry] Loading a Nitro service entry at server startup failed:')}, error)`
: '() => {}';

return [
`const preloads = [${entries.map(entry => `import(${JSON.stringify(entry)}).catch(${onError})`).join(', ')}];`,
`const awaitedServices = ${JSON.stringify(AWAITED_SERVICES_PLACEHOLDER)};`,
'const awaited = preloads.filter((_, index) => awaitedServices[index] === true);',
'if (awaited.length > 0) {',
' let loaded = false;',
' let timeout;',
` await Promise.race([Promise.all(awaited).then(() => { loaded = true; }), new Promise(resolve => { timeout = setTimeout(resolve, ${PRELOAD_TIMEOUT_MS}); })]);`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Startup timer is not unref'd

Low Severity

The generated preload uses setTimeout without unref() or safeUnref(). That timer is injected into the Nitro server bundle, so a pending 3s fallback can keep a Node process alive. This was flagged because the project review rules require unref on timers in server runtime paths.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 3e30f13. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the timer referenced on purpose. It only matters when the preload never settles, which happens with an import cycle the build did not detect. Then nothing else keeps the event loop alive, so with unref() Node would exit with code 13 (unsettled top-level await) before the timeout fires, which is the case the timeout exists for. The timer is cleared as soon as the preload finishes, and it keeps the process alive for at most 3s while the server starts.

' clearTimeout(timeout);',
` if (!loaded) console.warn(${JSON.stringify(
`[Sentry] Loading the server entry at server startup took longer than ${PRELOAD_TIMEOUT_MS}ms and continues in the background. The first request may not be traced.`,
)});`,
'}',
'export default function sentryPreloadServerEntry() {}',
].join('\n');
}

export function setAwaitedServices(code: string, awaited: boolean[]): string {
return code.replace(new RegExp(`(["'])${AWAITED_SERVICES_PLACEHOLDER}\\1`), JSON.stringify(awaited));
}
26 changes: 15 additions & 11 deletions packages/tanstackstart-react/src/vite/nitroSourceMaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as path from 'node:path';
import type { BuildEnvironmentOptions, Plugin, ResolvedConfig } from 'vite';

/** `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 @@ -16,17 +16,20 @@ type NitroSourceMapConfig = {
rolldownConfig?: BundlerSourceMapConfig;
};

/** The parts of the Nitro instance that this plugin uses. */
type Nitro = {
options: {
sourcemap: unknown;
experimental: { sourcemapMinify?: false };
// c12 keeps each config source that Nitro merged: the `nitro()` options, `nitro.config.ts`, presets and more.
// It is internal until Nitro exposes the layers: https://github.com/nitrojs/nitro/issues/4771
_c12?: { layers?: { config?: NitroSourceMapConfig | null }[] };
};
/** The parts of the Nitro options that the Sentry Nitro modules use. */
export type NitroOptions = {
dev: boolean;
node: boolean;
plugins: string[];
sourcemap: unknown;
experimental: { sourcemapMinify?: false };
// c12 keeps each config source that Nitro merged: the `nitro()` options, `nitro.config.ts`, presets and more.
// It is internal until Nitro exposes the layers: https://github.com/nitrojs/nitro/issues/4771
_c12?: { layers?: { config?: NitroSourceMapConfig | null }[] };
};

type Nitro = { options: Pick<NitroOptions, 'sourcemap' | 'experimental' | '_c12'> };

/**
* Tells the plugin that deletes source maps after the upload whether the source maps of an environment are only
* there because Sentry enabled them.
Expand Down Expand Up @@ -152,7 +155,8 @@ function hasNitroSourceMapSetting(config: NitroSourceMapConfig | null | undefine
);
}

function logDebug(debug: boolean | undefined, message: string): void {
/** Logs a message when the `debug` option is enabled. */
export function logDebug(debug: boolean | undefined, message: string): void {
if (debug) {
// eslint-disable-next-line no-console
console.log(`[Sentry] ${message}`);
Expand Down
Loading
Loading