Skip to content

Migrate TracerHealthMetrics onto the Accumulator primitive - #12383

Draft
dougqh wants to merge 3 commits into
dougqh/accumulator-primitivefrom
dougqh/accumulator-tracerhealthmetrics
Draft

dougqh wants to merge 3 commits into
dougqh/accumulator-primitivefrom
dougqh/accumulator-tracerhealthmetrics

Conversation

@dougqh

@dougqh dougqh commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

Trial migration of TracerHealthMetrics onto Accumulator<E> (#12351), the concrete case cited in that PR's review discussion as the motivating ceremony.

  • Replaces ~49 LongAdder fields and the hand-rolled previousCounts[]/countIndex delta-tracking (with its ArrayIndexOutOfBoundsException safety-net catch) in Flush.run() with a single Accumulator<Metric> (nested TracerHealthMetrics.Metric enum), using accumulateAndReset() for the periodic drain.
  • summary() reads a live value (storedTotal.plus(counters.sum())) that never races the periodic Flush drain, using the primitive's non-destructive sum()/Counts.plus().
  • Adds reusable StatsDCounterKey/StatsDCountReporter glue in metrics-api, decoupled from internal-api via a ToLongFunction<E> accessor.
  • HealthMetricsTest and MetricsReliabilityTest pass unmodified — same statsd.count(...) call shape per flush, same summary() 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 into TracerHealthMetrics — the concrete case already cited in that discussion — to settle the question on working code:

  • Ergonomics: eliminates the previousCounts/countIndex hand-tracking ceremony (Accumulator.accumulateAndReset() already returns the delta since the last drain) and the 49 individual reportIfChanged call sites, for a 187-line (28%) reduction in TracerHealthMetrics (656 → 469 lines vs. pre-migration at 77964b3996), despite now inlining all 54 Metric constants that previously lived in a separate file.
  • Performance: see benchmark results below — no longer a speed-for-ergonomics trade.

Additional Notes

Benchmark results (TracerHealthMetricsBenchmark, direct measurement of real call sites)

Accumulator was rewritten after this trial started to a lock-free AtomicLongArray-striped design (see #12351). Under that design, the new implementation is now at parity with, or measurably faster than, the legacy LongAdder baseline on every single-call-site benchmark, including under Threads.MAX contention. Confirmed stable across JDK 17 and JDK 25 (point estimates agree almost exactly between JVMs).

Apple M1 Max, 10 CPUs - macOS/aarch64 - JDK 17 (Zulu) / JDK 25 (Zulu)
Benchmark                       New (JDK17/25)  Legacy (JDK17/25)  Ratio
onCreateSpan_lowContention        0.007 / 0.007    0.007 / 0.007    1.0x / 1.0x
onCreateSpan_highContention       0.009 / 0.009    0.010 / 0.010    0.9x / 0.9x
onFailedPublish_lowContention     0.007 / 0.007    0.008 / 0.008    0.9x / 0.9x
onFailedPublish_highContention    0.010 / 0.010    0.011 / 0.012    0.9x / 0.8x
onPartialPublish_lowContention    0.007 / 0.007    0.008 / 0.008    0.9x / 0.9x
onPartialPublish_highContention   0.009 / 0.010    0.012 / 0.012    0.75x / 0.8x
onSend_lowContention              0.009 / 0.009    0.012 / 0.013    0.75x / 0.7x
onSend_highContention             0.013 / 0.013    0.020 / 0.022    0.65x / 0.6x
summaryWhileWriting_write         0.008 / 0.008    0.009 / 0.008    0.9x / 1.0x
summaryWhileWriting_read          1.835 / 1.833    0.705 / 0.720    2.6x / 2.55x
(all figures us/op, avgt; JDK17 / JDK25)

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-default Flush cadence 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 the longAdderGroup baseline), 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 the update() removal, no capturing-lambda call sites remain on this branch)

Contributor Checklist

Jira ticket: APMLP-1779

🤖 Generated with Claude Code

@dougqh dougqh added comp: metrics Metrics type: refactoring tag: ai generated Largely based on code generated by an AI or LLM tag: no release notes Changes to exclude from release notes labels Sep 2, 2026
@datadog-datadog-prod-us1

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Sep 2, 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 14.78 s 14.68 s [+0.0%; +1.4%] (maybe worse)
startup:insecure-bank:tracing:Agent 13.67 s 13.67 s [-0.9%; +0.9%] (no difference)
startup:petclinic:appsec:Agent 16.94 s 16.65 s [+1.0%; +2.6%] (maybe worse)
startup:petclinic:iast:Agent 16.91 s 17.02 s [-1.5%; +0.2%] (no difference)
startup:petclinic:profiling:Agent 16.29 s 16.77 s [-7.4%; +1.7%] (no difference)
startup:petclinic:sca:Agent 16.85 s 16.50 s [+1.2%; +2.9%] (significantly worse)
startup:petclinic:tracing:Agent 16.12 s 15.79 s [-2.1%; +6.2%] (no difference)

Commit: c5b2c72f · 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.


private final LongAdder statsAggregateDropped = new LongAdder();
private final LongAdder statsInboxFull = new LongAdder();
private final Accumulator<TracerHealthMetric> counters =

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.

Can we call this metricAccumulator instead?

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.

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();

@dougqh dougqh Sep 2, 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.

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.

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.

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;

@dougqh dougqh Sep 2, 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.

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.

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.

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;

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

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.

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(

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.

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.

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.

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.

@dougqh
dougqh force-pushed the dougqh/accumulator-tracerhealthmetrics branch 2 times, most recently from 47365ce to 95b9180 Compare September 2, 2026 14:36
public final class StatsDCountReporter {
private StatsDCountReporter() {}

public static <E extends Enum<E> & StatsDCounterKey> void report(

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

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.

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.

@dougqh
dougqh force-pushed the dougqh/accumulator-tracerhealthmetrics branch 2 times, most recently from 6599253 to df7b5d4 Compare September 2, 2026 15:27
public void onPartialPublish(final int numberOfDroppedSpans) {
partialTraces.increment();
samplerDropDroppedSpans.add(numberOfDroppedSpans);
metricAccumulator.update(

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 think we need a way to pass primitives as context without boxing. Or as least int or long?

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.

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(

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.

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.

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.

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.

@dougqh
dougqh force-pushed the dougqh/accumulator-tracerhealthmetrics branch 2 times, most recently from 7157bdd to 157d441 Compare September 2, 2026 18:16
Comment thread dd-trace-core/src/main/java/datadog/trace/core/monitor/TracerHealthMetric.java Outdated
Comment thread dd-trace-core/src/main/java/datadog/trace/core/monitor/TracerHealthMetrics.java Outdated
* 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 {

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'll probably remove this from the final PR, but for now, it shows the performance difference.

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

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:

  1. summary() can transiently under-report when it overlaps the two-step drain and storedTotal update.
  2. An exception during StatsD reporting permanently discards the drained interval, with ambiguous partial-delivery behavior.
  3. 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());

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.

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.

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.

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).

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.

@/tmp/reply1.txt

Comment thread dd-trace-core/src/main/java/datadog/trace/core/monitor/TracerHealthMetrics.java Outdated
@dougqh
dougqh force-pushed the dougqh/accumulator-tracerhealthmetrics branch 4 times, most recently from 910eb3d to 197d07b Compare September 11, 2026 17:59
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>
dougqh and others added 2 commits September 11, 2026 14:03
…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>
@dougqh
dougqh force-pushed the dougqh/accumulator-tracerhealthmetrics branch from 197d07b to c5b2c72 Compare September 11, 2026 18:04
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 tag: no release notes Changes to exclude from release notes type: refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants