Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
31 changes: 31 additions & 0 deletions integration-tests/debugger/target-app/template.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ fastify.get('/:name', function (request) {
const emptyArr = []
const arr = [{ a: 1 }, 2, 3, 4, 5]
const emptyObj = {}
const maxObj = { a: 1, b: 2, c: 3, d: 4, e: 5 }
Object.defineProperty(maxObj, 'hidden', { value: 6 })
const obj = {
foo: {
baz: 42,
Expand All @@ -35,6 +37,8 @@ fastify.get('/:name', function (request) {
get baz () {
return 'This is a getter!'
},
qux: 42,
quux: false,
[inspect.custom] () {
return 'This is a custom inspect!'
},
Expand All @@ -44,8 +48,31 @@ fastify.get('/:name', function (request) {
return 'This is a proxy!'
},
})
const objectWithProxyPrototype = Object.create(new Proxy({}, {
getPrototypeOf () {
throw new Error('Proxy prototype trap should not run')
},
}))
Object.defineProperties(objectWithProxyPrototype, {
a: { value: 1, enumerable: true },
b: { value: 2, enumerable: true },
c: { value: 3, enumerable: true },
d: { value: 4, enumerable: true },
e: { value: 5, enumerable: true },
f: { value: 6, enumerable: true },
})
const sideEffectfulObject = {
a: 1,
b: 2,
get [Symbol.toStringTag] () {
throw new Error('Symbol.toStringTag getter should not run')
},
[Symbol('extra')]: 4,
}
const circular = {}
circular.circular = circular
const wideCircular = { circular: undefined, a: 1, b: 2, c: 3, d: 4, e: 5 }
wideCircular.circular = wideCircular
const ins = new CustomClass()
const p = Promise.resolve(42)
const arrowFn = () => {}
Expand Down Expand Up @@ -80,6 +107,10 @@ class CustomClass {

constructor () {
this.c = 3
this.d = 4
this.e = 5
this.f = 6
this.g = 7
}

get [Symbol.toStringTag] () {
Expand Down
49 changes: 21 additions & 28 deletions integration-tests/debugger/template.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,27 +48,22 @@ describe('Dynamic Instrumentation', function () {
assert.strictEqual(messages.shift(), '[]')
assert.strictEqual(messages.shift(), '[ [Object], 2, 3, ... 2 more items ]')
assert.strictEqual(messages.shift(), '{}')
assert.strictEqual(messages.shift(), '{ a: 1, b: 2, c: 3, d: 4, e: 5 }')
const obj = messages.shift()
let expectedObjectShape = '{ ' +
'foo: [Object], ' +
'bar: true, ' +
'baz: [Getter], ' +
(NODE_MAJOR >= 24
? 'Symbol(nodejs.util.inspect.custom): [Function: [nodejs.util.inspect.custom]] '
: '[Symbol(nodejs.util.inspect.custom)]: [Function: [nodejs.util.inspect.custom]] ') +
const expectedObjectShape = '{ ' +
'foo: [Object], bar: true, baz: [Getter], qux: 42, quux: false, ... 1 more property ' +
'}'
assert.strictEqual(obj, expectedObjectShape)
if (NODE_MAJOR >= 26) {
// A proxy should be stringified to the wrapped object plus the proxy type in newer Node.js versions
expectedObjectShape = `Proxy(${expectedObjectShape})`
}
assert.strictEqual(messages.shift(), expectedObjectShape)
assert.strictEqual(messages.shift(), '[Proxy]')
assert.strictEqual(messages.shift(), '{ a: 1, b: 2, c: 3, d: 4, e: 5, ... 1 more property }')
assert.strictEqual(messages.shift(), '[Value omitted: inspection may execute user code]')
assert.strictEqual(messages.shift(), '<ref *1> { circular: [Circular *1] }')
assert.strictEqual(
messages.shift(),
'<ref *1> { circular: [Circular *1], a: 1, b: 2, c: 3, d: 4, ... 1 more property }'
)
assert.strictEqual(messages.shift(), '[class CustomClass]')
// Notice execution of `Symbol.toStringTag` getter (`foo`). There's nothing we can do about it when using
// `util.inspect`, but it has not been considered a big side-effects issue, as anyone implementing this
// function is doing so with the explicit intent of modifying the string representation of instances.
assert.strictEqual(messages.shift(), 'CustomClass [foo] { b: 2, c: 3 }')
assert.strictEqual(messages.shift(), '{ b: 2, c: 3, d: 4, e: 5, f: 6, ... 1 more property }')
if (NODE_MAJOR >= 24) {
assert.strictEqual(messages.shift(), 'Promise { 42 }')
} else {
Expand All @@ -83,18 +78,8 @@ describe('Dynamic Instrumentation', function () {
}
assert.strictEqual(messages.shift(), '[Function: arrowFn]')
assert.strictEqual(messages.shift(), '[Function: fn]')
assert.strictEqual(
messages.shift(),
NODE_MAJOR > 18
? 'Set(5) { 1, 2, 3, ... 2 more items }'
: 'Set(5) { 1, 2, 3, 4, 5 }'
)
assert.strictEqual(
messages.shift(),
NODE_MAJOR > 18
? 'Map(5) { 1 => 2, 3 => 4, 5 => 6, ... 2 more items }'
: 'Map(5) { 1 => 2, 3 => 4, 5 => 6, 7 => 8, 9 => 10 }'
)
assert.strictEqual(messages.shift(), 'Set(5) { 1, 2, 3, ... 2 more items }')
assert.strictEqual(messages.shift(), 'Map(5) { 1 => 2, 3 => 4, 5 => 6, ... 2 more items }')
assert.strictEqual(messages.shift(), 'WeakSet { <items unknown> }')
assert.strictEqual(messages.shift(), 'WeakMap { <items unknown> }')
assert.strictEqual(messages.shift(), 'Buffer(6) [Uint8Array] [ 102, 111, 111, ... 3 more items ]')
Expand Down Expand Up @@ -138,12 +123,20 @@ describe('Dynamic Instrumentation', function () {
{ str: ';' },
{ dsl: 'emptyObj', json: { ref: 'emptyObj' } },
{ str: ';' },
{ dsl: 'maxObj', json: { ref: 'maxObj' } },
{ str: ';' },
{ dsl: 'obj', json: { ref: 'obj' } },
{ str: ';' },
{ dsl: 'proxy', json: { ref: 'proxy' } },
{ str: ';' },
{ dsl: 'objectWithProxyPrototype', json: { ref: 'objectWithProxyPrototype' } },
{ str: ';' },
{ dsl: 'sideEffectfulObject', json: { ref: 'sideEffectfulObject' } },
{ str: ';' },
{ dsl: 'circular', json: { ref: 'circular' } },
{ str: ';' },
{ dsl: 'wideCircular', json: { ref: 'wideCircular' } },
{ str: ';' },
{ dsl: 'CustomClass', json: { ref: 'CustomClass' } },
{ str: ';' },
{ dsl: 'ins', json: { ref: 'ins' } },
Expand Down
1 change: 1 addition & 0 deletions packages/dd-trace/src/debugger/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ module.exports = {
DEBUGGER_DIAGNOSTICS_V1: '/debugger/v1/diagnostics',
DEBUGGER_INPUT_V1: '/debugger/v1/input',
DEBUGGER_INPUT_V2: '/debugger/v2/input',
INSPECT_SEGMENT_GLOBAL_PROPERTY: 'debuggerInspectSegment',
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function compileSegments (segments) {
? `(() => {
try {
const result = ${compile(json)}
return typeof result === 'string' ? result : $dd_inspect(result, $dd_segmentInspectOptions)
return typeof result === 'string' ? result : $dd_inspectSegment(result)
} catch (e) {
return { expr: ${JSON.stringify(dsl)}, message: \`\${e.name}: \${e.message}\` }
}
Expand Down
13 changes: 3 additions & 10 deletions packages/dd-trace/src/debugger/devtools_client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { randomUUID } = require('crypto')
const { workerData: { probeSamplerBuffer } } = require('worker_threads')
const { version } = require('../../../../../package.json')
const processTags = require('../../process-tags')
const { INSPECT_SEGMENT_GLOBAL_PROPERTY } = require('../constants')
const {
MAX_SAMPLED_PROBES_PER_PAUSE,
SAMPLED_PROBE_COUNT_INDEX,
Expand All @@ -23,16 +24,8 @@ require('./remote_config')

/** @typedef {import('node:inspector').Debugger.EvaluateOnCallFrameReturnType} EvaluateOnCallFrameResult */

const templateExpressionSetupCode = `
const $dd_inspect = global.require('node:util').inspect;
const $dd_segmentInspectOptions = {
depth: 0,
customInspect: false,
maxArrayLength: 3,
maxStringLength: 8 * 1024,
breakLength: Infinity
};
`
const templateExpressionSetupCode = 'const $dd_inspectSegment = ' +
`globalThis[Symbol.for('dd-trace')][${JSON.stringify(INSPECT_SEGMENT_GLOBAL_PROPERTY)}];`

// Expression to run on a call frame of the paused thread to get its active trace and span id.
const getDDTagsExpression = `(() => {
Expand Down
6 changes: 4 additions & 2 deletions packages/dd-trace/src/debugger/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const { Worker, MessageChannel, threadId: parentThreadId } = require('worker_thr
const log = require('../log')
const { fetchAgentInfo } = require('../agent/info')
const getDebuggerConfig = require('./config')
const { DEBUGGER_DIAGNOSTICS_V1, DEBUGGER_INPUT_V2 } = require('./constants')
const { DEBUGGER_DIAGNOSTICS_V1, DEBUGGER_INPUT_V2, INSPECT_SEGMENT_GLOBAL_PROPERTY } = require('./constants')
const { installProbeSampler, uninstallProbeSampler } = require('./probe_sampler')

/**
Expand Down Expand Up @@ -64,7 +64,9 @@ function start (config, rcInstance) {
const logChannel = new MessageChannel()
configChannel = new MessageChannel()

globalThis[Symbol.for('dd-trace')].utilTypes = types
const debuggerGlobals = globalThis[Symbol.for('dd-trace')]
debuggerGlobals.utilTypes = types
debuggerGlobals[INSPECT_SEGMENT_GLOBAL_PROPERTY] = require('./inspect-segment')

const probeSamplerBuffer = installProbeSampler()

Expand Down
102 changes: 102 additions & 0 deletions packages/dd-trace/src/debugger/inspect-segment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
'use strict'

const { inspect, types } = require('node:util')

/** @typedef {NonNullable<ReturnType<typeof globalThis.Object.getOwnPropertyDescriptor>>} PropertyDescriptor */

const maxProperties = 5
const segmentInspectOptions = {
depth: 0,
customInspect: false,
maxArrayLength: 3,
maxStringLength: 8 * 1024,
breakLength: Infinity,
}

module.exports = inspectSegment

/**
* Inspect a dynamic-instrumentation template value without invoking user code.
* Unlike collections, `util.inspect` has no option for limiting the number of object properties, so this function
* truncates objects before inspecting them.
*
* @param {unknown} value
* @returns {string}
*/
function inspectSegment (value) {
if (value === null || (typeof value !== 'object' && typeof value !== 'function')) {
return inspect(value, segmentInspectOptions)
}
if (types.isProxy(value)) return '[Proxy]'
if (
Array.isArray(value) ||
types.isTypedArray(value) ||
types.isAnyArrayBuffer(value) ||
types.isDataView(value) ||
types.isMap(value) ||
types.isSet(value) ||
types.isWeakMap(value) ||
types.isWeakSet(value) ||
types.isMapIterator(value) ||
types.isSetIterator(value)
Comment on lines +32 to +41

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could check for an iterable symbol. That is not perfect, since it could still be a regular object, while unlikely and it would allow to skip multiple checks.

@watson watson Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I don't like this wall of checks either... but was hoping the V8 optimizer would find a way to make it performant.

I am working on a PR to support this directly in Node core though. If that lands, we can live with a slower polyfill.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are implemented in Node.js in C++ as fast calls. So they are inlined, while we still need to go through each.

) {
return inspect(value, segmentInspectOptions)
}

/** @type {(string | symbol)[]} */
const keys = Object.keys(value)
let propertyCount = keys.length
const symbols = Object.getOwnPropertySymbols(value)
for (let i = 0; i < symbols.length; i++) {
if (Object.getOwnPropertyDescriptor(value, symbols[i])?.enumerable === true) {
propertyCount++
if (keys.length < maxProperties) keys.push(symbols[i])
}
}
Comment on lines +49 to +55

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If keys.length (propertyCount, while pulling out the length is not changing performance) is above maxProperties, this whole code will never add any keys and I think we should skip it (getting the symbols and descriptors is actually expensive)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was originally thinking the same, but ended up with this implementation to ensure the n more properties was counted correctly... but it's a tough choice. Do we optimize for performance or correctness 🤷

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, in new Node.js versions having objects with zero symbols is faster again. So it might not hurt that badly anymore.


if (propertyCount <= maxProperties) {
// TODO: Decide whether allowing util.inspect to invoke Symbol.toStringTag getters is acceptable. If it is,
// remove inspectionCanRunUserCode and the related omission paths.
if (inspectionCanRunUserCode(value)) {
return '[Value omitted: inspection may execute user code]'
}
return inspect(value, segmentInspectOptions)
}

const truncated = {}
for (let i = 0; i < maxProperties; i++) {
const descriptor = /** @type {PropertyDescriptor} */ (Object.getOwnPropertyDescriptor(value, keys[i]))
if (
(keys[i] === Symbol.toStringTag && descriptor.get !== undefined) ||
(descriptor.value !== value && inspectionCanRunUserCode(descriptor.value))
) {
return '[Value omitted: inspection may execute user code]'
}
if (descriptor.value === value) descriptor.value = truncated
Object.defineProperty(truncated, keys[i], descriptor)
Comment on lines +68 to +76

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the object will likely be truncated and it is a different object anyway, is copying the descriptor crucial?

If not, we could just check for the presence of the toStringTag with e.g., in and get the descriptor directly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need all the descriptor flags, but I don't think we can get around having to use descriptor in some capacity. It all depends how many side effects you're willing to potentially trigger. Or maybe I'm misunderstanding your proposal?

}

const omitted = propertyCount - maxProperties
const inspected = inspect(truncated, segmentInspectOptions)
return `${inspected.slice(0, -2)}, ... ${omitted} more ${omitted === 1 ? 'property' : 'properties'} }`
}

/**
* Determine whether inspecting a value could invoke a proxy trap or toStringTag getter.
*
* @param {unknown} value
* @returns {boolean}
*/
function inspectionCanRunUserCode (value) {
const type = typeof value
if (value === null || (type !== 'object' && type !== 'function')) return false
if (types.isProxy(value)) return true

let current = value
while (current !== null) {
if (Object.getOwnPropertyDescriptor(current, Symbol.toStringTag)?.get !== undefined) return true
current = Object.getPrototypeOf(current)
if (types.isProxy(current)) return true
}
return false
}
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ describe('Expression language', function () {
`[(() => {
try {
const result = foo
return typeof result === 'string' ? result : $dd_inspect(result, $dd_segmentInspectOptions)
return typeof result === 'string' ? result : $dd_inspectSegment(result)
} catch (e) {
return { expr: "foo", message: \`\${e.name}: \${e.message}\` }
}
Expand All @@ -146,7 +146,7 @@ describe('Expression language', function () {
`["foo: ",(() => {
try {
const result = foo
return typeof result === 'string' ? result : $dd_inspect(result, $dd_segmentInspectOptions)
return typeof result === 'string' ? result : $dd_inspectSegment(result)
} catch (e) {
return { expr: "foo", message: \`\${e.name}: \${e.message}\` }
}
Expand Down
Loading
Loading