Describe the bug
With kit.experimental.instrumentation.server: true and adapter-node, src/instrumentation.server.ts is documented as being loaded before any application code. However, its build output is not isolated from the application's chunk graph: if the instrumentation file imports an npm package that application code also imports, the bundler may colocate that package into a chunk that also contains application modules. The compiled instrumentation entry then imports that chunk — evaluating application modules before Server.init() has called set_private_env().
Any application module that reads $env/dynamic/private at module scope then silently captures undefined for every env value. There is no error at startup — things fail later at request time in whatever way the stale values produce.
This is a nastier variant of #14286: there the env modules are imported by instrumentation.server.ts itself; here instrumentation never touches $env — it merely dynamically imports @opentelemetry/api, and the chunking pulls in unrelated app modules.
Concretely, our instrumentation.server.ts begins with:
const { context, propagation, trace } = await import('@opentelemetry/api');
@opentelemetry/api is also imported by our hooks.server.ts and API-client wrapper modules (which read $env/dynamic/private at module scope to configure base URLs). In the production build the bundler placed @opentelemetry/api inside the same chunk as those app modules, so the compiled build/instrumentation.server.js starts with:
import('./server/chunks/chunks/better-auth.js-DA2TiEpY.js') // ← this is `await import('@opentelemetry/api')`
Instrumenting the chunks confirms the evaluation order at process start:
EVAL app chunk (module-scope createClient reads env) ← first thing the process does
captured API_BASE_URL = undefined
SET_PRIVATE_ENV called (real values) ← too late
Every outgoing fetch in the app then failed instantly with TypeError: Failed to parse URL from /v1/... (baseUrl undefined), taking down the whole login flow in our deployed environment.
The failure is platform/heuristic-dependent, which makes it very hard to catch: building the same commit with the same lockfile on Windows produced a layout where @opentelemetry/api got its own chunk cluster (propagation-api/trace-api/diag) with no app code — that build works fine. The Linux CI build produced the broken colocation. So it works on every dev machine and breaks only in the deployed artifact.
Reproduction
Mechanism summary (a minimal repo needs the bundler to pick the colocating layout, which is heuristic-dependent — I can invest in a pinned repro if that helps triage):
svelte.config.js: kit.experimental.instrumentation.server: true, adapter-node.
src/instrumentation.server.ts: top-level await import('some-shared-pkg') (e.g. @opentelemetry/api).
hooks.server.ts (or anything in its import graph) also imports some-shared-pkg, and some server module in that graph reads $env/dynamic/private at module scope.
- Build with vite 8 (rolldown). If the bundler colocates
some-shared-pkg with the app modules (observed on ubuntu-latest, not on Windows, same inputs), the compiled build/instrumentation.server.js dynamic-imports the app chunk before start.js runs, and the module-scope env reads see {}.
Verification that Server.init() itself is fine: in the compiled output it correctly calls set_private_env() before get_hooks() — the problem is purely that instrumentation's import graph can reach app chunks earlier than init.
Logs
TypeError: Failed to parse URL from /v1/authenticate/mitId
at new Request (node:internal/deps/undici/undici:12302:19)
at request (file:///app/build/server/chunks/chunks/better-auth.js-DA2TiEpY.js:47061:14)
System Info
@sveltejs/kit: 2.69.2
@sveltejs/adapter-node: 5.5.7
@sveltejs/vite-plugin-svelte: 7.2.0
vite: 8.1.3 (rolldown)
node: 26.3.0
broken build: GitHub Actions ubuntu-latest
working build (same commit + lockfile): Windows 11
Severity
serious, but I can work around it
Workaround: never read $env/dynamic/private at module scope — we now wrap every module-scope singleton that reads env in a Proxy-based lazy() helper that defers creation to first property access. But the failure mode is silent, platform-dependent, and contradicts the mental model that instrumentation.server.ts merely runs "before" the app — it can cause app code to run before the server is initialized.
Additional Information
Possible directions:
- Build
instrumentation.server.ts as an isolated graph (duplicate shared deps instead of sharing chunks with the app), so importing a package from instrumentation can never evaluate app modules.
- Or have the adapter entry call
set_private_env()/set_public_env() (from process.env) before importing instrumentation.server.js, so early-evaluated app modules at least see real values.
- At minimum, document that any package imported from
instrumentation.server.ts that is also used by app code may drag app modules into pre-init evaluation.
Describe the bug
With
kit.experimental.instrumentation.server: trueand adapter-node,src/instrumentation.server.tsis documented as being loaded before any application code. However, its build output is not isolated from the application's chunk graph: if the instrumentation file imports an npm package that application code also imports, the bundler may colocate that package into a chunk that also contains application modules. The compiled instrumentation entry then imports that chunk — evaluating application modules beforeServer.init()has calledset_private_env().Any application module that reads
$env/dynamic/privateat module scope then silently capturesundefinedfor every env value. There is no error at startup — things fail later at request time in whatever way the stale values produce.This is a nastier variant of #14286: there the env modules are imported by instrumentation.server.ts itself; here instrumentation never touches
$env— it merely dynamically imports@opentelemetry/api, and the chunking pulls in unrelated app modules.Concretely, our
instrumentation.server.tsbegins with:@opentelemetry/apiis also imported by ourhooks.server.tsand API-client wrapper modules (which read$env/dynamic/privateat module scope to configure base URLs). In the production build the bundler placed@opentelemetry/apiinside the same chunk as those app modules, so the compiledbuild/instrumentation.server.jsstarts with:Instrumenting the chunks confirms the evaluation order at process start:
Every outgoing
fetchin the app then failed instantly withTypeError: Failed to parse URL from /v1/...(baseUrl undefined), taking down the whole login flow in our deployed environment.The failure is platform/heuristic-dependent, which makes it very hard to catch: building the same commit with the same lockfile on Windows produced a layout where
@opentelemetry/apigot its own chunk cluster (propagation-api/trace-api/diag) with no app code — that build works fine. The Linux CI build produced the broken colocation. So it works on every dev machine and breaks only in the deployed artifact.Reproduction
Mechanism summary (a minimal repo needs the bundler to pick the colocating layout, which is heuristic-dependent — I can invest in a pinned repro if that helps triage):
svelte.config.js:kit.experimental.instrumentation.server: true, adapter-node.src/instrumentation.server.ts: top-levelawait import('some-shared-pkg')(e.g.@opentelemetry/api).hooks.server.ts(or anything in its import graph) also importssome-shared-pkg, and some server module in that graph reads$env/dynamic/privateat module scope.some-shared-pkgwith the app modules (observed on ubuntu-latest, not on Windows, same inputs), the compiledbuild/instrumentation.server.jsdynamic-imports the app chunk beforestart.jsruns, and the module-scope env reads see{}.Verification that
Server.init()itself is fine: in the compiled output it correctly callsset_private_env()beforeget_hooks()— the problem is purely that instrumentation's import graph can reach app chunks earlier than init.Logs
System Info
Severity
serious, but I can work around it
Workaround: never read
$env/dynamic/privateat module scope — we now wrap every module-scope singleton that reads env in a Proxy-basedlazy()helper that defers creation to first property access. But the failure mode is silent, platform-dependent, and contradicts the mental model thatinstrumentation.server.tsmerely runs "before" the app — it can cause app code to run before the server is initialized.Additional Information
Possible directions:
instrumentation.server.tsas an isolated graph (duplicate shared deps instead of sharing chunks with the app), so importing a package from instrumentation can never evaluate app modules.set_private_env()/set_public_env()(fromprocess.env) before importinginstrumentation.server.js, so early-evaluated app modules at least see real values.instrumentation.server.tsthat is also used by app code may drag app modules into pre-init evaluation.