UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

444 lines (411 loc) 15.3 kB
'use strict' const tags = require('../../../ext/tags') const constants = require('./constants') const { MAX_META_KEY_LENGTH, MAX_META_VALUE_LENGTH, MAX_METRIC_KEY_LENGTH, } = require('./encode/tags-processors') const id = require('./id') const { isError } = require('./util') const { registerExtraService } = require('./service-naming/extra-services') const { TRACING_FIELD_NAME } = require('./process-tags') const SAMPLING_PRIORITY_KEY = constants.SAMPLING_PRIORITY_KEY const SAMPLING_RULE_DECISION = constants.SAMPLING_RULE_DECISION const SAMPLING_LIMIT_DECISION = constants.SAMPLING_LIMIT_DECISION const SAMPLING_AGENT_DECISION = constants.SAMPLING_AGENT_DECISION const SPAN_SAMPLING_MECHANISM = constants.SPAN_SAMPLING_MECHANISM const SPAN_SAMPLING_RULE_RATE = constants.SPAN_SAMPLING_RULE_RATE const SPAN_SAMPLING_MAX_PER_SECOND = constants.SPAN_SAMPLING_MAX_PER_SECOND const SAMPLING_MECHANISM_SPAN = constants.SAMPLING_MECHANISM_SPAN const { MEASURED, BASE_SERVICE, ANALYTICS } = tags const ORIGIN_KEY = constants.ORIGIN_KEY const HOSTNAME_KEY = constants.HOSTNAME_KEY const TOP_LEVEL_KEY = constants.TOP_LEVEL_KEY const PROCESS_ID = constants.PROCESS_ID const ERROR_MESSAGE = constants.ERROR_MESSAGE const ERROR_STACK = constants.ERROR_STACK const ERROR_TYPE = constants.ERROR_TYPE const { IGNORE_OTEL_ERROR } = constants /** * @typedef {object} FormattedSpan * @property {import('./id').Identifier} trace_id * @property {import('./id').Identifier} span_id * @property {import('./id').Identifier} parent_id * @property {string} name * @property {string} resource * @property {string | undefined} service * @property {string | undefined} type * @property {number} error * @property {Record<string, string>} meta * @property {Record<string, number>} metrics * @property {Record<string, unknown> | undefined} meta_struct * @property {number} start * @property {number} duration * @property {Array} links * @property {Array<SpanEvent> | undefined} span_events * * @typedef {object} SpanEvent * @property {string} name * @property {number} time_unix_nano * @property {Record<string, string>} [attributes] */ function format (span, isFirstSpanInChunk = false, tagForFirstSpanInChunk = false) { const formatted = formatSpan(span) extractSpanLinks(formatted, span) extractSpanEvents(formatted, span) extractRootTags(formatted, span) if (isFirstSpanInChunk) { extractChunkTags(formatted, span, tagForFirstSpanInChunk) } extractTags(formatted, span) return formatted } function formatSpan (span) { const spanContext = span.context() // Pre-initialise the `service`, `type`, and `span_events` slots so every // formatted span shares one V8 hidden class regardless of which optional // tags fire later. Downstream encoders gate on truthy values for each, // so `undefined` stays byte-identical on the msgpack wire. return { trace_id: spanContext._traceId, span_id: spanContext._spanId, parent_id: spanContext._parentId || id('0'), name: String(spanContext._name), resource: String(spanContext._name), service: undefined, type: undefined, error: 0, meta: {}, meta_struct: span.meta_struct, metrics: {}, start: Math.round(span._startTime * 1e6), duration: Math.round(span._duration * 1e6), links: [], span_events: undefined, } } function setSingleSpanIngestionTags (formattedSpan, options) { if (!options) return const metrics = formattedSpan.metrics metrics[SPAN_SAMPLING_MECHANISM] = SAMPLING_MECHANISM_SPAN const sampleRate = options.sampleRate if (typeof sampleRate === 'number') { metrics[SPAN_SAMPLING_RULE_RATE] = sampleRate } const maxPerSecond = options.maxPerSecond if (typeof maxPerSecond === 'number') { metrics[SPAN_SAMPLING_MAX_PER_SECOND] = maxPerSecond } } /** * @param {FormattedSpan} formattedSpan * @param {import('./opentracing/span')} span */ function extractSpanLinks (formattedSpan, span) { if (!span._links?.length) { return } const links = span._links.map(({ context, attributes }) => { const formattedLink = { trace_id: context.toTraceId(true), span_id: context.toSpanId(true), } if (attributes && Object.keys(attributes).length > 0) { formattedLink.attributes = attributes } if (context?._sampling?.priority >= 0) formattedLink.flags = context._sampling.priority > 0 ? 1 : 0 if (context?._tracestate) formattedLink.tracestate = context._tracestate.toString() return formattedLink }) let serialized = JSON.stringify(links) if (serialized.length > MAX_META_VALUE_LENGTH) { serialized = `${serialized.slice(0, MAX_META_VALUE_LENGTH)}...` } formattedSpan.meta['_dd.span_links'] = serialized } /** * @param {FormattedSpan} formattedSpan * @param {import('./opentracing/span')} span */ function extractSpanEvents (formattedSpan, span) { if (!span._events?.length) { return } formattedSpan.span_events = span._events.map(event => { return { name: event.name, time_unix_nano: Math.round(event.startTime * 1e6), attributes: event.attributes && Object.keys(event.attributes).length > 0 ? event.attributes : undefined, } }) } function extractTags (formattedSpan, span) { const context = span.context() const origin = context._trace.origin // TODO(BridgeAR)[31.03.2025]: Look into changing the way we store tags. Using // a map is likely faster short term. const tags = context.getTags() const hostname = context._hostname const priority = context._sampling.priority const meta = formattedSpan.meta const metrics = formattedSpan.metrics if (tags['span.kind'] && tags['span.kind'] !== 'internal') { metrics[MEASURED] = 1 } const tracerService = span.tracer()._service.toLowerCase() if (tags['service.name']?.toLowerCase() !== tracerService) { span.setTag(BASE_SERVICE, tracerService) registerExtraService(tags['service.name']) } for (const tag of Object.keys(tags)) { const value = tags[tag] // The typed-helper bodies are inlined per case: V8 was not inlining // `addStringTag` / `addNumberTag` / `addMixedTag` here at the call rate // this loop runs in HTTP-server traces (10+ tags × 1M spans/sec), so each // one paid an extra call frame the helper body was small enough to // expand inline. switch (tag) { case 'service.name': if (typeof value === 'string') { formattedSpan.service = value.length > MAX_META_VALUE_LENGTH ? `${value.slice(0, MAX_META_VALUE_LENGTH)}...` : value } break case 'span.type': if (typeof value === 'string') { formattedSpan.type = value.length > MAX_META_VALUE_LENGTH ? `${value.slice(0, MAX_META_VALUE_LENGTH)}...` : value } break case 'resource.name': if (typeof value === 'string') { formattedSpan.resource = value.length > MAX_META_VALUE_LENGTH ? `${value.slice(0, MAX_META_VALUE_LENGTH)}...` : value } break // HACK: remove when Datadog supports numeric status code case 'http.status_code': { const stringValue = value && String(value) if (typeof stringValue === 'string') { meta[tag] = stringValue.length > MAX_META_VALUE_LENGTH ? `${stringValue.slice(0, MAX_META_VALUE_LENGTH)}...` : stringValue } break } case 'analytics.event': metrics[ANALYTICS] = value === undefined || value ? 1 : 0 break case HOSTNAME_KEY: case MEASURED: metrics[tag] = value === undefined || value ? 1 : 0 break // TODO(BridgeAR)[31.03.2025]: How come we use two different ways to pass // through errors? Can we just unify the behavior to always use one way? case 'error': if (context._name !== 'fs.operation') { extractError(formattedSpan, value) } break case ERROR_TYPE: case ERROR_MESSAGE: case ERROR_STACK: { // HACK: remove when implemented in the backend if (context._name === 'fs.operation') break // otel.recordException should not influence trace.error if (!tags[IGNORE_OTEL_ERROR]) { formattedSpan.error = 1 } if (value != null) writeErrorMeta(meta, tag, value) break } default: { const valueType = typeof value if (valueType === 'string') { let writeKey = tag if (writeKey.length > MAX_META_KEY_LENGTH) { writeKey = `${writeKey.slice(0, MAX_META_KEY_LENGTH)}...` } meta[writeKey] = value.length > MAX_META_VALUE_LENGTH ? `${value.slice(0, MAX_META_VALUE_LENGTH)}...` : value } else if (valueType === 'number') { if (!Number.isNaN(value)) { let writeKey = tag if (writeKey.length > MAX_METRIC_KEY_LENGTH) { writeKey = `${writeKey.slice(0, MAX_METRIC_KEY_LENGTH)}...` } metrics[writeKey] = value } } else if (valueType === 'boolean') { let writeKey = tag if (writeKey.length > MAX_METRIC_KEY_LENGTH) { writeKey = `${writeKey.slice(0, MAX_METRIC_KEY_LENGTH)}...` } metrics[writeKey] = value ? 1 : 0 } else { addMixedTag(meta, metrics, tag, value) } } } } setSingleSpanIngestionTags(formattedSpan, context._spanSampling) meta.language = 'javascript' metrics[PROCESS_ID] = process.pid if (typeof priority === 'number') { metrics[SAMPLING_PRIORITY_KEY] = priority } if (typeof origin === 'string') { meta[ORIGIN_KEY] = origin.length > MAX_META_VALUE_LENGTH ? `${origin.slice(0, MAX_META_VALUE_LENGTH)}...` : origin } if (typeof hostname === 'string') { meta[HOSTNAME_KEY] = hostname.length > MAX_META_VALUE_LENGTH ? `${hostname.slice(0, MAX_META_VALUE_LENGTH)}...` : hostname } } function extractRootTags (formattedSpan, span) { const context = span.context() const parentId = context._parentId if (span !== context._trace.started[0] || (parentId && parentId.toString(10) !== '0')) return const trace = context._trace const metrics = formattedSpan.metrics const ruleDecision = trace[SAMPLING_RULE_DECISION] if (typeof ruleDecision === 'number') { metrics[SAMPLING_RULE_DECISION] = ruleDecision } const limitDecision = trace[SAMPLING_LIMIT_DECISION] if (typeof limitDecision === 'number') { metrics[SAMPLING_LIMIT_DECISION] = limitDecision } const agentDecision = trace[SAMPLING_AGENT_DECISION] if (typeof agentDecision === 'number') { metrics[SAMPLING_AGENT_DECISION] = agentDecision } metrics[TOP_LEVEL_KEY] = 1 } function extractChunkTags (formattedSpan, span, tagForFirstSpanInChunk) { const meta = formattedSpan.meta if (typeof tagForFirstSpanInChunk === 'string') { meta[TRACING_FIELD_NAME] = tagForFirstSpanInChunk.length > MAX_META_VALUE_LENGTH ? `${tagForFirstSpanInChunk.slice(0, MAX_META_VALUE_LENGTH)}...` : tagForFirstSpanInChunk } // Chunk tags are always strings in production (`_dd.p.dm`, `_dd.p.tid`, // `_dd.p.ts`, `baggage.*`). Inline only the string branch; non-string // values fall through to `addMixedTag` so we don't carry duplicate // truncation logic for branches no real chunk tag ever takes. const metrics = formattedSpan.metrics const traceTags = span.context()._trace.tags for (const key of Object.keys(traceTags)) { const value = traceTags[key] if (typeof value === 'string') { let writeKey = key if (writeKey.length > MAX_META_KEY_LENGTH) { writeKey = `${writeKey.slice(0, MAX_META_KEY_LENGTH)}...` } meta[writeKey] = value.length > MAX_META_VALUE_LENGTH ? `${value.slice(0, MAX_META_VALUE_LENGTH)}...` : value } else { addMixedTag(meta, metrics, key, value) } } } function extractError (formattedSpan, error) { if (!error) return formattedSpan.error = 1 if (isError(error)) { // AggregateError only has a code and no message. // TODO(BridgeAR)[31.03.2025]: An AggregateError can have a message. Should // the code just generally be added, if available? const meta = formattedSpan.meta const message = error.message || error.code if (message != null) writeErrorMeta(meta, ERROR_MESSAGE, message) if (error.name != null) writeErrorMeta(meta, ERROR_TYPE, error.name) if (error.stack != null) writeErrorMeta(meta, ERROR_STACK, error.stack) } } /** * Coerces `value` to string and truncates at `MAX_META_VALUE_LENGTH` before * writing it to one of the three error meta fields. * * @param {Record<string, string>} meta * @param {string} key * @param {unknown} value */ function writeErrorMeta (meta, key, value) { const stringValue = typeof value === 'string' ? value : String(value) meta[key] = stringValue.length > MAX_META_VALUE_LENGTH ? `${stringValue.slice(0, MAX_META_VALUE_LENGTH)}...` : stringValue } /** * Mixed-type dispatch retained for `extractError` and the slow-path fallback * inside the inlined per-tag loops in `extractTags` / `extractChunkTags`. * The scalar branches are kept here so a single `addMixedTag` call covers * recursion (nested object values) without re-entering the inlined paths. * * @param {Record<string, string>} meta * @param {Record<string, number>} metrics * @param {string} key * @param {unknown} value * @param {boolean} [nested] */ function addMixedTag (meta, metrics, key, value, nested) { switch (typeof value) { case 'string': if (key.length > MAX_META_KEY_LENGTH) { key = `${key.slice(0, MAX_META_KEY_LENGTH)}...` } if (value.length > MAX_META_VALUE_LENGTH) { value = `${value.slice(0, MAX_META_VALUE_LENGTH)}...` } meta[key] = value break case 'number': if (Number.isNaN(value)) break if (key.length > MAX_METRIC_KEY_LENGTH) { key = `${key.slice(0, MAX_METRIC_KEY_LENGTH)}...` } metrics[key] = value break case 'boolean': if (key.length > MAX_METRIC_KEY_LENGTH) { key = `${key.slice(0, MAX_METRIC_KEY_LENGTH)}...` } metrics[key] = value ? 1 : 0 break default: if (value == null) break // Special case for Node.js Buffer and URL // TODO(BridgeAR)[31.03.2025]: Figure out if all typed arrays should be treated as buffers. if (isNodeBuffer(value) || isUrl(value)) { if (key.length > MAX_METRIC_KEY_LENGTH) { key = `${key.slice(0, MAX_METRIC_KEY_LENGTH)}...` } metrics[key] = value.toString() } else if (!Array.isArray(value) && !nested) { for (const [prop, val] of Object.entries(value)) { addMixedTag(meta, metrics, `${key}.${prop}`, val, true) } } } } function isNodeBuffer (obj) { return obj.constructor && obj.constructor.name === 'Buffer' && typeof obj.readInt8 === 'function' && typeof obj.toString === 'function' } function isUrl (obj) { return obj.constructor && obj.constructor.name === 'URL' && typeof obj.href === 'string' && typeof obj.toString === 'function' } module.exports = format