Skip to content

Commit 49f7a64

Browse files
dougqhclaude
andcommitted
Emit additional metric tags on the OTLP stats export path
The native SerializingMetricWriter path already carries user-configured span-derived additional metric tags (DD_TRACE_STATS_ADDITIONAL_TAGS), but the OTLP export path dropped them twice: ClientStatsAggregator built the OTLP writer through the schema-less (EMPTY) constructor, and OtlpStatsMetricWriter never read entry.getAdditionalTags(). Route the OTLP aggregator ctor through the schema-carrying ctor (capture is already unconditional once the schema is real) and split each packed "key:value" additional tag into a plain OTLP string attribute keyed by the tag name, in both default and otel-semantics modes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e910ab5 commit 49f7a64

4 files changed

Lines changed: 167 additions & 9 deletions

File tree

dd-trace-core/src/main/java/datadog/trace/common/metrics/ClientStatsAggregator.java

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,7 @@ public ClientStatsAggregator(
133133
this(
134134
config.getWellKnownTags(),
135135
config.getMetricsIgnoredResources(),
136-
AdditionalTagsSchema.from(
137-
config.getTraceStatsAdditionalTags(),
138-
config.getTraceStatsCardinalityLimit(
139-
"additional_tags", MetricCardinalityLimits.ADDITIONAL_TAG_VALUE),
140-
MetricCardinalityLimits.USE_BLOCKED_SENTINEL),
136+
additionalTagsSchemaFrom(config),
141137
sharedCommunicationObjects.featuresDiscovery(config),
142138
healthMetrics,
143139
new OkHttpSink(
@@ -165,6 +161,7 @@ public ClientStatsAggregator(
165161
OtlpStatsMetricWriter metricWriter) {
166162
this(
167163
config.getMetricsIgnoredResources(),
164+
additionalTagsSchemaFrom(config),
168165
sharedCommunicationObjects.featuresDiscovery(config),
169166
healthMetrics,
170167
NoOpSink.INSTANCE,
@@ -176,6 +173,14 @@ public ClientStatsAggregator(
176173
true);
177174
}
178175

176+
private static AdditionalTagsSchema additionalTagsSchemaFrom(Config config) {
177+
return AdditionalTagsSchema.from(
178+
config.getTraceStatsAdditionalTags(),
179+
config.getTraceStatsCardinalityLimit(
180+
"additional_tags", MetricCardinalityLimits.ADDITIONAL_TAG_VALUE),
181+
MetricCardinalityLimits.USE_BLOCKED_SENTINEL);
182+
}
183+
179184
ClientStatsAggregator(
180185
WellKnownTags wellKnownTags,
181186
Set<String> ignoredResources,

dd-trace-core/src/main/java/datadog/trace/core/otlp/metrics/OtlpStatsMetricWriter.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,13 @@ private void emitDataPointAttributes(
228228
if (entry.hasGrpcStatusCode()) {
229229
emitStringAttribute(metric, RPC_RESPONSE_STATUS_CODE, entry.getGrpcStatusCode());
230230
}
231+
// Additional metric tags: user-configured span-derived dimensions, carried as packed
232+
// "key:value" UTF8 strings in schema order. Emitted in both modes as plain OTLP string
233+
// attributes keyed by the tag name. NOTE: the attribute-key representation (raw tag name vs a
234+
// datadog.* namespace) is an open cross-team question with the OTLP/agent side -- see the PR.
235+
for (UTF8BytesString additionalTag : entry.getAdditionalTags()) {
236+
emitAdditionalTag(metric, additionalTag);
237+
}
231238
// Default (Datadog) mode: emit datadog.* per-point attributes
232239
if (!otelSemanticsMode) {
233240
emitStringAttribute(metric, DATADOG_OPERATION_NAME, entry.getOperationName());
@@ -239,6 +246,19 @@ private void emitDataPointAttributes(
239246
}
240247
}
241248

249+
// Splits a packed "key:value" additional-tag string at the first ':' (keys cannot contain ':',
250+
// values may) and emits it as an OTLP string attribute. Skips malformed slots (no ':', empty key
251+
// or empty value) defensively.
252+
private static void emitAdditionalTag(OtlpMetricVisitor metric, UTF8BytesString additionalTag) {
253+
String packed = additionalTag.toString();
254+
int separator = packed.indexOf(':');
255+
if (separator <= 0 || separator == packed.length() - 1) {
256+
return;
257+
}
258+
metric.visitAttribute(
259+
STRING_ATTRIBUTE, packed.substring(0, separator), packed.substring(separator + 1));
260+
}
261+
242262
// accepts both String literals and UTF8BytesString (both CharSequence); skips null values
243263
private static void emitStringAttribute(
244264
OtlpMetricVisitor metric, String key, @Nullable CharSequence value) {

dd-trace-core/src/test/java/datadog/trace/common/metrics/AggregateEntryTestUtils.java

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,43 @@ public static AggregateEntry of(
4545
@Nullable CharSequence httpMethod,
4646
@Nullable CharSequence httpEndpoint,
4747
@Nullable CharSequence grpcStatusCode) {
48+
return of(
49+
resource,
50+
service,
51+
operationName,
52+
serviceSource,
53+
type,
54+
httpStatusCode,
55+
synthetic,
56+
traceRoot,
57+
spanKind,
58+
peerTags,
59+
httpMethod,
60+
httpEndpoint,
61+
grpcStatusCode,
62+
null);
63+
}
64+
65+
/**
66+
* Same as {@link #of} but also carries pre-packed {@code "key:value"} additional metric tags (in
67+
* schema order), letting the OTLP/serializing writer tests exercise the additional-tags path
68+
* without driving a full {@code AggregateTable}/{@code AdditionalTagsSchema} canonicalization.
69+
*/
70+
public static AggregateEntry of(
71+
CharSequence resource,
72+
CharSequence service,
73+
CharSequence operationName,
74+
@Nullable CharSequence serviceSource,
75+
CharSequence type,
76+
int httpStatusCode,
77+
boolean synthetic,
78+
boolean traceRoot,
79+
CharSequence spanKind,
80+
@Nullable List<UTF8BytesString> peerTags,
81+
@Nullable CharSequence httpMethod,
82+
@Nullable CharSequence httpEndpoint,
83+
@Nullable CharSequence grpcStatusCode,
84+
@Nullable UTF8BytesString[] additionalTags) {
4885
UTF8BytesString resourceUtf = AggregateEntry.createUtf8(resource);
4986
UTF8BytesString serviceUtf = AggregateEntry.createUtf8(service);
5087
UTF8BytesString operationNameUtf = AggregateEntry.createUtf8(operationName);
@@ -56,7 +93,8 @@ public static AggregateEntry of(
5693
UTF8BytesString grpcUtf = AggregateEntry.createUtf8(grpcStatusCode);
5794
List<UTF8BytesString> peerTagsList = peerTags == null ? Collections.emptyList() : peerTags;
5895
UTF8BytesString[] peerTagsArr = peerTagsList.toArray(new UTF8BytesString[0]);
59-
UTF8BytesString[] emptyAdditional = new UTF8BytesString[0];
96+
UTF8BytesString[] additionalTagsArr =
97+
additionalTags == null ? new UTF8BytesString[0] : additionalTags;
6098
long keyHash =
6199
AggregateEntry.hashOf(
62100
resourceUtf,
@@ -73,8 +111,8 @@ public static AggregateEntry of(
73111
traceRoot,
74112
peerTagsArr,
75113
peerTagsArr.length,
76-
emptyAdditional,
77-
0);
114+
additionalTagsArr,
115+
additionalTagsArr.length);
78116
return new AggregateEntry(
79117
keyHash,
80118
resourceUtf,
@@ -90,7 +128,7 @@ public static AggregateEntry of(
90128
synthetic,
91129
traceRoot,
92130
peerTagsList,
93-
emptyAdditional);
131+
additionalTagsArr);
94132
}
95133

96134
/**

dd-trace-core/src/test/java/datadog/trace/core/otlp/metrics/OtlpStatsMetricWriterTest.java

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import com.google.protobuf.WireFormat;
1414
import datadog.metrics.api.Histograms;
1515
import datadog.metrics.impl.DDSketchHistograms;
16+
import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
1617
import datadog.trace.common.metrics.AggregateEntry;
1718
import datadog.trace.common.metrics.AggregateEntryTestUtils;
1819
import datadog.trace.common.writer.RemoteApi;
@@ -427,6 +428,100 @@ void httpAndGrpcAttributesAppearOnlyWhenSet() throws IOException {
427428
assertFalse(bareAttrs.containsKey("rpc.response.status_code"));
428429
}
429430

431+
@Test
432+
void additionalMetricTagsEmittedAsStringAttributes() throws IOException {
433+
// Additional tags arrive on the entry pre-packed as "key:value" UTF8 strings in schema order;
434+
// the writer splits each at the first ':' and emits it as a plain OTLP string attribute keyed
435+
// by the tag name, in both semantics modes.
436+
AggregateEntry e =
437+
AggregateEntryTestUtils.of(
438+
"GET /users",
439+
"web",
440+
"servlet.request",
441+
null,
442+
"web",
443+
0,
444+
false,
445+
true,
446+
"server",
447+
null,
448+
null,
449+
null,
450+
null,
451+
new UTF8BytesString[] {
452+
UTF8BytesString.create("region:us-east-1"),
453+
UTF8BytesString.create("tenant_id:acme:corp")
454+
});
455+
AggregateEntryTestUtils.recordOk(e, SECONDS.toNanos(1));
456+
457+
Map<String, Object> attrs = writeAndDecode(false, e).dataPoints.get(0).attributes;
458+
assertEquals("us-east-1", attrs.get("region"));
459+
// value may itself contain ':' — only the first ':' separates key from value
460+
assertEquals("acme:corp", attrs.get("tenant_id"));
461+
}
462+
463+
@Test
464+
void additionalMetricTagsEmittedInOtelSemanticsMode() throws IOException {
465+
// Unlike datadog.* attributes, additional tags are user-configured dimensions and are emitted
466+
// in otel-semantics mode too.
467+
AggregateEntry e =
468+
AggregateEntryTestUtils.of(
469+
"GET /users",
470+
"web",
471+
"servlet.request",
472+
null,
473+
"web",
474+
0,
475+
false,
476+
true,
477+
"server",
478+
null,
479+
null,
480+
null,
481+
null,
482+
new UTF8BytesString[] {UTF8BytesString.create("region:us-east-1")});
483+
AggregateEntryTestUtils.recordOk(e, SECONDS.toNanos(1));
484+
485+
Map<String, Object> attrs = writeAndDecode(true, e).dataPoints.get(0).attributes;
486+
assertEquals("us-east-1", attrs.get("region"));
487+
assertFalse(
488+
attrs.containsKey("datadog.operation.name"), "datadog.* still absent in otel-semantics");
489+
}
490+
491+
@Test
492+
void malformedAdditionalTagsAreSkipped() throws IOException {
493+
// Defensive: a slot with no ':', an empty key, or an empty value is dropped rather than emitted
494+
// as a malformed attribute.
495+
AggregateEntry e =
496+
AggregateEntryTestUtils.of(
497+
"GET /users",
498+
"web",
499+
"servlet.request",
500+
null,
501+
"web",
502+
0,
503+
false,
504+
true,
505+
"server",
506+
null,
507+
null,
508+
null,
509+
null,
510+
new UTF8BytesString[] {
511+
UTF8BytesString.create("noseparator"),
512+
UTF8BytesString.create(":emptykey"),
513+
UTF8BytesString.create("emptyvalue:"),
514+
UTF8BytesString.create("region:us-east-1")
515+
});
516+
AggregateEntryTestUtils.recordOk(e, SECONDS.toNanos(1));
517+
518+
Map<String, Object> attrs = writeAndDecode(false, e).dataPoints.get(0).attributes;
519+
assertEquals("us-east-1", attrs.get("region"), "well-formed tag still emitted");
520+
assertFalse(attrs.containsKey("noseparator"), "no-separator slot skipped");
521+
assertFalse(attrs.containsKey(""), "empty-key slot skipped");
522+
assertFalse(attrs.containsKey("emptyvalue"), "empty-value slot skipped");
523+
}
524+
430525
@Test
431526
void serviceNameEmittedOnlyForNonDefaultService() throws IOException {
432527
CapturingSender sender = new CapturingSender();

0 commit comments

Comments
 (0)