Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules
coverage
test/trace
1 change: 1 addition & 0 deletions .snapshots/6a43d2825780d3f626066653ae42ea9b/0.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"class Foo {\n constructor() {\n this.name = 'foo'\n }\n\n doStuff() {\n return 'doing stuff'\n }\n}\n\nexport default Foo\n\n"
1 change: 1 addition & 0 deletions .snapshots/92c32a3caef2a8ae0f34a33c4d0c9c09/0.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"const tr_ch_apm_dc = require(\"diagnostics_channel\");\nconst {tracingChannel: tr_ch_apm_tracingChannel} = tr_ch_apm_dc;\nconst tr_ch_apm$unitTestCjs = tr_ch_apm_tracingChannel(\"orchestrion:pkg-1:unitTestCjs\");\nconst tr_ch_apm_hasSubscribers = ch => ch.start.hasSubscribers || ch.end.hasSubscribers || ch.asyncStart.hasSubscribers || ch.asyncEnd.hasSubscribers || ch.error.hasSubscribers;\nclass Foo {\n constructor() {\n this.name = 'foo';\n }\n doStuff(...__apm$args) {\n const __apm$arguments = [...__apm$args].slice(0, arguments.length);\n const __apm$ctx = {\n arguments: __apm$arguments,\n self: this,\n moduleVersion: \"1.0.0\"\n };\n const __apm$traced = () => {\n const __apm$wrapped = function () {\n return 'doing stuff';\n };\n return __apm$wrapped.apply(this, __apm$arguments);\n };\n if (!tr_ch_apm_hasSubscribers(tr_ch_apm$unitTestCjs)) return __apm$traced();\n return tr_ch_apm$unitTestCjs.start.runStores(__apm$ctx, () => {\n try {\n let promise = __apm$traced();\n if (typeof promise?.then !== \"function\") {\n __apm$ctx.result = promise;\n return promise;\n }\n if (promise instanceof Promise && promise.constructor === Promise) {\n return promise.then(result => {\n __apm$ctx.result = result;\n tr_ch_apm$unitTestCjs.asyncStart.publish(__apm$ctx);\n tr_ch_apm$unitTestCjs.asyncEnd.publish(__apm$ctx);\n return result;\n }, err => {\n __apm$ctx.error = err;\n tr_ch_apm$unitTestCjs.error.publish(__apm$ctx);\n tr_ch_apm$unitTestCjs.asyncStart.publish(__apm$ctx);\n tr_ch_apm$unitTestCjs.asyncEnd.publish(__apm$ctx);\n throw err;\n });\n }\n promise.then(result => {\n __apm$ctx.result = result;\n tr_ch_apm$unitTestCjs.asyncStart.publish(__apm$ctx);\n tr_ch_apm$unitTestCjs.asyncEnd.publish(__apm$ctx);\n }, err => {\n __apm$ctx.error = err;\n tr_ch_apm$unitTestCjs.error.publish(__apm$ctx);\n tr_ch_apm$unitTestCjs.asyncStart.publish(__apm$ctx);\n tr_ch_apm$unitTestCjs.asyncEnd.publish(__apm$ctx);\n });\n return promise;\n } catch (err) {\n __apm$ctx.error = err;\n tr_ch_apm$unitTestCjs.error.publish(__apm$ctx);\n throw err;\n } finally {\n __apm$ctx.self ??= this;\n tr_ch_apm$unitTestCjs.end.publish(__apm$ctx);\n }\n });\n }\n}\nmodule.exports = Foo;\n"
1 change: 1 addition & 0 deletions .snapshots/bf201be9f3ad62844fbf7840451d8fb9/0.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"class Foo {\n constructor() {\n this.name = 'foo'\n }\n\n doStuff() {\n return 'doing stuff'\n }\n}\n\nexport default Foo\n\n"
1 change: 1 addition & 0 deletions .snapshots/bfdef7279baf7efbbdbab219c5724025/0.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"class Test {\n constructor() {\n this.name = 'Test'\n }\n\n getName() {\n return this.name\n }\n}\n\nexport default Test\n\n"
1 change: 1 addition & 0 deletions .snapshots/e913999be3e178237d5749cd95c1f7c2/0.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"class Test {\n constructor() {\n this.name = 'Test'\n }\n\n getName() {\n return this.name\n }\n}\n\nmodule.exports = Test\n"
1 change: 1 addition & 0 deletions .snapshots/f18d870d94ba54b2020bac0e9cbaafdf/0.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"const tr_ch_apm_dc = require(\"diagnostics_channel\");\nconst {tracingChannel: tr_ch_apm_tracingChannel} = tr_ch_apm_dc;\nconst tr_ch_apm$unitTestCjs = tr_ch_apm_tracingChannel(\"orchestrion:pkg-1:unitTestCjs\");\nconst tr_ch_apm_hasSubscribers = ch => ch.start.hasSubscribers || ch.end.hasSubscribers || ch.asyncStart.hasSubscribers || ch.asyncEnd.hasSubscribers || ch.error.hasSubscribers;\nclass Foo {\n constructor() {\n this.name = 'foo';\n }\n doStuff(...__apm$args) {\n const __apm$arguments = [...__apm$args].slice(0, arguments.length);\n const __apm$ctx = {\n arguments: __apm$arguments,\n self: this,\n moduleVersion: \"1.0.0\"\n };\n const __apm$traced = () => {\n const __apm$wrapped = function () {\n return 'doing stuff';\n };\n return __apm$wrapped.apply(this, __apm$arguments);\n };\n if (!tr_ch_apm_hasSubscribers(tr_ch_apm$unitTestCjs)) return __apm$traced();\n return tr_ch_apm$unitTestCjs.start.runStores(__apm$ctx, () => {\n try {\n let promise = __apm$traced();\n if (typeof promise?.then !== \"function\") {\n __apm$ctx.result = promise;\n return promise;\n }\n if (promise instanceof Promise && promise.constructor === Promise) {\n return promise.then(result => {\n __apm$ctx.result = result;\n tr_ch_apm$unitTestCjs.asyncStart.publish(__apm$ctx);\n tr_ch_apm$unitTestCjs.asyncEnd.publish(__apm$ctx);\n return result;\n }, err => {\n __apm$ctx.error = err;\n tr_ch_apm$unitTestCjs.error.publish(__apm$ctx);\n tr_ch_apm$unitTestCjs.asyncStart.publish(__apm$ctx);\n tr_ch_apm$unitTestCjs.asyncEnd.publish(__apm$ctx);\n throw err;\n });\n }\n promise.then(result => {\n __apm$ctx.result = result;\n tr_ch_apm$unitTestCjs.asyncStart.publish(__apm$ctx);\n tr_ch_apm$unitTestCjs.asyncEnd.publish(__apm$ctx);\n }, err => {\n __apm$ctx.error = err;\n tr_ch_apm$unitTestCjs.error.publish(__apm$ctx);\n tr_ch_apm$unitTestCjs.asyncStart.publish(__apm$ctx);\n tr_ch_apm$unitTestCjs.asyncEnd.publish(__apm$ctx);\n });\n return promise;\n } catch (err) {\n __apm$ctx.error = err;\n tr_ch_apm$unitTestCjs.error.publish(__apm$ctx);\n throw err;\n } finally {\n __apm$ctx.self ??= this;\n tr_ch_apm$unitTestCjs.end.publish(__apm$ctx);\n }\n });\n }\n}\nmodule.exports = Foo;\n"
1 change: 1 addition & 0 deletions .snapshots/f6db2a11e195aabf3d4758b820f07147/0.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"const tr_ch_apm_dc = require(\"diagnostics_channel\");\nconst {tracingChannel: tr_ch_apm_tracingChannel} = tr_ch_apm_dc;\nconst tr_ch_apm$unitTestEsm = tr_ch_apm_tracingChannel(\"orchestrion:esm-pkg:unitTestEsm\");\nconst tr_ch_apm_hasSubscribers = ch => ch.start.hasSubscribers || ch.end.hasSubscribers || ch.asyncStart.hasSubscribers || ch.asyncEnd.hasSubscribers || ch.error.hasSubscribers;\nclass Foo {\n constructor() {\n this.name = 'foo';\n }\n doStuff(...__apm$args) {\n const __apm$arguments = [...__apm$args].slice(0, arguments.length);\n const __apm$ctx = {\n arguments: __apm$arguments,\n self: this,\n moduleVersion: \"1.0.0\"\n };\n const __apm$traced = () => {\n const __apm$wrapped = function () {\n return 'doing stuff';\n };\n return __apm$wrapped.apply(this, __apm$arguments);\n };\n if (!tr_ch_apm_hasSubscribers(tr_ch_apm$unitTestEsm)) return __apm$traced();\n return tr_ch_apm$unitTestEsm.start.runStores(__apm$ctx, () => {\n try {\n let promise = __apm$traced();\n if (typeof promise?.then !== \"function\") {\n __apm$ctx.result = promise;\n return promise;\n }\n if (promise instanceof Promise && promise.constructor === Promise) {\n return promise.then(result => {\n __apm$ctx.result = result;\n tr_ch_apm$unitTestEsm.asyncStart.publish(__apm$ctx);\n tr_ch_apm$unitTestEsm.asyncEnd.publish(__apm$ctx);\n return result;\n }, err => {\n __apm$ctx.error = err;\n tr_ch_apm$unitTestEsm.error.publish(__apm$ctx);\n tr_ch_apm$unitTestEsm.asyncStart.publish(__apm$ctx);\n tr_ch_apm$unitTestEsm.asyncEnd.publish(__apm$ctx);\n throw err;\n });\n }\n promise.then(result => {\n __apm$ctx.result = result;\n tr_ch_apm$unitTestEsm.asyncStart.publish(__apm$ctx);\n tr_ch_apm$unitTestEsm.asyncEnd.publish(__apm$ctx);\n }, err => {\n __apm$ctx.error = err;\n tr_ch_apm$unitTestEsm.error.publish(__apm$ctx);\n tr_ch_apm$unitTestEsm.asyncStart.publish(__apm$ctx);\n tr_ch_apm$unitTestEsm.asyncEnd.publish(__apm$ctx);\n });\n return promise;\n } catch (err) {\n __apm$ctx.error = err;\n tr_ch_apm$unitTestEsm.error.publish(__apm$ctx);\n throw err;\n } finally {\n __apm$ctx.self ??= this;\n tr_ch_apm$unitTestEsm.end.publish(__apm$ctx);\n }\n });\n }\n}\nexport default Foo;\n"
93 changes: 48 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
# Tracing Hooks

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

To load esm loader:
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.

```js
// esm-loader.mjs
import { register } from 'node:module';
// 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',
methodName: 'method1',
kind: 'Async'
}
},
Expand All @@ -23,58 +35,49 @@ const instrumentations = [
module: { name: 'pkg2', verisonRange: '>=1.0.0', filePath: 'index.js' },
functionQuery: {
className: 'Class2,
methodName: 'method2',
methodName: 'method2',
kind: 'Sync'
}
}
]

register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, {
data: { instrumentations }
});
```
// 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

To use the loader, you can run your Node.js application with the `--import` flag:
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 }
});

```bash
node --import esm-loader.mjs your-app.js
// ALSO patch `Module.prototype._compile` for the CJS side: when
// an ESM file `import`s a CJS package, Node loads the package's
// entry through the ESM bridge but resolves the package's
// INTERNAL `require()` calls through the CJS machinery.
// Those internal requires never reach the ESM resolve hook, so
// without this patch the file we actually want to instrument is
// loaded untransformed.
// This isn't necessary 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 load CJS patch:
To run your application with these instrumentations applied, pass
it to the `--import` argument:

```js
// cjs-patch.js
const ModulePatch = require('@apm-js-collab/tracing-hooks')
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'
}
}
]


const modulePatch = new ModulePatch({ instrumentations });
modulePatch.patch()
```

To use the CJS patch you can run your Node.js application with the `--require` flag:

```bash
node --require cjs-patch.js your-app.js
node --import=loader.mjs ./my-app.js
```

## Debugging
Expand Down
1 change: 1 addition & 0 deletions hook-sync.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { initializeSync as initialize, loadSync as load, resolveSync as resolve } from './hook.mjs'
33 changes: 31 additions & 2 deletions hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,26 @@ import { create } from '@apm-js-collab/code-transformer'
import parse from 'module-details-from-path'
import { fileURLToPath } from 'node:url'
import getPackageVersion from './lib/get-package-version.js'
import { readFileSync } from 'node:fs';
const debug = createDebug('@apm-js-collab/tracing-hooks:esm-hook')
let transformers = null
let packages = null
let instrumentator = null

export async function initialize(data = {}) {
return initializeSync(data)
}
export function initializeSync(data = {}) {
const instrumentations = data?.instrumentations || []
instrumentator = create(instrumentations)
packages = new Set(instrumentations.map(i => i.module.name))
transformers = new Map()
}

export async function resolve(specifier, context, nextResolve) {
const url = await nextResolve(specifier, context)
return resolveFromURL(await nextResolve(specifier, context))
}
function resolveFromURL(url) {
const resolvedModule = parse(url.url)
if (resolvedModule && packages.has(resolvedModule.name)) {
const path = fileURLToPath(resolvedModule.basedir)
Expand All @@ -30,18 +36,42 @@ export async function resolve(specifier, context, nextResolve) {
}
return url
}
export function resolveSync(specifier, context, nextResolve) {
return resolveFromURL(nextResolve(specifier, context))
}

export async function load(url, context, nextLoad) {
const result = await nextLoad(url, context)

if (transformers.has(url) === false) {
return result
}

if (result.format === 'commonjs') {
const parsedUrl = new URL(result.responseURL ?? url)
result.source ??= await readFile(parsedUrl)
/* c8 ignore next - mysteriously uncovered closing brace? */
}

return loadResult(url, result)
}

export function loadSync(url, context, nextLoad) {
const result = nextLoad(url, context)

if (transformers.has(url) === false) {
return result
}

if (result.format === 'commonjs') {
const parsedUrl = new URL(result.responseURL ?? url)
result.source ??= readFileSync(parsedUrl)
}

return loadResult(url, result)
}

export function loadResult(url, result) {
const code = result.source
if (code) {
const transformer = transformers.get(url)
Expand All @@ -58,4 +88,3 @@ export async function load(url, context, nextLoad) {

return result
}

1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function dump(code, filename) {
const path = require('node:path')
const fs = require('node:fs')

/* c8 ignore next */
const base = process.env.TRACING_DUMP_DIR ?? os.tmpdir()
const dirname = path.dirname(filename)
const basename = path.basename(filename)
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"files": [
"index.js",
"hook.mjs",
"hook-sync.mjs",
"lib"
],
"dependencies": {
Expand Down
Loading
Loading