Repository navigation
fix(tanstackstart-react): Initialize the SDK at Nitro server startup #26088
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
6cde267
fix(tanstackstart-react): Initialize the SDK at Nitro server startup
chargome 5c0c075
test(tanstackstart-react): Use convention constants in server entry i…
chargome dd01be2
fix(tanstackstart-react): Preload exactly the services that Nitro loads
chargome e552a7c
ref(tanstackstart-react): Avoid non-null assertions when reading Nitr…
chargome 7c08218
chore: Retrigger CI
chargome 3e30f13
fix(tanstackstart-react): Find Nitro services and import cycles in th…
chargome File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
16 changes: 10 additions & 6 deletions
16
dev-packages/e2e-tests/test-applications/tanstackstart-react/playwright.config.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
56 changes: 56 additions & 0 deletions
56
...ckages/e2e-tests/test-applications/tanstackstart-react/tests/init-in-server-entry.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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('/'); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
169 changes: 169 additions & 0 deletions
169
packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}); })]);`, | ||
| ' 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)); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
setTimeoutwithoutunref()orsafeUnref(). 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.Triggered by project rule: PR Review Guidelines for Cursor Bot
Reviewed by Cursor Bugbot for commit 3e30f13. Configure here.
There was a problem hiding this comment.
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.