Spring Boot Observability: Metrics, Tracing, and Logs That Actually Help

Spring Boot 3.x has strong observability support built in. Spring Boot Actuator, Micrometer for metrics, Micrometer Tracing (built on OpenTelemetry), and structured logging all work out of the box with minimal configuration.

The gap between “instrumented” and “observable” is wider than it looks. Adding dependencies and enabling endpoints is instrumentation. Being able to answer “why are 3% of requests failing between the payment service and the inventory service, and since when?” is observability.

The Three Pillars in Practice

Metrics With Micrometer

Micrometer is Spring Boot 3’s metrics API. It abstracts over backends — Prometheus, Datadog, InfluxDB, CloudWatch — so you write one instrumentation and push to any backend.

Spring Boot auto-configures many metrics: JVM memory, thread pools, HTTP request counts and durations, database connection pool usage, cache hit rates.

The useful custom metrics are the business ones that auto-configuration can’t know about:

@Service
public class OrderService {

    private final MeterRegistry registry;
    private final Counter ordersPlaced;
    private final Counter ordersRejected;
    private final Timer orderProcessingTimer;

    public OrderService(MeterRegistry registry) {
        this.registry = registry;
        this.ordersPlaced = Counter.builder("orders.placed")
            .description("Number of orders placed successfully")
            .register(registry);
        this.ordersRejected = Counter.builder("orders.rejected")
            .description("Number of orders rejected")
            .tag("reason", "unknown")
            .register(registry);
        this.orderProcessingTimer = Timer.builder("orders.processing.duration")
            .description("Time to process an order end-to-end")
            .register(registry);
    }

    public OrderId placeOrder(PlaceOrderCommand command) {
        return orderProcessingTimer.record(() -> {
            try {
                OrderId id = doPlaceOrder(command);
                ordersPlaced.increment();
                return id;
            } catch (InsufficientInventoryException e) {
                registry.counter("orders.rejected", "reason", "inventory").increment();
                throw e;
            }
        });
    }
}

Good metrics are:

  • Named meaningfully (not my.counter)
  • Tagged usefully (reason, status, endpoint) — tags enable filtering and grouping
  • Aligned with business operations, not just technical calls

The Prometheus format for these metrics:

# HELP orders_placed_total Number of orders placed successfully
# TYPE orders_placed_total counter
orders_placed_total 1423.0

# HELP orders_rejected_total Number of orders rejected
orders_rejected_total{reason="inventory"} 47.0
orders_rejected_total{reason="payment_failed"} 12.0

# HELP orders_processing_duration_seconds Time to process an order
orders_processing_duration_seconds_bucket{le="0.05"} 892
orders_processing_duration_seconds_bucket{le="0.1"} 1201
orders_processing_duration_seconds_bucket{le="0.5"} 1415

Distributed Tracing With Micrometer Tracing

Spring Boot 3 uses Micrometer Tracing, which wraps OpenTelemetry and Brave. Traces are composed of spans — each span represents a unit of work within a request.

Dependencies for OpenTelemetry + Zipkin/Jaeger:

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>

Configuration:

management:
  tracing:
    sampling:
      probability: 1.0  # 100% in dev; 0.1-0.2 in production
  otlp:
    tracing:
      endpoint: http://tempo:4318/v1/traces  # Grafana Tempo, Jaeger, etc.

Spring Boot auto-instruments:

  • Incoming HTTP requests (creates root span)
  • Outgoing HTTP calls via RestClient or WebClient (creates child spans)
  • @Scheduled methods
  • Message listener containers

Custom spans for significant operations:

@Service
public class InventoryService {

    private final Tracer tracer;

    public ReservationId reserveItems(OrderId orderId, List<OrderLine> items) {
        Span span = tracer.nextSpan()
            .name("inventory.reserve")
            .tag("order.id", orderId.value())
            .tag("item.count", String.valueOf(items.size()))
            .start();

        try (Tracer.SpanInScope scope = tracer.withSpan(span)) {
            // Business logic
            return doReserve(orderId, items);
        } catch (Exception e) {
            span.error(e);
            throw e;
        } finally {
            span.end();
        }
    }
}

A trace for a slow order placement request shows:

[Order API: POST /orders] 285ms
  ├── [inventory.reserve] 45ms
  ├── [payment.charge] 198ms      ← Bottleneck visible here
  │     └── [external.payment-gateway] 185ms
  └── [OrderRepository.save] 12ms

Without distributed tracing, you’d know the request was slow. With it, you know exactly which service and which operation is the bottleneck.

Structured Logging

Logs are the most accessible signal in production — every engineer knows how to read them. The difference between useful logs and noise is structure.

Spring Boot with Logback or Log4j2 supports JSON-structured logging:

<!-- logback-spring.xml — JSON format for production -->
<springProfile name="production">
    <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
    </appender>
    <root level="INFO">
        <appender-ref ref="JSON"/>
    </root>
</springProfile>

JSON log output includes trace context automatically with Micrometer Tracing:

{
  "timestamp": "2025-03-31T14:23:45.123Z",
  "level": "ERROR",
  "logger": "c.e.o.OrderService",
  "message": "Failed to reserve inventory for order ORD-001",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "spanId": "00f067aa0ba902b7",
  "userId": "user-42",
  "orderId": "ORD-001",
  "exception": "InsufficientInventoryException: Product PROD-99 has 0 units available"
}

The traceId connects this log entry to the distributed trace. Given an error report from a user, you have a chain: error metric → trace → logs.

What Useful Observability Looks Like

Instrumentation is not observability. Observability is the ability to answer questions about your system’s behavior from the outside.

Questions you should be able to answer:

“Is the service healthy right now?” → Health check endpoint, error rate dashboard, latency p99 over last 5 minutes.

“Why is this user getting errors?” → Trace the request by user ID through all services, find the failing span, read the associated logs.

“What changed 20 minutes ago that caused the error rate spike?” → Correlate deployment events with error rate metrics, trace sample from the spike window.

“Which endpoint is slowest?” → HTTP request duration histogram by endpoint.

“Is the database connection pool saturated?” → Connection pool metrics (active, idle, pending).

The Actuator baseline:

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus
  endpoint:
    health:
      show-details: when-authorized
      probes:
        enabled: true  # /actuator/health/liveness, /actuator/health/readiness

Kubernetes uses the liveness and readiness probes. The readiness probe should report unhealthy when the service is not ready to receive traffic (e.g., DB connection pool exhausted).

Correlation IDs

In a microservices architecture, a user request travels through multiple services. Without a correlation ID that propagates across all of them, connecting logs from different services to the same user request requires guesswork.

With Micrometer Tracing, the trace ID serves as the correlation ID and propagates automatically via HTTP headers (traceparent).

For requests that don’t use standard HTTP propagation, propagate explicitly:

// Pass trace context in message headers
kafkaTemplate.send(
    MessageBuilder.withPayload(orderPlaced)
        .setHeader("traceparent", currentTraceContext.traceId())
        .build()
);

// Restore trace context in consumer
@KafkaListener(topics = "orders.placed")
public void onOrderPlaced(@Payload OrderPlaced event,
                          @Header("traceparent") String traceParent) {
    try (var scope = tracing.propagation()
        .extractor(Map::get)
        .extract(Map.of("traceparent", traceParent))) {
        // Business logic runs with restored trace context
        processOrder(event);
    }
}

What Not to Instrument

Instrumenting everything produces noise. Engineers stop reading dashboards with 50 panels. Alerts on metrics nobody understands get ignored.

Instrument:

  • Request rate, error rate, latency for every service entry point
  • Resource utilization (DB connection pools, thread pools, memory)
  • Business operations (orders placed, payments processed, users created)
  • External service call success rate and latency by dependency

Don’t instrument:

  • Every internal method call (this is profiling, not observability)
  • Every log statement (log at appropriate levels, don’t log everything)
  • Metrics that have no action associated with them

The operational test: for every metric and alert you create, ask “what would I do if this fires?” If the answer is “I’d look at it and probably ignore it,” don’t create the metric or alert.

spring-bootobservabilityopentelemetrymicrometertracingmetrics
← All articles