diff --git a/packages/datadog-instrumentations/src/child_process.js b/packages/datadog-instrumentations/src/child_process.js index ac67d94d64..dd58deae24 100644 --- a/packages/datadog-instrumentations/src/child_process.js +++ b/packages/datadog-instrumentations/src/child_process.js @@ -12,7 +12,7 @@ const { const childProcessChannel = dc.tracingChannel('datadog:child_process:execution') // ignored exec method because it calls to execFile directly -const execAsyncMethods = ['execFile', 'spawn'] +const execAsyncMethods = ['execFile', 'spawn', 'fork'] const names = ['child_process', 'node:child_process'] @@ -97,8 +97,10 @@ function wrapChildProcessSyncMethod (returnError, shell = false) { return childProcessMethod.apply(this, arguments) } - const childProcessInfo = normalizeArgs(arguments, shell) + const callArgs = [...arguments] + const childProcessInfo = normalizeArgs(callArgs, shell) const context = createContextFromChildProcessInfo(childProcessInfo) + context.callArgs = callArgs return childProcessChannel.start.runStores(context, () => { try { @@ -108,7 +110,7 @@ function wrapChildProcessSyncMethod (returnError, shell = false) { return returnError(error, context) } - const result = childProcessMethod.apply(this, arguments) + const result = childProcessMethod.apply(this, context.callArgs) context.result = result return result @@ -131,9 +133,11 @@ function wrapChildProcessCustomPromisifyMethod (customPromisifyMethod, shell) { return customPromisifyMethod.apply(this, arguments) } - const childProcessInfo = normalizeArgs(arguments, shell) + const callArgs = [...arguments] + const childProcessInfo = normalizeArgs(callArgs, shell) const context = createContextFromChildProcessInfo(childProcessInfo) + context.callArgs = callArgs const { start, end, asyncStart, asyncEnd, error } = childProcessChannel start.publish(context) @@ -143,7 +147,7 @@ function wrapChildProcessCustomPromisifyMethod (customPromisifyMethod, shell) { result = Promise.reject(context.abortController.signal.reason || new Error('Aborted')) } else { try { - result = customPromisifyMethod.apply(this, arguments) + result = customPromisifyMethod.apply(this, context.callArgs) } catch (error) { context.error = error error.publish(context) @@ -181,9 +185,11 @@ function wrapChildProcessAsyncMethod (ChildProcess, shell = false) { return childProcessMethod.apply(this, arguments) } - const childProcessInfo = normalizeArgs(arguments, shell) + const callArgs = [...arguments] + const childProcessInfo = normalizeArgs(callArgs, shell) const context = createContextFromChildProcessInfo(childProcessInfo) + context.callArgs = callArgs return childProcessChannel.start.runStores(context, () => { let childProcess if (context.abortController.signal.aborted) { @@ -194,7 +200,7 @@ function wrapChildProcessAsyncMethod (ChildProcess, shell = false) { const error = context.abortController.signal.reason || new Error('Aborted') childProcess.emit('error', error) - const cb = arguments[arguments.length - 1] + const cb = context.callArgs[context.callArgs.length - 1] if (typeof cb === 'function') { cb(error) } @@ -202,7 +208,7 @@ function wrapChildProcessAsyncMethod (ChildProcess, shell = false) { childProcess.emit('close') }) } else { - childProcess = childProcessMethod.apply(this, arguments) + childProcess = childProcessMethod.apply(this, context.callArgs) } if (childProcess) { diff --git a/packages/datadog-instrumentations/test/child_process.spec.js b/packages/datadog-instrumentations/test/child_process.spec.js index 0aa4d63d79..cd99472df2 100644 --- a/packages/datadog-instrumentations/test/child_process.spec.js +++ b/packages/datadog-instrumentations/test/child_process.spec.js @@ -1,6 +1,9 @@ 'use strict' const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') const { promisify } = require('node:util') const dc = require('dc-polyfill') @@ -326,7 +329,6 @@ describe('child process', () => { abortController: sinon.match.instanceOf(AbortController), shell: true, }) - sinon.assert.calledOnce(start) sinon.assert.calledWithMatch(asyncFinish, { command: 'echo', file: 'echo', @@ -701,6 +703,130 @@ describe('child process', () => { }) }) }) + + describe('fork', () => { + let tmpScript + + before(() => { + tmpScript = path.join(os.tmpdir(), `dd-trace-test-fork-${Date.now()}.js`) + fs.writeFileSync(tmpScript, 'process.exit(0)') + }) + + after(() => { + try { fs.unlinkSync(tmpScript) } catch (e) { /* ignore */ } + }) + + it('should execute success callbacks', (done) => { + const child = childProcess.fork(tmpScript) + + child.once('close', () => { + sinon.assert.calledOnce(start) + sinon.assert.calledWithMatch(start, { + command: tmpScript, + file: tmpScript, + shell: false, + abortController: sinon.match.instanceOf(AbortController), + }) + sinon.assert.calledOnce(asyncFinish) + sinon.assert.calledWithMatch(asyncFinish, { + command: tmpScript, + file: tmpScript, + shell: false, + result: 0, + }) + sinon.assert.notCalled(error) + done() + }) + }) + + it('should publish arguments', (done) => { + const child = childProcess.fork(tmpScript, ['--flag']) + + child.once('close', () => { + sinon.assert.calledOnce(start) + sinon.assert.calledWithMatch(start, { + command: `${tmpScript} --flag`, + file: tmpScript, + fileArgs: ['--flag'], + shell: false, + abortController: sinon.match.instanceOf(AbortController), + }) + done() + }) + }) + + it('should execute error callback for non-existent module', (done) => { + const child = childProcess.fork('non_existent_module_test.js') + + child.once('error', () => {}) + + child.once('close', () => { + sinon.assert.calledOnce(start) + sinon.assert.calledWithMatch(start, { + command: 'non_existent_module_test.js', + file: 'non_existent_module_test.js', + shell: false, + }) + sinon.assert.calledOnce(error) + done() + }) + }) + }) + + describe('callArgs on context', () => { + function injectTestEnv (context) { + if (!context.callArgs) return + const args = context.callArgs + const opts = args[2] != null && typeof args[2] === 'object' ? args[2] : {} + args[2] = { ...opts, env: { ...process.env, DD_TEST_VAR: 'injected' } } + } + + it('should include callArgs for async methods', (done) => { + const child = childProcess.spawn('echo', ['hello']) + + child.once('close', () => { + sinon.assert.calledOnce(start) + const context = start.firstCall.firstArg + assert.ok(Array.isArray(context.callArgs)) + assert.strictEqual(context.callArgs[0], 'echo') + assert.deepStrictEqual(context.callArgs[1], ['hello']) + done() + }) + }) + + it('should include callArgs for sync methods', () => { + childProcess.spawnSync('echo', ['hello']) + + sinon.assert.calledOnce(start) + const context = start.firstCall.firstArg + assert.ok(Array.isArray(context.callArgs)) + assert.strictEqual(context.callArgs[0], 'echo') + assert.deepStrictEqual(context.callArgs[1], ['hello']) + }) + + it('should allow subscribers to mutate callArgs for async methods', (done) => { + childProcessChannel.subscribe({ start: injectTestEnv }) + + const script = 'process.exit(process.env.DD_TEST_VAR === "injected" ? 0 : 1)' + const child = childProcess.spawn('node', ['-e', script]) + + child.once('close', (code) => { + childProcessChannel.unsubscribe({ start: injectTestEnv }) + assert.strictEqual(code, 0) + done() + }) + }) + + it('should allow subscribers to mutate callArgs for sync methods', () => { + childProcessChannel.subscribe({ start: injectTestEnv }) + + const script = 'process.exit(process.env.DD_TEST_VAR === "injected" ? 0 : 1)' + const result = childProcess.spawnSync('node', ['-e', script]) + + childProcessChannel.unsubscribe({ start: injectTestEnv }) + assert.strictEqual(result.status, 0) + }) + }) }) }) }) diff --git a/packages/dd-trace/src/config/index.js b/packages/dd-trace/src/config/index.js index 73849a0dad..797d983e64 100644 --- a/packages/dd-trace/src/config/index.js +++ b/packages/dd-trace/src/config/index.js @@ -49,6 +49,8 @@ const VALID_PROPAGATION_BEHAVIOR_EXTRACT = new Set(['continue', 'restart', 'igno const VALID_LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error']) const DEFAULT_OTLP_PORT = 4318 const RUNTIME_ID = uuid() +// eslint-disable-next-line eslint-rules/eslint-process-env -- internal propagation, not user config +const ROOT_SESSION_ID = process.env.DD_ROOT_JS_SESSION_ID || RUNTIME_ID const NAMING_VERSIONS = new Set(['v0', 'v1']) const DEFAULT_NAMING_VERSION = 'v0' @@ -145,6 +147,8 @@ class Config { 'runtime-id': RUNTIME_ID, }) + this.rootSessionId = ROOT_SESSION_ID + if (this.isCiVisibility) { tagger.add(this.tags, { [ORIGIN_KEY]: 'ciapp-test', diff --git a/packages/dd-trace/src/telemetry/send-data.js b/packages/dd-trace/src/telemetry/send-data.js index 242b6ed6a9..fb7af48e64 100644 --- a/packages/dd-trace/src/telemetry/send-data.js +++ b/packages/dd-trace/src/telemetry/send-data.js @@ -91,12 +91,17 @@ let agentTelemetry = true * @returns {Record} */ function getHeaders (config, application, reqType) { + const sessionId = config.tags['runtime-id'] const headers = { 'content-type': 'application/json', 'dd-telemetry-api-version': 'v2', 'dd-telemetry-request-type': reqType, 'dd-client-library-language': application.language_name, 'dd-client-library-version': application.tracer_version, + 'dd-session-id': sessionId, + } + if (config.rootSessionId && config.rootSessionId !== sessionId) { + headers['dd-root-session-id'] = config.rootSessionId } const debug = config.telemetry && config.telemetry.debug if (debug) { diff --git a/packages/dd-trace/src/telemetry/session-propagation.js b/packages/dd-trace/src/telemetry/session-propagation.js new file mode 100644 index 0000000000..0af4968db5 --- /dev/null +++ b/packages/dd-trace/src/telemetry/session-propagation.js @@ -0,0 +1,78 @@ +'use strict' + +const dc = require('dc-polyfill') + +const childProcessChannel = dc.tracingChannel('datadog:child_process:execution') + +let subscribed = false +let rootSessionId +let runtimeId + +function injectSessionEnv (existingEnv) { + // eslint-disable-next-line eslint-rules/eslint-process-env -- not in supported-configurations.json + const base = existingEnv == null ? process.env : existingEnv + return { + ...base, + DD_ROOT_JS_SESSION_ID: rootSessionId, + DD_PARENT_JS_SESSION_ID: runtimeId, + } +} + +function findOptionsIndex (args, shell) { + if (Array.isArray(args[1])) { + return { index: 2, exists: args[2] != null && typeof args[2] === 'object' } + } + if (args[1] != null && typeof args[1] === 'object') { + return { index: 1, exists: true } + } + if (!shell && args[2] != null && typeof args[2] === 'object') { + return { index: 2, exists: true } + } + return { index: shell ? 1 : 2, exists: false } +} + +function onChildProcessStart (context) { + if (!context.callArgs) return + + const args = context.callArgs + const { index, exists } = findOptionsIndex(args, context.shell) + + if (exists) { + args[index] = { ...args[index], env: injectSessionEnv(args[index].env) } + return + } + + const opts = { env: injectSessionEnv(null) } + + if (!context.shell && !Array.isArray(args[1])) { + args.splice(1, 0, []) + } + + if (typeof args[index] === 'function') { + args.splice(index, 0, opts) + } else { + args[index] = opts + } +} + +const handler = { start: onChildProcessStart } + +function start (config) { + if (!config.telemetry?.enabled || subscribed) return + subscribed = true + + rootSessionId = config.rootSessionId + runtimeId = config.tags['runtime-id'] + + childProcessChannel.subscribe(handler) +} + +function stop () { + if (!subscribed) return + childProcessChannel.unsubscribe(handler) + subscribed = false + rootSessionId = undefined + runtimeId = undefined +} + +module.exports = { start, stop, _onChildProcessStart: onChildProcessStart } diff --git a/packages/dd-trace/src/telemetry/telemetry.js b/packages/dd-trace/src/telemetry/telemetry.js index 86202bb061..e113bb9e07 100644 --- a/packages/dd-trace/src/telemetry/telemetry.js +++ b/packages/dd-trace/src/telemetry/telemetry.js @@ -12,6 +12,7 @@ const endpoints = require('./endpoints') const { sendData } = require('./send-data') const { manager: metricsManager } = require('./metrics') const telemetryLogger = require('./logs') +const sessionPropagation = require('./session-propagation') /** * @typedef {Record} TelemetryPayloadObject @@ -370,6 +371,7 @@ function start (aConfig, thePluginManager) { dependencies.start(config, application, host, getRetryData, updateRetryData) telemetryLogger.start(config) endpoints.start(config, application, host, getRetryData, updateRetryData) + sessionPropagation.start(config) sendData(config, application, host, 'app-started', appStarted(config)) @@ -397,6 +399,7 @@ function stop () { telemetryStopChannel.publish(getTelemetryData()) endpoints.stop() + sessionPropagation.stop() config = undefined } diff --git a/packages/dd-trace/test/telemetry/send-data.spec.js b/packages/dd-trace/test/telemetry/send-data.spec.js index 5b01b89a7f..c3e7e78f46 100644 --- a/packages/dd-trace/test/telemetry/send-data.spec.js +++ b/packages/dd-trace/test/telemetry/send-data.spec.js @@ -44,6 +44,7 @@ describe('sendData', () => { 'dd-telemetry-request-type': 'req-type', 'dd-client-library-language': application.language_name, 'dd-client-library-version': application.tracer_version, + 'dd-session-id': '123', }, url: undefined, hostname: '', @@ -69,6 +70,7 @@ describe('sendData', () => { 'dd-telemetry-request-type': 'req-type', 'dd-client-library-language': application.language_name, 'dd-client-library-version': application.tracer_version, + 'dd-session-id': '123', }, url: 'unix:/foo/bar/baz', hostname: undefined, @@ -96,6 +98,7 @@ describe('sendData', () => { 'dd-telemetry-debug-enabled': 'true', 'dd-client-library-language': application.language_name, 'dd-client-library-version': application.tracer_version, + 'dd-session-id': '123', }, url: '/test', hostname: undefined, @@ -103,6 +106,34 @@ describe('sendData', () => { }) }) + it('should include dd-root-session-id header when rootSessionId differs from runtime-id', () => { + sendDataModule.sendData({ + url: '/test', + tags: { 'runtime-id': 'child-runtime-id' }, + rootSessionId: 'root-runtime-id', + }, application, 'test', 'req-type') + + sinon.assert.calledOnce(request) + const options = request.getCall(0).args[1] + + assert.strictEqual(options.headers['dd-session-id'], 'child-runtime-id') + assert.strictEqual(options.headers['dd-root-session-id'], 'root-runtime-id') + }) + + it('should not include dd-root-session-id header when rootSessionId equals runtime-id', () => { + sendDataModule.sendData({ + url: '/test', + tags: { 'runtime-id': 'same-id' }, + rootSessionId: 'same-id', + }, application, 'test', 'req-type') + + sinon.assert.calledOnce(request) + const options = request.getCall(0).args[1] + + assert.strictEqual(options.headers['dd-session-id'], 'same-id') + assert.strictEqual(options.headers['dd-root-session-id'], undefined) + }) + it('should remove not wanted properties from a payload with object type', () => { const payload = { message: 'test', @@ -133,7 +164,7 @@ describe('sendData', () => { }, retryObjData] sendDataModule.sendData({ tags: { 'runtime-id': '123' } }, - { language: 'js' }, 'test', 'message-batch', payload) / + { language: 'js' }, 'test', 'message-batch', payload) sinon.assert.calledOnce(request) diff --git a/packages/dd-trace/test/telemetry/session-propagation.spec.js b/packages/dd-trace/test/telemetry/session-propagation.spec.js new file mode 100644 index 0000000000..7a40ca4cf6 --- /dev/null +++ b/packages/dd-trace/test/telemetry/session-propagation.spec.js @@ -0,0 +1,217 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it, beforeEach, afterEach } = require('mocha') +const sinon = require('sinon') +const dc = require('dc-polyfill') + +require('../setup/core') + +describe('session-propagation', () => { + const childProcessChannel = dc.tracingChannel('datadog:child_process:execution') + let sessionPropagation + + beforeEach(() => { + // Fresh require to reset the subscribed flag + delete require.cache[require.resolve('../../src/telemetry/session-propagation')] + sessionPropagation = require('../../src/telemetry/session-propagation') + }) + + afterEach(() => { + sinon.restore() + }) + + it('should subscribe to child_process channel', () => { + sessionPropagation.start({ + telemetry: { enabled: true }, + rootSessionId: 'root-id', + tags: { 'runtime-id': 'current-id' }, + }) + + assert.ok(childProcessChannel.start.hasSubscribers) + }) + + it('should not subscribe when telemetry is disabled', () => { + const subscribeSpy = sinon.spy(childProcessChannel, 'subscribe') + + sessionPropagation.start({ + telemetry: { enabled: false }, + rootSessionId: 'root-id', + tags: { 'runtime-id': 'current-id' }, + }) + + assert.strictEqual(subscribeSpy.callCount, 0) + }) + + it('should only subscribe once', () => { + const config = { telemetry: { enabled: true }, rootSessionId: 'root-id', tags: { 'runtime-id': 'current-id' } } + sessionPropagation.start(config) + + const subscribeSpy = sinon.spy(childProcessChannel, 'subscribe') + sessionPropagation.start(config) + + assert.strictEqual(subscribeSpy.callCount, 0) + }) + + it('should unsubscribe and allow re-subscribe after stop()', () => { + sessionPropagation.start({ + telemetry: { enabled: true }, + rootSessionId: 'root-id', + tags: { 'runtime-id': 'current-id' }, + }) + + sessionPropagation.stop() + + // After stop(), start() should accept new config + sessionPropagation.start({ + telemetry: { enabled: true }, + rootSessionId: 'new-root', + tags: { 'runtime-id': 'new-id' }, + }) + + const context = { callArgs: ['node', ['test.js'], {}], shell: false } + sessionPropagation._onChildProcessStart(context) + assert.strictEqual(context.callArgs[2].env.DD_ROOT_JS_SESSION_ID, 'new-root') + assert.strictEqual(context.callArgs[2].env.DD_PARENT_JS_SESSION_ID, 'new-id') + }) + + describe('env injection via callArgs', () => { + let onChildProcessStart + + beforeEach(() => { + sessionPropagation.start({ + telemetry: { enabled: true }, + rootSessionId: 'root-id', + tags: { 'runtime-id': 'current-id' }, + }) + onChildProcessStart = sessionPropagation._onChildProcessStart + }) + + it('should inject env vars when callArgs has (file, args, options)', () => { + const context = { + callArgs: ['node', ['test.js'], { cwd: '/tmp', env: { FOO: 'bar' } }], + shell: false, + } + + onChildProcessStart(context) + + assert.strictEqual(context.callArgs[0], 'node') + assert.deepStrictEqual(context.callArgs[1], ['test.js']) + assert.strictEqual(context.callArgs[2].cwd, '/tmp') + assert.strictEqual(context.callArgs[2].env.FOO, 'bar') + assert.strictEqual(context.callArgs[2].env.DD_ROOT_JS_SESSION_ID, 'root-id') + assert.strictEqual(context.callArgs[2].env.DD_PARENT_JS_SESSION_ID, 'current-id') + }) + + it('should inject env vars when callArgs has (file, options)', () => { + const context = { + callArgs: ['node', { cwd: '/tmp' }], + shell: false, + } + + onChildProcessStart(context) + + assert.strictEqual(context.callArgs[0], 'node') + assert.strictEqual(context.callArgs[1].cwd, '/tmp') + assert.strictEqual(context.callArgs[1].env.DD_ROOT_JS_SESSION_ID, 'root-id') + assert.strictEqual(context.callArgs[1].env.DD_PARENT_JS_SESSION_ID, 'current-id') + }) + + it('should inject env vars when callArgs has (file) only for non-shell', () => { + const context = { + callArgs: ['node'], + shell: false, + } + + onChildProcessStart(context) + + assert.strictEqual(context.callArgs[0], 'node') + assert.deepStrictEqual(context.callArgs[1], []) + assert.strictEqual(context.callArgs[2].env.DD_ROOT_JS_SESSION_ID, 'root-id') + assert.strictEqual(context.callArgs[2].env.DD_PARENT_JS_SESSION_ID, 'current-id') + }) + + it('should inject env vars as options for shell commands with no options', () => { + const context = { + callArgs: ['ls -la'], + shell: true, + } + + onChildProcessStart(context) + + assert.strictEqual(context.callArgs[0], 'ls -la') + assert.strictEqual(context.callArgs[1].env.DD_ROOT_JS_SESSION_ID, 'root-id') + assert.strictEqual(context.callArgs[1].env.DD_PARENT_JS_SESSION_ID, 'current-id') + }) + + it('should use process.env as base when no env is specified', () => { + const context = { + callArgs: ['node', ['test.js'], {}], + shell: false, + } + + onChildProcessStart(context) + + const env = context.callArgs[2].env + assert.strictEqual(env.DD_ROOT_JS_SESSION_ID, 'root-id') + assert.ok(Object.keys(env).length > 2, 'env should contain process.env keys') + }) + + it('should preserve callback when callArgs has (file, args, cb)', () => { + const cb = () => {} + const context = { + callArgs: ['node', ['-v'], cb], + shell: false, + } + + onChildProcessStart(context) + + assert.strictEqual(context.callArgs[0], 'node') + assert.deepStrictEqual(context.callArgs[1], ['-v']) + assert.strictEqual(context.callArgs[2].env.DD_ROOT_JS_SESSION_ID, 'root-id') + assert.strictEqual(context.callArgs[3], cb) + }) + + it('should preserve callback when callArgs has (file, cb)', () => { + const cb = () => {} + const context = { + callArgs: ['cmd', cb], + shell: false, + } + + onChildProcessStart(context) + + assert.strictEqual(context.callArgs[0], 'cmd') + assert.deepStrictEqual(context.callArgs[1], []) + assert.strictEqual(context.callArgs[2].env.DD_ROOT_JS_SESSION_ID, 'root-id') + assert.strictEqual(context.callArgs[3], cb) + }) + + it('should merge into existing options when args is skipped with undefined', () => { + const context = { + callArgs: ['node', undefined, { cwd: '/tmp', env: { FOO: 'bar' } }], + shell: false, + } + + onChildProcessStart(context) + + assert.strictEqual(context.callArgs[2].cwd, '/tmp') + assert.strictEqual(context.callArgs[2].env.FOO, 'bar') + assert.strictEqual(context.callArgs[2].env.DD_ROOT_JS_SESSION_ID, 'root-id') + assert.strictEqual(context.callArgs[2].env.DD_PARENT_JS_SESSION_ID, 'current-id') + }) + + it('should not modify context without callArgs', () => { + const context = { + command: 'node test.js', + file: 'node', + shell: false, + } + + onChildProcessStart(context) + + assert.strictEqual(context.callArgs, undefined) + }) + }) +})