Observability with Micronaut: metrics, traces, and the context-propagation trap
Monitoring tells you that something is wrong. Observability is what lets you explain why, including for failure modes you never saw coming, without shipping a new build just to add a log line. The working test is simple. When a single request is slow or wrong in production, can you reconstruct what it did, across services, from the telemetry you already emit? If the honest answer involves SSHing into a box, you don’t have observability yet.
Micronaut is a good fit for this. Because dependency injection and most of the framework’s mechanics happen at compile time, instrumentation is wired at build time instead of being bolted on by a runtime Java agent. You get low overhead, fast startup, and telemetry that survives GraalVM native compilation. That model has one downside. Micronaut leans heavily on reactive and asynchronous execution, and the moment your code crosses a thread boundary the framework didn’t set up, the trace context vanishes. Most of this article is the easy part. The section on context propagation is where things actually go wrong in production: it explains traces that look perfect in a demo and come back full of holes under load.
The shape of the stack
Three signals, three libraries, one set of endpoints.
- Metrics come from Micrometer, exposed on a Prometheus scrape endpoint. Aggregated, cheap, always on. They answer “how is the system behaving overall”.
- Traces come from OpenTelemetry, exported over OTLP to a collector. Per-request causal chains across service boundaries. They answer “what did this particular request do”.
- Logs are your existing Logback setup, made useful by stamping every line with the trace and span IDs, so that a log search and a trace become the same investigation.
Micronaut exposes the operational surface through its management module: /health, /metrics, /prometheus, /info, /loggers. These aren’t the product. They’re how the product gets scraped, probed, and tuned at runtime.
Metrics: Micrometer and a Prometheus endpoint
Add the core metrics module and the Prometheus registry. Micrometer is a facade. You pick the registry that matches your backend, and the instrumentation code never changes.
// build.gradle.kts
implementation("io.micronaut.micrometer:micronaut-micrometer-core")
implementation("io.micronaut.micrometer:micronaut-micrometer-registry-prometheus")
implementation("io.micronaut:micronaut-management")
# application.yml
micronaut:
application:
name: orders-api # becomes the 'application' tag on every meter
metrics:
enabled: true
binders: # JVM, system and pool metrics, on by default. Keep them.
jvm.enabled: true
web.enabled: true # http.server.requests, http.client.requests
export:
prometheus:
enabled: true
step: PT1M
descriptions: true
endpoints:
prometheus:
sensitive: false # the scrape endpoint must be reachable by Prometheus
health:
enabled: true
details-visible: ANONYMOUS
Out of the box that already gives you the meters that matter for most incidents. There’s http.server.requests, a timer tagged by URI, method and status, which is the basis of every latency and error-rate dashboard you’ll build. There’s http.client.requests for outbound calls, JVM heap and GC, thread states, and connection-pool gauges if you’re on HikariCP or a Micronaut-managed HTTP client. Before you write a single custom metric, you can build the RED dashboard (Rate, Errors, Duration) entirely from http.server.requests.
Custom metrics come in two flavours. For ad-hoc instrumentation, inject the MeterRegistry and record directly:
@Singleton
class OrderService {
private final Counter ordersPlaced;
private final MeterRegistry registry;
OrderService(MeterRegistry registry) {
this.registry = registry;
this.ordersPlaced = registry.counter("orders.placed", "channel", "web");
}
void place(Order order) {
// Timer.record measures wall-clock around the block
registry.timer("orders.persist").record(() -> repository.save(order));
ordersPlaced.increment();
}
}
For the common case of timing a method, the annotation is cleaner and reads better at the call site:
@Timed(value = "orders.checkout", description = "End-to-end checkout time", percentiles = {0.5, 0.95, 0.99})
public Receipt checkout(Cart cart) { ... }
One thing about percentiles trips almost everyone up. Writing percentiles = {0.95} computes the quantile inside the instance, and that number can’t be aggregated across replicas. Averaging two p95s is meaningless. If you run more than one instance, which you do, and you want a fleet-wide p95, publish a histogram instead with histogram = true (or publishPercentileHistogram) and let Prometheus compute the quantile from the buckets. Per-instance percentiles are fine for a quick look at one pod. They start lying the moment you sum across the fleet.
Keep tag cardinality under control
The fastest way to take down your metrics backend, and run up the invoice, is an unbounded tag value. Every distinct combination of tag values is a separate time series, stored forever. order.id, user.id, a raw URL with path parameters, a full exception message: these are high-cardinality and have no business being metric tags. They belong on a trace or in a log, where per-event detail is the whole point.
The discipline is simple and worth enforcing in review. Metric tags must come from a small, bounded set you know in advance. status (a handful of HTTP codes), region (a fixed list), outcome (success or failure), channel: all fine. Anything you can’t enumerate ahead of time is a red flag. Micronaut helps here by tagging http.server.requests with the templated URI (/orders/{id}) rather than the resolved path, so /orders/1 and /orders/2 collapse into one series instead of exploding into millions. Preserve that property in your own instrumentation.
Tracing with OpenTelemetry
Micronaut’s tracing integration speaks OpenTelemetry natively. Add the HTTP instrumentation and an OTLP exporter:
implementation("io.micronaut.tracing:micronaut-tracing-opentelemetry-http")
runtimeOnly("io.opentelemetry:opentelemetry-exporter-otlp")
Configuration follows the OpenTelemetry SDK conventions, which means the production-friendly knobs are environment variables. No rebuild to repoint your collector or change sampling:
OTEL_SERVICE_NAME=orders-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1 # head-sample 10% of root traces
# application.yml: keep health checks and the scrape endpoint out of your traces
otel:
exclusions:
- /health
- /prometheus
Once that’s in place, inbound HTTP requests, outbound calls through the Micronaut HTTP client, and W3C traceparent propagation between services all happen on their own. The framework opens a server span per request, links it to the incoming traceparent if there is one, and injects the header on outgoing calls so the next service continues the same trace.
You add domain-level spans where the automatic instrumentation can’t see, around a business operation that spans several collaborators, or an expensive computation you want to attribute:
@NewSpan("inventory.reserve")
public Reservation reserve(@SpanTag("sku") String sku, int quantity) {
// a new child span named "inventory.reserve", tagged with the SKU,
// nested under whatever span is currently active
}
@NewSpan starts a child span. @SpanTag lifts a method argument onto it as an attribute. @ContinueSpan adds tags to the current span without creating a new one. This is exactly where high-cardinality detail should go: a SKU, an order ID, a tenant, anything you’ll want to filter or pivot on when you’re staring at one slow trace. The rule is the inverse of metrics. Traces are per-request, so per-request identifiers aren’t just acceptable here, they’re the entire point.
A word on sampling. parentbased_traceidratio is the sane default. It respects an upstream sampling decision, so a trace is either fully captured or fully dropped across all services, with no half-traces, and it head-samples roots at the ratio you pick. Start at 10% under real traffic and adjust from there. If what you want is “always keep the interesting ones”, that’s tail-sampling, and it lives in the collector rather than the app. The application can’t know a trace is interesting until the trace is finished.
The context-propagation trap
This is where traces break, with no error and no warning.
A span and the log MDC live in a context that Micronaut propagates for you, but only across boundaries it controls. Micronaut 4 models this explicitly with PropagatedContext. As long as you stay on the request thread, or use a Micronaut-managed executor, the active span and MDC follow your code on their own. Reactive pipelines (Mono, Flux, Publisher) are instrumented, and @ExecuteOn(TaskExecutors.BLOCKING) offloads to a managed pool that keeps the context.
The trap is any thread boundary the framework didn’t set up. The classic offender:
// BROKEN: the span and trace_id do NOT cross into this thread
CompletableFuture.supplyAsync(() -> chargeCard(order)); // ForkJoinPool.commonPool
// Also broken: a hand-rolled thread, or a raw ExecutorService you created yourself
new Thread(() -> sendReceipt(order)).start();
Inside that lambda, Span.current() is the no-op root span, the MDC is empty, and any work you do is silently detached from the trace. No error, no warning. The trace just stops at the boundary, and the slow downstream call you were trying to find never shows up. If a Micronaut trace is mysteriously incomplete, this is almost always why.
The fix is to capture the context on the calling thread and reinstate it inside the task:
PropagatedContext context = PropagatedContext.get();
CompletableFuture.supplyAsync(() -> {
try (PropagatedContext.Scope ignored = context.propagate()) {
return chargeCard(order); // now nested under the right span, MDC restored
}
});
Better still, don’t hand-roll executors at all. Inject a Micronaut-managed one, or annotate the method with @ExecuteOn, so propagation is handled for you, and keep the manual propagate() for the genuinely external cases: a third-party callback, a message-listener thread you don’t own. The principle is worth internalising because it isn’t specific to Micronaut. Thread-local context does not follow work across an arbitrary thread hop unless something explicitly carries it. Every tracing system on the JVM has some version of this rule.
Correlating the three signals
Metrics, traces and logs are only worth having together if you can pivot between them. Two cheap changes get you most of the way there.
First, put the trace and span IDs in every log line. The OpenTelemetry Logback MDC instrumentation populates the MDC with trace_id, span_id and trace_flags, and you reference them in the pattern:
<!-- logback.xml -->
<dependency>io.opentelemetry.instrumentation:opentelemetry-logback-mdc-1.0</dependency>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%X{trace_id:-},%X{span_id:-}] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
Now a log line carries the trace ID, and a trace carries the time window and service, so “this error in the logs” and “this span in the trace UI” are one click apart. In a structured (JSON) log pipeline, emit trace_id as a real field and your log backend can deep-link straight to the trace.
Second, exemplars. A Prometheus exemplar attaches a trace ID to a single observation inside a histogram bucket, so when a latency dashboard shows a spike in the p99 bucket you can jump from that bucket to an actual trace that landed in it. The Micrometer Prometheus registry emits exemplars when you give it a SpanContextSupplier that reads the current OpenTelemetry span:
@Singleton
class OtelSpanContextSupplier implements SpanContextSupplier {
public String getTraceId() { return Span.current().getSpanContext().getTraceId(); }
public String getSpanId() { return Span.current().getSpanContext().getSpanId(); }
public boolean isSampled() { return Span.current().getSpanContext().isSampled(); }
}
With exemplars in place, a rising p99 takes minutes to chase down: spike, exemplar, trace, the exact slow span.
Health checks that mean something
Micronaut’s /health aggregates HealthIndicator beans. The built-ins cover the disk, the JDBC pool, and configured clients. Write your own for the dependencies that decide whether your service can actually do its job:
@Singleton
class PaymentGatewayHealth implements HealthIndicator {
public Publisher<HealthResult> getResult() {
return Mono.fromCallable(() -> gateway.ping()
? HealthResult.builder("payment-gateway", HealthStatus.UP).build()
: HealthResult.builder("payment-gateway", HealthStatus.DOWN).build());
}
}
In Kubernetes, one distinction matters: liveness versus readiness. Liveness asks “is the process wedged and in need of a restart”, so keep it cheap and dependency-free, or a flaky database will trigger a pointless restart loop. Readiness asks “should this instance receive traffic right now”, and here it’s correct to fail when a critical downstream is unreachable, so the pod is pulled from the load balancer instead of serving errors. Conflate the two and a transient outage turns into a self-inflicted crash loop. Micronaut exposes both readiness and liveness probes; map them to the right Kubernetes probes and resist the urge to make liveness “thorough”.
A note on native image
If you compile to a GraalVM native image, which is a large part of why teams reach for Micronaut in the first place, observability mostly just works, precisely because the instrumentation is resolved at build time rather than through a runtime agent. The official Micrometer and OpenTelemetry integrations ship the reachability metadata the native compiler needs. Two caveats, though. A third-party meter binder or exporter that relies on reflection may need hints in your reachability-metadata (or @ReflectionConfig), and you should prefer the pull-based Prometheus scrape, which fits a native, short-startup process more naturally than a push-based one. This is a genuine edge Micronaut has over an agent-based setup. There’s no agent to attach, so there’s nothing that fails to attach inside a native binary.
What to actually instrument
Don’t instrument everything. Start from the questions you’ll be asked during the next incident. The RED method gives you the request-handling view almost for free from http.server.requests: Rate, Errors, Duration per endpoint. The USE method (Utilization, Saturation, Errors) covers your resources, and the JVM and pool binders already feed it. Add custom metrics only for the business events you’d otherwise have no signal on: orders placed, payments declined, queue depth. Add custom spans only around the operations whose latency you can’t attribute any other way. Everything else is noise you’ll pay to store and then ignore.
The cost is small: a few milliseconds of overhead at sane sampling, some storage, and the discipline to keep cardinality bounded and signals correlated. In exchange, the next incident starts with a query in your tools instead of an SSH session. On Micronaut, all of this is wired at compile time and survives native compilation without an agent, with the one condition this article keeps returning to: respect the context boundary. Get propagation right, correlate the three signals, bound your cardinality, and the telemetry holds up on the day you need it.