diff --git a/packages/dd-trace/src/config.js b/packages/dd-trace/src/config.js index 6ff1fdcbe19..f5bd85416c5 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') @@ -423,6 +423,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) @@ -529,6 +530,7 @@ class Config { DD_ENV, DD_EXPERIMENTAL_API_SECURITY_ENABLED, DD_EXPERIMENTAL_APPSEC_STANDALONE_ENABLED, + DD_EXPERIMENTAL_DYNAMIC_INSTRUMENTATION_ENABLED, DD_EXPERIMENTAL_PROFILING_ENABLED, JEST_WORKER_ID, DD_IAST_DEDUPLICATION_ENABLED, @@ -655,6 +657,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_EXPERIMENTAL_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) @@ -822,11 +825,11 @@ class Config { this._setString(opts, 'dogstatsd.port', options.dogstatsd.port) } this._setBoolean(opts, 'dsmEnabled', options.dsmEnabled) + this._setBoolean(opts, 'dynamicInstrumentationEnabled', options.experimental?.dynamicInstrumentationEnabled) this._setString(opts, 'env', options.env || tags.env) - this._setBoolean(opts, 'experimental.enableGetRumData', - options.experimental && options.experimental.enableGetRumData) - this._setString(opts, 'experimental.exporter', options.experimental && options.experimental.exporter) - this._setBoolean(opts, 'experimental.runtimeId', options.experimental && options.experimental.runtimeId) + this._setBoolean(opts, 'experimental.enableGetRumData', options.experimental?.enableGetRumData) + this._setString(opts, 'experimental.exporter', options.experimental?.exporter) + this._setBoolean(opts, 'experimental.runtimeId', options.experimental?.runtimeId) this._setValue(opts, 'flushInterval', maybeInt(options.flushInterval)) this._optsUnprocessed.flushInterval = options.flushInterval this._setValue(opts, 'flushMinSpans', maybeInt(options.flushMinSpans)) 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..0546534a50d --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/config.js @@ -0,0 +1,24 @@ +'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) { + 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..2552aa4ed17 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/index.js @@ -0,0 +1,42 @@ +'use strict' + +const uuid = require('crypto-randomuuid') +const { breakpoints } = require('./state') +const session = require('./session') +const send = require('./send') +const { ackEmitting } = require('./status') +require('./remote_config') +const log = require('../../log') + +// The `session.connectToMainThread()` method called inside `session.js` 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() + const probes = params.hitBreakpoints.map((id) => breakpoints.get(id)) + await session.post('Debugger.resume') + const diff = process.hrtime.bigint() - start // TODO: Should this be recored as telemetry? + + log.debug(`Finished processing breakpoints - main 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) { + await send( + probe.template, // TODO: Process template + { + id: uuid(), + timestamp, + probe: { + id: probe.id, + version: probe.version, + location: probe.location + }, + language: 'javascript' + } + ) + + ackEmitting(probe) + } +}) 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..b301d54bb24 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/remote_config.js @@ -0,0 +1,134 @@ +'use strict' + +const { workerData: { rcPort } } = require('node:worker_threads') +const { getScript, probes, breakpoints } = require('./state') +const session = require('./session') +const { ackReceived, ackInstalled, ackError } = require('./status') +const log = require('../../log') + +let sessionStarted = false + +// Example log line probe (simplified): +// { +// id: '100c9a5c-45ad-49dc-818b-c570d31e11d1', +// version: 0, +// type: 'LOG_PROBE', +// where: { sourceFile: 'index.js', lines: ['25'] }, // only use first array element +// template: 'Hello World 2', +// segments: [...], +// captureSnapshot: true, +// capture: { maxReferenceDepth: 1 }, +// sampling: { snapshotsPerSecond: 1 }, +// evaluateAt: 'EXIT' // only used for method probes +// } +// +// Example log method probe (simplified): +// { +// id: 'd692ee6d-5734-4df7-9d86-e3bc6449cc8c', +// version: 0, +// type: 'LOG_PROBE', +// where: { typeName: 'index.js', methodName: 'handlerA' }, +// template: 'Executed index.js.handlerA, it took {@duration}ms', +// segments: [...], +// captureSnapshot: false, +// capture: { maxReferenceDepth: 3 }, +// sampling: { snapshotsPerSecond: 5000 }, +// 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})`) + } + if (!probe.where.sourceFile && !probe.where.lines) { + throw new Error( + // eslint-disable-next-line max-len + `Unsupported probe insertion point! Only line-based probes are supported (id: ${probe.id}, version: ${probe.version})` + ) + } + + 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() + + const file = probe.where.sourceFile + const line = Number(probe.where.lines[0]) // Tracer doesn't support multiple-line breakpoints + + // Optimize for sending data to /debugger/v1/input endpoint + probe.location = { file, lines: [line] } + delete probe.where + + const script = getScript(file) + if (!script) throw new Error(`No loaded script found for ${file} (probe: ${probe.id}, version: ${probe.version})`) + const [path, scriptId] = script + + log.debug(`Adding breakpoint at ${path}:${line} (probe: ${probe.id}, version: ${probe.version})`) + + const { breakpointId } = await session.post('Debugger.setBreakpoint', { + location: { + scriptId, + lineNumber: line - 1 // Beware! lineNumber is zero-indexed + } + }) + + 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 fail unexpected + 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..96b6d7235c2 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/send.js @@ -0,0 +1,41 @@ +'use strict' + +const { threadId } = require('node:worker_threads') +const config = require('./config') +const log = require('../../log') +const request = require('../../exporters/common/request') + +module.exports = send + +const ddsource = 'dd_debugger' +const service = config.service + +// TODO: Figure out correct logger values +const logger = { + name: __filename, // name of the class/type/file emitting the snapshot + method: send.name, // name of the method/function emitting the snapshot + version: 2, // version of the snapshot format (not currently used or enforced) + thread_id: threadId, // current thread/process id emitting the snapshot + thread_name: `${process.argv0};pid:${process.pid}` // name of the current thread emitting the snapshot +} + +async function send (message, snapshot) { + const opts = { + method: 'POST', + url: config.url, + path: '/debugger/v1/input', + headers: { 'Content-Type': 'application/json; charset=utf-8' } + } + + const payload = { + ddsource, + service, + message, + logger, + 'debugger.snapshot': snapshot + } + + request(JSON.stringify(payload), opts, (err) => { + if (err) log.error(err) + }) +} 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/state.js b/packages/dd-trace/src/debugger/devtools_client/state.js new file mode 100644 index 00000000000..a98a0416f46 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/state.js @@ -0,0 +1,46 @@ +'use strict' + +const session = require('./session') + +const scripts = [] + +module.exports = { + probes: new Map(), + breakpoints: new Map(), + + /** + * Find the matching script that can be inspected based on a partial path. + * + * Algorithm: Find the sortest url that ends in the requested path. + * + * Will identify the correct script as long as Node.js doesn't load a module from a `node_modules` folder outside the + * project root. If so, there's a risk that this path is sorter than the expected path inside the project root. + * Example of mismatch where path = `index.js`: + * + * Expected match: /www/code/my-projects/demo-project1/index.js + * Actual sorter match: /www/node_modules/dd-trace/index.js + * + * To fix this, specify a more unique file path, e.g `demo-project1/index.js` instead of `index.js` + * + * @param {string} path + * @returns {[string, string] | undefined} + */ + getScript (path) { + return scripts + .filter(([url]) => url.endsWith(path)) + .sort(([a], [b]) => a.length - b.length)[0] + } +} + +// 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.push([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..025f87169b0 --- /dev/null +++ b/packages/dd-trace/src/debugger/devtools_client/status.js @@ -0,0 +1,83 @@ +'use strict' + +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 ddsource = 'dd_debugger' +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) + + 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) { + 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() + } + + request(form, options, (err) => { + if (err) log.error(err) + }) +} + +function statusPayload (probeId, version, status) { + return { + ddsource, + 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..18019c16a83 --- /dev/null +++ b/packages/dd-trace/src/debugger/index.js @@ -0,0 +1,57 @@ +'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 }) + }) + + 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})`) + }) + + worker.on('error', (err) => log.error(err)) + + // TODO: How should we handle exits? + worker.on('exit', (code) => { + if (code === 0) { + log.debug(`Dynamic Instrumentation worker thread exited with code ${code}`) + } else { + log.error(`Dynamic Instrumentation worker thread exited with unexpected 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 c3b865226ed..241141588c1 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') @@ -110,6 +111,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) { @@ -196,6 +201,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 4034bc28048..4afc2666bfc 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_RUNTIME_METRICS_ENABLED = 'true' process.env.DD_TRACE_REPORT_HOSTNAME = 'true' process.env.DD_ENV = 'test' + process.env.DD_EXPERIMENTAL_DYNAMIC_INSTRUMENTATION_ENABLED = 'true' process.env.DD_TRACE_GLOBAL_TAGS = 'foo:bar,baz:qux' process.env.DD_TRACE_SAMPLE_RATE = '0.5' process.env.DD_TRACE_RATE_LIMIT = '-1' @@ -503,6 +506,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) @@ -599,6 +603,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' }, @@ -758,6 +763,7 @@ describe('Config', () => { }, experimental: { b3: true, + dynamicInstrumentationEnabled: true, traceparent: true, runtimeId: true, exporter: 'log', @@ -795,6 +801,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) @@ -866,6 +873,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' }, @@ -1036,6 +1044,7 @@ describe('Config', () => { process.env.DD_RUNTIME_METRICS_ENABLED = 'true' process.env.DD_TRACE_REPORT_HOSTNAME = 'true' process.env.DD_ENV = 'test' + process.env.DD_EXPERIMENTAL_DYNAMIC_INSTRUMENTATION_ENABLED = 'true' process.env.DD_API_KEY = '123' process.env.DD_TRACE_SPAN_ATTRIBUTE_SCHEMA = 'v0' process.env.DD_TRACE_PEER_SERVICE_DEFAULTS_ENABLED = 'false' @@ -1109,6 +1118,7 @@ describe('Config', () => { }, experimental: { b3: false, + dynamicInstrumentationEnabled: false, traceparent: false, runtimeId: false, exporter: 'agent', @@ -1164,6 +1174,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')