Link to the code that reproduces this issue
https://github.com/r34son/next-otel-http-instrumentation-repro
To Reproduce
- npm install && npm run build && npm run start (standalone output, node .next/standalone/server.js)
- curl -sI http://localhost:3000/ | grep -i x-otel-trace-id
The repro uses the OpenTelemetry setup from the Next.js OpenTelemetry guide (manual configuration) — NodeSDK initialized from instrumentation.ts — plus @opentelemetry/instrumentation-http@0.219.0 with a responseHook that sets an x-otel-trace-id response header, used as an externally observable marker of whether the instrumentation handles incoming requests.
Control experiment in the same repo: npm run start:preload loads the exact same SDK setup via NODE_OPTIONS="--require ./otel-preload.cjs" instead of instrumentation.ts.
Current vs. Expected behavior
Current:
| Setup |
x-otel-trace-id header |
HTTP server spans |
via instrumentation.ts, next@16.2.10 (Turbopack build) |
0/3 requests |
none — every exported span has instrumentationScope: next.js |
via instrumentation.ts, next@15.5.20 (webpack build, same files) |
0/3 requests |
none |
same code via NODE_OPTIONS="--require", either version |
3/3 requests |
present |
The failure is completely silent: the SDK starts fine ([repro] OTel NodeSDK started is printed), Next's built-in next.js-scoped spans flow, and trace.getSpanContext(context.active()) returns valid contexts during SSR — which masks the fact that @opentelemetry/instrumentation-http never attached. There is no warning (the "Module http has been loaded before…" diag warning is only visible if a diag logger is configured).
Expected: instrumentations registered in instrumentation.ts register() — the place the OpenTelemetry guide recommends — instrument incoming HTTP requests, or at least the limitation is documented in the OpenTelemetry guide.
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin 25.5.0
Available memory (MB): 32768
Available CPU cores: 12
Binaries:
Node: 22.18.0
npm: 10.9.3
Relevant Packages:
next: 16.2.10 // Latest available version is detected (16.2.10).
react: 19.2.3
react-dom: 19.2.3
Next.js Config:
output: standalone
Also reproduces with next@15.5.20 (webpack) on the same environment.
Which area(s) are affected? (Select all that apply)
Instrumentation
Which stage(s) are affected? (Select all that apply)
next start (local)
Additional context
Root cause
HttpInstrumentation patches http/https through require-in-the-middle, which wraps Module.prototype.require and applies the patch on the next require('http') call passing through it (a cache hit is fine — it still goes through the wrapped require).
- In standalone mode the generated
server.js requires next/dist/server/lib/start-server, which imports http/https at module top — long before register() runs: https://github.com/vercel/next.js/blob/v15.5.12/packages/next/src/server/lib/start-server.ts#L18-L19
register() is invoked much later, from prepare() of an already-listening server: startServer() → server.listen → getRequestHandlers → NextServer.prepare → loadInstrumentationModule → prepareImpl → runInstrumentationHookIfAvailable (see packages/next/src/server/base-server.ts / next-server.ts).
- Nothing guarantees a
require('http') after register(): page preloading (unstable_preloadEntries, which deliberately awaits prepare() first — “Ensure prepare process will be finished before preloading entries”) does run after register(), but the bundler's externals factories for http inside route chunks evaluate lazily and, for most pages, never; the externals factory of the instrumentation chunk itself evaluates while that chunk is being imported, i.e. before the hook is armed inside register(). So whether the instrumentation ever attaches depends on the accidental import graph of the app — a hello-world never wins this race, and a larger app wins or loses it per bundle graph.
The mechanism is confirmed by an OpenTelemetry maintainer in open-telemetry/opentelemetry-js-contrib#3209: “if Next internally imports or requires the http module before we've had a chance to patch it, it won't be patched” — with --require suggested as the only reliable option (which defeats the purpose of instrumentation.ts, and breaks .env loading).
A 10-line proof without Next behaves identically: load http, create a server, then start the SDK — no patch ever; a single fresh require('http') afterwards — patched, header present on the very next request.
Related issues
Workarounds
-
NODE_OPTIONS="--require ./otel-preload.cjs" — works (see control experiment), but bypasses instrumentation.ts entirely and has known downsides (.env not loaded yet, extra ops surface).
-
require-in-the-middle >= 7.4 also hooks process.getBuiltinModule, so one call right after sdk.start() inside register() applies the pending patch to the already-loaded core modules (public Node >= 20.16 API, untouched by bundlers):
sdk.start();
process.getBuiltinModule('http');
process.getBuiltinModule('https');
-
Things that do not work: createRequire() (its require bypasses Module.prototype.require, the hook never sees it); a bare require('http') in bundled code (compiled into the bundler's own runtime shim); moving the SDK setup to the top level of instrumentation.ts (placement makes no difference — setups that appear to work after this change work because their import graph happens to issue hooked loads after arming, e.g. @opentelemetry/auto-instrumentations-node, and they work inside register() too).
It would be great if Next either performed a module-system pass for core modules after register() (a single process.getBuiltinModule('http')-style touch would do), or documented this pitfall and the escape hatch in the OpenTelemetry guide.
Link to the code that reproduces this issue
https://github.com/r34son/next-otel-http-instrumentation-repro
To Reproduce
The repro uses the OpenTelemetry setup from the Next.js OpenTelemetry guide (manual configuration) — NodeSDK initialized from instrumentation.ts — plus @opentelemetry/instrumentation-http@0.219.0 with a responseHook that sets an x-otel-trace-id response header, used as an externally observable marker of whether the instrumentation handles incoming requests.
Control experiment in the same repo: npm run start:preload loads the exact same SDK setup via NODE_OPTIONS="--require ./otel-preload.cjs" instead of instrumentation.ts.
Current vs. Expected behavior
Current:
x-otel-trace-idheaderinstrumentation.ts, next@16.2.10 (Turbopack build)instrumentationScope: next.jsinstrumentation.ts, next@15.5.20 (webpack build, same files)NODE_OPTIONS="--require", either versionThe failure is completely silent: the SDK starts fine (
[repro] OTel NodeSDK startedis printed), Next's built-innext.js-scoped spans flow, andtrace.getSpanContext(context.active())returns valid contexts during SSR — which masks the fact that@opentelemetry/instrumentation-httpnever attached. There is no warning (the"Module http has been loaded before…"diag warning is only visible if a diag logger is configured).Expected: instrumentations registered in
instrumentation.tsregister()— the place the OpenTelemetry guide recommends — instrument incoming HTTP requests, or at least the limitation is documented in the OpenTelemetry guide.Provide environment information
Which area(s) are affected? (Select all that apply)
Instrumentation
Which stage(s) are affected? (Select all that apply)
next start (local)
Additional context
Root cause
HttpInstrumentationpatcheshttp/httpsthroughrequire-in-the-middle, which wrapsModule.prototype.requireand applies the patch on the nextrequire('http')call passing through it (a cache hit is fine — it still goes through the wrapped require).server.jsrequiresnext/dist/server/lib/start-server, which importshttp/httpsat module top — long beforeregister()runs: https://github.com/vercel/next.js/blob/v15.5.12/packages/next/src/server/lib/start-server.ts#L18-L19register()is invoked much later, fromprepare()of an already-listening server:startServer()→server.listen→getRequestHandlers→NextServer.prepare→loadInstrumentationModule→prepareImpl→runInstrumentationHookIfAvailable(seepackages/next/src/server/base-server.ts/next-server.ts).require('http')afterregister(): page preloading (unstable_preloadEntries, which deliberately awaitsprepare()first — “Ensure prepare process will be finished before preloading entries”) does run afterregister(), but the bundler's externals factories forhttpinside route chunks evaluate lazily and, for most pages, never; the externals factory of theinstrumentationchunk itself evaluates while that chunk is being imported, i.e. before the hook is armed insideregister(). So whether the instrumentation ever attaches depends on the accidental import graph of the app — a hello-world never wins this race, and a larger app wins or loses it per bundle graph.The mechanism is confirmed by an OpenTelemetry maintainer in open-telemetry/opentelemetry-js-contrib#3209: “if Next internally imports or requires the
httpmodule before we've had a chance to patch it, it won't be patched” — with--requiresuggested as the only reliable option (which defeats the purpose ofinstrumentation.ts, and breaks.envloading).A 10-line proof without Next behaves identically: load
http, create a server, then start the SDK — no patch ever; a single freshrequire('http')afterwards — patched, header present on the very next request.Related issues
instrumentation.tscannot be used for instrumenting native modules with OpenTelemetry when native modules are imported frominstrumentation.ts#64879 — same class of problem, framed as “native modules imported frominstrumentation.tsare cached beforeregister()”. This repro shows it is broader: it fails even wheninstrumentation.tsimports nothing http-related, because Next's ownstart-serverimport is what cacheshttp.http_server_request_durationmetrics are never collected” (those metrics are produced byinstrumentation-http).HttpInstrumentationdoes manage to attach.Workarounds
NODE_OPTIONS="--require ./otel-preload.cjs"— works (see control experiment), but bypassesinstrumentation.tsentirely and has known downsides (.envnot loaded yet, extra ops surface).require-in-the-middle>= 7.4 also hooksprocess.getBuiltinModule, so one call right aftersdk.start()insideregister()applies the pending patch to the already-loaded core modules (public Node >= 20.16 API, untouched by bundlers):Things that do not work:
createRequire()(its require bypassesModule.prototype.require, the hook never sees it); a barerequire('http')in bundled code (compiled into the bundler's own runtime shim); moving the SDK setup to the top level ofinstrumentation.ts(placement makes no difference — setups that appear to work after this change work because their import graph happens to issue hooked loads after arming, e.g.@opentelemetry/auto-instrumentations-node, and they work insideregister()too).It would be great if Next either performed a module-system pass for core modules after
register()(a singleprocess.getBuiltinModule('http')-style touch would do), or documented this pitfall and the escape hatch in the OpenTelemetry guide.