Skip to content

[instrumentation] Consider IITM's include list (createAddHookMessageChannel) as the default for ESM, since wrapping every module breaks bundled apps #6984

Description

@segevfiner

Note

This issue was researched and written by Claude (an AI agent) on behalf of @segevfiner, who reviewed it before filing. Findings below were verified against the installed packages on a real project — nothing is inferred from documentation alone.

Important

Overlap disclosed up front. This touches #4933 (which already shows createAddHookMessageChannel in a proposed bootstrap), shares a root cause with #6489 (IITM proxy holding stale snapshots), and is adjacent to #4845. I believe the ask here is distinct — default behaviour rather than documented bootstrap — but please close as a duplicate or fold it into #4933 if you disagree.

The problem

Registering @opentelemetry/instrumentation/hook.mjs rewrites every module in the graph. For bundled ESM applications that is not merely wasteful, it is a hard crash — and it happens with the hook alone, before any SDK is started.

import-in-the-middle replaces ESM live bindings with a snapshot taken when its wrapper evaluates. Bundlers emit lazily initialized bindings (esbuild's __esm helper, which rolldown also emits): var X; declared undefined and assigned later inside an init_*() function, relying on the live binding across a chunk boundary. The wrapper snapshots undefined, and a synchronous consumer reads that:

TypeError: Class extends value undefined is not a constructor or null

IITM does retry still-undefined exports, but only on a microtask and then at 0/10/50 ms, so a class X extends Y evaluated during module evaluation always loses that race. Filed upstream with a three-file reproduction that involves no OpenTelemetry and no bundler: nodejs/import-in-the-middle#280.

That crash is arguably IITM's to fix. What is in scope here is that OpenTelemetry chooses the configuration that triggers it, and doesn't have to.

Why the include list is a reasonable default for OTel specifically

In the common path, OpenTelemetry already knows the complete module set at sdk.start() — it is the code calling Hook() for each instrumentation. That is precisely the precondition IITM's include list requires. So the information needed to scope the hook is already in hand and simply isn't used:

  • @opentelemetry/auto-instrumentations-node/build/src/register.js (0.79.0) constructs NodeSDK and calls sdk.start() with no include channel.
  • The documented ESM bootstrap registers the hook bare, so everything is wrapped.

Concretely, on a bundled Fastify service this was the difference between crash-on-boot and working:

const { registerOptions, waitForAllMessagesAcknowledged } = createAddHookMessageChannel();
module.register('@opentelemetry/instrumentation/hook.mjs', import.meta.url, registerOptions);

// … construct NodeSDK …
sdk.start();
await waitForAllMessagesAcknowledged();

With that change the app boots and instrumentation is fully live — not merely non-crashing:

@opentelemetry/instrumentation-http -> ['GET /health']
@fastify/otel                      -> ['handler - health', 'request']

Dropping the hook instead is not an alternative: the service then boots but emits only dns.lookup spans, losing every request span.

Proposal

Default to the include list where OTel controls the bootstrap — @opentelemetry/auto-instrumentations-node/register first, and the recommended ESM bootstrap in the docs — with an opt-out for the dynamic case.

The tradeoff, stated honestly

Wrapping everything is not merely a default-by-accident; it buys something real. addHook replays IITM's toHook list, so an instrumentation enabled after a module was imported still patches it retroactively. With an include list, the loader must know a module before it is imported, so registerInstrumentations() called after startup would silently stop instrumenting already-loaded modules.

That argues for an escape hatch, not against the default:

  • The NodeSDK + fixed-instrumentation-list path — which is what register.js and the docs describe — has no late registration, so nothing is lost there.
  • Late registration is the exception and can keep wrap-everything behaviour behind an explicit option.
  • waitForAllMessagesAcknowledged() cannot be made implicit by IITM, since it can't know when an app has finished registering hooks — but OTel can know, because it owns sdk.start(). This asymmetry is the core of the argument: it is a default OTel can implement safely and IITM cannot.

Two smaller benefits come along with it: less transform work at startup, and no rewriting of application modules that no instrumentation will ever touch.

Environment

@opentelemetry/sdk-node 0.221.0
@opentelemetry/instrumentation 0.221.0
@opentelemetry/auto-instrumentations-node 0.79.0
import-in-the-middle 3.3.3
Node.js v22.23.2
Bundler rolldown via tsdown 0.22.14

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    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