diff --git a/packages/dd-trace/src/opentelemetry/trace/otlp_transformer.js b/packages/dd-trace/src/opentelemetry/trace/otlp_transformer.js index debc7ffac37..38a0f685f17 100644 --- a/packages/dd-trace/src/opentelemetry/trace/otlp_transformer.js +++ b/packages/dd-trace/src/opentelemetry/trace/otlp_transformer.js @@ -2,7 +2,9 @@ const OtlpTransformerBase = require('../otlp/otlp_transformer_base') const { getProtobufTypes } = require('../otlp/protobuf_loader') +const { AUTO_KEEP } = require('../../../../../ext/priority') const { VERSION } = require('../../../../../version') +const { SAMPLING_PRIORITY_KEY } = require('../../constants') const id = require('../../id') const { eventTimeNano } = require('../../encode/tags-processors') @@ -52,6 +54,7 @@ const TRACE_ID_128 = '_dd.p.tid' * @property {number} start - Start time in nanoseconds since epoch * @property {number} duration - Duration in nanoseconds * @property {DDSpanEvent[]} [span_events] - Span events + * @property {string} [trace_state] - W3C tracestate for OTLP export */ // Map DD span.kind string values to OTLP SpanKind numeric values @@ -161,11 +164,13 @@ class OtlpTraceTransformer extends OtlpTransformerBase { #transformSpan (span, traceIdHigh) { const parentId = span.parent_id const links = this.#extractLinks(span.meta?.['_dd.span_links']) + const samplingPriority = span.metrics?.[SAMPLING_PRIORITY_KEY] return { traceId: span.trace_id.toTraceIdHex(traceIdHigh).padStart(32, '0'), spanId: this.#idToBytes(span.span_id, 8), parentSpanId: (parentId && !parentId.equals(ZERO_ID)) ? this.#idToBytes(parentId, 8) : undefined, + traceState: span.trace_state, name: span.resource, kind: this.#mapSpanKind(span.meta?.['span.kind']), startTimeUnixNano: span.start, @@ -177,6 +182,7 @@ class OtlpTraceTransformer extends OtlpTransformerBase { links: links.length ? links : undefined, droppedLinksCount: 0, status: this.#mapStatus(span), + flags: typeof samplingPriority === 'number' ? (samplingPriority >= AUTO_KEEP ? 1 : 0) : undefined, } } diff --git a/packages/dd-trace/src/opentracing/propagation/text_map.js b/packages/dd-trace/src/opentracing/propagation/text_map.js index d77089f4a49..ae8a8124abc 100644 --- a/packages/dd-trace/src/opentracing/propagation/text_map.js +++ b/packages/dd-trace/src/opentracing/propagation/text_map.js @@ -45,6 +45,7 @@ const telemetryMetrics = require('../../telemetry/metrics') const { DD_MAJOR } = require('../../../../../version') const { AUTO_KEEP, AUTO_REJECT, USER_KEEP } = require('../../../../../ext/priority') +const { formatTraceState, hasTraceTagReplacement } = require('./tracecontext') const TraceState = require('./tracestate') const tracerMetrics = telemetryMetrics.manager.namespace('tracers') @@ -66,19 +67,11 @@ const tagValueExpr = /^[\x20-\x2B\x2D-\x7E]*$/ // ASCII minus commas // https://github.com/nodejs/node/blob/main/lib/_http_common.js const invalidHeaderValueCharExpr = /[^\t\x20-\x7E\x80-\xFF]/ const traceparentExpr = /^([a-f0-9]{2})-([a-f0-9]{32})-([a-f0-9]{16})-([a-f0-9]{2})(-.*)?$/i -// Origin value in tracestate replaces '~', ',' and ';' with '_" -const tracestateOriginFilter = /[^\x20-\x2B\x2D-\x3A\x3C-\x7D]/g -// Tag keys in tracestate replace ' ', ',' and '=' with '_' -const tracestateTagKeyFilter = /[^\x21-\x2B\x2D-\x3C\x3E-\x7E]/g -// Tag values in tracestate replace ',', '~' and ';' with '_' -const tracestateTagValueFilter = /[^\x20-\x2B\x2D-\x3A\x3C-\x7D]/g const invalidSegment = /^0+$/ const zeroTraceId = '0000000000000000' const hex16 = /^[0-9A-Fa-f]{16}$/ const percentByte = /%([0-9A-Fa-f]{2})/g -let updateOtelTraceState - /** * @typedef {object} B3Context * @property {string} [flags] @@ -87,18 +80,6 @@ let updateOtelTraceState * @property {string} [traceId] */ -/** - * @param {Array} traceTagReplacements - * @param {string} key - * @returns {boolean} - */ -function hasTraceTagReplacement (traceTagReplacements, key) { - for (let index = 0; index < traceTagReplacements.length; index += 2) { - if (traceTagReplacements[index] === key) return true - } - return false -} - /** * @param {string | undefined} traceId * @param {string | undefined} spanId @@ -605,82 +586,8 @@ class TextMapPropagator { } carrier ??= {} - const { - _sampling: { priority, mechanism }, - _tracestate, - _trace: { origin }, - } = spanContext - const ts = traceTagReplacements - ? TraceState.fromString(_tracestate?.toString()) - : _tracestate ?? new TraceState() - writeTraceparent(carrier, spanContext.toTraceparent()) - - updateOtelTraceState ??= require('../../otel-sampling').updateOtelTraceState - updateOtelTraceState(spanContext, ts) - - ts.forVendor('dd', state => { - if (!spanContext._isRemote) { - // SpanContext was created by a ddtrace span. - // Last datadog span id should be set to the current span. - state.set('p', spanContext._spanId) - } else if (spanContext._trace.tags[tags.DD_PARENT_ID]) { - // Propagate the last Datadog span id set on the remote span. - state.set('p', spanContext._trace.tags[tags.DD_PARENT_ID]) - } - state.set('s', priority) - if (mechanism) { - state.set('t.dm', `-${mechanism}`) - } - - if (typeof origin === 'string') { - const originValue = origin - .replaceAll(tracestateOriginFilter, '_') - .replaceAll('=', '~') - - state.set('o', originValue) - } - - for (const key of Object.keys(spanContext._trace.tags)) { - if (traceTagReplacements && hasTraceTagReplacement(traceTagReplacements, key)) continue - const tagValueRaw = spanContext._trace.tags[key] - if (!tagValueRaw || !key.startsWith('_dd.p.')) continue - - const tagKey = 't.' + key.slice(6) - .replaceAll(tracestateTagKeyFilter, '_') - - const tagValue = tagValueRaw - .toString() - .replaceAll(tracestateTagValueFilter, '_') - .replaceAll('=', '~') - - state.set(tagKey, tagValue) - } - - if (traceTagReplacements) { - for (let index = 0; index < traceTagReplacements.length; index += 2) { - const key = traceTagReplacements[index] - if (!key.startsWith('_dd.p.')) continue - - const tagKey = 't.' + key.slice(6) - .replaceAll(tracestateTagKeyFilter, '_') - const tagValueRaw = traceTagReplacements[index + 1] - if (!tagValueRaw) { - state.delete(tagKey) - continue - } - - const tagValue = tagValueRaw - .toString() - .replaceAll(tracestateTagValueFilter, '_') - .replaceAll('=', '~') - - state.set(tagKey, tagValue) - } - } - }) - - writeTracestate(carrier, ts.toString()) + writeTracestate(carrier, formatTraceState(spanContext, traceTagReplacements)) return carrier } diff --git a/packages/dd-trace/src/opentracing/propagation/tracecontext.js b/packages/dd-trace/src/opentracing/propagation/tracecontext.js new file mode 100644 index 00000000000..0ec7b80f69c --- /dev/null +++ b/packages/dd-trace/src/opentracing/propagation/tracecontext.js @@ -0,0 +1,111 @@ +'use strict' + +const tags = require('../../../../../ext/tags') +const TraceState = require('./tracestate') + +// Origin value in tracestate replaces '~', ',' and ';' with '_'. +const tracestateOriginFilter = /[^\x20-\x2B\x2D-\x3A\x3C-\x7D]/g +// Tag keys in tracestate replace ' ', ',' and '=' with '_'. +const tracestateTagKeyFilter = /[^\x21-\x2B\x2D-\x3C\x3E-\x7E]/g +// Tag values in tracestate replace ',', '~' and ';' with '_'. +const tracestateTagValueFilter = /[^\x20-\x2B\x2D-\x3A\x3C-\x7D]/g + +let updateOtelTraceState + +/** + * @param {Array} traceTagReplacements + * @param {string} key + * @returns {boolean} + */ +function hasTraceTagReplacement (traceTagReplacements, key) { + for (let index = 0; index < traceTagReplacements.length; index += 2) { + if (traceTagReplacements[index] === key) return true + } + return false +} + +/** + * Builds the W3C tracestate for propagation or OTLP export from the live span context. + * + * @param {import('../span_context')} spanContext + * @param {Array} [traceTagReplacements] + * @returns {string} + */ +function formatTraceState (spanContext, traceTagReplacements) { + const { + _sampling: { priority, mechanism }, + _tracestate, + _trace: { origin }, + } = spanContext + const traceState = traceTagReplacements + ? TraceState.fromString(_tracestate?.toString()) + : _tracestate ?? new TraceState() + + updateOtelTraceState ??= require('../../otel-sampling').updateOtelTraceState + updateOtelTraceState(spanContext, traceState) + + traceState.forVendor('dd', state => { + if (!spanContext._isRemote) { + state.set('p', spanContext._spanId) + } else if (spanContext._trace.tags[tags.DD_PARENT_ID]) { + state.set('p', spanContext._trace.tags[tags.DD_PARENT_ID]) + } + state.set('s', priority) + if (mechanism) { + state.set('t.dm', `-${mechanism}`) + } + + if (typeof origin === 'string') { + const originValue = origin + .replaceAll(tracestateOriginFilter, '_') + .replaceAll('=', '~') + + state.set('o', originValue) + } + + for (const key of Object.keys(spanContext._trace.tags)) { + if (traceTagReplacements && hasTraceTagReplacement(traceTagReplacements, key)) continue + const tagValueRaw = spanContext._trace.tags[key] + if (!tagValueRaw || !key.startsWith('_dd.p.')) continue + + const tagKey = 't.' + key.slice(6) + .replaceAll(tracestateTagKeyFilter, '_') + + const tagValue = tagValueRaw + .toString() + .replaceAll(tracestateTagValueFilter, '_') + .replaceAll('=', '~') + + state.set(tagKey, tagValue) + } + + if (traceTagReplacements) { + for (let index = 0; index < traceTagReplacements.length; index += 2) { + const key = traceTagReplacements[index] + if (!key.startsWith('_dd.p.')) continue + + const tagKey = 't.' + key.slice(6) + .replaceAll(tracestateTagKeyFilter, '_') + const tagValueRaw = traceTagReplacements[index + 1] + if (!tagValueRaw) { + state.delete(tagKey) + continue + } + + const tagValue = tagValueRaw + .toString() + .replaceAll(tracestateTagValueFilter, '_') + .replaceAll('=', '~') + + state.set(tagKey, tagValue) + } + } + }) + + return traceState.toString() +} + +module.exports = { + formatTraceState, + hasTraceTagReplacement, +} diff --git a/packages/dd-trace/src/opentracing/tracer.js b/packages/dd-trace/src/opentracing/tracer.js index d2e9036dedb..fed3517d81c 100644 --- a/packages/dd-trace/src/opentracing/tracer.js +++ b/packages/dd-trace/src/opentracing/tracer.js @@ -39,8 +39,9 @@ class DatadogTracer { // silently lose all test spans. The same applies to the Electron exporter: // spans must reach the Electron SDK's IPC bridge, not an OTLP endpoint, // even when OTEL_* vars are set for unrelated telemetry. - if (config.OTEL_TRACES_EXPORTER === 'otlp' && !config.isCiVisibility && - config.experimental.exporter !== 'electron') { + const exportOtlpTraces = config.OTEL_TRACES_EXPORTER === 'otlp' && !config.isCiVisibility && + config.experimental.exporter !== 'electron' + if (exportOtlpTraces) { const { createOtlpTraceExporter } = require('../opentelemetry/trace') this._exporter = createOtlpTraceExporter(config) } else { @@ -53,7 +54,13 @@ class DatadogTracer { const { createOtlpSpanStatsExporter } = require('../opentelemetry/metrics') otlpStatsExporter = createOtlpSpanStatsExporter(config) } - this._processor = new SpanProcessor(this._exporter, this._prioritySampler, config, otlpStatsExporter) + this._processor = new SpanProcessor( + this._exporter, + this._prioritySampler, + config, + otlpStatsExporter, + exportOtlpTraces + ) this._url = this._exporter._url this._enableGetRumData = config.experimental.enableGetRumData this._traceId128BitGenerationEnabled = config.traceId128BitGenerationEnabled diff --git a/packages/dd-trace/src/span_processor.js b/packages/dd-trace/src/span_processor.js index 727631a1085..47dd80058d8 100644 --- a/packages/dd-trace/src/span_processor.js +++ b/packages/dd-trace/src/span_processor.js @@ -6,17 +6,40 @@ const SpanSampler = require('./span_sampler') const GitMetadataTagger = require('./git_metadata_tagger') const processTags = require('./process-tags') const { applyHttpOtelSemantics } = require('./plugins/util/http-otel-semantics') +const { formatTraceState } = require('./opentracing/propagation/tracecontext') const { APM_TRACING_ENABLED_KEY } = require('./constants') const startedSpans = new WeakSet() const finishedSpans = new WeakSet() +/** + * Adds first-class OTLP trace context to a DD-formatted span. + * + * @param {import('./opentracing/span')} span + * @param {boolean} isFirstSpanInChunk + * @param {string | false} processTagsValue + * @returns {import('./span_format').FormattedSpan} + */ +function formatOtlpSpan (span, isFirstSpanInChunk, processTagsValue) { + const formattedSpan = spanFormat(span, isFirstSpanInChunk, processTagsValue) + formattedSpan.trace_state = formatTraceState(span.context()) + return formattedSpan +} + class SpanProcessor { - constructor (exporter, prioritySampler, config, otlpStatsExporter) { + /** + * @param {object} exporter + * @param {import('./priority_sampler')} prioritySampler + * @param {object} config + * @param {import('./opentelemetry/metrics/otlp_span_stats_exporter').OtlpStatsExporter} [otlpStatsExporter] + * @param {boolean} [exportOtlpTraces] + */ + constructor (exporter, prioritySampler, config, otlpStatsExporter, exportOtlpTraces) { this._exporter = exporter this._prioritySampler = prioritySampler this._config = config this._killAll = false + this._formatSpan = exportOtlpTraces ? formatOtlpSpan : spanFormat if (config.stats?.DD_TRACE_STATS_COMPUTATION_ENABLED && !config.appsec?.standalone?.enabled) { const { SpanStatsProcessor } = require('./span_stats') @@ -56,12 +79,13 @@ class SpanProcessor { let isFirstSpanInChunk = true const stampApmDisabled = this._config.apmTracingEnabled === false + const formatSpan = this._formatSpan for (const span of started) { if (span._duration === undefined) { active.push(span) } else { - const formattedSpan = spanFormat(span, isFirstSpanInChunk, this._processTags) + const formattedSpan = formatSpan(span, isFirstSpanInChunk, this._processTags) if (stampApmDisabled) { formattedSpan.metrics[APM_TRACING_ENABLED_KEY] = 0 } diff --git a/packages/dd-trace/test/opentelemetry/traces.spec.js b/packages/dd-trace/test/opentelemetry/traces.spec.js index d448b10c03e..74e2a1d3f7d 100644 --- a/packages/dd-trace/test/opentelemetry/traces.spec.js +++ b/packages/dd-trace/test/opentelemetry/traces.spec.js @@ -229,6 +229,21 @@ describe('OpenTelemetry Traces', () => { assert.strictEqual(otlpSpan.parentSpanId.length, 16, 'parentSpanId must be 16 hex chars (8 bytes)') }) + it('exports W3C tracestate and the sampled flag as first-class OTLP fields', () => { + const transformer = new OtlpTraceTransformer({}) + const traceState = 'dd=s:1,ot=rv:ef284ace7a91e1;th:e6666666666668' + const span = createMockSpan({ + trace_state: traceState, + metrics: { _sampling_priority_v1: 1 }, + }) + + const decoded = decodePayload(transformer.transformSpans([span])) + const otlpSpan = decoded.resourceSpans[0].scopeSpans[0].spans[0] + + assert.strictEqual(otlpSpan.traceState, traceState) + assert.strictEqual(otlpSpan.flags, 1) + }) + it('maps span kind correctly', () => { const transformer = new OtlpTraceTransformer({}) diff --git a/packages/dd-trace/test/span_processor.spec.js b/packages/dd-trace/test/span_processor.spec.js index 06433980cd6..632a4bc6668 100644 --- a/packages/dd-trace/test/span_processor.spec.js +++ b/packages/dd-trace/test/span_processor.spec.js @@ -23,6 +23,7 @@ describe('SpanProcessor', () => { let spanFormat let config let SpanSampler + let formatTraceState let sample before(() => { @@ -67,6 +68,7 @@ describe('SpanProcessor', () => { appsec: {}, } spanFormat = sinon.stub().returns({ formatted: true }) + formatTraceState = sinon.stub().returns('dd=s:1,ot=rv:ef284ace7a91e1;th:e6666666666668') sample = sinon.stub() SpanSampler = sinon.stub().returns({ @@ -76,6 +78,7 @@ describe('SpanProcessor', () => { SpanProcessor = proxyquire('../src/span_processor', { './span_format': spanFormat, './span_sampler': SpanSampler, + './opentracing/propagation/tracecontext': { formatTraceState }, }) processor = new SpanProcessor(exporter, prioritySampler, config) }) @@ -235,6 +238,32 @@ describe('SpanProcessor', () => { sinon.assert.calledWith(spanFormat.getCall(3), finishedSpan, false, processor._processTags) }) + it('should add live tracestate to spans exported through OTLP', () => { + const formattedSpan = { metrics: {} } + spanFormat.returns(formattedSpan) + trace.started = [finishedSpan] + trace.finished = [finishedSpan] + const processor = new SpanProcessor(exporter, prioritySampler, config, undefined, true) + + processor.process(finishedSpan) + + assert.strictEqual(formattedSpan.trace_state, 'dd=s:1,ot=rv:ef284ace7a91e1;th:e6666666666668') + sinon.assert.calledWith(formatTraceState, finishedSpan.context()) + sinon.assert.calledWith(exporter.export, [formattedSpan]) + }) + + it('should not build tracestate for the Datadog exporter', () => { + const formattedSpan = { metrics: {} } + spanFormat.returns(formattedSpan) + trace.started = [finishedSpan] + trace.finished = [finishedSpan] + + processor.process(finishedSpan) + + assert.ok(!Object.hasOwn(formattedSpan, 'trace_state')) + sinon.assert.notCalled(formatTraceState) + }) + it('should add APM disabled marker to every span in a chunk when APM tracing is disabled', () => { config.apmTracingEnabled = false config.flushMinSpans = 2