Instrumenting Spring Boot with OpenTelemetry

The fastest path to trace a Spring Boot service is the zero-code OpenTelemetry Java agent attached with -javaagent at JVM launch; when you need the SDK inside your application context — for programmatic sampling, custom processors, or @WithSpan methods — use the opentelemetry-spring-boot-starter instead, but never both at once.

Context and when it matters

Spring Boot applications sit on two very different request pipelines. The traditional stack runs on the servlet API (Tomcat, Jetty, Undertow) with one thread bound to a request for its lifetime; the reactive stack runs on Project Reactor and WebFlux, where a single request hops across scheduler threads as it flows through operators. Any framework instrumentation approach has to attach a span to the inbound request and keep the context propagation object alive across whichever pipeline you run — and the two pipelines fail in different ways.

OpenTelemetry gives you two supported entry points for Spring Boot. The Java agent uses bytecode instrumentation to patch Spring MVC, WebFlux, JDBC, the reactive and blocking HTTP clients, Kafka, and dozens of other libraries with no source changes. The Spring Boot starter uses Spring’s auto-configuration to construct an SDK OpenTelemetry bean inside your application context, giving you programmatic control at the cost of covering fewer libraries out of the box. Choosing between them — and understanding where the W3C TraceContext traceparent header is read and written — is the whole job.

Minimal working setup

Option A — the zero-code Java agent

Download the agent JAR and attach it. No dependency changes, no code changes:

# Fetch the latest agent release
curl -sSLO https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar

# Launch your Spring Boot app with the agent attached
java -javaagent:./opentelemetry-javaagent.jar \
     -Dotel.service.name=checkout-service \
     -Dotel.exporter.otlp.endpoint=http://localhost:4317 \
     -Dotel.traces.exporter=otlp \
     -jar target/checkout-service.jar

The same configuration is usually supplied as environment variables in a container, which keeps the JVM command line clean:

export OTEL_SERVICE_NAME=checkout-service
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
export OTEL_TRACES_EXPORTER=otlp
export OTEL_PROPAGATORS=tracecontext,baggage
export JAVA_TOOL_OPTIONS="-javaagent:/opt/opentelemetry-javaagent.jar"
java -jar /app/checkout-service.jar

From here every inbound MVC controller call, every RestTemplate/WebClient request, and every JDBC query produces spans automatically, and the agent injects traceparent into outbound calls so context flows to the next service — ideally into an OpenTelemetry Collector that batches and forwards to your backend.

Option B — the Spring Boot starter

When you want the SDK constructed inside the Spring container, add the starter:

<!-- pom.xml -->
<dependency>
  <groupId>io.opentelemetry.instrumentation</groupId>
  <artifactId>opentelemetry-spring-boot-starter</artifactId>
  <version>2.9.0</version>
</dependency>

Then configure it through application.properties (or the YAML equivalent) exactly as you would any other Spring setting:

# application.yml
otel:
  service:
    name: checkout-service
  traces:
    exporter: otlp
    sampler: parentbased_traceidratio
    sampler.arg: "0.1"          # sample 10% at the head
  exporter:
    otlp:
      endpoint: http://otel-collector:4317
  propagators: tracecontext,baggage
  instrumentation:
    spring-webmvc:
      enabled: true

The starter registers a Spring MVC filter and a WebClient filter, wires an OTLP exporter bean, and honours the same environment variables as the agent, so the two share a configuration vocabulary.

Servlet, WebFlux, and manual spans

On the servlet path the model is simple: the container binds one thread to the request, the instrumentation opens a SERVER span, and every synchronous call down the stack inherits it through a ThreadLocal-backed context. This is the case where auto-instrumentation is nearly complete on its own.

On the WebFlux path a request is a Mono/Flux pipeline that Reactor schedules across parallel and boundedElastic threads. The active span is bound to whatever thread starts the chain, so unless Reactor copies the context between operators, downstream spans detach from their parent. Reactor solves this with Hooks.enableAutomaticContextPropagation(), which the agent enables for you but which you must call yourself in some starter setups.

For business operations that are not a library call — a pricing calculation, a fraud check, a batch step — add a manual span. The starter and agent both support the @WithSpan annotation, which wraps a method in a span whose parent is the active context, bridging auto and manual instrumentation:

import io.opentelemetry.instrumentation.annotations.WithSpan;
import io.opentelemetry.instrumentation.annotations.SpanAttribute;
import org.springframework.stereotype.Service;

@Service
public class PricingService {

    // @WithSpan opens a child span named "PricingService.quote"
    // parented to whatever SERVER span the request is running under.
    @WithSpan("price.quote")
    public Money quote(@SpanAttribute("cart.id") String cartId, int itemCount) {
        // Business logic here runs inside the child span; any
        // downstream JDBC / WebClient call auto-parents to it.
        return computeQuote(cartId, itemCount);
    }
}

Implementation

The block below shows the pieces you actually write when you want programmatic control with the starter: a custom span for an in-process step, and a correctly wrapped executor so background work keeps its parent. Every comment maps a line to a tracing concept.

import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.Scope;
import org.springframework.web.bind.annotation.*;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@RestController
public class CheckoutController {

    private final Tracer tracer;
    // taskWrapping() returns an executor that copies the active context
    // onto every submitted task — see the multi-threaded guide below.
    private final ExecutorService pool;

    public CheckoutController(OpenTelemetry openTelemetry) {
        // Prefer the injected OpenTelemetry bean over the global instance
        // so tests can supply an in-memory SDK.
        this.tracer = openTelemetry.getTracer("checkout-service", "1.4.0");
        this.pool = Context.taskWrapping(Executors.newFixedThreadPool(8));
    }

    @PostMapping("/checkout")
    public String checkout(@RequestBody CartRequest req) {
        // start a CLIENT/INTERNAL span; setParent(Context.current())
        // links it to the SERVER span the servlet filter already opened.
        Span span = tracer.spanBuilder("reserve.inventory")
                          .setParent(Context.current())
                          .startSpan();
        try (Scope scope = span.makeCurrent()) {   // bind span to this thread
            span.setAttribute("cart.id", req.cartId());
            reserve(req);
            // Background work keeps the parent because pool is task-wrapped.
            pool.submit(() -> emitReceiptEvent(req.cartId()));
            return "reserved";
        } finally {
            span.end();   // ALWAYS end; makeCurrent()'s Scope is closed by try-with-resources
        }
    }
}

Decision criteria: agent vs starter

Use this checklist to pick an approach and confirm it is wired correctly.

Common pitfalls

  • Double instrumentation. Shipping the agent and the starter patches the servlet path twice, so each request yields two SERVER spans with the same name and overlapping timing. Symptoms are duplicated root spans and doubled request counts on RED dashboards. Remove one.
  • WebFlux context loss on the reactive chain. If parent spans vanish the moment a request crosses a flatMap onto a boundedElastic thread, Reactor context propagation is off. This is the reactive analogue of the thread-boundary loss covered in the multi-threaded context guide, and in the same family as async boundary breaks in other runtimes.
  • Raw executors for @Async and manual thread pools. A plain ThreadPoolTaskExecutor never carries the active span, so @Async methods and hand-rolled pools orphan their work. Wrap the executor with Context.taskWrapping(...) before Spring hands it out.

FAQ

Do I need both the Java agent and the Spring Boot starter?

No. Pick one. The agent instruments bytecode at JVM launch with no code changes; the starter wires the SDK through Spring auto-configuration. Running both at once double-instruments the servlet path and produces duplicate spans for every request.

Why do my WebFlux spans lose their parent on downstream calls?

Reactor runs operators on shared scheduler threads, so the context bound to the request thread does not automatically follow the reactive chain. Enable Reactor context propagation with Hooks.enableAutomaticContextPropagation() on recent reactor-core versions, or the WebFlux instrumentation cannot restore the parent span.

Why does work submitted to an ExecutorService start a new trace?

A plain thread pool never sees the caller’s context. Wrap the executor with Context.taskWrapping(executor) or wrap each Runnable with Context.current().wrap(runnable) so the worker thread inherits the active span instead of starting a fresh root trace.


↑ Back to Instrumenting Web Frameworks with OpenTelemetry