Skip to content

Commit 617d49a

Browse files
feat: auto-register ESM loader hooks in TuskDrift.initialize() (#146)
1 parent 1b739c1 commit 617d49a

18 files changed

Lines changed: 189 additions & 171 deletions

File tree

CONTRIBUTING.md

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,22 +65,40 @@ The SDK supports both CommonJS and ESM module systems, using different intercept
6565

6666
#### CommonJS Module Interception
6767

68-
- **Package**: `require-in-the-middle`
69-
- **How it works**: Hooks into `Module.prototype.require` globally
70-
- **When it activates**: When `require()` is called
71-
- **Setup**: Automatic - no special flags needed
72-
- **Use case**: Works for all CommonJS modules
68+
- **Package**: `require-in-the-middle` (RITM)
69+
- **How it works**: CJS modules are loaded through a single JavaScript function (`Module._load`). RITM monkey-patches this function so that every `require()` call passes through the patch, giving the SDK a chance to intercept and wrap module exports.
70+
- **Setup**: Automatic -- no special flags or loader registration needed. Just calling `TuskDrift.initialize()` before other `require()` calls is sufficient.
7371

7472
#### ESM Module Interception
7573

76-
- **Package**: `import-in-the-middle`, created by [Datadog](https://opensource.datadoghq.com/projects/node/#the-import-in-the-middle-library)
77-
- **How it works**: Uses Node.js loader hooks to intercept imports before they're cached
78-
- **When it activates**: During module resolution/loading phase
79-
- **Setup**: Requires `--import` flag or `module.register()` call
80-
- **Use case**: Required for ESM modules
81-
- **Loader file**: `hook.mjs` - re-exports loader hooks from `import-in-the-middle`
74+
- **Package**: `import-in-the-middle` (IITM), created by [Datadog](https://opensource.datadoghq.com/projects/node/#the-import-in-the-middle-library)
75+
- **How it works**: Unlike CJS, ESM module loading is handled by Node.js internals (C++), not a patchable JavaScript function. The only way to intercept ESM imports is through Node's official [customization hooks API](https://nodejs.org/api/module.html#customization-hooks) (`module.register`), which runs hook code in a separate loader thread.
76+
- **Setup**: The SDK automatically registers ESM loader hooks inside `TuskDrift.initialize()` via `module.register()` (see `src/core/esmLoader.ts`). ESM applications must still use `--import` to ensure the init file runs before the application's import graph is resolved. The `hook.mjs` file at the package root is kept for backward compatibility but is no longer required for manual registration.
8277

83-
**Key difference**: CommonJS's `require()` is synchronous and sequential, so you can control order. ESM's `import` is hoisted and parallel, requiring loader hooks to intercept before evaluation.
78+
#### How ESM instrumentation works end-to-end
79+
80+
1. **Loader registration**: `initializeEsmLoader()` (called from `TuskDrift.initialize()`) uses `createAddHookMessageChannel()` from IITM to set up a `MessagePort` between the main thread and the loader thread, then calls `module.register('import-in-the-middle/hook.mjs', ...)` to install the loader hooks.
81+
2. **Module wrapping**: When any ESM module is imported, IITM's `load` hook transforms its source code on the fly, replacing all named exports with getter/setter proxies. The module works normally, but exports now pass through a proxy layer.
82+
3. **Hook registration**: `TdInstrumentationBase.enable()` creates `new HookImport(['pg'], {}, hookFn)` for each instrumented module. This registers a callback and sends the module name to the loader thread via the `MessagePort` so the loader knows to watch for it.
83+
4. **Interception at runtime**: When application code accesses a wrapped module's exports (e.g., `import { Client } from 'pg'`), the getter proxy fires, the `hookFn` callback runs, and the SDK patches the export with its instrumented version.
84+
85+
For CJS, steps 1-2 are unnecessary -- RITM patches `Module._load` directly in the main thread, and the rest works the same way.
86+
87+
#### Why `--import` is still needed for ESM
88+
89+
In CJS, `require()` is synchronous and imperative -- putting `require('./tuskDriftInit')` first guarantees it runs before other modules. In ESM, all `import` declarations are hoisted and the entire module graph is resolved before any module-level code executes. The `--import` flag runs the init file in a pre-evaluation phase, ensuring `TuskDrift.initialize()` (and the loader registration) happens before the application's imports are resolved.
90+
91+
#### Node.js built-in modules are always CJS
92+
93+
Node.js built-in modules (`http`, `https`, `net`, `fs`, etc.) are loaded through the CJS `require()` path internally, even when imported via ESM `import` syntax. This means RITM can intercept them regardless of the application's module system, and the ESM loader hooks are not required for built-in module instrumentation.
94+
95+
#### The `registerEsmLoaderHooks` opt-out
96+
97+
Because we pass `include: []` during `module.register()`, IITM starts with an empty allowlist and only wraps modules that are explicitly registered via `new Hook([...])` on the main thread (sent to the loader thread over the `MessagePort`). This means only modules the SDK actually instruments get their exports wrapped with getter/setter proxies -- unrelated modules are left untouched. In rare cases, the wrapping can still conflict with non-standard export patterns, native/WASM bindings, or bundler-generated ESM in the instrumented modules themselves. Users can disable this with `registerEsmLoaderHooks: false` in `TuskDrift.initialize()`, which means only CJS-loaded modules will be instrumentable. See `docs/initialization.md` for the user-facing documentation.
98+
99+
#### Compatibility with other IITM consumers (Sentry, OpenTelemetry)
100+
101+
Multiple SDKs can each call `module.register()` with their own IITM loader instance and `MessagePort`. IITM detects the duplicate initialization (`global.__import_in_the_middle_initialized__`) and logs a warning, but both SDKs' hooks will fire correctly. Patches layer on top of each other -- if Sentry wraps `pg.Client.query` and Drift also wraps it, the final export passes through both wrappers.
84102

85103
### When Does an Instrumentation Need Special ESM Handling?
86104

docs/initialization.md

Lines changed: 56 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,7 @@ Create a separate file (e.g. `tuskDriftInit.ts` or `tuskDriftInit.js`) to initia
2020

2121
**IMPORTANT**: Ensure that `TuskDrift` is initialized before any other telemetry providers (e.g. OpenTelemetry, Sentry, etc.). If not, your existing telemetry may not work properly.
2222

23-
### Determining Your Module System
24-
25-
Before proceeding, you need to determine whether your application uses **CommonJS** or **ESM** (ECMAScript Modules).
26-
27-
The easiest way to determine this is by looking at your import syntax.
28-
29-
**If your application uses `require()`:**
30-
31-
- Your application is CommonJS (use the CommonJS setup below)
32-
33-
**If your application uses `import` statements:**
34-
35-
- This could be either CommonJS or ESM, depending on your build configuration
36-
- Check your compiled output (if you compile to a directory like `dist/`):
37-
- If the compiled code contains `require()` statements → CommonJS application
38-
- If the compiled code contains `import` statements → ESM application
39-
- If you don't compile your code (running source files directly):
40-
- It is an ESM application
41-
42-
### For CommonJS Applications
23+
The initialization file is the same for both CommonJS and ESM applications. The SDK automatically registers ESM loader hooks when running in an ESM environment (Node.js >= 18.19.0 or >= 20.6.0).
4324

4425
```typescript
4526
// tuskDriftInit.ts or tuskDriftInit.js
@@ -54,31 +35,7 @@ TuskDrift.initialize({
5435
export { TuskDrift };
5536
```
5637

57-
### For ESM Applications
58-
59-
ESM applications require additional setup to properly intercept module imports:
60-
61-
```typescript
62-
// tuskDriftInit.ts
63-
import { register } from "node:module";
64-
import { pathToFileURL } from "node:url";
65-
66-
// Register the ESM loader
67-
// This enables interception of ESM module imports
68-
register("@use-tusk/drift-node-sdk/hook.mjs", pathToFileURL("./"));
69-
70-
import { TuskDrift } from "@use-tusk/drift-node-sdk";
71-
72-
// Initialize SDK immediately
73-
TuskDrift.initialize({
74-
apiKey: process.env.TUSK_API_KEY,
75-
env: process.env.NODE_ENV,
76-
});
77-
78-
export { TuskDrift };
79-
```
80-
81-
**Why the ESM loader is needed**: ESM imports are statically analyzed and hoisted, meaning all imports are resolved before any code runs. The `register()` call sets up Node.js loader hooks that intercept module imports, allowing the SDK to instrument packages like `postgres`, `http`, etc. Without this, the SDK cannot patch ESM modules.
38+
> **Note:** ESM applications still require the `--import` flag when starting Node.js. See [Step 2](#2-import-sdk-at-application-entry-point) for details.
8239
8340
### Initialization Parameters
8441

@@ -116,13 +73,36 @@ export { TuskDrift };
11673
<td><code>1.0</code></td>
11774
<td>Override sampling rate (0.0 - 1.0) for recording. Takes precedence over <code>TUSK_SAMPLING_RATE</code> env var and config file.</td>
11875
</tr>
76+
<tr>
77+
<td><code>registerEsmLoaderHooks</code></td>
78+
<td><code>boolean</code></td>
79+
<td><code>true</code></td>
80+
<td>Automatically register ESM loader hooks for module interception. Set to <code>false</code> to disable if <code>import-in-the-middle</code> causes issues with certain packages. See <a href="#troubleshooting-esm">Troubleshooting ESM</a>.</td>
81+
</tr>
11982
</tbody>
12083
</table>
12184

12285
> **See also:** [Environment Variables guide](./environment-variables.md) for detailed information about environment variables.
12386
12487
## 2. Import SDK at Application Entry Point
12588

89+
### Determining Your Module System
90+
91+
You need to know whether your application uses **CommonJS** or **ESM** (ECMAScript Modules) because the entry point setup differs.
92+
93+
**If your application uses `require()`:**
94+
95+
- Your application is CommonJS
96+
97+
**If your application uses `import` statements:**
98+
99+
- This could be either CommonJS or ESM, depending on your build configuration
100+
- Check your compiled output (if you compile to a directory like `dist/`):
101+
- If the compiled code contains `require()` statements → CommonJS application
102+
- If the compiled code contains `import` statements → ESM application
103+
- If you don't compile your code (running source files directly):
104+
- It is an ESM application
105+
126106
### For CommonJS Applications
127107

128108
In your main server file (e.g., `server.ts`, `index.ts`, `app.ts`), require the initialized SDK **at the very top**, before any other requires:
@@ -153,7 +133,7 @@ For ESM applications, you **cannot** control import order within your applicatio
153133
}
154134
```
155135

156-
**Why `--import` is required for ESM**: In ESM, all `import` statements are hoisted and evaluated before any code runs, making it impossible to control import order within a file. The `--import` flag ensures the SDK initialization (including loader registration) happens in a separate phase before your application code loads, guaranteeing proper module interception.
136+
**Why `--import` is required for ESM**: In ESM, all `import` statements are hoisted and evaluated before any code runs, making it impossible to control import order within a file. The `--import` flag ensures the SDK initialization happens in a separate phase before your application code loads, guaranteeing proper module interception.
157137

158138
### 3. Configure Sampling Rate
159139

@@ -255,3 +235,33 @@ app.listen(8000, () => {
255235
console.log("Server started and ready for Tusk Drift");
256236
});
257237
```
238+
239+
## Troubleshooting ESM
240+
241+
The SDK automatically registers ESM loader hooks via [`import-in-the-middle`](https://www.npmjs.com/package/import-in-the-middle) to intercept ES module imports. Only modules that the SDK instruments have their exports wrapped with getter/setter proxies -- unrelated modules are left untouched.
242+
243+
In rare cases, the wrapping can cause issues with instrumented packages:
244+
245+
- **Non-standard export patterns**: Some packages use dynamic `export *` re-exports or conditional exports that the wrapper's static analysis cannot parse, resulting in runtime syntax errors.
246+
- **Native or WASM bindings**: Packages with native addons loaded via ESM can conflict with the proxy wrapping mechanism.
247+
- **Bundler-generated ESM**: Code that was bundled (e.g., by esbuild or webpack) into ESM sometimes produces patterns the wrapper does not handle correctly.
248+
- **Circular ESM imports**: The proxy layer can interact badly with circular ESM import graphs in some edge cases.
249+
250+
If you encounter errors like:
251+
252+
```
253+
SyntaxError: The requested module '...' does not provide an export named '...'
254+
(node:1234) Error: 'import-in-the-middle' failed to wrap 'file://../../path/to/file.js'
255+
```
256+
257+
You can disable the automatic ESM hook registration:
258+
259+
```typescript
260+
TuskDrift.initialize({
261+
apiKey: process.env.TUSK_API_KEY,
262+
env: process.env.NODE_ENV,
263+
registerEsmLoaderHooks: false,
264+
});
265+
```
266+
267+
> **Note:** Disabling ESM loader hooks means the SDK will only be able to instrument packages loaded via CommonJS (`require()`). Packages loaded purely through ESM `import` statements will not be intercepted. Node.js built-in modules (like `http`, `https`, `net`) are always loaded through the CJS path internally, so they will continue to be instrumented regardless of this setting.

src/core/TuskDrift.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,22 @@ import {
4040
loadTuskConfig,
4141
TuskConfig,
4242
OriginalGlobalUtils,
43+
isCommonJS,
4344
} from "./utils";
4445
import { TransformConfigs } from "../instrumentation/libraries/types";
4546
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
4647
import { Resource } from "@opentelemetry/resources";
4748
import { getRustCoreStartupStatus } from "./rustCoreBinding";
49+
import { initializeEsmLoader } from "./esmLoader";
4850

4951
export interface InitParams {
5052
apiKey?: string;
5153
env?: string;
5254
logLevel?: LogLevel;
5355
transforms?: TransformConfigs;
5456
samplingRate?: number;
57+
/** Set to `false` to disable automatic ESM loader hook registration. Defaults to `true`. */
58+
registerEsmLoaderHooks?: boolean;
5559
}
5660

5761
export enum TuskDriftMode {
@@ -83,15 +87,6 @@ export class TuskDriftCore {
8387
this.config = loadTuskConfig() || {};
8488
}
8589

86-
private isCommonJS(): boolean {
87-
return (
88-
typeof module !== "undefined" &&
89-
"exports" in module &&
90-
typeof require !== "undefined" &&
91-
typeof require.cache !== "undefined"
92-
);
93-
}
94-
9590
private getPackageName(modulePath: string): string | null {
9691
let dir = path.dirname(modulePath);
9792
while (dir) {
@@ -117,7 +112,7 @@ export class TuskDriftCore {
117112
private alreadyRequiredModules(): Set<string> {
118113
const alreadyRequiredModuleNames = new Set<string>();
119114

120-
if (this.isCommonJS()) {
115+
if (isCommonJS()) {
121116
const requireCache = Object.keys(require.cache);
122117
for (const modulePath of requireCache) {
123118
if (modulePath.includes("node_modules")) {
@@ -478,6 +473,10 @@ export class TuskDriftCore {
478473
return;
479474
}
480475

476+
if (initParams.registerEsmLoaderHooks !== false) {
477+
initializeEsmLoader();
478+
}
479+
481480
this.logRustCoreStartupStatus();
482481
logger.debug(`Initializing in ${this.mode} mode`);
483482

src/core/esmLoader.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { createAddHookMessageChannel } from "import-in-the-middle";
2+
import * as moduleModule from "module";
3+
import { logger } from "./utils";
4+
import { isCommonJS } from "./utils/runtimeDetectionUtils";
5+
6+
const NODE_MAJOR = parseInt(process.versions.node.split(".")[0]!, 10);
7+
const NODE_MINOR = parseInt(process.versions.node.split(".")[1]!, 10);
8+
9+
function supportsModuleRegister(): boolean {
10+
return (
11+
NODE_MAJOR >= 21 ||
12+
(NODE_MAJOR === 20 && NODE_MINOR >= 6) ||
13+
(NODE_MAJOR === 18 && NODE_MINOR >= 19)
14+
);
15+
}
16+
17+
/**
18+
* Automatically register ESM loader hooks via `import-in-the-middle` so that
19+
* ESM imports can be intercepted for instrumentation.
20+
*
21+
* In CJS mode this is a no-op because `require-in-the-middle` handles
22+
* interception. On Node versions that lack `module.register` support
23+
* (< 18.19, < 20.6) we log a warning and skip.
24+
*
25+
* https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options
26+
*/
27+
export function initializeEsmLoader(): void {
28+
if (isCommonJS()) {
29+
return;
30+
}
31+
32+
if (!supportsModuleRegister()) {
33+
logger.warn(
34+
`Node.js ${process.versions.node} does not support module.register(). ` +
35+
`ESM loader hooks will not be registered automatically. ` +
36+
`Upgrade to Node.js >= 18.19.0 or >= 20.6.0, or register the hooks manually.`,
37+
);
38+
return;
39+
}
40+
41+
if ((globalThis as any).__tuskDriftEsmLoaderRegistered) {
42+
return;
43+
}
44+
(globalThis as any).__tuskDriftEsmLoaderRegistered = true;
45+
46+
try {
47+
// createAddHookMessageChannel sets up a MessagePort so the main thread can
48+
// send new hook registrations (from `new Hook(...)` calls in userland) to
49+
// the loader thread, which runs in a separate context.
50+
const { addHookMessagePort } = createAddHookMessageChannel();
51+
52+
// The IITM loader hook module that intercepts ESM imports.
53+
// Resolved relative to this SDK package (import.meta.url) so the hook
54+
// module is found from node_modules regardless of the user's cwd.
55+
// @ts-expect-error register exists on module in supported Node versions
56+
moduleModule.register("import-in-the-middle/hook.mjs", import.meta.url, {
57+
// Payload sent to the loader hook's initialize() function:
58+
// - addHookMessagePort: the MessagePort for main↔loader communication
59+
// - include: [] starts with an empty allowlist; only modules registered
60+
// via new Hook([...]) on the main thread get added dynamically through
61+
// the MessagePort, so only instrumented modules are wrapped.
62+
data: { addHookMessagePort, include: [] },
63+
// Transfer (not clone) the port — a MessagePort can only be owned by one thread
64+
transferList: [addHookMessagePort],
65+
});
66+
logger.debug("ESM loader hooks registered successfully");
67+
} catch (error) {
68+
logger.warn("Failed to register ESM loader hooks:", error);
69+
}
70+
}

src/core/utils/runtimeDetectionUtils.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
export function isCommonJS(): boolean {
2+
try {
3+
return (
4+
typeof module !== "undefined" &&
5+
"exports" in module &&
6+
typeof require !== "undefined" &&
7+
typeof require.cache !== "undefined"
8+
);
9+
} catch {
10+
return false;
11+
}
12+
}
13+
114
export function isNextJsRuntime(): boolean {
215
return (
316
process.env.NEXT_RUNTIME !== undefined || typeof (global as any).__NEXT_DATA__ !== "undefined"

src/instrumentation/libraries/fetch/e2e-tests/esm-fetch/src/tdInit.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import { register } from 'node:module';
2-
import { pathToFileURL } from 'node:url';
3-
4-
// Register the ESM loader
5-
// This enables interception of ESM module imports
6-
register('@use-tusk/drift-node-sdk/hook.mjs', pathToFileURL('./'));
7-
8-
import { TuskDrift } from '@use-tusk/drift-node-sdk';
1+
import { TuskDrift } from "@use-tusk/drift-node-sdk";
92

103
TuskDrift.initialize({
114
apiKey: "api-key",

src/instrumentation/libraries/firestore/e2e-tests/esm-firestore/src/tdInit.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import { register } from 'node:module';
2-
import { pathToFileURL } from 'node:url';
3-
4-
// Register the ESM loader
5-
// This enables interception of ESM module imports
6-
register('@use-tusk/drift-node-sdk/hook.mjs', pathToFileURL('./'));
7-
8-
import { TuskDrift } from '@use-tusk/drift-node-sdk';
1+
import { TuskDrift } from "@use-tusk/drift-node-sdk";
92

103
TuskDrift.initialize({
114
apiKey: "api-key",

src/instrumentation/libraries/grpc/e2e-tests/esm-grpc/src/tdInit.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import { register } from 'node:module';
2-
import { pathToFileURL } from 'node:url';
3-
4-
// Register the ESM loader
5-
// This enables interception of ESM module imports
6-
register('@use-tusk/drift-node-sdk/hook.mjs', pathToFileURL('./'));
7-
8-
import { TuskDrift } from '@use-tusk/drift-node-sdk';
1+
import { TuskDrift } from "@use-tusk/drift-node-sdk";
92

103
TuskDrift.initialize({
114
apiKey: "api-key",

src/instrumentation/libraries/http/e2e-tests/esm-http/src/tdInit.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import { register } from 'node:module';
2-
import { pathToFileURL } from 'node:url';
3-
4-
// Register the ESM loader
5-
// This enables interception of ESM module imports
6-
register('@use-tusk/drift-node-sdk/hook.mjs', pathToFileURL('./'));
7-
8-
import { TuskDrift } from '@use-tusk/drift-node-sdk';
1+
import { TuskDrift } from "@use-tusk/drift-node-sdk";
92

103
TuskDrift.initialize({
114
apiKey: "api-key",

0 commit comments

Comments
 (0)