Skip to content

Implement alternative IITM/RITM wrapping utility #20171

@linear-code

Description

@linear-code

Today, we rely on OTELs registerInstrumentation() to register monkey patching for our Node SDK. While there will continue to be a way to use OTEL, we want to also allow a more generic way to register instrumentation without OTEL as well.

Goal

Replace @opentelemetry/instrumentation's InstrumentationBase + registerInstrumentations() with a Sentry-owned registerModuleWrapper() primitive. This decouples Sentry's module-patching mechanism from the OTel instrumentation infrastructure while keeping the same underlying IITM/RITM hooks.


How OTel Instrumentation Works Today

Data Flow

  1. Construction: new ConnectInstrumentation() calls InstrumentationBase constructor, which calls this.init() to get InstrumentationNodeModuleDefinition[], stores them in this._modules, then calls this.enable().

  2. init() return value: Each instrumentation defines its targets via InstrumentationNodeModuleDefinition:

    new InstrumentationNodeModuleDefinition(
      'connect',           // module name
      ['>=3.0.0 <4'],      // supported versions (semver)
      (exports) => patch(exports),  // patch function
      (exports) => unpatch(exports) // unpatch function (optional)
    )
  3. enable() sets up hooks (the critical piece, in @opentelemetry/instrumentation/build/src/platform/node/instrumentation.js):

    • CJS: Registers with RequireInTheMiddleSingleton (a singleton require-in-the-middle Hook with trie-based module matching)
    • ESM: Creates new import-in-the-middle.Hook([moduleName], { internals: true }, hookFn)
  4. Hook fires on require/import: Calls _onRequire(module, exports, name, baseDir) which:

    • Reads baseDir/package.json to get the package version
    • Checks version against supportedVersions using OTel's lightweight semver
    • If matched and enabled, calls module.patch(exports, version)
  5. registerInstrumentations(): Top-level API that iterates instrumentations and calls enable() on each.

The shimmer layer

OTel has its own shimmer (~120 lines, not the npm shimmer package). _wrap(obj, name, wrapper):

  1. Saves original as obj[name]
  2. Calls wrapper(original) to get wrapped version
  3. Sets markers (e.g. __original, __wrapped) so isWrapped() can detect patched exports
  4. The Node-specific InstrumentationBase overrides _wrap to handle ESM Proxy objects

--> We can likely replace this with our own fill method or something similar

Key OTel dependencies involved

  • @opentelemetry/instrumentationInstrumentationBase, InstrumentationNodeModuleDefinition, shimmer, registerInstrumentations()
  • require-in-the-middle — CJS hook
  • import-in-the-middle — ESM hook
  • OTel's internal semver.js (~440 lines, lightweight, no npm semver dep)
  • OTel's internal RequireInTheMiddleSingleton + ModuleNameTrie — singleton RITM with efficient module name matching

How Sentry Uses This Today

Three patterns for instrumentations

Pattern A: Third-party OTel instrumentation (direct usage)
~15+ integrations (connect, pg, mongodb, redis, etc.):

export const instrumentConnect = generateInstrumentOnce(
  INTEGRATION_NAME, () => new ConnectInstrumentation()
);

Pattern B: Sentry-written class extending InstrumentationBase
~16 instrumentations (Express, Hono, FastifyV3, OpenAI, Anthropic, Vercel AI, LangChain, PostgresJs, Firebase, NestJS, Remix, etc.):

class SentryExpressInstrumentation extends InstrumentationBase {
  init(): InstrumentationNodeModuleDefinition[] { ... }
}

Pattern C: Diagnostics channel (no real module patching)
SentryNodeFetchInstrumentation, SentryHttpInstrumentation — extend InstrumentationBase but return undefined from init() and use diagnostics_channel directly.

The init flow

  1. Sentry.init()initNodeCore()initializeEsmLoader() (registers IITM loader hook via module.register())
  2. initOpenTelemetry() sets up OTel providers
  3. Integrations' setupOnce() calls instrumentXxx() (created via generateInstrumentOnce)
  4. That calls registerInstrumentations()enable() → creates RITM + IITM hooks
  5. User code require('connect') / import 'connect' triggers hook → version check → patch()

Key Sentry files

File Role
packages/node-core/src/otel/instrument.ts generateInstrumentOnce() + registerInstrumentations() call site
packages/node-core/src/sdk/esmLoader.ts IITM loader hook registration (module.register())
packages/node-core/src/utils/ensureIsWrapped.ts Uses isWrapped from @opentelemetry/instrumentation
packages/node/src/integrations/tracing/*.ts All Pattern A/B/C instrumentations
packages/opentelemetry/src/ OTel provider setup

Proposed Design

New API: registerModuleWrapper()

interface ModuleWrapperOptions {
  moduleName: string;
  supportedVersions: string[];  // semver ranges, e.g. ['>=3.0.0 <4']
  patch: (moduleExports: any, version?: string) => any;
  files?: Array<{
    name: string;           // relative path within the package
    supportedVersions: string[];
    patch: (exports: any, version?: string) => any;
  }>;
}

function registerModuleWrapper(options: ModuleWrapperOptions): void;

New file structure

packages/node-core/src/module-wrapper/
  index.ts              — registerModuleWrapper() + public API
  singleton.ts          — RITM singleton (like OTel's RequireInTheMiddleSingleton + ModuleNameTrie)
  semver.ts             — vendored lightweight semver satisfies()
  shimmer.ts            — wrap + isWrapped utilities (with ESM Proxy awareness)
  version.ts            — extractPackageVersion(baseDir) helper

What gets vendored from OTel

Component Source Size Notes
RITM singleton RequireInTheMiddleSingleton + ModuleNameTrie ~150 lines Single RITM hook with trie-based matching
Module version extraction InstrumentationBase._onRequire (partial) ~30 lines Read package.json version from baseDir

What does NOT get vendored

  • OTel tracer/meter/logger provider plumbing (not needed for module wrapping)
  • InstrumentationAbstract (we write our own simpler base class)
  • registerInstrumentations() (replaced by registerModuleWrapper())
  • The diagnostics channel integration (Pattern C instrumentations don't need module wrapping)

Migration Strategy

Step 1: Build the primitives

Create packages/node-core/src/module-wrapper/ with:

  1. shimmer.ts — Vendor OTel's shimmer with Proxy-awareness. Must set __wrapped / __original (or equivalent) so ensureIsWrapped() and isWrapped() keep working.
  2. semver.ts — Vendor OTel's lightweight semver satisfies() or use something comparable
  3. singleton.ts — RITM singleton with ModuleNameTrie. One global RITM hook, register module names into it.
  4. index.tsregisterModuleWrapper() implementation:
    • Register with RITM singleton for CJS
    • Create new iitm.Hook() for ESM (leveraging Sentry's already-registered loader from initializeEsmLoader())
    • On hook invocation: extract version → check semver → call patch()

Step 2: Create SentryInstrumentationBase (compatibility layer)

A drop-in replacement for OTel's InstrumentationBase that:

  • Keeps the same init()InstrumentationNodeModuleDefinition[] pattern
  • Uses registerModuleWrapper() internally instead of OTel's enable() loop
  • Provides _wrap / isWrapped from our vendored shimmer

This minimizes migration effort for Pattern B instrumentations — they just change extends InstrumentationBase to extends SentryInstrumentationBase.

Step 3: Migrate Pattern B instrumentations

Change all ~16 Sentry-written instrumentations:

- import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
+ import { SentryInstrumentationBase, InstrumentationNodeModuleDefinition } from '@sentry/node-core';

- class SentryExpressInstrumentation extends InstrumentationBase {
+ class SentryExpressInstrumentation extends SentryInstrumentationBase {

Step 4: Migrate Pattern A instrumentations (vendored third-party)

For each vendored instrumentation (like connect, which we've already started), the patch logic moves out of the OTel class and into a direct registerModuleWrapper() call:

// Before (using OTel InstrumentationBase)
export const instrumentConnect = generateInstrumentOnce(
  'Connect', () => new ConnectInstrumentation()
);

// After (using registerModuleWrapper directly)
export const instrumentConnect = generateInstrumentOnce('Connect', () =>
  registerModuleWrapper({
    moduleName: 'connect',
    supportedVersions: ['>=3.0.0 <4'],
    patch: (moduleExports) => patchConnect(moduleExports),
  })
);

This requires generateInstrumentOnce to accept registrations that use registerModuleWrapper() (not only OTel Instrumentation objects).

Step 5: Update generateInstrumentOnce

Currently calls registerInstrumentations(). Update to:

  • Accept both legacy Instrumentation objects (for any remaining OTel instrumentations during transition) and registerModuleWrapper()-based registrations
  • Eventually remove OTel registerInstrumentations() call entirely

Step 6: Update ensureIsWrapped

Replace import of isWrapped from @opentelemetry/instrumentation with our own shimmer's isWrapped.

Step 7: Remove @opentelemetry/instrumentation dependency

Once all instrumentations are migrated, remove from @sentry/node-core and @sentry/node package.json.


Potential Challenges

  1. ESM loader hook sharing: IITM's loader hook (registered by initializeEsmLoader()) must be active before any new iitm.Hook() is created. Current code already handles this ordering.
  2. Dual RITM instances: If users load their own OTel instrumentations alongside Sentry, there will be two RITM hooks. This is unavoidable but acceptable — both will fire independently.
  3. isWrapped() compatibility: Our shimmer must expose the same isWrapped() behavior (and compatible markers) as OTel's shimmer, so ensureIsWrapped() and any code that checks isWrapped() still works.
  4. Pattern C instrumentations: These don't use module wrapping at all (init() returns undefined). They just need a simpler base class that provides a tracer — or they can be refactored to not extend any base class.
  5. InstrumentationNodeModuleDefinition: All Pattern B instrumentations use this class. We should either vendor it (trivial — it's a simple data holder) or create our own equivalent with the same shape.
  6. import-in-the-middle version coupling: IITM is already a direct Sentry dependency. No issue here.
  7. require-in-the-middle: Currently a transitive dependency via @opentelemetry/instrumentation. After removing that dep, we'll need to add require-in-the-middle as a direct dependency of @sentry/node-core.

Order of Execution

Phase What Depends On
1 Vendor shimmer, semver, RITM singleton into module-wrapper/ Nothing
2 Implement registerModuleWrapper() Phase 1
3 Create SentryInstrumentationBase using registerModuleWrapper() Phase 2
4 Update ensureIsWrapped to use vendored shimmer Phase 1
5 Migrate Pattern B instrumentations to SentryInstrumentationBase Phase 3
6 Vendor remaining Pattern A instrumentations (like connect) Phase 2
7 Update generateInstrumentOnce to support new API Phase 2
8 Migrate Pattern C instrumentations (simplify, drop base class) Phase 3
9 Remove @opentelemetry/instrumentation dependency All above

Metadata

Metadata

Assignees

No one assigned

    Labels

    javascriptPull requests that update javascript code
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions