Migrate TracerHealthMetrics onto the Accumulator primitive - #12383
Migrate TracerHealthMetrics onto the Accumulator primitive#12383dougqh wants to merge 13 commits into
Conversation
|
🎯 Code Coverage (details) 🔗 Commit SHA: 97041a6 | Docs | View more details | Give us feedback! |
🟢 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.
df7b5d4 to
daa0ae5
Compare
Replaces ~49 LongAdder fields plus the hand-rolled previousCounts[] delta-tracking in Flush.run() with a single Accumulator<TracerHealthMetric>, using accumulateAndReset() for the periodic drain and sum()/plus() to give summary() a live total that never races that drain. Adds the reusable StatsDCounterKey/StatsDCountReporter glue in metrics-api along the way. HealthMetricsTest and MetricsReliabilityTest pass unmodified. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
….zero(), drop capturing lambdas from onPublish/onFailedPublish - counters -> metricAccumulator for clarity. - storedTotal is now seeded via the new Accumulator.Counts.zero(), instead of constructing a fresh Accumulator just to call sum() on it. - onPublish/onFailedPublish now increment inline per switch case instead of building metric-selecting locals and combining them in a single capturing accumulator.update() lambda -- takes the per-stripe lock twice instead of once, in exchange for no lambda capture on these paths. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cals onFailedSerialize and onPartialPublish now pass their int context (trace size, dropped-span count) as an explicit parameter to Accumulator.update() instead of closing over a local, per review. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ethod ref StatsDCountReporter.report(StatsDClient, Counts<E>) uses the primitive's new Counts.values() to reduce this call site to one argument instead of TracerHealthMetric.values() + delta::get. Also switch metricAccumulator's construction to Accumulator.of(TracerHealthMetric.class)/Counts.zero(class) now that those overloads exist. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s context in onSendAttempt
…ixing a pre-existing oversight
Drops the shared Tags nested class in favor of literal string tags at each enum constant, per the same reasoning as the earlier NO_TAGS removal: the array-sharing only saved a one-time classload allocation.
…ed hot path Measures the real onCreateSpan/onFailedPublish/onPartialPublish/onSend call sites (not a synthetic stand-in) plus summary()'s peek-under- concurrent-writers cost, checking AccumulatorBenchmark's raw-primitive numbers against actual production call shapes.
LegacyTracerHealthMetrics faithfully reconstructs pre-migration TracerHealthMetrics (as of 77964b3) so the new Accumulator-backed implementation can be benchmarked against it in the same run/JVM, not just argued about. Accumulator's per-call cost is 1.1-4.6x the LongAdder baseline depending on contention and counter grouping, and summary() costs ~4x more to walk; the migration's case rests on eliminating the previousCounts/countIndex ceremony and atomic multi-field updates, not on raw per-call speed.
7157bdd to
157d441
Compare
…rray striping Benchmarking showed the AtomicLongArray design beats the realistic LongAdder-per-counter baseline by ~2 orders of magnitude on increment (the hot path) at a small cost on drain (the rare, periodic path). Per-counter atomicity is preserved via getAndSet/getAndAdd; row-wide atomicity across counters is dropped since no real caller needs it, so EmbeddingSupport, Stripe, and the update() overloads are removed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…erHealthMetric as Metric The grouped update() call sites collapse to sequential inc/add calls now that Accumulator no longer offers row-wide atomicity -- none of the 6 sites relied on it as a correctness invariant. Also fold the standalone TracerHealthMetric enum into a nested TracerHealthMetrics.Metric to shorten both the type name and the file count. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| * combination -- several constants can share a metric name but differ by tag, mirroring the | ||
| * distinct {@code LongAdder} fields this enum replaces. | ||
| */ | ||
| enum TracerHealthMetric implements StatsDCounterKey { |
There was a problem hiding this comment.
I'd like to rename this Metric and make an inner class of TraceHealthMetrics.
Summary
TracerHealthMetricsontoAccumulator<E>(Add Accumulator: a striped long counter primitive as an alternative to LongAdder #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<TracerHealthMetric>, 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.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)/techdebt— clean, no fixable debt (this branch is itself a debt-removal commit)/perf-review— 1 flag-as-measure finding (SEV-3, non-blocking): capturing lambdas passed toAccumulator.update()at 8 call sites are a different shape from the shippedAccumulatorBenchmark's non-capturing case; worth a JFR allocation check before merge but not required🤖 Generated with Claude Code