Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>Row keys follow the OPA CLI naming convention:
*
* <ul>
* <li>Timers are emitted as {@code timer_<key>_ns} with the duration in nanoseconds.
* <li>Counters are emitted as {@code counter_<key>} with the integer value.
* <li>Histograms are exploded into one row per stat: {@code histogram_<key>_count},
* {@code _min}, {@code _max}, {@code _mean}, {@code _stddev}, {@code _median}, plus one row
* per percentile ({@code _75%}, {@code _99%}, …).
* </ul>
*
* <p>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<String, String> 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<String, String> 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<String, String> collectRows(Metrics metrics) {
SortedMap<String, String> rows = new TreeMap<>();
Map<String, Metric> all = metrics.all();
if (all == null) {
return rows;
}
for (Map.Entry<String, Metric> 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<String, Integer> p : v.percentiles.entrySet()) {
rows.put(prefix + p.getKey(), String.valueOf(p.getValue()));
}
}
}
}
return rows;
}

private static int maxLen(Iterable<String> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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.*;

Expand Down Expand Up @@ -31,7 +30,6 @@ public void stop() {
end = System.nanoTime();
}

@JsonValue
@Override
public Duration value() {
return Duration.ofNanos(end - start);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -66,7 +67,7 @@ public Map<String, StatementSummary> 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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String, Object> 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<Boolean> results = pq.eval(input("bob", "admin"), Boolean.class);

assertTrue(results.get(0), "bob/admin should be allowed via privileged group");

Map<String, Metric> 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_<key>_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<String> 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());
}
}
Loading
Loading