UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

942 lines (826 loc) 30.3 kB
'use strict' const getConfig = require('../config') const { MsgpackChunk, MsgpackEncoder } = require('../msgpack') const log = require('../log') const { normalizeSpan } = require('./tags-processors') const SOFT_LIMIT = 8 * 1024 * 1024 // 8MB // Pre-encoded static keys + value-prefix bytes; the hot encode loop emits // each via one Uint8Array.set instead of routing through the string cache. /** * @param {string} key fixstr key, must be < 32 UTF-8 bytes. * @returns {Buffer} */ function buildKey (key) { const length = Buffer.byteLength(key) const buffer = Buffer.allocUnsafe(length + 1) buffer[0] = length | 0xA0 buffer.utf8Write(key, 1, length) return buffer } /** * @param {string} key fixstr key, must be < 32 UTF-8 bytes. * @param {number} prefix msgpack prefix byte for the value that follows the key. * @returns {Buffer} */ function buildKeyWithPrefix (key, prefix) { const length = Buffer.byteLength(key) const buffer = Buffer.allocUnsafe(length + 2) buffer[0] = length | 0xA0 buffer.utf8Write(key, 1, length) buffer[length + 1] = prefix return buffer } const KEY_TYPE = buildKey('type') const KEY_NAME = buildKey('name') const KEY_RESOURCE = buildKey('resource') const KEY_SERVICE = buildKey('service') const KEY_ERROR = buildKey('error') const KEY_START = buildKey('start') const KEY_DURATION = buildKey('duration') const KEY_SPAN_EVENTS = buildKey('span_events') const KEY_META_STRUCT = buildKey('meta_struct') const KEY_TRACE_ID_PREFIX = buildKeyWithPrefix('trace_id', 0xCF) const KEY_SPAN_ID_PREFIX = buildKeyWithPrefix('span_id', 0xCF) const KEY_PARENT_ID_PREFIX = buildKeyWithPrefix('parent_id', 0xCF) const KEY_META_PREFIX = buildKeyWithPrefix('meta', 0xDF) const KEY_METRICS_PREFIX = buildKeyWithPrefix('metrics', 0xDF) // Span-event field keys — `name` is shared with the span-level KEY_NAME. const KEY_EVENT_TIME = buildKey('time_unix_nano') const KEY_EVENT_ATTRIBUTES = buildKey('attributes') // Pre-encoded prefix for a span-event-attribute typed wrapper: // `[0x82 fixmap(2), 'type' fixstr, type fixint, '<value>_value' fixstr]`. // The hot path emits one of these constants + the raw value, so the encoder // never has to allocate `{ type: N, *_value: ... }` wrappers. function buildAttrPrefix (typeByte, valueKey) { const valueKeyBuf = buildKey(valueKey) const buf = Buffer.allocUnsafe(7 + valueKeyBuf.length) buf[0] = 0x82 buf[1] = 0xA4 buf[2] = 0x74 // t buf[3] = 0x79 // y buf[4] = 0x70 // p buf[5] = 0x65 // e buf[6] = typeByte valueKeyBuf.copy(buf, 7) return buf } const ATTR_PREFIX_STRING = buildAttrPrefix(0x00, 'string_value') const ATTR_PREFIX_BOOL = buildAttrPrefix(0x01, 'bool_value') const ATTR_PREFIX_INT = buildAttrPrefix(0x02, 'int_value') const ATTR_PREFIX_DOUBLE = buildAttrPrefix(0x03, 'double_value') // Outer array attribute is the only nested case: `[0x82, 'type', 4, // 'array_value', 0x81 fixmap(1), 'values', 0xDD array32-prefix]`. The 4-byte // length slot follows. const ATTR_PREFIX_ARRAY = Buffer.concat([ buildAttrPrefix(0x04, 'array_value'), Buffer.from([0x81]), buildKey('values'), Buffer.from([0xDD]), ]) // Pre-encoded boolean payloads: `[ATTR_PREFIX_BOOL, 0xC3 / 0xC2]`. Used by // `#emitAttribute` and `#emitArrayItem` to emit the whole bool wrapper in a // single `bytes.set`. const ATTR_PAYLOAD_BOOL_TRUE = Buffer.concat([ATTR_PREFIX_BOOL, Buffer.from([0xC3])]) const ATTR_PAYLOAD_BOOL_FALSE = Buffer.concat([ATTR_PREFIX_BOOL, Buffer.from([0xC2])]) function formatSpanWithLegacyEvents (span) { span = normalizeSpan(span) if (span.span_events) { span.meta.events = stringifySpanEvents(span.span_events) // `= undefined` over `delete` to keep the span's hidden class — `delete` // would push every event-bearing span into V8 dictionary mode. span.span_events = undefined } return span } /** * Hand-written stringifier for `span.span_events`. The shape is fixed by * `extractSpanEvents` (`{ name, time_unix_nano, attributes? }`) and attribute * values are pre-sanitized to primitives or arrays of primitives, so we can * skip everything `JSON.stringify` does for the generic case (toJSON probing, * key iteration over the prototype chain, replacer hooks). Output matches * `JSON.stringify(spanEvents)` byte-for-byte for the post-sanitization shape. * * @param {Array<{ name: string, time_unix_nano: number, attributes?: object }>} spanEvents * @returns {string} */ function stringifySpanEvents (spanEvents) { let result = '[' for (let index = 0; index < spanEvents.length; index++) { if (index > 0) result += ',' const event = spanEvents[index] // `addEvent` does not type-check `name`; defer the unusual cases to // `JSON.stringify` so non-string names match the prior behaviour // instead of throwing in `escapeJsonString`. if (typeof event.name !== 'string') { result += JSON.stringify(event) continue } result += '{"name":' + escapeJsonString(event.name) + ',"time_unix_nano":' + jsonNumber(event.time_unix_nano) if (event.attributes) { result += ',"attributes":' + stringifyAttributes(event.attributes) } result += '}' } return result + ']' } function stringifyAttributes (attributes) { let result = '{' let first = true for (const key of Object.keys(attributes)) { if (first) { first = false } else { result += ',' } result += escapeJsonString(key) + ':' + stringifyAttributeValue(attributes[key]) } return result + '}' } function stringifyAttributeValue (value) { if (typeof value === 'string') return escapeJsonString(value) if (typeof value === 'number') return jsonNumber(value) if (typeof value === 'boolean') return value ? 'true' : 'false' if (Array.isArray(value)) { let result = '[' for (let index = 0; index < value.length; index++) { if (index > 0) result += ',' result += stringifyAttributeValue(value[index]) } return result + ']' } // Sanitization rejects everything else, but keep the safety net. return 'null' } /** * Match `JSON.stringify` for numbers: `NaN` and `±Infinity` collapse to the * literal `null`, everything else uses ECMAScript's default `Number → String` * conversion (which is what `JSON.stringify` calls internally). * * @param {number} value * @returns {string} */ function jsonNumber (value) { if (Number.isFinite(value)) return String(value) return 'null' } /** * Fast path: scan once, and if no character in the string requires JSON * escaping, emit `"<str>"` as-is. The scanned chars are `"`, `\`, and any * control char in the U+0000–U+001F range. Anything else delegates to * `JSON.stringify` for full spec-compliant escaping (surrogate pairs, * lone surrogates, etc.). * * @param {string} value * @returns {string} */ function escapeJsonString (value) { for (let index = 0; index < value.length; index++) { const code = value.charCodeAt(index) if (code < 0x20 || code === 0x22 || code === 0x5C) { return JSON.stringify(value) } } return '"' + value + '"' } class AgentEncoder { #msgpack = new MsgpackEncoder() #limit #writer #config #debugEncoding #formatSpan constructor (writer, limit = SOFT_LIMIT) { this.#limit = limit this._traceBytes = new MsgpackChunk() this._stringBytes = new MsgpackChunk() this.#writer = writer this._reset() this.#config = getConfig() this.#debugEncoding = this.#config.DD_TRACE_ENCODING_DEBUG // Pick the per-span formatter once so the hot loop pays no per-span // config check. The native path doesn't need to reshape `span_events` // because `#encodeSpanEvents` works directly on the raw attributes. this.#formatSpan = this.#config.DD_TRACE_NATIVE_SPAN_EVENTS ? normalizeSpan : formatSpanWithLegacyEvents } count () { return this._traceCount } encode (trace) { const bytes = this._traceBytes const start = bytes.length this._traceCount++ this._encode(bytes, trace) if (this.#debugEncoding) { const end = bytes.length // eslint-disable-next-line eslint-rules/eslint-log-printf-style log.debug(() => { const hex = bytes.buffer.subarray(start, end).toString('hex').match(/../g).join(' ') return `Adding encoded trace to buffer: ${hex}` }) } // Soft limit overshoot is fine — the agent caps at 50 MB. if (this._traceBytes.length > this.#limit || this._stringBytes.length > this.#limit) { log.debug('Buffer went over soft limit, flushing') this.#writer.flush() } } makePayload () { const traceSize = this._traceBytes.length + 5 const buffer = Buffer.allocUnsafe(traceSize) this._writeTraces(buffer) this._reset() return buffer } reset () { this._reset() } _encode (bytes, trace) { this._encodeArrayPrefix(bytes, trace) const formatSpan = this.#formatSpan const stringMap = this._stringMap // Snapshot the string buffer so we can detect a mid-encode resize and // release the old backing store afterwards (see `#refreshStringCache`). const stringBufferAtStart = this._stringBytes.buffer for (let span of trace) { span = formatSpan(span) let mapSize = 11 if (span.type) mapSize++ if (span.meta_struct) mapSize++ if (span.span_events) mapSize++ // Pre-fetch the cached string entries up front and fuse the map prefix, // optional `type`, three IDs, and `name` / `resource` / `service` // emissions into a single `bytes.reserve` + sequential native writes. // Replaces seven `bytes.reserve` calls per span (one each for the // header, type, three IDs, three strings) with one. let typeEntry if (span.type) { typeEntry = stringMap[span.type] ?? this._cacheString(span.type) } const nameEntry = stringMap[span.name] ?? this._cacheString(span.name) const resourceEntry = stringMap[span.resource] ?? this._cacheString(span.resource) const serviceEntry = stringMap[span.service] ?? this._cacheString(span.service) const nameLen = nameEntry.length const resourceLen = resourceEntry.length const serviceLen = serviceEntry.length // 1 byte map prefix + 3 ID fields (10/9/11 bytes prefix + 8 bytes value // each) + the three string fields. let blockSize = 1 + KEY_TRACE_ID_PREFIX.length + 8 + KEY_SPAN_ID_PREFIX.length + 8 + KEY_PARENT_ID_PREFIX.length + 8 + KEY_NAME.length + nameLen + KEY_RESOURCE.length + resourceLen + KEY_SERVICE.length + serviceLen if (typeEntry) blockSize += KEY_TYPE.length + typeEntry.length const blockOffset = bytes.length bytes.reserve(blockSize) const target = bytes.buffer let cursor = blockOffset target[cursor++] = 0x80 + mapSize if (typeEntry) { target.set(KEY_TYPE, cursor) cursor += KEY_TYPE.length target.set(typeEntry, cursor) cursor += typeEntry.length } cursor = this.#writeIdAt(target, cursor, KEY_TRACE_ID_PREFIX, span.trace_id) cursor = this.#writeIdAt(target, cursor, KEY_SPAN_ID_PREFIX, span.span_id) cursor = this.#writeIdAt(target, cursor, KEY_PARENT_ID_PREFIX, span.parent_id) target.set(KEY_NAME, cursor) cursor += KEY_NAME.length target.set(nameEntry, cursor) cursor += nameLen target.set(KEY_RESOURCE, cursor) cursor += KEY_RESOURCE.length target.set(resourceEntry, cursor) cursor += resourceLen target.set(KEY_SERVICE, cursor) cursor += KEY_SERVICE.length target.set(serviceEntry, cursor) bytes.set(KEY_ERROR) this._encodeIntOrFloat(bytes, span.error) bytes.set(KEY_START) this._encodeIntOrFloat(bytes, span.start) bytes.set(KEY_DURATION) this._encodeIntOrFloat(bytes, span.duration) this.#encodeMetaEntries(bytes, KEY_META_PREFIX, span.meta) this.#encodeMetaEntries(bytes, KEY_METRICS_PREFIX, span.metrics) if (span.span_events) { bytes.set(KEY_SPAN_EVENTS) this.#encodeSpanEvents(bytes, span.span_events) } if (span.meta_struct) { bytes.set(KEY_META_STRUCT) this.#encodeMetaStruct(bytes, span.meta_struct) } } if (this._stringBytes.buffer !== stringBufferAtStart) { this.#refreshStringCache() } } /** * Rebuild the cached string subarrays in `_stringMap` against the current * `_stringBytes.buffer`. After `MsgpackChunk.reserve()` resizes, the prior * subarrays still reference the old `Buffer`'s `ArrayBuffer` and pin it * until `_reset()` clears the map; for a payload that grows 2 → 4 → 8 MB * that is up to 6 MB of avoidable peak memory. `Buffer.allocUnsafe(>= 2 * MB)` bypasses the small-allocation pool and starts at offset 0 in its * `ArrayBuffer`, so the old subarray's `byteOffset` is the entry's start * position in the new buffer. */ #refreshStringCache () { const newBuffer = this._stringBytes.buffer const stringMap = this._stringMap for (const key of Object.keys(stringMap)) { const old = stringMap[key] stringMap[key] = newBuffer.subarray(old.byteOffset, old.byteOffset + old.length) } } _reset () { this._traceCount = 0 this._traceBytes.length = 0 this._stringCount = 0 this._stringBytes.length = 0 this._stringMap = Object.create(null) this._cacheString('') } _encodeBuffer (bytes, buffer) { this.#msgpack.encodeBin(bytes, buffer) } _encodeBool (bytes, value) { this.#msgpack.encodeBoolean(bytes, value) } _encodeArrayPrefix (bytes, value) { this.#msgpack.encodeArrayPrefix(bytes, value) } _encodeMapPrefix (bytes, keysLength) { this.#msgpack.encodeMapPrefix(bytes, keysLength) } _encodeByte (bytes, value) { this.#msgpack.encodeByte(bytes, value) } // TODO: Use BigInt instead. _encodeId (bytes, identifier) { const idBuffer = identifier.toBuffer() const start = idBuffer.length - 8 const offset = bytes.length bytes.reserve(9) const target = bytes.buffer target[offset] = 0xCF target[offset + 1] = idBuffer[start] target[offset + 2] = idBuffer[start + 1] target[offset + 3] = idBuffer[start + 2] target[offset + 4] = idBuffer[start + 3] target[offset + 5] = idBuffer[start + 4] target[offset + 6] = idBuffer[start + 5] target[offset + 7] = idBuffer[start + 6] target[offset + 8] = idBuffer[start + 7] } _encodeNumber (bytes, value) { this.#msgpack.encodeNumber(bytes, value) } _encodeInteger (bytes, value) { this.#msgpack.encodeInteger(bytes, value) } _encodeLong (bytes, value) { this.#msgpack.encodeLong(bytes, value) } // Single pass: reserve the count slot, encode entries while counting, patch the count. // Subclasses (0.5, CI visibility encoders) inherit this; the wire stays on float64 // for numeric values to keep their established trace / events intake unchanged. _encodeMap (bytes, value) { const offset = bytes.length bytes.reserve(5) bytes.buffer[offset] = 0xDF let count = 0 for (const key of Object.keys(value)) { const entryValue = value[key] if (typeof entryValue === 'string') { this._encodeString(bytes, key) this._encodeString(bytes, entryValue) count++ } else if (typeof entryValue === 'number') { this._encodeString(bytes, key) this.#encodeFloat(bytes, entryValue) count++ } } const target = bytes.buffer target[offset + 1] = count >>> 24 target[offset + 2] = count >>> 16 target[offset + 3] = count >>> 8 target[offset + 4] = count } _encodeString (bytes, value = '') { const entry = this._stringMap[value] ?? this._cacheString(value) const length = entry.length const offset = bytes.length bytes.reserve(length) bytes.buffer.set(entry, offset) } _cacheString (value) { let entry = this._stringMap[value] if (entry === undefined) { this._stringCount++ const start = this._stringBytes.length const written = this._stringBytes.write(value) entry = this._stringBytes.buffer.subarray(start, start + written) this._stringMap[value] = entry } return entry } _writeArrayPrefix (buffer, offset, count) { buffer[offset++] = 0xDD buffer.writeUInt32BE(count, offset) return offset + 4 } _writeTraces (buffer, offset = 0) { offset = this._writeArrayPrefix(buffer, offset, this._traceCount) offset += this._traceBytes.buffer.copy(buffer, offset, 0, this._traceBytes.length) return offset } /** * Fast path for `span.meta` / `span.metrics`. Inlines the string cache so * each entry is one reserve (not two) and skips the polymorphic dispatch. * * @param {MsgpackChunk} bytes * @param {Buffer} keyPrefix Precomputed `[key, 0xDF]`. * @param {Record<string, unknown>} value */ #encodeMetaEntries (bytes, keyPrefix, value) { const keyPrefixLen = keyPrefix.length const headerOffset = bytes.length bytes.reserve(keyPrefixLen + 4) bytes.buffer.set(keyPrefix, headerOffset) const countOffset = headerOffset + keyPrefixLen const stringMap = this._stringMap let count = 0 for (const key of Object.keys(value)) { const entryValue = value[key] if (typeof entryValue !== 'string' && typeof entryValue !== 'number') continue const keyEntry = stringMap[key] ?? this._cacheString(key) const keyEntryLen = keyEntry.length const writeOffset = bytes.length if (typeof entryValue === 'string') { const valueEntry = stringMap[entryValue] ?? this._cacheString(entryValue) const valueEntryLen = valueEntry.length bytes.reserve(keyEntryLen + valueEntryLen) const target = bytes.buffer target.set(keyEntry, writeOffset) target.set(valueEntry, writeOffset + keyEntryLen) } else { bytes.reserve(keyEntryLen) bytes.buffer.set(keyEntry, writeOffset) this._encodeIntOrFloat(bytes, entryValue) } count++ } const target = bytes.buffer target[countOffset] = count >>> 24 target[countOffset + 1] = count >>> 16 target[countOffset + 2] = count >>> 8 target[countOffset + 3] = count } /** * Write `[keyPrefix, 8-byte uint64 id]` into `target` at `offset` and * return the new cursor. Caller is responsible for having reserved enough * room — this is the no-reserve variant used inside `_encode`'s combined * fixed-fields block. * * @param {Uint8Array} target * @param {number} offset * @param {Buffer} keyPrefix Precomputed `[key, 0xCF]`. * @param {{ toBuffer: () => Uint8Array | number[] }} identifier * @returns {number} */ #writeIdAt (target, offset, keyPrefix, identifier) { target.set(keyPrefix, offset) offset += keyPrefix.length const idBuffer = identifier.toBuffer() const start = idBuffer.length - 8 target[offset] = idBuffer[start] target[offset + 1] = idBuffer[start + 1] target[offset + 2] = idBuffer[start + 2] target[offset + 3] = idBuffer[start + 3] target[offset + 4] = idBuffer[start + 4] target[offset + 5] = idBuffer[start + 5] target[offset + 6] = idBuffer[start + 6] target[offset + 7] = idBuffer[start + 7] return offset + 8 } /** * Emit `value` as the smallest valid msgpack number encoding: compact * unsigned/signed int when integer, float64 otherwise. Unlike * `MsgpackEncoder#encodeNumber`, NaN keeps its float64 bits instead of * coercing to fixint 0. * * Underscore-protected so the 0.5 subclass can call it from its own * `_encode` / `_encodeMap` overrides. * * @param {MsgpackChunk} bytes * @param {number} value */ _encodeIntOrFloat (bytes, value) { // Fast path: positive fixint (0..127). `value === (value & 0x7F)` is true // iff `value` is an exact integer in that range — covers `error: 0/1`, // priority flags, attribute counts, HTTP status codes mapped to numbers, // and most small metrics. NaN, ±Infinity, negatives, and any non-integer // float fall through. if (value === (value & 0x7F)) { const offset = bytes.length bytes.reserve(1) bytes.buffer[offset] = value return } if (Number.isInteger(value)) { if (value >= 0) { this.#msgpack.encodeUnsigned(bytes, value) } else { this.#msgpack.encodeSigned(bytes, value) } } else { this.#encodeFloat(bytes, value) } } /** * @param {MsgpackChunk} bytes * @param {string | number | boolean} value */ #encodeValue (bytes, value) { switch (typeof value) { case 'string': this._encodeString(bytes, value) break case 'number': this.#encodeFloat(bytes, value) break case 'boolean': this._encodeBool(bytes, value) break } } #encodeFloat (bytes, value) { this.#msgpack.encodeFloat(bytes, value) } #encodeMetaStruct (bytes, value) { if (Array.isArray(value)) { this._encodeMapPrefix(bytes, 0) return } const offset = bytes.length bytes.reserve(5) bytes.buffer[offset] = 0xDF let count = 0 for (const key of Object.keys(value)) { const entryValue = value[key] if (typeof entryValue === 'string' || typeof entryValue === 'number' || (entryValue !== null && typeof entryValue === 'object')) { this._encodeString(bytes, key) this.#encodeObjectAsByteArray(bytes, entryValue) count++ } } const target = bytes.buffer target[offset + 1] = count >>> 24 target[offset + 2] = count >>> 16 target[offset + 3] = count >>> 8 target[offset + 4] = count } #encodeObjectAsByteArray (bytes, value) { const prefixLength = 5 const offset = bytes.length bytes.reserve(prefixLength) this.#encodeObject(bytes, value) // The byte length isn't known until the inner object has been encoded. const length = bytes.length - offset - prefixLength bytes.buffer[offset] = 0xC6 bytes.buffer[offset + 1] = length >> 24 bytes.buffer[offset + 2] = length >> 16 bytes.buffer[offset + 3] = length >> 8 bytes.buffer[offset + 4] = length } #encodeObject (bytes, value, circularReferencesDetector = new Set()) { circularReferencesDetector.add(value) if (Array.isArray(value)) { this.#encodeObjectAsArray(bytes, value, circularReferencesDetector) } else if (value !== null && typeof value === 'object') { this.#encodeObjectAsMap(bytes, value, circularReferencesDetector) } else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { this.#encodeValue(bytes, value) } } #encodeObjectAsMap (bytes, value, circularReferencesDetector) { const offset = bytes.length bytes.reserve(5) bytes.buffer[offset] = 0xDF let count = 0 for (const key of Object.keys(value)) { const entryValue = value[key] if (typeof entryValue === 'string' || typeof entryValue === 'number' || typeof entryValue === 'boolean' || (entryValue !== null && typeof entryValue === 'object' && !circularReferencesDetector.has(entryValue))) { this._encodeString(bytes, key) this.#encodeObject(bytes, entryValue, circularReferencesDetector) count++ } } const target = bytes.buffer target[offset + 1] = count >>> 24 target[offset + 2] = count >>> 16 target[offset + 3] = count >>> 8 target[offset + 4] = count } #encodeObjectAsArray (bytes, value, circularReferencesDetector) { const offset = bytes.length bytes.reserve(5) bytes.buffer[offset] = 0xDD let count = 0 for (const item of value) { if (typeof item === 'string' || typeof item === 'number' || (item !== null && typeof item === 'object' && !circularReferencesDetector.has(item))) { this.#encodeObject(bytes, item, circularReferencesDetector) count++ } } const target = bytes.buffer target[offset + 1] = count >>> 24 target[offset + 2] = count >>> 16 target[offset + 3] = count >>> 8 target[offset + 4] = count } /** * Specialized encoder for `span.span_events`. Walks the events directly, * emitting OTel attribute typed wrappers inline from the raw attribute * values — no `formatSpanEvents` pre-pass and no recursive generic walk. * * @param {MsgpackChunk} bytes * @param {Array<{ name: string, time_unix_nano: number, attributes?: object }>} spanEvents */ #encodeSpanEvents (bytes, spanEvents) { const offset = bytes.length bytes.reserve(5) bytes.buffer[offset] = 0xDD let arrayCount = 0 for (const event of spanEvents) { // `addEvent` and the OTel bridge do not type-check `name`, and a // non-string would throw downstream in `Buffer.byteLength`. Drop the // bad event silently so the rest of the trace still encodes. if (event === null || typeof event !== 'object' || typeof event.name !== 'string') continue const eventHeaderOffset = bytes.length bytes.reserve(1) bytes.buffer[eventHeaderOffset] = 0x82 bytes.set(KEY_NAME) this._encodeString(bytes, event.name) bytes.set(KEY_EVENT_TIME) this.#encodeFloat(bytes, event.time_unix_nano) const attributes = event.attributes if (attributes !== null && typeof attributes === 'object') { this.#encodeAttributesIfAny(bytes, attributes, eventHeaderOffset) } arrayCount++ } const target = bytes.buffer target[offset + 1] = arrayCount >>> 24 target[offset + 2] = arrayCount >>> 16 target[offset + 3] = arrayCount >>> 8 target[offset + 4] = arrayCount } /** * Optimistically emits the `'attributes'` slot for an event. If every entry * filters out, the partial output is rewound and the event's map header * stays at 2 entries. * * @param {MsgpackChunk} bytes * @param {Record<string, unknown>} attributes * @param {number} eventHeaderOffset */ #encodeAttributesIfAny (bytes, attributes, eventHeaderOffset) { const sectionStart = bytes.length bytes.set(KEY_EVENT_ATTRIBUTES) const countOffset = bytes.length bytes.reserve(5) bytes.buffer[countOffset] = 0xDF let count = 0 for (const key of Object.keys(attributes)) { if (this.#emitAttribute(bytes, key, attributes[key])) count++ } if (count === 0) { bytes.length = sectionStart return } const target = bytes.buffer target[countOffset + 1] = count >>> 24 target[countOffset + 2] = count >>> 16 target[countOffset + 3] = count >>> 8 target[countOffset + 4] = count bytes.buffer[eventHeaderOffset] = 0x83 } /** * Emit `<key, typed_wrapper>` for a single attribute. Returns true on a * supported type, false (with a memoized debug log) otherwise. * * @param {MsgpackChunk} bytes * @param {string} key * @param {unknown} value * @returns {boolean} */ #emitAttribute (bytes, key, value) { if (typeof value === 'string') { this._encodeString(bytes, key) bytes.set(ATTR_PREFIX_STRING) this._encodeString(bytes, value) return true } if (typeof value === 'number') { this._encodeString(bytes, key) bytes.set(Number.isInteger(value) ? ATTR_PREFIX_INT : ATTR_PREFIX_DOUBLE) this._encodeIntOrFloat(bytes, value) return true } if (typeof value === 'boolean') { this._encodeString(bytes, key) bytes.set(value ? ATTR_PAYLOAD_BOOL_TRUE : ATTR_PAYLOAD_BOOL_FALSE) return true } if (Array.isArray(value)) { return this.#emitArrayAttribute(bytes, key, value) } memoizedLogDebug(key, 'Encountered unsupported data type for span event v0.4 encoding, key: ' + `${key}: with value: ${typeof value}. Skipping encoding of pair.` ) return false } /** * Emit `<key, { type: 4, array_value: { values: [...] } }>` from a raw * array of primitives. Filters nested arrays / unsupported items; if no * items remain the whole entry is rewound. * * @param {MsgpackChunk} bytes * @param {string} key * @param {Array<unknown>} array * @returns {boolean} */ #emitArrayAttribute (bytes, key, array) { const sectionStart = bytes.length this._encodeString(bytes, key) bytes.set(ATTR_PREFIX_ARRAY) const arrayCountOffset = bytes.length bytes.reserve(4) let count = 0 for (const item of array) { if (this.#emitArrayItem(bytes, key, item)) count++ } if (count === 0) { bytes.length = sectionStart return false } const target = bytes.buffer target[arrayCountOffset] = count >>> 24 target[arrayCountOffset + 1] = count >>> 16 target[arrayCountOffset + 2] = count >>> 8 target[arrayCountOffset + 3] = count return true } /** * Emit a single typed wrapper inside an `array_value.values` array. No * recursion: nested arrays are dropped with a memoized debug log. * * @param {MsgpackChunk} bytes * @param {string} key * @param {unknown} value * @returns {boolean} */ #emitArrayItem (bytes, key, value) { if (typeof value === 'string') { bytes.set(ATTR_PREFIX_STRING) this._encodeString(bytes, value) return true } if (typeof value === 'number') { bytes.set(Number.isInteger(value) ? ATTR_PREFIX_INT : ATTR_PREFIX_DOUBLE) this._encodeIntOrFloat(bytes, value) return true } if (typeof value === 'boolean') { bytes.set(value ? ATTR_PAYLOAD_BOOL_TRUE : ATTR_PAYLOAD_BOOL_FALSE) return true } if (Array.isArray(value)) { memoizedLogDebug(key, 'Encountered nested array data type for span event v0.4 encoding. ' + `Skipping encoding key: ${key}: with value: ${typeof value}.` ) } return false } } const seenKeys = new Set() function memoizedLogDebug (key, message) { if (!seenKeys.has(key)) { seenKeys.add(key) log.debug(message) } } module.exports = { AgentEncoder, stringifySpanEvents }