Conversation
This comment has been minimized.
This comment has been minimized.
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
|
|
||
| private final LongAdder statsAggregateDropped = new LongAdder(); | ||
| private final LongAdder statsInboxFull = new LongAdder(); | ||
| private final Accumulator<TracerHealthMetric> counters = |
There was a problem hiding this comment.
Can we call this metricAccumulator instead?
There was a problem hiding this comment.
Renamed to metricAccumulator.
| private final LongAdder statsInboxFull = new LongAdder(); | ||
| private final Accumulator<TracerHealthMetric> counters = | ||
| Accumulator.of(TracerHealthMetric.values()); | ||
| private volatile Accumulator.Counts<TracerHealthMetric> storedTotal = counters.sum(); |
There was a problem hiding this comment.
Maybe we should provide an API for creating a Counts directly. This feels a little awkward to me.
I'm also wondering if Counts should handle thread-safety, but I'm torn on that.
There was a problem hiding this comment.
Added Accumulator.Counts.zero(E[] values) (an all-zero Counts sized for the enum, no scratch Accumulator/sum() needed) and switched storedTotal's seeding to it. Left Counts itself as a plain immutable value type for now since you flagged you're torn on the thread-safety question -- happy to revisit if you land on an answer there.
| switch (samplingPriority) { | ||
| case USER_DROP: | ||
| userDropEnqueuedTraces.increment(); | ||
| enqueuedTracesMetric = TracerHealthMetric.USER_DROP_ENQUEUED_TRACES; |
There was a problem hiding this comment.
To keep the porting simple, I think let's just do inline increments here. I know that means taking the lock multiple times, but so be it.
There was a problem hiding this comment.
Done -- onPublish now increments the priority-specific counter inline per switch case and adds ENQUEUED_SPANS separately, no update() lambda.
| case USER_DROP: | ||
| userDropDroppedSpans.add(spanCount); | ||
| userDropDroppedTraces.increment(); | ||
| droppedSpansMetric = TracerHealthMetric.USER_DROP_DROPPED_SPANS; |
There was a problem hiding this comment.
I think I'd prefer either just do inline increments. Or if we can still coarsen and use, accumulator.update, but do inline in each switch case.
Right now, we're creating a capturing lambda which is something that we want to avoid.
There was a problem hiding this comment.
Same treatment here -- onFailedPublish now does inline per-case add/inc pairs instead of building droppedSpansMetric/droppedTracesMetric locals and closing over them in a single update() lambda.
| if (trace != null) { | ||
| serialFailedDroppedTraces.increment(); | ||
| serialFailedDroppedSpans.add(trace.size()); | ||
| counters.update( |
There was a problem hiding this comment.
Hmm, this case is slightly annoying.
We end up capturing the trace.
I guess we can a contextual variation on update that uses BiConsumer<C, Stripe> to solve that.
There was a problem hiding this comment.
Added exactly the overload you sketched: Accumulator.update(C context, BiConsumer<C, Stripe<E>> mutator) on dougqh/accumulator-primitive (so it's shared with anyone else pulling in the primitive). Wired it into onFailedSerialize (passes trace.size() instead of capturing trace) and, for consistency, onPartialPublish too (passes numberOfDroppedSpans). Javadoc on the new overload flags the one caveat: a primitive context gets boxed, so it's a real allocation trade against the capturing lambda it replaces rather than a free win -- worth it here since it kills the capture, but not a blanket recommendation.
47365ce to
95b9180
Compare
| public final class StatsDCountReporter { | ||
| private StatsDCountReporter() {} | ||
|
|
||
| public static <E extends Enum<E> & StatsDCounterKey> void report( |
There was a problem hiding this comment.
I'm wondering it a better option is take a Counts directly.
I'm also pondering whether Accumulator or Counts should provide a way to get the corresponding enum elements to reduce the amount of ceremony.
There was a problem hiding this comment.
Done both ways: Accumulator/Counts now remember the enum's values() array from construction, so Counts.values() lets a caller iterate its own keys -- StatsDCountReporter.report(StatsDClient, Counts<E>) uses that to collapse to one arg instead of TracerHealthMetric.values() + delta::get. Also added Accumulator.of(Class<E>)/Counts.zero(Class<E>) so metricAccumulator's construction reads as Accumulator.of(TracerHealthMetric.class). Landed on dougqh/accumulator-primitive (extra field on Counts, ~free since it's already a per-drain allocation) and wired in here.
6599253 to
df7b5d4
Compare
| public void onPartialPublish(final int numberOfDroppedSpans) { | ||
| partialTraces.increment(); | ||
| samplerDropDroppedSpans.add(numberOfDroppedSpans); | ||
| metricAccumulator.update( |
There was a problem hiding this comment.
I think we need a way to pass primitives as context without boxing. Or as least int or long?
There was a problem hiding this comment.
Added Accumulator.update(long context, ObjLongConsumer<Stripe<E>> mutator) on #12351 — reuses the JDK's ObjLongConsumer instead of the generic BiConsumer, so the context is passed as a primitive long, no boxing. An int argument widens into it for free (also no boxing), so it covers both. I didn't add a separate int overload alongside it: passing a plain int literal/variable turned out ambiguous between the two (exact match for one, free widening to the other, and the differing functional-interface types block the usual most-specific tiebreak) — long alone is the simplest fix and costs nothing for the int case.
Updated onPartialPublish (and onFailedSerialize, same shape) to use it — just a lambda-arg-order swap since ObjLongConsumer#accept is (T, long) rather than (long, T).
| statsd.incrementCounter("api.responses.total", statusTagsCache.get(status)); | ||
| } | ||
| final int status = response.status().orElse(0); | ||
| metricAccumulator.update( |
There was a problem hiding this comment.
Now, that we can pass response as context we should use that.
I think we can divide into several updates to avoid capture & boxing.
At least here, I think using update is more best effort to avoid lock acquisition overhead.
There was a problem hiding this comment.
Restructured onSendAttempt: API_REQUESTS/FLUSHED_TRACES/FLUSHED_BYTES are now three top-level inc/add calls (independent counters, same pattern as onPublish/onFailedPublish), and response is now passed as update's context for the two response-derived checks (API_ERRORS/API_RESPONSES_OK) — since it's already a reference, that's zero boxing, and grouping just those two under one update keeps a single lock acquisition for the part where it actually helps (they both need response.status()/response.exception()). The lambda no longer captures traceCount/sizeInBytes/response together.
I kept the post-lock response.status().orElse(0) call for the non-200 statsd.incrementCounter path recomputed rather than threaded through — it's a cheap Optional unwrap, and threading it out would've meant carrying a second context value. Let me know if you'd rather see that differently.
7157bdd to
157d441
Compare
| * as a standalone class here (not resurrected via checkout) purely for a same-run, same-JVM | ||
| * before/after comparison; it is not wired into anything and should never be. | ||
| */ | ||
| class LegacyTracerHealthMetrics { |
There was a problem hiding this comment.
I'll probably remove this from the final PR, but for now, it shows the performance difference.
amarziali
left a comment
There was a problem hiding this comment.
Automated review — three points worth accounting for
The migration substantially reduces the bookkeeping in TracerHealthMetrics, and the accumulator's individual atomic operations appear sound.
I found three remaining concerns:
- summary() can transiently under-report when it overlaps the two-step drain and storedTotal update.
- An exception during StatsD reporting permanently discards the drained interval, with ambiguous partial-delivery behavior.
- The convenience reporter overload exposes an internal-api type through the public metrics-api ABI.
The concurrency measurements cover writer-versus-summary and writer-versus-drain independently, but nothing currently exercises summary() concurrently with the complete drain-and-publish operation in TracerHealthMetrics.
Details are in the inline comments.
This was an automated, read-only review of head b2f791f.
| + statsAggregateDropped.sum() | ||
| + "\nstatsInboxFull=" | ||
| + statsInboxFull.sum(); | ||
| Accumulator.Counts<Metric> live = storedTotal.plus(metricAccumulator.sum()); |
There was a problem hiding this comment.
This can transiently under-report while Flush transfers counts. Java reads storedTotal before evaluating metricAccumulator.sum(). If Flush resets the accumulator and updates storedTotal between those operations, summary() combines the old stored total with the already-reset accumulator. For one pending increment it can return 0 even though the cumulative count was always 1. Please coordinate the transfer with summary(), or otherwise version the snapshot. A focused concurrency test should cover this interleaving; the current benchmarks race summaries with writers and drains with writers separately, but never summary() with the full drain-and-publish sequence.
There was a problem hiding this comment.
Fixed in ef15a5f: Flush.run()'s drain-and-publish and summary()'s read now share a lock (totalLock), so summary() can no longer land between the drain and the storedTotal publish. Only the periodic Flush task and the infrequent summary() call ever contend on it -- the hot inc/add write path is untouched, still going straight to metricAccumulator's lock-free stripes. This is a consumer-side fix (nothing changed in Accumulator itself -- see the reply on the corresponding #12351 thread).
910eb3d to
197d07b
Compare
Replaces ~49 hand-tracked LongAdder fields plus the previousCounts/ countIndex delta-tracking ceremony with one Accumulator<Metric>-backed StatsDCountReporter: each (counter, tag) pair becomes a Metric enum constant, Flush.run() collapses to a single flush() call, and summary() reads a live, non-drain-perturbing total via RunningTotal. Preserves the existing statsd call shape and summary() labels exactly, so HealthMetricsTest/MetricsReliabilityTest need no changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cy LongAdder hot path LegacyTracerHealthMetrics preserves the pre-migration LongAdder implementation as a direct JMH comparison point for the migration's hot write path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Single-JDK re-run this time (JDK 25 only, no comparable JDK 17 data point), so the dual-column JDK17/JDK25 table collapses to one column. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
197d07b to
c5b2c72
Compare
What Does This Do
Trial migration of
TracerHealthMetricsontoAccumulator<E>(#12351), the concrete case cited in that PR's review discussion as the motivating ceremony.LongAdderfields and the hand-rolledpreviousCounts[]/countIndexdelta-tracking (with itsArrayIndexOutOfBoundsExceptionsafety-net catch) inFlush.run()with a singleAccumulator<Metric>(nestedTracerHealthMetrics.Metricenum), usingaccumulateAndReset()for the periodic drain.summary()reads a live value (storedTotal.plus(counters.sum())) that never races the periodicFlushdrain, using the primitive's non-destructivesum()/Counts.plus().StatsDCounterKey/StatsDCountReporterglue inmetrics-api, decoupled frominternal-apivia aToLongFunction<E>accessor.HealthMetricsTestandMetricsReliabilityTestpass unmodified — samestatsd.count(...)call shape per flush, samesummary()labels.Motivation
Accumulator(#12351) had no real caller yet, and review pushed back on whether the abstraction earns its keep versus the status quo (LongAdder+ hand-rolled delta tracking). Rather than keep arguing in the abstract, this wires it intoTracerHealthMetrics— the concrete case already cited in that discussion — to settle the question on working code:previousCounts/countIndexhand-tracking ceremony (Accumulator.accumulateAndReset()already returns the delta since the last drain) and the 49 individualreportIfChangedcall sites, for a 187-line (28%) reduction inTracerHealthMetrics(656 → 469 lines vs. pre-migration at77964b3996), despite now inlining all 54Metricconstants that previously lived in a separate file.Additional Notes
Benchmark results (
TracerHealthMetricsBenchmark, direct measurement of real call sites)Accumulatorwas rewritten after this trial started to a lock-freeAtomicLongArray-striped design (see #12351). Under that design, the new implementation is now at parity with, or measurably faster than, the legacyLongAdderbaseline on every single-call-site benchmark, including underThreads.MAXcontention. Confirmed stable across JDK 17 and JDK 25 (point estimates agree almost exactly between JVMs).The only remaining cost is the diagnostic
summary()read (walking all 54 stripes non-destructively, ~2.5-2.6x legacy) — well below the periodic 30s-defaultFlushcadence and the ad hoc/diagnostic calls that trigger it, so not disqualifying.Note:
AccumulatorBenchmark's own javadoc (on #12351) went through two corrections. A width-8, per-thread-distributed write-side comparison was added (the single-counter benchmarks were the degenerate worst case for thelongAdderGroupbaseline), and a Fork(5)/15-sample re-run showed the drain-side "regression" from an earlier correction (13.357 us/op, ~5.5x worse) was itself a correlated anomaly across two low-sample runs — the verified number is 2.746 us/op, roughly at parity with (and in this reading faster than)longAdderGroup's noisy 4.770 ± 1.795 us/op. Neither correction changes this PR's real-call-site numbers above, which were re-measured directly.Test plan
./gradlew :dd-trace-core:test --tests "datadog.trace.core.monitor.HealthMetricsTest"— 40/40 passing, no test-file changes./gradlew :dd-trace-core:test --tests "datadog.trace.common.metrics.MetricsReliabilityTest"— passing, no test-file changes./gradlew :products:metrics:metrics-api:test --tests "datadog.metrics.api.statsd.StatsDCountReporterTest"— new tests passing./gradlew :internal-api:test --tests "datadog.trace.util.AccumulatorTest"— passing (on base branch)./gradlew :dd-trace-core:jmh -Pjmh.includes=TracerHealthMetricsBenchmark— run on both JDK 17 and JDK 25, results above/techdebt— clean, no fixable debt (this branch is itself a debt-removal commit)/perf-review— 1 flag-as-measure finding (SEV-3, non-blocking, now stale — predates theupdate()removal, no capturing-lambda call sites remain on this branch)Contributor Checklist
type:and (comp:orinst:) labels in addition to any other useful labelsclose,fix, or any linking keywords when referencing an issueJira ticket: APMLP-1779
🤖 Generated with Claude Code