Skip to content

instrumentation.server.js can evaluate app modules before Server.init() when the bundler colocates a shared dependency ($env/dynamic/private silently undefined) #16288

Description

@SobolievOleksii

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):

  1. svelte.config.js: kit.experimental.instrumentation.server: true, adapter-node.
  2. src/instrumentation.server.ts: top-level await import('some-shared-pkg') (e.g. @opentelemetry/api).
  3. 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.
  4. 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.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions