Skip to content

fix(metrics): implement SimpleMetrics counter and histogram - #216

Merged
sspaink merged 7 commits into
open-policy-agent:mainfrom
c879873067877881111:fix/simple-metrics-counter-histogram
Sep 21, 2026
Merged

sspaink merged 7 commits into
open-policy-agent:mainfrom
c879873067877881111:fix/simple-metrics-counter-histogram

Conversation

@c879873067877881111

Copy link
Copy Markdown
Contributor

Fixes #60

Summary

SimpleMetrics only implemented Timer. histogram(String) and counter(String)
returned null, so any caller of those metric types threw an NPE, and all()
returned only the timer map, so neither type ever reached MetricsPrinter.

Changes

  • Counter backed by an AtomicInteger. The issue suggests AtomicLong/LongAdder,
    but Metrics.Counter declares add(int) and int value(), so a long accumulator
    would only add a lossy narrowing at the boundary.
  • Histogram over a bounded reservoir of 1028 samples — the size Go-OPA takes from
    rcrowley/go-metrics — replacing a random slot once full, so a long-lived Metrics
    instance cannot grow without bound. Statistics follow the Go implementation:
    population standard deviation (SampleVariance divides by N) and the linearly
    interpolated SamplePercentiles, at Go's hardcoded set (median, 75%, 90%,
    95%, 99%, 99.9%, 99.99%). count is the number of updates, not the reservoir
    occupancy, matching snap.Count().
  • all() and clear() now cover all three metric types.
  • Histogram.Values keeps its int fields, so the rows MetricsPrinterTest asserts
    are unchanged.
  • The timer map and the inline Timer are untouched — that is Make SimpleMetrics thread-safe #56. The new
    counter/histogram code is thread-safe as written.

Verification

  • ./gradlew :opa-evaluator:test :opa-evaluator:checkstyleMain :opa-evaluator:checkstyleTest :opa-evaluator:pmdMain :opa-evaluator:pmdTest — no PMD findings, and no Checkstyle
    findings for SimpleMetrics.java. SimpleMetricsTest picks up the usual MethodName
    warnings for snake_case test names, as the existing test sources do (Relax Checkstyle MethodName for test sources #88).
  • Adds SimpleMetricsTest, 8 cases. Reverting only SimpleMetrics.java fails 7 of the
    8; the 8th covers clear(), which passes trivially when nothing can be registered.
  • ./gradlew build: the only remaining failures are json_match_schema/invalid document
    and :opa-proto:vendorProtoSchemas. The first reproduces unchanged with my change
    reverted; the second is go mod download unable to reach proxy.golang.org from here.

Open questions

  1. The issue asks for configurable percentiles, but upstream Go is explicitly "a
    histogram with hardcoded percentiles" and CONTRIBUTING asks to keep the public
    surface minimal, so I matched Go. Happy to add a constructor overload if you would
    rather have it configurable.
  2. mean and stddev are float64 in Go but int in Histogram.Values, so precision
    is lost. Widening them is a public-API change that also rewrites the
    MetricsPrinterTest expectations — worth a separate issue?

@c879873067877881111
c879873067877881111 requested a review from a team as a code owner September 3, 2026 08:07
SimpleMetrics only implemented Timer. histogram(String) and
counter(String) returned null, so any caller using those metric
types hit a NullPointerException, and neither type was reported
by all(), so MetricsPrinter never saw them either.

Counter is backed by an AtomicInteger, which matches the
int-typed Metrics.Counter interface. Histogram keeps a bounded
reservoir of 1028 samples -- the size Go-OPA takes from
rcrowley/go-metrics -- and reports the same statistics as the Go
implementation: population standard deviation, and linearly
interpolated percentiles at 75%, 90%, 95%, 99%, 99.9% and
99.99%.

Histogram.Values keeps its existing int fields, so the row
shapes MetricsPrinterTest asserts are unchanged.

Fixes open-policy-agent#60

Signed-off-by: c879873067877881111 <c879873067877881111@gmail.com>
@c879873067877881111
c879873067877881111 force-pushed the fix/simple-metrics-counter-histogram branch from 0b9ef5b to ec9976d Compare September 3, 2026 18:19
return new TreeMap<>(timers);
Map<String, Metric> all = new TreeMap<>(timers);
all.putAll(counters);
all.putAll(histograms);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that counters and histograms are in all(), DecisionLogPlugin.buildDecisionEvent falls into its else branch and calls MAPPER.valueToTree(...) on SimpleCounter/SimpleHistogram — neither has a Jackson-visible property, so it throws No serializer found ... and no properties discovered to create BeanSerializer. DecisionLogs.logDecision swallows that as "Failed to log decision", so the entire decision event is dropped, which is the opposite of the CHANGELOG's "so they reach ... decision logs".

MetricsModule already solves this for Timer; the same mixin treatment covers the other two:

setMixInAnnotation(Metrics.Counter.class, CounterMixin.class);
setMixInAnnotation(Metrics.Histogram.class, HistogramMixin.class);

abstract static class CounterMixin {
  @JsonValue
  abstract int value();
}

abstract static class HistogramMixin {
  @JsonValue
  abstract Metrics.Histogram.Values value();
}

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.

Mixins added. Testing them turned up a second path: DecisionLogs.MAPPER is a bare ObjectMapper + JavaTimeModule, and opa-jackson is only runtimeOnly for opa-services, so the mixins don't reach it — with them in place the repro still failed identically. Timers avoid it only because of the explicit timer_<name>_ns branch.
So buildDecisionEvent now writes counters and histograms itself as well, under formatKey's names, counter_<name> and histogram_<name>; unknown Metrics keep the Jackson fallback. Covered by decisionLogs_includesEveryMetricType.

}

private static int round(double value) {
return (int) Math.round(value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can overflow. The largest value an int holds is about 2.1 billion, which is only 2.15 seconds in nanoseconds — and nanosecond timings are what Go feeds histograms in topdown/instrumentation.go. Anything bigger turns negative: update(3e9) reports -1294967296 for every stat. The line above does Math.min(count, Integer.MAX_VALUE) to avoid this; the same idea works here:

Suggested change
return (int) Math.round(value);
return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, Math.round(value)));

@c879873067877881111 c879873067877881111 Sep 9, 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.

The suggestion was adopted without modification.
Overflow occurs in the negative direction as well—update(-3e9) results in +1294967296; his original comment only mentioned the positive-to-negative case.
Both directions were tested; the test names are histogram_saturatesStatsAboveIntRange and histogram_saturatesStatsBelowIntRange.
I left Values as int rather than widening to Go's int64/float64.

public Map<String, Metric> all() {
return new TreeMap<>(timers);
Map<String, Metric> all = new TreeMap<>(timers);
all.putAll(counters);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since all three maps share one key space, reusing a name across types silently drops one. After metrics.timer("x") and metrics.counter("x"), all() returns just the counter — the timer is gone from the printer, the CLI table and decision logs. Go's All() prefixes each key by type (timer_x, counter_x), so both survive there.

Matching that means moving the prefixing out of MetricsPrinter and into all(), which is bigger than this PR — fine as a follow-up, but worth noting the gap 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.

Agreed with all three types sharing a name, only one survives all().
Confirmed formatKey behaves as you describe. Leaving it out of this PR
Do you want to open an issue to track it?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue opened: #229)

Now that all() reports counters and histograms, buildDecisionEvent
falls into its else branch and calls MAPPER.valueToTree on them.
Neither SimpleCounter nor SimpleHistogram has a Jackson-visible
property, so Jackson throws "No serializer found ... and no
properties discovered to create BeanSerializer". logDecision
swallows that as "Failed to log decision", so the entire decision
event is dropped, not just the metric.

Counters and histograms are now written out field by field under
the names OPA Go publishes them as (see metrics.formatKey):
counter_<name> and histogram_<name>, alongside the existing
timer_<name>_ns. Timers never hit this because they already had
an explicit branch. Any other Metric implementation still falls
back to Jackson under its bare name, as Go's default branch does.

Registering MetricsModule is not an alternative fix here: the
mapper in DecisionLogs is a bare ObjectMapper plus JavaTimeModule,
and opa-jackson is only a runtimeOnly dependency of opa-services,
so the module's mixins never reach it.

MetricsModule still needs the same treatment for its own
consumers, though, so Counter and Histogram get the mixin that
Timer already had. All three mixins are now interfaces, matching
the interfaces they are applied to.

Signed-off-by: c879873067877881111 <c879873067877881111@gmail.com>
Histogram.Values holds ints, but Go feeds histograms nanosecond
timings (topdown/instrumentation.go) and Integer.MAX_VALUE
nanoseconds is only 2.15 seconds. Anything past that wrapped:
update(3e9) reported -1294967296 for every stat, and the sign
flips both ways, so update(-3e9) reported +1294967296.

round() now clamps to the int range the way the count field above
it already did, rather than letting the cast wrap. Widening
Values to long/double would match Go more closely, but it is a
public type this change does not otherwise touch.

Signed-off-by: c879873067877881111 <c879873067877881111@gmail.com>
Integer.MAX_VALUE nanoseconds is 2.147s, not 2.15s, so the wording
implied a safe window that already overflowed. Also note the sign flips
in both directions, which update(-3e9) does.

Signed-off-by: c879873067877881111 <c879873067877881111@gmail.com>
addMetric's javadoc claims any Metric that is not a Timer, Counter
or Histogram still reaches the event under its bare name, matching
Go's formatKey default branch. Nothing tested that: deleting the
else branch left every test green.

Metrics is a public interface, so an implementation reporting a
metric of some other type is reachable, and silently dropping it
would be a real loss. The test uses a Jackson-serializable Metric
and fails when the fallback is removed.

Signed-off-by: c879873067877881111 <c879873067877881111@gmail.com>
Integer.MAX_VALUE nanoseconds is 2.147s, not 2.15s, so the wording
implied a safe window that already overflowed. The CHANGELOG was
corrected but these two comments were missed.

Signed-off-by: c879873067877881111 <c879873067877881111@gmail.com>

@sspaink sspaink left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks!

@sspaink
sspaink merged commit 1e45d85 into open-policy-agent:main Sep 21, 2026
23 checks passed
@c879873067877881111
c879873067877881111 deleted the fix/simple-metrics-counter-histogram branch September 23, 2026 01:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SimpleMetrics.histogram() and counter() return null

2 participants