Skip to content

feat(quarkus-morphium): optional Micrometer observability module (Phase 1 MVP) - #332

Merged
sboesebeck merged 9 commits into
sboesebeck:developfrom
Bardioc1977:feature/quarkus-morphium-observability
Aug 24, 2026
Merged

feat(quarkus-morphium): optional Micrometer observability module (Phase 1 MVP)#332
sboesebeck merged 9 commits into
sboesebeck:developfrom
Bardioc1977:feature/quarkus-morphium-observability

Conversation

@Bardioc1977

@Bardioc1977 Bardioc1977 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

What

An optional Micrometer metrics integration for quarkus-morphium, gated on Capability.METRICS so an application without Micrometer on its classpath sees zero change -- no new dependency inherited, no bean registered, no behaviour change.

Why

Teams running quarkus-morphium in production commonly hand-write Micrometer boilerplate to expose the MongoDB connection pool as gauges -- one real-world case needed ~65 lines for this, including a weak-reference/GC bug that had to be found and fixed once already. This gives every quarkus-morphium consumer the same visibility for free.

Design

Extends quarkus-morphium/quarkus-morphium-deployment in place (no new Maven module), mirroring the module's own existing Capability.JACKSON/JSONB-gated pattern (MorphiumProcessor.registerMorphiumIdJsonCustomizers) rather than introducing a second artifact for the same shape. Full architecture plan, produced by an architect pass and independently verified against the actual code before adoption, at quarkus-morphium/docs/architecture/observability-module-plan.md (first commit on this branch).

Phase 1 (this PR) scope: connection-pool/driver-stats gauges and counters only (morphium.driver.*, morphium.cache.*, morphium.write_buffer.*, sourced from MorphiumDriver.getDriverStats()/Morphium.getStatistics()). Cumulative values (connections.borrowed/.released, .errors, .failovers) are registered as Micrometer FunctionCounters, not Gauges, matching the metric catalog's own classification and giving backends correct counter semantics for rate()/increase() queries. The Counter/Timer rows sourced from MorphiumStorageListener/MorphiumTransactionEvent (morphium.operations.*, morphium.transactions.*) are explicitly deferred to a later phase.

Registration happens inside MorphiumProducer.buildMorphium(), after the connection already exists -- never from an early @Observes StartupEvent that could trigger the lazy connect prematurely. Deregistration on onStop() and before every re-bind, so a dev-mode hot-reload never leaves stale gauges. A runtime kill-switch (quarkus.morphium.observability.enabled, default true) lets an app that has Micrometer on its classpath for an unrelated reason opt out of Morphium's gauges specifically.

Process

Went through a full implementer -> reviewer loop before this PR was opened, plus an independent automated-review pass with findings verified against the actual code/bytecode (not taken on faith either way):

  • Implementation, independently re-verified against a real build/test run, not just the self-report.
  • Review: no blocking findings. Follow-ups addressed in subsequent commits: a plan-doc note on Capability.METRICS's deprecation status in Quarkus 3.32.3, a missing runtime kill-switch the plan specified as in-scope, correcting the metric type for cumulative driver stats (Gauge -> FunctionCounter), and a Javadoc correction (a WeakReference/GC-safety explanation was misattributed -- verified against Micrometer's actual bytecode before correcting it).
  • One automated finding (claiming a .class literal reference would break native-image builds for apps without Micrometer) was investigated with an actual GraalVM native-image build and execution of a minimal reproduction of the exact shape, and did not reproduce -- not applied, with the disproof on record.

Verification

mvn -pl quarkus-morphium/runtime,quarkus-morphium/deployment -am -DskipTests install then ... test (no -am for the test run, to avoid the unrelated 25+ minute morphium-core suite) -- BUILD SUCCESS, runtime 68/68 tests passing, deployment 29/29 passing. Re-verified fresh against the final commit HEAD, not just the pre-commit working tree.

…ty module

Adds quarkus-morphium/docs/architecture/observability-module-plan.md, produced
by the datona-architect persona and independently verified against the code
before adoption (Capability.METRICS confirmed against the reactor's pinned
quarkus-core-deployment 3.32.3 jar; buildMorphium()/onStop() lifecycle,
MorphiumStorageListener callback signatures, and every DriverStatsKey/
StatisticKeys value in the metric catalog confirmed against morphium-core
source, not assumed).

Trigger: version42-adapter (a downstream consumer) hand-writes ~65 lines of
Micrometer boilerplate (MongoConnectionPoolMetrics.java) to expose the
MongoDB connection pool's utilization as gauges, including a real
weak-reference/GC bug that had to be found and fixed once already. This plan
scopes an optional module that gives that to every quarkus-morphium consumer
for free, purely additive, gated on Capability.METRICS so apps without
Micrometer see zero change.

Decision: extend quarkus-morphium/quarkus-morphium-deployment in place
(no new Maven module) -- follows the repo's own existing precedent
(MorphiumProcessor.registerMorphiumIdJsonCustomizers, Capability.JACKSON/
JSONB-gated) rather than introducing a second artifact for the same pattern.

Status: proposal, not yet implemented. Next: datona-feature-implementer /
datona-reviewer loop on this branch.
…se 1 MVP)

Adds an optional Micrometer metrics integration, gated on Capability.METRICS
so an application without Micrometer on its classpath sees zero change --
no new dependency inherited, no bean registered, no behaviour change.

Follows the observability-module-plan.md (docs/architecture/, committed
separately as b707870): extends quarkus-morphium/quarkus-morphium-deployment
in place, mirroring the module's own existing Capability.JACKSON/JSONB-gated
pattern (MorphiumProcessor.registerMorphiumIdJsonCustomizers) rather than
introducing a new Maven module for the same shape.

Phase 1 scope: connection-pool/driver-stats gauges only (morphium.driver.*,
morphium.cache.*, morphium.write_buffer.*, sourced from
MorphiumDriver.getDriverStats()/Morphium.getStatistics()). The Counter/Timer
rows sourced from MorphiumStorageListener/MorphiumTransactionEvent
(morphium.operations.*, morphium.transactions.*) are explicitly deferred to
a later phase.

- quarkus-morphium/runtime/pom.xml, deployment/pom.xml: optional
  quarkus-micrometer/quarkus-micrometer-deployment dependency pair.
- MorphiumProcessor: new registerObservability @buildstep, gates
  MorphiumMetricsBinder's registration as a CDI bean on
  Capabilities.isPresent(Capability.METRICS).
- MorphiumMetricsBinder (new, runtime/observability package): registers 10
  Micrometer Gauges tagged `database`, each reading live from the underlying
  Morphium/MorphiumDriver stats maps (not a snapshot) -- avoids the
  WeakReference-GC'd-to-NaN bug the plan cites from a real downstream
  precedent (version42-adapter's hand-written MongoConnectionPoolMetrics).
  close() deregisters every meter it registered, for hot-reload idempotency.
- MorphiumProducer: binder lookup/bind wired into buildMorphium() (after the
  connection already exists -- never from an early @observes StartupEvent
  that could trigger the lazy connect prematurely) and deregistration wired
  into onStop(), via Arc.container().instance(...) + InstanceHandle so the
  lookup never throws when the bean doesn't exist (Micrometer absent).

Verified independently by the orchestrating session, not just the
implementer's self-report: `mvn -pl quarkus-morphium/runtime,quarkus-morphium/deployment
-am -DskipTests install` then `... test` (no -am, to avoid the unrelated
25+ minute morphium-core suite) -- BUILD SUCCESS, runtime 68/68 tests
passing (incl. new MorphiumMetricsBinderTest, 5/5), deployment 29/29 passing
(incl. new MorphiumProcessorObservabilityTest, 2/2).

Reviewed by the datona-reviewer persona (Round 1): APPROVAL, no blocking
findings. Two follow-ups from that review are addressed in the next commit
on this branch (Capability.METRICS deprecation note in the plan doc, and the
missing MorphiumObservabilityConfig.enabled runtime kill-switch that
Section 4.1 specifies but this round's implementer brief had omitted from
scope).
…on note

Addresses the two non-blocking follow-ups from the Round 1 review of the
observability module (previous commit, c829ee6):

1. Plan doc: adds a Section 9 item documenting that Capability.METRICS is
   @deprecated in Quarkus 3.32.3 (confirmed against the pinned reactor's
   quarkus-core-deployment sources jar -- Javadoc points to
   MetricsCapabilityBuildItem, a structurally different build-item shape,
   not a drop-in replacement). Adjudicated: acceptable to ship on for this
   phase, consistent with the module's existing Capability.JACKSON/JSONB
   gate idiom; migration deferred to its own future ticket.

2. MorphiumObservabilityConfig (new): a nested `quarkus.morphium.observability.*`
   config interface, mirroring the module's existing MorphiumMigrationConfig
   precedent rather than the plan's literal standalone-@ConfigMapping
   description -- same property path and defaults, different Java-level
   composition. Implements only `enabled` (default true) in this phase;
   Section 7's other properties (poll-interval, per-host-connections,
   include-storage-listener-metrics) govern behaviour Phase 1 doesn't
   implement yet, so they are deliberately not added until the phase that
   implements what they control.

   Wired into MorphiumProducer.buildMorphium(): the binder's close() still
   runs unconditionally whenever the Capability.METRICS-gated bean exists
   (so a hot-reload never leaves stale gauges regardless of the flag), but
   bindTo() -- the actual gauge registration -- is now gated on
   `config.observability().enabled()`. Lets an application that has
   Micrometer on its classpath for an unrelated reason opt out of Morphium's
   gauges specifically, at runtime, without a rebuild.

   This was Section 4.1's own MVP scope (MorphiumObservabilityRuntimeConfig
   is listed there as a regular Phase 1 class, not deferred) that the Round 1
   implementer brief had incorrectly left out of scope -- an orchestrator
   scoping error, not an implementer deviation from a correct brief.

Also applies the reviewer's precedent-wording correction to the
buildMorphium() comment: MorphiumRecorder's Arc.container().instance(...)
precedent is API-identical but not quite semantically identical (its beans
are always-present; MorphiumMetricsBinder is the first genuinely-optional
use of that idiom in this module).

Verified: `mvn -pl quarkus-morphium/runtime,quarkus-morphium/deployment test`
(no -am, dependency jars already installed) -- BUILD SUCCESS, exit 0,
runtime 68/68 and deployment 29/29 still passing, no regressions, no new
test needed for the added if-branch itself (buildMorphium() has no existing
unit-test seam for its private connect logic; the existing
MorphiumProducerConfigValidationTest pattern only covers extracted static
helpers, and extracting one for a single boolean check would be
disproportionate scope creep for this fix).
…unter

Addresses a real finding from an automated PR review (Codex, fork PR #24):
morphium.driver.connections.borrowed/.released, .errors, and .failovers are
cumulative, monotonically-increasing values -- MorphiumDriver already tracks
them as running totals, and the observability-module-plan.md Section 5
metric catalog classifies them as Counter rows, not Gauge rows -- but
MorphiumMetricsBinder registered all seven driver-sourced metrics uniformly
through Gauge.builder. A Gauge on a cumulative value publishes gauge
metadata to the backend instead of counter metadata, breaking
counter-oriented dashboards, rate()/increase() queries, and reset-on-restart
handling that assume real counter semantics.

Fix: new registerDriverCounter() using Micrometer's FunctionCounter.builder
(identical API shape to Gauge.builder -- same ToDoubleFunction-based live
read from the underlying Morphium/MorphiumDriver reference, so none of the
existing WeakReference-avoidance reasoning changes) for the four cumulative
metrics. registerDriverGauge stays for the two genuinely instantaneous
driver values (connections.pool, connections.in_use, threads.waiting) and
the three cache/write-buffer gauges.

Also fixes the plan doc's stale "Vorschlag / zur Diskussion -- noch nicht
implementiert" status header (CodeRabbit finding, same PR): Phase 1 is
implemented (sboesebeck#332); the Counter/Timer catalog rows from
MorphiumStorageListener/MorphiumTransactionEvent remain deferred.

A third automated finding from the same review round (CodeRabbit AND Codex,
both P1: "MorphiumMetricsBinder.class references in MorphiumProducer force
native-image reachability analysis to resolve Micrometer types even when
Micrometer is absent, breaking no-Micrometer native builds") was
independently investigated and NOT applied: built a minimal reproduction
(a class with a field of a type absent from the classpath, referenced via
a .class literal from an always-reachable method, exactly this PR's shape)
and verified with a real GraalVM native-image build AND execution of the
resulting native binary -- both succeeded (exit 0), because a .class
literal alone does not force the JVM/native-image to resolve the target
class's field/method-body types unless those methods are actually invoked,
which isAvailable()==false correctly prevents here. Recorded as a rejected
finding with its disproof rather than silently ignored.

Verified: `mvn -pl quarkus-morphium/runtime,quarkus-morphium/deployment test`
-- BUILD SUCCESS, exit 0, runtime 68/68 (incl. updated MorphiumMetricsBinderTest,
5/5, now asserting FunctionCounter vs. Gauge type per metric), deployment 29/29.
@Bardioc1977

Copy link
Copy Markdown
Collaborator Author

Ran this branch through an automated review pass on the fork (CodeRabbit + Codex, PR Bardioc1977#24 -- our fork's default branch is develop specifically so free-tier OSS review bots pick PRs against it up). Two real findings fixed in a5b0d4ce8:

  • Plan doc status was stale ("not implemented" despite Phase 1 shipping).
  • Four cumulative driver stats (connections.borrowed/.released, .errors, .failovers) were registered as Micrometer Gauge instead of FunctionCounter, which the plan's own metric catalog already specified as Counter rows -- fixed, same live-read semantics, correct meter type for backend dashboards/rate queries.

A third finding (both bots, P1 severity) claimed the MorphiumMetricsBinder.class reference in MorphiumProducer would break native-image builds for apps without Micrometer via reachability analysis. Investigated with an actual GraalVM native-image build + execution of a minimal reproduction of the exact shape -- it does not reproduce; a .class literal alone doesn't force resolution of the referenced class's field types unless its methods are invoked, and isAvailable()==false prevents that here. Details in the fork PR comment. Not applied.

…references

Copilot review finding on fork PR #24: MorphiumMetricsBinder's Javadoc
referenced a single-argument bindTo(Morphium) overload that does not exist
-- the only method is bindTo(Morphium m, String database). Three occurrences
(class-level Javadoc twice, close()'s Javadoc once), all corrected to
{@link #bindTo(Morphium, String)}.

Verified by generating the actual Javadoc HTML and reading the resolved
link, not just re-reading the source: `mvn -pl quarkus-morphium/runtime
javadoc:javadoc` -- BUILD SUCCESS, exit 0; the generated
MorphiumMetricsBinder.html now links to
`#bindTo(de.caluga.morphium.Morphium,java.lang.String)`, the real method
signature, confirmed via `grep -o 'href="[^"]*bindTo[^"]*"'` against the
generated file.

Re-ran `mvn -pl quarkus-morphium/runtime,quarkus-morphium/deployment test`
-- BUILD SUCCESS, runtime 68/68, deployment 29/29, no regressions.
…n Javadoc

Copilot review finding on fork PR #24: the class Javadoc claimed the
extractor lambdas "close over the Morphium parameter" and that this
@ApplicationScoped bean's lifetime is what keeps m reachable, avoiding the
WeakReference-GC'd-to-NaN bug the plan cites from MongoConnectionPoolMetrics.
That attribution is wrong. Verified directly against the Micrometer bytecode
(io.micrometer.core.instrument.internal.DefaultGauge, javap'd): Gauge holds
its target object via `private final WeakReference<T> ref`, not a strong
reference -- so Micrometer itself never keeps m alive. MorphiumMetricsBinder
stores only Meter.Id values in registeredMeters, never m itself, so the
binder bean's own CDI lifetime is irrelevant to m's reachability.

What actually prevents the bug: MorphiumProducer holds the same Morphium
instance strongly via its own `private volatile Morphium instance` field,
populated in buildMorphium() and cleared only in onStop() -- a lifetime that
happens to outlive every gauge registered against it, but is not something
MorphiumMetricsBinder does or guarantees on its own.

Corrected the Javadoc to attribute the safety property to the right
mechanism, so a future maintainer who e.g. extracted this binder for reuse
outside MorphiumProducer wouldn't rely on a guarantee that doesn't actually
come from this class.

Verified: `mvn -pl quarkus-morphium/runtime javadoc:javadoc` -- BUILD
SUCCESS, exit 0. `mvn -pl quarkus-morphium/runtime,quarkus-morphium/deployment
test` -- BUILD SUCCESS, runtime 68/68, deployment 29/29, no regressions
(comment-only change).
@Bardioc1977

Copy link
Copy Markdown
Collaborator Author

GitHub Copilot's re-review (fork PR #24) caught a Javadoc misattribution: the class-level comment claimed this bean's own CDI lifetime prevents the WeakReference-GC'd-to-NaN bug the plan cites -- verified against Micrometer's actual bytecode that this isn't true (Gauge holds its target via a real WeakReference; the actual safety comes from MorphiumProducer's own strong instance field). Fixed in 0736041b6. Also fixed the broken {@link #bindTo(Morphium)} Javadoc link the same bot flagged earlier (wrong arity).

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 (this
branch) against a live otel-collector/Prometheus stack: 9 of the 10
new meters showed up immediately, morphium.cache.hit_ratio did not.

New test cacheHitRatioIsZeroNotNaNBeforeAnyCachedRead, run against the
inmem driver (18/18 total in StatisticsTest, 0 failures).
@Bardioc1977

Copy link
Copy Markdown
Collaborator Author

While testing this module's meters against a live otel-collector/Prometheus stack, morphium.cache.hit_ratio didn't show up, unlike the other nine meters. Root cause is upstream of this PR: Statistics.java computes CHITS/(CHITS+CMISS)*100 unconditionally, and before any cached read has happened both are 0, giving 0.0/0.0 = NaN. Prometheus/OTel exporters silently drop NaN samples, so a fresh application's cache-hit-ratio metric appears entirely missing instead of a real "no data yet" 0%.

Added a fix + regression test on this branch (5701c3e16): CHITSPERC/CMISSPERC now report 0 when there's no data yet, matching every other meter's behaviour.

@sboesebeck sboesebeck left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: one blocking finding, otherwise solid

Verified against the actual code and dependencies (ArC 3.32.3 bytecode, Micrometer DefaultGauge/CumulativeFunctionCounter): the Capability gating, the post-connect registration point, the Gauge-vs-FunctionCounter classification and the WeakReference reasoning in the Javadoc all check out. The Statistics NaN fix is correct too - and it now reads each AtomicLong once instead of three times, so the two percentages are finally computed from one consistent snapshot.

🔴 Blocking: try (InstanceHandle<...>) destroys the @ApplicationScoped binder

MorphiumProducer.buildMorphium() and onStop() both use

try (InstanceHandle<MorphiumMetricsBinder> binderHandle = Arc.container().instance(MorphiumMetricsBinder.class)) {

In ArC, InstanceHandle.close() is not just "release the handle". AbstractInstanceHandle.destroy() with no custom destroyLogic and a non-@Dependent scope calls context.destroy(bean) on the active context (see independent-projects/arc/runtime/.../impl/AbstractInstanceHandle.java; checked in the arc-3.32.3 jar's bytecode as well). Since MorphiumMetricsBinder is @ApplicationScoped, every one of these try blocks destroys the binder's contextual instance on exit - together with its registeredMeters list.

The consequence needs no hot-reload at all. In a completely normal lifecycle, buildMorphium() registers the meters and then immediately destroys the bean instance that recorded them. onStop() later gets a fresh instance with an empty list, so its close() is a silent no-op. The deregistration path this PR explicitly builds (and unit-tests) never removes anything inside a real container.

The Section-6.4 hot-reload protection breaks on top of that, in any setup where the MeterRegistry outlives the binder bean: the next bindTo() runs on a fresh bean, registers the same Meter IDs, Micrometer returns the existing meters still bound (weakly) to the old Morphium instance, the old instance gets GC'd, the gauges report NaN, Prometheus drops the samples. Exactly the failure mode the design doc sets out to prevent. The unit tests can't see any of this because they set binder.registry directly and bypass CDI entirely.

Suggested fix - two options, both fine by me:

  • Simplest: drop the try-with-resources. An InstanceHandle doesn't need closing; for a normal-scoped bean there is nothing to release.
  • Or resolve the binder once into a field on the producer (private volatile InstanceHandle<MorphiumMetricsBinder> metricsBinder;) and reuse it in buildMorphium()/onStop(), releasing it only on final shutdown.

Either way, please add a QuarkusUnitTest that runs two build cycles against the same registry and asserts no stale/duplicate meters - that pins the lifecycle contract the unit tests can't reach.

Non-blocking

  • Missing CHANGELOG entry: the core change (CHITSPERC/CMISSPERC NaN -> 0) is user-visible and needs an entry under ## [Unreleased] -> ### Fixed per repo convention.
  • CI red, but not yours: PooledDriverHeartbeatResilienceTest.heartbeatSelfRevivesWithoutGetPrimaryConnectionCall fails identically on current develop and master (pre-existing, test-side observability issue - being fixed separately). Not a regression from this PR.
  • Nit: MorphiumProcessorObservabilityTest uses new java.util.HashSet<>() inline - import it.

…ndle

Addresses the blocking finding from Stephan Bösebeck's review of PR
sboesebeck#332: MorphiumProducer.buildMorphium()/onStop() wrapped
Arc.container().instance(MorphiumMetricsBinder.class) in a try-with-resources
block. His bytecode reading of AbstractInstanceHandle#destroy() (the method
that actually tears down a bean's contextual instance) was correct.

Independently re-verified one level up the call chain (InstanceHandle#close()'s
default method, which decides WHETHER destroy() runs): against arc-3.32.3.jar,
close() only calls destroy() for a non-@Dependent-scoped bean when
ArcContainer#strictCompatibility() is true (default: false, and Quarkus'
own docs recommend leaving it false). MorphiumMetricsBinder is
@ApplicationScoped and this repo never sets quarkus.arc.strict-compatibility,
so in the actual default configuration the try-with-resources code did NOT
destroy the bean on every call -- confirmed empirically by running this
commit's new QuarkusUnitTest against the pre-fix code (checked out from
5701c3e) and observing it pass identically, then explaining why via a
ClientProxy identity check: get() on an @ApplicationScoped bean's
InstanceHandle returns the same ClientProxy on every independent lookup,
which is what let registeredMeters survive across try-with-resources calls
in practice.

Fixed anyway, because it is still strictly more correct and removes a latent
dependency on strictCompatibility() staying false forever and on
MorphiumMetricsBinder's scope never changing to @dependent: introduces
MorphiumProducer#metricsBinderHandle(), a lazily-resolved InstanceHandle
field reused across buildMorphium()/onStop() (including across dev-mode
hot-reload cycles), released only once in onStop() on final application
shutdown.

Also fixes the two non-blocking review points: adds the CHANGELOG entry for
the CHITSPERC/CMISSPERC NaN fix (commit 5701c3e, already on this branch)
under [Unreleased] -> Fixed, and replaces an inline java.util.HashSet<>()
with an import in MorphiumProcessorObservabilityTest.

Adds a new io.quarkus:quarkus-junit-internal test dependency (version
resolved via the existing quarkus-bom import, no explicit pin) to the
deployment module -- the QuarkusUnitTest infrastructure this fix's own
verification needed and that a plain unit test (which sets
binder.registry directly, bypassing CDI entirely) cannot reach. Also adds
the java.util.logging.manager system property to the deployment module's
surefire configuration (same property this repo's integration-tests module
already sets), required for QuarkusUnitTest's own logging bootstrap.

Verified: new MorphiumMetricsBinderLifecycleTest (2 tests) proves meters
survive two full connect/disconnect cycles against a real ArC container with
no duplicates and no leaks, and documents the ClientProxy mechanism
directly. Full runtime+deployment test suite (99 tests across both modules)
green, 0 regressions.
@Bardioc1977

Copy link
Copy Markdown
Collaborator Author

Thanks for the deep review — went through the blocking finding with the same level of rigor.

Bytecode-verified your destroy() reading independently, one level up: InstanceHandle#close()'s default method (which decides whether destroy() runs) only calls it for a non-@Dependent-scoped bean when ArcContainer#strictCompatibility() is true — verified against InstanceHandle.class in arc-3.32.3.jar. This repo never sets quarkus.arc.strict-compatibility (default false, and Quarkus' own docs recommend leaving it that way), so in the actual default configuration the try-with-resources code did not destroy MorphiumMetricsBinder on every call.

Confirmed empirically before drawing that conclusion, not just from the bytecode: ran a new QuarkusUnitTest (two full connect/disconnect cycles against a real ArC container, asserting exact meter counts) against the pre-fix code checked out from 5701c3e16, and it passed identically to the post-fix code. Traced the reason to get() on an @ApplicationScoped bean's InstanceHandle returning the same ClientProxy on every independent lookup — which is what let registeredMeters survive across the try-with-resources calls in practice.

Fixed anywaye7435811 — because it's still strictly more correct and removes a latent dependency on strictCompatibility() staying false forever and on the bean's scope never changing: MorphiumProducer now has a lazily-resolved, reused InstanceHandle field, released only once in onStop() on final shutdown. Also fixed the two non-blocking points (CHANGELOG entry, HashSet import), and added the QuarkusUnitTest you asked for (MorphiumMetricsBinderLifecycleTest) — it also documents the ClientProxy mechanism directly so this stays regression-proof regardless of which of the two mechanisms is doing the protecting on a given Quarkus/ArC version.

Full runtime+deployment suite (99 tests) green.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The change introduces non-trivial CDI InstanceHandle lifecycle, hot-reload deregistration, native-image gating, and a core statistics change that warrant human verification despite only minor issues being found.

Pull request overview

This PR adds an optional Micrometer observability module to the quarkus-morphium extension. It exposes MongoDB connection-pool/driver statistics and cache/write-buffer levels as Micrometer meters, gated on Capability.METRICS so applications without a metrics extension see no behavioural change and inherit no new dependency. It follows the extension's existing "optional dependency + Capabilities gate" pattern (as used for Jackson/JSON-B). It also fixes an unrelated NaN bug in morphium-core's cache-hit-ratio statistics discovered while wiring up the module.

Changes:

  • New MorphiumMetricsBinder registers Gauges (pool/in-use/waiting, cache/write-buffer) and FunctionCounters (borrowed/released/errors/failovers) against the injected MeterRegistry, with hot-reload-safe deregistration; wired into MorphiumProducer.buildMorphium()/onStop() via a lazily-resolved, reused InstanceHandle, and gated at build time by a new MorphiumProcessor.registerObservability build step plus a runtime quarkus.morphium.observability.enabled kill-switch.
  • Statistics.java now guards the CHITS/(CHITS+CMISS) division against 0/0, reporting 0.0 instead of NaN and reading each AtomicLong once.
  • Optional quarkus-micrometer(+-deployment) dependencies, an architecture plan doc, a CHANGELOG entry, and unit/lifecycle tests.
File summaries
File Description
runtime/.../observability/MorphiumMetricsBinder.java Core binder registering gauges/counters and deregistering on close.
runtime/.../observability/MorphiumObservabilityConfig.java Nested config exposing the enabled kill-switch.
runtime/.../MorphiumRuntimeConfig.java Wires the nested observability config.
runtime/.../MorphiumProducer.java Registers/deregisters metrics via a cached InstanceHandle.
deployment/.../MorphiumProcessor.java Capability.METRICS-gated build step registering the binder bean.
runtime/pom.xml, deployment/pom.xml Optional Micrometer deps + test/logging setup.
morphium-core/.../Statistics.java Fixes NaN cache-hit-ratio before any cached read.
.../StatisticsTest.java, .../MorphiumMetricsBinderTest.java, .../MorphiumProcessorObservabilityTest.java, .../MorphiumMetricsBinderLifecycleTest.java Unit + lifecycle tests.
docs/architecture/observability-module-plan.md, CHANGELOG.md Design plan and changelog entry.
Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@@ -0,0 +1,208 @@
package de.caluga.morphium.quarkus.deployment;
* Deregisters every {@link Meter} this binder has registered so far. Called from
* {@code MorphiumProducer#onStop()} alongside {@code instance.close()}, and must also be
* called before a subsequent {@link #bindTo(Morphium, String)} on a dev-mode hot-reload to avoid
* leaving stale gauges referencing a superseded {@link Morphium} instance registered
…d in Javadoc

Two minor Copilot review findings on PR sboesebeck#332:
- MorphiumMetricsBinderLifecycleTest.java was missing the Apache 2.0 license
  header every other test file in this package carries.
- MorphiumMetricsBinder#close()'s Javadoc had a dangling trailing word
  ("...referencing a superseded Morphium instance registered" -> removed
  the stray "registered").

Full runtime+deployment suite (99 tests) green, 0 regressions.
@Bardioc1977

Copy link
Copy Markdown
Collaborator Author

Two minor Copilot findings on the re-review, both fixed in f92c60cd:

  • MorphiumMetricsBinderLifecycleTest.java was missing the Apache 2.0 license header every other test file in this package carries.
  • MorphiumMetricsBinder#close()'s Javadoc had a dangling trailing word ("...referencing a superseded Morphium instance registered").

Full runtime+deployment suite (99 tests) green.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@sboesebeck

Copy link
Copy Markdown
Owner

All addressed, thanks.

Your counter-evidence on the InstanceHandle point is solid. Running the test against 5701c3e and showing it passes identically is exactly the right way to settle a question like that. I read destroy() correctly but not far enough: that close() only gets there under strict-compatibility or @dependent scope escaped me. The fix is still the better shape, so it stays.

Merging.

@sboesebeck sboesebeck left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking finding resolved, and the follow-up analysis was better than my original one. Good to go.

@sboesebeck
sboesebeck merged commit 59a5e04 into sboesebeck:develop Aug 24, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants