diff --git a/packages/dd-trace/src/config.js b/packages/dd-trace/src/config.js index d8df053e163..9ad701e9faf 100644 --- a/packages/dd-trace/src/config.js +++ b/packages/dd-trace/src/config.js @@ -3,7 +3,7 @@ const fs = require('fs') const os = require('os') const uuid = require('crypto-randomuuid') // we need to keep the old uuid dep because of cypress -const URL = require('url').URL +const { URL } = require('url') const log = require('./log') const pkg = require('./pkg') const coalesce = require('koalas') @@ -498,6 +498,7 @@ class Config { this._setValue(defaults, 'dogstatsd.hostname', '127.0.0.1') this._setValue(defaults, 'dogstatsd.port', '8125') this._setValue(defaults, 'dsmEnabled', false) + this._setValue(defaults, 'dynamicInstrumentationEnabled', false) this._setValue(defaults, 'env', undefined) this._setValue(defaults, 'experimental.enableGetRumData', false) this._setValue(defaults, 'experimental.exporter', undefined) @@ -589,6 +590,7 @@ class Config { DD_DBM_PROPAGATION_MODE, DD_DOGSTATSD_HOSTNAME, DD_DOGSTATSD_PORT, + DD_DYNAMIC_INSTRUMENTATION_ENABLED, DD_ENV, DD_EXPERIMENTAL_APPSEC_STANDALONE_ENABLED, DD_EXPERIMENTAL_PROFILING_ENABLED, @@ -696,6 +698,7 @@ class Config { this._setString(env, 'dogstatsd.hostname', DD_DOGSTATSD_HOSTNAME) this._setString(env, 'dogstatsd.port', DD_DOGSTATSD_PORT) this._setBoolean(env, 'dsmEnabled', DD_DATA_STREAMS_ENABLED) + this._setBoolean(env, 'dynamicInstrumentationEnabled', DD_DYNAMIC_INSTRUMENTATION_ENABLED) this._setString(env, 'env', DD_ENV || tags.env) this._setBoolean(env, 'experimental.enableGetRumData', DD_TRACE_EXPERIMENTAL_GET_RUM_DATA_ENABLED) this._setString(env, 'experimental.exporter', DD_TRACE_EXPERIMENTAL_EXPORTER) @@ -837,6 +840,7 @@ class Config { this._setString(opts, 'dogstatsd.port', options.dogstatsd.port) } this._setBoolean(opts, 'dsmEnabled', options.dsmEnabled) + this._setBoolean(opts, 'dynamicInstrumentationEnabled', options.dynamicInstrumentationEnabled) this._setString(opts, 'env', options.env || tags.env) this._setBoolean(opts, 'experimental.enableGetRumData', options.experimental && options.experimental.enableGetRumData) diff --git a/packages/dd-trace/src/debugger/devtools_client/config.js b/packages/dd-trace/src/debugger/devtools_client/config.js new file mode 100644 index 00000000000..16cde3fe1b2 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/config.js @@ -0,0 +1,26 @@ +'use strict' + +const { workerData: { config: parentConfig, configPort } } = require('node:worker_threads') +const { URL, format } = require('node:url') + +const config = module.exports = { + runtimeId: parentConfig.tags['runtime-id'], + service: parentConfig.service +} + +updateUrl(parentConfig) + +configPort.on('message', updateUrl) + +function updateUrl (updates) { + // if (!updates.url && !updates.hostname && !updates.port) return + + const url = updates.url || new URL(format({ + // TODO: Can this ever be anything other than `http:`, and if so, how do we get the configured value? + protocol: config.url?.protocol || 'http:', + hostname: updates.hostname || config.url?.hostname || 'localhost', + port: updates.port || config.url?.port + })) + + config.url = url.toString() +} diff --git a/packages/dd-trace/src/debugger/devtools_client/index.js b/packages/dd-trace/src/debugger/devtools_client/index.js new file mode 100644 index 00000000000..69337718ce3 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/index.js @@ -0,0 +1,96 @@ +'use strict' + +const uuid = require('crypto-randomuuid') +const { breakpoints } = require('./state') +const session = require('./session') +const { getLocalStateForBreakpoint } = require('./snapshot') +const send = require('./send') +const { ackEmitting, ackError } = require('./status') +require('./remote_config') +const log = require('../../log') + +// The `session.connectToMainThread()` method called above doesn't "register" any active handles, so the worker thread +// will exit with code 0 once when reaches the end of the file unless we do something to keep it alive: +setInterval(() => {}, 1000 * 60) + +session.on('Debugger.paused', async ({ params }) => { + const start = process.hrtime.bigint() + const timestamp = Date.now() + + let captureSnapshotForProbe = null + const probes = params.hitBreakpoints.map((id) => { + const probe = breakpoints.get(id) + if (captureSnapshotForProbe === null && probe.captureSnapshot) captureSnapshotForProbe = probe + return probe + }) + + let state + if (captureSnapshotForProbe !== null) { + try { + state = await getLocalStateForBreakpoint(params) + } catch (err) { + ackError(err, captureSnapshotForProbe) // TODO: Ok to continue after sending ackError? + } + } + + await session.post('Debugger.resume') + const diff = process.hrtime.bigint() - start // TODO: Should we log this using some sort of telemetry? + + log.debug(`Finished processing breakpoints - thread paused for: ${Number(diff) / 1000000} ms`) + + // TODO: Is this the correct way of handling multiple breakpoints hit at the same time? + for (const probe of probes) { + const captures = probe.captureSnapshot && state + ? { + lines: { + [probe.location.lines[0]]: state + } + } + : undefined + + await send({ + message: probe.template, // TODO: Process template + snapshot: { + id: uuid(), + timestamp, + captures, + probe: { + id: probe.id, + version: probe.version, // TODO: Should this always be 2??? + location: probe.location + }, + language: 'javascript' + } + }) + + ackEmitting(probe) + + // TODO: Remove before shipping + process._rawDebug( + '\nLocal state:\n' + + '--------------------------------------------------\n' + + stateToString(state) + + '--------------------------------------------------\n' + + '\nStats:\n' + + '--------------------------------------------------\n' + + ` Total state JSON size: ${JSON.stringify(state).length} bytes\n` + + `Processed was paused for: ${Number(diff) / 1000000} ms\n` + + '--------------------------------------------------\n' + ) + } +}) + +// TODO: Remove this function before shipping +function stateToString (state) { + if (state === undefined) return '' + let str = '' + for (const [name, value] of Object.entries(state)) { + str += `${name}: ${color(value)}\n` + } + return str +} + +// TODO: Remove this function before shipping +function color (obj) { + return require('node:util').inspect(obj, { depth: null, colors: true }) +} diff --git a/packages/dd-trace/src/debugger/devtools_client/inspector_promises_polyfill.js b/packages/dd-trace/src/debugger/devtools_client/inspector_promises_polyfill.js new file mode 100644 index 00000000000..bb4b0340be6 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/inspector_promises_polyfill.js @@ -0,0 +1,23 @@ +'use strict' + +const { builtinModules } = require('node:module') + +if (builtinModules.includes('inspector/promises')) { + module.exports = require('node:inspector/promises') +} else { + const inspector = require('node:inspector') + const { promisify } = require('node:util') + + // The rest of the code in this file is lifted from: + // https://github.com/nodejs/node/blob/1d4d76ff3fb08f9a0c55a1d5530b46c4d5d550c7/lib/inspector/promises.js + class Session extends inspector.Session { + constructor () { super() } // eslint-disable-line no-useless-constructor + } + + Session.prototype.post = promisify(inspector.Session.prototype.post) + + module.exports = { + ...inspector, + Session + } +} diff --git a/packages/dd-trace/src/debugger/devtools_client/remote_config.js b/packages/dd-trace/src/debugger/devtools_client/remote_config.js new file mode 100644 index 00000000000..47c82f09725 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/remote_config.js @@ -0,0 +1,119 @@ +'use strict' + +const { workerData: { rcPort } } = require('node:worker_threads') +const { scripts, probes, breakpoints } = require('./state') +const session = require('./session') +const { ackReceived, ackInstalled, ackError } = require('./status') +const log = require('../../log') + +let sessionStarted = false + +// Example `probe`: +// { +// id: '100c9a5c-45ad-49dc-818b-c570d31e11d1', +// version: 0, +// type: 'LOG_PROBE', +// language: 'javascript', // ignore +// where: { +// sourceFile: 'index.js', +// lines: ['25'] // only use first array element +// }, +// tags: [], // not used +// template: 'Hello World 2', +// segments: [{ str: 'Hello World 2' }], +// captureSnapshot: true, +// capture: { maxReferenceDepth: 1 }, +// sampling: { snapshotsPerSecond: 1 }, +// evaluateAt: 'EXIT' // only used for method probes +// } +rcPort.on('message', async ({ action, conf: probe }) => { + try { + await processMsg(action, probe) + } catch (err) { + ackError(err, probe) + } +}) + +async function start () { + sessionStarted = true + await session.post('Debugger.enable') +} + +async function stop () { + sessionStarted = false + await session.post('Debugger.disable') +} + +async function processMsg (action, probe) { + log.debug(`Received request to ${action} ${probe.type} probe (id: ${probe.id}, version: ${probe.version})`) + + ackReceived(probe) + + if (probe.type !== 'LOG_PROBE') { + throw new Error(`Unsupported probe type: ${probe.type} (id: ${probe.id}, version: ${probe.version})`) + // TODO: Throw also if method log probe + } + + switch (action) { + case 'unapply': + await removeBreakpoint(probe) + break + case 'apply': + await addBreakpoint(probe) + break + case 'modify': + // TODO: Can we modify in place? + await removeBreakpoint(probe) + await addBreakpoint(probe) + break + default: + throw new Error( + `Cannot process probe ${probe.id} (version: ${probe.version}) - unknown remote configuration action: ${action}` + ) + } +} + +async function addBreakpoint (probe) { + if (!sessionStarted) await start() + + // Optimize for sending data to /debugger/v1/input endpoint + probe.location = { + file: probe.where.sourceFile, + lines: [Number(probe.where.lines[0])] // Tracer doesn't support multiple-line breakpoints + } + delete probe.where + + // TODO: Figure out what to do about the full path + const path = `file:///Users/thomas.watson/go/src/github.com/DataDog/debugger-demos/nodejs/${probe.location.file}` + + const { breakpointId } = await session.post('Debugger.setBreakpoint', { + location: { + scriptId: scripts.get(path), + lineNumber: probe.location.lines[0] - 1 // Beware! lineNumber is zero-indexed + } + // TODO: Support conditions + // condition: "request.params.name === 'break'" + }) + + probes.set(probe.id, breakpointId) + breakpoints.set(breakpointId, probe) + + ackInstalled(probe) +} + +async function removeBreakpoint ({ id }) { + if (!sessionStarted) { + // We should not get in this state, but abort if we do so the code doesn't throw + throw Error(`Cannot remove probe ${id}: Debugger not started`) + } + if (!probes.has(id)) { + throw Error(`Unknown probe id: ${id}`) + } + + const breakpointId = probes.get(id) + await session.post('Debugger.removeBreakpoint', { breakpointId }) + probes.delete(id) + breakpoints.delete(breakpointId) + + if (breakpoints.size === 0) await stop() +} diff --git a/packages/dd-trace/src/debugger/devtools_client/send.js b/packages/dd-trace/src/debugger/devtools_client/send.js new file mode 100644 index 00000000000..d6346136b74 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/send.js @@ -0,0 +1,60 @@ +'use strict' + +const { hostname } = require('node:os') +const { inspect } = require('node:util') +const config = require('./config') +const request = require('../../exporters/common/request') + +const host = hostname() +const service = config.service +const ddsource = 'dd_debugger' + +// TODO: Figure out correct logger values +const logger = { + name: __filename, + method: '', + thread_name: `${process.argv0};pid:${process.pid}`, + thread_id: 42, + version: 2 +} + +module.exports = async function send ({ message, snapshot: { id, timestamp, captures, probe, language } }) { + const opts = { + method: 'POST', + url: config.url, + path: '/debugger/v1/input', + headers: { + 'Content-Type': 'application/json; charset=utf-8' + // Accept: 'text/plain' // TODO: This seems wrong (from Python tracer) + } + } + + const payload = { + service, + ddsource, + message, + logger, + // 'dd.trace_id': null, + // 'dd.span_id': null, + // method, + 'debugger.snapshot': { + id, + timestamp, + captures, + // evaluationErrors: [{ expr: 'foo == 42', message: 'foo' }], // TODO: This doesn't seem to be documented? + probe, + language + }, + host, // TODO: This doesn't seem to be documented? + timestamp: Date.now() // TODO: This doesn't seem to be documented? + // ddtags: {} // TODO: This doesn't seem to be documented? + } + + process._rawDebug('Payload:', inspect(payload, { depth: null, colors: true })) // TODO: Remove + + request(JSON.stringify(payload), opts, (err, data, statusCode) => { + if (err) throw err // TODO: Handle error + process._rawDebug('Response:', { statusCode }) // TODO: Remove + process._rawDebug('Response body:', JSON.parse(data)) // TODO: Remove + }) +} diff --git a/packages/dd-trace/src/debugger/devtools_client/session.js b/packages/dd-trace/src/debugger/devtools_client/session.js new file mode 100644 index 00000000000..3cda2322b36 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/session.js @@ -0,0 +1,7 @@ +'use strict' + +const inspector = require('./inspector_promises_polyfill') + +const session = module.exports = new inspector.Session() + +session.connectToMainThread() diff --git a/packages/dd-trace/src/debugger/devtools_client/snapshot.js b/packages/dd-trace/src/debugger/devtools_client/snapshot.js new file mode 100644 index 00000000000..4912bbc6074 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/snapshot.js @@ -0,0 +1,154 @@ +'use strict' + +const { breakpoints } = require('./state') +const session = require('./session') + +module.exports = { + getLocalStateForBreakpoint +} + +async function getLocalStateForBreakpoint (params) { + const scope = params.callFrames[0].scopeChain[0] // TODO: Should we only ever look at the top? + const conf = breakpoints.get(params.hitBreakpoints[0]) // TODO: Handle multiple breakpoints + return toObject(await getObjectWithChildren(scope.object.objectId, conf)).fields +} + +async function getObjectWithChildren (objectId, conf, depth = 0) { + const { result } = (await session.post('Runtime.getProperties', { + objectId, + ownProperties: true // exclude inherited properties + // TODO: Remove the following commented out lines before shipping + // accessorPropertiesOnly: true, // true: only return get/set accessor properties + // generatePreview: true, // true: generate `value.preview` object with details (including content) of maps and sets + // nonIndexedPropertiesOnly: true // true: do not get array elements + })) + + // TODO: Deside if we should filter out enumerable properties or not: + // result = result.filter((e) => e.enumerable) + + if (depth < conf.capture.maxReferenceDepth) { + for (const entry of result) { + if (entry?.value?.type === 'object' && entry?.value?.objectId) { + entry.value.properties = await getObjectWithChildren(entry.value.objectId, conf, depth + 1) + } + } + } + + return result +} + +function toObject (state) { + if (state === undefined) { + return { + type: 'object', + notCapturedReason: 'depth' + } + } + + const result = { + type: 'object', + fields: {} + } + + for (const prop of state) { + result.fields[prop.name] = getPropVal(prop) + } + + return result +} + +function toArray (state) { + if (state === undefined) { + return { + type: 'array', // TODO: Should this be 'object' as typeof x === 'object'? + notCapturedReason: 'depth' + } + } + + const result = { + type: 'array', // TODO: Should this be 'object' as typeof x === 'object'? + elements: [] + } + + for (const elm of state) { + if (elm.enumerable === false) continue // the value of the `length` property should not be part of the array + result.elements.push(getPropVal(elm)) + } + + return result +} + +function getPropVal (prop) { + const value = prop.value ?? prop.get + switch (value.type) { + case 'undefined': + return { + type: 'undefined', + value: undefined // TODO: We can't send undefined values over JSON + } + case 'boolean': + return { + type: 'boolean', + value: value.value + } + case 'string': + return { + type: 'string', + value: value.value + } + case 'number': + return { + type: 'number', + value: value.value + } + case 'bigint': + return { + type: 'bigint', + value: value.description + } + case 'symbol': + return { + type: 'symbol', + value: value.description // TODO: Should we really send this as a string? + } + case 'function': + return { + type: value.description.startsWith('class ') ? 'class' : 'function' + } + case 'object': + return getObjVal(value) + default: + throw new Error(`Unknown type "${value.type}": ${JSON.stringify(prop)}`) + } +} + +function getObjVal (obj) { + switch (obj.subtype) { + case undefined: + return toObject(obj.properties) + case 'array': + return toArray(obj.properties) + case 'null': + return { + type: 'null', // TODO: Should this be 'object' as typeof x === 'null'? + isNull: true + } + case 'set': + return { + type: 'set', // TODO: Should this be 'object' as typeof x === 'object'? + value: obj.description // TODO: Should include Set content in 'elements' + } + case 'map': + return { + type: 'map', // TODO: Should this be 'object' as typeof x === 'object'? + value: obj.description // TODO: Should include Map content 'entries' + } + case 'regexp': + return { + type: 'regexp', // TODO: Should this be 'object' as typeof x === 'object'? + value: obj.description // TODO: This doesn't seem right + } + default: + throw new Error(`Unknown subtype "${obj.subtype}": ${JSON.stringify(obj)}`) + } +} diff --git a/packages/dd-trace/src/debugger/devtools_client/state.js b/packages/dd-trace/src/debugger/devtools_client/state.js new file mode 100644 index 00000000000..0caf9aa8ab6 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/state.js @@ -0,0 +1,24 @@ +'use strict' + +const session = require('./session') + +const scripts = new Map() + +module.exports = { + scripts, + probes: new Map(), + breakpoints: new Map() +} + +// Known params.url protocols: +// - `node:` - Ignored, as we don't want to instrument Node.js internals +// - `wasm:` - Ignored, as we don't support instrumenting WebAssembly +// - `file:` - Regular on disk file +// Unknown params.url values: +// - `structured-stack` - Not sure what this is, but should just be ignored +// - `` - Not sure what this is, but should just be ignored +session.on('Debugger.scriptParsed', ({ params }) => { + if (params.url.startsWith('file:')) { + scripts.set(params.url, params.scriptId) + } +}) diff --git a/packages/dd-trace/src/debugger/devtools_client/status.js b/packages/dd-trace/src/debugger/devtools_client/status.js new file mode 100644 index 00000000000..ed41cddfa97 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/status.js @@ -0,0 +1,89 @@ +'use strict' + +const { inspect } = require('node:util') +const config = require('./config') +const request = require('../../exporters/common/request') +const FormData = require('../../exporters/common/form-data') +const log = require('../../log') + +module.exports = { + ackReceived, + ackInstalled, + ackEmitting, + ackError +} + +const service = config.service +const runtimeId = config.runtimeId + +const STATUSES = { + RECEIVED: 'RECEIVED', + INSTALLED: 'INSTALLED', + EMITTING: 'EMITTING', + WARNING: 'WARNING', + ERROR: 'ERROR', + BLOCKED: 'BLOCKED' +} + +function ackReceived ({ id: probeId, version }) { + send(statusPayload(probeId, version, STATUSES.RECEIVED)) +} + +function ackInstalled ({ id: probeId, version }) { + send(statusPayload(probeId, version, STATUSES.INSTALLED)) +} + +function ackEmitting ({ id: probeId, version }) { + send(statusPayload(probeId, version, STATUSES.EMITTING)) +} + +function ackError (err, { id: probeId, version }) { + log.error(err) // TODO: Is there a standard format og loggering errors from the tracer? + + const payload = statusPayload(probeId, version, STATUSES.ERROR) + + payload.debugger.diagnostics.exception = { + type: err.code, + message: err.message, + stacktrace: err.stack + } + + send(payload) +} + +function send (payload) { + process._rawDebug('Diagnostics request event.json:', inspect(payload, { depth: null, colors: true })) // TODO: Remove + + const form = new FormData() + + form.append( + 'event', + JSON.stringify(payload), + { filename: 'event.json', contentType: 'application/json; charset=utf-8' } + ) + + const options = { + method: 'POST', + url: config.url, + path: '/debugger/v1/diagnostics', + headers: form.getHeaders() + } + + process._rawDebug('Diagnostics request options:', options) // TODO: Remove + + request(form, options, (err, data, statusCode) => { + if (err) throw err // TODO: Handle error + process._rawDebug('Response:', { statusCode }) // TODO: Remove + process._rawDebug('Response body:', JSON.parse(data)) // TODO: Remove + }) +} + +function statusPayload (probeId, version, status) { + return { + ddsource: 'dd_debugger', + service, + debugger: { + diagnostics: { probeId, runtimeId, version, status } + } + } +} diff --git a/packages/dd-trace/src/debugger/index.js b/packages/dd-trace/src/debugger/index.js new file mode 100644 index 00000000000..be06ffde070 --- /dev/null +++ b/packages/dd-trace/src/debugger/index.js @@ -0,0 +1,62 @@ +'use strict' + +const { join } = require('node:path') +const { Worker, MessageChannel } = require('node:worker_threads') +const log = require('../log') + +let worker = null +let configChannel = null + +module.exports = { + start, + configure +} + +function start (config, rc) { + if (worker !== null) return + + log.debug('Starting Dynamic Instrumentation client...') + + rc.on('LIVE_DEBUGGING', (action, conf) => { + rcChannel.port2.postMessage({ action, conf }) + }) + + rc.on('LIVE_DEBUGGING_SYMBOL_DB', (action, conf) => { + // TODO: Implement + process._rawDebug('-- RC: LIVE_DEBUGGING_SYMBOL_DB', action, conf) + }) + + const rcChannel = new MessageChannel() + configChannel = new MessageChannel() + + worker = new Worker( + join(__dirname, 'devtools_client', 'index.js'), + { + execArgv: [], // Avoid worker thread inheriting the `-r` command line argument + workerData: { config, rcPort: rcChannel.port1, configPort: configChannel.port1 }, + transferList: [rcChannel.port1, configChannel.port1] + } + ) + + worker.unref() + + worker.on('online', () => { + log.debug(`Dynamic Instrumentation worker thread started successfully (thread id: ${worker.threadId})`) + }) + + // TODO: How should we handle errors? + worker.on('error', (err) => process._rawDebug('DevTools client error:', err)) + + // TODO: How should we handle exits? + worker.on('exit', (code) => { + log.debug(`Dynamic Instrumentation worker thread exited with code ${code}`) + if (code !== 0) { + throw new Error(`DevTools client stopped with unexpected exit code: ${code}`) + } + }) +} + +function configure (config) { + if (configChannel === null) return + configChannel.port2.postMessage(config) +} diff --git a/packages/dd-trace/src/proxy.js b/packages/dd-trace/src/proxy.js index 7f3a0e81780..5335f91555b 100644 --- a/packages/dd-trace/src/proxy.js +++ b/packages/dd-trace/src/proxy.js @@ -5,6 +5,7 @@ const Config = require('./config') const runtimeMetrics = require('./runtime_metrics') const log = require('./log') const { setStartupLogPluginManager } = require('./startup-log') +const DynamicInstrumentation = require('./debugger') const telemetry = require('./telemetry') const nomenclature = require('./service-naming') const PluginManager = require('./plugin_manager') @@ -111,6 +112,10 @@ class Tracer extends NoopProxy { this._flare.enable(config) this._flare.module.send(conf.args) }) + + if (config.dynamicInstrumentationEnabled) { + DynamicInstrumentation.start(config, rc) + } } if (config.isGCPFunction || config.isAzureFunction) { @@ -195,6 +200,7 @@ class Tracer extends NoopProxy { if (this._tracingInitialized) { this._tracer.configure(config) this._pluginManager.configure(config) + DynamicInstrumentation.configure(config) setStartupLogPluginManager(this._pluginManager) } } diff --git a/packages/dd-trace/test/config.spec.js b/packages/dd-trace/test/config.spec.js index bf639804f23..8ee68eaf827 100644 --- a/packages/dd-trace/test/config.spec.js +++ b/packages/dd-trace/test/config.spec.js @@ -219,6 +219,7 @@ describe('Config', () => { expect(config).to.have.property('reportHostname', false) expect(config).to.have.property('scope', undefined) expect(config).to.have.property('logLevel', 'debug') + expect(config).to.have.property('dynamicInstrumentationEnabled', false) expect(config).to.have.property('traceId128BitGenerationEnabled', true) expect(config).to.have.property('traceId128BitLoggingEnabled', false) expect(config).to.have.property('spanAttributeSchema', 'v0') @@ -294,6 +295,7 @@ describe('Config', () => { { name: 'dogstatsd.hostname', value: '127.0.0.1', origin: 'calculated' }, { name: 'dogstatsd.port', value: '8125', origin: 'default' }, { name: 'dsmEnabled', value: false, origin: 'default' }, + { name: 'dynamicInstrumentationEnabled', value: false, origin: 'default' }, { name: 'env', value: undefined, origin: 'default' }, { name: 'experimental.enableGetRumData', value: false, origin: 'default' }, { name: 'experimental.exporter', value: undefined, origin: 'default' }, @@ -422,6 +424,7 @@ describe('Config', () => { process.env.DD_TRACE_CLIENT_IP_HEADER = 'x-true-client-ip' process.env.DD_RUNTIME_METRICS_ENABLED = 'true' process.env.DD_TRACE_REPORT_HOSTNAME = 'true' + process.env.DD_DYNAMIC_INSTRUMENTATION_ENABLED = 'true' process.env.DD_ENV = 'test' process.env.DD_TRACE_GLOBAL_TAGS = 'foo:bar,baz:qux' process.env.DD_TRACE_SAMPLE_RATE = '0.5' @@ -504,6 +507,7 @@ describe('Config', () => { expect(config).to.have.property('clientIpHeader', 'x-true-client-ip') expect(config).to.have.property('runtimeMetrics', true) expect(config).to.have.property('reportHostname', true) + expect(config).to.have.property('dynamicInstrumentationEnabled', true) expect(config).to.have.property('env', 'test') expect(config).to.have.property('sampleRate', 0.5) expect(config).to.have.property('traceId128BitGenerationEnabled', true) @@ -600,6 +604,7 @@ describe('Config', () => { { name: 'clientIpHeader', value: 'x-true-client-ip', origin: 'env_var' }, { name: 'dogstatsd.hostname', value: 'dsd-agent', origin: 'env_var' }, { name: 'dogstatsd.port', value: '5218', origin: 'env_var' }, + { name: 'dynamicInstrumentationEnabled', value: true, origin: 'env_var' }, { name: 'env', value: 'test', origin: 'env_var' }, { name: 'experimental.enableGetRumData', value: true, origin: 'env_var' }, { name: 'experimental.exporter', value: 'log', origin: 'env_var' }, @@ -725,6 +730,7 @@ describe('Config', () => { }, service: 'service', version: '0.1.0', + dynamicInstrumentationEnabled: true, env: 'test', clientIpEnabled: true, clientIpHeader: 'x-true-client-ip', @@ -798,6 +804,7 @@ describe('Config', () => { expect(config).to.have.nested.property('dogstatsd.port', '5218') expect(config).to.have.property('service', 'service') expect(config).to.have.property('version', '0.1.0') + expect(config).to.have.property('dynamicInstrumentationEnabled', true) expect(config).to.have.property('env', 'test') expect(config).to.have.property('sampleRate', 0.5) expect(config).to.have.property('logger', logger) @@ -869,6 +876,7 @@ describe('Config', () => { { name: 'clientIpHeader', value: 'x-true-client-ip', origin: 'code' }, { name: 'dogstatsd.hostname', value: 'agent-dsd', origin: 'code' }, { name: 'dogstatsd.port', value: '5218', origin: 'code' }, + { name: 'dynamicInstrumentationEnabled', value: true, origin: 'code' }, { name: 'env', value: 'test', origin: 'code' }, { name: 'experimental.enableGetRumData', value: true, origin: 'code' }, { name: 'experimental.exporter', value: 'log', origin: 'code' }, @@ -1039,6 +1047,7 @@ describe('Config', () => { process.env.DD_VERSION = '0.0.0' process.env.DD_RUNTIME_METRICS_ENABLED = 'true' process.env.DD_TRACE_REPORT_HOSTNAME = 'true' + process.env.DD_DYNAMIC_INSTRUMENTATION_ENABLED = 'true' process.env.DD_ENV = 'test' process.env.DD_API_KEY = '123' process.env.DD_TRACE_SPAN_ATTRIBUTE_SCHEMA = 'v0' @@ -1092,6 +1101,7 @@ describe('Config', () => { flushMinSpans: 500, service: 'test', version: '1.0.0', + dynamicInstrumentationEnabled: false, env: 'development', clientIpEnabled: true, clientIpHeader: 'x-true-client-ip', @@ -1168,6 +1178,7 @@ describe('Config', () => { expect(config).to.have.property('flushMinSpans', 500) expect(config).to.have.property('service', 'test') expect(config).to.have.property('version', '1.0.0') + expect(config).to.have.property('dynamicInstrumentationEnabled', false) expect(config).to.have.property('env', 'development') expect(config).to.have.property('clientIpEnabled', true) expect(config).to.have.property('clientIpHeader', 'x-true-client-ip') diff --git a/packages/dd-trace/test/debugger/devtools_client/_inspected_file.js b/packages/dd-trace/test/debugger/devtools_client/_inspected_file.js new file mode 100644 index 00000000000..97210686ab1 --- /dev/null +++ b/packages/dd-trace/test/debugger/devtools_client/_inspected_file.js @@ -0,0 +1,62 @@ +'use strict' + +function getPrimitives (myArg1 = 1, myArg2 = 2) { + // eslint-disable-next-line no-unused-vars + const { myUndef, myNull, myBool, myNumber, myBigInt, myString, mySym } = primitives + return 'my return value' +} + +function getComplextTypes (myArg1 = 1, myArg2 = 2) { + // eslint-disable-next-line no-unused-vars + const { myRegex, myMap, mySet, myArr, myObj, myFunc, myArrowFunc, myInstance, MyClass, circular } = customObj + return 'my return value' +} + +function getNestedObj (myArg1 = 1, myArg2 = 2) { + // eslint-disable-next-line no-unused-vars + const { myNestedObj } = nested + return 'my return value' +} + +class MyClass { + constructor () { + this.foo = 42 + } +} + +const primitives = { + myUndef: undefined, + myNull: null, + myBool: true, + myNumber: 42, + myBigInt: 42n, + myString: 'foo', + mySym: Symbol('foo') +} + +const customObj = { + myRegex: /foo/, + myMap: new Map([[1, 2], [3, 4]]), + mySet: new Set([1, 2, 3]), + myArr: [1, 2, 3], + myObj: { a: 1, b: 2, c: 3 }, + myFunc () { return 42 }, + myArrowFunc: () => { return 42 }, + myInstance: new MyClass(), + MyClass +} + +customObj.circular = customObj + +const nested = { + myNestedObj: { + deepObj: { foo: { foo: { foo: { foo: { foo: true } } } } }, + deepArr: [[[[[42]]]]] + } +} + +module.exports = { + getPrimitives, + getComplextTypes, + getNestedObj +} diff --git a/packages/dd-trace/test/debugger/devtools_client/snapshot.spec.js b/packages/dd-trace/test/debugger/devtools_client/snapshot.spec.js new file mode 100644 index 00000000000..63fe7caefd9 --- /dev/null +++ b/packages/dd-trace/test/debugger/devtools_client/snapshot.spec.js @@ -0,0 +1,210 @@ +'use strict' + +require('../../setup/tap') + +const { expect } = require('chai') + +const inspector = require('../../../src/debugger/devtools_client/inspector_promises_polyfill') +const session = new inspector.Session() +session.connect() + +const { getPrimitives, getComplextTypes, getNestedObj } = require('./_inspected_file') + +const mockedState = { + breakpoints: new Map(), + '@noCallThru': true +} +session['@noCallThru'] = true +const { getLocalStateForBreakpoint } = proxyquire('../src/debugger/devtools_client/snapshot', { + './state': mockedState, + './session': session +}) + +let scriptId + +// Be aware, if any of these tests fail, a nasty native stack trace will be thrown along with the test error! +// Just ignore it, as it's a bug in tap: https://github.com/tapjs/libtap/issues/53 +describe('debugger -> devtools client -> snapshot.getLocalStateForBreakpoint', () => { + beforeEach(async () => { + scriptId = new Promise((resolve) => { + session.on('Debugger.scriptParsed', ({ params }) => { + if (params.url.endsWith('/_inspected_file.js')) { + session.removeAllListeners('Debugger.scriptParsed') // TODO: Can we do this in prod code? + resolve(params.scriptId) + } + }) + }) + + await session.post('Debugger.enable') + }) + + afterEach(async () => { + await session.post('Debugger.disable') + }) + + it('should return expected object for primitives', async () => { + session.once('Debugger.paused', async ({ params }) => { + expect(params.hitBreakpoints.length).to.eq(1) + + const state = await getLocalStateForBreakpoint(params) + + expect(Object.entries(state).length).to.equal(7) + expect(state).to.have.deep.property('myUndef', { type: 'undefined', value: undefined }) + expect(state).to.have.deep.property('myNull', { type: 'null', isNull: true }) + expect(state).to.have.deep.property('myBool', { type: 'boolean', value: true }) + expect(state).to.have.deep.property('myNumber', { type: 'number', value: 42 }) + expect(state).to.have.deep.property('myBigInt', { type: 'bigint', value: '42n' }) + expect(state).to.have.deep.property('myString', { type: 'string', value: 'foo' }) + expect(state).to.have.deep.property('mySym', { type: 'symbol', value: 'Symbol(foo)' }) + }) + + await setBreakpointOnLine(6) + getPrimitives() + }) + + it('should return expected object for complex types', async () => { + session.once('Debugger.paused', async ({ params }) => { + expect(params.hitBreakpoints.length).to.eq(1) + + const state = await getLocalStateForBreakpoint(params) + + expect(Object.entries(state).length).to.equal(10) + expect(state).to.have.deep.property('myRegex', { type: 'regexp', value: '/foo/' }) + expect(state).to.have.deep.property('myMap', { type: 'map', value: 'Map(2)' }) + expect(state).to.have.deep.property('mySet', { type: 'set', value: 'Set(3)' }) + expect(state).to.have.deep.property('myArr', { + type: 'array', + elements: [ + { type: 'number', value: 1 }, + { type: 'number', value: 2 }, + { type: 'number', value: 3 } + ] + }) + expect(state).to.have.deep.property('myObj', { + type: 'object', + fields: { + a: { type: 'number', value: 1 }, + b: { type: 'number', value: 2 }, + c: { type: 'number', value: 3 } + } + }) + expect(state).to.have.deep.property('myFunc', { type: 'function' }) + expect(state).to.have.deep.property('myArrowFunc', { type: 'function' }) + expect(state).to.have.deep.property('myInstance', { + type: 'object', + fields: { foo: { type: 'number', value: 42 } } + }) + expect(state).to.have.deep.property('MyClass', { type: 'class' }) + expect(state).to.have.property('circular') + expect(state.circular).to.have.property('type', 'object') + expect(state.circular).to.have.property('fields') + // For the circular field, just check that at least one of the expected properties are present + expect(state.circular.fields).to.deep.include({ + myRegex: { type: 'regexp', value: '/foo/' } + }) + }) + + await setBreakpointOnLine(12) + getComplextTypes() + }) + + it('should return expected object for nested objects with maxReferenceDepth: 1', async () => { + session.once('Debugger.paused', async ({ params }) => { + expect(params.hitBreakpoints.length).to.eq(1) + + const state = await getLocalStateForBreakpoint(params) + + expect(Object.entries(state).length).to.equal(1) + + expect(state).to.have.property('myNestedObj') + expect(state.myNestedObj).to.have.property('type', 'object') + expect(state.myNestedObj).to.have.property('fields') + expect(Object.entries(state.myNestedObj).length).to.equal(2) + + expect(state.myNestedObj.fields).to.have.deep.property('deepObj', { + type: 'object', notCapturedReason: 'depth' + }) + + expect(state.myNestedObj.fields).to.have.deep.property('deepArr', { + type: 'array', notCapturedReason: 'depth' + }) + }) + + await setBreakpointOnLine(18, 1) + getNestedObj() + }) + + it('should return expected object for nested objects with maxReferenceDepth: 5', async () => { + session.once('Debugger.paused', async ({ params }) => { + expect(params.hitBreakpoints.length).to.eq(1) + + const state = await getLocalStateForBreakpoint(params) + + expect(Object.entries(state).length).to.equal(1) + + expect(state).to.have.property('myNestedObj') + expect(state.myNestedObj).to.have.property('type', 'object') + expect(state.myNestedObj).to.have.property('fields') + expect(Object.entries(state.myNestedObj).length).to.equal(2) + + expect(state.myNestedObj.fields).to.have.deep.property('deepObj', { + type: 'object', + fields: { + foo: { + type: 'object', + fields: { + foo: { + type: 'object', + fields: { + foo: { + type: 'object', + fields: { + foo: { type: 'object', notCapturedReason: 'depth' } + } + } + } + } + } + } + } + }) + + expect(state.myNestedObj.fields).to.have.deep.property('deepArr', { + type: 'array', + elements: [ + { + type: 'array', + elements: [ + { + type: 'array', + elements: [ + { + type: 'array', + elements: [{ type: 'array', notCapturedReason: 'depth' }] + } + ] + } + ] + } + ] + }) + }) + + await setBreakpointOnLine(18, 5) + getNestedObj() + }) +}) + +async function setBreakpointOnLine (line, maxReferenceDepth = 2) { + const { breakpointId } = await session.post('Debugger.setBreakpoint', { + location: { + scriptId: await scriptId, + lineNumber: line - 1 // Beware! lineNumber is zero-indexed + } + }) + mockedState.breakpoints.set(breakpointId, { + capture: { + maxReferenceDepth + } + }) +}