Tracing Hooks
July 14, 2026 ยท View on GitHub
This repository contains a ESM loader for injecting tracing channel hooks into Node.js modules. It also has a patch for Module to be used to patch CJS modules.
Usage
Note: the module loading hooks API in Node.js has changed as of v26. To support all active Node.js versions with forward-compatibility, create a combined loader as an ESM module.
This can be done for any CommonJS or ES Module application, but the loader itself must use ESM.
// loader.mjs
import Module from 'node:module'
// the synchronous hooks for newer node versions
import { initialize, resolve, load } from '@apm-js-collab/tracing-hooks/hook-sync.mjs'
import ModulePatch from '@apm-js-collab/tracing-hooks'
// the instrumentations we want to apply
const instrumentations = [
{
channelName: 'channel1',
module: { name: 'pkg1', verisonRange: '>=1.0.0', filePath: 'index.js' },
functionQuery: {
className: 'Class1',
methodName: 'method1',
kind: 'Async'
}
},
{
channelName: 'channel2',
module: { name: 'pkg2', verisonRange: '>=1.0.0', filePath: 'index.js' },
functionQuery: {
className: 'Class2,
methodName: 'method2',
kind: 'Sync'
}
}
]
// detection to decide module loader hooks to use
// registerHooks was present but not stable until 24.13 and 25.1
const version = (process.versions.node ?? '0.0.0')
.split('.')
.map(n => parseInt(n, 10))
const stableSyncHooks = version[0] > 25 ||
version[0] === 25 && version[1] >= 1 ||
version[0] === 24 && version[1] >= 13
if (typeof Module.registerHooks === 'function' && stableSyncHooks) {
initialize({ instrumentations })
Module.registerHooks({ resolve, load })
} else if (typeof Module.register === 'function') {
Module.register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, {
data: { instrumentations }
});
// ALSO patch `Module.prototype._compile`. This is REQUIRED, not an
// optimisation: on this path CommonJS is instrumented entirely by
// the patch, never by the async `load` hook.
// Two reasons. When an ESM file `import`s a CJS package, Node
// resolves the package's INTERNAL `require()` calls through the CJS
// machinery, so they never reach the ESM resolve hook. And handing
// `source` back for a CommonJS module from the async `load` hook
// makes Node evaluate it on the synchronous require(esm) bridge,
// which throws ERR_VM_MODULE_LINK_FAILURE on Node < 24.12 whenever
// that module's top-level require() chain reaches an ES module
// (https://github.com/nodejs/node/issues/59666).
// Neither applies in the registerHooks case, because Node applies
// those hooks to all CJS and ESM modules.
new ModulePatch({ instrumentations }).patch();
} else {
throw new Error('No available API to apply module load hooks')
}
To run your application with these instrumentations applied, pass
it to the --import argument:
node --import=loader.mjs ./my-app.js
Debugging
The debug module is used to provide
insight into the patching process. Set DEBUG='@apm-js-collab*' to view these
logs.
Additionally, any patched files can be written out by enabling dump mode. This
is done by setting the environment variable TRACING_DUMP to any value. By
default, it will write out file to the system's temporary directory as the
parent directory. The target parent directory can be configured by setting
the TRACING_DUMP_DIR environment variable to an absolute path. In either
case, the resolved filename of the module being patched is appended. For
example, if we are patching lib/index.js in the foo package, and we set
a base directory of /tmp/dump/, then the patched code will be written to
/tmp/dump/foo/lib/index.js.
Diagnostics Hook
A diagnostics hook can be set which is called every time a module is transformed or transformation fails.
import { setDiagnosticsHook } from '@apm-js-collab/tracing-hooks/hook-sync.mjs'
setDiagnosticsHook(({ url, moduleName, error }) => {
if(error) {
// injection failed
} else {
// injection succeeded
}
})
With the synchronous registerHooks (and the _compile patch) this is all that
is needed. The older async register runs its hooks on a separate thread where
the hook set above is not visible, so pass a MessagePort created with
createDiagnosticsPort() for the loader thread to post its diagnostics back:
import { setDiagnosticsHook, createDiagnosticsPort } from '@apm-js-collab/tracing-hooks/hook-sync.mjs'
setDiagnosticsHook(({ url, moduleName, error }) => { /* ... */ })
const diagnosticsPort = createDiagnosticsPort()
Module.register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, {
data: { instrumentations, diagnosticsPort },
transferList: [diagnosticsPort]
})
On this path, diagnostics for ES modules are posted from the loader thread and
therefore arrive asynchronously, some time after the module was transformed;
diagnostics for CommonJS modules come from the _compile patch and are emitted
synchronously as before. The port does not keep the process alive, so
diagnostics still in flight when the process exits are dropped.