Add span-derived primary tags (CSS v1.3.0)#11402
Conversation
46c04bd to
823a5d4
Compare
c552e73 to
42947dd
Compare
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>
This comment has been minimized.
This comment has been minimized.
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>
…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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
Convenience added to make AggregateTable / AggregateEntry a bit cleaner
| return additionalTags; | ||
| } | ||
|
|
||
| // ----- recording state accessors ----- |
There was a problem hiding this comment.
Curious, why do we not use GET accessor annotations?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
Why do we pass the blocked variable when the method name is onTagCardinalityBlocked?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Yes, blocked is a count. Maybe rename to numBlocked? Or blockedCount?
There was a problem hiding this comment.
Sure whatever makes it more readable. Upon reading it initially I thought it was a 0/1 boolean
There was a problem hiding this comment.
[Claude] Done — renamed to numBlocked consistently across all three reset loops: PropertyHandlers, PeerTagSchema, and AdditionalTagsSchema (commit de9849d).
| @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) |
There was a problem hiding this comment.
Curious, why do we choose to run benchmarks via annotations instead of programmatically via OptionsBuilder?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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 🙇 .
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
[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).
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… storing it Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Left one clarification comment but otherwise this generally looks good to me as far as my understanding of the change goes. |
There was a problem hiding this comment.
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!
| } | ||
|
|
||
| public Set<String> getTraceStatsAdditionalTags() { | ||
| if (!experimentalFeaturesEnabled.contains( |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
the test is here: https://github.com/DataDog/dd-trace-java/blob/master/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy#L2248 and likely why CI is failing
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>
Consistent with additionalTagValues: annotation on the type, after final. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bits has a CI fix ready🟢 Investigated · 🟢 Fix prepared · ⚪ Validation skipped · 🟠 Ready Updated View in Datadog | Reviewed commit a7ebd82 · Any feedback? Reach out in #deveng-pr-agent |
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>
45d14ea to
a7ebd82
Compare
| ++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; |
There was a problem hiding this comment.
minor: Just curious why ++hitCount (and others)? Why not hitCount++?
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
Q: What if length of values will be less than n ? Or it is impossible?
There was a problem hiding this comment.
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.
| if (!ignoredResources.isEmpty() | ||
| && resourceName != null |
There was a problem hiding this comment.
Just curious why order swapped?
| /** | ||
| * 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; |
There was a problem hiding this comment.
Would it make sense to put any description why 100 and 200 selected?
| log.warn( | ||
| "Cardinality limit reached for peer tag '{}'; further values are reported as" | ||
| + " 'tracer_blocked_value' until the next reporting cycle", | ||
| names[i]); |
There was a problem hiding this comment.
Just curious if we can start flooding in logs?
| 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() { |
There was a problem hiding this comment.
All those package visibility is for testing? I recall we have an annotation @VisibleForTesting
There was a problem hiding this comment.
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>
What Does This Do
Implements span-derived primary tags (Client-Side Stats v1.3.0): users configure
DD_TRACE_STATS_ADDITIONAL_TAGSto extract matching span tag values as additional aggregation dimensions onClientGroupedStats.AdditionalMetricTags.Motivation
From the RFC...
Configuration
This feature is experimental and must be explicitly opted in.
DD_TRACE_EXPERIMENTAL_FEATURES_ENABLEDdd.trace.experimental.features.enabledDD_TRACE_STATS_ADDITIONAL_TAGS(comma-separate to enable multiple experimental features).DD_TRACE_STATS_ADDITIONAL_TAGSdd.trace.stats.additional.tagsDD_TRACE_STATS_ADDITIONAL_TAGS_CARDINALITY_LIMITdd.trace.stats.additional.tags.cardinality.limit100tracer_blocked_value.Minimal configuration to enable:
Additional Notes
Design
Wire format: new
AdditionalMetricTagsfield onClientGroupedStats, emitted asrepeated stringof"<key>:<value>"entries (mirrorsPeerTags). 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.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 (
UTF8BytesStringinterning, cardinality limiting) runs on the aggregator thread. The producer path captures rawStringvalues into aString[]parallel to the schema — no sync on the hot path.Tests
Migrated
SerializingMetricWriterTestfrom Spock/Groovy to JUnit 5 Java and folded the additional-tags coverage into its sharedValidatingSink— one msgpack-decode harness instead of two.Benchmark
AdditionalTagsMetricsBenchmark.publish(JMH, Java 17, 8 threads,@Fork(1)):limitsEnabledfalsetrueThe
truearm is slower because it records more — over-cardinality values collapse into thetracer_blocked_valuesentinel — not because limiting itself is expensive.🤖 Generated with Claude Code