Skip to content
Open
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,102 @@
package io.github.open_policy_agent.opa.gson;

import com.google.gson.Gson;
import com.google.gson.TypeAdapter;
import com.google.gson.TypeAdapterFactory;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import io.github.open_policy_agent.opa.metrics.Metrics;
import java.io.IOException;

/**
* Gson {@link TypeAdapterFactory} that serializes {@link Metrics} types to match the JSON shape
* produced by OPA's decision logs ({@code timer_<name>_ns} convention).
*
* <ul>
* <li>{@link Metrics.Timer} &rarr; nanoseconds (long)
* <li>{@link Metrics.Counter} &rarr; integer value
* <li>{@link Metrics.Histogram} &rarr; its {@link Metrics.Histogram.Values} object
* </ul>
*
* <p>Usage:
* <pre>{@code
* Gson gson = new GsonBuilder()
* .registerTypeAdapterFactory(new MetricsTypeAdapterFactory())
* .create();
* String json = gson.toJson(timer);
* }</pre>
*/
public class MetricsTypeAdapterFactory implements TypeAdapterFactory {

@SuppressWarnings("unchecked")
@Override
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
if (Metrics.Timer.class.isAssignableFrom(type.getRawType())) {
return (TypeAdapter<T>) new TimerAdapter();
}
if (Metrics.Counter.class.isAssignableFrom(type.getRawType())) {
return (TypeAdapter<T>) new CounterAdapter();
}
if (Metrics.Histogram.class.isAssignableFrom(type.getRawType())) {
return (TypeAdapter<T>) new HistogramAdapter(gson);
}
return null;
}

private static final class TimerAdapter extends TypeAdapter<Metrics.Timer> {
@Override
public void write(JsonWriter out, Metrics.Timer timer) throws IOException {
if (timer == null) {
out.nullValue();
} else {
out.value(timer.value().toNanos());

@sspaink sspaink Jul 6, 2026

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.

Timer JSON shape diverges from the opa-jackson MetricsModule this mirrors.

This emits nanoseconds as an integer (12345), but opa-jackson serializes Metrics.Timer as its Duration via @JsonValue, which with JavaTimeModule produces fractional seconds — a 12345 ns timer yields 0.000012345. So a consumer who swaps mappers gets a structurally different value for the same timer, which cuts against the "close the parity gap" goal of the PR.

The nanosecond shape here is the correct one: it matches DecisionLogPlugin (timer.value().toNanos()) and OPA's canonical decision-log format, where the timer_<name>_ns key declares the unit as nanoseconds. jackson's fractional-seconds output is effectively a unit mismatch under that key, so opa-jackson is the module that should change — not this one.

So: keep this gson adapter as written, and align opa-jackson's MetricsModule to emit nanoseconds too (e.g. a JsonSerializer<Metrics.Timer> or a @JsonValue method returning value().toNanos(), plus updating MetricsModuleTest to assert against toNanos()). That also drops the JavaTimeModule requirement on that path.

@sanajitjana if you have the time, would you mind folding that jackson fix into this PR so parity is actually true when it lands? No pressure — if you'd rather keep this PR scoped to gson, I'll follow up with the jackson change separately.

}
}

@Override
public Metrics.Timer read(JsonReader in) {
throw new UnsupportedOperationException("Deserialization of Metrics.Timer is not supported");
}
}

private static final class CounterAdapter extends TypeAdapter<Metrics.Counter> {
@Override

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.

CounterAdapter and HistogramAdapter (below) ship with zero test coverage.

MetricsTypeAdapterFactoryTest only exercises the Timer path, and SimpleMetrics.counter(...)/histogram(...) both return null, so there is no in-tree implementation that produces a non-null Counter or Histogram. That leaves the int-counter path and the whole Histogram.Values field layout (via gson.getAdapter(Values.class)) unverified.

Consider adding one test per adapter using a small hand-built Metrics.Counter/Metrics.Histogram stub — these are the paths that go beyond what opa-jackson's MetricsModule covers, so they're the most valuable to pin down.

public void write(JsonWriter out, Metrics.Counter counter) throws IOException {
if (counter == null) {
out.nullValue();
} else {
out.value(counter.value());
}
}

@Override
public Metrics.Counter read(JsonReader in) {
throw new UnsupportedOperationException(
"Deserialization of Metrics.Counter is not supported");
}
}

private static final class HistogramAdapter extends TypeAdapter<Metrics.Histogram> {
private final TypeAdapter<Metrics.Histogram.Values> valuesAdapter;

HistogramAdapter(Gson gson) {
this.valuesAdapter = gson.getAdapter(Metrics.Histogram.Values.class);
}

@Override
public void write(JsonWriter out, Metrics.Histogram histogram) throws IOException {
if (histogram == null) {
out.nullValue();
} else {
valuesAdapter.write(out, histogram.value());
}
}

@Override
public Metrics.Histogram read(JsonReader in) {
throw new UnsupportedOperationException(
"Deserialization of Metrics.Histogram is not supported");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package io.github.open_policy_agent.opa.gson;

import static org.assertj.core.api.Assertions.assertThat;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.github.open_policy_agent.opa.metrics.Metrics;
import io.github.open_policy_agent.opa.metrics.SimpleMetrics;
import org.junit.jupiter.api.Test;

/**
* Parallel to opa-jackson's MetricsModuleTest. Verifies that MetricsTypeAdapterFactory produces
* JSON matching OPA's decision log shape ({@code timer_<name>_ns} = nanoseconds as a long).
*/
class MetricsTypeAdapterFactoryTest {

private final Gson gson =
new GsonBuilder().registerTypeAdapterFactory(new MetricsTypeAdapterFactory()).create();

@Test
void timer_serializesAsNanoseconds() {
SimpleMetrics metrics = new SimpleMetrics();
Metrics.Timer timer = metrics.timer("rego_query_eval");
timer.start();
timer.stop();

String json = gson.toJson(timer, Metrics.Timer.class);

// Must be a plain number (nanoseconds), matching DecisionLogPlugin's timer.value().toNanos()
long nanos = timer.value().toNanos();
assertThat(json).isEqualTo(String.valueOf(nanos));
}

@Test
void timer_jsonShapeMatchesDirectNanosSerialization() {
SimpleMetrics metrics = new SimpleMetrics();
Metrics.Timer timer = metrics.timer("rego_query_eval");
timer.start();
timer.stop();

String timerJson = gson.toJson(timer, Metrics.Timer.class);
String nanosJson = gson.toJson(timer.value().toNanos());

// Serializing the Timer must produce exactly what serializing its nanosecond value produces.
assertThat(timerJson).isEqualTo(nanosJson);
}

@Test
void timer_null_serializesAsNull() {
String json = gson.toJson(null, Metrics.Timer.class);
assertThat(json).isEqualTo("null");
}
}