Skip to content

OpenTelemetry HttpInstrumentation set up via instrumentation.ts never instruments incoming requests — http is loaded before register() and no later require passes the hook #95894

Description

@r34son

Link to the code that reproduces this issue

https://github.com/r34son/next-otel-http-instrumentation-repro

To Reproduce

  1. npm install && npm run build && npm run start (standalone output, node .next/standalone/server.js)
  2. 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).

  1. 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
  2. register() is invoked much later, from prepare() of an already-listening server: startServer()server.listengetRequestHandlersNextServer.prepareloadInstrumentationModuleprepareImplrunInstrumentationHookIfAvailable (see packages/next/src/server/base-server.ts / next-server.ts).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    InstrumentationRelated to Next.js Instrumentation.

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions