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-observation vs fun-tracing — Both modules instrument Try/Result operations, but they target different Micrometer layers:

ModuleAPI usedWhat you get
fun-observationMicrometer Observation APIMetrics and spans from one call — the recommended choice
fun-tracingMicrometer Tracing APISpans only — use when you already depend on micrometer-tracing directly

Prefer fun-observation for new projects. It sits at a higher abstraction layer and will automatically emit metrics, spans, or both depending on the handlers in your ObservationRegistry — with no code changes required when you add or change backends.

Adding the dependency

// Gradle
implementation("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-observation declares micrometer-core as compileOnly. 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:

SignalWhen
Timer {name}_secondsAlways — tagged outcome=success|failure
Span named {name}When a tracing bridge (micrometer-tracing-bridge-otel or -brave) is configured
outcome=success|failureAlways — low-cardinality key on both metric and span
exception=<label>On failure — low-cardinality key on both metric and span
Observation marked as errorOn 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 exception key uses getClass().getSimpleName(). When arbitrary third-party exceptions can appear at runtime, this produces an unbounded number of distinct values — a violation of Micrometer’s lowCardinalityKeyValue contract (≤ 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.charge
  • inventory.reserve
  • order.place
  • db.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 context
DmxObservation dmx = DmxObservation.of(observationRegistry);
// metrics + spans in one call — name becomes the observation name
Try<Response> result = dmx.observeTry("http.client.get",
() -> httpClient.get(url)
);
result
.onSuccess(r -> render(r))
.onFailure(ex -> renderError(ex));
// Same with Result
Result<User, Throwable> user = dmx.observeResult("db.user.find",
() -> userRepo.findById(id)
);

Signals recorded for each call (dispatched through registered ObservationHandlers):

SignalWhen
Timer {name}Always — tagged outcome=success|failure
Span named {name}When a tracing bridge is configured
outcome=success|failureAlways — low-cardinality key on both metric and span
exception=<SimpleClassName>On failure — low-cardinality key on both metric and span
Observation marked as errorOn 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 → execute
Try<Receipt> result = DmxObserved.of("payment.charge")
.registry(observationRegistry)
.observeTry(() -> stripe.charge(amount));
// Same with Result
Result<User, Throwable> user = DmxObserved.of("db.user.find")
.registry(observationRegistry)
.observeResult(() -> userRepo.findById(id));

DmxObserved is a mutable builder — call observeTry or observeResult once at the end. For repeated instrumentation of the same operation, prefer DmxObservation.of(registry) and call observeTry directly.

Choosing DmxObservation vs DmxObserved

ScenarioRecommendation
Spring Boot with fun-spring-bootInject 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 methodDmxObserved.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;
@Service
public 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:

  1. micrometer-core and fun-observation are on the classpath (@ConditionalOnClass).
  2. An ObservationRegistry bean is present in the context (@ConditionalOnBean) — Spring Boot registers one automatically via ObservationAutoConfiguration.

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-configuration

Replacing the bean

The bean is guarded by @ConditionalOnMissingBean. Declare your own @Bean of type DmxObservation and the auto-configuration backs off:

@Bean
DmxObservation 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:

@Service
public 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 CoreSpring BootStatus
1.16.x3.5.xtested
1.15.x3.4.xtested
1.14.x3.3.xtested
1.13.x3.2.xtested
1.12.x3.1.xtested
1.11.x3.0.xtested
1.10.x3.0.xtested

To test locally against a specific version:

Terminal window
./gradlew :observation:test -PmicrometerVersion=1.15.9