OpenTelemetry with Spring Boot 3: Unified Traces, Metrics and Logs in One Pipeline

Introduction

Modern distributed applications generate enormous volumes of operational data — HTTP requests flowing between microservices, database queries, cache hits, background jobs, and the errors that inevitably surface under production load. Without a coherent way to correlate that data, diagnosing a latency spike in service A that was actually caused by a slow database query in service B is like reconstructing a crime scene from photographs taken by different cameras, none of them synchronised.

OpenTelemetry (OTel) is the CNCF standard that solves this. It defines a vendor-neutral API and SDK for collecting the three pillars of observability — traces, metrics, and logs — and shipping them through a common pipeline to whatever backend your team already uses: Grafana, Prometheus, Jaeger, Datadog, or others.

Spring Boot 3 has first-class OTel support through its Micrometer Tracing integration. With a handful of dependencies and a few lines of YAML, your application gains distributed tracing, Prometheus metrics, and correlated log output that links every log line to the trace it belongs to.

This tutorial builds a production-ready observability setup step by step. By the end you will have:

  • Distributed traces exported via OTLP to an OpenTelemetry Collector and on to Grafana Tempo

  • Prometheus metrics scraped from /actuator/prometheus

  • Structured JSON logs with traceId and spanId fields, ready for Grafana Loki

  • Custom business spans using the @WithSpan annotation and the Micrometer Observation API

  • A Docker Compose stack running the full Grafana observability stack locally

Prerequisites

  • Java 21 or later
  • Maven 3.9+
  • Docker and Docker Compose (for the local observability stack)
  • Basic familiarity with Spring Boot and REST APIs

Project Setup

Create a new Spring Boot 3.4.4 project. The spring-boot-starter-parent BOM manages all Micrometer and OpenTelemetry SDK versions automatically — no need to pin individual OTel versions.

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.4.4</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    <!-- Micrometer Tracing bridge to OpenTelemetry -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-tracing-bridge-otel</artifactId>
    </dependency>

    <!-- OTLP exporter for traces -->
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-exporter-otlp</artifactId>
    </dependency>

    <!-- OTLP push for metrics -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-otlp</artifactId>
    </dependency>

    <!-- Prometheus pull scrape endpoint -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>

    <!-- Structured JSON logging with trace correlation -->
    <dependency>
        <groupId>net.logstash.logback</groupId>
        <artifactId>logstash-logback-encoder</artifactId>
        <version>7.4</version>
    </dependency>

    <!-- @WithSpan and @SpanAttribute annotations -->
    <dependency>
        <groupId>io.opentelemetry.instrumentation</groupId>
        <artifactId>opentelemetry-instrumentation-annotations</artifactId>
        <version>2.9.0</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Configuring the Observability Pipeline

All three signal pipelines — traces, metrics, and logs — are configured in application.yml. Spring Boot's Actuator reads the management.* namespace and wires everything together automatically:

spring:
  application:
    name: order-service

management:
  otlp:
    tracing:
      endpoint: http://localhost:4318/v1/traces
    metrics:
      export:
        url: http://localhost:4318/v1/metrics
        step: 30s
  tracing:
    sampling:
      probability: 1.0   # 100% in dev; use 0.1 in production
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus
  metrics:
    tags:
      application: ${spring.application.name}
      environment: ${spring.profiles.active:default}
    distribution:
      percentiles-histogram:
        http.server.requests: true
      percentiles:
        http.server.requests: [0.5, 0.95, 0.99]

logging:
  pattern:
    level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"

Two things worth calling out:

Dual metrics export: both micrometer-registry-otlp (push to collector) and micrometer-registry-prometheus (pull scrape) are active simultaneously. This supports the common real-world situation where Prometheus already scrapes your services and you're gradually adopting a push-based OTel pipeline.

Sampling at 100%: the Spring Boot 3 default is 10%. In development you want every request traced. If you deploy to production and see no traces, this setting is the first thing to check.

The OpenTelemetry Collector

The Collector sits between your application and your observability backends. It receives telemetry via OTLP and fans it out to Tempo (traces), Prometheus (metrics), and Loki (logs) — meaning your service code has no direct dependency on any backend.

docker-compose.yml:

version: '3.8'
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.110.0
    command: ["--config=/etc/otel-config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-config.yaml
    ports:
      - "4317:4317"   # gRPC
      - "4318:4318"   # HTTP/protobuf
      - "8889:8889"   # Prometheus metrics scrape

  tempo:
    image: grafana/tempo:latest
    command: ["-config.file=/etc/tempo.yaml"]
    ports:
      - "3200:3200"

  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin

otel-collector-config.yaml:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
  batch:
    timeout: 5s

exporters:
  otlphttp/tempo:
    endpoint: http://tempo:4318
  prometheus:
    endpoint: "0.0.0.0:8889"
  debug:
    verbosity: basic

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlphttp/tempo, debug]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheus]

Building the Application

Create the main application class:

package com.vimleshpandey.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

A simple domain record:

package com.vimleshpandey.demo.model;

public record Order(String orderId, String customerId, String status, double amount) {}

The REST controller — HTTP spans are auto-instrumented by Spring MVC, so you don't need any tracing annotations here:

package com.vimleshpandey.demo.controller;

import com.vimleshpandey.demo.model.Order;
import com.vimleshpandey.demo.service.OrderService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private static final Logger log = LoggerFactory.getLogger(OrderController.class);
    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{orderId}")
    public ResponseEntity<Order> getOrder(@PathVariable String orderId) {
        log.info("Fetching order: {}", orderId);
        return ResponseEntity.ok(orderService.findOrder(orderId));
    }

    @PostMapping
    public ResponseEntity<Order> createOrder(@RequestBody Order order) {
        log.info("Creating order for customer: {}", order.customerId());
        return ResponseEntity.ok(orderService.createOrder(order));
    }
}

Custom Instrumentation: @WithSpan and the Observation API

Spring Boot auto-instruments Spring MVC, JDBC, RestTemplate, WebClient, Kafka, and Redis. For your own business logic, you have two instruments:

@WithSpan creates a child span for the annotated method. @SpanAttribute attaches a method parameter as an attribute on that span — searchable in Tempo:

package com.vimleshpandey.demo.service;

import com.vimleshpandey.demo.model.Order;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import io.opentelemetry.instrumentation.annotations.SpanAttribute;
import io.opentelemetry.instrumentation.annotations.WithSpan;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.UUID;

@Service
public class OrderService {

    private static final Logger log = LoggerFactory.getLogger(OrderService.class);
    private final ObservationRegistry registry;

    public OrderService(ObservationRegistry registry) {
        this.registry = registry;
    }

    @WithSpan("orderService.findOrder")
    public Order findOrder(@SpanAttribute("order.id") String orderId) {
        log.info("Looking up order: {}", orderId);
        simulateLatency(50);
        return new Order(orderId, "CUST-001", "COMPLETED", 149.99);
    }

    public Order createOrder(Order order) {
        // Observation.createNotStarted produces BOTH a metric timer AND a trace span
        return Observation.createNotStarted("order.create", registry)
                .lowCardinalityKeyValue("customer.tier", "standard")
                .observe(() -> {
                    log.info("Processing order for customer: {}", order.customerId());
                    validateOrder(order);
                    processPayment(order);
                    return new Order(UUID.randomUUID().toString(),
                            order.customerId(), "PENDING", order.amount());
                });
    }

    @WithSpan("orderService.validateOrder")
    private void validateOrder(Order order) {
        log.debug("Validating order constraints");
        simulateLatency(20);
    }

    @WithSpan("orderService.processPayment")
    private void processPayment(Order order) {
        log.info("Processing payment, amount={}", order.amount());
        simulateLatency(100);
    }

    private void simulateLatency(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

The Observation API's key advantage over @WithSpan: a call to Observation.createNotStarted("order.create", registry) emits both a Micrometer histogram metric (named order.create) and a trace span. That single instrumentation call makes the operation measurable in Prometheus dashboards and traceable in Tempo simultaneously — no code duplication.

Preserving Trace Context Across @Async Boundaries

Trace context lives in a ThreadLocal. When @Async switches threads, that context is silently dropped, and child operations appear as disconnected root spans in your trace view. Fix this by registering a task decorator that captures and restores the context:

package com.vimleshpandey.demo.config;

import io.micrometer.context.ContextSnapshot;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
public class AsyncConfig {

    @Bean
    public TaskExecutor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(20);
        executor.setTaskDecorator(runnable -> {
            ContextSnapshot snapshot = ContextSnapshot.captureAll();
            return () -> {
                try (ContextSnapshot.Scope scope = snapshot.setThreadLocals()) {
                    runnable.run();
                }
            };
        });
        return executor;
    }
}

Structured Log Correlation

Configure Logback to emit JSON with traceId and spanId as indexed fields. Create src/main/resources/logback-spring.xml:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <springProperty scope="context" name="appName" source="spring.application.name"/>

    <appender name="JSON_CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <includeMdcKeyName>traceId</includeMdcKeyName>
            <includeMdcKeyName>spanId</includeMdcKeyName>
            <customFields>{"application":"${appName}"}</customFields>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="JSON_CONSOLE"/>
    </root>
</configuration>

With this configuration, every log line emitted inside a traced request looks like:

{
  "@timestamp": "2026-10-04T10:23:15.123Z",
  "level": "INFO",
  "application": "order-service",
  "traceId": "65d4f8c1a7b3e2f04a1c9e8b3d2f1a0c",
  "spanId": "1a2b3c4d5e6f7890",
  "message": "Processing payment, amount=149.99",
  "logger_name": "com.vimleshpandey.demo.service.OrderService"
}

The traceId field is what Grafana Loki uses for its jump to trace link: from a log line showing an error, one click takes you to the full distributed trace in Tempo that contains the error span.

Testing

package com.vimleshpandey.demo;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
class OrderServiceTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void getOrderReturnsExpectedFields() throws Exception {
        mockMvc.perform(get("/api/orders/ORD-123"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.orderId").value("ORD-123"))
                .andExpect(jsonPath("$.status").value("COMPLETED"));
    }

    @Test
    void createOrderReturnsNewId() throws Exception {
        String body = """
                {"orderId": null, "customerId": "CUST-001", "status": null, "amount": 99.99}
                """;
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(body))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.status").value("PENDING"))
                .andExpect(jsonPath("$.customerId").value("CUST-001"));
    }
}

Spring Boot's test slice auto-configures a no-op ObservationRegistry when no collector is running, so the tests pass without a Docker Compose stack.

Running the Application

Start the observability backends first, then the application:

# Start Grafana + Tempo + OTel Collector + Prometheus
docker-compose up -d

# Build and run
mvn spring-boot:run

Generate traffic:

curl http://localhost:8080/api/orders/ORD-123
curl -X POST http://localhost:8080/api/orders \
  -H 'Content-Type: application/json' \
  -d '{"orderId":null,"customerId":"CUST-001","status":null,"amount":79.99}'

Open Grafana at http://localhost:3000. Select the Tempo data source in Explore and search for recent traces. You should see:

  • A root span for GET /api/orders/{orderId} (auto-instrumented)

  • A child span orderService.findOrder from @WithSpan

  • Grandchild spans for validateOrder and processPayment

The Prometheus endpoint at http://localhost:8080/actuator/prometheus exposes the order_create_* histogram generated by the Observation API call.

Common Pitfalls and Tips

Never use new RestTemplate(): always inject the auto-configured RestTemplateBuilder. Constructing RestTemplate directly bypasses the interceptors that propagate the traceparent header to downstream services. Your traces will look disconnected even though the services are communicating correctly.

BOM order matters in Spring Boot 3.5+: if you switch to the community opentelemetry-spring-boot-starter, the opentelemetry-instrumentation-bom must be declared before spring-boot-dependencies in your . Boot 3.5 introduced its own OTel version pin that silently wins the dependency resolution if the BOM order is wrong.

Do not mix the Java Agent and the library approach: attaching opentelemetry-javaagent.jar at JVM startup and having the Micrometer tracing bridge on the classpath causes duplicate instrumentation and inflated metrics. Pick one. For new Spring Boot 3 projects, the library approach is more idiomatic.

Metrics default sampling at 10%: management.tracing.sampling.probability defaults to 0.1. If you're not seeing traces in development, this is almost always why.

@Async drops context: register a ContextPropagatingTaskDecorator (or the Micrometer ContextSnapshot equivalent shown above) on every ThreadPoolTaskExecutor bean, or async operations appear as disconnected root spans.

Conclusion

With Spring Boot 3's Micrometer Tracing bridge, wiring all three observability signals through a single OTel Collector takes under an hour and fewer than a hundred lines of configuration. The architectural payoff is backend independence: today the Collector fans out to Tempo and Prometheus; adding a Datadog or Honeycomb exporter tomorrow requires no application code changes.

The Observation API's single-call model — one Observation.createNotStarted() call producing both a Micrometer histogram and a trace span — means your business operations are measurable on dashboards and traceable in Tempo without duplicating instrumentation. Combine that with structured JSON logging carrying traceId as an indexed field and you have the complete loop: from a p99 latency alert, through the exact traces that contributed to it, down to the correlated log lines for each span.