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:
new InstrumentationNodeModuleDefinition(
'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 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
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()
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:
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 comparable
singleton.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)
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
- 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