+ * The test application runs with a short Prometheus step ({@code quarkus.micrometer.export.prometheus.step=PT1S}),
+ * because Prometheus-backed counters and timers only report the value of the latest settled step. The assertions poll
+ * the registry briefly until the increment is visible; precise accumulation behavior is covered on a plain registry in
+ * {@code GoblinMetricsObserverTest}.
+ */
+@QuarkusTest
+public class GoblinMetricsIntegrationTest {
+
+ @Inject
+ AssaultEngine engine;
+
+ @Inject
+ MeterRegistry registry;
+
+ @Inject
+ PrometheusMeterRegistry prometheusRegistry;
+
+ @BeforeEach
+ void start() {
+ engine.setActive(true);
+ MutableAssaultConfig cfg = engine.getMutableConfig();
+ cfg.setLatencyEnabled(false);
+ cfg.setExceptionEnabled(false);
+ cfg.setHttpStatusEnabled(false);
+ cfg.setDependencyDegradationEnabled(false);
+ cfg.setClientLatencyEnabled(false);
+ cfg.setClientExceptionEnabled(false);
+ cfg.setResponseBodyEnabled(false);
+ cfg.setResponseHeaderEnabled(false);
+ cfg.setLatencyMinMs(100);
+ cfg.setLatencyMaxMs(100);
+ cfg.setTargetLevel(100);
+ engine.clearHistory();
+ }
+
+ @Test
+ public void serverLatencyAssaultExposesMetrics() {
+ MutableAssaultConfig cfg = engine.getMutableConfig();
+ cfg.setLatencyEnabled(true);
+
+ RestAssured.given()
+ .get("/api/hello")
+ .then()
+ .statusCode(200);
+
+ assertHistory("latency", p -> p.equals("SampleResource.hello"));
+ awaitCounter("latency", "server");
+ awaitTimerCount("server");
+ assertEquals(1, activeGauge(), "engine must be active during the assault");
+ }
+
+ @Test
+ public void webClientAssaultExposesMetrics() {
+ MutableAssaultConfig cfg = engine.getMutableConfig();
+ cfg.setClientLatencyEnabled(true);
+
+ RestAssured.given()
+ .get("/api/web-proxy")
+ .then()
+ .statusCode(200);
+
+ assertHistory("latency", p -> p.startsWith("WebClient"));
+ awaitCounter("latency", "webclient");
+ awaitTimerCount("webclient");
+ }
+
+ @Test
+ public void restClientAssaultExposesMetrics() {
+ MutableAssaultConfig cfg = engine.getMutableConfig();
+ cfg.setClientLatencyEnabled(true);
+
+ RestAssured.given()
+ .get("/api/proxy")
+ .then()
+ .statusCode(200);
+
+ assertHistory("latency", p -> p.startsWith("REST-Client"));
+ awaitCounter("latency", "rest-client");
+ awaitTimerCount("rest-client");
+ }
+
+ @Test
+ public void exceptionAssaultIsCounted() {
+ MutableAssaultConfig cfg = engine.getMutableConfig();
+ cfg.setClientExceptionEnabled(true);
+ cfg.setExceptionType("java.lang.IllegalStateException");
+ cfg.setExceptionMessage("downstream is in trouble");
+
+ RestAssured.given()
+ .get("/api/web-proxy")
+ .then()
+ .statusCode(500);
+
+ assertHistory("exception", p -> p.startsWith("WebClient"));
+ awaitCounter("exception", "webclient");
+ }
+
+ @Test
+ public void latencyTimerIsExportedAsPrometheusHistogram() {
+ MutableAssaultConfig cfg = engine.getMutableConfig();
+ cfg.setLatencyEnabled(true);
+
+ RestAssured.given()
+ .get("/api/hello")
+ .then()
+ .statusCode(200);
+
+ assertHistory("latency", p -> p.equals("SampleResource.hello"));
+ awaitTimerCount("server");
+
+ String scrape = awaitScrapeContaining("goblin_latency_injected_seconds_bucket");
+ assertTrue(scrape.contains("goblin_latency_injected_seconds_count{source=\"server\""),
+ "expected a cumulative count line in the scrape: " + scrape);
+ var infBucket = java.util.regex.Pattern
+ .compile("goblin_latency_injected_seconds_bucket\\{source=\"server\",le=\"\\+Inf\",} ([0-9.]+)")
+ .matcher(scrape);
+ assertTrue(infBucket.find() && Double.parseDouble(infBucket.group(1)) >= 1,
+ "expected at least one record in the +Inf bucket of the server latency histogram: " + scrape);
+ }
+
+ @Test
+ public void engineDeactivationIsReflectedInGauge() {
+ engine.setActive(false);
+ assertEquals(0, activeGauge(), "deactivating the engine must set goblin.active to 0");
+
+ engine.setActive(true);
+ assertEquals(1, activeGauge(), "reactivating the engine must set goblin.active to 1");
+ }
+
+ private void assertHistory(String type, java.util.function.Predicate
+ * The following metrics are registered against the application's {@link MeterRegistry} when the
+ * {@code quarkus-goblin-metrics} dependency is present:
+ *
+ * This SPI is the integration point for optional modules such as Micrometer metrics, OpenTelemetry tracing or
+ * post-assault assertions. Every method has a default no-op implementation so a partial implementation is trivially
+ * safe to register.
+ */
+public interface AssaultObserver {
+
+ /**
+ * Called after an assault has been recorded in the engine history.
+ *
+ * @param record the assault record that was just added
+ */
+ default void onAssault(AssaultEngine.AssaultRecord record) {
+ }
+
+ /**
+ * Called whenever the engine is activated or deactivated.
+ *
+ * @param active the new engine state
+ */
+ default void onActiveChange(boolean active) {
+ }
+}
\ No newline at end of file
diff --git a/runtime/src/test/java/io/quarkiverse/goblin/AssaultEngineTest.java b/runtime/src/test/java/io/quarkiverse/goblin/AssaultEngineTest.java
index c3add85..5865808 100644
--- a/runtime/src/test/java/io/quarkiverse/goblin/AssaultEngineTest.java
+++ b/runtime/src/test/java/io/quarkiverse/goblin/AssaultEngineTest.java
@@ -2,11 +2,16 @@
import static org.junit.jupiter.api.Assertions.*;
+import java.lang.annotation.Annotation;
import java.util.ArrayList;
+import java.util.Iterator;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
+import jakarta.enterprise.inject.Instance;
+import jakarta.enterprise.util.TypeLiteral;
+
import org.junit.jupiter.api.Test;
class AssaultEngineTest {
@@ -199,4 +204,142 @@ void shouldAssaultClientFalseWhenServerToggleOnlyAndLevel100() throws Exception
assertFalse(engine.shouldAssaultClient());
}
+
+ @Test
+ void failingObserverDoesNotBreakTheAssaultFlow() {
+ AssaultEngine engine = new AssaultEngine();
+ List
+ *
+ * All metrics are derived from the {@link AssaultObserver} notifications fired by the engine, so this module only needs
+ * to be on the classpath -- it never alters the assault behavior.
+ */
+@ApplicationScoped
+public class GoblinMetricsObserver implements AssaultObserver {
+
+ public static final String TOTAL_METRIC = "goblin.assaults.total";
+ public static final String LATENCY_METRIC = "goblin.latency.injected.seconds";
+ public static final String ACTIVE_METRIC = "goblin.active";
+ public static final String TAG_TYPE = "type";
+ public static final String TAG_SOURCE = "source";
+ static final String SOURCE_SERVER = "server";
+ static final String SOURCE_REST_CLIENT = "rest-client";
+ static final String SOURCE_WEB_CLIENT = "webclient";
+
+ private final MeterRegistry registry;
+ private final AssaultEngine engine;
+
+ /**
+ * Creates the observer and registers the active-state gauge as a functional gauge over the shared
+ * {@link AssaultEngine}, so its value always reflects the current engine state without any bookkeeping.
+ *
+ * @param registry the application's {@link MeterRegistry}
+ * @param engine the shared {@link AssaultEngine}
+ */
+ public GoblinMetricsObserver(MeterRegistry registry, AssaultEngine engine) {
+ this.registry = registry;
+ this.engine = engine;
+ Gauge.builder(ACTIVE_METRIC, engine, e -> e.isActive() ? 1.0 : 0.0).register(registry);
+ }
+
+ void init(@Observes StartupEvent event) {
+ // The gauge is functional, so there is nothing to initialise; this observer method merely anchors the bean in
+ // the application context (a bean declaring observer methods is never pruned as unused).
+ }
+
+ @Override
+ public void onActiveChange(boolean active) {
+ // The gauge reads the engine state lazily.
+ }
+
+ @Override
+ public void onAssault(AssaultEngine.AssaultRecord record) {
+ String source = sourceOf(record.method());
+ Counter.builder(TOTAL_METRIC)
+ .tags(TAG_TYPE, record.type(), TAG_SOURCE, source)
+ .register(registry)
+ .increment();
+ if (record.latencyMs() > 0) {
+ Timer.builder(LATENCY_METRIC)
+ .tags(TAG_SOURCE, source)
+ .publishPercentileHistogram()
+ .register(registry)
+ .record(record.latencyMs(), TimeUnit.MILLISECONDS);
+ }
+ }
+
+ /**
+ * Derives the assault source tag from the history identifier produced by the engine.
+ *
+ * @param method the history identifier (e.g. {@code "SampleResource.hello"}, {@code "REST-Client GET ..."},
+ * {@code "WebClient GET ..."})
+ * @return the source tag value
+ */
+ static String sourceOf(String method) {
+ if (method != null) {
+ if (method.startsWith("REST-Client ")) {
+ return SOURCE_REST_CLIENT;
+ }
+ if (method.startsWith("WebClient ")) {
+ return SOURCE_WEB_CLIENT;
+ }
+ }
+ return SOURCE_SERVER;
+ }
+}
\ No newline at end of file
diff --git a/metrics/src/test/java/io/quarkiverse/goblin/metrics/GoblinMetricsObserverTest.java b/metrics/src/test/java/io/quarkiverse/goblin/metrics/GoblinMetricsObserverTest.java
new file mode 100644
index 0000000..ae4b23d
--- /dev/null
+++ b/metrics/src/test/java/io/quarkiverse/goblin/metrics/GoblinMetricsObserverTest.java
@@ -0,0 +1,104 @@
+package io.quarkiverse.goblin.metrics;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.Test;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import io.quarkiverse.goblin.AssaultEngine;
+
+class GoblinMetricsObserverTest {
+
+ private final MeterRegistry registry = new SimpleMeterRegistry();
+ private final AssaultEngine engine = new AssaultEngine();
+ private final GoblinMetricsObserver observer = new GoblinMetricsObserver(registry, engine);
+
+ @Test
+ void activeGaugeFollowsEngineState() {
+ engine.setActive(true);
+ assertEquals(1, registry.get(GoblinMetricsObserver.ACTIVE_METRIC).gauge().value());
+
+ engine.setActive(false);
+ assertEquals(0, registry.get(GoblinMetricsObserver.ACTIVE_METRIC).gauge().value());
+ }
+
+ @Test
+ void serverLatencyAssaultIsCountedAndTimed() {
+ observer.onAssault(record("SampleResource.hello", "latency", 250));
+
+ assertEquals(1, totalCount("latency", "server"));
+ assertNull(totalCounter("latency", "rest-client"));
+ assertNull(totalCounter("latency", "webclient"));
+
+ Timer timer = registry.find(GoblinMetricsObserver.LATENCY_METRIC)
+ .tag("source", "server").timer();
+ assertNotNull(timer);
+ assertEquals(1, timer.count());
+ assertEquals(0.25, timer.totalTime(TimeUnit.SECONDS), 0.001);
+ }
+
+ @Test
+ void nonLatencyAssaultIsCountedButNotTimed() {
+ observer.onAssault(record("SampleResource.hello", "http-status", 0));
+
+ assertEquals(1, totalCount("http-status", "server"));
+ assertNull(registry.find(GoblinMetricsObserver.LATENCY_METRIC).tag("source", "server").timer());
+ }
+
+ @Test
+ void restClientSourceIsDetected() {
+ observer.onAssault(record("REST-Client GET http://localhost:8081/api/hello", "latency", 100));
+
+ assertEquals(1, totalCount("latency", "rest-client"));
+ Timer timer = registry.find(GoblinMetricsObserver.LATENCY_METRIC)
+ .tag("source", "rest-client").timer();
+ assertNotNull(timer);
+ assertEquals(1, timer.count());
+ }
+
+ @Test
+ void webClientSourceIsDetected() {
+ observer.onAssault(record("WebClient GET http://localhost:8081/api/hello", "latency", 100));
+
+ assertEquals(1, totalCount("latency", "webclient"));
+ }
+
+ @Test
+ void unknownMethodFallsBackToServer() {
+ observer.onAssault(record(null, "exception", 0));
+ observer.onAssault(record("", "exception", 0));
+
+ assertEquals(2, totalCount("exception", "server"));
+ }
+
+ @Test
+ void sourceDetectionIsUnitTestable() {
+ assertEquals("server", GoblinMetricsObserver.sourceOf("SampleResource.hello"));
+ assertEquals("server", GoblinMetricsObserver.sourceOf(""));
+ assertEquals("rest-client", GoblinMetricsObserver.sourceOf("REST-Client GET http://x"));
+ assertEquals("webclient", GoblinMetricsObserver.sourceOf("WebClient GET http://x"));
+ }
+
+ private AssaultEngine.AssaultRecord record(String method, String type, long latencyMs) {
+ return new AssaultEngine.AssaultRecord(method, type, System.currentTimeMillis(), latencyMs, "snapshot");
+ }
+
+ private double totalCount(String type, String source) {
+ var counter = totalCounter(type, source);
+ assertNotNull(counter, "expected a goblin.assaults.total counter for " + source + "/" + type);
+ return counter.count();
+ }
+
+ private io.micrometer.core.instrument.Counter totalCounter(String type, String source) {
+ return registry.find(GoblinMetricsObserver.TOTAL_METRIC)
+ .tag("type", type)
+ .tag("source", source)
+ .counter();
+ }
+}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 6757e20..0f8261a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -40,6 +40,7 @@