Skip to content

Commit 5885d1e

Browse files
committed
feat(node): Add registerModuleWrapper utility
1 parent cea58ff commit 5885d1e

8 files changed

Lines changed: 935 additions & 58 deletions

File tree

packages/node-core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@
107107
"dependencies": {
108108
"@sentry/core": "10.51.0",
109109
"@sentry/opentelemetry": "10.51.0",
110-
"import-in-the-middle": "^3.0.0"
110+
"import-in-the-middle": "^3.0.0",
111+
"require-in-the-middle": "^7.5.0"
111112
},
112113
"devDependencies": {
113114
"@opentelemetry/api": "^1.9.1",

packages/node-core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export { processSessionIntegration } from './integrations/processSession';
2525

2626
export type { OpenTelemetryServerRuntimeOptions } from './types';
2727

28+
export { registerModuleWrapper } from './module-wrapper';
29+
2830
export {
2931
// This needs exporting so the NodeClient can be used without calling init
3032
setOpenTelemetryContextAsyncContextStrategy as setNodeAsyncContextStrategy,
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* Module wrapper utilities for patching Node.js modules.
3+
*
4+
* This provides a Sentry-owned alternative to OTel's registerInstrumentations(),
5+
* allowing module patching without requiring the full OTel instrumentation infrastructure.
6+
*/
7+
8+
import { Hook } from 'import-in-the-middle';
9+
import { satisfies } from './semver';
10+
import { RequireInTheMiddleSingleton, type OnRequireFn } from './singleton';
11+
import { extractPackageVersion } from './version';
12+
import { DEBUG_BUILD } from '../debug-build';
13+
import { debug } from '@sentry/core';
14+
export type { OnRequireFn };
15+
export { satisfies } from './semver';
16+
export { extractPackageVersion } from './version';
17+
18+
/** Store for module options, keyed by module name */
19+
const MODULE_OPTIONS = new Map<string, unknown>();
20+
21+
/** Options for file-level patching within a module */
22+
export interface ModuleWrapperFileOptions<TOptions = unknown> {
23+
/** Relative path within the package (e.g., 'lib/client.js') */
24+
name: string;
25+
/** Semver ranges for supported versions of the file */
26+
supportedVersions: string[];
27+
/** Function to patch the file's exports. Use getOptions() to access current options at runtime. */
28+
patch: (exports: unknown, getOptions: () => TOptions | undefined, version?: string) => unknown;
29+
}
30+
31+
/** Options for registering a module wrapper */
32+
export interface ModuleWrapperOptions<TOptions = unknown> {
33+
/** Module name to wrap (e.g., 'express', 'pg', '@prisma/client') */
34+
moduleName: string;
35+
/** Semver ranges for supported versions (e.g., ['>=4.0.0 <5.0.0']) */
36+
supportedVersions: string[];
37+
/** Function to patch the module's exports. Use getOptions() to access current options at runtime. */
38+
patch: (moduleExports: unknown, getOptions: () => TOptions | undefined, version?: string) => unknown;
39+
/** Optional array of specific files within the module to patch */
40+
files?: ModuleWrapperFileOptions<TOptions>[];
41+
/** Optional configuration options that can be updated on subsequent calls */
42+
options?: TOptions;
43+
}
44+
45+
/**
46+
* Register a module wrapper to patch a module when it's required/imported.
47+
*
48+
* This sets up hooks for both CommonJS (via require-in-the-middle) and
49+
* ESM (via import-in-the-middle) module loading.
50+
*
51+
* Calling this multiple times for the same module is safe:
52+
* - The wrapping/hooking only happens once (first call)
53+
* - Options are always updated (subsequent calls replace options)
54+
* - Use `getOptions()` in your patch function to access current options at runtime
55+
*
56+
* @param wrapperOptions - Configuration for the module wrapper
57+
*
58+
* @example
59+
* ```ts
60+
* registerModuleWrapper({
61+
* moduleName: 'express',
62+
* supportedVersions: ['>=4.0.0 <6.0.0'],
63+
* options: { customOption: true },
64+
* patch: (moduleExports, getOptions, version) => {
65+
* // getOptions() returns the current options at runtime
66+
* patchExpressModule(moduleExports, getOptions);
67+
* return moduleExports;
68+
* },
69+
* });
70+
* ```
71+
*/
72+
export function registerModuleWrapper<TOptions = unknown>(wrapperOptions: ModuleWrapperOptions<TOptions>): void {
73+
const { moduleName, supportedVersions, patch, files, options } = wrapperOptions;
74+
75+
// Always update the stored options (even if already registered)
76+
MODULE_OPTIONS.set(moduleName, options);
77+
78+
// If already registered, skip the wrapping - options have been updated above
79+
if (MODULE_OPTIONS.has(moduleName) && options === undefined) {
80+
// This means we've registered before but this call has no new options
81+
// Still skip re-registration
82+
return;
83+
}
84+
85+
// Create a getter that retrieves current options at runtime
86+
const getOptions = () => MODULE_OPTIONS.get(moduleName) as TOptions;
87+
88+
// Create the onRequire handler for CJS
89+
const onRequire: OnRequireFn = (exports, name, basedir) => {
90+
// Check if this is the main module or a file within it
91+
const isMainModule = name === moduleName;
92+
93+
if (isMainModule) {
94+
// Main module - check version and patch
95+
const version = extractPackageVersion(basedir);
96+
if (isVersionSupported(version, supportedVersions)) {
97+
DEBUG_BUILD &&
98+
debug.log(
99+
'[ModuleWrapper]',
100+
`registering module wrapper for ${moduleName} with version ${version}`,
101+
`supportedVersions: ${supportedVersions}`,
102+
`file hooks: ${files?.map(f => f.name).join(', ')}`,
103+
);
104+
105+
return patch(exports, getOptions, version);
106+
}
107+
} else if (files) {
108+
// Check if this is one of the specified files
109+
for (const file of files) {
110+
const expectedPath = `${moduleName}/${file.name}`;
111+
if (name === expectedPath || name.endsWith(`/${expectedPath}`)) {
112+
const version = extractPackageVersion(basedir);
113+
if (isVersionSupported(version, file.supportedVersions)) {
114+
return file.patch(exports, getOptions, version);
115+
}
116+
}
117+
}
118+
}
119+
120+
return exports;
121+
};
122+
123+
// Register with CJS singleton (require-in-the-middle)
124+
const ritmSingleton = RequireInTheMiddleSingleton.getInstance();
125+
ritmSingleton.register(moduleName, onRequire);
126+
127+
// Register file hooks with the singleton as well
128+
if (files) {
129+
for (const file of files) {
130+
const filePath = `${moduleName}/${file.name}`;
131+
ritmSingleton.register(filePath, onRequire);
132+
}
133+
}
134+
135+
// Register with ESM (import-in-the-middle)
136+
// The ESM loader must be initialized before this (via initializeEsmLoader())
137+
const moduleNames = [moduleName];
138+
if (files) {
139+
for (const file of files) {
140+
moduleNames.push(`${moduleName}/${file.name}`);
141+
}
142+
}
143+
144+
new Hook(moduleNames, { internals: true }, (exports, name, basedir) => {
145+
// Convert void to undefined for compatibility
146+
const baseDirectory = basedir || undefined;
147+
const isMainModule = name === moduleName;
148+
149+
if (isMainModule) {
150+
const version = extractPackageVersion(baseDirectory);
151+
if (isVersionSupported(version, supportedVersions)) {
152+
DEBUG_BUILD &&
153+
debug.log(
154+
'[ModuleWrapper]',
155+
`registering ESM module wrapper for ${moduleName} with version ${version}`,
156+
`supportedVersions: ${supportedVersions}`,
157+
`file hooks: ${files?.map(f => f.name).join(', ')}`,
158+
);
159+
160+
return patch(exports, getOptions, version);
161+
}
162+
} else if (files) {
163+
for (const file of files) {
164+
const expectedPath = `${moduleName}/${file.name}`;
165+
if (name === expectedPath || name.endsWith(`/${expectedPath}`)) {
166+
const version = extractPackageVersion(baseDirectory);
167+
if (isVersionSupported(version, file.supportedVersions)) {
168+
return file.patch(exports, getOptions, version);
169+
}
170+
}
171+
}
172+
}
173+
174+
return exports;
175+
});
176+
}
177+
178+
/**
179+
* Check if a version is supported by the given semver ranges.
180+
*
181+
* @param version - The version to check (or undefined if not available)
182+
* @param supportedVersions - Array of semver range strings
183+
* @returns true if the version is supported
184+
*/
185+
function isVersionSupported(version: string | undefined, supportedVersions: string[]): boolean {
186+
// If no version is available (e.g., core modules), we allow patching
187+
if (!version) {
188+
return true;
189+
}
190+
191+
// Check if the version satisfies any of the supported ranges
192+
for (const range of supportedVersions) {
193+
if (satisfies(version, range)) {
194+
return true;
195+
}
196+
}
197+
198+
return false;
199+
}

0 commit comments

Comments
 (0)