Skip to content

Commit 3a2e8c4

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 8fea172 commit 3a2e8c4

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
@@ -216,6 +216,13 @@ private void emitDataPointAttributes(
216216
if (entry.hasGrpcStatusCode()) {
217217
emitStringAttribute(metric, RPC_RESPONSE_STATUS_CODE, entry.getGrpcStatusCode());
218218
}
219+
// Additional metric tags: user-configured span-derived dimensions, carried as packed
220+
// "key:value" UTF8 strings in schema order. Emitted in both modes as plain OTLP string
221+
// attributes keyed by the tag name. NOTE: the attribute-key representation (raw tag name vs a
222+
// datadog.* namespace) is an open cross-team question with the OTLP/agent side -- see the PR.
223+
for (UTF8BytesString additionalTag : entry.getAdditionalTags()) {
224+
emitAdditionalTag(metric, additionalTag);
225+
}
219226
// Default (Datadog) mode: emit datadog.* per-point attributes
220227
if (!otelSemanticsMode) {
221228
emitStringAttribute(metric, DATADOG_OPERATION_NAME, entry.getOperationName());
@@ -227,6 +234,19 @@ private void emitDataPointAttributes(
227234
}
228235
}
229236

237+
// Splits a packed "key:value" additional-tag string at the first ':' (keys cannot contain ':',
238+
// values may) and emits it as an OTLP string attribute. Skips malformed slots (no ':', empty key
239+
// or empty value) defensively.
240+
private static void emitAdditionalTag(OtlpMetricVisitor metric, UTF8BytesString additionalTag) {
241+
String packed = additionalTag.toString();
242+
int separator = packed.indexOf(':');
243+
if (separator <= 0 || separator == packed.length() - 1) {
244+
return;
245+
}
246+
metric.visitAttribute(
247+
STRING_ATTRIBUTE, packed.substring(0, separator), packed.substring(separator + 1));
248+
}
249+
230250
// accepts both String literals and UTF8BytesString (both CharSequence); skips null values
231251
private static void emitStringAttribute(
232252
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
@@ -12,6 +12,7 @@
1212
import com.google.protobuf.WireFormat;
1313
import datadog.metrics.api.Histograms;
1414
import datadog.metrics.impl.DDSketchHistograms;
15+
import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
1516
import datadog.trace.common.metrics.AggregateEntry;
1617
import datadog.trace.common.metrics.AggregateEntryTestUtils;
1718
import datadog.trace.core.otlp.common.OtlpPayload;
@@ -424,6 +425,100 @@ void httpAndGrpcAttributesAppearOnlyWhenSet() throws IOException {
424425
assertFalse(bareAttrs.containsKey("rpc.response.status_code"));
425426
}
426427

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

0 commit comments

Comments
 (0)