dd-trace
Version:
Datadog APM tracing client for JavaScript
216 lines (192 loc) • 9.2 kB
JavaScript
'use strict'
const session = require('../session')
const { collectObjectProperties } = require('./collector')
const { processRawState, processRemoteObject } = require('./processor')
const BIGINT_MAX = (1n << 256n) - 1n
module.exports = {
getLocalStateForCallFrame,
evaluateCaptureExpressions,
}
/**
* @typedef {object} CaptureLimits - Fully resolved capture limits (all fallbacks already applied)
* @property {number} maxReferenceDepth - The maximum depth of the object to traverse
* @property {number} maxCollectionSize - The maximum size of a collection to include in the snapshot
* @property {number} maxFieldCount - The maximum number of properties on an object to include in the snapshot
* @property {number} maxLength - The maximum length of a string to include in the snapshot
*/
/**
* Get the local state for a call frame.
*
* @param {import('inspector').Debugger.CallFrame} callFrame - The call frame to get the local state for
* @param {CaptureLimits} limits - The capture limits
* @param {bigint} [deadlineNs] - The deadline in nanoseconds compared to `process.hrtime.bigint()`. Defaults to
* {@link BIGINT_MAX}. If the deadline is reached, the snapshot will be truncated.
* @returns {Promise<{ processLocalState: () => ReturnType<typeof processRawState>, fatalErrors: Error[] }>} The local
* state for the call frame
*/
async function getLocalStateForCallFrame (callFrame, limits, deadlineNs = BIGINT_MAX) {
const { maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength } = limits
/** @type {{ deadlineReached: boolean, fatalErrors: Error[] }} */
const ctx = { deadlineReached: false, fatalErrors: [] }
const opts = { maxReferenceDepth, maxCollectionSize, maxFieldCount, deadlineNs, ctx }
const rawState = []
/** @type {ReturnType<typeof processRawState> | null} */
let processedState = null
for (const scope of callFrame.scopeChain) {
if (scope.type === 'global') continue // The global scope is too noisy
const { objectId } = scope.object
if (objectId === undefined) continue // I haven't seen this happen, but according to the types it's possible
try {
// The objectId for a scope points to a pseudo-object whose properties are the actual variables in the scope.
// This is why we can just call `collectObjectProperties` directly and expect it to return the in-scope variables
// as an array.
// eslint-disable-next-line no-await-in-loop
rawState.push(...await collectObjectProperties(objectId, opts))
} catch (err) {
ctx.fatalErrors.push(new Error(
`Error getting local state for closure scope (type: ${scope.type}). ` +
'Future snapshots for existing probes in this location will be skipped until the probes are re-applied',
{ cause: err } // TODO: The cause is not used by the backend
))
}
if (ctx.deadlineReached === true) break // TODO: Bad UX; Variables in remaining scopes are silently dropped
}
// Delay calling `processRawState` so caller can resume the main thread before processing `rawState`
return {
processLocalState () {
processedState = processedState ?? processRawState(rawState, maxLength)
return processedState
},
fatalErrors: ctx.fatalErrors,
}
}
/**
* @typedef {object} CompiledCaptureExpression
* @property {string} name - The name of the expression (used as key in snapshot)
* @property {string} expression - The compiled expression string to evaluate
* @property {CaptureLimits} limits - Fully resolved capture limits (precomputed at probe setup)
*/
/**
* @typedef {object} CaptureExpressionResult
* @property {() => Record<string, ReturnType<typeof processRemoteObject>>} processCaptureExpressions - Callback to
* process raw data into snapshot format
* @property {{ expr: string, message: string }[]} evaluationErrors - Transient errors from expression evaluation
* (safe to retry)
* @property {Error[]} fatalErrors - Fatal errors that should disable capture expressions for this probe permanently
*/
/**
* @typedef {object} EvaluateOnCallFrameResult
* @property {import('./processor').RemoteObjectWithProperties} result - The result of the evaluation
* @property {import('inspector').Runtime.ExceptionDetails} [exceptionDetails] - Exception details if evaluation failed
*/
/**
* Evaluate capture expressions for a call frame.
*
* Collects raw data while paused, returns a callback to process after resume.
*
* @param {import('inspector').Debugger.CallFrame} callFrame - The call frame to evaluate expressions on
* @param {CompiledCaptureExpression[]} expressions - The compiled expressions with precomputed capture limits
* @param {bigint} [deadlineNs] - The deadline in nanoseconds. Defaults to {@link BIGINT_MAX}. If the deadline is
* reached, the snapshot will be truncated.
* @returns {Promise<CaptureExpressionResult>} Raw results with deferred processing callback
*/
async function evaluateCaptureExpressions (callFrame, expressions, deadlineNs = BIGINT_MAX) {
/** @type {{ name: string, remoteObject: object, maxLength: number }[]} */
const rawResults = []
/** @type {{ expr: string, message: string }[]} */
const evaluationErrors = []
/** @type {Error[]} */
const fatalErrors = []
/** @type {Record<string, ReturnType<typeof processRemoteObject>> | null} */
let processedResult = null
for (let i = 0; i < expressions.length; i++) {
const { name, expression, limits } = expressions[i]
const { maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength } = limits
try {
const { result, exceptionDetails } = /** @type {EvaluateOnCallFrameResult} */ (
// eslint-disable-next-line no-await-in-loop
await session.post('Debugger.evaluateOnCallFrame', {
callFrameId: callFrame.callFrameId,
expression,
})
)
// Handle evaluation exceptions (maybe transient - bad expression, undefined var, etc.)
if (exceptionDetails) {
evaluationErrors.push({ expr: name, message: extractErrorMessage(exceptionDetails) })
continue
}
// Collect raw properties for objects/functions while still paused
if ((result.type === 'object' || result.type === 'function') && result.objectId && maxReferenceDepth > 0) {
const ctx = { deadlineReached: false, fatalErrors: [] }
const isCollection = result.subtype === 'array' || result.subtype === 'typedarray'
// eslint-disable-next-line no-await-in-loop
result.properties = await collectObjectProperties(
result.objectId,
{
// The expression result itself is depth 0, so we subtract 1 when collecting its properties (depth 1+)
maxReferenceDepth: maxReferenceDepth - 1,
maxCollectionSize,
maxFieldCount,
deadlineNs,
ctx,
},
0,
isCollection
)
// Propagate fatal errors from nested collection
if (ctx.fatalErrors.length > 0) {
fatalErrors.push(...ctx.fatalErrors)
}
if (ctx.deadlineReached === true) {
// Add the current expression (properties may be incomplete due to timeout)
rawResults.push({ name, remoteObject: result, maxLength })
// Add stub entries for remaining uncaptured expressions
for (let j = i + 1; j < expressions.length; j++) {
rawResults.push({
name: expressions[j].name,
remoteObject: { notCapturedReason: 'timeout' },
maxLength: 0,
})
}
break
}
}
rawResults.push({ name, remoteObject: result, maxLength })
} catch (err) {
fatalErrors.push(new Error(
`Error capturing expression "${name}". ` +
'Capture expressions for this probe will be skipped until the probe is re-applied',
{ cause: err } // TODO: The cause is not used by the backend
))
}
}
// Delay calling `processRemoteObject` so caller can resume the main thread before processing `remoteObject`
return {
processCaptureExpressions () {
if (processedResult !== null) return processedResult
processedResult = {}
for (const { name, remoteObject, maxLength } of rawResults) {
// If the remote object has notCapturedReason (e.g., timeout), use it as-is without processing
processedResult[name] = remoteObject.notCapturedReason === undefined
? processRemoteObject(remoteObject, maxLength)
: remoteObject
}
return processedResult
},
evaluationErrors,
fatalErrors,
}
}
/**
* Extract the error message from the exception details.
*
* @param {import('inspector').Runtime.ExceptionDetails} exceptionDetails - The exception details
* @returns {string} The error message
*/
function extractErrorMessage (exceptionDetails) {
const description = exceptionDetails.exception?.description
if (!description) return 'Unknown evaluation error'
const startOfStackTraceIndex = description.indexOf('\n at ')
if (startOfStackTraceIndex === -1) return description
return description.slice(0, startOfStackTraceIndex)
}