Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
123 changes: 103 additions & 20 deletions lib/instrumentation/core/child_process.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,120 @@
/*
* Copyright 2020 New Relic Corporation. All rights reserved.
* Copyright 2026 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

const { RecorderSpec } = require('../../../lib/shim/specs')
// eslint-disable-next-line n/no-unsupported-features/node-builtins
const { tracingChannel } = require('node:diagnostics_channel')
const defaultLogger = require('#agentlib/logger.js').child({ component: 'child_process' })

module.exports = initialize
const channels = {
exec: tracingChannel('child_process.exec'),
execFile: tracingChannel('child_process.execFile')
}

function initialize(agent, childProcess, moduleName, shim) {
if (!childProcess) {
shim.logger.debug('Could not find child_process, not instrumenting')
return false
class ChildProcessInstrumentation {
constructor(agent) {
this.tracer = agent.tracer
this.store = this.tracer._contextManager._asyncLocalStorage
Comment thread
amychisholm03 marked this conversation as resolved.
Outdated
this.originals = {}
}

const methods = ['exec', 'execFile']
patch(childProcess) {
this.childProcess = childProcess

shim.record(childProcess, methods, function recordExec(shim, fn, name) {
return new RecorderSpec({ name: 'child_process.' + name, callback: shim.LAST })
})
for (const [methodName, channel] of Object.entries(channels)) {
const original = childProcess[methodName]
this.originals[methodName] = original
const tracer = this.tracer

makePromisifyCompatible(shim, childProcess)
}
childProcess[methodName] = function wrappedMethod(...args) {
const ctx = tracer.getContext()
if (!ctx?.transaction?.isActive()) {
return original.apply(this, args)
}

const lastArg = args[args.length - 1]
const hasCallback = typeof lastArg === 'function'
const data = { methodName, callbackName: hasCallback ? lastArg.name || '<anonymous>' : null }

function makePromisifyCompatible(shim, childProcess) {
const originalExec = shim.getOriginal(childProcess.exec)
for (const symbol of Object.getOwnPropertySymbols(originalExec)) {
childProcess.exec[symbol] = originalExec[symbol]
return hasCallback
Comment thread
amychisholm03 marked this conversation as resolved.
Outdated
? channel.traceCallback(original, -1, data, this, ...args)
: channel.traceSync(original, data, this, ...args)
}

for (const symbol of Object.getOwnPropertySymbols(original)) {
childProcess[methodName][symbol] = original[symbol]
}
}
}

// TODO: this can be extracted to be used by other core instrumentation
// as we refactor them to use tracing channel
createSegment(ctx, name) {
const segment = this.tracer.createSegment({ name, parent: ctx?.segment, transaction: ctx?.transaction })
if (segment) {
segment.start()
}
return segment
}

const originalExecFile = shim.getOriginal(childProcess.execFile)
for (const symbol of Object.getOwnPropertySymbols(originalExecFile)) {
childProcess.execFile[symbol] = originalExecFile[symbol]
bindStore() {
const { tracer, store } = this

for (const channel of Object.values(channels)) {
channel.start.bindStore(store, (data) => {
const ctx = tracer.getContext()
const segment = this.createSegment(ctx, 'child_process.' + data.methodName)
data.ctx = segment ? ctx.enterSegment({ segment }) : ctx
return data.ctx
})

channel.asyncStart.bindStore(store, (data) => {
const { ctx, callbackName } = data
ctx.segment.touch()

const segment = this.createSegment(ctx, 'Callback: ' + callbackName)
data.callbackSegment = segment
return ctx.enterSegment({ segment })
})
}
}

teardown() {
for (const [methodName, channel] of Object.entries(channels)) {
if (this.originals[methodName]) {
this.childProcess[methodName] = this.originals[methodName]
}
channel.start.unbindStore(this.store)
Comment thread
amychisholm03 marked this conversation as resolved.
Outdated
channel.asyncStart.unbindStore(this.store)
}
}
}

module.exports = function initialize(agent, childProcess, { logger = defaultLogger } = {}) {
if (!childProcess) {
logger.debug('Could not find child_process, not instrumenting')
return false
}

const instrumentation = new ChildProcessInstrumentation(agent)
Comment thread
amychisholm03 marked this conversation as resolved.
Outdated
instrumentation.patch(childProcess)
instrumentation.bindStore()
return instrumentation
}

// `end`/`asyncEnd` are agent-agnostic (operate only on what's already
// stashed on `data` by the bindStore transforms above), so unlike
// `start`/`asyncStart` they only ever need to be subscribed once.
for (const channel of Object.values(channels)) {
channel.subscribe({
end(data) {
data.ctx?.segment?.touch()
Comment thread
amychisholm03 marked this conversation as resolved.
Outdated
},
asyncEnd(data) {
data.callbackSegment?.touch()
}
})
}
10 changes: 9 additions & 1 deletion lib/shimmer.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,8 @@ const shimmer = (module.exports = {
registerCoreInstrumentation(agent) {
instrumentProcessMethods(agent)

this._coreInstrumentations = {}

// Instrument each of the core modules.
for (const [mojule, core] of Object.entries(CORE_INSTRUMENTATION)) {
if (agent.config.instrumentation?.[mojule].enabled === false) {
Expand All @@ -298,7 +300,7 @@ const shimmer = (module.exports = {
resolvedName: mojule
})
applyDebugState(shim, core, false)
_firstPartyInstrumentation(agent, filePath, shim, uninstrumented, mojule)
this._coreInstrumentations[mojule] = _firstPartyInstrumentation(agent, filePath, shim, uninstrumented, mojule)
}
}
},
Expand Down Expand Up @@ -335,6 +337,12 @@ const shimmer = (module.exports = {
if (this._subscribers) {
shimmer.teardownSubscribers()
}
if (this._coreInstrumentations) {
for (const instrumentation of Object.values(this._coreInstrumentations)) {
instrumentation?.teardown?.()
}
this._coreInstrumentations = {}
}
if (this._modulePatch) {
this._modulePatch.unpatch()
}
Expand Down
55 changes: 55 additions & 0 deletions test/unit/instrumentation/core/child_process.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright 2026 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

const test = require('node:test')
const assert = require('node:assert')
const helper = require('#testlib/agent_helper.js')
const childProcessInstrumentation = require('#agentlib/instrumentation/core/child_process.js')

test('child_process instrumentation', async (t) => {
const agent = helper.loadMockedAgent()
t.after(() => {
helper.unloadAgent(agent)
})

await t.test('should log and return false when child_process is not available', () => {
const debugCalls = []
const stubLogger = { debug: (msg) => debugCalls.push(msg) }

const result = childProcessInstrumentation(agent, null, { logger: stubLogger })

assert.equal(result, false)
assert.deepEqual(debugCalls, ['Could not find child_process, not instrumenting'])
})

await t.test('teardown should restore the original exec/execFile', () => {
const originalExec = function exec() {}
const originalExecFile = function execFile() {}
const fakeChildProcess = { exec: originalExec, execFile: originalExecFile }

const instrumentation = childProcessInstrumentation(agent, fakeChildProcess)
assert.notEqual(fakeChildProcess.exec, originalExec, 'exec should be wrapped after patch')
assert.notEqual(fakeChildProcess.execFile, originalExecFile, 'execFile should be wrapped after patch')

instrumentation.teardown()

assert.equal(fakeChildProcess.exec, originalExec, 'exec should be restored after teardown')
assert.equal(fakeChildProcess.execFile, originalExecFile, 'execFile should be restored after teardown')
})

await t.test('should not double-wrap after a teardown/re-patch cycle', () => {
const originalExec = function exec() {}
const fakeChildProcess = { exec: originalExec, execFile: function execFile() {} }

const first = childProcessInstrumentation(agent, fakeChildProcess)
first.teardown()

const second = childProcessInstrumentation(agent, fakeChildProcess)

assert.equal(second.originals.exec, originalExec, 'second patch should have wrapped the true original, not a stale wrapper')
})
})
11 changes: 11 additions & 0 deletions test/unit/shimmer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,17 @@ test('should not throw if you call removeHooks before creating ritm and iitm hoo
})
})

test('should call teardown on core instrumentations that provide it when removeHooks runs', async () => {
const cp = require('child_process')
const originalExec = cp.exec

const agent = helper.instrumentMockedAgent()
assert.notEqual(cp.exec, originalExec, 'exec should be wrapped after bootstrapping instrumentation')

helper.unloadAgent(agent)
assert.equal(cp.exec, originalExec, 'exec should be restored to the original after removeHooks tears it down')
})

test('Shimmer with logger mock', async (t) => {
const sandbox = sinon.createSandbox()
const loggerMock = require('./mocks/logger')(sandbox)
Expand Down
Loading