diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/metrics/MetricsPrinter.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/metrics/MetricsPrinter.java
new file mode 100644
index 00000000..8a707b18
--- /dev/null
+++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/metrics/MetricsPrinter.java
@@ -0,0 +1,137 @@
+package io.github.open_policy_agent.opa.metrics;
+
+import io.github.open_policy_agent.opa.metrics.Metrics.Counter;
+import io.github.open_policy_agent.opa.metrics.Metrics.Histogram;
+import io.github.open_policy_agent.opa.metrics.Metrics.Metric;
+import io.github.open_policy_agent.opa.metrics.Metrics.Timer;
+import java.io.ByteArrayOutputStream;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+/**
+ * Pretty-prints a {@link Metrics} snapshot as a box-drawn two-column table that resembles the
+ * output of {@code opa eval --metrics}.
+ *
+ *
Row keys follow the OPA CLI naming convention:
+ *
+ *
+ * - Timers are emitted as {@code timer__ns} with the duration in nanoseconds.
+ *
- Counters are emitted as {@code counter_} with the integer value.
+ *
- Histograms are exploded into one row per stat: {@code histogram__count},
+ * {@code _min}, {@code _max}, {@code _mean}, {@code _stddev}, {@code _median}, plus one row
+ * per percentile ({@code _75%}, {@code _99%}, …).
+ *
+ *
+ * Rows are sorted alphabetically by display name. Column widths are sized to the widest cell.
+ */
+public class MetricsPrinter {
+
+ private static final String NAME_HEADER = "Metric";
+ private static final String VALUE_HEADER = "Value";
+
+ public static void printMetrics(Metrics metrics, OutputStream out) {
+ PrintWriter writer = new PrintWriter(out);
+
+ SortedMap rows = collectRows(metrics);
+ int nameWidth = Math.max(NAME_HEADER.length(), maxLen(rows.keySet())) + 2;
+ int valueWidth = Math.max(VALUE_HEADER.length(), maxLen(rows.values())) + 2;
+
+ writer.append(border('┌', '┬', '┐', nameWidth, valueWidth)).append("\n");
+ writer
+ .append("│")
+ .append(center(NAME_HEADER, nameWidth))
+ .append("│")
+ .append(center(VALUE_HEADER, valueWidth))
+ .append("│\n");
+ writer.append(border('├', '┼', '┤', nameWidth, valueWidth)).append("\n");
+ for (Map.Entry e : rows.entrySet()) {
+ writer
+ .append("│")
+ .append(leftCell(e.getKey(), nameWidth))
+ .append("│")
+ .append(leftCell(e.getValue(), valueWidth))
+ .append("│\n");
+ }
+ writer.append(border('└', '┴', '┘', nameWidth, valueWidth)).append("\n");
+
+ writer.flush();
+ }
+
+ public static String metricsToString(Metrics metrics) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ printMetrics(metrics, out);
+ return out.toString();
+ }
+
+ private static SortedMap collectRows(Metrics metrics) {
+ SortedMap rows = new TreeMap<>();
+ Map all = metrics.all();
+ if (all == null) {
+ return rows;
+ }
+ for (Map.Entry entry : all.entrySet()) {
+ String key = entry.getKey();
+ Metric m = entry.getValue();
+ if (m instanceof Timer) {
+ rows.put("timer_" + key + "_ns", String.valueOf(((Timer) m).value().toNanos()));
+ } else if (m instanceof Counter) {
+ rows.put("counter_" + key, String.valueOf(((Counter) m).value()));
+ } else if (m instanceof Histogram) {
+ Histogram.Values v = ((Histogram) m).value();
+ if (v == null) {
+ continue;
+ }
+ String prefix = "histogram_" + key + "_";
+ rows.put(prefix + "count", String.valueOf(v.count));
+ rows.put(prefix + "min", String.valueOf(v.min));
+ rows.put(prefix + "max", String.valueOf(v.max));
+ rows.put(prefix + "mean", String.valueOf(v.mean));
+ rows.put(prefix + "stddev", String.valueOf(v.stddev));
+ rows.put(prefix + "median", String.valueOf(v.median));
+ if (v.percentiles != null) {
+ for (Map.Entry p : v.percentiles.entrySet()) {
+ rows.put(prefix + p.getKey(), String.valueOf(p.getValue()));
+ }
+ }
+ }
+ }
+ return rows;
+ }
+
+ private static int maxLen(Iterable values) {
+ int max = 0;
+ for (String s : values) {
+ if (s != null && s.length() > max) {
+ max = s.length();
+ }
+ }
+ return max;
+ }
+
+ private static String border(char left, char mid, char right, int nameWidth, int valueWidth) {
+ return left + repeat('─', nameWidth) + mid + repeat('─', valueWidth) + right;
+ }
+
+ private static String center(String s, int width) {
+ int total = width - s.length();
+ int leftPad = total / 2;
+ int rightPad = total - leftPad;
+ return repeat(' ', leftPad) + s + repeat(' ', rightPad);
+ }
+
+ private static String leftCell(String s, int width) {
+ // 1 leading space + content + trailing spaces, matching OPA CLI alignment.
+ return " " + s + repeat(' ', width - 1 - s.length());
+ }
+
+ private static String repeat(char c, int n) {
+ StringBuilder sb = new StringBuilder(n);
+ for (int i = 0; i < n; i++) {
+ sb.append(c);
+ }
+ return sb.toString();
+ }
+}
diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/metrics/SimpleMetrics.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/metrics/SimpleMetrics.java
similarity index 93%
rename from opa-services/src/main/java/io/github/open_policy_agent/opa/metrics/SimpleMetrics.java
rename to opa-evaluator/src/main/java/io/github/open_policy_agent/opa/metrics/SimpleMetrics.java
index 57feee1e..e9cc5350 100644
--- a/opa-services/src/main/java/io/github/open_policy_agent/opa/metrics/SimpleMetrics.java
+++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/metrics/SimpleMetrics.java
@@ -1,6 +1,5 @@
package io.github.open_policy_agent.opa.metrics;
-import com.fasterxml.jackson.annotation.JsonValue;
import java.time.Duration;
import java.util.*;
@@ -31,7 +30,6 @@ public void stop() {
end = System.nanoTime();
}
- @JsonValue
@Override
public Duration value() {
return Duration.ofNanos(end - start);
diff --git a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/profiling/SimpleStatementProfiler.java b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/profiling/SimpleStatementProfiler.java
index 26000924..e269fb81 100644
--- a/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/profiling/SimpleStatementProfiler.java
+++ b/opa-evaluator/src/main/java/io/github/open_policy_agent/opa/profiling/SimpleStatementProfiler.java
@@ -4,6 +4,7 @@
import java.util.*;
import io.github.open_policy_agent.opa.ir.stmts.CallStmt;
import io.github.open_policy_agent.opa.ir.stmts.Stmt;
+import io.github.open_policy_agent.opa.ir.stmts.Stmt.STMT_TYPE;
/**
* Simple implementation of StatementProfiler that tracks statement execution time and frequency.
@@ -66,7 +67,7 @@ public Map getStatementSummaries() {
* @return a human-readable name for the statement
*/
private String getStatementName(Stmt stmt) {
- if ("CallStmt".equals(stmt.getType())) {
+ if (stmt.getType() == STMT_TYPE.CALL) {
return ((CallStmt) stmt).getFunc();
} else {
return stmt.getType().getTypeName();
diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/metrics/MetricsIntegrationTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/metrics/MetricsIntegrationTest.java
new file mode 100644
index 00000000..78cefe5c
--- /dev/null
+++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/metrics/MetricsIntegrationTest.java
@@ -0,0 +1,150 @@
+package io.github.open_policy_agent.opa.metrics;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.ServiceLoader;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import io.github.open_policy_agent.opa.ast.types.RegoObject;
+import io.github.open_policy_agent.opa.bundle.Bundle;
+import io.github.open_policy_agent.opa.ir.PolicyReader;
+import io.github.open_policy_agent.opa.ir.policy.Policy;
+import io.github.open_policy_agent.opa.metrics.Metrics.Metric;
+import io.github.open_policy_agent.opa.metrics.Metrics.Timer;
+import io.github.open_policy_agent.opa.rego.Engine;
+import io.github.open_policy_agent.opa.storage.InMem;
+import io.github.open_policy_agent.opa.storage.Store;
+
+/**
+ * End-to-end integration test for {@link Metrics} and {@link MetricsPrinter}, exercising the full
+ * evaluator via {@link Engine.PreparedQuery} so the timer hooks fired by {@code Engine} are
+ * actually populated.
+ */
+class MetricsIntegrationTest {
+
+ private static final ObjectMapper MAPPER =
+ new ObjectMapper().registerModule(new io.github.open_policy_agent.opa.jackson.RegoValueModule());
+ private static final PolicyReader POLICY_READER =
+ ServiceLoader.load(PolicyReader.class).findFirst().orElseThrow();
+ private static final String ENTRYPOINT = "authz/allow";
+
+ // The Engine wires these timers around every prepared-query evaluation.
+ private static final Set EXPECTED_TIMER_KEYS =
+ Set.of("rego_query_eval", "rego_parse_pojo_input", "rego_marshal_pojo_results");
+
+ private static Policy policy;
+
+ @BeforeAll
+ static void loadPolicy() throws IOException {
+ File policyFile =
+ new File(
+ Objects.requireNonNull(
+ MetricsIntegrationTest.class
+ .getClassLoader()
+ .getResource("engine/testdata/authz-policy.json"))
+ .getFile());
+ policy = POLICY_READER.read(Files.newInputStream(policyFile.toPath()));
+ }
+
+ private static Engine buildEngine(String dataJson) throws IOException {
+ Store store = new InMem();
+ RegoObject data = MAPPER.readValue(dataJson, RegoObject.class);
+ Bundle bundle = new Bundle.Builder().withIrPolicy(policy).build();
+ store.write(ENTRYPOINT, bundle, data);
+
+ return new Engine.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build();
+ }
+
+ private static Map input(String id, String... groups) {
+ return Map.of("user", Map.of("id", id, "groups", List.of(groups)));
+ }
+
+ @Test
+ void preparedQuery_recordsExpectedTimersFromRealEvaluation() throws IOException {
+ Engine engine = buildEngine("{\"groups\":{\"admin\":{\"privileged\":true}}}");
+ SimpleMetrics metrics = new SimpleMetrics();
+ Engine.PreparedQuery pq =
+ engine.prepareForEvaluation().withMetrics(metrics).build();
+
+ List results = pq.eval(input("bob", "admin"), Boolean.class);
+
+ assertTrue(results.get(0), "bob/admin should be allowed via privileged group");
+
+ Map all = metrics.all();
+ assertFalse(all.isEmpty(), "expected at least one metric recorded");
+ EXPECTED_TIMER_KEYS.forEach(
+ key -> {
+ Metric m = all.get(key);
+ assertNotNull(m, "missing expected timer: " + key + "; saw " + all.keySet());
+ assertTrue(m instanceof Timer, key + " was not a Timer: " + m.getClass());
+ assertFalse(
+ ((Timer) m).value().isNegative(), key + " duration should be non-negative");
+ });
+ }
+
+ @Test
+ void metricsPrinter_overPreparedQueryEvaluation_emitsBoxedTableWithTimerRows() throws IOException {
+ Engine engine = buildEngine("{}");
+ SimpleMetrics metrics = new SimpleMetrics();
+ Engine.PreparedQuery pq =
+ engine.prepareForEvaluation().withMetrics(metrics).build();
+ pq.eval(input("alice"), Boolean.class);
+
+ String table = MetricsPrinter.metricsToString(metrics);
+
+ // Box-drawn structure: top, header, separator, ..., bottom.
+ assertTrue(table.startsWith("┌"), "table should start with top border:\n" + table);
+ assertTrue(table.contains("Metric"), "table should contain the Metric header:\n" + table);
+ assertTrue(table.contains("Value"), "table should contain the Value header:\n" + table);
+ assertTrue(
+ table.trim().endsWith("┘"), "table should end with bottom border:\n" + table);
+
+ // Every Engine-emitted timer should appear with the timer__ns naming convention.
+ EXPECTED_TIMER_KEYS.forEach(
+ key ->
+ assertTrue(
+ table.contains("timer_" + key + "_ns"),
+ "expected timer_" + key + "_ns row in:\n" + table));
+
+ // Each data row must respect the box width set by the widest cell.
+ int boxWidth = table.indexOf('\n');
+ table
+ .lines()
+ .filter(l -> !l.isEmpty())
+ .forEach(
+ line ->
+ assertEquals(
+ boxWidth,
+ line.length(),
+ "row width mismatch for line: '" + line + "' (expected " + boxWidth + ")"));
+ }
+
+ @Test
+ void preparedQuery_repeatedEvaluations_accumulateTimerCallsButRetainNamesOnce() throws IOException {
+ Engine engine = buildEngine("{}");
+ SimpleMetrics metrics = new SimpleMetrics();
+ Engine.PreparedQuery pq =
+ engine.prepareForEvaluation().withMetrics(metrics).build();
+
+ pq.eval(input("alice"), Boolean.class);
+ Set firstKeys = Set.copyOf(metrics.all().keySet());
+ pq.eval(input("alice"), Boolean.class);
+ pq.eval(input("alice"), Boolean.class);
+
+ // SimpleMetrics keeps one Timer per key across evaluations — repeated evals must not register
+ // duplicate keys.
+ assertEquals(firstKeys, metrics.all().keySet());
+ }
+}
diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/metrics/MetricsPrinterTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/metrics/MetricsPrinterTest.java
new file mode 100644
index 00000000..95f9900a
--- /dev/null
+++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/metrics/MetricsPrinterTest.java
@@ -0,0 +1,220 @@
+package io.github.open_policy_agent.opa.metrics;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.github.open_policy_agent.opa.metrics.Metrics.Counter;
+import io.github.open_policy_agent.opa.metrics.Metrics.Histogram;
+import io.github.open_policy_agent.opa.metrics.Metrics.Metric;
+import io.github.open_policy_agent.opa.metrics.Metrics.Timer;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class MetricsPrinterTest {
+
+ static Stream formattingCases() {
+ return Stream.of(
+ Arguments.of(
+ "empty metrics produces just headers",
+ metricsOf(Map.of()),
+ join(
+ "┌────────┬───────┐",
+ "│ Metric │ Value │",
+ "├────────┼───────┤",
+ "└────────┴───────┘")),
+ Arguments.of(
+ "single timer is keyed timer__ns and value is in nanoseconds",
+ metricsOf(Map.of("foo", fixedTimer(Duration.ofNanos(123)))),
+ join(
+ "┌──────────────┬───────┐",
+ "│ Metric │ Value │",
+ "├──────────────┼───────┤",
+ "│ timer_foo_ns │ 123 │",
+ "└──────────────┴───────┘")),
+ Arguments.of(
+ "rows are sorted alphabetically by display name",
+ metricsOf(
+ linkedMap(
+ "zeta", fixedTimer(Duration.ofNanos(2)),
+ "alpha", fixedTimer(Duration.ofNanos(1)))),
+ join(
+ "┌────────────────┬───────┐",
+ "│ Metric │ Value │",
+ "├────────────────┼───────┤",
+ "│ timer_alpha_ns │ 1 │",
+ "│ timer_zeta_ns │ 2 │",
+ "└────────────────┴───────┘")),
+ Arguments.of(
+ "counter is keyed counter_",
+ metricsOf(Map.of("hits", fixedCounter(42))),
+ join(
+ "┌──────────────┬───────┐",
+ "│ Metric │ Value │",
+ "├──────────────┼───────┤",
+ "│ counter_hits │ 42 │",
+ "└──────────────┴───────┘")),
+ Arguments.of(
+ "histogram explodes into one row per stat plus percentiles",
+ metricsOf(
+ Map.of(
+ "calls",
+ fixedHistogram(
+ histogramValues(10, 1, 9, 5, 3, 4, linkedMap("75%", 7, "99%", 9))))),
+ join(
+ "┌────────────────────────┬───────┐",
+ "│ Metric │ Value │",
+ "├────────────────────────┼───────┤",
+ "│ histogram_calls_75% │ 7 │",
+ "│ histogram_calls_99% │ 9 │",
+ "│ histogram_calls_count │ 10 │",
+ "│ histogram_calls_max │ 9 │",
+ "│ histogram_calls_mean │ 5 │",
+ "│ histogram_calls_median │ 4 │",
+ "│ histogram_calls_min │ 1 │",
+ "│ histogram_calls_stddev │ 3 │",
+ "└────────────────────────┴───────┘")),
+ Arguments.of(
+ "column widths size to the widest cell",
+ metricsOf(Map.of("a_long_metric_name", fixedTimer(Duration.ofNanos(1234567890L)))),
+ join(
+ "┌─────────────────────────────┬────────────┐",
+ "│ Metric │ Value │",
+ "├─────────────────────────────┼────────────┤",
+ "│ timer_a_long_metric_name_ns │ 1234567890 │",
+ "└─────────────────────────────┴────────────┘")));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("formattingCases")
+ void produceExpectedOutput(String name, Metrics metrics, String expected) {
+ assertEquals(expected, MetricsPrinter.metricsToString(metrics));
+ }
+
+ @Test
+ void simpleMetrics_unstartedTimers_produceZeroNanosRows() {
+ SimpleMetrics metrics = new SimpleMetrics();
+ metrics.timer("rego_query_eval");
+ metrics.timer("rego_query_parse");
+
+ String out = MetricsPrinter.metricsToString(metrics);
+
+ assertTrue(out.contains("timer_rego_query_eval_ns"), out);
+ assertTrue(out.contains("timer_rego_query_parse_ns"), out);
+ // rego_query_eval sorts before rego_query_parse alphabetically.
+ int evalIdx = out.indexOf("timer_rego_query_eval_ns");
+ int parseIdx = out.indexOf("timer_rego_query_parse_ns");
+ assertTrue(evalIdx > 0 && parseIdx > evalIdx, "rows are not sorted alphabetically:\n" + out);
+ }
+
+ // --- helpers ---
+
+ private static String join(String... lines) {
+ StringBuilder sb = new StringBuilder();
+ for (String line : lines) {
+ sb.append(line).append('\n');
+ }
+ return sb.toString();
+ }
+
+ private static Metrics metricsOf(Map entries) {
+ Map all = new LinkedHashMap<>(entries);
+ return new Metrics() {
+ @Override
+ public String name() {
+ return "";
+ }
+
+ @Override
+ public Timer timer(String name) {
+ return (Timer) all.get(name);
+ }
+
+ @Override
+ public Histogram histogram(String name) {
+ return (Histogram) all.get(name);
+ }
+
+ @Override
+ public Counter counter(String name) {
+ return (Counter) all.get(name);
+ }
+
+ @Override
+ public Map all() {
+ return all;
+ }
+
+ @Override
+ public void Clear() {}
+ };
+ }
+
+ private static Timer fixedTimer(Duration duration) {
+ return new Timer() {
+ @Override
+ public void start() {}
+
+ @Override
+ public void stop() {}
+
+ @Override
+ public Duration value() {
+ return duration;
+ }
+ };
+ }
+
+ private static Counter fixedCounter(int value) {
+ return new Counter() {
+ @Override
+ public void add(int v) {}
+
+ @Override
+ public void incr() {}
+
+ @Override
+ public int value() {
+ return value;
+ }
+ };
+ }
+
+ private static Histogram fixedHistogram(Histogram.Values values) {
+ return new Histogram() {
+ @Override
+ public void update(double v) {}
+
+ @Override
+ public Histogram.Values value() {
+ return values;
+ }
+ };
+ }
+
+ private static Histogram.Values histogramValues(
+ int count, int min, int max, int mean, int stddev, int median, Map p) {
+ Histogram.Values v = new Histogram.Values();
+ v.count = count;
+ v.min = min;
+ v.max = max;
+ v.mean = mean;
+ v.stddev = stddev;
+ v.median = median;
+ v.percentiles = new HashMap<>(p);
+ return v;
+ }
+
+ private static Map linkedMap(K k1, V v1, K k2, V v2) {
+ Map m = new LinkedHashMap<>();
+ m.put(k1, v1);
+ m.put(k2, v2);
+ return m;
+ }
+}
diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/profiling/SimpleStatementProfilerIntegrationTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/profiling/SimpleStatementProfilerIntegrationTest.java
new file mode 100644
index 00000000..32279d0e
--- /dev/null
+++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/profiling/SimpleStatementProfilerIntegrationTest.java
@@ -0,0 +1,131 @@
+package io.github.open_policy_agent.opa.profiling;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.ServiceLoader;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import io.github.open_policy_agent.opa.ast.types.RegoObject;
+import io.github.open_policy_agent.opa.bundle.Bundle;
+import io.github.open_policy_agent.opa.ir.PolicyReader;
+import io.github.open_policy_agent.opa.ir.policy.Policy;
+import io.github.open_policy_agent.opa.profiling.StatementProfiler.StatementSummary;
+import io.github.open_policy_agent.opa.rego.Engine;
+import io.github.open_policy_agent.opa.storage.InMem;
+import io.github.open_policy_agent.opa.storage.Store;
+
+/**
+ * End-to-end integration test for {@link SimpleStatementProfiler}, exercising the full evaluator
+ * via {@link Engine} and {@link Engine.PreparedQuery} so the start/stop hooks fired from
+ * {@code Evaluator} are covered.
+ */
+class SimpleStatementProfilerIntegrationTest {
+
+ private static final ObjectMapper MAPPER =
+ new ObjectMapper().registerModule(new io.github.open_policy_agent.opa.jackson.RegoValueModule());
+ private static final PolicyReader POLICY_READER =
+ ServiceLoader.load(PolicyReader.class).findFirst().orElseThrow();
+ private static final String ENTRYPOINT = "authz/allow";
+
+ private static Policy policy;
+
+ @BeforeAll
+ static void loadPolicy() throws IOException {
+ File policyFile =
+ new File(
+ Objects.requireNonNull(
+ SimpleStatementProfilerIntegrationTest.class
+ .getClassLoader()
+ .getResource("engine/testdata/authz-policy.json"))
+ .getFile());
+ policy = POLICY_READER.read(Files.newInputStream(policyFile.toPath()));
+ }
+
+ private static Engine buildEngine(String dataJson) throws IOException {
+ Store store = new InMem();
+ RegoObject data = MAPPER.readValue(dataJson, RegoObject.class);
+ Bundle bundle = new Bundle.Builder().withIrPolicy(policy).build();
+ store.write(ENTRYPOINT, bundle, data);
+
+ return new Engine.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build();
+ }
+
+ private static Map input(String id, String... groups) {
+ return Map.of("user", Map.of("id", id, "groups", List.of(groups)));
+ }
+
+ @Test
+ void preparedQuery_recordsStatementSummariesFromRealEvaluation() throws IOException {
+ Engine engine = buildEngine("{\"groups\":{\"admin\":{\"privileged\":true}}}");
+ SimpleStatementProfiler profiler = new SimpleStatementProfiler();
+ Engine.PreparedQuery pq =
+ engine.prepareForEvaluation().withStatementProfiler(profiler).build();
+
+ List results = pq.eval(input("bob", "admin"), Boolean.class);
+
+ assertTrue(results.get(0), "bob/admin should be allowed via privileged group");
+
+ Map summaries = profiler.getStatementSummaries();
+ assertFalse(summaries.isEmpty(), "expected at least one statement summary");
+
+ // Every recorded statement must have run at least once.
+ summaries.values().forEach(s -> assertTrue(s.getCount() > 0, s.getName() + " count not > 0"));
+
+ // The authz policy walks input.user.groups via a ScanStmt and dispatches a CallStmt to the
+ // membership builtin and the rule body. Both should be visible after evaluation.
+ assertTrue(summaries.containsKey("ScanStmt"), "expected ScanStmt; saw " + summaries.keySet());
+ assertTrue(
+ summaries.containsKey("internal.member_2"),
+ "expected CallStmt to be keyed by function name; saw " + summaries.keySet());
+ assertTrue(
+ summaries.containsKey("g0.data.authz.allow"),
+ "expected the rule call to be keyed by function name; saw " + summaries.keySet());
+
+ // CallStmt naming bug regression: function-call summaries must not be lumped under the raw
+ // type name "CallStmt".
+ assertFalse(
+ summaries.containsKey("CallStmt"),
+ "CallStmts must be keyed by function name, not the type name");
+ }
+
+ @Test
+ void preparedQuery_repeatedEvaluations_accumulateCounts() throws IOException {
+ Engine engine = buildEngine("{}");
+ SimpleStatementProfiler profiler = new SimpleStatementProfiler();
+ Engine.PreparedQuery pq =
+ engine.prepareForEvaluation().withStatementProfiler(profiler).build();
+
+ pq.eval(input("alice"), Boolean.class);
+ Map firstCounts = countsByName(profiler.getStatementSummaries());
+
+ pq.eval(input("alice"), Boolean.class);
+ pq.eval(input("alice"), Boolean.class);
+ Map finalCounts = countsByName(profiler.getStatementSummaries());
+
+ assertNotNull(firstCounts);
+ assertFalse(firstCounts.isEmpty());
+ // After three identical evaluations every recorded statement should have run 3x its first-eval
+ // count — the profiler is shared across all prepared-query calls and must not reset.
+ firstCounts.forEach(
+ (name, count) ->
+ assertEquals(
+ count * 3, finalCounts.get(name), "count for " + name + " did not triple"));
+ }
+
+ private static Map countsByName(Map summaries) {
+ return summaries.entrySet().stream()
+ .collect(
+ java.util.stream.Collectors.toMap(
+ Map.Entry::getKey, e -> e.getValue().getCount()));
+ }
+}
diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/profiling/SimpleStatementProfilerTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/profiling/SimpleStatementProfilerTest.java
new file mode 100644
index 00000000..c0003bd4
--- /dev/null
+++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/profiling/SimpleStatementProfilerTest.java
@@ -0,0 +1,218 @@
+package io.github.open_policy_agent.opa.profiling;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import io.github.open_policy_agent.opa.ir.stmts.CallStmt;
+import io.github.open_policy_agent.opa.ir.stmts.NopStmt;
+import io.github.open_policy_agent.opa.ir.stmts.Stmt;
+import io.github.open_policy_agent.opa.profiling.StatementProfiler.StatementSummary;
+
+class SimpleStatementProfilerTest {
+
+ private SimpleStatementProfiler profiler;
+
+ @BeforeEach
+ void setUp() {
+ profiler = new SimpleStatementProfiler();
+ }
+
+ static Stream recordingScenarios() {
+ return Stream.of(
+ Arguments.of(
+ "single statement",
+ (Consumer)
+ p -> {
+ NopStmt nop = new NopStmt(0, 1, 5);
+ p.startStatement(nop);
+ p.stopStatement(nop, 100L);
+ },
+ Map.of("NopStmt", expected(1, 100L))),
+ Arguments.of(
+ "repeated statement accumulates count and duration",
+ (Consumer)
+ p -> {
+ NopStmt nop = new NopStmt(0, 1, 5);
+ p.startStatement(nop);
+ p.stopStatement(nop, 30L);
+ p.startStatement(nop);
+ p.stopStatement(nop, 70L);
+ p.startStatement(nop);
+ p.stopStatement(nop, 50L);
+ },
+ Map.of("NopStmt", expected(3, 150L))),
+ Arguments.of(
+ "CallStmt is keyed by function name",
+ (Consumer)
+ p -> {
+ CallStmt call = new CallStmt("my.func", List.of(), 0);
+ p.startStatement(call);
+ p.stopStatement(call, 42L);
+ },
+ Map.of("my.func", expected(1, 42L))),
+ Arguments.of(
+ "CallStmts group by function name",
+ (Consumer)
+ p -> {
+ CallStmt foo1 = new CallStmt("foo", List.of(), 0);
+ CallStmt foo2 = new CallStmt("foo", List.of(), 0);
+ CallStmt bar = new CallStmt("bar", List.of(), 0);
+ p.startStatement(foo1);
+ p.stopStatement(foo1, 10L);
+ p.startStatement(foo2);
+ p.stopStatement(foo2, 20L);
+ p.startStatement(bar);
+ p.stopStatement(bar, 5L);
+ },
+ Map.of(
+ "foo", expected(2, 30L),
+ "bar", expected(1, 5L))),
+ Arguments.of(
+ // Parent (Nop) takes 100ns total; child (Call f) takes 60ns.
+ // Parent's exclusive time should be 40ns.
+ "nested child time subtracts from parent",
+ (Consumer)
+ p -> {
+ NopStmt parent = new NopStmt(0, 1, 5);
+ CallStmt child = new CallStmt("f", List.of(), 0);
+ p.startStatement(parent);
+ p.startStatement(child);
+ p.stopStatement(child, 60L);
+ p.stopStatement(parent, 100L);
+ },
+ Map.of(
+ "NopStmt", expected(1, 40L),
+ "f", expected(1, 60L))),
+ Arguments.of(
+ // Parent takes 100ns, with two sequential children of 30ns and 40ns.
+ // Parent's exclusive time should be 100 - 30 - 40 = 30ns.
+ "sibling children both subtract from parent",
+ (Consumer)
+ p -> {
+ NopStmt parent = new NopStmt(0, 1, 5);
+ CallStmt a = new CallStmt("a", List.of(), 0);
+ CallStmt b = new CallStmt("b", List.of(), 0);
+ p.startStatement(parent);
+ p.startStatement(a);
+ p.stopStatement(a, 30L);
+ p.startStatement(b);
+ p.stopStatement(b, 40L);
+ p.stopStatement(parent, 100L);
+ },
+ Map.of(
+ "NopStmt", expected(1, 30L),
+ "a", expected(1, 30L),
+ "b", expected(1, 40L))),
+ Arguments.of(
+ // grandparent (Nop) -> parent (Call p) -> child (Call c).
+ // Child of 20ns subtracts from parent only; grandparent unchanged by child.
+ "deeply nested only immediate parent is adjusted",
+ (Consumer)
+ p -> {
+ NopStmt grandparent = new NopStmt(0, 1, 5);
+ CallStmt parent = new CallStmt("p", List.of(), 0);
+ CallStmt child = new CallStmt("c", List.of(), 0);
+ p.startStatement(grandparent);
+ p.startStatement(parent);
+ p.startStatement(child);
+ p.stopStatement(child, 20L);
+ p.stopStatement(parent, 80L);
+ p.stopStatement(grandparent, 200L);
+ },
+ Map.of(
+ "NopStmt", expected(1, 120L), // 200 - 80
+ "p", expected(1, 60L), // 80 - 20
+ "c", expected(1, 20L))));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("recordingScenarios")
+ void recordingScenarios_produceExpectedSummaries(
+ String name, Consumer recording, Map expected) {
+ recording.accept(profiler);
+
+ Map summaries = profiler.getStatementSummaries();
+ assertEquals(expected.keySet(), summaries.keySet(), "summary keys");
+ expected.forEach(
+ (key, exp) -> {
+ StatementSummary actual = summaries.get(key);
+ assertEquals(exp[0], actual.getCount(), "count for " + key);
+ assertEquals(Duration.ofNanos(exp[1]), actual.getDuration(), "duration for " + key);
+ });
+ }
+
+ static Stream mismatchedStopCases() {
+ return Stream.of(
+ Arguments.of(
+ "different statement types",
+ (Stmt) new NopStmt(0, 1, 5),
+ (Stmt) new CallStmt("f", List.of(), 0)),
+ Arguments.of(
+ "different function names",
+ (Stmt) new CallStmt("foo", List.of(), 0),
+ (Stmt) new CallStmt("bar", List.of(), 0)));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("mismatchedStopCases")
+ void stopStatement_withMismatchedStmt_throws(String name, Stmt started, Stmt stopped) {
+ profiler.startStatement(started);
+
+ assertThrows(IllegalStateException.class, () -> profiler.stopStatement(stopped, 10L));
+ }
+
+ @Test
+ void getStatementSummaries_isUnmodifiable() {
+ NopStmt stmt = new NopStmt(0, 1, 5);
+ profiler.startStatement(stmt);
+ profiler.stopStatement(stmt, 1L);
+
+ Map summaries = profiler.getStatementSummaries();
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> summaries.put("x", new StatementSummary("x")));
+ }
+
+ @Test
+ void getStatementSummaries_reflectsLiveProfilerState() {
+ // The unmodifiable view is a *view*, not a snapshot — later starts show up.
+ Map view = profiler.getStatementSummaries();
+ assertTrue(view.isEmpty());
+
+ NopStmt stmt = new NopStmt(0, 1, 5);
+ profiler.startStatement(stmt);
+ profiler.stopStatement(stmt, 1L);
+
+ assertEquals(1, view.size());
+ }
+
+ @Test
+ void repeatedStarts_reuseSameSummaryInstance() {
+ NopStmt stmt = new NopStmt(0, 1, 5);
+ profiler.startStatement(stmt);
+ profiler.stopStatement(stmt, 1L);
+ StatementSummary first = profiler.getStatementSummaries().get("NopStmt");
+
+ profiler.startStatement(stmt);
+ profiler.stopStatement(stmt, 1L);
+ StatementSummary second = profiler.getStatementSummaries().get("NopStmt");
+
+ assertSame(first, second);
+ }
+
+ private static long[] expected(long count, long nanos) {
+ return new long[] {count, nanos};
+ }
+}
\ No newline at end of file
diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/tracing/DurationProfilerIntegrationTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/tracing/DurationProfilerIntegrationTest.java
new file mode 100644
index 00000000..7e217f93
--- /dev/null
+++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/tracing/DurationProfilerIntegrationTest.java
@@ -0,0 +1,140 @@
+package io.github.open_policy_agent.opa.tracing;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.ServiceLoader;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import io.github.open_policy_agent.opa.ast.types.RegoObject;
+import io.github.open_policy_agent.opa.bundle.Bundle;
+import io.github.open_policy_agent.opa.ir.PolicyReader;
+import io.github.open_policy_agent.opa.ir.policy.Policy;
+import io.github.open_policy_agent.opa.rego.Engine;
+import io.github.open_policy_agent.opa.storage.InMem;
+import io.github.open_policy_agent.opa.storage.Store;
+import io.github.open_policy_agent.opa.tracing.DurationProfiler.EvalTotal;
+import io.github.open_policy_agent.opa.tracing.DurationProfiler.Loc;
+
+/**
+ * End-to-end integration test for {@link DurationProfiler}, exercising the full evaluator via
+ * {@link Engine} and {@link Engine.PreparedQuery} so the {@code addStart} / {@code addEntry} hooks
+ * fired from {@code EvaluationContext.traceEnterEvent}/{@code traceExitEvent} are covered.
+ */
+class DurationProfilerIntegrationTest {
+
+ private static final ObjectMapper MAPPER =
+ new ObjectMapper().registerModule(new io.github.open_policy_agent.opa.jackson.RegoValueModule());
+ private static final PolicyReader POLICY_READER =
+ ServiceLoader.load(PolicyReader.class).findFirst().orElseThrow();
+ private static final String ENTRYPOINT = "authz/allow";
+
+ private static Policy policy;
+
+ @BeforeAll
+ static void loadPolicy() throws IOException {
+ File policyFile =
+ new File(
+ Objects.requireNonNull(
+ DurationProfilerIntegrationTest.class
+ .getClassLoader()
+ .getResource("engine/testdata/authz-policy.json"))
+ .getFile());
+ policy = POLICY_READER.read(Files.newInputStream(policyFile.toPath()));
+ }
+
+ private static Engine buildEngine(String dataJson) throws IOException {
+ Store store = new InMem();
+ RegoObject data = MAPPER.readValue(dataJson, RegoObject.class);
+ Bundle bundle = new Bundle.Builder().withIrPolicy(policy).build();
+ store.write(ENTRYPOINT, bundle, data);
+
+ return new Engine.Builder().withStore(store).withEntrypoint(ENTRYPOINT).build();
+ }
+
+ private static Map input(String id, String... groups) {
+ return Map.of("user", Map.of("id", id, "groups", List.of(groups)));
+ }
+
+ @Test
+ void preparedQuery_recordsDurationsFromRealEvaluation() throws IOException {
+ Engine engine = buildEngine("{\"groups\":{\"admin\":{\"privileged\":true}}}");
+ DurationProfiler profiler = new DurationProfiler();
+ Engine.PreparedQuery pq =
+ engine.prepareForEvaluation().withProfiler(profiler).build();
+
+ List results = pq.eval(input("bob", "admin"), Boolean.class);
+
+ assertTrue(results.get(0), "bob/admin should be allowed via privileged group");
+
+ Map durations = profiler.getDurations();
+ assertFalse(durations.isEmpty(), "expected at least one location summary");
+
+ durations.forEach(
+ (loc, total) -> {
+ assertTrue(total.getCount() > 0, "count not > 0 for " + loc);
+ // Real wall-clock evaluations are expected to take a non-negative amount of time per
+ // location. Negative durations would indicate that exclusive-time backoff over-subtracted.
+ assertFalse(
+ total.getTotalDuration().isNegative(), "duration negative for " + loc);
+ });
+ }
+
+ @Test
+ void preparedQuery_repeatedEvaluations_accumulateCounts() throws IOException {
+ Engine engine = buildEngine("{}");
+ DurationProfiler profiler = new DurationProfiler();
+ Engine.PreparedQuery pq =
+ engine.prepareForEvaluation().withProfiler(profiler).build();
+
+ pq.eval(input("alice"), Boolean.class);
+ Map firstCounts = countsByLoc(profiler.getDurations());
+
+ pq.eval(input("alice"), Boolean.class);
+ pq.eval(input("alice"), Boolean.class);
+ Map finalCounts = countsByLoc(profiler.getDurations());
+
+ assertNotNull(firstCounts);
+ assertFalse(firstCounts.isEmpty());
+ // After three identical evaluations every recorded location should have run 3x its first-eval
+ // count — the profiler is shared across all prepared-query calls and must not reset.
+ firstCounts.forEach(
+ (loc, count) ->
+ assertEquals(count * 3, finalCounts.get(loc), "count for " + loc + " did not triple"));
+ }
+
+ @Test
+ void preparedQuery_durationsAreNonZeroAfterEvaluation() throws IOException {
+ // Sanity check that real evaluations actually accrue measurable time somewhere — guards against
+ // the profiler silently zeroing every location via overzealous backoff.
+ Engine engine = buildEngine("{\"groups\":{\"admin\":{\"privileged\":true}}}");
+ DurationProfiler profiler = new DurationProfiler();
+ Engine.PreparedQuery pq =
+ engine.prepareForEvaluation().withProfiler(profiler).build();
+
+ pq.eval(input("bob", "admin"), Boolean.class);
+
+ Duration totalRecorded =
+ profiler.getDurations().values().stream()
+ .map(EvalTotal::getTotalDuration)
+ .reduce(Duration.ZERO, Duration::plus);
+ assertFalse(totalRecorded.isZero(), "expected some non-zero recorded duration");
+ }
+
+ private static Map countsByLoc(Map durations) {
+ return durations.entrySet().stream()
+ .collect(
+ java.util.stream.Collectors.toMap(
+ Map.Entry::getKey, e -> e.getValue().getCount()));
+ }
+}
diff --git a/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/tracing/DurationProfilerTest.java b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/tracing/DurationProfilerTest.java
new file mode 100644
index 00000000..72f56008
--- /dev/null
+++ b/opa-evaluator/src/test/java/io/github/open_policy_agent/opa/tracing/DurationProfilerTest.java
@@ -0,0 +1,177 @@
+package io.github.open_policy_agent.opa.tracing;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import io.github.open_policy_agent.opa.ir.Location;
+import io.github.open_policy_agent.opa.tracing.DurationProfiler.EvalTotal;
+import io.github.open_policy_agent.opa.tracing.DurationProfiler.Loc;
+
+class DurationProfilerTest {
+
+ private DurationProfiler profiler;
+
+ @BeforeEach
+ void setUp() {
+ profiler = new DurationProfiler();
+ }
+
+ static Stream recordingScenarios() {
+ return Stream.of(
+ Arguments.of(
+ "single statement",
+ (Consumer)
+ p -> {
+ p.addStart();
+ p.addEntry(new Location(0, 1, 5), 100L);
+ },
+ Map.of(new Loc(0, 5), expected(1, 100L))),
+ Arguments.of(
+ "repeated same location accumulates count and duration",
+ (Consumer)
+ p -> {
+ p.addStart();
+ p.addEntry(new Location(0, 1, 5), 30L);
+ p.addStart();
+ p.addEntry(new Location(0, 1, 5), 70L);
+ },
+ Map.of(new Loc(0, 5), expected(2, 100L))),
+ Arguments.of(
+ "discards column — same row+file collapses",
+ (Consumer)
+ p -> {
+ p.addStart();
+ p.addEntry(new Location(0, 3, 5), 10L);
+ p.addStart();
+ p.addEntry(new Location(0, 99, 5), 20L);
+ },
+ Map.of(new Loc(0, 5), expected(2, 30L))),
+ Arguments.of(
+ "different rows in same file are tracked separately",
+ (Consumer)
+ p -> {
+ p.addStart();
+ p.addEntry(new Location(0, 1, 5), 10L);
+ p.addStart();
+ p.addEntry(new Location(0, 1, 8), 20L);
+ },
+ Map.of(
+ new Loc(0, 5), expected(1, 10L),
+ new Loc(0, 8), expected(1, 20L))),
+ Arguments.of(
+ "different files are tracked separately",
+ (Consumer)
+ p -> {
+ p.addStart();
+ p.addEntry(new Location(0, 1, 5), 10L);
+ p.addStart();
+ p.addEntry(new Location(1, 1, 5), 20L);
+ },
+ Map.of(
+ new Loc(0, 5), expected(1, 10L),
+ new Loc(1, 5), expected(1, 20L))),
+ Arguments.of(
+ // Parent (row 5) takes 100ns total; child (row 8) takes 60ns.
+ // Parent's exclusive time should be 40ns.
+ "nested child time subtracts from parent",
+ (Consumer)
+ p -> {
+ p.addStart(); // parent
+ p.addStart(); // child
+ p.addEntry(new Location(0, 1, 8), 60L);
+ p.addEntry(new Location(0, 1, 5), 100L);
+ },
+ Map.of(
+ new Loc(0, 5), expected(1, 40L),
+ new Loc(0, 8), expected(1, 60L))),
+ Arguments.of(
+ // Parent (row 5) takes 100ns, two sequential children (rows 8, 9) of 30 + 40.
+ // Parent's exclusive time should be 100 - 30 - 40 = 30ns.
+ "sibling children both subtract from parent",
+ (Consumer)
+ p -> {
+ p.addStart(); // parent
+ p.addStart(); // child A
+ p.addEntry(new Location(0, 1, 8), 30L);
+ p.addStart(); // child B
+ p.addEntry(new Location(0, 1, 9), 40L);
+ p.addEntry(new Location(0, 1, 5), 100L);
+ },
+ Map.of(
+ new Loc(0, 5), expected(1, 30L),
+ new Loc(0, 8), expected(1, 30L),
+ new Loc(0, 9), expected(1, 40L))),
+ Arguments.of(
+ // grandparent (row 5) -> parent (row 8) -> child (row 9).
+ // Child of 20ns subtracts from parent only; grandparent unchanged by child.
+ "deeply nested only immediate parent is adjusted",
+ (Consumer)
+ p -> {
+ p.addStart(); // grandparent
+ p.addStart(); // parent
+ p.addStart(); // child
+ p.addEntry(new Location(0, 1, 9), 20L);
+ p.addEntry(new Location(0, 1, 8), 80L); // 80 - 20 = 60
+ p.addEntry(new Location(0, 1, 5), 200L); // 200 - 80 = 120
+ },
+ Map.of(
+ new Loc(0, 5), expected(1, 120L),
+ new Loc(0, 8), expected(1, 60L),
+ new Loc(0, 9), expected(1, 20L))));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("recordingScenarios")
+ void recordingScenarios_produceExpectedDurations(
+ String name, Consumer recording, Map expected) {
+ recording.accept(profiler);
+
+ Map durations = profiler.getDurations();
+ assertEquals(expected.keySet(), durations.keySet(), "location keys");
+ expected.forEach(
+ (loc, exp) -> {
+ EvalTotal actual = durations.get(loc);
+ assertEquals(exp[0], actual.getCount(), "count for " + loc);
+ assertEquals(
+ Duration.ofNanos(exp[1]), actual.getTotalDuration(), "duration for " + loc);
+ });
+ }
+
+ static Stream locEqualityCases() {
+ return Stream.of(
+ Arguments.of("equal", new Loc(0, 5), new Loc(0, 5), true),
+ Arguments.of("different row", new Loc(0, 5), new Loc(0, 6), false),
+ Arguments.of("different file", new Loc(0, 5), new Loc(1, 5), false),
+ Arguments.of("different file and row", new Loc(0, 5), new Loc(1, 6), false));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("locEqualityCases")
+ void loc_equalityFollowsFileAndRow(String name, Loc a, Loc b, boolean equal) {
+ if (equal) {
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ } else {
+ assertNotEquals(a, b);
+ }
+ }
+
+ @Test
+ void loc_isNotEqualToOtherTypes() {
+ assertNotEquals(new Loc(0, 5), "0:5");
+ assertNotEquals(new Loc(0, 5), null);
+ }
+
+ private static long[] expected(long count, long nanos) {
+ return new long[] {count, nanos};
+ }
+}
\ No newline at end of file
diff --git a/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/MetricsModule.java b/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/MetricsModule.java
new file mode 100644
index 00000000..2404f8a7
--- /dev/null
+++ b/opa-jackson/src/main/java/io/github/open_policy_agent/opa/jackson/MetricsModule.java
@@ -0,0 +1,36 @@
+package io.github.open_policy_agent.opa.jackson;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import io.github.open_policy_agent.opa.metrics.Metrics;
+import io.github.open_policy_agent.opa.metrics.SimpleMetrics;
+import java.time.Duration;
+
+/**
+ * Jackson {@link SimpleModule} that adds {@code @JsonValue} behavior to {@link SimpleMetrics}'s inner {@code Timer}.
+ * Register this module to have {@link Metrics.Timer} serialize as the underlying
+ * {@link Duration} value rather than a default bean.
+ *
+ * Applied at the {@link Metrics.Timer} interface level via a mixin, so it covers any Timer
+ * implementation, not just the one returned by {@link SimpleMetrics}.
+ *
+ *
Usage:
+ *
+ *
{@code
+ * ObjectMapper mapper = new ObjectMapper().registerModule(new MetricsModule());
+ * String json = mapper.writeValueAsString(simpleMetrics.timer("foo"));
+ * }
+ */
+public class MetricsModule extends SimpleModule {
+
+ public MetricsModule() {
+ super("opa-metrics");
+ setMixInAnnotation(Metrics.Timer.class, TimerMixin.class);
+ }
+
+ abstract static class TimerMixin {
+ @JsonValue
+ abstract Duration value();
+ }
+}
diff --git a/opa-jackson/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module b/opa-jackson/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module
index b6b9774b..7f40ddc2 100644
--- a/opa-jackson/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module
+++ b/opa-jackson/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module
@@ -1 +1,2 @@
io.github.open_policy_agent.opa.jackson.RegoValueModule
+io.github.open_policy_agent.opa.jackson.MetricsModule
diff --git a/opa-jackson/src/test/java/io/github/open_policy_agent/opa/jackson/MetricsModuleTest.java b/opa-jackson/src/test/java/io/github/open_policy_agent/opa/jackson/MetricsModuleTest.java
new file mode 100644
index 00000000..0ba9f42c
--- /dev/null
+++ b/opa-jackson/src/test/java/io/github/open_policy_agent/opa/jackson/MetricsModuleTest.java
@@ -0,0 +1,34 @@
+package io.github.open_policy_agent.opa.jackson;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import io.github.open_policy_agent.opa.metrics.Metrics;
+import io.github.open_policy_agent.opa.metrics.SimpleMetrics;
+import java.io.IOException;
+import org.junit.jupiter.api.Test;
+
+class MetricsModuleTest {
+
+ // Mirrors a real consumer: JavaTimeModule is required for Jackson to handle Duration; the
+ // MetricsModule shim restores @JsonValue on Metrics.Timer that was lost when SimpleMetrics moved
+ // into the JSON-free opa-evaluator module.
+ private final ObjectMapper mapper =
+ new ObjectMapper().registerModule(new JavaTimeModule()).registerModule(new MetricsModule());
+
+ @Test
+ void timer_jsonShapeMatchesDirectDurationSerialization() throws IOException {
+ SimpleMetrics metrics = new SimpleMetrics();
+ Metrics.Timer timer = metrics.timer("rego_query_eval");
+ timer.start();
+ timer.stop();
+
+ String timerJson = mapper.writeValueAsString(timer);
+ String durationJson = mapper.writeValueAsString(timer.value());
+
+ // The whole point of @JsonValue here: serializing the Timer must produce exactly what
+ // serializing its underlying Duration produces.
+ assertThat(timerJson).isEqualTo(durationJson);
+ }
+}