Spring Boot 3 Observability with Micrometer and OpenTelemetry


title: “Spring Boot 3 Observability with Micrometer and OpenTelemetry” date: 2025-04-10 tags: [“Spring Boot 3”, “Micrometer”, “OpenTelemetry”, “Observability”, “Distributed Tracing”, “Metrics”] categories: [“Java”]

Spring Boot 3 Observability with Micrometer and OpenTelemetry

If you’ve ever tried to debug a production issue in a distributed system without proper observability, you know the pain. Logs alone tell you what happened, but not why or where the latency came from. With Spring Boot 3, observability is no longer an afterthought—it’s a first-class citizen. The combination of Micrometer and OpenTelemetry provides a powerful, vendor-neutral way to capture metrics, traces, and logs in a unified manner.

In this post, I’ll walk you through setting up observability in a Spring Boot 3 application using Micrometer for metrics and OpenTelemetry for distributed tracing. We’ll cover practical configurations, code examples, and how to export data to popular backends like Prometheus, Jaeger, and Grafana.

Why Spring Boot 3 Changes the Game

Spring Boot 3 introduced a new observability API built on top of Micrometer’s Observation API. This API unifies metrics and tracing under a single abstraction. Before Spring Boot 3, you had to manually instrument your code with separate libraries for metrics (Micrometer) and tracing (Spring Cloud Sleuth + OpenTelemetry). Now, you write one observation and get both metrics and traces automatically.

Key benefits:

Setting Up a Spring Boot 3 Project

Let’s start from scratch. Create a new Spring Boot 3 project with the necessary dependencies. I’ll use Maven, but Gradle works similarly.

pom.xml dependencies

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<dependencies>
<!-- Core Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

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

<!-- OpenTelemetry exporter (e.g., Jaeger) -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>

<!-- Micrometer registry for metrics (e.g., Prometheus) -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

<!-- Actuator for health and metrics endpoints -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>

Key dependencies explained

Configuration

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
spring:
application:
name: order-service

management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics,otlp
tracing:
sampling:
probability: 1.0 # 100% sampling for development; reduce in production
metrics:
tags:
application: ${spring.application.name}

otel:
service:
name: ${spring.application.name}
exporter:
otlp:
endpoint: http://localhost:4317 # OTLP gRPC endpoint (Jaeger, Grafana Tempo, etc.)
protocol: grpc

Important notes:

Automatic Instrumentation: What You Get for Free

Spring Boot 3 automatically instruments many components. Without writing a single line of code, you get:

Let’s test this with a simple REST controller.

Sample Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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("/{id}")
public Order getOrder(@PathVariable Long id) {
log.info("Fetching order with id: {}", id);
return orderService.findById(id);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class OrderService {

private final JdbcTemplate jdbcTemplate;

public OrderService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}

public Order findById(Long id) {
String sql = "SELECT id, customer, total FROM orders WHERE id = ?";
return jdbcTemplate.queryForObject(sql, new BeanPropertyRowMapper<>(Order.class), id);
}
}

With the default configuration, every HTTP request to /api/orders/{id} generates:

You can view the trace in Jaeger (or your backend) and see the exact SQL statement executed.

Custom Instrumentation with @Observed

Sometimes you need to instrument custom business logic. Spring Boot 3 provides the @Observed annotation for this purpose.

Using @Observed

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import io.micrometer.observation.annotation.Observed;

@Service
public class PaymentService {

@Observed(name = "payment.process",
contextualName = "process-payment",
lowCardinalityKeyValues = {"paymentType", "credit-card"})
public PaymentResult processPayment(PaymentRequest request) {
// Simulate payment processing
log.info("Processing payment for order {}", request.getOrderId());

// This creates a span named "payment.process" with a tag paymentType=credit-card
// It also records metrics: payment.process.seconds (histogram)

return new PaymentResult(true, "Payment approved");
}
}

What @Observed does:

  1. Creates a new span in the current trace
  2. Records timing metrics (duration histogram)
  3. Automatically captures exceptions as error tags
  4. Propagates the trace context to downstream calls

Programmatic Observation

If annotations aren’t flexible enough, use the ObservationRegistry directly.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;

@Service
public class InventoryService {

private final ObservationRegistry observationRegistry;

public InventoryService(ObservationRegistry observationRegistry) {
this.observationRegistry = observationRegistry;
}

public boolean checkStock(Long productId) {
return Observation.createNotStarted("inventory.check", observationRegistry)
.lowCardinalityKeyValue("productId", String.valueOf(productId))
.observe(() -> {
// Actual business logic
log.info("Checking stock for product {}", productId);
return stockRepository.hasStock(productId);
});
}
}

Adding Custom Metrics

While the Observation API handles common cases, you may need custom metrics like gauges or counters.

Counter Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Counter;

@Component
public class OrderMetrics {

private final Counter orderCreatedCounter;
private final Counter orderCancelledCounter;

public OrderMetrics(MeterRegistry meterRegistry) {
this.orderCreatedCounter = Counter.builder("orders.created")
.description("Number of orders created")
.tag("application", "order-service")
.register(meterRegistry);

this.orderCancelledCounter = Counter.builder("orders.cancelled")
.description("Number of orders cancelled")
.tag("application", "order-service")
.register(meterRegistry);
}

public void incrementCreated() {
orderCreatedCounter.increment();
}

public void incrementCancelled() {
orderCancelledCounter.increment();
}
}

Timer Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.MeterRegistry;

@Component
public class PaymentMetrics {

private final Timer paymentTimer;

public PaymentMetrics(MeterRegistry meterRegistry) {
this.paymentTimer = Timer.builder("payment.duration")
.description("Time taken to process a payment")
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry);
}

public void recordPayment(Runnable paymentLogic) {
paymentTimer.record(paymentLogic);
}
}

Integrating with Grafana, Prometheus, and Jaeger

Let’s set up a complete observability stack using Docker Compose.

docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
version: '3.8'

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

jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # UI
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP

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

prometheus.yml

1
2
3
4
5
scrape_configs:
- job_name: 'spring-boot-app'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['host.docker.internal:8080']

Advanced: Context Propagation Across Threads

One common challenge is preserving trace context when using async operations. Spring Boot 3 handles this with ThreadPoolTaskExecutor auto-configuration.

Async Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
}
1
2
3
4
5
6
7
8
9
10
11
@Service
public class NotificationService {

@Async
@Observed(name = "notification.send")
public CompletableFuture<Void> sendEmail(Long orderId) {
// This runs in a separate thread but preserves the trace context
log.info("Sending email for order {}", orderId);
return CompletableFuture.completedFuture(null);
}
}

No extra configuration needed—Spring Boot 3 automatically wraps the executor with trace context propagation.

Best Practices from Production

After running this setup in production for several months, here are some lessons learned:

1. Sampling Strategy

Don’t sample 100% in production unless you have unlimited storage. Use a probabilistic sampler with a rate that balances cost and visibility. For critical services, consider a rate-limiting sampler that captures all traces for high-latency requests.

1
2
3
4
management:
tracing:
sampling:
probability: 0.1

2. Tag Cardinality

Avoid high-cardinality tags (e.g., user IDs, session IDs) in metrics. They explode the number of time series in Prometheus. Use them only in traces, not metrics.

3. Custom Spans for External Calls

If your service calls external APIs not instrumented by Spring Boot, wrap them with @Observed or programmatic observations.

1
2
3
4
5
@Observed(name = "external.api.call", 
contextualName = "call-payment-gateway")
public PaymentResponse callPaymentGateway(PaymentRequest request) {
// HTTP call to external service
}

4. Use OpenTelemetry Collector

Instead of exporting directly to Jaeger or Prometheus, use the OpenTelemetry Collector as a middleware. It provides buffering, retries, and can fan-out to multiple backends.

5. Log Correlation

Spring Boot 3 automatically adds trace IDs and span IDs to MDC (Mapped Diagnostic Context). Configure your logging pattern to include them.

1
2
3
logging:
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{traceId:-},%X{spanId:-}] %msg%n"

This allows you to correlate logs with traces in Grafana or Kibana.

Troubleshooting Common Issues

Traces not appearing?

  1. Check that the OTLP exporter is correctly configured
  2. Verify the backend (Jaeger, Tempo) is running and accessible
  3. Look for errors in logs like Failed to export spans
  4. Ensure spring-boot-starter-actuator is on the classpath

Metrics not showing in Prometheus?

  1. Hit /actuator/prometheus endpoint to verify metrics are exposed
  2. Check Prometheus target status in the UI
  3. Ensure micrometer-registry-prometheus is on the classpath

High memory usage?

Reduce sampling probability or increase the export interval. Also, consider using the OpenTelemetry Collector with batching.

Key Takeaways

Observability in Spring Boot 3 is no longer a headache. With Micrometer and OpenTelemetry, you get a robust, vendor-neutral foundation that grows with your system. Start instrumenting today—your future self (and on-call team) will thank you.