Micrometer Observation Integration
The fun-observation module instruments Try and Result executions using the
Micrometer Observation API (micrometer-core ≥ 1.10). A single
observeTry or observeResult call produces both metrics and distributed
tracing spans, dispatched through whichever ObservationHandlers are registered
— no manual counter.increment(), timer.record(), or Span.start() calls
required in your business logic.
The module is optional — the core fun library has no runtime dependency on
Micrometer. fun-observation declares micrometer-core as compileOnly; you
bring your own version and backend. Any version from 1.10.x through 1.16.x
is supported (see version compatibility below).
fun-observationvsfun-tracing— Both modules instrumentTry/Resultoperations, but they target different Micrometer layers:
Module API used What you get fun-observationMicrometer Observation API Metrics and spans from one call — the recommended choice fun-tracingMicrometer Tracing API Spans only — use when you already depend on micrometer-tracingdirectlyPrefer
fun-observationfor new projects. It sits at a higher abstraction layer and will automatically emit metrics, spans, or both depending on the handlers in yourObservationRegistry— with no code changes required when you add or change backends.
Adding the dependency
// Gradleimplementation("codes.domix:fun-observation:LATEST_VERSION")// Micrometer — bring your own version (1.10.x – 1.16.x)implementation("io.micrometer:micrometer-core:1.16.5")// Optional: add a tracing bridge to also get distributed tracing spans// implementation("io.micrometer:micrometer-tracing-bridge-otel:1.6.5")<!-- Maven --><dependency> <groupId>codes.domix</groupId> <artifactId>fun-observation</artifactId> <version>LATEST_VERSION</version></dependency><!-- Micrometer — bring your own version (1.10.x – 1.16.x) --><dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-core</artifactId> <version>1.16.5</version></dependency><!-- Optional: add a tracing bridge to also get distributed tracing spans --><!--<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-otel</artifactId> <version>1.6.5</version></dependency>-->
fun-observationdeclaresmicrometer-coreascompileOnly. You bring your own version and backend. A tracing bridge is optional — without one, only metrics are recorded.
Signals recorded
For every instrumented call, the following signals are dispatched through the
registered ObservationHandlers:
| Signal | When |
|---|---|
Timer {name}_seconds | Always — tagged outcome=success|failure |
Span named {name} | When a tracing bridge (micrometer-tracing-bridge-otel or -brave) is configured |
outcome=success|failure | Always — low-cardinality key on both metric and span |
exception=<label> | On failure — low-cardinality key on both metric and span |
| Observation marked as error | On failure — recorded via observation.error(cause) |
The observation is always stopped before the method returns, even when the supplier throws.
Exception key cardinality
Warning: The default
exceptionkey usesgetClass().getSimpleName(). When arbitrary third-party exceptions can appear at runtime, this produces an unbounded number of distinct values — a violation of Micrometer’slowCardinalityKeyValuecontract (≤ 100 distinct values per key). This causes excessive series growth in Prometheus, Zipkin, and similar backends.
In production, always supply an explicit classifier that maps every reachable exception type to one of a small, fixed set of labels:
DmxObservation dmx = DmxObservation.of(observationRegistry, cause -> switch (cause) { case IOException _ -> "io"; case TimeoutException _ -> "timeout"; case IllegalStateException _ -> "state"; default -> "other"; });The same option is available on the DmxObserved builder:
DmxObserved.of("payment.charge") .registry(observationRegistry) .exceptionClassifier(cause -> cause instanceof IOException ? "io" : "other") .observeTry(() -> stripe.charge(amount));Observation naming convention
Use stable, low-cardinality operation names. A practical convention is:
<bounded-context>.<operation>
Examples:
payment.chargeinventory.reserveorder.placedb.user.find
Avoid embedding IDs or user input in observation names — those belong in log fields or baggage, not in metric/span names, as high-cardinality names generate excessive entries in Prometheus, Zipkin, Jaeger, and similar backends.
DmxObservation
The primary entry point. Create one instance per ObservationRegistry (typically
application-scoped) and call observeTry or observeResult at each
instrumentation point.
import dmx.fun.Result;import dmx.fun.Try;import dmx.fun.observation.DmxObservation;import io.micrometer.observation.ObservationRegistry;
// One-time setup — typically injected from the application contextDmxObservation dmx = DmxObservation.of(observationRegistry);
// metrics + spans in one call — name becomes the observation nameTry<Response> result = dmx.observeTry("http.client.get", () -> httpClient.get(url));
result .onSuccess(r -> render(r)) .onFailure(ex -> renderError(ex));
// Same with ResultResult<User, Throwable> user = dmx.observeResult("db.user.find", () -> userRepo.findById(id));Signals recorded for each call (dispatched through registered ObservationHandlers):
| Signal | When |
|---|---|
Timer {name} | Always — tagged outcome=success|failure |
Span named {name} | When a tracing bridge is configured |
outcome=success|failure | Always — low-cardinality key on both metric and span |
exception=<SimpleClassName> | On failure — low-cardinality key on both metric and span |
| Observation marked as error | On failure |
DmxObserved — fluent builder
When you prefer to configure name and registry in a single chain — for example
inside a factory method or when wiring the observation name and registry at
different call sites — use the DmxObserved fluent builder:
import dmx.fun.Try;import dmx.fun.Result;import dmx.fun.observation.DmxObserved;
// Chain observation name → registry → executeTry<Receipt> result = DmxObserved.of("payment.charge") .registry(observationRegistry) .observeTry(() -> stripe.charge(amount));
// Same with ResultResult<User, Throwable> user = DmxObserved.of("db.user.find") .registry(observationRegistry) .observeResult(() -> userRepo.findById(id));
DmxObservedis a mutable builder — callobserveTryorobserveResultonce at the end. For repeated instrumentation of the same operation, preferDmxObservation.of(registry)and callobserveTrydirectly.
Choosing DmxObservation vs DmxObserved
| Scenario | Recommendation |
|---|---|
Spring Boot with fun-spring-boot | Inject DmxObservation directly — auto-configured, no setup needed |
| Multiple operations in the same class (plain Spring) | DmxObservation.of(registry) — inject ObservationRegistry, wrap once in the constructor |
| Single ad-hoc operation or factory method | DmxObserved.of(name).registry(registry).observeTry(...) |
Real-world example
An order service where every step — inventory reservation, payment charge, and persistence — is instrumented as an independent observation. Each call automatically produces a timer metric and, when a tracing bridge is on the classpath, a child span. No manual instrumentation in the business logic.
import dmx.fun.Result;import dmx.fun.Try;import dmx.fun.observation.DmxObservation;import io.micrometer.observation.ObservationRegistry;
/** * An order service where each step is wrapped in its own named Observation. * * With Spring Boot 3.0+ and a tracing bridge on the classpath, each call * automatically produces: * - A timer: payment_charge_seconds_count{outcome="success"|"failure"} * - A child span: "payment.charge" tagged outcome + exception on failure * * No manual counter.increment(), Span.start(), or stopwatch calls needed. */public class OrderService {
private final DmxObservation dmx; private final PaymentGateway paymentGateway; private final InventoryClient inventoryClient; private final OrderRepository orderRepository;
public OrderService(ObservationRegistry registry, PaymentGateway paymentGateway, InventoryClient inventoryClient, OrderRepository orderRepository) { this.dmx = DmxObservation.of(registry); this.paymentGateway = paymentGateway; this.inventoryClient = inventoryClient; this.orderRepository = orderRepository; }
public Result<Order, String> placeOrder(PlaceOrderCommand cmd) {
// 1. Reserve inventory — observation: inventory.reserve Try<Reservation> reservation = dmx.observeTry("inventory.reserve", () -> inventoryClient.reserve(cmd.items()) ); if (reservation.isFailure()) { return Result.err("Inventory unavailable: " + reservation.getCause().getMessage()); }
// 2. Charge payment — observation: payment.charge Try<Receipt> receipt = dmx.observeTry("payment.charge", () -> paymentGateway.charge(cmd.amount(), cmd.paymentToken()) ); if (receipt.isFailure()) { inventoryClient.release(reservation.get()); return Result.err("Payment failed: " + receipt.getCause().getMessage()); }
// 3. Persist order — observation: order.place return dmx.observeResult("order.place", () -> orderRepository.save(Order.from(cmd, reservation.get(), receipt.get())) ) .mapError(ex -> "Failed to persist order: " + ex.getMessage()); }}Spring Boot integration
When fun-spring-boot and fun-observation are both on the classpath, Spring
Boot registers a DmxObservation bean automatically — no @Bean declaration or
manual wiring required. Inject it directly into your services:
import dmx.fun.Try;import dmx.fun.observation.DmxObservation;import org.springframework.stereotype.Service;
@Servicepublic class PaymentService {
private final DmxObservation dmx; // injected — auto-configured by fun-spring-boot
public PaymentService(DmxObservation dmx) { this.dmx = dmx; }
public Try<Receipt> charge(Amount amount, PaymentToken token) { return dmx.observeTry("payment.charge", () -> gateway.charge(amount, token) ); }}How it works
DmxFunObservationAutoConfiguration activates when:
micrometer-coreandfun-observationare on the classpath (@ConditionalOnClass).- An
ObservationRegistrybean is present in the context (@ConditionalOnBean) — Spring Boot registers one automatically viaObservationAutoConfiguration.
The DmxObservation bean is backed by that ObservationRegistry, so all signals
it records inherit the active observation context without any extra wiring.
Opting out
dmx.fun.observation.enabled=false # disable DmxObservation auto-configurationReplacing the bean
The bean is guarded by @ConditionalOnMissingBean. Declare your own @Bean of
type DmxObservation and the auto-configuration backs off:
@BeanDmxObservation dmxObservation(ObservationRegistry registry) { // Supply an explicit classifier in production — see "Exception key cardinality" above. return DmxObservation.of(registry);}Without fun-spring-boot
If you use plain Spring (no fun-spring-boot), construct DmxObservation manually
in your @Bean or constructor:
@Servicepublic class PaymentService {
private final DmxObservation dmx;
public PaymentService(ObservationRegistry registry) { // Supply an explicit classifier in production — see "Exception key cardinality" above. this.dmx = DmxObservation.of(registry); }}Version compatibility
The module is tested in CI against the following Micrometer versions on every
pull request that touches the observation/ module:
| Micrometer Core | Spring Boot | Status |
|---|---|---|
| 1.16.x | 3.5.x | tested |
| 1.15.x | 3.4.x | tested |
| 1.14.x | 3.3.x | tested |
| 1.13.x | 3.2.x | tested |
| 1.12.x | 3.1.x | tested |
| 1.11.x | 3.0.x | tested |
| 1.10.x | 3.0.x | tested |
To test locally against a specific version:
./gradlew :observation:test -PmicrometerVersion=1.15.9