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
Construction: new ConnectInstrumentation() calls InstrumentationBase constructor, which calls this.init() to get InstrumentationNodeModuleDefinition[], stores them in this._modules, then calls this.enable().
init() return value: Each instrumentation defines its targets via InstrumentationNodeModuleDefinition:
newInstrumentationNodeModuleDefinition('connect',// module name['>=3.0.0 <4'],// supported versions (semver)(exports)=>patch(exports),// patch function(exports)=>unpatch(exports)// unpatch function (optional))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)
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)
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):
- Saves original as
obj[name] - Calls
wrapper(original) to get wrapped version - Sets markers (e.g.
__original, __wrapped) so isWrapped() can detect patched exports - 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/instrumentation — InstrumentationBase, InstrumentationNodeModuleDefinition, shimmer, registerInstrumentations()require-in-the-middle — CJS hookimport-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.):
exportconstinstrumentConnect=generateInstrumentOnce(INTEGRATION_NAME,()=>newConnectInstrumentation());
Pattern B: Sentry-written class extendingInstrumentationBase
~16 instrumentations (Express, Hono, FastifyV3, OpenAI, Anthropic, Vercel AI, LangChain, PostgresJs, Firebase, NestJS, Remix, etc.):
classSentryExpressInstrumentationextendsInstrumentationBase{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
Sentry.init() → initNodeCore() → initializeEsmLoader() (registers IITM loader hook via module.register())initOpenTelemetry() sets up OTel providers- Integrations'
setupOnce() calls instrumentXxx() (created via generateInstrumentOnce) - That calls
registerInstrumentations() → enable() → creates RITM + IITM hooks - 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()
interfaceModuleWrapperOptions{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 packagesupportedVersions: string[];patch: (exports: any,version?: string)=>any;}>;}functionregisterModuleWrapper(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:
shimmer.ts — Vendor OTel's shimmer with Proxy-awareness. Must set __wrapped / __original (or equivalent) so ensureIsWrapped() and isWrapped() keep working.semver.ts — Vendor OTel's lightweight semver satisfies() or use something comparablesingleton.ts — RITM singleton with ModuleNameTrie. One global RITM hook, register module names into it.index.ts — registerModuleWrapper() 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)exportconstinstrumentConnect=generateInstrumentOnce('Connect',()=>newConnectInstrumentation());// After (using registerModuleWrapper directly)exportconstinstrumentConnect=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
- 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. - 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.
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.- 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. 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.import-in-the-middle version coupling: IITM is already a direct Sentry dependency. No issue here.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 |
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'sInstrumentationBase+registerInstrumentations()with a Sentry-ownedregisterModuleWrapper()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
Construction:
new ConnectInstrumentation()callsInstrumentationBaseconstructor, which callsthis.init()to getInstrumentationNodeModuleDefinition[], stores them inthis._modules, then callsthis.enable().init()return value: Each instrumentation defines its targets viaInstrumentationNodeModuleDefinition:enable()sets up hooks (the critical piece, in@opentelemetry/instrumentation/build/src/platform/node/instrumentation.js):RequireInTheMiddleSingleton(a singletonrequire-in-the-middleHook with trie-based module matching)new import-in-the-middle.Hook([moduleName], { internals: true }, hookFn)Hook fires on require/import: Calls
_onRequire(module, exports, name, baseDir)which:baseDir/package.jsonto get the package versionsupportedVersionsusing OTel's lightweight semvermodule.patch(exports, version)registerInstrumentations(): Top-level API that iterates instrumentations and callsenable()on each.The shimmer layer
OTel has its own shimmer (~120 lines, not the npm
shimmerpackage)._wrap(obj, name, wrapper):obj[name]wrapper(original)to get wrapped version__original,__wrapped) soisWrapped()can detect patched exportsInstrumentationBaseoverrides_wrapto handle ESM Proxy objects--> We can likely replace this with our own
fillmethod or something similarKey OTel dependencies involved
@opentelemetry/instrumentation—InstrumentationBase,InstrumentationNodeModuleDefinition, shimmer,registerInstrumentations()require-in-the-middle— CJS hookimport-in-the-middle— ESM hooksemver.js(~440 lines, lightweight, no npmsemverdep)RequireInTheMiddleSingleton+ModuleNameTrie— singleton RITM with efficient module name matchingHow Sentry Uses This Today
Three patterns for instrumentations
Pattern A: Third-party OTel instrumentation (direct usage)
~15+ integrations (connect, pg, mongodb, redis, etc.):
Pattern B: Sentry-written class extending
InstrumentationBase~16 instrumentations (Express, Hono, FastifyV3, OpenAI, Anthropic, Vercel AI, LangChain, PostgresJs, Firebase, NestJS, Remix, etc.):
Pattern C: Diagnostics channel (no real module patching)
SentryNodeFetchInstrumentation,SentryHttpInstrumentation— extendInstrumentationBasebut returnundefinedfrominit()and usediagnostics_channeldirectly.The init flow
Sentry.init()→initNodeCore()→initializeEsmLoader()(registers IITM loader hook viamodule.register())initOpenTelemetry()sets up OTel providerssetupOnce()callsinstrumentXxx()(created viagenerateInstrumentOnce)registerInstrumentations()→enable()→ creates RITM + IITM hooksrequire('connect')/import 'connect'triggers hook → version check →patch()Key Sentry files
packages/node-core/src/otel/instrument.tsgenerateInstrumentOnce()+registerInstrumentations()call sitepackages/node-core/src/sdk/esmLoader.tsmodule.register())packages/node-core/src/utils/ensureIsWrapped.tsisWrappedfrom@opentelemetry/instrumentationpackages/node/src/integrations/tracing/*.tspackages/opentelemetry/src/Proposed Design
New API:
registerModuleWrapper()New file structure
What gets vendored from OTel
RequireInTheMiddleSingleton+ModuleNameTrieInstrumentationBase._onRequire(partial)package.jsonversion from baseDirWhat does NOT get vendored
InstrumentationAbstract(we write our own simpler base class)registerInstrumentations()(replaced byregisterModuleWrapper())Migration Strategy
Step 1: Build the primitives
Create
packages/node-core/src/module-wrapper/with:shimmer.ts— Vendor OTel's shimmer with Proxy-awareness. Must set__wrapped/__original(or equivalent) soensureIsWrapped()andisWrapped()keep working.semver.ts— Vendor OTel's lightweight semversatisfies()or use something comparablesingleton.ts— RITM singleton withModuleNameTrie. One global RITM hook, register module names into it.index.ts—registerModuleWrapper()implementation:new iitm.Hook()for ESM (leveraging Sentry's already-registered loader frominitializeEsmLoader())patch()Step 2: Create
SentryInstrumentationBase(compatibility layer)A drop-in replacement for OTel's
InstrumentationBasethat:init()→InstrumentationNodeModuleDefinition[]patternregisterModuleWrapper()internally instead of OTel'senable()loop_wrap/isWrappedfrom our vendored shimmerThis minimizes migration effort for Pattern B instrumentations — they just change
extends InstrumentationBasetoextends SentryInstrumentationBase.Step 3: Migrate Pattern B instrumentations
Change all ~16 Sentry-written instrumentations:
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:This requires
generateInstrumentOnceto accept registrations that useregisterModuleWrapper()(not only OTelInstrumentationobjects).Step 5: Update
generateInstrumentOnceCurrently calls
registerInstrumentations(). Update to:Instrumentationobjects (for any remaining OTel instrumentations during transition) andregisterModuleWrapper()-based registrationsregisterInstrumentations()call entirelyStep 6: Update
ensureIsWrappedReplace import of
isWrappedfrom@opentelemetry/instrumentationwith our own shimmer'sisWrapped.Step 7: Remove
@opentelemetry/instrumentationdependencyOnce all instrumentations are migrated, remove from
@sentry/node-coreand@sentry/nodepackage.json.Potential Challenges
initializeEsmLoader()) must be active before anynew iitm.Hook()is created. Current code already handles this ordering.isWrapped()compatibility: Our shimmer must expose the sameisWrapped()behavior (and compatible markers) as OTel's shimmer, soensureIsWrapped()and any code that checksisWrapped()still works.init()returnsundefined). They just need a simpler base class that provides a tracer — or they can be refactored to not extend any base class.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.import-in-the-middleversion coupling: IITM is already a direct Sentry dependency. No issue here.require-in-the-middle: Currently a transitive dependency via@opentelemetry/instrumentation. After removing that dep, we'll need to addrequire-in-the-middleas a direct dependency of@sentry/node-core.Order of Execution
module-wrapper/registerModuleWrapper()SentryInstrumentationBaseusingregisterModuleWrapper()ensureIsWrappedto use vendored shimmerSentryInstrumentationBasegenerateInstrumentOnceto support new API@opentelemetry/instrumentationdependency