// Built in the openWorked on 2026

One dependency for logs, traces and metrics: writing a Spring Boot starter

Every service wires observability slightly differently, and the differences show up during incidents. How I packaged logging, tracing and metrics into one Spring Boot starter — and three gaps a code review of my own starter found.

#springboot#observability#opentelemetry#java

The worst time to discover that your services log differently is during an incident. It happens easily: each service is set up by a different person at a different time, so you end up with different field names, trace IDs in some services but not others, and a correlation ID that stops at the first hop. In a platform with several services I’ve worked on, the answer was a shared module for logging, auth and tracing; this starter is that idea rebuilt in the open.

The fix isn’t a wiki page of conventions. It’s making the right setup the default by putting it in a dependency. That’s what spring-boot-starter-observability does: add one dependency to a Spring Boot 3 app and you get:

  • Structured JSON logs with traceId, spanId, requestId and correlationId on every line.
  • OpenTelemetry tracing: a server span per HTTP request, spans across Kafka producers and consumers using the W3C traceparent header, and @Traced for any method.
  • Micrometer metrics: JVM metrics plus @Timed and @Counted on any method, and a health indicator.
  • Correlation IDs that cross service boundaries via X-Correlation-Id.

What a starter actually is

A Spring Boot starter is two small ideas:

  1. A list of auto-configuration classes in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, which Spring Boot loads at startup.
  2. Those classes create beans only when it makes sense, using @Conditional... annotations.

The project is split the way the Spring team splits its own starters:

Module Role
starter-core Annotations (@Traced) and constants; no auto-configuration
starter-autoconfigure Logging, tracing and metrics auto-configuration
starter-sample A product API that shows everything working

Rule 1: every bean backs off

The most important annotation in a starter is @ConditionalOnMissingBean. If the application defines its own SpanExporter or SdkTracerProvider, the starter’s version quietly steps aside:

@AutoConfiguration
@ConditionalOnClass({OpenTelemetrySdk.class, OtlpGrpcSpanExporter.class})
@ConditionalOnProperty(prefix = "observability.tracing", name = "enabled",
                       havingValue = "true", matchIfMissing = true)
public class ObservabilityTracingAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public SpanExporter otlpGrpcSpanExporter(ObservabilityProperties properties) { ... }
}

Each piece is also gated on the classpath and on a property. Kafka tracing only appears when Kafka’s interceptor classes are present and a Tracer bean exists; the servlet filter and MVC interceptor only appear in servlet web applications. A starter that forces beans on you gets ripped out within a quarter.

Rule 2: observability never stops the app

If the trace collector is down, your service should still serve traffic. If the exporter can’t even be built — a malformed endpoint, say — the auto-configuration catches the failure, logs a warning and returns a no-op exporter:

} catch (Exception e) {
    log.warn("Failed to initialize OTLP span exporter (endpoint: {}). " +
             "Traces will not be exported: {}", tracingProps.getEndpoint(), e.getMessage());
    return SpanExporter.composite(); // no-op: the app starts, spans are discarded
}

When the collector is simply unreachable at runtime, the OpenTelemetry SDK exports in the background and drops what it can’t send. Either way, requests keep flowing.

Logs that carry the trace

RequestContextFilter runs once per request and fills the SLF4J MDC, so every log line written while handling the request carries the same identifiers:

String correlationId = request.getHeader("X-Correlation-Id");
if (correlationId == null || correlationId.isBlank()) {
    correlationId = UUID.randomUUID().toString();
}
MDC.put("correlationId", correlationId);
response.setHeader("X-Correlation-Id", correlationId);

Everything is cleared in a finally block — without that, values leak into the next request that reuses the pooled thread, which produces some of the most confusing logs you’ll ever read.

The filter is configurable with properties (include-headers, header-filter, custom-fields) so a team can, for example, add tenantId to every line without writing code.

Tracing across Kafka

HTTP has standard instrumentation in most stacks; message queues are where traces usually break. The producer interceptor writes a W3C header onto each record:

// version-traceId-spanId-traceFlags
String traceparent = "00-" + traceId + "-" + spanId + "-" + traceFlags;
record.headers().add("traceparent", traceparent.getBytes(StandardCharsets.UTF_8));

The consumer interceptor reads it back and starts a child span, so a trace that begins with an HTTP request continues through the topic into the consumer. In Jaeger you see one trace across both services instead of two unrelated ones.

An HTTP request passes through the request-context filter and tracing interceptor; Kafka messages pass through producer and consumer interceptors; all emit JSON logs, OpenTelemetry spans and Micrometer metrics.An HTTP request passes through the request-context filter and tracing interceptor; Kafka messages pass through producer and consumer interceptors; all emit JSON logs, OpenTelemetry spans and Micrometer metrics.
Entry points on the left, the three signals on the right. Explore the interactive diagram ↗

Three gaps I found reviewing my own starter

I re-read the code as if it were someone else’s pull request. Three findings:

  1. HTTP spans don’t continue the caller’s trace. The tracing interceptor starts the server span with setParent(Context.current()), but nothing extracts an incoming traceparent header first. Kafka hops are linked; HTTP hops start a new trace. The fix is to run the OpenTelemetry TextMapPropagator over the request headers before starting the span.
  2. Two different trace IDs. The log filter takes its traceId from an X-Trace-Id header (or falls back to the request ID), while spans have OpenTelemetry’s own trace ID. Logs and traces should share one ID; the filter should read it from the current span.
  3. http.route uses the raw URI. /products/123 and /products/456 become different routes, which explodes metric cardinality. It should use Spring’s matched pattern (/products/{id}).

None of these show up in a demo. All three show up in production, during the incident you built the starter for. Fixes are the next commits.

Takeaways

  • Put conventions in a dependency, not a document.
  • In a starter, @ConditionalOnMissingBean is a feature: let teams override anything.
  • Observability failures must degrade to “no data”, never to “no service”.
  • Trace propagation is only as good as its weakest hop — test every protocol your services speak.

Code, tests and the sample app are on GitHub.