Skip to content

Commit 2d980f7

Browse files
mabdinurclaude
authored andcommitted
feat(span-stats): encode SpanKind and GRPCStatusCode in v0.6 stats payload (#9190)
* feat(span-stats): encode SpanKind and GRPCStatusCode in v0.6 stats payload The Agent's msgpack decoder supports SpanKind and GRPCStatusCode fields but the encoder was never wired to send them, so spans differing only by span kind or gRPC status were silently merged at the tracer level. This adds both fields to the aggregation key, serializes them via toJSON(), and encodes them in SpanStatsEncoder (map prefix 15 → 17). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(span-stats): add GRPC_STATUS_CODE to ext/tags TypeScript declaration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(span-stats): normalize GRPCStatusCode to numeric string to match Agent convention The Agent's parseGRPCStatusString always produces numeric strings (e.g. "14" for UNAVAILABLE). String status names like "NOT_FOUND" are now converted to their numeric equivalents ("5") so client-computed stats aggregate correctly with Agent-computed stats. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(span-stats): check OTel gRPC status aliases rpc.grpc.status_code and rpc.response.status_code The OTel bridge stores attributes under their original key, so gRPC status set via rpc.grpc.status_code or rpc.response.status_code was silently dropped from GRPCStatusCode in client-side stats. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(span-stats): use bytes.writeMapPrefix for map prefix encoding Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(span-stats): convert numeric rpcStatusCode back to canonical name in OTLP output The P1 fix stores GRPCStatusCode as a numeric string in SpanAggKey for correct Agent v0.6 stats aggregation, but the OTLP transformer must emit the canonical name string (e.g. 'NOT_FOUND') as rpc.response.status_code. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8f7d862 commit 2d980f7

6 files changed

Lines changed: 99 additions & 6 deletions

File tree

ext/tags.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ declare const tags: {
2020
HTTP_RESPONSE_HEADERS: 'http.response.headers'
2121
HTTP_USERAGENT: 'http.useragent',
2222
HTTP_CLIENT_IP: 'http.client_ip',
23+
GRPC_STATUS_CODE: 'grpc.status.code'
2324
PATHWAY_HASH: 'pathway.hash'
2425
}
2526

packages/dd-trace/src/encode/span-stats.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class SpanStatsEncoder extends AgentEncoder {
3131
}
3232

3333
_encodeStat (bytes, stat) {
34-
bytes.writeMapPrefix(15)
34+
bytes.writeMapPrefix(17)
3535

3636
this._encodeString(bytes, 'Service')
3737
const service = stat.Service || DEFAULT_SERVICE_NAME
@@ -79,6 +79,12 @@ class SpanStatsEncoder extends AgentEncoder {
7979

8080
this._encodeString(bytes, 'srv_src')
8181
this._encodeString(bytes, stat.srv_src || '')
82+
83+
this._encodeString(bytes, 'SpanKind')
84+
this._encodeString(bytes, stat.SpanKind || '')
85+
86+
this._encodeString(bytes, 'GRPCStatusCode')
87+
this._encodeString(bytes, stat.GRPCStatusCode || '')
8288
}
8389

8490
_encodeBucket (bytes, bucket) {

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
const { LogCollapsingLowestDenseDDSketch } = require('../../../../../vendor/dist/@datadog/sketches-js')
44
const OtlpTransformerBase = require('../otlp/otlp_transformer_base')
55
const { getProtobufTypes } = require('../otlp/protobuf_loader')
6+
const { GRPC_STATUS_NAMES } = require('../../constants')
67

78
const NS_PER_S = 1e9
89

@@ -152,7 +153,10 @@ class OtlpStatsTransformer extends OtlpTransformerBase {
152153
if (aggKey.method) raw['http.request.method'] = aggKey.method
153154
if (aggKey.endpoint) raw['http.route'] = aggKey.endpoint
154155
if (aggKey.rpcStatusCode !== '') {
155-
raw['rpc.response.status_code'] = String(aggKey.rpcStatusCode).toUpperCase()
156+
const n = Number(aggKey.rpcStatusCode)
157+
raw['rpc.response.status_code'] = Number.isInteger(n) && n >= 0 && n < GRPC_STATUS_NAMES.length
158+
? GRPC_STATUS_NAMES[n]
159+
: String(aggKey.rpcStatusCode).toUpperCase()
156160
}
157161

158162
if (!this.#otelSemanticsEnabled) {

packages/dd-trace/src/span_stats.js

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ 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+
18+
const GRPC_STATUS_CODE_MAP = Object.fromEntries(GRPC_STATUS_NAMES.map((name, i) => [name, String(i)]))
1719
const { version } = require('./pkg')
1820
const processTags = require('./process-tags')
1921

@@ -108,10 +110,26 @@ class SpanAggKey {
108110
this.srvSrc = span.meta[SVC_SRC_KEY] || ''
109111
this.spanKind = span.meta[SPAN_KIND] || ''
110112
// dd gRPC plugin sets a numeric code via setTag; OTel/manual sets a string name via meta.
111-
const grpcCode = span.meta[GRPC_STATUS_CODE] ?? span.metrics?.[GRPC_STATUS_CODE]
112-
this.rpcStatusCode = typeof grpcCode === 'number'
113-
? (GRPC_STATUS_NAMES[grpcCode] ?? String(grpcCode))
114-
: (grpcCode ?? '')
113+
// Normalize to numeric string to match the Agent's parseGRPCStatusString convention.
114+
// Also check OTel semantic aliases (rpc.grpc.status_code, rpc.response.status_code) as
115+
// the OTel bridge stores attributes under their original key without remapping.
116+
const grpcCode = span.meta[GRPC_STATUS_CODE] ?? span.metrics?.[GRPC_STATUS_CODE] ??
117+
span.meta['rpc.grpc.status_code'] ?? span.metrics?.['rpc.grpc.status_code'] ??
118+
span.meta['rpc.response.status_code'] ?? span.metrics?.['rpc.response.status_code']
119+
if (typeof grpcCode === 'number') {
120+
this.rpcStatusCode = String(grpcCode)
121+
} else if (grpcCode) {
122+
const upper = String(grpcCode).toUpperCase()
123+
const numeric = GRPC_STATUS_CODE_MAP[upper]
124+
if (numeric === undefined) {
125+
const n = Number(grpcCode)
126+
this.rpcStatusCode = Number.isInteger(n) && n >= 0 ? String(n) : ''
127+
} else {
128+
this.rpcStatusCode = numeric
129+
}
130+
} else {
131+
this.rpcStatusCode = ''
132+
}
115133
}
116134

117135
toString () {

packages/dd-trace/test/encode/span-stats.spec.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ describe('span-stats-encode', () => {
4949
HTTPMethod: 'GET',
5050
HTTPEndpoint: '/users/:id',
5151
srv_src: 'kafka',
52+
SpanKind: 'server',
53+
GRPCStatusCode: '0',
5254
Hits: 30799,
5355
TopLevelHits: 30799,
5456
Duration: 1230,
@@ -204,4 +206,30 @@ describe('span-stats-encode', () => {
204206
const decodedStat = decoded.Stats[0].Stats[0]
205207
assert.strictEqual(decodedStat.srv_src, '')
206208
})
209+
210+
it('should encode SpanKind and GRPCStatusCode', () => {
211+
encoder.encode(stats)
212+
213+
const buffer = encoder.makePayload()
214+
const decoded = msgpack.decode(buffer)
215+
216+
const decodedStat = decoded.Stats[0].Stats[0]
217+
assert.strictEqual(decodedStat.SpanKind, 'server')
218+
assert.strictEqual(decodedStat.GRPCStatusCode, '0')
219+
})
220+
221+
it('should encode SpanKind and GRPCStatusCode as empty strings when not present', () => {
222+
const statsWithout = {
223+
...stats,
224+
Stats: [{ ...bucket, Stats: [{ ...stat, SpanKind: undefined, GRPCStatusCode: undefined }] }],
225+
}
226+
encoder.encode(statsWithout)
227+
228+
const buffer = encoder.makePayload()
229+
const decoded = msgpack.decode(buffer)
230+
231+
const decodedStat = decoded.Stats[0].Stats[0]
232+
assert.strictEqual(decodedStat.SpanKind, '')
233+
assert.strictEqual(decodedStat.GRPCStatusCode, '')
234+
})
207235
})

packages/dd-trace/test/span_stats.spec.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const {
1919
HTTP_ENDPOINT,
2020
HTTP_ROUTE,
2121
HTTP_METHOD,
22+
SPAN_KIND,
23+
GRPC_STATUS_CODE,
2224
} = require('../../../ext/tags')
2325
const {
2426
DEFAULT_SPAN_NAME,
@@ -179,6 +181,40 @@ describe('SpanAggKey', () => {
179181
assert.strictEqual(
180182
key.toString(), 'basic-span,service-name,resource-name,span-type,200,false,,,opt.plugin,,')
181183
})
184+
185+
it('should include span kind in aggregation key', () => {
186+
const span = { ...basicSpan, meta: { ...basicSpan.meta, [SPAN_KIND]: 'server' } }
187+
const key = new SpanAggKey(span)
188+
assert.strictEqual(
189+
key.toString(), 'basic-span,service-name,resource-name,span-type,200,false,,,integration,server,')
190+
})
191+
192+
it('should normalize gRPC status name to numeric string in aggregation key', () => {
193+
const span = { ...basicSpan, meta: { ...basicSpan.meta, [GRPC_STATUS_CODE]: 'NOT_FOUND' } }
194+
const key = new SpanAggKey(span)
195+
assert.strictEqual(
196+
key.toString(), 'basic-span,service-name,resource-name,span-type,200,false,,,integration,,5')
197+
})
198+
199+
it('should keep numeric gRPC status code as numeric string in aggregation key', () => {
200+
const span = { ...basicSpan, meta: {}, metrics: { [GRPC_STATUS_CODE]: 14 } }
201+
const key = new SpanAggKey(span)
202+
assert.strictEqual(
203+
key.toString(), 'basic-span,service-name,resource-name,span-type,0,false,,,,,14')
204+
})
205+
206+
it('should use rpc.grpc.status_code OTel alias when grpc.status.code is absent', () => {
207+
const span = { ...basicSpan, meta: { ...basicSpan.meta, 'rpc.grpc.status_code': '2' }, metrics: {} }
208+
const key = new SpanAggKey(span)
209+
assert.strictEqual(key.rpcStatusCode, '2')
210+
})
211+
212+
it('should use rpc.response.status_code OTel alias as last resort', () => {
213+
const meta = { ...basicSpan.meta, 'rpc.response.status_code': 'INVALID_ARGUMENT' }
214+
const span = { ...basicSpan, meta, metrics: {} }
215+
const key = new SpanAggKey(span)
216+
assert.strictEqual(key.rpcStatusCode, '3')
217+
})
182218
})
183219

184220
describe('SpanAggStats', () => {

0 commit comments

Comments
 (0)