diff --git a/CHANGELOG.md b/CHANGELOG.md
index 803ef188e..4b0b9b7de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+
+#### CHITSPERC/CMISSPERC reported NaN instead of 0 before any cached read had happened
+`Statistics.java` computed `CHITS/(CHITS+CMISS)*100` unconditionally; before any cached read has
+happened both are 0, so the ratio was `0.0/0.0 = NaN`. Prometheus/OTel exporters silently drop NaN
+samples, so a fresh application's cache-hit-ratio metric appeared entirely missing instead of a
+real "no data yet" 0%. Found while verifying the quarkus-morphium observability module against a
+live otel-collector/Prometheus stack. Both percentages are now also computed by reading each
+`AtomicLong` once instead of three times, so they come from one consistent snapshot.
+
## [6.3.6] - 2026-08-21
diff --git a/morphium-core/src/main/java/de/caluga/morphium/Statistics.java b/morphium-core/src/main/java/de/caluga/morphium/Statistics.java
index 71fd508bd..51ad5c928 100644
--- a/morphium-core/src/main/java/de/caluga/morphium/Statistics.java
+++ b/morphium-core/src/main/java/de/caluga/morphium/Statistics.java
@@ -29,10 +29,16 @@ public Statistics(Morphium morphium) {
}
super.put(StatisticKeys.CACHE_ENTRIES.name(), entries);
- super.put(StatisticKeys.CHITSPERC.name(), ((double) morphium.getStats().get(StatisticKeys.CHITS).get()) / (morphium.getStats().get(StatisticKeys.CHITS).get() + morphium.getStats().get(
- StatisticKeys.CMISS).get()) * 100.0);
- super.put(StatisticKeys.CMISSPERC.name(), ((double) morphium.getStats().get(StatisticKeys.CMISS).get()) / (morphium.getStats().get(StatisticKeys.CHITS).get() + morphium.getStats().get(
- StatisticKeys.CMISS).get()) * 100.0);
+
+ long chits = morphium.getStats().get(StatisticKeys.CHITS).get();
+ long cmiss = morphium.getStats().get(StatisticKeys.CMISS).get();
+ long total = chits + cmiss;
+ // Before any cached read has happened, chits+cmiss is 0 -- report
+ // 0% rather than 0.0/0.0 = NaN (NaN samples are silently dropped
+ // by Prometheus/OTel exporters, so consumers saw the metric
+ // simply missing instead of a real "no data yet" zero).
+ super.put(StatisticKeys.CHITSPERC.name(), total == 0 ? 0.0 : ((double) chits) / total * 100.0);
+ super.put(StatisticKeys.CMISSPERC.name(), total == 0 ? 0.0 : ((double) cmiss) / total * 100.0);
}
super.put(StatisticKeys.WRITE_BUFFER_ENTRIES.name(), (double) morphium.getWriteBufferCount());
diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatisticsTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatisticsTest.java
index 54cb6aa36..60da29a48 100644
--- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatisticsTest.java
+++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatisticsTest.java
@@ -9,11 +9,33 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import de.caluga.morphium.Morphium;
+import de.caluga.morphium.StatisticKeys;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("core")
public class StatisticsTest extends MultiDriverTestBase {
+ /**
+ * Before any cached read has happened, CHITS and CMISS are both 0, so the
+ * naive ratio CHITS/(CHITS+CMISS) is 0.0/0.0 = NaN. Prometheus/OTel
+ * exporters silently drop NaN samples, so a fresh application shows the
+ * cache-hit-ratio metric as entirely missing instead of a real "no data
+ * yet" 0% -- found while wiring the quarkus-morphium observability
+ * module (this PR) against a live otel-collector/Prometheus stack.
+ */
+ @ParameterizedTest
+ @MethodSource("getMorphiumInstancesNoSingle")
+ public void cacheHitRatioIsZeroNotNaNBeforeAnyCachedRead(Morphium morphium) {
+ Double hitPerc = morphium.getStatistics().get(StatisticKeys.CHITSPERC.name());
+ Double missPerc = morphium.getStatistics().get(StatisticKeys.CMISSPERC.name());
+ assertFalse(hitPerc.isNaN(), "CHITSPERC must not be NaN before any cached read");
+ assertFalse(missPerc.isNaN(), "CMISSPERC must not be NaN before any cached read");
+ assertEquals(0.0, hitPerc);
+ assertEquals(0.0, missPerc);
+ }
+
@ParameterizedTest
@MethodSource("getMorphiumInstancesNoSingle")
public void statisticsTest(Morphium morphium) {
diff --git a/quarkus-morphium/deployment/pom.xml b/quarkus-morphium/deployment/pom.xml
index b7b60d747..e60313747 100644
--- a/quarkus-morphium/deployment/pom.xml
+++ b/quarkus-morphium/deployment/pom.xml
@@ -54,6 +54,18 @@
true
+
+
+ io.quarkus
+ quarkus-micrometer-deployment
+ true
+
+
@@ -98,6 +110,16 @@
assertj-coretest
+
+
+ io.quarkus
+ quarkus-junit-internal
+ test
+
@@ -126,6 +148,19 @@
org.apache.maven.pluginsmaven-javadoc-plugin
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ org.jboss.logmanager.LogManager
+
+
+
diff --git a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessor.java b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessor.java
index 842a749f8..1e66a32e7 100644
--- a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessor.java
+++ b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessor.java
@@ -163,6 +163,37 @@ void registerMorphiumIdJsonCustomizers(Capabilities capabilities,
}
}
+ // ------------------------------------------------------------------
+ // Micrometer metrics: connection-pool / driver-stats gauges (MVP)
+ // ------------------------------------------------------------------
+
+ /**
+ * Registers {@code MorphiumMetricsBinder} as a CDI bean, but only when the application
+ * has Micrometer on its classpath (Capability.METRICS present at build time).
+ *
+ *
Mirrors {@link #registerMorphiumIdJsonCustomizers}'s exact structure: gating on
+ * {@link Capabilities}, producing an {@link AdditionalBeanBuildItem} referenced by class
+ * name (so this processor class never needs a compile-time dependency on Micrometer types
+ * itself), and marking it unremovable. Apps without Micrometer never get this bean added,
+ * so {@code MorphiumMetricsBinder} — which does have a hard compile-time dependency on
+ * Micrometer's {@code MeterRegistry} — never loads on their classpath at all.
+ *
+ *
Scope note: this build step only registers the Phase 1 (MVP) gauge binder. The
+ * Counter/Timer sources from {@code MorphiumStorageListener}/{@code MorphiumTransactionEvent}
+ * (Section 5 catalog rows {@code morphium.operations.*}/{@code morphium.transactions.*}) are
+ * explicitly deferred to a later phase and are not registered here.
+ */
+ @BuildStep
+ void registerObservability(Capabilities capabilities,
+ BuildProducer additionalBeans) {
+ if (capabilities.isPresent(Capability.METRICS)) {
+ additionalBeans.produce(AdditionalBeanBuildItem.builder()
+ .addBeanClass("de.caluga.morphium.quarkus.observability.MorphiumMetricsBinder")
+ .setUnremovable()
+ .build());
+ }
+ }
+
// ------------------------------------------------------------------
// Health check registration
// ------------------------------------------------------------------
diff --git a/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumMetricsBinderLifecycleTest.java b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumMetricsBinderLifecycleTest.java
new file mode 100644
index 000000000..bab1267ba
--- /dev/null
+++ b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumMetricsBinderLifecycleTest.java
@@ -0,0 +1,223 @@
+/*
+ * Copyright 2025 The Quarkiverse Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.caluga.morphium.quarkus.deployment;
+
+import de.caluga.morphium.Morphium;
+import de.caluga.morphium.annotations.Entity;
+import de.caluga.morphium.annotations.Id;
+import de.caluga.morphium.driver.MorphiumId;
+import de.caluga.morphium.quarkus.MorphiumProducer;
+import de.caluga.morphium.quarkus.observability.MorphiumMetricsBinder;
+import io.micrometer.core.instrument.Meter;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.quarkus.arc.Arc;
+import io.quarkus.arc.ClientProxy;
+import io.quarkus.arc.InstanceHandle;
+import io.quarkus.runtime.ShutdownEvent;
+import io.quarkus.test.QuarkusUnitTest;
+import org.jboss.shrinkwrap.api.ShrinkWrap;
+import org.jboss.shrinkwrap.api.spec.JavaArchive;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import jakarta.enterprise.event.Event;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * End-to-end proof for a real, bytecode-verified finding from an upstream review (Stephan
+ * Bösebeck, PR sboesebeck/morphium#332): {@code MorphiumProducer.buildMorphium()}/{@code onStop()}
+ * previously wrapped {@code Arc.container().instance(MorphiumMetricsBinder.class)} in a
+ * try-with-resources block. His bytecode reading of {@code AbstractInstanceHandle#destroy()}
+ * (does the actual work) was correct. His conclusion that {@code InstanceHandle#close()}'s
+ * default method (which decides WHETHER to call {@code destroy()}) invokes it for a
+ * non-{@code @Dependent}-scoped bean needed one more level of bytecode reading to settle:
+ * verified directly against {@code InstanceHandle.class} in arc-3.32.3.jar --
+ * {@code close()}'s default implementation only calls {@code destroy()} when
+ * {@code ArcContainer#strictCompatibility()} is {@code true} (default: {@code false}, and Quarkus'
+ * own docs recommend leaving it {@code false}) OR the bean's scope IS {@code @Dependent}.
+ * {@code MorphiumMetricsBinder} is {@code @ApplicationScoped} and this repo does not set
+ * {@code quarkus.arc.strict-compatibility}, so in the actual default configuration the previous
+ * try-with-resources code did NOT destroy the bean on every {@code buildMorphium()}/{@code onStop()}
+ * call -- confirmed by first running this exact test against the pre-fix code (checked out from
+ * commit 5701c3e16, before this PR's fix) and observing it pass identically to the post-fix code,
+ * then confirming why via {@code InstanceHandle.class}'s bytecode and a same-instance/ClientProxy
+ * check (see {@code clientProxyProtectsAgainstDestroyInDefaultConfiguration()}).
+ *
+ * The fix in this PR (a lazily-resolved, reused {@code InstanceHandle} field, never wrapped in
+ * try-with-resources except once at final shutdown) is kept regardless: it is still strictly more
+ * correct (removes a latent dependency on {@code strictCompatibility()} staying {@code false}
+ * forever, and on {@code @ApplicationScoped} never becoming {@code @Dependent}), and this test
+ * class exists specifically to keep proving the observable contract -- meters survive multiple
+ * connect/disconnect cycles with no duplicates and no leaks -- regardless of which of those two
+ * mechanisms is doing the protecting on a given Quarkus/ArC version.
+ *
+ * Runs two full connect/disconnect cycles against the same {@code MeterRegistry} and asserts
+ * EXACTLY ONE registration per meter name after each connect (not just "the name is present
+ * somewhere" -- a name-set check alone cannot distinguish one registration from a leaked
+ * duplicate registered twice under the same MeterId) and zero morphium.* meters after each
+ * shutdown. Uses a real Micrometer {@code SimpleMeterRegistry} (auto-created by {@code
+ * quarkus-micrometer} when no registry extension like {@code quarkus-micrometer-registry-
+ * prometheus} is present) via the InMemoryDriver, so no MongoDB is required.
+ */
+public class MorphiumMetricsBinderLifecycleTest {
+
+ private static final List EXPECTED_METER_NAMES = List.of(
+ "morphium.driver.connections.pool",
+ "morphium.driver.connections.in_use",
+ "morphium.driver.connections.borrowed",
+ "morphium.driver.connections.released",
+ "morphium.driver.threads.waiting",
+ "morphium.driver.errors",
+ "morphium.driver.failovers",
+ "morphium.cache.entries",
+ "morphium.cache.hit_ratio",
+ "morphium.write_buffer.entries"
+ );
+
+ @RegisterExtension
+ static final QuarkusUnitTest TEST = new QuarkusUnitTest()
+ .setArchiveProducer(new Supplier() {
+ @Override
+ public JavaArchive get() {
+ return ShrinkWrap.create(JavaArchive.class)
+ .addClasses(LifecycleTestEntity.class);
+ }
+ })
+ .overrideConfigKey("quarkus.morphium.driver-name", "InMemDriver")
+ .overrideConfigKey("quarkus.morphium.hosts", "localhost:27017")
+ .overrideConfigKey("quarkus.morphium.database", "morphium_metrics_lifecycle_test");
+
+ @Entity(collectionName = "morphium_metrics_lifecycle_entity")
+ public static class LifecycleTestEntity {
+ @Id
+ public MorphiumId id;
+ }
+
+ @Test
+ @DisplayName("MorphiumMetricsBinder's registered meters survive buildMorphium()/onStop() unchanged, across two full cycles, with no duplicates")
+ public void metersSurviveTwoFullConnectDisconnectCycles() {
+ MorphiumProducer producer = Arc.container().instance(MorphiumProducer.class).get();
+ MeterRegistry registry = Arc.container().instance(MeterRegistry.class).get();
+
+ // Cycle 1: connect (this is the real CDI bean-creation path, not a direct method call --
+ // it exercises the exact code path buildMorphium()'s InstanceHandle fix must not break).
+ Morphium morphium1 = producer.morphium();
+ assertThat(morphium1).as("first connect must succeed").isNotNull();
+ assertExactlyOneRegistrationEach(registry, "after the first connect");
+
+ // Simulate application shutdown via the real CDI event (not a direct onStop() call --
+ // onStop() is package-private and this is the actual mechanism Quarkus uses).
+ fireShutdownEvent();
+ assertNoMorphiumMeters(registry, "after the first shutdown -- this is the exact "
+ + "deregistration path the try-with-resources finding was concerned about");
+
+ // Cycle 2: the producer's own `instance` field was reset to null by onStop(), so calling
+ // morphium() again goes through the full buildMorphium() path again, including the fixed
+ // InstanceHandle re-resolution.
+ Morphium morphium2 = producer.morphium();
+ assertThat(morphium2).as("second connect must succeed").isNotNull();
+ assertThat(morphium2).as("second connect must build a fresh Morphium instance").isNotSameAs(morphium1);
+ assertExactlyOneRegistrationEach(registry, "after the second connect -- no duplicates "
+ + "left over from the first cycle's MorphiumMetricsBinder instance");
+
+ fireShutdownEvent();
+ assertNoMorphiumMeters(registry, "after the second shutdown");
+ }
+
+ /**
+ * Documents WHY the pre-fix try-with-resources code did not actually destroy the bean in this
+ * repo's default configuration -- see the class Javadoc for the full bytecode-verified
+ * reasoning. {@code get()} on an {@code @ApplicationScoped} bean's {@code InstanceHandle}
+ * returns a {@link ClientProxy}: the same proxy object on every lookup, which transparently
+ * re-resolves the underlying contextual instance from the active context on each method call.
+ * Even if {@code destroy()} genuinely ran (e.g. with {@code strictCompatibility=true}), code
+ * holding only the proxy -- never true here, since {@code MorphiumMetricsBinder.registeredMeters}
+ * is a plain instance field with no static/proxy-level survival -- would still not observe a
+ * stale reference the way a raw instance reference would. This test exists to make that
+ * mechanism explicit and regression-proof, not to argue the original finding was wrong to
+ * raise: the underlying {@code destroy()} behavior IS real and IS scope/config-dependent, so
+ * fixing the try-with-resources (done in this PR) remains the correct, forward-compatible
+ * change regardless of what today's default configuration happens to paper over.
+ */
+ @Test
+ @DisplayName("get() on MorphiumMetricsBinder's InstanceHandle returns a stable ClientProxy, not a raw instance reference")
+ public void clientProxyProtectsAgainstDestroyInDefaultConfiguration() {
+ Object firstLookup;
+ try (InstanceHandle handle = Arc.container().instance(MorphiumMetricsBinder.class)) {
+ firstLookup = handle.get();
+ }
+ Object secondLookup;
+ try (InstanceHandle handle = Arc.container().instance(MorphiumMetricsBinder.class)) {
+ secondLookup = handle.get();
+ }
+
+ assertThat(firstLookup)
+ .as("get() must return a ClientProxy for an @ApplicationScoped bean, not the raw contextual instance")
+ .isInstanceOf(ClientProxy.class);
+ assertThat(firstLookup)
+ .as("the proxy itself is stable across independent InstanceHandle lookups, even across "
+ + "a try-with-resources close() in between -- this is what let the pre-fix code's "
+ + "registeredMeters state survive in practice under this repo's default ArC "
+ + "configuration (quarkus.arc.strict-compatibility left at its default false)")
+ .isSameAs(secondLookup);
+ }
+
+ private static void fireShutdownEvent() {
+ Event event = Arc.container().beanManager().getEvent().select(ShutdownEvent.class);
+ event.fire(new ShutdownEvent());
+ }
+
+ /**
+ * Asserts every expected morphium.* meter name is present EXACTLY once -- not merely
+ * "present somewhere", which a plain name-set check cannot distinguish from a leaked
+ * duplicate registered twice under the same MeterId (Micrometer's own
+ * "This Gauge has been already registered" warning logs but does not fail on that case, so
+ * only counting catches it).
+ */
+ private static void assertExactlyOneRegistrationEach(MeterRegistry registry, String when) {
+ Map countsByName = morphiumMeterCountsByName(registry);
+ for (String expectedName : EXPECTED_METER_NAMES) {
+ assertThat(countsByName.getOrDefault(expectedName, 0L))
+ .as("expected exactly one registration of '%s' %s, found %s", expectedName, when,
+ countsByName.getOrDefault(expectedName, 0L))
+ .isEqualTo(1L);
+ }
+ assertThat(countsByName.keySet())
+ .as("no unexpected morphium.* meters %s", when)
+ .containsExactlyInAnyOrderElementsOf(EXPECTED_METER_NAMES);
+ }
+
+ private static void assertNoMorphiumMeters(MeterRegistry registry, String when) {
+ assertThat(morphiumMeterCountsByName(registry).keySet())
+ .as("no morphium.* meters must remain registered %s", when)
+ .isEmpty();
+ }
+
+ private static Map morphiumMeterCountsByName(MeterRegistry registry) {
+ return registry.getMeters().stream()
+ .map(Meter::getId)
+ .map(id -> id.getName())
+ .filter(name -> name.startsWith("morphium."))
+ .collect(Collectors.groupingBy(name -> name, Collectors.counting()));
+ }
+}
diff --git a/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorObservabilityTest.java b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorObservabilityTest.java
new file mode 100644
index 000000000..d53448c22
--- /dev/null
+++ b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorObservabilityTest.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2025 The Quarkiverse Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.caluga.morphium.quarkus.deployment;
+
+import io.quarkus.arc.deployment.AdditionalBeanBuildItem;
+import io.quarkus.deployment.Capabilities;
+import io.quarkus.deployment.Capability;
+import io.quarkus.deployment.annotations.BuildProducer;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link MorphiumProcessor#registerObservability}: verifies that
+ * {@code MorphiumMetricsBinder} is registered as an additional CDI bean only when
+ * {@code Capability.METRICS} (Micrometer) is present at build time, and is NOT registered
+ * for an app that lacks it -- mirroring
+ * {@code MorphiumIdJsonSerializationTest}/the Jackson/JSON-B Capability-gating pattern, but as
+ * a plain unit test against the processor method directly (same style as
+ * {@link MorphiumProcessorReflectionTest}), since {@code MorphiumProcessor} needs no live
+ * Jandex index for this build step.
+ */
+@DisplayName("MorphiumProcessor#registerObservability — Capability.METRICS gating")
+class MorphiumProcessorObservabilityTest {
+
+ private static final String BINDER_CLASS = "de.caluga.morphium.quarkus.observability.MorphiumMetricsBinder";
+
+ /** Collects every {@code AdditionalBeanBuildItem} produced, flattened to bean class names. */
+ private static class CollectingBeanProducer implements BuildProducer {
+ final List items = new ArrayList<>();
+
+ @Override
+ public void produce(AdditionalBeanBuildItem item) {
+ items.add(item);
+ }
+
+ Set beanClassNames() {
+ Set names = new HashSet<>();
+ for (AdditionalBeanBuildItem item : items) {
+ names.addAll(item.getBeanClasses());
+ }
+ return names;
+ }
+ }
+
+ @Test
+ @DisplayName("Capability.METRICS present -> MorphiumMetricsBinder IS registered as an additional bean")
+ void metricsCapabilityPresent_registersBinder() {
+ Capabilities capabilities = new Capabilities(Set.of(Capability.METRICS));
+ CollectingBeanProducer producer = new CollectingBeanProducer();
+ MorphiumProcessor processor = new MorphiumProcessor();
+
+ processor.registerObservability(capabilities, producer);
+
+ assertThat(producer.beanClassNames())
+ .as("MorphiumMetricsBinder must be added as an AdditionalBeanBuildItem when Micrometer is present")
+ .contains(BINDER_CLASS);
+ }
+
+ @Test
+ @DisplayName("Capability.METRICS absent -> MorphiumMetricsBinder is NOT registered (no-op for apps without Micrometer)")
+ void metricsCapabilityAbsent_doesNotRegisterBinder() {
+ Capabilities capabilities = new Capabilities(Collections.emptySet());
+ CollectingBeanProducer producer = new CollectingBeanProducer();
+ MorphiumProcessor processor = new MorphiumProcessor();
+
+ processor.registerObservability(capabilities, producer);
+
+ assertThat(producer.items)
+ .as("no AdditionalBeanBuildItem must be produced at all when Micrometer is absent")
+ .isEmpty();
+ }
+}
diff --git a/quarkus-morphium/docs/architecture/observability-module-plan.md b/quarkus-morphium/docs/architecture/observability-module-plan.md
new file mode 100644
index 000000000..7f8c64161
--- /dev/null
+++ b/quarkus-morphium/docs/architecture/observability-module-plan.md
@@ -0,0 +1,399 @@
+# Architektur-Plan: Optionales Observability-Modul für `quarkus-morphium`
+
+> Autor: datona-architect (Agent-Entwurf)
+> Status: **Phase 1 (MVP) implementiert** — Connection-Pool/Driver-Stats-Gauges (Abschnitt 4.1/5),
+> Capability.METRICS-Gate, Konfiguration (`MorphiumObservabilityConfig`), Lifecycle-Cleanup
+> (Hot-Reload-Idempotenz) sind umgesetzt (sboesebeck/morphium#332). Die Counter/Timer-Zeilen aus
+> Abschnitt 5 (`morphium.operations.*`, `morphium.transactions.*`, sourced from
+> `MorphiumStorageListener`/`MorphiumTransactionEvent`) sind weiterhin **nicht** implementiert,
+> zurückgestellt auf eine spätere Phase (Abschnitt 8).
+> Repo: `morphium` (Maven-Modul `quarkus-morphium/`)
+> Letzte Prüfung des Codes: 2026-08-23
+
+---
+
+## 1. Ziel & Auslöser
+
+**Auslöser:** Im Repo `datona-version42-adapter-ng-workspace/main-quarkus` wurde beobachtet,
+dass Teams, die `quarkus-morphium` UND eine Micrometer/OpenTelemetry-Metrics-Extension
+(`quarkus-micrometer`, `quarkus-micrometer-registry-prometheus`, `quarkus-opentelemetry`, …)
+gleichzeitig einsetzen, händisch Boilerplate schreiben, um MongoDB-Operationsmetriken
+(Connection-Pool, Latenzen, Fehlerzähler) in ihr Metrics-Backend zu spiegeln.
+
+**Ziel dieses Plans:** Ein neues, **rein optionales** Sub-Modul der `quarkus-morphium`-Extension,
+das — nur wenn eine Metrics-Extension bereits auf dem Klassenpfad der Anwendung liegt —
+automatisch Morphium/MongoDB-Kennzahlen als Micrometer-`Meter`s registriert. Apps ohne
+Metrics-Extension dürfen **keine** zusätzliche Dependency, keinen zusätzlichen Klassenpfad-Eintrag
+und keine Verhaltensänderung bekommen.
+
+---
+
+## 2. Verifizierter Code-Kontext (Ist-Zustand)
+
+### 2.1 Modulstruktur (bestehend)
+```
+quarkus-morphium/
+├── runtime/ (quarkus-morphium) – CDI-Producer, Config, Interceptors
+├── deployment/ (quarkus-morphium-deployment) – BuildSteps, Jandex-Scanning, Capabilities-Gates
+├── testing/
+└── integration-tests/
+```
+Parent-POM (`quarkus-morphium/pom.xml`) verwaltet die Quarkus-BOM; jedes Sub-Modul hat eine
+eigene `pom.xml` mit `parent = quarkus-morphium-parent`.
+
+### 2.2 Bereits etabliertes Muster für "optionale Integration bei vorhandener Capability"
+
+`MorphiumProcessor.registerMorphiumIdJsonCustomizers(...)` (deployment) ist das **exakte
+Vorbild** für das, was wir bauen wollen:
+
+```java
+@BuildStep
+void registerMorphiumIdJsonCustomizers(Capabilities capabilities,
+ BuildProducer additionalBeans) {
+ if (capabilities.isPresent(Capability.JACKSON)) {
+ additionalBeans.produce(AdditionalBeanBuildItem.builder()
+ .addBeanClass("de.caluga.morphium.quarkus.json.MorphiumIdJacksonModule")
+ .setUnremovable().build());
+ }
+ if (capabilities.isPresent(Capability.JSONB)) { ... }
+}
+```
+Kombiniert mit optionalen Maven-Dependencies (`true` auf
+`quarkus-jackson` / `quarkus-jsonb` in `runtime/pom.xml`, plus die `-deployment`-Pendants in
+`deployment/pom.xml`) und Deployment-Parity (Quarkus verlangt: jede optionale Runtime-Dependency
+braucht ein `-deployment`-Gegenstück im Deployment-Modul, sonst schlägt der
+Extension-Consistency-Check fehl).
+
+**→ Dieses Muster wird 1:1 für Micrometer/OpenTelemetry wiederverwendet.**
+
+### 2.3 Bereits vorhandene Kennzahlen-Quellen in Morphium-Core (kein neuer Code für Datenerhebung nötig)
+
+| Quelle | Typ | Inhalt |
+|---|---|---|
+| `MorphiumDriver.getDriverStats()` → `Map` | Pull (on-demand) | `CONNECTIONS_OPENED/CLOSED/BORROWED/RELEASED/IN_POOL/IN_USE`, `ERRORS`, `FAILOVERS`, `MSG_SENT`, `REPLY_*`, `THREADS_CREATED`, `THREADS_WAITING_FOR_CONNECTION` |
+| `Morphium.getStatistics()` → `Statistics extends HashMap` | Pull (on-demand) | `StatisticKeys`: `WRITES`, `WRITES_CACHED`, `READS`, `CHITS`, `CMISS`, `NO_CACHED_READS`, `CHITSPERC`, `CMISSPERC`, `CACHE_ENTRIES`, `REGISTERED_LOGGERS`, `WRITE_BUFFER_ENTRIES`, `PULL`, `PULLSKIP`, `SKIPPED_MSG_UPDATES`, `INSTANCE_COUNT` |
+| `driver.getNumConnectionsByHost()` | Pull | Connections pro Host (für Multi-Host-Setups) |
+| `MorphiumStorageListener` (Interface, bereits für `MorphiumBlockingCallDetector` genutzt) | Push (Event) | `preStore`/`postStore`/`preRemove`/`postRemove`/`postLoad`/`preUpdate`/`postUpdate` — je Aufruf, mit Objekt/Query/Class-Kontext |
+| `MorphiumTransactionEvent` (CDI Event, `@MorphiumTxPhase`) | Push (CDI Event) | `BEFORE_COMMIT`/`AFTER_COMMIT`/`AFTER_ROLLBACK`, inkl. Exception bei Rollback |
+| `MorphiumReadinessCheck` | Pull (schon gebaut) | liest bereits `getDriverStats()` als Health-Metadata — Beleg, dass die Werte zur Laufzeit zugreifbar sind |
+
+**Wichtiger Befund:** Es gibt **keine** dedizierte "MetricsListener"/"ProfilingListener"-Klasse
+in `morphium-core` — die Statistik-APIs sind reine Pull-Snapshots (`Map`), die
+Event-Hooks (`MorphiumStorageListener`, `MorphiumTransactionEvent`) sind die einzigen
+Push-Mechanismen. Das Observability-Modul muss beide Muster kombinieren:
+- **Gauges** aus periodischem Pull der Statistics/DriverStats-Maps.
+- **Counter/Timer** aus dem Push-Pfad (`MorphiumStorageListener`, Transaction-Events) für
+ Latenz- und Fehlerzähler pro Operation.
+
+### 2.4 CDI-Lifecycle-Realität, die das Design einschränkt
+
+- `Morphium` ist ein **normal-scoped, lazy** CDI-Bean (`MorphiumProducer.morphium()` mit
+ double-checked locking) — der erste Proxy-Zugriff löst den echten Connect aus. Ein
+ Observability-Feature darf **niemals** `Instance.get()` aufrufen, um sich früh zu
+ registrieren (exakt das Problem, das `MorphiumBlockingCallDetector`s Javadoc dokumentiert:
+ ein früherer `@Observes StartupEvent`-Ansatz löste ungewollt den Connect aus). Die Registrierung
+ muss **innerhalb** von `MorphiumProducer.buildMorphium()`, nach dem echten Connect, erfolgen —
+ wie `MorphiumBlockingCallDetector.registerOn(instance)` es bereits tut (Zeile ~497 in
+ `MorphiumProducer.java`, direkt vor dem `log.info(...)` Banner).
+- Native-Image-Kompatibilität: keine Reflection außerhalb der bereits etablierten
+ `ClassGraphCache.preRegister*`-Mechanik; keine neuen `sun.*`/`Unsafe`-Zugriffe.
+- Dev-Mode Hot-Reload: `instance` wird bei jedem Hot-Reload neu gebaut (`buildMorphium()` läuft
+ erneut) — jede Meter-Registrierung muss **idempotent** sein (Micrometer wirft bei
+ Doppel-Registrierung mit identischem `Id` keinen Fehler, aber doppelte `MeterBinder`-Aufrufe
+ ohne Deduplizierung erzeugen doppelte Callback-Referenzen auf alte `Morphium`-Instanzen ⇒
+ Memory-Leak / falsche Werte nach Reload). Siehe Abschnitt 6.4.
+
+### 2.5 Was zu Micrometer/OpenTelemetry bereits im Repo existiert
+
+Keine Treffer für `Capability.METRICS`, `MICROMETER` oder `OPENTELEMETRY` im gesamten
+`morphium-workspace` — die Extension hat **aktuell keine Berührung** mit Metrics/Tracing.
+Das neue Modul ist somit komplett grüne Wiese, aber mit einem klaren Vorbild (JSON-Customizer-Muster).
+
+---
+
+## 3. Architekturentscheidung: Neues Sub-Modul vs. In-Place-Erweiterung
+
+### Optionen
+
+| Option | Beschreibung | Bewertung |
+|---|---|---|
+| **A. Neues Maven-Sub-Modul** `quarkus-morphium-observability` (+ `-deployment`) | Eigenständige Extension, die `quarkus-morphium` UND `quarkus-micrometer` als **beide optional/required** deklariert | ✅ Saubere Trennung, kein Zwang für Micrometer-Dependency in der Haupt-Extension; **aber**: zweite Extension zum Pflegen, zweiter Versionsstand, Nutzer müssen zwei Coordinates kennen |
+| **B. Klassen direkt in `quarkus-morphium` (runtime+deployment), Micrometer als `true`** | Wie das bestehende Jackson/JSON-B-Muster (Abschnitt 2.2), nur für Micrometer | ✅ Ein Artefakt, ein Versionsstand, Nutzer bekommen Observability "geschenkt" sobald sie Micrometer schon haben; folgt 1:1 etabliertem Repo-Muster; ⚠️ etwas mehr Verantwortung im Kern-Modul |
+| **C. Separates Community-Extension-Projekt außerhalb des Morphium-Reactors** | Wie es früher `io.quarkiverse.morphium` war (siehe README "Migrating from standalone") | ❌ Widerspricht der bewussten Entscheidung, alles in den Morphium-Reactor zu holen (README: "this extension is now an optional module... built in lockstep") |
+
+### Empfehlung: **Option B** — Erweiterung von `quarkus-morphium` (runtime + deployment), kein neues Top-Level-Modul
+
+**Begründung:**
+1. Das Repo hat mit dem Jackson/JSON-B-Muster bereits bewiesen, dass "optional dependency +
+ Capabilities-Gate im selben Modul" der etablierte, von den Maintainern akzeptierte Weg ist —
+ ein neues Sub-Modul für exakt dasselbe Muster wäre unnötige Divergenz.
+2. Ein zusätzliches Maven-Modul bedeutet: eigene `pom.xml`, eigener Eintrag in
+ `quarkus-morphium/pom.xml` ``, eigene Extension-Metadata
+ (`quarkus-extension.yaml` via `quarkus-extension-maven-plugin`), eigene Versionierung im
+ Reactor, eigene Release-Koordination — Mehraufwand ohne technischen Zwang, da Capabilities-Gates
+ bereits verhindern, dass Nicht-Metrics-Apps etwas davon spüren.
+3. **Einzige Ausnahme, die ein eigenes Modul rechtfertigen würde:** falls das Observability-Modul
+ selbst harte (non-optional) Compile-Abhängigkeiten zu Micrometer-APIs bräuchte, die den
+ Bytecode von `quarkus-morphium` aufblähen, auch wenn die Capability nicht aktiv ist. Das ist
+ vermeidbar (siehe Abschnitt 5) — Micrometer-Typen werden ausschließlich in isolierten, über
+ `Capabilities.isPresent(...)` gated Klassen referenziert, exakt wie
+ `MorphiumIdJacksonModule`/`MorphiumIdJsonbModule` heute mit Jackson/JSON-B.
+
+**Falls das Team dennoch strikte Modul-Trennung will** (z. B. weil Observability-Code schneller
+iterieren soll als der Core), ist Option A die Fallback-Wahl — der Rest dieses Plans (BuildSteps,
+Capabilities-Gate-Logik, Metrik-Katalog, Naming) bleibt inhaltlich identisch und wird nur auf
+zwei Module statt zwei Package innerhalb eines Moduls verteilt.
+
+---
+
+## 4. Zielarchitektur (Option B im Detail)
+
+### 4.1 Neue Klassen (runtime, Package `de.caluga.morphium.quarkus.observability`)
+
+| Klasse | Verantwortung |
+|---|---|
+| `MorphiumMetricsBinder` | Registriert Micrometer-`Gauge`s für Driver-Stats (`getDriverStats()`) und Morphium-Statistics (`getStatistics()`) gegen eine injizierte `MeterRegistry`. Analog zu bestehenden Micrometer-Bindern (`io.micrometer.core.instrument.binder.MeterBinder`). |
+| `MorphiumMetricsStorageListener implements MorphiumStorageListener