Skip to content

Emit additional metric tags on the OTLP stats export path (client-side stats) - #12069

Open
dougqh wants to merge 4 commits into
masterfrom
dougqh/otlp-additional-tags
Open

Emit additional metric tags on the OTLP stats export path (client-side stats)#12069
dougqh wants to merge 4 commits into
masterfrom
dougqh/otlp-additional-tags

Conversation

@dougqh

@dougqh dougqh commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

Emit user-configured additional metric tags (DD_TRACE_STATS_ADDITIONAL_TAGS) on the OTLP client-side-stats export path, matching the native msgpack (SerializingMetricWriter) path.

Motivation

Additional metric tags landed for the native path in #11402. On the OTLP path they were dropped twice:

  1. ClientStatsAggregator constructed OtlpStatsMetricWriter through the schema-less (AdditionalTagsSchema.EMPTY) aggregator constructor, so the tags were never captured off the span.
  2. OtlpStatsMetricWriter never read entry.getAdditionalTags(), so even a populated entry wouldn't emit them.

Additional Notes

  • Route the OTLP aggregator constructor through the schema-carrying constructor (additionalTagsSchemaFrom(config)). Capture + per-cycle reset are already unconditional once the schema is real, so no other aggregator change is needed.
  • In OtlpStatsMetricWriter, split each packed "key:value" additional tag at the first : and emit it as a plain OTLP string attribute keyed by the tag name, in both default and otel-semantics modes. Malformed slots (no :, empty key/value) are skipped defensively.

Open question (cross-team)

The attribute-key representation — raw tag name vs a datadog.* namespace — is an open question with the OTLP/agent side. Flagged inline in the code; happy to adjust the key scheme once that's settled.

Testing

New JUnit 5 cases in OtlpStatsMetricWriterTest decode the emitted protobuf and assert:

  • additional tags surface as string attributes (value may itself contain :)
  • they appear in otel-semantics mode too (unlike datadog.*)
  • malformed slots are dropped

Stacked on #11402 (native additional-tags) — this is that seam's OTLP counterpart.

🤖 Generated with Claude Code

@dougqh dougqh added comp: metrics Metrics tag: ai generated Largely based on code generated by an AI or LLM type: enhancement labels Jul 24, 2026
@datadog-datadog-us1-prod

datadog-datadog-us1-prod Bot commented Jul 24, 2026

Copy link
Copy Markdown

🎯 Code Coverage (details)
Patch Coverage: 100.00%
Overall Coverage: 57.73% (+0.02%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 94e310e | Docs | Datadog PR Page | Give us feedback!

@dd-octo-sts

dd-octo-sts Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 13.95 s 13.95 s [-0.8%; +0.9%] (no difference)
startup:insecure-bank:tracing:Agent 12.98 s 12.96 s [-0.8%; +1.2%] (no difference)
startup:petclinic:appsec:Agent 17.50 s 17.33 s [+0.2%; +1.8%] (maybe worse)
startup:petclinic:iast:Agent 17.36 s 17.45 s [-1.3%; +0.3%] (no difference)
startup:petclinic:profiling:Agent 16.57 s 17.46 s [-9.4%; -0.9%] (maybe better)
startup:petclinic:sca:Agent 17.54 s 17.38 s [-0.1%; +1.9%] (no difference)
startup:petclinic:tracing:Agent 16.60 s 16.76 s [-1.8%; -0.1%] (maybe better)

Commit: 94e310e7 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

@PerfectSlayer PerfectSlayer added type: feature Enhancements and improvements and removed type: enhancement labels Jul 27, 2026
@dougqh

dougqh commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@dougqh
dougqh changed the base branch from master to dougqh/metrics-arbitrary-tags July 27, 2026 13:27
@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

new TagCardinalityHandler(
namesArr[i],
limit,
useBlockedSentinel,
MetricCardinalityLimits.ADDITIONAL_TAG_MAX_VALUE_LENGTH);

P1 Badge Bound the configurable additional-tag table size

When DD_TRACE_STATS_ADDITIONAL_TAGS_CARDINALITY_LIMIT is enabled with at least one additional tag, this passes the user-controlled limit directly into TagCardinalityHandler: values above 1 << 29 throw during tracer construction, while smaller but still large values allocate four power-of-two arrays per configured key and can exhaust the application heap. Validate against a safe upper bound and fall back to the default before constructing these handlers.


return tryMakeImmutableSet(configProvider.getList(TRACE_STATS_ADDITIONAL_TAGS));

P1 Badge Snapshot the new configuration in Config

When this setting is enabled, the getter re-queries ConfigProvider instead of returning immutable startup state, so a late system-property mutation can change an existing Config object's value; moreover, when no metrics aggregator is constructed, the setting is never resolved or included in Config.toString(). The repository's configuration guide requires a final field populated in the constructor, a getter returning that field, and inclusion in toString(), so resolve and store this set during Config construction.

AGENTS.md reference: AGENTS.md:L37-L44


if (separator <= 0 || separator == packed.length() - 1) {
return;

P2 Badge Preserve empty additional-tag values in OTLP

When a configured span tag is explicitly set to "", the aggregation path treats it as present and creates a distinct "key:" aggregate, but this branch drops that attribute during OTLP serialization. If otherwise-identical spans alternate between an absent tag and an empty value, two separately aggregated rows are exported with identical OTLP attribute sets, which can corrupt or reject the metric stream; emit the empty string or normalize it to absent before aggregation.


metric.visitAttribute(
STRING_ATTRIBUTE, packed.substring(0, separator), packed.substring(separator + 1));

P2 Badge Prevent additional tags from duplicating built-in attributes

When a configured key matches an attribute already emitted above, such as http.route, this writes a second KeyValue with the same key into one OTLP data point. Map-style consumers commonly retain only one occurrence, so the user value can replace the route used to aggregate the row, while other consumers may reject or interpret the duplicate differently; reject reserved names or namespace additional attributes before emitting them.


for (int i = 0; i < namesArr.length; i++) {
handlersArr[i] =
new TagCardinalityHandler(
namesArr[i],
limit,
useBlockedSentinel,
MetricCardinalityLimits.ADDITIONAL_TAG_MAX_VALUE_LENGTH);

P1 Badge Cap the combined additional-tag cardinality

perf: When multiple configured tags vary independently, giving each key its own budget does not bound the aggregate-key cardinality: two keys with the default limit of 100 permit 10,000 tuples, while the normal aggregate-table default is 2,048 rows and the tight-heap default is only 256. Once those rows are live in a reporting cycle, AggregateTable.findOrInsert drops the remaining stats, so use a shared tuple budget or otherwise constrain the product before these values enter the aggregate key.

AGENTS.md reference: AGENTS.md:L77-L77



P1 Badge Move the new unit test to JUnit 5

This newly added case is a core metrics unit test, not an instrumentation or smoke test, but it is implemented as a Spock feature in a Groovy file. Move this coverage to a JUnit 5 Java test so the added test follows the repository's explicit framework requirement.

AGENTS.md reference: AGENTS.md:L63-L63



P2 Badge Include additional tags in the test equality contract

The new overload folds additionalTagsArr into keyHash, but AggregateEntryTestUtils.equals still omits getAdditionalTags(). Consequently, two entries that differ only in additional tags compare equal while producing different helper hash codes, and tests using this matcher can silently accept missing or incorrect additional dimensions; compare the arrays as part of the equality helper.


AggregateEntry recordDurations(int count, AtomicLongArray durations) {

P3 Badge Remove the unused production-only test helper

A repo-wide search at this commit finds no caller of recordDurations; the migrated writer tests record hits through recordOneDuration instead. Keeping this method in production code solely for an unused test path also retains the otherwise-unneeded AtomicLongArray dependency and extra mutation surface, so remove it or move any genuinely needed helper into test sources.

AGENTS.md reference: AGENTS.md:L76-L76

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// values may) and emits it as an OTLP string attribute. Skips malformed slots (no ':', empty key
// or empty value) defensively.
private static void emitAdditionalTag(OtlpMetricVisitor metric, UTF8BytesString additionalTag) {
String packed = additionalTag.toString();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not thrilled with the need to split here, but I'm leaving client-side stats optimized for interaction with Datadog agent and our protocol for the moment.

The problems also run deeper than just the split.
There is also an issue with encoding to UTF8 as well.

That's probably solvable with the our UTF8 cache, but I'm leaving that to another PR.

@dougqh dougqh changed the title Emit additional metric tags on the OTLP stats export path Emit additional metric tags on the OTLP stats export path (client-side stats) Jul 27, 2026
@dougqh
dougqh marked this pull request as ready for review July 27, 2026 16:03
@dougqh
dougqh requested a review from a team as a code owner July 27, 2026 16:03
@dougqh
dougqh requested a review from amarziali July 27, 2026 16:03

@datadog-datadog-us1-prod datadog-datadog-us1-prod Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Autotest was unable to complete this review. View session

Please try again by commenting @autotest review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3a2e8c49f4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +246 to +247
metric.visitAttribute(
STRING_ATTRIBUTE, packed.substring(0, separator), packed.substring(separator + 1));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent additional tags from duplicating built-in attributes

When DD_TRACE_STATS_ADDITIONAL_TAGS contains a name already emitted by this writer, such as span.name, status.code, or http.response.status_code, this raw key creates two KeyValue entries for the same OTLP attribute; the latter example can even have both integer and string values. Receivers may retain different occurrences, so built-in dimensions and their types become ambiguous. Reserve these names, skip duplicates, or settle the namespace before emitting user tags.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the above Datadog comment.

@dougqh
dougqh requested a review from mhlidd July 27, 2026 16:09
@dougqh
dougqh force-pushed the dougqh/otlp-additional-tags branch from 3a2e8c4 to 763cf32 Compare July 27, 2026 16:09
@dougqh

dougqh commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @codex. Reconciling against the current branch — this review was posted against 3a2e8c49f4, before the rebase onto the updated #11402 base and the follow-up commits:

Resolved on the base PR #11402 (this branch was rebased onto it):

  1. Bound the configurable additional-tag table size — ✅ DD_TRACE_STATS_ADDITIONAL_TAGS_CARDINALITY_LIMIT flows through Config.getTraceStatsCardinalityLimit("additional_tags", …), which now rejects values above MAX_TRACE_STATS_CARDINALITY_LIMIT (1<<16) and falls back to the default instead of allocating oversized arrays or throwing at startup (4188b43471).
  2. Snapshot the new configuration in Config — ✅ traceStatsAdditionalTags is a final field resolved in the constructor, returned directly by the getter, and included in toString() (f5b083bd80).
  3. Remove the unused production-only test helper — ✅ AggregateEntry.recordDurations (and its AtomicLongArray dependency) removed (976d335d7a).

Fixed on this branch:
3. Preserve empty additional-tag values in OTLP — ✅ emitAdditionalTag now emits an explicitly-empty value as key="" rather than dropping it, so an empty-value row and an absent-tag row no longer export identical attribute sets (f7244cc336). Chose emit-at-OTLP over normalize-to-absent at aggregation to keep the OTLP attributes faithful to the aggregate key.
7. Include additional tags in the test equality contract — ✅ AggregateEntryTestUtils.equals now compares getAdditionalTags() positionally, matching what keyHash folds in (d61f2a06f8).

Accepted by design / deferred:
5. Cap the combined additional-tag cardinality — Accepted by design. The per-key budget is intentional; aggregate-key cardinality is bounded by the aggregate-table cap, and overflow is reported via the stats.collapsed_spans / collapsed:whole_key telemetry (made observable and hot-path-cheap by #12070). A shared tuple budget would add cross-key coupling on the hot path for a bound the table cap already enforces.
4. Prevent additional tags from duplicating built-in attributes — Deferred. The attribute-key representation for additional tags (raw tag name vs. a datadog.* namespace) is an open cross-team question with the OTLP/agent side, flagged in-code at the emit site; reserved-name handling will be settled together with that decision rather than with an ad-hoc reject list now.
6. Move the new unit test to JUnit 5 — Acknowledged. The new case lives in the large existing ClientStatsAggregatorTest.groovy and reuses its Spock harness; migrating just that case in isolation isn't clean, so the full-file JUnit 5 migration is tracked as a separate effort rather than blocking this PR.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

Base automatically changed from dougqh/metrics-arbitrary-tags to master July 27, 2026 21:06
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot requested a review from a team as a code owner July 27, 2026 21:06

@datadog-datadog-us1-prod datadog-datadog-us1-prod Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Datadog Autotest: FAIL

A user can configure an additional metric tag named http.route, service.name, or another standard OTLP attribute because the schema only rejects empty and colon-containing keys. The writer then appends that tag after the standard attribute, producing duplicate keys with conflicting values and potentially corrupting semantic dimensions for downstream OTLP consumers.

Open Bits AI session

🤖 Datadog Autotest · Commit d61f2a0 · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest

// "key:value" UTF8 strings in schema order. Emitted in both modes as plain OTLP string
// attributes keyed by the tag name. NOTE: the attribute-key representation (raw tag name vs a
// datadog.* namespace) is an open cross-team question with the OTLP/agent side -- see the PR.
for (UTF8BytesString additionalTag : entry.getAdditionalTags()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Additional tags can overwrite standard OTLP attributes

OTLP client-side metric points can have corrupted HTTP semantic attributes or be rejected when a valid user configuration overlaps a standard OTLP key.

Assertion details
  • Input: Set DD_TRACE_STATS_ADDITIONAL_TAGS to http.route and process a span that has an HTTP route tag of /users/{id} but an additional-tag value such as custom-route.
  • Expected: The emitted data point has one unambiguous http.route semantic attribute representing the standard HTTP route, while the user-configured dimension is represented without colliding with reserved OTLP keys.
  • Actual: emitDataPointAttributes writes the standard http.route attribute first, then emitAdditionalTag writes another http.route attribute from the packed user tag. The protobuf and JSON collectors append both entries; there is no collision check or namespace, so consumers that collapse attributes by key can observe custom-route as the HTTP route, and consumers that reject duplicate keys can reject the point.

Was this helpful? React 👍 or 👎
🤖 Datadog Autotest · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dougqh Is this a valid concern? Should we handle this the same way that native CSS handles it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had Claude do an analysis of this for both the Datadog & OTLP serialization. Here are the findings...

Yes, it's a valid concern — but it's specific to the OTLP path, and native doesn't have it by design.

On the native msgpack path (SerializingMetricWriter), additional tags are written into a dedicated AdditionalMetricTags array, structurally separate from the built-in dimensions (Resource, HTTPStatusCode, PeerTags, …). So configuring http.route as an additional tag lands in its own section and never touches the built-in route — no collision possible.

OTLP data-point attributes are a single flat KeyValue bag. We emit the built-ins first (span.name, span.kind, service.name, http.request.method, http.response.status_code, http.route, rpc.response.status_code, status.code, plus datadog.* in default mode), then loop the user's additional tags with raw keys. So a user tag whose name equals a built-in produces two entries for the same key, and a receiver may keep either or reject the point. That's exactly what the P1 describes.

The two ways to reproduce native's separation on a flat bag are (a) a key namespace like datadog.*, or (b) don't let a user tag overwrite a built-in. Since we've settled on raw keys (a) is out, so the fix has to be collision-avoidance at emit time: reserve the built-in attribute keys and skip any additional tag that collides — built-in wins. Corrupting or duplicating a standard semantic attribute is worse than dropping a user tag that's almost certainly a misconfiguration (the name is already emitted as a first-class dimension).

One implementation note: this reservation must live in OtlpStatsMetricWriter, not in the shared AdditionalTagsSchema. On the native path those same names are legitimate and non-colliding, so rejecting them at schema construction would wrongly break native. I'll compute the collision set once at writer construction (intersect the configured tag names with the built-in key set, warn once), and skip those slots at emit — no per-point cost.

I'll push that change; happy to revisit if we ever reintroduce a namespace.

dougqh and others added 4 commits July 28, 2026 09:08
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>
Aggregation treats an explicitly-empty configured tag ("key:") as a distinct
dimension from an absent one, so it forms a separate aggregate row. The OTLP
writer dropped the empty-value slot, which meant an absent-tag row and an
empty-value row exported identical attribute sets -- two separately-aggregated
points indistinguishable to the backend. Emit the empty value as key="" so the
OTLP attributes stay faithful to the aggregate key; only truly-malformed slots
(no ':' or empty key) are still skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The helper's equals() omitted getAdditionalTags() while keyHash folds it in, so
entries differing only in additional tags compared equal yet produced different
hash codes -- letting matcher-based tests silently accept missing or wrong
additional dimensions. Compare the arrays positionally (schema order) to match
the key identity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dougqh
dougqh force-pushed the dougqh/otlp-additional-tags branch from d61f2a0 to 94e310e Compare July 28, 2026 13:13

@mhlidd mhlidd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM following fixes to Codex comments.

// "key:value" UTF8 strings in schema order. Emitted in both modes as plain OTLP string
// attributes keyed by the tag name. NOTE: the attribute-key representation (raw tag name vs a
// datadog.* namespace) is an open cross-team question with the OTLP/agent side -- see the PR.
for (UTF8BytesString additionalTag : entry.getAdditionalTags()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dougqh Is this a valid concern? Should we handle this the same way that native CSS handles it?

// Additional metric tags: user-configured span-derived dimensions, carried as packed
// "key:value" UTF8 strings in schema order. Emitted in both modes as plain OTLP string
// attributes keyed by the tag name. NOTE: the attribute-key representation (raw tag name vs a
// datadog.* namespace) is an open cross-team question with the OTLP/agent side -- see the PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed w/ Munir - let's keep the raw key instead of datadog.*

Comment on lines +246 to +247
metric.visitAttribute(
STRING_ATTRIBUTE, packed.substring(0, separator), packed.substring(separator + 1));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the above Datadog comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: metrics Metrics tag: ai generated Largely based on code generated by an AI or LLM type: feature Enhancements and improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants