From 54df63ec7dec001a8c879f32aa58a0e3df013eaf Mon Sep 17 00:00:00 2001 From: James Sumners Date: Thu, 18 Jun 2026 12:20:54 -0400 Subject: [PATCH 1/2] feat: Added transformer injection --- README.md | 7 +- hook-sync.mjs | 7 +- hook.mjs | 9 +- index.js | 4 +- test/create-injection.test.js | 343 ++++++++++++++++++++++++++++++++ test/hook-from-env.test.js | 359 ++++++++++++++++++++++++++++++++++ 6 files changed, 721 insertions(+), 8 deletions(-) create mode 100644 test/create-injection.test.js create mode 100644 test/hook-from-env.test.js diff --git a/README.md b/README.md index 4fe1cbb..b59e9f3 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # 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. +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. +forward-compatibility, create a combined loader as an ES Module (ESM). This can be done for any CommonJS _or_ ES Module application, but the loader itself must use ESM. @@ -112,4 +113,4 @@ setDiagnosticsHook(({ url, moduleName, error }) => { // injection succeeded } }) -``` \ No newline at end of file +``` diff --git a/hook-sync.mjs b/hook-sync.mjs index 2b221a1..940572b 100644 --- a/hook-sync.mjs +++ b/hook-sync.mjs @@ -1 +1,6 @@ -export { initializeSync as initialize, loadSync as load, resolveSync as resolve, setDiagnosticsHook } from './hook.mjs' +export { + initializeSync as initialize, + loadSync as load, + resolveSync as resolve, + setDiagnosticsHook +} from './hook.mjs' diff --git a/hook.mjs b/hook.mjs index d962e98..227688a 100644 --- a/hook.mjs +++ b/hook.mjs @@ -1,7 +1,12 @@ 'use strict' + +const createFrom = process.env.TRACING_TRANSFORMER_MODULE != null + ? process.env.TRACING_TRANSFORMER_MODULE + : '@apm-js-collab/code-transformer' +const { create } = await import(createFrom) + import createDebug from 'debug' import { readFile } from 'node:fs/promises' -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' @@ -101,4 +106,4 @@ export function loadResult(url, result) { } return result -} \ No newline at end of file +} diff --git a/index.js b/index.js index f97d5f2..d57290a 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,12 @@ 'use strict' -const { create } = require('@apm-js-collab/code-transformer') +const { create: defaultCreate } = require('@apm-js-collab/code-transformer') const Module = require('node:module') const parse = require('module-details-from-path') const getPackageVersion = require('./lib/get-package-version') const debug = require('debug')('@apm-js-collab/tracing-hooks:module-patch') class ModulePatch { - constructor({ instrumentations = [] } = {}) { + constructor({ instrumentations = [], create = defaultCreate } = {}) { this.packages = new Set(instrumentations.map(i => i.module.name)) this.instrumentator = create(instrumentations) this.compile = Module.prototype._compile diff --git a/test/create-injection.test.js b/test/create-injection.test.js new file mode 100644 index 0000000..454173f --- /dev/null +++ b/test/create-injection.test.js @@ -0,0 +1,343 @@ +'use strict' + +const test = require('node:test') +const path = require('node:path') +const { readFileSync } = require('node:fs') +const Module = require('node:module') + +const ModulePatch = require('../index.js') + +test('create function receives instrumentations array', (t) => { + t.plan(2) + + const mockCreate = (instrumentations) => { + t.assert.ok(Array.isArray(instrumentations), 'instrumentations should be an array') + t.assert.strictEqual(instrumentations.length, 1, 'should receive one instrumentation') + return { + getTransformer: () => null + } + } + + const instrumentations = [ + { + channelName: 'testChannel', + module: { name: 'test-pkg', versionRange: '>=1.0.0', filePath: 'index.js' }, + functionQuery: { className: 'TestClass', methodName: 'testMethod' } + } + ] + + new ModulePatch({ instrumentations, create: mockCreate }) +}) + +test('getTransformer is called with correct arguments', (t) => { + t.plan(3) + t.after(() => { + modulePatch.unpatch() + }) + + const mockCreate = () => { + return { + getTransformer: (name, version, filePath) => { + t.assert.strictEqual(name, 'pkg-1', 'package name should be pkg-1') + t.assert.ok(version, 'version should be provided') + t.assert.strictEqual(filePath, 'foo.js', 'filePath should be foo.js') + return null + } + } + } + + const instrumentations = [ + { + channelName: 'testChannel', + module: { name: 'pkg-1', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { className: 'Foo', methodName: 'doStuff' } + } + ] + + const modulePatch = new ModulePatch({ instrumentations, create: mockCreate }) + modulePatch.patch() + + const modulePath = path.join(__dirname, './example-deps/lib/node_modules/pkg-1/foo.js') + const resolvedPath = Module._resolveFilename(modulePath, null, false) + const data = readFileSync(resolvedPath, 'utf8') + const testModule = new Module(resolvedPath) + testModule._compile(data, resolvedPath) +}) + +test('transformer.transform is called with correct arguments', (t) => { + t.plan(3) + t.after(() => { + modulePatch.unpatch() + }) + + const mockCreate = () => { + return { + getTransformer: () => { + return { + transform: (content, format) => { + t.assert.ok(typeof content === 'string', 'content should be a string') + t.assert.ok(content.length > 0, 'content should not be empty') + t.assert.strictEqual(format, 'cjs', 'format should be cjs') + return { code: content } + }, + free: () => {} + } + } + } + } + + const instrumentations = [ + { + channelName: 'testChannel', + module: { name: 'pkg-1', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { className: 'Foo', methodName: 'doStuff' } + } + ] + + const modulePatch = new ModulePatch({ instrumentations, create: mockCreate }) + modulePatch.patch() + + const modulePath = path.join(__dirname, './example-deps/lib/node_modules/pkg-1/foo.js') + const resolvedPath = Module._resolveFilename(modulePath, null, false) + const data = readFileSync(resolvedPath, 'utf8') + const testModule = new Module(resolvedPath) + testModule._compile(data, resolvedPath) +}) + +test('transformer.free is called after transform', (t) => { + t.plan(2) + t.after(() => { + modulePatch.unpatch() + }) + + let transformCalled = false + + const mockCreate = () => { + return { + getTransformer: () => { + return { + transform: (content, format) => { + transformCalled = true + return { code: content } + }, + free: () => { + t.assert.ok(transformCalled, 'transform should be called before free') + t.assert.ok(true, 'free should be called') + } + } + } + } + } + + const instrumentations = [ + { + channelName: 'testChannel', + module: { name: 'pkg-1', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { className: 'Foo', methodName: 'doStuff' } + } + ] + + const modulePatch = new ModulePatch({ instrumentations, create: mockCreate }) + modulePatch.patch() + + const modulePath = path.join(__dirname, './example-deps/lib/node_modules/pkg-1/foo.js') + const resolvedPath = Module._resolveFilename(modulePath, null, false) + const data = readFileSync(resolvedPath, 'utf8') + const testModule = new Module(resolvedPath) + testModule._compile(data, resolvedPath) +}) + +test('transformer.free is called even when transform throws', (t) => { + t.plan(2) + t.after(() => { + modulePatch.unpatch() + }) + + const mockCreate = () => { + return { + getTransformer: () => { + return { + transform: () => { + t.assert.ok(true, 'transform should be called') + throw new Error('Transform error') + }, + free: () => { + t.assert.ok(true, 'free should be called even when transform throws') + } + } + } + } + } + + const instrumentations = [ + { + channelName: 'testChannel', + module: { name: 'pkg-1', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { className: 'Foo', methodName: 'doStuff' } + } + ] + + const modulePatch = new ModulePatch({ instrumentations, create: mockCreate }) + modulePatch.patch() + + const modulePath = path.join(__dirname, './example-deps/lib/node_modules/pkg-1/foo.js') + const resolvedPath = Module._resolveFilename(modulePath, null, false) + const data = readFileSync(resolvedPath, 'utf8') + const testModule = new Module(resolvedPath) + // Should not throw - error is caught internally + testModule._compile(data, resolvedPath) +}) + +test('getTransformer not called for non-instrumented packages', (t) => { + t.plan(1) + t.after(() => { + modulePatch.unpatch() + }) + + const mockCreate = () => { + return { + getTransformer: () => { + t.assert.fail('getTransformer should not be called for non-instrumented packages') + return null + } + } + } + + const instrumentations = [ + { + channelName: 'testChannel', + module: { name: 'pkg-1', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { className: 'Foo', methodName: 'doStuff' } + } + ] + + const modulePatch = new ModulePatch({ instrumentations, create: mockCreate }) + modulePatch.patch() + + // Try to compile a different package + const modulePath = path.join(__dirname, './example-deps/lib/node_modules/pkg-2/index.js') + const resolvedPath = Module._resolveFilename(modulePath, null, false) + const data = readFileSync(resolvedPath, 'utf8') + const testModule = new Module(resolvedPath) + testModule._compile(data, resolvedPath) + + t.assert.ok(testModule.exports, 'module should compile successfully') +}) + +test('mock create function can return transformed code', (t) => { + t.plan(3) + t.after(() => { + modulePatch.unpatch() + }) + + const mockCreate = () => { + return { + getTransformer: () => { + return { + transform: (content) => { + t.assert.ok(content.includes('class Foo'), 'original content should contain Foo class') + const transformed = `/* TRANSFORMED */\n${content}` + t.assert.ok(transformed.startsWith('/* TRANSFORMED */'), 'transformed code should have comment') + return { code: transformed } + }, + free: () => {} + } + } + } + } + + const instrumentations = [ + { + channelName: 'testChannel', + module: { name: 'pkg-1', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { className: 'Foo', methodName: 'doStuff' } + } + ] + + const modulePatch = new ModulePatch({ instrumentations, create: mockCreate }) + modulePatch.patch() + + const modulePath = path.join(__dirname, './example-deps/lib/node_modules/pkg-1/foo.js') + const resolvedPath = Module._resolveFilename(modulePath, null, false) + const data = readFileSync(resolvedPath, 'utf8') + const testModule = new Module(resolvedPath) + testModule._compile(data, resolvedPath) + + t.assert.ok(testModule.exports, 'module should export successfully') +}) + +test('getTransformer returns null for non-matching transformer', (t) => { + t.plan(2) + t.after(() => { + modulePatch.unpatch() + }) + + const mockCreate = () => { + return { + getTransformer: (name, version, filePath) => { + t.assert.strictEqual(name, 'pkg-1', 'should be called with pkg-1') + t.assert.strictEqual(filePath, 'foo.js', 'should be called with foo.js') + // Return null to simulate no matching transformer + return null + } + } + } + + const instrumentations = [ + { + channelName: 'testChannel', + module: { name: 'pkg-1', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { className: 'NonExistent', methodName: 'nonExistent' } + } + ] + + const modulePatch = new ModulePatch({ instrumentations, create: mockCreate }) + modulePatch.patch() + + const modulePath = path.join(__dirname, './example-deps/lib/node_modules/pkg-1/foo.js') + const resolvedPath = Module._resolveFilename(modulePath, null, false) + const data = readFileSync(resolvedPath, 'utf8') + const testModule = new Module(resolvedPath) + testModule._compile(data, resolvedPath) +}) + +test('transform receives exact file content', (t) => { + t.plan(2) + t.after(() => { + modulePatch.unpatch() + }) + + const modulePath = path.join(__dirname, './example-deps/lib/node_modules/pkg-1/foo.js') + const resolvedPath = Module._resolveFilename(modulePath, null, false) + const expectedContent = readFileSync(resolvedPath, 'utf8') + + const mockCreate = () => { + return { + getTransformer: () => { + return { + transform: (content, format) => { + t.assert.strictEqual(content, expectedContent, 'content should match original file') + t.assert.strictEqual(format, 'cjs', 'format should be cjs') + return { code: content } + }, + free: () => {} + } + } + } + } + + const instrumentations = [ + { + channelName: 'testChannel', + module: { name: 'pkg-1', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { className: 'Foo', methodName: 'doStuff' } + } + ] + + const modulePatch = new ModulePatch({ instrumentations, create: mockCreate }) + modulePatch.patch() + + const data = readFileSync(resolvedPath, 'utf8') + const testModule = new Module(resolvedPath) + testModule._compile(data, resolvedPath) +}) diff --git a/test/hook-from-env.test.js b/test/hook-from-env.test.js new file mode 100644 index 0000000..8271126 --- /dev/null +++ b/test/hook-from-env.test.js @@ -0,0 +1,359 @@ +'use strict' + +const test = require('node:test') +const path = require('node:path') +const os = require('node:os') +const { + readFile, + writeFile, + mkdir, + rm +} = require('node:fs/promises') + +test('hook.mjs loads transformer from TRACING_TRANSFORMER_MODULE env var', async (t) => { + t.plan(4) + + const testDir = path.join(os.tmpdir(), 'tracing-hooks-test', 'temp-transformer-test-' + Date.now()) + const mockTransformerPath = path.join(testDir, 'mock-transformer.mjs') + + t.after(async () => { + await rm(testDir, { recursive: true, force: true }) + delete process.env.TRACING_TRANSFORMER_MODULE + }) + + await mkdir(testDir, { recursive: true }) + const mockTransformerCode = ` +export function create(instrumentations) { + return { + getTransformer(name, version, filePath) { + if (name === 'esm-pkg' && filePath === 'foo.js') { + return { + transform(content, format) { + return { code: '/* CUSTOM TRANSFORMER */\\n' + content } + }, + free() {}, + moduleName: name + } + } + return null + } + } +} +` + await writeFile(mockTransformerPath, mockTransformerCode, 'utf8') + + process.env.TRACING_TRANSFORMER_MODULE = mockTransformerPath + + const hookUrl = `../hook.mjs?test=${Date.now()}` + const hook = await import(hookUrl) + + t.assert.ok(hook, 'hook module should load') + t.assert.ok(typeof hook.initialize === 'function', 'should have initialize function') + + hook.initialize({ + instrumentations: [ + { + channelName: 'envTest', + module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { + className: 'Foo', + methodName: 'doStuff', + kind: 'Async' + } + } + ] + }) + + const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') + async function resolveFn() { + return { url: `file://${esmPath}` } + } + async function nextLoad() { + const data = await readFile(esmPath, 'utf8') + return { + format: 'module', + source: data + } + } + + const url = await hook.resolve('esm-pkg', {}, resolveFn) + const result = await hook.load(url.url, {}, nextLoad) + + t.assert.ok(result.source.includes('/* CUSTOM TRANSFORMER */'), 'should use custom transformer from env var') + t.assert.strictEqual(result.shortCircuit, true, 'should short circuit') +}) + +test('hook.mjs defaults to @apm-js-collab/code-transformer when env var not set', async (t) => { + t.plan(3) + + delete process.env.TRACING_TRANSFORMER_MODULE + + const hookUrl = `../hook.mjs?test=${Date.now()}` + const hook = await import(hookUrl) + + t.assert.ok(hook, 'hook module should load') + + hook.initialize({ + instrumentations: [ + { + channelName: 'defaultTest', + module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { + className: 'Foo', + methodName: 'doStuff', + kind: 'Async' + } + } + ] + }) + + const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') + async function resolveFn() { + return { url: `file://${esmPath}` } + } + async function nextLoad() { + const data = await readFile(esmPath, 'utf8') + return { + format: 'module', + source: data + } + } + + const url = await hook.resolve('esm-pkg', {}, resolveFn) + const result = await hook.load(url.url, {}, nextLoad) + + t.assert.strictEqual(result.shortCircuit, true, 'should transform using default transformer') + t.assert.ok(result.source.includes('diagnostics_channel'), 'should include diagnostics_channel from default transformer') +}) + +test('custom transformer via env var exercises getTransformer with correct args', async (t) => { + t.plan(4) + + const testDir = path.join(os.tmpdir(), 'tracing-hooks-test', 'temp-transformer-test-args-' + Date.now()) + const mockTransformerPath = path.join(testDir, 'mock-transformer-args.mjs') + + t.after(async () => { + await rm(testDir, { recursive: true, force: true }) + delete process.env.TRACING_TRANSFORMER_MODULE + }) + + await mkdir(testDir, { recursive: true }) + const mockTransformerCode = ` +export function create(instrumentations) { + return { + getTransformer(name, version, filePath) { + if (name !== 'esm-pkg') throw new Error('Expected name to be esm-pkg') + if (!version) throw new Error('Expected version to be provided') + if (filePath !== 'foo.js') throw new Error('Expected filePath to be foo.js') + + return { + transform(content, format) { + if (format !== 'esm') throw new Error('Expected format to be esm') + return { code: content } + }, + free() {}, + moduleName: name + } + } + } +} +` + await writeFile(mockTransformerPath, mockTransformerCode, 'utf8') + + process.env.TRACING_TRANSFORMER_MODULE = mockTransformerPath + + const hookUrl = `../hook.mjs?test=${Date.now()}` + const hook = await import(hookUrl) + + hook.initialize({ + instrumentations: [ + { + channelName: 'argsTest', + module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { + className: 'Foo', + methodName: 'doStuff', + kind: 'Async' + } + } + ] + }) + + const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') + async function resolveFn() { + return { url: `file://${esmPath}` } + } + async function nextLoad() { + const data = await readFile(esmPath, 'utf8') + return { + format: 'module', + source: data + } + } + + const url = await hook.resolve('esm-pkg', {}, resolveFn) + + await t.assert.doesNotReject( + async () => await hook.load(url.url, {}, nextLoad), + 'custom transformer should receive correct arguments' + ) + + const result = await hook.load(url.url, {}, nextLoad) + t.assert.strictEqual(result.shortCircuit, true, 'should transform successfully') + t.assert.ok(result.source, 'should have transformed source') + t.assert.strictEqual(result.format, 'module', 'should preserve module format') +}) + +test('custom transformer via env var exercises free method', async (t) => { + t.plan(2) + + const testDir = path.join(os.tmpdir(), 'tracing-hooks-test', 'temp-transformer-test-free-' + Date.now()) + const mockTransformerPath = path.join(testDir, 'mock-transformer-free.mjs') + const freeCallsPath = path.join(testDir, 'free-calls.json') + + t.after(async () => { + await rm(testDir, { recursive: true, force: true }) + delete process.env.TRACING_TRANSFORMER_MODULE + }) + + await mkdir(testDir, { recursive: true }) + await writeFile(freeCallsPath, '0', 'utf8') + + const mockTransformerCode = ` +import { readFileSync, writeFileSync } from 'node:fs' + +export function create(instrumentations) { + return { + getTransformer(name, version, filePath) { + return { + transform(content, format) { + return { code: content } + }, + free() { + const count = parseInt(readFileSync('${freeCallsPath}', 'utf8')) + writeFileSync('${freeCallsPath}', String(count + 1), 'utf8') + }, + moduleName: name + } + } + } +} +` + await writeFile(mockTransformerPath, mockTransformerCode, 'utf8') + + process.env.TRACING_TRANSFORMER_MODULE = mockTransformerPath + + const hookUrl = `../hook.mjs?test=${Date.now()}` + const hook = await import(hookUrl) + + hook.initialize({ + instrumentations: [ + { + channelName: 'freeTest', + module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { + className: 'Foo', + methodName: 'doStuff' + } + } + ] + }) + + const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') + async function resolveFn() { + return { url: `file://${esmPath}` } + } + async function nextLoad() { + return { + format: 'module', + source: await readFile(esmPath, 'utf8') + } + } + + const url = await hook.resolve('esm-pkg', {}, resolveFn) + await hook.load(url.url, {}, nextLoad) + + const freeCalls = parseInt(await readFile(freeCallsPath, 'utf8')) + t.assert.strictEqual(freeCalls, 1, 'free should be called once') + + await hook.load(url.url, {}, nextLoad) + const freeCallsAfter = parseInt(await readFile(freeCallsPath, 'utf8')) + t.assert.strictEqual(freeCallsAfter, 2, 'free should be called again on second load') +}) + +test('custom transformer via env var handles transform errors', async (t) => { + t.plan(2) + + const testDir = path.join(os.tmpdir(), 'tracing-hooks-test', 'temp-transformer-test-error-' + Date.now()) + const mockTransformerPath = path.join(testDir, 'mock-transformer-error.mjs') + const freeCallsPath = path.join(testDir, 'free-calls-error.json') + + t.after(async () => { + await rm(testDir, { recursive: true, force: true }) + delete process.env.TRACING_TRANSFORMER_MODULE + }) + + await mkdir(testDir, { recursive: true }) + await writeFile(freeCallsPath, '0', 'utf8') + + const mockTransformerCode = ` +import { readFileSync, writeFileSync } from 'node:fs' + +export function create(instrumentations) { + return { + getTransformer(name, version, filePath) { + return { + transform(content, format) { + throw new Error('Transform failed intentionally') + }, + free() { + const count = parseInt(readFileSync('${freeCallsPath}', 'utf8')) + writeFileSync('${freeCallsPath}', String(count + 1), 'utf8') + }, + moduleName: name + } + } + } +} +` + await writeFile(mockTransformerPath, mockTransformerCode, 'utf8') + + process.env.TRACING_TRANSFORMER_MODULE = mockTransformerPath + + const hookUrl = `../hook.mjs?test=${Date.now()}` + const hook = await import(hookUrl) + + hook.initialize({ + instrumentations: [ + { + channelName: 'errorTest', + module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { + className: 'Foo', + methodName: 'doStuff' + } + } + ] + }) + + const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') + async function resolveFn() { + return { url: `file://${esmPath}` } + } + async function nextLoad() { + return { + format: 'module', + source: await readFile(esmPath, 'utf8') + } + } + + const url = await hook.resolve('esm-pkg', {}, resolveFn) + + await t.assert.doesNotReject( + async () => await hook.load(url.url, {}, nextLoad), + 'should handle transform errors gracefully' + ) + + const freeCalls = parseInt(await readFile(freeCallsPath, 'utf8')) + t.assert.strictEqual(freeCalls, 1, 'free should still be called when transform throws') +}) From 11db2954068e6422a63963c5af9d5bafd96469e6 Mon Sep 17 00:00:00 2001 From: James Sumners Date: Wed, 1 Jul 2026 08:20:15 -0400 Subject: [PATCH 2/2] address feedback --- hook.mjs | 19 +- test/hook-from-env.test.js | 359 ---------------------------- test/hook-with-injected-dep.test.js | 266 +++++++++++++++++++++ 3 files changed, 274 insertions(+), 370 deletions(-) delete mode 100644 test/hook-from-env.test.js create mode 100644 test/hook-with-injected-dep.test.js diff --git a/hook.mjs b/hook.mjs index 227688a..935e5ca 100644 --- a/hook.mjs +++ b/hook.mjs @@ -1,16 +1,13 @@ 'use strict' -const createFrom = process.env.TRACING_TRANSFORMER_MODULE != null - ? process.env.TRACING_TRANSFORMER_MODULE - : '@apm-js-collab/code-transformer' -const { create } = await import(createFrom) - -import createDebug from 'debug' import { readFile } from 'node:fs/promises' -import parse from 'module-details-from-path' +import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' +import { create as defaultCreate } from '@apm-js-collab/code-transformer' +import createDebug from 'debug' +import parse from 'module-details-from-path' 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 @@ -22,10 +19,10 @@ export function setDiagnosticsHook(hook) { diagnosticsHook = hook } -export async function initialize(data = {}) { - return initializeSync(data) +export async function initialize(data = {}, { create = defaultCreate } = {}) { + return initializeSync(data, { create }) } -export function initializeSync(data = {}) { +export function initializeSync(data = {}, { create = defaultCreate } = {}) { const instrumentations = data?.instrumentations || [] instrumentator = create(instrumentations) packages = new Set(instrumentations.map(i => i.module.name)) diff --git a/test/hook-from-env.test.js b/test/hook-from-env.test.js deleted file mode 100644 index 8271126..0000000 --- a/test/hook-from-env.test.js +++ /dev/null @@ -1,359 +0,0 @@ -'use strict' - -const test = require('node:test') -const path = require('node:path') -const os = require('node:os') -const { - readFile, - writeFile, - mkdir, - rm -} = require('node:fs/promises') - -test('hook.mjs loads transformer from TRACING_TRANSFORMER_MODULE env var', async (t) => { - t.plan(4) - - const testDir = path.join(os.tmpdir(), 'tracing-hooks-test', 'temp-transformer-test-' + Date.now()) - const mockTransformerPath = path.join(testDir, 'mock-transformer.mjs') - - t.after(async () => { - await rm(testDir, { recursive: true, force: true }) - delete process.env.TRACING_TRANSFORMER_MODULE - }) - - await mkdir(testDir, { recursive: true }) - const mockTransformerCode = ` -export function create(instrumentations) { - return { - getTransformer(name, version, filePath) { - if (name === 'esm-pkg' && filePath === 'foo.js') { - return { - transform(content, format) { - return { code: '/* CUSTOM TRANSFORMER */\\n' + content } - }, - free() {}, - moduleName: name - } - } - return null - } - } -} -` - await writeFile(mockTransformerPath, mockTransformerCode, 'utf8') - - process.env.TRACING_TRANSFORMER_MODULE = mockTransformerPath - - const hookUrl = `../hook.mjs?test=${Date.now()}` - const hook = await import(hookUrl) - - t.assert.ok(hook, 'hook module should load') - t.assert.ok(typeof hook.initialize === 'function', 'should have initialize function') - - hook.initialize({ - instrumentations: [ - { - channelName: 'envTest', - module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, - functionQuery: { - className: 'Foo', - methodName: 'doStuff', - kind: 'Async' - } - } - ] - }) - - const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') - async function resolveFn() { - return { url: `file://${esmPath}` } - } - async function nextLoad() { - const data = await readFile(esmPath, 'utf8') - return { - format: 'module', - source: data - } - } - - const url = await hook.resolve('esm-pkg', {}, resolveFn) - const result = await hook.load(url.url, {}, nextLoad) - - t.assert.ok(result.source.includes('/* CUSTOM TRANSFORMER */'), 'should use custom transformer from env var') - t.assert.strictEqual(result.shortCircuit, true, 'should short circuit') -}) - -test('hook.mjs defaults to @apm-js-collab/code-transformer when env var not set', async (t) => { - t.plan(3) - - delete process.env.TRACING_TRANSFORMER_MODULE - - const hookUrl = `../hook.mjs?test=${Date.now()}` - const hook = await import(hookUrl) - - t.assert.ok(hook, 'hook module should load') - - hook.initialize({ - instrumentations: [ - { - channelName: 'defaultTest', - module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, - functionQuery: { - className: 'Foo', - methodName: 'doStuff', - kind: 'Async' - } - } - ] - }) - - const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') - async function resolveFn() { - return { url: `file://${esmPath}` } - } - async function nextLoad() { - const data = await readFile(esmPath, 'utf8') - return { - format: 'module', - source: data - } - } - - const url = await hook.resolve('esm-pkg', {}, resolveFn) - const result = await hook.load(url.url, {}, nextLoad) - - t.assert.strictEqual(result.shortCircuit, true, 'should transform using default transformer') - t.assert.ok(result.source.includes('diagnostics_channel'), 'should include diagnostics_channel from default transformer') -}) - -test('custom transformer via env var exercises getTransformer with correct args', async (t) => { - t.plan(4) - - const testDir = path.join(os.tmpdir(), 'tracing-hooks-test', 'temp-transformer-test-args-' + Date.now()) - const mockTransformerPath = path.join(testDir, 'mock-transformer-args.mjs') - - t.after(async () => { - await rm(testDir, { recursive: true, force: true }) - delete process.env.TRACING_TRANSFORMER_MODULE - }) - - await mkdir(testDir, { recursive: true }) - const mockTransformerCode = ` -export function create(instrumentations) { - return { - getTransformer(name, version, filePath) { - if (name !== 'esm-pkg') throw new Error('Expected name to be esm-pkg') - if (!version) throw new Error('Expected version to be provided') - if (filePath !== 'foo.js') throw new Error('Expected filePath to be foo.js') - - return { - transform(content, format) { - if (format !== 'esm') throw new Error('Expected format to be esm') - return { code: content } - }, - free() {}, - moduleName: name - } - } - } -} -` - await writeFile(mockTransformerPath, mockTransformerCode, 'utf8') - - process.env.TRACING_TRANSFORMER_MODULE = mockTransformerPath - - const hookUrl = `../hook.mjs?test=${Date.now()}` - const hook = await import(hookUrl) - - hook.initialize({ - instrumentations: [ - { - channelName: 'argsTest', - module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, - functionQuery: { - className: 'Foo', - methodName: 'doStuff', - kind: 'Async' - } - } - ] - }) - - const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') - async function resolveFn() { - return { url: `file://${esmPath}` } - } - async function nextLoad() { - const data = await readFile(esmPath, 'utf8') - return { - format: 'module', - source: data - } - } - - const url = await hook.resolve('esm-pkg', {}, resolveFn) - - await t.assert.doesNotReject( - async () => await hook.load(url.url, {}, nextLoad), - 'custom transformer should receive correct arguments' - ) - - const result = await hook.load(url.url, {}, nextLoad) - t.assert.strictEqual(result.shortCircuit, true, 'should transform successfully') - t.assert.ok(result.source, 'should have transformed source') - t.assert.strictEqual(result.format, 'module', 'should preserve module format') -}) - -test('custom transformer via env var exercises free method', async (t) => { - t.plan(2) - - const testDir = path.join(os.tmpdir(), 'tracing-hooks-test', 'temp-transformer-test-free-' + Date.now()) - const mockTransformerPath = path.join(testDir, 'mock-transformer-free.mjs') - const freeCallsPath = path.join(testDir, 'free-calls.json') - - t.after(async () => { - await rm(testDir, { recursive: true, force: true }) - delete process.env.TRACING_TRANSFORMER_MODULE - }) - - await mkdir(testDir, { recursive: true }) - await writeFile(freeCallsPath, '0', 'utf8') - - const mockTransformerCode = ` -import { readFileSync, writeFileSync } from 'node:fs' - -export function create(instrumentations) { - return { - getTransformer(name, version, filePath) { - return { - transform(content, format) { - return { code: content } - }, - free() { - const count = parseInt(readFileSync('${freeCallsPath}', 'utf8')) - writeFileSync('${freeCallsPath}', String(count + 1), 'utf8') - }, - moduleName: name - } - } - } -} -` - await writeFile(mockTransformerPath, mockTransformerCode, 'utf8') - - process.env.TRACING_TRANSFORMER_MODULE = mockTransformerPath - - const hookUrl = `../hook.mjs?test=${Date.now()}` - const hook = await import(hookUrl) - - hook.initialize({ - instrumentations: [ - { - channelName: 'freeTest', - module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, - functionQuery: { - className: 'Foo', - methodName: 'doStuff' - } - } - ] - }) - - const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') - async function resolveFn() { - return { url: `file://${esmPath}` } - } - async function nextLoad() { - return { - format: 'module', - source: await readFile(esmPath, 'utf8') - } - } - - const url = await hook.resolve('esm-pkg', {}, resolveFn) - await hook.load(url.url, {}, nextLoad) - - const freeCalls = parseInt(await readFile(freeCallsPath, 'utf8')) - t.assert.strictEqual(freeCalls, 1, 'free should be called once') - - await hook.load(url.url, {}, nextLoad) - const freeCallsAfter = parseInt(await readFile(freeCallsPath, 'utf8')) - t.assert.strictEqual(freeCallsAfter, 2, 'free should be called again on second load') -}) - -test('custom transformer via env var handles transform errors', async (t) => { - t.plan(2) - - const testDir = path.join(os.tmpdir(), 'tracing-hooks-test', 'temp-transformer-test-error-' + Date.now()) - const mockTransformerPath = path.join(testDir, 'mock-transformer-error.mjs') - const freeCallsPath = path.join(testDir, 'free-calls-error.json') - - t.after(async () => { - await rm(testDir, { recursive: true, force: true }) - delete process.env.TRACING_TRANSFORMER_MODULE - }) - - await mkdir(testDir, { recursive: true }) - await writeFile(freeCallsPath, '0', 'utf8') - - const mockTransformerCode = ` -import { readFileSync, writeFileSync } from 'node:fs' - -export function create(instrumentations) { - return { - getTransformer(name, version, filePath) { - return { - transform(content, format) { - throw new Error('Transform failed intentionally') - }, - free() { - const count = parseInt(readFileSync('${freeCallsPath}', 'utf8')) - writeFileSync('${freeCallsPath}', String(count + 1), 'utf8') - }, - moduleName: name - } - } - } -} -` - await writeFile(mockTransformerPath, mockTransformerCode, 'utf8') - - process.env.TRACING_TRANSFORMER_MODULE = mockTransformerPath - - const hookUrl = `../hook.mjs?test=${Date.now()}` - const hook = await import(hookUrl) - - hook.initialize({ - instrumentations: [ - { - channelName: 'errorTest', - module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, - functionQuery: { - className: 'Foo', - methodName: 'doStuff' - } - } - ] - }) - - const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') - async function resolveFn() { - return { url: `file://${esmPath}` } - } - async function nextLoad() { - return { - format: 'module', - source: await readFile(esmPath, 'utf8') - } - } - - const url = await hook.resolve('esm-pkg', {}, resolveFn) - - await t.assert.doesNotReject( - async () => await hook.load(url.url, {}, nextLoad), - 'should handle transform errors gracefully' - ) - - const freeCalls = parseInt(await readFile(freeCallsPath, 'utf8')) - t.assert.strictEqual(freeCalls, 1, 'free should still be called when transform throws') -}) diff --git a/test/hook-with-injected-dep.test.js b/test/hook-with-injected-dep.test.js new file mode 100644 index 0000000..e02efa3 --- /dev/null +++ b/test/hook-with-injected-dep.test.js @@ -0,0 +1,266 @@ +'use strict' + +const test = require('node:test') +const path = require('node:path') +const { readFile } = require('node:fs/promises') + +test('hook.mjs accepts custom create function via initialize options', async (t) => { + t.plan(2) + + const mockCreate = (instrumentations) => { + return { + getTransformer(name, version, filePath) { + if (name === 'esm-pkg' && filePath === 'foo.js') { + return { + transform(content, format) { + return { code: '/* CUSTOM TRANSFORMER */\n' + content } + }, + free() {}, + moduleName: name + } + } + return null + } + } + } + + const hook = await import('../hook.mjs?' + Date.now()) + + hook.initialize({ + instrumentations: [ + { + channelName: 'createTest', + module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { + className: 'Foo', + methodName: 'doStuff', + kind: 'Async' + } + } + ] + }, { create: mockCreate }) + + const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') + async function resolveFn() { + return { url: `file://${esmPath}` } + } + async function nextLoad() { + const data = await readFile(esmPath, 'utf8') + return { + format: 'module', + source: data + } + } + + const url = await hook.resolve('esm-pkg', {}, resolveFn) + const result = await hook.load(url.url, {}, nextLoad) + + t.assert.ok(result.source.includes('/* CUSTOM TRANSFORMER */'), 'should use custom create function') + t.assert.strictEqual(result.shortCircuit, true, 'should short circuit') +}) + +test('hook.mjs defaults to @apm-js-collab/code-transformer when create not provided', async (t) => { + t.plan(2) + + const hook = await import('../hook.mjs?' + Date.now()) + + hook.initialize({ + instrumentations: [ + { + channelName: 'defaultTest', + module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { + className: 'Foo', + methodName: 'doStuff', + kind: 'Async' + } + } + ] + }) + + const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') + async function resolveFn() { + return { url: `file://${esmPath}` } + } + async function nextLoad() { + const data = await readFile(esmPath, 'utf8') + return { + format: 'module', + source: data + } + } + + const url = await hook.resolve('esm-pkg', {}, resolveFn) + const result = await hook.load(url.url, {}, nextLoad) + + t.assert.strictEqual(result.shortCircuit, true, 'should transform using default transformer') + t.assert.ok(result.source.includes('diagnostics_channel'), 'should include diagnostics_channel from default transformer') +}) + +test('custom create function exercises getTransformer with correct args', async (t) => { + t.plan(4) + + const mockCreate = (instrumentations) => { + t.assert.strictEqual(instrumentations.length, 1, 'should receive one instrumentation') + return { + getTransformer(name, version, filePath) { + t.assert.strictEqual(name, 'esm-pkg', 'name should be esm-pkg') + t.assert.ok(version, 'version should be provided') + t.assert.strictEqual(filePath, 'foo.js', 'filePath should be foo.js') + + return { + transform(content, format) { + if (format !== 'esm') throw new Error('Expected format to be esm') + return { code: content } + }, + free() {}, + moduleName: name + } + } + } + } + + const hook = await import('../hook.mjs?' + Date.now()) + + hook.initialize({ + instrumentations: [ + { + channelName: 'argsTest', + module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { + className: 'Foo', + methodName: 'doStuff', + kind: 'Async' + } + } + ] + }, { create: mockCreate }) + + const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') + async function resolveFn() { + return { url: `file://${esmPath}` } + } + async function nextLoad() { + const data = await readFile(esmPath, 'utf8') + return { + format: 'module', + source: data + } + } + + const url = await hook.resolve('esm-pkg', {}, resolveFn) + await hook.load(url.url, {}, nextLoad) +}) + +test('custom create function exercises free method', async (t) => { + t.plan(2) + + let freeCalls = 0 + + const mockCreate = () => { + return { + getTransformer(name, version, filePath) { + return { + transform(content, format) { + return { code: content } + }, + free() { + freeCalls++ + }, + moduleName: name + } + } + } + } + + const hook = await import('../hook.mjs?' + Date.now()) + + hook.initialize({ + instrumentations: [ + { + channelName: 'freeTest', + module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { + className: 'Foo', + methodName: 'doStuff' + } + } + ] + }, { create: mockCreate }) + + const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') + async function resolveFn() { + return { url: `file://${esmPath}` } + } + async function nextLoad() { + return { + format: 'module', + source: await readFile(esmPath, 'utf8') + } + } + + const url = await hook.resolve('esm-pkg', {}, resolveFn) + await hook.load(url.url, {}, nextLoad) + + t.assert.strictEqual(freeCalls, 1, 'free should be called once') + + await hook.load(url.url, {}, nextLoad) + t.assert.strictEqual(freeCalls, 2, 'free should be called again on second load') +}) + +test('custom create function handles transform errors', async (t) => { + t.plan(2) + + let freeCalled = false + + const mockCreate = () => { + return { + getTransformer(name, version, filePath) { + return { + transform(content, format) { + throw new Error('Transform failed intentionally') + }, + free() { + freeCalled = true + }, + moduleName: name + } + } + } + } + + const hook = await import('../hook.mjs?' + Date.now()) + + hook.initialize({ + instrumentations: [ + { + channelName: 'errorTest', + module: { name: 'esm-pkg', versionRange: '>=1', filePath: 'foo.js' }, + functionQuery: { + className: 'Foo', + methodName: 'doStuff' + } + } + ] + }, { create: mockCreate }) + + const esmPath = path.join(__dirname, './example-deps/lib/node_modules/esm-pkg/foo.js') + async function resolveFn() { + return { url: `file://${esmPath}` } + } + async function nextLoad() { + return { + format: 'module', + source: await readFile(esmPath, 'utf8') + } + } + + const url = await hook.resolve('esm-pkg', {}, resolveFn) + + await t.assert.doesNotReject( + async () => await hook.load(url.url, {}, nextLoad), + 'should handle transform errors gracefully' + ) + + t.assert.strictEqual(freeCalled, true, 'free should still be called when transform throws') +})