Skip to content

Commit 550b5a2

Browse files
mabdinurBridgeAR
authored andcommitted
fix(otlp): align trace metric attributes and aggregation (#9685)
* fix(otlp): align trace-metrics attributes with the RFC attribute spec Fixes several OTLP trace-metrics (traces.span.sdk.metrics.duration) attribute gaps found in a cross-tracer audit against SEMCON-1093: - datadog.process_tags now emits one array-valued resource attribute (mirroring the legacy v0.6/stats ProcessTags shape) instead of flattening each tag into its own datadog.<key> attribute. - datadog.is_trace_root is now emitted per data point, gated the same way as the other datadog.* attributes. - span.kind is canonicalized to the OTel Span Metrics Connector's SPAN_KIND_* uppercase convention instead of being passed through lowercase. - status.code is now a required string attribute (STATUS_CODE_OK/STATUS_CODE_ERROR) on every data point, replacing the previous OTLP-trace-style int enum that was only set on errors. additional_metric_tags and peer_tags remain unimplemented on this path; both are flagged with a one-line TODO pointing at the corresponding gap (or its absence) in the legacy v0.6/stats exporter. * fix(otlp): always emit service.name on trace-metrics data points service.name was omitted from OTLP trace-metrics data points whenever a span's service matched the default/global service, mirroring the status.code fix earlier in this branch: required attributes must be unconditional, not skipped as an optimization. * fix(otlp): coalesce trace metrics by exported attributes * fix(otlp): finalize trace metrics semantics * fix(otlp): retain semantic attributes in OTel mode * fix(otlp): defer trace-root attribute detection * test(otlp): reserve only datadog attributes * fix(otlp): omit unknown trace-root attribute * fix(otlp): always emit trace metric attributes * fix(otlp): guard span kind mapping * test(otlp): cover unspecified span kind fallback * fix(otlp): include service-entry metrics and tracer tags * fix(otlp): harden tracer tags and service-entry tracking * fix(otlp): retain services across partial flushes * fix(otlp): weakly retain cached span services * perf(otlp): reduce trace metric processing overhead * perf(otlp): remove service tracking from stats hot path Native stats without OTLP regressed from a 1.26s to 1.43s benchmark median because service-boundary state was tracked for every span. Removing that tracking restores the master baseline; the behavior will move to an OTLP-gated follow-up. * chore(otlp): keep span processor out of core changes * refactor(otlp): rely on initialized resource inputs * refactor(stats): pass trace-root flag directly * perf(otlp): reuse span metric attribute key Serialize base attributes once per aggregation group and append the top-level and status dimensions for each distribution key. Key-generation microbenchmark, two fresh runs with five-trial medians: 570.6ms to 126.9ms and 569.9ms to 127.3ms. * perf(otlp): avoid redundant attribute transforms Build the known top-level and status OTLP values directly instead of copying and re-transforming the base attributes.
1 parent 1d1fabf commit 550b5a2

7 files changed

Lines changed: 393 additions & 123 deletions

File tree

packages/dd-trace/src/opentelemetry/metrics/index.js

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const MeterProvider = require('./meter_provider')
1010
const PeriodicMetricReader = require('./periodic_metric_reader')
1111
const OtlpHttpMetricExporter = require('./otlp_http_metric_exporter')
1212

13+
const RESERVED_TRACER_TAGS = new Set(['service', 'env', 'version', 'runtime_id', 'runtime-id'])
14+
1315
/**
1416
* @typedef {import('../../config')} Config
1517
*/
@@ -78,7 +80,16 @@ function initializeOpenTelemetryMetrics (config) {
7880
metrics.setGlobalMeterProvider(meterProvider)
7981
}
8082

81-
function buildResourceAttributes (tags, { reportHostname, otelSemanticsEnabled, service, env, serviceVersion } = {}) {
83+
/**
84+
* @param {Record<string, unknown>} tags
85+
* @param {object} [options]
86+
* @param {boolean} [options.reportHostname]
87+
* @param {string} [options.service]
88+
* @param {string} [options.env]
89+
* @param {string} [options.serviceVersion]
90+
* @returns {import('@opentelemetry/api').Attributes}
91+
*/
92+
function buildResourceAttributes (tags, { reportHostname, service, env, serviceVersion } = {}) {
8293
const attrs = {
8394
'telemetry.sdk.name': 'datadog',
8495
'telemetry.sdk.language': 'nodejs',
@@ -89,14 +100,19 @@ function buildResourceAttributes (tags, { reportHostname, otelSemanticsEnabled,
89100
if (env) attrs['deployment.environment.name'] = env
90101
if (reportHostname) attrs['host.name'] = os.hostname()
91102

92-
if (!otelSemanticsEnabled) {
93-
if (tags?.['runtime-id']) attrs['datadog.runtime_id'] = tags['runtime-id']
94-
const processTagsObject = processTags.tagsObject
95-
if (processTagsObject) {
96-
for (const key of Object.keys(processTagsObject)) {
97-
attrs[`datadog.${key}`] = processTagsObject[key]
98-
}
99-
}
103+
if (tags['runtime-id']) attrs['datadog.runtime_id'] = tags['runtime-id']
104+
const tracerTags = []
105+
for (const [key, value] of Object.entries(tags)) {
106+
const valueType = typeof value
107+
const supported = valueType === 'string' || valueType === 'boolean' ||
108+
(valueType === 'number' && Number.isFinite(value))
109+
if (!RESERVED_TRACER_TAGS.has(key) && supported) tracerTags.push(`${key}:${value}`)
110+
}
111+
if (tracerTags.length) attrs['datadog.tracer_tags'] = tracerTags
112+
// Mirrors the legacy v0.6/stats ProcessTags shape (buildProcessTags().tagsArray); keep both in sync.
113+
const processTagsArray = processTags.tagsArray
114+
if (processTagsArray.length) {
115+
attrs['datadog.process_tags'] = processTagsArray
100116
}
101117
return attrs
102118
}
@@ -106,7 +122,6 @@ function createOtlpSpanStatsExporter (config) {
106122
const protocol = config.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL || 'http/json'
107123
const resourceAttributes = buildResourceAttributes(config.tags, {
108124
reportHostname: config.reportHostname,
109-
otelSemanticsEnabled: config.DD_TRACE_OTEL_SEMANTICS_ENABLED,
110125
service: config.service,
111126
env: config.env,
112127
serviceVersion: config.version,
@@ -115,8 +130,6 @@ function createOtlpSpanStatsExporter (config) {
115130
config.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT,
116131
protocol,
117132
resourceAttributes,
118-
config.DD_TRACE_OTEL_SEMANTICS_ENABLED,
119-
config.service,
120133
config.OTEL_EXPORTER_OTLP_METRICS_HEADERS,
121134
config.OTEL_EXPORTER_OTLP_METRICS_TIMEOUT
122135
)

packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_exporter.js

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,12 @@ class OtlpStatsExporter extends OtlpHttpExporterBase {
1111
* @param {string} url
1212
* @param {string} protocol
1313
* @param {import('@opentelemetry/api').Attributes} resourceAttributes
14-
* @param {boolean} [otelSemanticsEnabled]
15-
* @param {string} [defaultService]
1614
* @param {Record<string, string>} [headers]
1715
* @param {number} [timeout]
1816
*/
19-
constructor (url, protocol, resourceAttributes, otelSemanticsEnabled = false, defaultService = '',
20-
headers, timeout = 10_000) {
17+
constructor (url, protocol, resourceAttributes, headers, timeout = 10_000) {
2118
super(url, headers, timeout, protocol, 'span-stats')
22-
this.#transformer = new OtlpStatsTransformer(resourceAttributes, protocol, otelSemanticsEnabled, defaultService)
19+
this.#transformer = new OtlpStatsTransformer(resourceAttributes, protocol)
2320
}
2421

2522
/**

packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_transformer.js

Lines changed: 96 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
11
'use strict'
22

3-
const { LogCollapsingLowestDenseDDSketch } = require('../../../../../vendor/dist/@datadog/sketches-js')
43
const OtlpTransformerBase = require('../otlp/otlp_transformer_base')
54
const { getProtobufTypes } = require('../otlp/protobuf_loader')
65
const { GRPC_STATUS_NAMES } = require('../../constants')
76

7+
const { stableStringify } = OtlpTransformerBase
8+
89
const NS_PER_S = 1e9
910

1011
// Must match libdatadog's EXPLICIT_BOUNDS_SECONDS and OTel spanmetrics connector defaults.
1112
const EXPLICIT_BOUNDS_SECONDS = [
1213
0.002, 0.004, 0.006, 0.008, 0.01, 0.05, 0.1, 0.2, 0.4, 0.8, 1, 1.4, 2, 5, 10, 15,
1314
]
1415

16+
const SPAN_KIND_METRIC_MAP = {
17+
internal: 'SPAN_KIND_INTERNAL',
18+
SPAN_KIND_INTERNAL: 'SPAN_KIND_INTERNAL',
19+
server: 'SPAN_KIND_SERVER',
20+
SPAN_KIND_SERVER: 'SPAN_KIND_SERVER',
21+
client: 'SPAN_KIND_CLIENT',
22+
SPAN_KIND_CLIENT: 'SPAN_KIND_CLIENT',
23+
producer: 'SPAN_KIND_PRODUCER',
24+
SPAN_KIND_PRODUCER: 'SPAN_KIND_PRODUCER',
25+
consumer: 'SPAN_KIND_CONSUMER',
26+
SPAN_KIND_CONSUMER: 'SPAN_KIND_CONSUMER',
27+
}
28+
1529
/**
1630
* @param {object} sketch
1731
* @returns {number[]}
@@ -41,22 +55,16 @@ function getDeltaTemporality () {
4155
return _deltaTemporality
4256
}
4357

44-
const ERROR_STATUS_ATTR = { key: 'status.code', value: { intValue: 2 } }
58+
const STATUS_CODE_OK = 'STATUS_CODE_OK'
59+
const STATUS_CODE_ERROR = 'STATUS_CODE_ERROR'
4560

4661
class OtlpStatsTransformer extends OtlpTransformerBase {
47-
#otelSemanticsEnabled
48-
#defaultService
49-
5062
/**
5163
* @param {import('@opentelemetry/api').Attributes} resourceAttributes
5264
* @param {string} protocol
53-
* @param {boolean} [otelSemanticsEnabled]
54-
* @param {string} [defaultService]
5565
*/
56-
constructor (resourceAttributes, protocol, otelSemanticsEnabled = false, defaultService = '') {
66+
constructor (resourceAttributes, protocol) {
5767
super(resourceAttributes, protocol, 'span-stats')
58-
this.#otelSemanticsEnabled = otelSemanticsEnabled
59-
this.#defaultService = defaultService
6068
}
6169

6270
/**
@@ -82,32 +90,35 @@ class OtlpStatsTransformer extends OtlpTransformerBase {
8290
const dataPoints = []
8391

8492
for (const { timeNs, bucket } of drained) {
93+
const distributions = new Map()
8594
const endTimeNs = timeNs + bucketSizeNs
8695
const startNano = isJson ? String(timeNs) : timeNs
8796
const endNano = isJson ? String(endTimeNs) : endTimeNs
8897

8998
for (const aggStats of bucket.values()) {
90-
const baseAttrs = this.#buildAttributes(aggStats.aggKey)
91-
92-
if (this.#otelSemanticsEnabled) {
93-
const okDist = new LogCollapsingLowestDenseDDSketch()
94-
okDist.merge(aggStats.topLevelOkDistribution)
95-
okDist.merge(aggStats.nonTopLevelOkDistribution)
96-
const errDist = new LogCollapsingLowestDenseDDSketch()
97-
errDist.merge(aggStats.topLevelErrorDistribution)
98-
errDist.merge(aggStats.nonTopLevelErrorDistribution)
99-
this.#pushPoint(dataPoints, okDist, startNano, endNano, baseAttrs)
100-
this.#pushPoint(dataPoints, errDist, startNano, endNano, [...baseAttrs, ERROR_STATUS_ATTR])
101-
} else {
102-
const tlAttrs = [...baseAttrs, { key: 'datadog.span.top_level', value: { boolValue: true } }]
103-
const ntlAttrs = [...baseAttrs, { key: 'datadog.span.top_level', value: { boolValue: false } }]
104-
this.#pushPoint(dataPoints, aggStats.topLevelOkDistribution, startNano, endNano, tlAttrs)
105-
this.#pushPoint(dataPoints, aggStats.topLevelErrorDistribution, startNano, endNano,
106-
[...tlAttrs, ERROR_STATUS_ATTR])
107-
this.#pushPoint(dataPoints, aggStats.nonTopLevelOkDistribution, startNano, endNano, ntlAttrs)
108-
this.#pushPoint(dataPoints, aggStats.nonTopLevelErrorDistribution, startNano, endNano,
109-
[...ntlAttrs, ERROR_STATUS_ATTR])
110-
}
99+
const baseAttributes = this.#buildAttributes(aggStats.aggKey)
100+
const baseKey = stableStringify(baseAttributes)
101+
102+
this.#addDistribution(
103+
distributions, aggStats.topLevelOkDistribution, startNano, endNano,
104+
baseAttributes, baseKey, true, STATUS_CODE_OK
105+
)
106+
this.#addDistribution(
107+
distributions, aggStats.topLevelErrorDistribution, startNano, endNano,
108+
baseAttributes, baseKey, true, STATUS_CODE_ERROR
109+
)
110+
this.#addDistribution(
111+
distributions, aggStats.nonTopLevelOkDistribution, startNano, endNano,
112+
baseAttributes, baseKey, false, STATUS_CODE_OK
113+
)
114+
this.#addDistribution(
115+
distributions, aggStats.nonTopLevelErrorDistribution, startNano, endNano,
116+
baseAttributes, baseKey, false, STATUS_CODE_ERROR
117+
)
118+
}
119+
120+
for (const { sketch, startNano, endNano, attributes } of distributions.values()) {
121+
this.#pushPoint(dataPoints, sketch, startNano, endNano, attributes)
111122
}
112123
}
113124

@@ -123,8 +134,45 @@ class OtlpStatsTransformer extends OtlpTransformerBase {
123134
}]
124135
}
125136

126-
#pushPoint (points, sketch, startNano, endNano, attributes) {
137+
/**
138+
* @param {Map<string, {
139+
* sketch: object,
140+
* startNano: string | number,
141+
* endNano: string | number,
142+
* attributes: object[]
143+
* }>} distributions
144+
* @param {object} sketch
145+
* @param {string | number} startNano
146+
* @param {string | number} endNano
147+
* @param {import('@opentelemetry/api').Attributes} baseAttributes
148+
* @param {string} baseKey
149+
* @param {boolean} topLevel
150+
* @param {string} statusCode
151+
* @returns {void}
152+
*/
153+
#addDistribution (distributions, sketch, startNano, endNano, baseAttributes, baseKey, topLevel, statusCode) {
127154
if (!sketch || sketch.count === 0) return
155+
156+
const key = `${baseKey},${topLevel},${statusCode}`
157+
const existing = distributions.get(key)
158+
if (existing) {
159+
existing.sketch.merge(sketch)
160+
} else {
161+
const attributes = this.transformAttributes(baseAttributes)
162+
attributes.push(
163+
{ key: 'datadog.span.top_level', value: { boolValue: topLevel } },
164+
{ key: 'status.code', value: { stringValue: statusCode } }
165+
)
166+
distributions.set(key, {
167+
sketch,
168+
startNano,
169+
endNano,
170+
attributes,
171+
})
172+
}
173+
}
174+
175+
#pushPoint (points, sketch, startNano, endNano, attributes) {
128176
points.push({
129177
attributes,
130178
startTimeUnixNano: startNano,
@@ -140,15 +188,18 @@ class OtlpStatsTransformer extends OtlpTransformerBase {
140188

141189
/**
142190
* @param {import('../../span_stats').SpanAggKey} aggKey
191+
* @returns {import('@opentelemetry/api').Attributes}
143192
*/
144193
#buildAttributes (aggKey) {
145-
const raw = { 'span.name': aggKey.resource }
146-
147-
if (aggKey.service && aggKey.service !== this.#defaultService) {
148-
raw['service.name'] = aggKey.service
194+
const spanKind = Object.hasOwn(SPAN_KIND_METRIC_MAP, aggKey.spanKind)
195+
? SPAN_KIND_METRIC_MAP[aggKey.spanKind]
196+
: 'SPAN_KIND_INTERNAL'
197+
const raw = {
198+
'span.name': aggKey.resource,
199+
'service.name': aggKey.service,
200+
'span.kind': spanKind,
149201
}
150202

151-
if (aggKey.spanKind) raw['span.kind'] = aggKey.spanKind
152203
if (aggKey.statusCode) raw['http.response.status_code'] = Number(aggKey.statusCode)
153204
if (aggKey.method) raw['http.request.method'] = aggKey.method
154205
if (aggKey.endpoint) raw['http.route'] = aggKey.endpoint
@@ -159,13 +210,15 @@ class OtlpStatsTransformer extends OtlpTransformerBase {
159210
: String(aggKey.rpcStatusCode).toUpperCase()
160211
}
161212

162-
if (!this.#otelSemanticsEnabled) {
163-
raw['datadog.operation.name'] = aggKey.name
164-
if (aggKey.type) raw['datadog.span.type'] = aggKey.type
165-
if (aggKey.synthetics) raw['datadog.origin'] = 'synthetics'
166-
}
213+
// TODO: additional_metric_tags support is still evolving/TBD across most SDKs; not implemented here yet.
214+
215+
raw['datadog.operation.name'] = aggKey.name
216+
if (aggKey.type) raw['datadog.span.type'] = aggKey.type
217+
if (aggKey.synthetics) raw['datadog.origin'] = 'synthetics'
218+
if (aggKey.srvSrc) raw['datadog.svc_src'] = aggKey.srvSrc
219+
if (typeof aggKey.isTraceRoot === 'boolean') raw['datadog.is_trace_root'] = aggKey.isTraceRoot
167220

168-
return this.transformAttributes(raw)
221+
return raw
169222
}
170223
}
171224

packages/dd-trace/src/span_stats.js

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ const {
1414
GRPC_STATUS_CODE,
1515
} = require('../../../ext/tags')
1616
const { ORIGIN_KEY, TOP_LEVEL_KEY, SVC_SRC_KEY, GRPC_STATUS_NAMES } = require('./constants')
17+
const id = require('./id')
1718

1819
const GRPC_STATUS_CODE_MAP = Object.fromEntries(GRPC_STATUS_NAMES.map((name, i) => [name, String(i)]))
20+
const ZERO_ID = id('0')
1921
const { version } = require('./pkg')
2022
const processTags = require('./process-tags')
2123

@@ -150,9 +152,24 @@ class SpanAggKey {
150152
}
151153

152154
class SpanBuckets extends Map {
155+
#includeTraceRoot
156+
157+
/**
158+
* @param {boolean} [includeTraceRoot]
159+
*/
160+
constructor (includeTraceRoot = false) {
161+
super()
162+
this.#includeTraceRoot = includeTraceRoot
163+
}
164+
153165
forSpan (span) {
154166
const aggKey = new SpanAggKey(span)
155-
const key = aggKey.toString()
167+
const baseKey = aggKey.toString()
168+
const parentId = span.parent_id
169+
if (this.#includeTraceRoot && parentId !== undefined && parentId !== null) {
170+
aggKey.isTraceRoot = parentId.equals(ZERO_ID)
171+
}
172+
const key = this.#includeTraceRoot ? `${baseKey},${aggKey.isTraceRoot}` : baseKey
156173

157174
if (!this.has(key)) {
158175
this.set(key, new SpanAggStats(aggKey))
@@ -163,9 +180,19 @@ class SpanBuckets extends Map {
163180
}
164181

165182
class TimeBuckets extends Map {
183+
#includeTraceRoot
184+
185+
/**
186+
* @param {boolean} [includeTraceRoot]
187+
*/
188+
constructor (includeTraceRoot = false) {
189+
super()
190+
this.#includeTraceRoot = includeTraceRoot
191+
}
192+
166193
forTime (time) {
167194
if (!this.has(time)) {
168-
this.set(time, new SpanBuckets())
195+
this.set(time, new SpanBuckets(this.#includeTraceRoot))
169196
}
170197

171198
return this.get(time)
@@ -192,7 +219,7 @@ class SpanStatsProcessor {
192219
const intervalMs = otlpExporter ? (flushIntervalMs ?? 10_000) : interval * 1e3
193220
this.interval = intervalMs / 1e3
194221
this.bucketSizeNs = intervalMs * 1e6
195-
this.buckets = new TimeBuckets()
222+
this.buckets = new TimeBuckets(Boolean(otlpExporter))
196223
this.hostname = os.hostname()
197224
this.enabled = enabled
198225
this.otlpExporter = otlpExporter || null

0 commit comments

Comments
 (0)