Logs, Metrics, and Traces: The Three Pillars of Observability
A staff-engineer guide to structured logging, Micrometer metrics, and OpenTelemetry distributed tracing in Spring Boot 3.x.
Logs, Metrics, and Traces
"Observability" gets thrown around as a single concept, but it is really three distinct data types answering three distinct questions, produced by three distinct pipelines, at three distinct costs. Confusing them leads to the two most common observability failures in production systems: logging so much that you can't afford to keep it (or search it), and having no way to connect a slow request in a dashboard to the specific log lines that explain why. This guide covers each pillar individually and — more importantly — how they connect through correlation and trace IDs, because the connections are where the actual debugging value lives.
1. Three Pillars, Three Questions
| Pillar | Answers | Volume | Cost profile | Typical retention |
|---|---|---|---|---|
| Logs | "What exactly happened in this one request/event?" | Very high, one entry per event | Expensive to index & store | Days to a few weeks (hot), longer cold/archived |
| Metrics | "How is the system behaving in aggregate, over time?" | Low, fixed cardinality per interval | Cheap — pre-aggregated | Months to years (downsampled) |
| Traces | "Where did this specific request spend its time, across N services?" | High per-request, usually sampled | Moderate — sampled to control cost | Days to weeks |
A useful mental model: metrics tell you something is wrong, traces tell you where, logs tell you why. A latency dashboard (metrics) shows p99 spiking on POST /orders. A trace for a slow request shows the time is spent in a call to inventory-service. The logs for that specific trace ID show inventory-service was retrying a timed-out Redis call three times before giving up. Each pillar alone gives you a fragment; together they give you the incident.
2. Structured Logging
Unstructured logs (log.info("Order " + orderId + " failed for user " + userId)) are fine to read in a terminal and nearly useless to query at scale. Structured logging emits each log entry as a set of key-value fields — typically JSON — so a log aggregator (Loki, Elasticsearch, CloudWatch Logs Insights, Datadog) can filter, group, and alert on fields instead of doing regex over free text.
Configuring structured JSON output (Spring Boot 3.4+)
Spring Boot 3.4 introduced first-class structured logging support without needing Logback XML surgery:
# application.properties
logging.structured.format.console=ecs
logging.structured.format.file=ecs{
"@timestamp": "2026-08-14T09:32:11.482Z",
"log.level": "ERROR",
"message": "Order processing failed",
"service.name": "order-service",
"process.thread.name": "http-nio-8080-exec-4",
"log.logger": "com.acme.orders.OrderService",
"trace.id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span.id": "00f067aa0ba902b7",
"error.type": "PaymentDeclinedException",
"order_id": "ORD-88213",
"user_id": "usr_4471"
}For older Spring Boot versions or finer control, configure Logback's logstash-logback-encoder directly:
<!-- logback-spring.xml -->
<configuration>
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
<includeMdcKeyName>orderId</includeMdcKeyName>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON"/>
</root>
</configuration>Adding contextual fields with MDC
The Mapped Diagnostic Context (MDC) attaches fields to every log line emitted on the current thread, without having to pass them into every log call manually.
@Component
public class RequestContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws IOException, ServletException {
String requestId = Optional.ofNullable(req.getHeader("X-Request-Id"))
.orElse(UUID.randomUUID().toString());
MDC.put("requestId", requestId);
res.setHeader("X-Request-Id", requestId);
try {
chain.doFilter(req, res);
} finally {
MDC.clear(); // MUST clear — thread pools reuse threads across requests
}
}
}MDC.clear() is not optional. Application servers and reactive schedulers reuse threads across requests. If you forget to clear the MDC in a finally block, request A's orderId can leak into request B's log lines on the same pooled thread — a subtle, hard-to-reproduce bug that shows up as "impossible" log correlations during an incident review.
What to log — and what not to
| Log this | Don't log this |
|---|---|
| Request/trace IDs, entity IDs (order ID, user ID) | Full PII (email, phone, card numbers) unless masked |
| State transitions ("order confirmed", "payment declined") | Full request/response bodies at INFO level by default |
| Errors with exception type and message | Raw exception stack traces at scale without sampling (cost) |
| Retryable failure counts | Secrets, tokens, API keys — ever, at any level |
Logging a full request body "just in case" at INFO level is one of the most common causes of runaway logging bills and accidental PII/secret leakage into your log aggregator. Log identifiers and outcomes; log full payloads only at DEBUG/TRACE, gated behind a sampling or feature-flag mechanism you can turn on selectively during an investigation.
3. Metrics with Micrometer
Micrometer is Spring Boot's metrics facade — a vendor-neutral API (think SLF4J, but for metrics) that can export to Prometheus, Datadog, CloudWatch, or others without changing your instrumentation code. Spring Boot Actuator auto-configures a MeterRegistry and instruments HTTP requests, JVM memory, GC pauses, thread pools, and DataSource pools out of the box.
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>The three metric types that matter
@Service
public class OrderService {
private final Counter ordersPlaced;
private final Timer orderProcessingTime;
private final MeterRegistry registry;
public OrderService(MeterRegistry registry) {
this.registry = registry;
this.ordersPlaced = Counter.builder("orders.placed")
.description("Total orders placed")
.tag("region", "us-east-1")
.register(registry);
this.orderProcessingTime = Timer.builder("orders.processing.time")
.description("Time to fully process an order")
.publishPercentileHistogram()
.register(registry);
// Gauge — reflects current state, not a cumulative count
Gauge.builder("orders.queue.depth", this, OrderService::currentQueueDepth)
.register(registry);
}
public Order placeOrder(OrderRequest request) {
return orderProcessingTime.record(() -> {
Order order = doPlaceOrder(request);
ordersPlaced.increment();
return order;
});
}
private double currentQueueDepth() {
return pendingOrders.size();
}
}| Type | Behavior | Example | Prometheus type |
|---|---|---|---|
| Counter | Monotonically increases, resets only on restart | orders.placed, errors.total | counter |
| Gauge | Reflects a current value, can go up or down | queue.depth, active.connections | gauge |
| Timer / DistributionSummary | Records duration/size samples, exposes count + sum + percentiles | http.server.requests, orders.processing.time | histogram / summary |
Tags: the dimension that makes metrics queryable — and dangerous
Counter.builder("payment.attempts")
.tag("gateway", "stripe")
.tag("outcome", "declined")
.register(registry);Cardinality is the single biggest way to blow up a metrics backend. Every unique combination of tag values creates a new time series. Tagging a counter with orderId or userId — anything with unbounded unique values — can create millions of time series from a single metric definition, overwhelming Prometheus's memory and query performance. Tag with bounded, low-cardinality dimensions: status code, region, gateway name, outcome. Never tag with IDs, emails, or free-text.
Auto-instrumented metrics worth knowing
| Metric | What it tells you |
|---|---|
http.server.requests | Request count, latency percentiles, tagged by URI template, method, status |
jvm.memory.used / jvm.gc.pause | Heap pressure and GC pause duration — correlate with latency spikes |
hikaricp.connections.active / .pending | Connection pool saturation — pending > 0 sustained means requests are queueing for a connection |
resilience4j.circuitbreaker.state | Circuit breaker open/closed/half-open transitions |
process.cpu.usage | Container-level CPU throttling risk |
4. Distributed Tracing with OpenTelemetry
A single user-facing request in a microservice architecture might touch five, ten, or twenty internal services. Metrics tell you the p99 latency went up; logs from any one service only show that service's slice. Distributed tracing stitches the whole request together into a tree of spans — one span per unit of work, each with a start time, duration, and parent-child relationship — so you can see exactly where the time went.
Wiring OpenTelemetry into Spring Boot
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>management.tracing.sampling.probability=0.1
management.otlp.tracing.endpoint=http://otel-collector:4318/v1/traces
management.tracing.propagation.type=w3cSpring Boot's tracing bridge auto-instruments RestTemplate, WebClient, @Scheduled methods, Kafka listeners, and JDBC calls — spans are created and propagated automatically for anything going through those integration points. For custom business logic, create spans explicitly:
@Service
public class InventoryReservationService {
private final Tracer tracer;
public ReservationResult reserve(String sku, int quantity) {
Span span = tracer.nextSpan().name("inventory.reserve").start();
try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
span.tag("sku", sku);
span.tag("quantity", String.valueOf(quantity));
return doReserve(sku, quantity);
} catch (Exception e) {
span.error(e);
throw e;
} finally {
span.end();
}
}
}Trace context propagation across services
The key mechanic making distributed tracing work is propagation: the trace ID and parent span ID travel across the wire as HTTP headers (W3C traceparent) or message headers (for Kafka/RabbitMQ), so the downstream service can attach its own spans to the same trace.
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
^^ version ^^ trace-id (32 hex) ^^ parent-span-id ^^ flags
You almost never need to touch propagation headers by hand — micrometer-tracing + the auto-instrumented HTTP clients handle it. The place it bites people is custom async code: if you hand off work to a raw new Thread(), a manually created ExecutorService, or a fire-and-forget message publish without the tracing-aware wrappers, the trace context does not automatically follow. Use ContextExecutorService/ContextSnapshot from Micrometer Context Propagation, or Spring's async instrumentation, to carry the context across the boundary.
Sampling: why you can't trace everything
At high request volume, capturing and exporting a span for every single request is both expensive and mostly useless — you don't need to store 50,000 identical "successful GET" traces per minute. Sampling controls what fraction of traces get fully recorded and exported.
| Strategy | Behavior | Trade-off |
|---|---|---|
| Head-based sampling | Decide to sample at the start of the trace (e.g., 10% of requests) | Simple, cheap, but might sample-out the interesting slow/error requests |
| Tail-based sampling | Buffer the whole trace, decide after seeing the outcome (e.g., always keep errors and p99 latency) | Better signal, but requires a collector that can buffer and needs more infra (e.g., OTel Collector tail-sampling processor) |
| Always-sample errors | Force 100% sampling on any span marked as an error, regardless of head-based rate | Common hybrid — cheap baseline + guaranteed capture of the traces you actually need |
5. Correlation: Tying It All Together
The entire value of the three pillars compounds when they're correlated — when a metric dashboard, a trace, and a log line all reference the same trace ID.
Spring Boot's Micrometer Tracing integration does this automatically for logs: once micrometer-tracing-bridge-otel is on the classpath, the default log pattern includes traceId and spanId via MDC without any manual wiring:
logging.pattern.level=%5p [${spring.application.name},%X{traceId:-},%X{spanId:-}]2026-08-14 09:32:11.482 ERROR [order-service,4bf92f3577b34da6a3ce929d0e0e4736,00f067aa0ba902b7] 12480 --- [http-nio-8080-exec-4] c.a.orders.OrderService : Payment declined
A useful operational habit: put the trace ID in every error response returned to clients (as a header, X-Trace-Id, or in the error body). When a user reports "checkout failed," support can hand you the trace ID directly instead of a timestamp and a vague description — turning a 20-minute log archaeology exercise into a single trace lookup.
Key takeaways
- Logs, metrics, and traces answer different questions at different costs — don't try to make one pillar do the job of the other two (e.g., don't grep logs for latency percentiles Micrometer already computes for free).
- Structured (JSON) logging is non-negotiable at any real scale — free-text logs cannot be reliably filtered, aggregated, or alerted on.
- Always clear
MDCin afinallyblock — pooled threads carrying stale context between requests is a classic, hard-to-diagnose bug. - Tag metrics with bounded, low-cardinality dimensions only. Tagging with an ID or email is the single fastest way to overwhelm a metrics backend.
- Trace context propagation is automatic through instrumented HTTP clients and messaging, but breaks silently across raw threads/executors unless you propagate context explicitly.
- Sample traces — you don't need 100% capture, but you almost always want 100% capture of errors and slow outliers specifically.
- Correlate all three pillars via a shared trace ID; surface that trace ID to users/support so incident triage starts from a lookup, not a search.
Interview Questions
- What distinct question does each of logs, metrics, and traces answer, and why can't one substitute for another?
- Why is structured (JSON) logging preferred over free-text logging in production systems?
- What is MDC, and why is it critical to clear it in a
finallyblock? - What is metric cardinality, and why is tagging a counter with a user ID dangerous?
- Explain the difference between a Counter, a Gauge, and a Timer in Micrometer.
- How does distributed trace context propagate across service boundaries? What header carries it over HTTP?
- What breaks trace propagation in async code, and how do you fix it?
- What is the difference between head-based and tail-based sampling? When would you choose each?
- Why would you force 100% sampling on error spans even with a low overall sampling rate?
- How would you correlate a slow trace with the exact log lines that explain the slowness?
- What auto-instrumented metrics would you check first if
hikaricp.connections.pendingstarted climbing? - Why shouldn't you log full request/response bodies at INFO level by default?
- Describe the parent-child relationship between spans in a trace, and how it's represented in the
traceparentheader. - If a dashboard shows a latency spike but you have no way to find the specific slow requests, what pillar is missing from your setup?