dd-trace
Version:
Datadog APM tracing client for JavaScript
260 lines (226 loc) • 8.19 kB
JavaScript
const { readFile } = require('fs')
const { types } = require('util')
const { join } = require('path')
const { Worker, MessageChannel, threadId: parentThreadId } = require('worker_threads')
const log = require('../log')
const { fetchAgentInfo } = require('../agent/info')
const { getAgentUrl } = require('../agent/url')
const getDebuggerConfig = require('./config')
const { DEBUGGER_DIAGNOSTICS_V1, DEBUGGER_INPUT_V2 } = require('./constants')
/**
* @typedef {ReturnType<import('../config')>} Config
*/
/**
* @typedef {import('../remote_config')} RemoteConfig
*/
let worker = null
let configChannel = null
let ackId = 0
let rcAckCallbacks = null
let rc = null
let inputPath = null
// eslint-disable-next-line eslint-rules/eslint-process-env
const { NODE_OPTIONS, ...env } = process.env
module.exports = {
isStarted,
start,
configure,
stop,
}
/**
* Check if the Debugger worker is currently running
*
* @returns {boolean} True if the worker is started, false otherwise
*/
function isStarted () {
return worker !== null
}
/**
* Start the Debugger worker thread.
* Creates a worker thread, sets up message channels, and registers
* the LIVE_DEBUGGING product handler with remote config.
* Does nothing if the worker is already started.
*
* @param {Config} config - The tracer configuration object
* @param {RemoteConfig} rcInstance - The RemoteConfig instance
*/
function start (config, rcInstance) {
if (worker !== null) return
log.debug('[debugger] Starting Dynamic Instrumentation client...')
rc = rcInstance
rcAckCallbacks = new Map()
const probeChannel = new MessageChannel()
const logChannel = new MessageChannel()
configChannel = new MessageChannel()
globalThis[Symbol.for('dd-trace')].utilTypes = types
readProbeFile(config.dynamicInstrumentation.probeFile, (probes) => {
const action = 'apply'
for (const probe of probes) {
probeChannel.port2.postMessage({ action, probe })
}
})
rc.setProductHandler('LIVE_DEBUGGING', (action, probe, id, ack) => {
rcAckCallbacks.set(++ackId, ack)
probeChannel.port2.postMessage({ action, probe, ackId })
})
probeChannel.port2.on('message', ({ ackId, error }) => {
const ack = rcAckCallbacks.get(ackId)
if (ack === undefined) {
// This should never happen, but just in case something changes in the future, we should guard against it
log.error('[debugger] Received an unknown ackId: %s', ackId)
if (error) log.error('[debugger] Error starting Dynamic Instrumentation client', error)
return
}
ack(error)
rcAckCallbacks.delete(ackId)
})
probeChannel.port2.on('messageerror', (err) => log.error('[debugger] received "messageerror" on probe port', err))
logChannel.port2.on('message', ({ level, args }) => {
log[level](...args)
})
logChannel.port2.on('messageerror', (err) => log.error('[debugger] received "messageerror" on log port', err))
detectDebuggerEndpoint(config, (_inputPath) => {
inputPath = _inputPath
worker = new Worker(
join(__dirname, 'devtools_client', 'index.js'),
{
name: 'dd-debugger',
execArgv: [], // Avoid worker thread inheriting the `-r` command line argument
env, // Avoid worker thread inheriting the `NODE_OPTIONS` environment variable (in case it contains `-r`)
workerData: {
config: getDebuggerConfig(config, inputPath),
parentThreadId,
probePort: probeChannel.port1,
logPort: logChannel.port1,
configPort: configChannel.port1,
},
transferList: [probeChannel.port1, logChannel.port1, configChannel.port1],
}
)
worker.on('online', () => {
log.debug(
'[debugger] Dynamic Instrumentation worker thread started successfully (thread id: %d)',
worker.threadId
)
})
worker.on('error', (err) => log.error('[debugger] worker thread error', err))
worker.on('messageerror', (err) => log.error('[debugger] received "messageerror" from worker', err))
worker.once('exit', (code) => {
const error = new Error(`Dynamic Instrumentation worker thread exited unexpectedly with code ${code}`)
log.error('[debugger] worker thread exited unexpectedly', error)
cleanup(error) // Be nice, clean up now that the worker thread encountered an issue and we can't continue
})
worker.unref?.()
probeChannel.port1.unref?.()
probeChannel.port2.unref?.()
logChannel.port1.unref?.()
logChannel.port2.unref?.()
configChannel.port1.unref?.()
configChannel.port2.unref?.()
})
}
/**
* Reconfigure the Debugger worker with updated settings.
* Sends the new configuration to the worker thread via the config channel.
* Does nothing if the worker is not started.
*
* @param {import('../config/config-base')} config - The updated tracer configuration object
*/
function configure (config) {
if (configChannel === null) return
configChannel.port2.postMessage(getDebuggerConfig(config, inputPath))
}
/**
* Stop the Debugger worker thread.
* Terminates the worker and cleans up resources.
* Safe to call even if the worker is not started.
*/
function stop () {
if (worker === null) return
log.debug('[debugger] Stopping Dynamic Instrumentation client...')
try {
worker.terminate()
cleanup() // Graceful shutdown - termination succeeded
} catch (err) {
log.error('[debugger] Error terminating worker', err)
cleanup(err) // Cleanup with error - termination failed
}
}
/**
* Internal cleanup function to reset all debugger resources.
* Called when stopping the debugger or when the worker exits unexpectedly.
*
* @param {Error} [error] - Optional error to pass to pending ack callbacks (for unexpected exits)
*/
function cleanup (error) {
if (rc) {
rc.removeProductHandler('LIVE_DEBUGGING')
rc = null
}
if (worker) {
worker.removeAllListeners()
worker = null
}
configChannel = null
inputPath = null
// Call any pending ack callbacks
// Pass error for unexpected exits, or undefined for graceful shutdown
if (rcAckCallbacks) {
for (const ackId of rcAckCallbacks.keys()) {
rcAckCallbacks.get(ackId)(error)
rcAckCallbacks.delete(ackId)
}
rcAckCallbacks = null
}
}
/**
* Detect which debugger endpoint is available on the agent
*
* @param {Config} config - The tracer configuration object
* @param {(endpointPath: string) => void} cb - Callback with the detected endpoint path
*/
function detectDebuggerEndpoint (config, cb) {
log.debug('[debugger] Detecting available debugger endpoints...')
fetchAgentInfo(getAgentUrl(config), (err, agentInfo) => {
if (err) {
log.warn('[debugger] Failed to query agent %s endpoint, falling back to %s',
DEBUGGER_INPUT_V2,
DEBUGGER_DIAGNOSTICS_V1,
err)
return cb(DEBUGGER_DIAGNOSTICS_V1)
}
const endpoints = agentInfo.endpoints || []
if (endpoints.includes(DEBUGGER_INPUT_V2)) {
log.debug('[debugger] Agent supports %s', DEBUGGER_INPUT_V2)
return cb(DEBUGGER_INPUT_V2)
}
log.debug('[debugger] Agent does not support %s, using %s', DEBUGGER_INPUT_V2, DEBUGGER_DIAGNOSTICS_V1)
return cb(DEBUGGER_DIAGNOSTICS_V1)
})
}
/**
* Read and parse a probe configuration file.
* Reads the file from disk, parses it as JSON, and invokes the callback with the parsed probes.
* Does nothing if no path is provided. Errors are logged but do not invoke the callback.
*
* @param {string | undefined} path - Path to the probe configuration file
* @param {(probes: unknown[]) => void} cb - Callback invoked with the parsed probe array
*/
function readProbeFile (path, cb) {
if (!path) return
log.debug('[debugger] Reading probe file: %s', path)
readFile(path, 'utf8', (err, data) => {
if (err) {
log.error('[debugger] Failed to read probe file: %s', path, err)
return
}
try {
const parsedData = JSON.parse(data)
log.debug('[debugger] Successfully parsed probe file: %s', path)
cb(parsedData)
} catch (err) {
log.error('[debugger] Probe file (%s) is not valid JSON', path, err)
}
})
}