Skip to content

Add span-derived primary tags (CSS v1.3.0)#11402

Open
dougqh wants to merge 315 commits into
masterfrom
dougqh/metrics-arbitrary-tags
Open

Add span-derived primary tags (CSS v1.3.0)#11402
dougqh wants to merge 315 commits into
masterfrom
dougqh/metrics-arbitrary-tags

Conversation

@dougqh

@dougqh dougqh commented May 18, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

Implements span-derived primary tags (Client-Side Stats v1.3.0): users configure DD_TRACE_STATS_ADDITIONAL_TAGS to extract matching span tag values as additional aggregation dimensions on ClientGroupedStats.AdditionalMetricTags.

Motivation

From the RFC...

Users may want to aggregate APM statistics by custom dimensions beyond the standard primary tag (typically env). For example, a user might want stats grouped by region, tenant_id, or other business-relevant span tags. This enables more granular visibility into service performance across different segments.

Configuration

This feature is experimental and must be explicitly opted in.

Environment Variable System Property Default Description
DD_TRACE_EXPERIMENTAL_FEATURES_ENABLED dd.trace.experimental.features.enabled (none) Required to enable this feature. Set to DD_TRACE_STATS_ADDITIONAL_TAGS (comma-separate to enable multiple experimental features).
DD_TRACE_STATS_ADDITIONAL_TAGS dd.trace.stats.additional.tags (none) Comma-separated span tag keys to use as additional aggregation dimensions. Max 4 keys; excess dropped at startup with a warn log.
DD_TRACE_STATS_ADDITIONAL_TAGS_CARDINALITY_LIMIT dd.trace.stats.additional.tags.cardinality.limit 100 Per-key distinct-value cap per reporting cycle. Values beyond the cap are reported as tracer_blocked_value.

Minimal configuration to enable:

DD_TRACE_EXPERIMENTAL_FEATURES_ENABLED=DD_TRACE_STATS_ADDITIONAL_TAGS
DD_TRACE_STATS_ADDITIONAL_TAGS=region,tenant_id

Additional Notes

Design

Wire format: new AdditionalMetricTags field on ClientGroupedStats, emitted as repeated string of "<key>:<value>" entries (mirrors PeerTags). Schema-ordered (alphabetical by key); null slots skipped; field omitted when empty, so unconfigured deployments pay zero payload overhead.

Cardinality protection — three caps, matching the .NET implementation:

  • MAX_ADDITIONAL_TAG_KEYS = 4 — configured-key count cap; excess keys dropped at startup with a warn log.
  • Per-value length cap (200 chars) — values exceeding this are replaced with tracer_blocked_value.
  • DD_TRACE_STATS_ADDITIONAL_TAGS_CARDINALITY_LIMIT (default 100) — per-key distinct-value cap per reporting cycle; further new values collapse to the "<key>:tracer_blocked_value" sentinel.

Threading: all canonicalization (UTF8BytesString interning, cardinality limiting) runs on the aggregator thread. The producer path captures raw String values into a String[] parallel to the schema — no sync on the hot path.

Tests

Migrated SerializingMetricWriterTest from Spock/Groovy to JUnit 5 Java and folded the additional-tags coverage into its shared ValidatingSink — one msgpack-decode harness instead of two.

Benchmark

AdditionalTagsMetricsBenchmark.publish (JMH, Java 17, 8 threads, @Fork(1)):

limitsEnabled throughput
false 22.3M ± 0.7M ops/s
true 6.9M ± 1.4M ops/s

The true arm is slower because it records more — over-cardinality values collapse into the tracer_blocked_value sentinel — not because limiting itself is expensive.

🤖 Generated with Claude Code

@dougqh dougqh added type: feature Enhancements and improvements comp: metrics Metrics tag: ai generated Largely based on code generated by an AI or LLM labels May 18, 2026
@dougqh
dougqh force-pushed the dougqh/metrics-memory-efficiency branch from 46c04bd to 823a5d4 Compare May 18, 2026 19:28
@dougqh
dougqh force-pushed the dougqh/metrics-arbitrary-tags branch from c552e73 to 42947dd Compare May 18, 2026 19:30
dougqh and others added 8 commits May 19, 2026 21:22
The 9 property limits and the peer-tag value limit were sprinkled
inline. Pull them into a single class with per-field javadoc so the
sizing rationale lives in one place. Six values change from the
DDCache-inherited defaults based on workload analysis:

- RESOURCE      32 -> 128   (highest-cardinality field; tight today)
- HTTP_ENDPOINT 32 -> 64    (same shape as RESOURCE for HTTP-heavy)
- TYPE          8  -> 16    (DDSpanTypes catalogue is ~30)
- HTTP_METHOD   8  -> 16    (WebDAV/custom verbs push past 8)
- SPAN_KIND     16 -> 8     (OTel defines exactly 5 standard kinds)
- GRPC_STATUS   32 -> 24    (gRPC spec has exactly 17 codes)

SERVICE, OPERATION, SERVICE_SOURCE, and PEER_TAG_VALUE keep their
current values. Net worst-case memory delta: roughly +90 KB driven by
the RESOURCE and HTTP_ENDPOINT bumps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the previous LinkedHashMap-based design for PropertyCardinality
Handler (and the HashMap-based TagCardinalityHandler) with parallel
Object[] / UTF8BytesString[] arrays and linear-probing open addressing.

Two tables per handler, "current cycle" and "prior cycle":
- Capacity is the next power of two >= 2 * cardinalityLimit, so the
  linear-probing load factor stays <= 0.5 even when the budget is full.
- Current tracks values that have consumed a slot of the cardinality
  budget this cycle.
- Prior holds the just-completed cycle's entries verbatim. A first-time-
  this-cycle value that hits in prior reuses its UTF8BytesString
  instance -- no re-allocation. Implements the cross-reset reuse that
  the prior commit's LinkedHashMap LRU provided, with less overhead.

Reset swaps the table pointers (just-completed cycle -> prior; the
2-cycles-ago tables get nulled out and become the new empty current).
One O(capacity) pass, half the work of a copy-then-null.

Wins:
- No per-entry Node allocations (HashMap / LinkedHashMap) and no
  access-order linked-list maintenance per get.
- Smaller working set: two Object[] + two UTF8BytesString[] per handler
  vs HashMap + HashSet + LinkedHashMap heap shapes.
- Stable workloads pay zero UTF8BytesString allocations after the first
  cycle and produce identical references across cycles, so downstream
  equals short-circuits to ==.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The stored UTF8BytesString can serve as the slot's identity on its own:
its hashCode() returns the underlying String.hashCode (content-stable
with whatever shape the input takes), and equality is checked via
stored.toString().contentEquals(value) -- the JDK's content-equality
routine that fast-paths to String.equals when the input is a String.

Halves the per-handler array footprint: one UTF8BytesString[] per
cycle (current + prior) instead of one Object[] + one
UTF8BytesString[] per cycle. No behavior change.

TagCardinalityHandler keeps the parallel-arrays shape because its
stored UTF8 is "tag:value" and cannot be compared directly against the
bare incoming value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The type parameter was load-bearing when slot identity went through a
parallel Object[] keys array (where T determined the runtime class
whose equals/hashCode the HashMap used). The single-array shape probes
via UTF8BytesString.hashCode() (content-stable with the underlying
String) and stored.toString().contentEquals(value), so any CharSequence
input -- String, UTF8BytesString, anything else with a content-stable
hash -- collapses to the right slot.

register(CharSequence value) is enough. AggregateEntry's 9 static
handler declarations and the registerOrEmpty helper lose their type
parameters too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Both handlers now reject cardinalityLimit > 2^29 to prevent overflow
  in the (cardinalityLimit * 2 - 1) capacity calc. Practical limits are
  8..512 so this is well beyond any realistic configuration.
- TagCardinalityHandler's keys array is now String[] (was Object[]) to
  match the actual contract -- minor clarity win.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PropertyCardinalityHandler.register(null) now returns UTF8BytesString
.EMPTY. All AggregateEntry UTF8 fields are non-null. Callers stop
checking for null at every site.

- AggregateEntry: drop @nullable on serviceSource/httpMethod/
  httpEndpoint/grpcStatusCode (both the entry fields and the
  Canonical scratch buffer). Drop @nullable on getters and on the of
  factory parameters. Drop the unused registerOrEmpty helper.
- Canonical.populate: each field is now this.field = HANDLER.register
  (s.field) -- no inline conditionals.
- of() factory: drop the value == null ? null : createUtf8(value)
  pattern; createUtf8 already returns EMPTY on null.
- SerializingMetricWriter: switch the four presence checks from !=
  null to != EMPTY (identity comparison on the singleton).

Net win: nine identically-shaped call sites in Canonical.populate
and a smaller null surface across the package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- TagCardinalityHandler.register now mirrors PropertyCardinalityHandler:
  null input returns UTF8BytesString.EMPTY.
- Canonical.populatePeerTags now calls register for every schema slot
  and tests the result against EMPTY rather than the input against null.
  The wire-format buffer still holds only present peer tags (EMPTY is
  elided), but the check is now consistent with how AggregateEntry's
  scalar UTF8 fields handle absence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#4: PropertyCardinalityHandler and TagCardinalityHandler are only
consumed within this package; drop `public` from the class declarations,
constructors, and methods. They're package-private now.

#6: Add tests that lock down the EMPTY-on-null contract that the rest
of the package depends on:
- CardinalityHandlerTest covers both handlers: register(null) -> EMPTY,
  and registering null repeatedly doesn't consume the cardinality
  budget.
- AggregateEntryTest covers the entry shape: optional fields built
  from a snapshot with null inputs resolve to EMPTY; populated
  optional fields carry their value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@datadog-official

This comment has been minimized.

dougqh and others added 16 commits May 20, 2026 00:09
Adds a per-cycle one-shot warn log + HealthMetrics counter
(`stats.tag_cardinality_blocked` with `tag:<name>`) when a peer-tag
value gets collapsed to the `blocked_by_tracer` sentinel because its
cardinality budget is exhausted. Implemented as a `register(int i,
String value)` method on `PeerTagSchema` that does the post-block
notification work; `TagCardinalityHandler` exposes `blockedSentinel()`
so the schema can identity-compare and stays free of logger / health
metric coupling. Warn-once gating uses a `Set<String>` of names seen
this cycle, cleared by `resetCardinalityHandlers()`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-side reconcile

- PropertyCardinalityHandler / TagCardinalityHandler: header comment
  explaining the limiter-and-cache dual role and the prior-cycle reuse
  trick that preserves UTF8 caching across resets.

- ClientStatsAggregator: rename peerAggSchema -> peerTagSchema across
  field, method, and parameter; disambiguate the inner per-span local as
  spanPeerTagSchema (return of peerTagSchemaFor).

- SpanSnapshot: replace prose "or null" docstrings with
  javax.annotation.@nullable on peerTagSchema/peerTagValues fields and
  their constructor params.

- Consumer-side peer-tag reconciliation:
  * DDAgentFeaturesDiscovery: drop State.peerTagsRevision + bump logic +
    peerTagsRevision() accessor. Expose getLastTimeDiscovered().
  * PeerTagSchema: rename peerTagsRevision -> lastTimeDiscovered, drop
    final (consumer-thread-only mutation), add hasSameTagsAs(Set).
  * ClientStatsAggregator: producer hot path is now a single volatile
    read with a one-time synchronized bootstrap; resetCardinalityHandlers
    runs reconcilePeerTagSchema first, which fast-paths on timestamp
    equality and either bumps in place (preserving warm handlers when
    the tag set is unchanged) or swaps in a fresh schema. The schema's
    timestamp field no longer needs to be volatile because mutation is
    confined to the aggregator thread.

Note: the @nullable annotations on AggregateEntry's errorLatencies and
related fields only apply after the downstream lazy-init / Canonical
buffer work; those land in a separate commit on the downstream branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #11387 review (test coverage gap):

- Fix misleading comment in propertyResetRefreshesBudget ("the previous
  instances aren't reused") -- they ARE reused; the test only passed
  because it asserted on .toString() content rather than identity.

- Add propertyPriorCycleInstancesAreReusedAcrossReset: explicit
  assertSame check that registering the same value after a reset returns
  the SAME UTF8BytesString instance from the prior cycle. This is the
  "dual role as cache" property the canonical-key lookup depends on.

- Add propertyPriorCycleReuseSurvivesOneResetButNotTwo: nails down the
  reuse window depth (one cycle, not two).

- Add tagPriorCycleInstancesAreReusedAcrossReset mirroring the property
  handler test for the tag handler (cached "tag:value" UTF8BytesString).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR #11409 review comments:

- #3267164119 / #3267165525: wrap every single-line if/break body in
  braces (7 sites across BucketIterator, MutatingBucketIterator, and the
  full-table Iterator).

- #3275947761 / #3275948108 (sarahchen6): null out the removed/replaced
  entry's next pointer after splicing it out of the chain in
  MutatingBucketIterator.remove / .replace. Applied the same fix to the
  full-table Iterator.remove for consistency.

  Rationale: detaching prevents accidental traversal through a removed
  entry via a stale reference and lets the GC reclaim a chain tail that
  the removed entry was the last referrer to.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two general-purpose utilities used by the client-side stats aggregator
work (PR #11382 and follow-ups), extracted into their own change so the
metrics-specific PRs can build on a smaller, reviewable foundation.

  - Hashtable: a generic open-addressed-ish bucket table abstraction
    keyed by a 64-bit hash, with a public abstract Entry type so client
    code can subclass it for higher-arity keys. The metrics aggregator
    uses it to back its AggregateTable.

  - LongHashingUtils: chained 64-bit hash combiners with primitive
    overloads (boolean, short, int, long, Object). Used in place of
    varargs combiners to avoid Object[] allocation and boxing on the
    hot path.

No callers within internal-api itself yet -- the metrics aggregator PR
will introduce the first usages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LongHashingUtilsTest (14 cases):
  - hashCodeX null sentinel + non-null pass-through
  - all primitive hash() overloads match the boxed Java hashCodes
  - hash(Object...) 2/3/4/5-arg overloads match the chained addToHash
    formula they are documented to constant-fold to
  - addToHash(long, primitive) overloads match the Object-version
  - linear-accumulation invariant (31 * h + v) holds across a sequence
  - iterable / deprecated int[] / deprecated Object[] variants match
    chained addToHash
  - intHash treats null as 0 (observable via hash(null, "x"))

HashtableTest (24 cases across 5 nested classes):
  - D1: insert/get/remove/insertOrReplace/clear/forEach, in-place value
    mutation, null-key handling, hash-collision chaining with disambig-
    uating equals, remove-from-collided-chain leaves siblings intact
  - D2: pair-key identity, remove(pair), insertOrReplace matches on
    both parts, forEach
  - Support: capacity rounds up to a power of two, bucketIndex stays
    in range across a wide hash sample, clear nulls every slot
  - BucketIterator: walks only matching-hash entries in a chain, throws
    NoSuchElementException when exhausted
  - MutatingBucketIterator: remove from head-of-chain unlinks, replace
    swaps the entry while preserving chain, remove() without prior
    next() throws IllegalStateException

Tests live in internal-api/src/test/java/datadog/trace/util and use the
already-present JUnit 5 setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bring the new util/ files in line with google-java-format
(tabs → spaces, line wrapping, javadoc list markup) so
spotlessCheck passes in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compares Hashtable.D1 and Hashtable.D2 against equivalent HashMap
usage for add, update, and iterate operations. Each benchmark thread
owns its own map (Scope.Thread), but @threads(8) is used so the
allocation/GC pressure that Hashtable is designed to avoid surfaces
in the throughput numbers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Guard Support.sizeFor against overflow and use Integer.highestOneBit;
  reject capacities above 1 << 30 instead of looping forever.
- Add braces around single-statement while bodies in BucketIterator.
- Split HashtableBenchmark into HashtableD1Benchmark / HashtableD2Benchmark.
- Add regression tests for Support.sizeFor bounds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 5-arg Object overload was forwarding only obj0..obj3 to the int
overload, silently dropping obj4. Also align LongHashingUtils.hash 3-arg
signature with its 2/4/5-arg siblings (int parameters) and strengthen
the 5-arg HashingUtilsTest to detect the missing-arg regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Split D1Tests and D2Tests into HashtableD1Test and HashtableD2Test;
  extract shared test entry classes into HashtableTestEntries.
- Reduce visibility of LongHashingUtils.hash(int...) chaining overloads
  to package-private; they are internal building blocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The iterator tests need a populated Hashtable.Entry[] to drive
Support.bucketIterator / mutatingBucketIterator. Relaxing D1.buckets
from private to package-private lets the same-package tests read it
directly, removing the reflection helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the TagMap pattern: pairs the existing forEach(Consumer) with a
forEach(T context, BiConsumer<T, TEntry>) overload so callers can hand
side-band state to a non-capturing lambda and avoid the
fresh-Consumer-per-call allocation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Factors the unchecked (TEntry) cast out of D1.forEach / D2.forEach (and
the BiConsumer variants) into Support.forEach(buckets, ...). The cast
now lives in one place, mirroring how Entry.next() handles it, and the
D1/D2 methods become one-liners. Downstream higher-arity tables built
on Support gain the same helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dougqh and others added 5 commits July 14, 2026 16:07
…re change

Added limit parameter to AdditionalTagsSchema.from() call in the benchmark
to match the updated four-argument signature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PeerTagSchema.testSchema() was removed; replace the one call site with
the package-private constructor directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These methods were never called -- production bucketing goes through
keyHash + Canonical.matches, and the comment on the class said this was
intentional. The dead branches pushed Jacoco coverage below threshold.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Central registry does not accept 'list'; the correct type is 'array'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds explicit array-range overloads to both LongHashingUtils and
HashingUtils, avoiding the var-args allocation trap of the deprecated
hash(Object[]) method while keeping call sites clean. Uses them to
replace the manual peer-tag and additional-tag loops in
AggregateEntry.hashOf().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
return addToHash(hash, Double.hashCode(value));
}

public static final int addToHash(int hash, Object[] arr, int len) {

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.

This is bit off to the side. But since I was adding the convenience to LongHashingUtils, I wanted to update HashingUtils, too

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
return addToHash(hash, Double.hashCode(value));
}

public static final long addToHash(long hash, Object[] arr, int len) {

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.

Convenience added to make AggregateTable / AggregateEntry a bit cleaner

return additionalTags;
}

// ----- recording state accessors -----

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.

Curious, why do we not use GET accessor annotations?

@dougqh dougqh Jul 17, 2026

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.

Do you mean something like Lombok?
We use to, but Lombok does more than we want.

The main one being encouraging the use builders - which are an anti-pattern in dd-trace-java from an allocation & CPU perspective.

log.warn(
"Cardinality limit reached for stats field '{}'; further values will be reported as tracer_blocked_value",
h.name);
healthMetrics.onTagCardinalityBlocked(h.statsDTag(), blocked);

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.

Why do we pass the blocked variable when the method name is onTagCardinalityBlocked?

@mhdatie mhdatie Jul 17, 2026

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.

I see, so this reports the number of blocked values per cardinality budget on each reset / flush of reporting cycles. Could budget be renamed to blockedCount or tracerBlockedValueCount?

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.

Yes, blocked is a count. Maybe rename to numBlocked? Or blockedCount?

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.

Sure whatever makes it more readable. Upon reading it initially I thought it was a 0/1 boolean

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.

[Claude] Done — renamed to numBlocked consistently across all three reset loops: PropertyHandlers, PeerTagSchema, and AdditionalTagsSchema (commit de9849d).

Comment on lines +34 to +40
@State(Scope.Benchmark)
@Warmup(iterations = 2, time = 15, timeUnit = SECONDS)
@Measurement(iterations = 5, time = 15, timeUnit = SECONDS)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(SECONDS)
@Threads(8)
@Fork(value = 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.

Curious, why do we choose to run benchmarks via annotations instead of programmatically via OptionsBuilder?

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.

Mostly, that's just what we've done historically.

Although in other places, I'm pushing for minimum number of threads & forks, so allocation translates into an obvious throughput hit. That was missed in a lot of places previously.

@mhdatie mhdatie Jul 17, 2026

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.

Although in other places, I'm pushing for minimum number of threads & forks, so allocation translates into an obvious throughput hit

Makes sense to me - is this work recorded somewhere? If not, we can create a ticket and expand on it later

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.

My question came from a re-usability standpoint in case we plan to pass configuration there unless there is a way to read config with annotations

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.

Normally, you'd do that through some command line flags to jmh.
Our gradle build currently makes that slightly annoying.

I fixed that in isolated place where I was frequently changing the benchmark set.
It would be good to generalized that support into a Gradle plug-in.

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.

Our gradle build currently makes that slightly annoying.
...I fixed that in isolated place where I was frequently changing the benchmark set.

I could use more context - why it makes it annoying and where was the isolated place so one can compare how that looks compared to a Gradle plug-in support?

And do you think that would improve our benchmark use if we move to a standardized Gradle support where it would support minimum number of threads & forks?

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'm often wanting to run just a subset of benchmarks related to the changes that I'm making rather than the full benchmark set. Our gradle wasn't allowing for that.

And sometimes, I also want to run spot checks for those I use less forks, so it is nice to have the ability to override.

I wouldn't Gradle to prevent me from doing either of those things. Although, if I had to use a force override flag that would be fine with me.

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.

I have created a ticket with the help of Claude - please make the necessary adjustments if your ask isn't clear in there or if the information is incorrect https://datadoghq.atlassian.net/browse/APMLP-1614

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
return handlers[i].register(value);
}

void resetHandlers() {

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.

Why is this schema class designed to have a HealthMetrics property when the aggregator and PeerTagSchema (for example) rely on methods which accept a health metrics instance? Or for example not allowing TagCardinalityHandler#reset to accept a health metric instance (each handler is reporting its own blocked cardinality anyways).

It's primarily a design question - not recommending a better design here. I want to understand the rationale behind it or whether I'm missing an important design piece 🙇 .

@dougqh dougqh Jul 19, 2026

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.

Actually, I think that's worth cleaning up.

PeerTagSchema worked the same way originally, but then ended up being decoupled because the config can be updated dynamically and some weird build interactions with Graal native image.

I think it does make to change AdditionalTagsSchema to work the same way -- even it isn't strictly necessary here.

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.

[Claude] Good question — and it's now addressed. AdditionalTagsSchema no longer holds a HealthMetrics reference. The field and constructor parameter have been removed; HealthMetrics is now passed directly to resetHandlers(HealthMetrics), matching the existing PeerTagSchema.resetHandlers(HealthMetrics) pattern. The from() factory and EMPTY singleton are simplified accordingly (commit de9849d).

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.

I wanted to add that we could leverage adding this design choice to the Agents.md file so that future extensions to the schema can follow it

@dougqh dougqh Jul 21, 2026

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.

Yes, I'd also like to start to put design docs into the repo, but I'm not quite sure how others feel about that.

And I do think at some point we need to revisit HealthMetrics on a few levels.

My usual stance is that I like to bake the design choice into the API.
Similar to the perf toolbox, I want to give people an easy way to do the right thing.

Right now, I'm doing some managing of local counters to avoid high overhead from HealthMetrics. That would be a good thing to document in the perf rubric.

But ideally, the API would make it easy to create a per-use counter and then flush it periodically.

@mhdatie mhdatie 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.

Left a few questions for learning purposes - not planning on approving or blocking the merge.

And in case this is missed since it's a collapsed conversation #11402 (comment)

dougqh and others added 2 commits July 20, 2026 08:07
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… storing it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@mhdatie

mhdatie commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Left one clarification comment but otherwise this generally looks good to me as far as my understanding of the change goes.

@sarahchen6 sarahchen6 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.

First pass looks good, left some comments from me and Codex!

Codex also recommended adding a bit more test coverage, specifically for:

- CardinalityHandlerTest.java: direct maxValueLength, blocked count, and reset tests.
- AdditionalTagsSchemaTest.java: per-key isolation and resetHandlers() health-metric
  reporting.
- AggregateTableAdditionalTagsTest.java: verify oversized/over-cardinality values
  collapse into the same aggregate.
- ClientStatsAggregatorTest.groovy: one full publish/report/reset-cycle test proving
  collapse and health metrics through the production path.

I think it wouldn't hurt, but up to you!

Comment thread dd-trace-core/src/main/java/datadog/trace/common/metrics/SpanSnapshot.java Outdated
}

public Set<String> getTraceStatsAdditionalTags() {
if (!experimentalFeaturesEnabled.contains(

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.

since we're using experimentalFeaturesEnabled, we should update https://github.com/DataDog/dd-trace-java/blob/master/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java#L297 and its corresponding =all test as well to include TRACE_STATS_ADDITIONAL_TAGS

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.

From Claude:

Added DD_TRACE_STATS_ADDITIONAL_TAGS to the =all set (DEFAULT_TRACE_EXPERIMENTAL_FEATURES_ENABLED) so experimental=all picks it up — a17ce32. There wasn't an existing =all test to extend; happy to add one if you'd like coverage there.

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 and others added 6 commits July 21, 2026 12:57
DD_TRACE_EXPERIMENTAL_FEATURES_ENABLED=all expands to
DEFAULT_TRACE_EXPERIMENTAL_FEATURES_ENABLED, which omitted the additional-tags
feature, so =all silently did not enable it. Add DD_TRACE_STATS_ADDITIONAL_TAGS.
Does not enable by default (the non-all path is an explicit set). Per review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
from() only takes a Set, so the input keys are already distinct and the
sort-then-dedup pass could never remove anything — dead code. Use the sorted
valid list directly. Rename schemaDedupesAndCapsAtMaxTagKeys ->
schemaSortsAndCapsAtMaxTagKeys to match what it actually covers (sort + cap).
Per review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- AdditionalTagsMetricsBenchmark: @fork(value = 1) -> @fork(1) to match the
  other benchmarks (per sarahchen6).
- SpanSnapshot.additionalTagValues: add @nullable (its javadoc already notes the
  array is null when no additional tags are configured/set; matches sibling
  fields).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…String[])

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consistent with additionalTagValues: annotation on the type, after final.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@datadog-official

datadog-official Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Bits has a CI fix ready

🟢 Investigated · 🟢 Fix prepared · ⚪ Validation skipped · 🟠 Ready

Updated internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy so the DD_TRACE_EXPERIMENTAL_FEATURES_ENABLED=all expectation includes DD_TRACE_STATS_ADDITIONAL_TAGS, matching the expanded default experimental feature set.

Commit fix to this PR


View in Datadog | Reviewed commit a7ebd82 · Any feedback? Reach out in #deveng-pr-agent

dougqh and others added 2 commits July 21, 2026 13:55
Faithful port: all 5 methods (7 cases incl. two 2-row where: blocks), the
msgpack-decode ValidatingSink harness, and the 10001-element data build
preserved. where: -> @MethodSource / @TableTest. Reviewed clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold SerializingMetricWriterAdditionalTagsTest into the migrated
SerializingMetricWriterTest: extend the shared ValidatingSink to validate the
additional-tags payload field (hasAdditionalTags folded into the map-size math;
validation block after the peer-tags loop, matching the writer's field order),
and move the 3 additional-tags cases (set / omitted / null-slots) over. Delete
the standalone test, removing ~110 lines of duplicate msgpack-decode harness.
One decode path now. Per Codex review, endorsed by sarahchen6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dougqh
dougqh force-pushed the dougqh/metrics-arbitrary-tags branch from 45d14ea to a7ebd82 Compare July 21, 2026 18:14
Comment on lines +283 to +291
++hitCount;
if ((tagAndDuration & TOP_LEVEL_TAG) == TOP_LEVEL_TAG) {
tagAndDuration ^= TOP_LEVEL_TAG;
++topLevelCount;
}
if ((tagAndDuration & ERROR_TAG) == ERROR_TAG) {
tagAndDuration ^= ERROR_TAG;
errorLatenciesForWrite().accept(tagAndDuration);
++errorCount;

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.

minor: Just curious why ++hitCount (and others)? Why not hitCount++?

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.

That's a stylistic quirk of mine from C++ where it used to make a difference performance-wise. I can change it if you want.

return;
}
for (int i = 0; i < n; i++) {
String v = values[i];

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.

Q: What if length of values will be less than n ? Or it is impossible?

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.

The design of the Schemas is that the tags and values are the same length by construction.

That said, I do think checking values reads more clearly (and optimizes better), so I've updated the code accordingly.

Comment on lines +321 to +322
if (!ignoredResources.isEmpty()
&& resourceName != null

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.

Just curious why order swapped?

Comment on lines +75 to +87
/**
* Distinct values per additional-tag key (e.g. distinct values of a span-derived primary tag).
* Each configured additional tag gets its own {@link TagCardinalityHandler} at this limit.
*/
static final int ADDITIONAL_TAG_VALUE = 100;

/**
* Maximum character length for a single additional-tag value. Values longer than this are
* replaced with {@code tracer_blocked_value}; the application could accidentally populate a tag
* with a stack trace, SQL statement, or JSON blob, which would bloat every MetricKey and msgpack
* payload until the bucket flushes.
*/
static final int ADDITIONAL_TAG_MAX_VALUE_LENGTH = 200;

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.

Would it make sense to put any description why 100 and 200 selected?

Comment on lines +126 to +129
log.warn(
"Cardinality limit reached for peer tag '{}'; further values are reported as"
+ " 'tracer_blocked_value' until the next reporting cycle",
names[i]);

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.

Just curious if we can start flooding in logs?

Comment on lines +15 to +27
final PropertyCardinalityHandler resource;
final PropertyCardinalityHandler service;
final PropertyCardinalityHandler operation;
final PropertyCardinalityHandler serviceSource;
final PropertyCardinalityHandler type;
final PropertyCardinalityHandler spanKind;
final PropertyCardinalityHandler httpMethod;
final PropertyCardinalityHandler httpEndpoint;
final PropertyCardinalityHandler grpcStatusCode;

private final PropertyCardinalityHandler[] handlers;

PropertyHandlers() {

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.

All those package visibility is for testing? I recall we have an annotation @VisibleForTesting

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.

The fields are not just visible for testing, but also for use in the code itself (e.g. AggregateEntry.Canonical calls handlers.resource.register(...) etc.).

I prefer package-visible fields to introducing package-visible accessors everywhere.

…length

values is captured against the same additionalTagsSchema, so it is either
null or exactly schema.size() long. Guard the array we actually index
(values == null || values.length == 0) and document the invariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

5 participants