Post

Spring Boot Observability Stack — Metrics, Logs, Traces with OpenTelemetry

Build a complete observability stack for Spring Boot with OpenTelemetry, Grafana, Prometheus, Loki, and Tempo. Covers metrics collection, structured logging, distributed tracing, custom spans, correlation IDs, dashboards, and alerting — with Docker Compose and working code.

Spring Boot Observability Stack — Metrics, Logs, Traces with OpenTelemetry

The Problem

Your distributed tracing post covers trace propagation between services. But observability is three pillars — not one:

  1. Metrics — is the system healthy? (latency, error rate, throughput)
  2. Logs — what happened? (structured events with context)
  3. Traces — where did it go? (request path across services)

In production you need all three, correlated. When a 500 error fires, you should jump from the metric alert → to the log entry → to the full trace — in one click.

This post builds that complete stack with the LGTM stack (Loki, Grafana, Tempo, Mimir/Prometheus).


What We Are Building

flowchart TD
    subgraph "Spring Boot Application"
        A[Business Logic]
        B[Micrometer Metrics]
        C[Structured Logs + TraceID]
        D[OpenTelemetry Traces]
    end

    subgraph "Collection"
        B --> E[Prometheus]
        C --> F[Loki]
        D --> G[Tempo]
    end

    subgraph "Visualization"
        E --> H[Grafana]
        F --> H
        G --> H
    end

    style H fill:#feca57,color:#333
    style E fill:#48dbfb,color:#333
    style F fill:#2ed573,color:#fff
    style G fill:#ff6b6b,color:#fff

diagram

One Grafana dashboard where you can:

  • See request rate and error rate (metrics from Prometheus)
  • Click an error → see the log line (from Loki)
  • Click the trace ID in the log → see the full distributed trace (from Tempo)

Prerequisites

ComponentVersion
Java21+
Spring Boot3.5+
Docker ComposeLatest
Maven3.9+

Step 1: Infrastructure (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
24
25
26
27
28
29
30
31
32
33
34
35
services:
  prometheus:
    image: prom/prometheus:v2.53.0
    container_name: prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml

  loki:
    image: grafana/loki:3.1.0
    container_name: loki
    ports:
      - "3100:3100"

  tempo:
    image: grafana/tempo:2.5.0
    container_name: tempo
    ports:
      - "3200:3200"    # Tempo API
      - "4317:4317"    # OTLP gRPC
      - "4318:4318"    # OTLP HTTP
    volumes:
      - ./observability/tempo.yml:/etc/tempo/config.yml
    command: ["-config.file=/etc/tempo/config.yml"]

  grafana:
    image: grafana/grafana:11.1.0
    container_name: grafana
    ports:
      - "3000:3000"
    environment:
      GF_SECURITY_ADMIN_PASSWORD: admin
    volumes:
      - ./observability/grafana-datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml

Prometheus config (observability/prometheus.yml)

1
2
3
4
5
6
7
8
global:
  scrape_interval: 15s

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

Tempo config (observability/tempo.yml)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server:
  http_listen_port: 3200

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

storage:
  trace:
    backend: local
    local:
      path: /var/tempo/traces

Grafana datasources (observability/grafana-datasources.yml)

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    url: http://prometheus:9090
    isDefault: true
  - name: Loki
    type: loki
    url: http://loki:3100
  - name: Tempo
    type: tempo
    url: http://tempo:3200
1
docker compose up -d

Step 2: Spring Boot 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
32
33
<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>

    <!-- Metrics → Prometheus -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>

    <!-- Tracing → OpenTelemetry → Tempo -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-tracing-bridge-otel</artifactId>
    </dependency>
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-exporter-otlp</artifactId>
    </dependency>

    <!-- Structured Logging → Loki -->
    <dependency>
        <groupId>com.github.loki4j</groupId>
        <artifactId>loki-logback-appender</artifactId>
        <version>1.5.1</version>
    </dependency>
</dependencies>
DependencyPurpose
micrometer-registry-prometheusExposes metrics at /actuator/prometheus
micrometer-tracing-bridge-otelBridges Micrometer tracing to OpenTelemetry
opentelemetry-exporter-otlpExports traces to Tempo via OTLP
loki-logback-appenderShips logs directly to Loki

Step 3: Application Configuration

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

management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,metrics
  tracing:
    sampling:
      probability: 1.0  # Sample 100% in dev. Use 0.1 (10%) in production.
  otlp:
    tracing:
      endpoint: http://localhost:4318/v1/traces

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

The correlation pattern injects traceId and spanId into every log line. This is the glue between logs and traces.


Step 4: Logback Configuration for Loki

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
<!-- src/main/resources/logback-spring.xml -->
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <!-- Console with trace correlation -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- Ship to Loki with labels -->
    <appender name="LOKI" class="com.github.loki4j.logback.Loki4jAppender">
        <http>
            <url>http://localhost:3100/loki/api/v1/push</url>
        </http>
        <format>
            <label>
                <pattern>app=${spring.application.name},level=%level</pattern>
            </label>
            <message>
                <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n</pattern>
            </message>
        </format>
    </appender>

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

Every log line in Loki includes the traceId. Click it in Grafana → jump to the full trace in Tempo.


Step 5: Custom Metrics

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
32
@Service
public class PaymentService {

    private final Counter paymentCounter;
    private final Timer paymentTimer;

    public PaymentService(MeterRegistry meterRegistry) {
        this.paymentCounter = Counter.builder("payments.processed")
                .description("Total payments processed")
                .tag("type", "all")
                .register(meterRegistry);

        this.paymentTimer = Timer.builder("payments.duration")
                .description("Payment processing duration")
                .register(meterRegistry);
    }

    public PaymentResult processPayment(PaymentRequest request) {
        return paymentTimer.record(() -> {
            // Business logic
            PaymentResult result = doProcess(request);

            paymentCounter.increment();
            Counter.builder("payments.processed")
                    .tag("status", result.status())
                    .register(meterRegistry)
                    .increment();

            return result;
        });
    }
}

These custom metrics appear in Prometheus alongside the auto-configured ones (HTTP request duration, JVM memory, thread pools, etc.).


Step 6: Custom Spans for Fine-Grained Tracing

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
@Service
public class PaymentService {

    private final ObservationRegistry observationRegistry;

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

    public PaymentResult processPayment(PaymentRequest request) {
        Observation observation = Observation.createNotStarted("payment.process", observationRegistry)
                .lowCardinalityKeyValue("payment.method", request.method())
                .highCardinalityKeyValue("payment.id", request.paymentId());

        return observation.observe(() -> {
            validatePayment(request);
            PaymentResult result = chargeProvider(request);
            saveResult(result);
            return result;
        });
    }

    private void validatePayment(PaymentRequest request) {
        Observation.createNotStarted("payment.validate", observationRegistry)
                .observe(() -> {
                    // Validation logic — appears as a child span
                });
    }
}

This creates nested spans: payment.process → payment.validate → payment.charge visible in Tempo.


Step 7: Correlation in Action

When a request flows through the system:

1
2
3
2026-08-22 10:30:01 [http-nio-8080-exec-1] INFO  [abc123def456,span789] PaymentController - Received payment request
2026-08-22 10:30:01 [http-nio-8080-exec-1] INFO  [abc123def456,span790] PaymentService - Processing payment PAY-001
2026-08-22 10:30:02 [http-nio-8080-exec-1] INFO  [abc123def456,span791] PaymentService - Payment PAY-001 completed

The abc123def456 trace ID is the same across all log lines for this request. In Grafana:

  1. Prometheus shows a spike in payments.duration → you click to investigate
  2. Loki shows the log lines with that trace ID → you see the error message
  3. Tempo shows the full trace → you see which span was slow (the external provider call)

Grafana Dashboard Queries

Request rate (Prometheus)

rate(http_server_requests_seconds_count{application="payment-service"}[5m])

Error rate (Prometheus)

rate(http_server_requests_seconds_count{application="payment-service", status=~"5.."}[5m])
/ rate(http_server_requests_seconds_count{application="payment-service"}[5m])

P99 latency (Prometheus)

histogram_quantile(0.99, rate(http_server_requests_seconds_bucket{application="payment-service"}[5m]))

Find error logs (Loki)

{app="payment-service"} |= "ERROR"

Find logs for a specific trace (Loki)

{app="payment-service"} |= "abc123def456"

Auto-Configured Metrics (Free)

Spring Boot + Micrometer gives you these with zero code:

MetricWhat It Measures
http_server_requests_secondsRequest duration, status codes, endpoints
jvm_memory_used_bytesHeap and non-heap memory
jvm_threads_statesThread pool utilization
system_cpu_usageCPU utilization
hikaricp_connections_activeDatabase connection pool
spring_kafka_listener_secondsKafka consumer processing time

Production Sampling

Don’t trace 100% in production — it’s expensive:

1
2
3
4
management:
  tracing:
    sampling:
      probability: 0.1  # 10% of requests

For errors, always capture:

1
2
3
4
@Bean
public SpanExportingPredicate alwaysExportErrors() {
    return span -> span.getStatus() == StatusData.error() || Math.random() < 0.1;
}

Common Problems

SymptomCauseFix
No traces in TempoOTLP endpoint wrongVerify management.otlp.tracing.endpoint
No metrics in PrometheusActuator not exposedAdd management.endpoints.web.exposure.include=prometheus
Logs missing traceIdNo tracing bridgeAdd micrometer-tracing-bridge-otel dependency
Grafana shows no dataDatasource misconfiguredCheck URLs in grafana-datasources.yml
High cardinality metricsUsing user IDs as tagsUse highCardinalityKeyValue for high-cardinality, not tags
Trace not connecting across servicesMissing propagation headerEnsure traceparent header is forwarded

Full Working Example

The complete project with Docker Compose, Grafana dashboards, and instrumented app is at github.com/AnupamSinha/spring-boot-examples/tree/main/11-observability.

1
2
3
4
5
git clone https://github.com/AnupamSinha/spring-boot-examples/tree/main/11-observability
cd spring-boot-observability
docker compose up -d   # Starts Prometheus, Loki, Tempo, Grafana
./mvnw spring-boot:run
# Open Grafana at http://localhost:3000 (admin/admin)

References

This post is licensed under CC BY 4.0 by the author.