OpenTelemetry: Common Standard for Traces, Logs, and Metrics

1. Overview

When your system splits into multiple services, a single request can pass through five, ten different processes. When an error occurs, you need to know where the request went, which step was slow, and which logs belong to that request. OpenTelemetry (OTel) is an open standard that solves exactly this problem. It unifies how traces, metrics, and logs are collected, then sends them to any backend.

After reading this article, you’ll understand OTel’s core concepts and why it’s gaining attention. You’ll also know how to integrate OTel into a Spring Boot 3 application, run a Collector, write tests for the instrumentation part, and recognize when not to use it.

2. What is OpenTelemetry?

OpenTelemetry is an open-source project under CNCF. It defines how to create, describe, and transmit telemetry data. OTel is not a storage location or viewing interface. That role is handled by backends like Jaeger, Prometheus, Grafana Tempo/Loki, or commercial services.

2.1. Three types of signals

  • Trace: the journey of a request. Each trace consists of multiple spans, where each span is a unit of work—for example, “call PaymentService” or “query orders table”. A span has a start time, end time, and attributes.
  • Metric: aggregated measurements over time, such as requests per second, p99 latency, or failed orders count.
  • Log: discrete event records. OTel attaches a traceId and spanId to each log, so you can jump from a log line to its corresponding trace.

The greatest value lies in correlation. All three signals share the same context and resource description, for instance service.name.

2.2. Main components

Component Role
API Interface for code to create spans and metrics, vendor-agnostic
SDK API implementation: sampling, batching, export
OTLP Data transmission protocol (gRPC or HTTP/protobuf)
Collector Standalone process that receives, processes, and forwards telemetry
Semantic conventions Naming conventions for attributes like http.request.method, db.system…
Instrumentation Libraries or agents that automatically generate telemetry for popular frameworks

2.3. Context propagation

For spans across multiple services to be stitched into a single trace, context must follow the request. With HTTP, OTel uses the W3C Trace Context standard by default via the traceparent header:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

This header has four parts: version, traceId, the parent span’s spanId, and a sampling flag. A service receiving the request reads this header and creates a child span belonging to the same trace.

3. Why is OpenTelemetry getting attention?

  • Vendor neutral: you instrument your code once. Want to switch backends? Simply update the exporter configuration or Collector—no code changes needed.
  • Unifies old standards: OTel emerged from merging OpenTracing and OpenCensus. The community no longer has to choose between two approaches.
  • One standard for all three signals: previously, each data type typically had its own agent and format.
  • Wide ecosystem: many frameworks, cloud providers, and observability tools support OTLP. Spring Boot 3 also integrates via Micrometer.
  • Flexible Collector: you can filter sensitive data, sample, and route to multiple backends at the infrastructure layer without touching your application.

4. Applying OpenTelemetry to Spring Boot 3

Spring Boot 3 uses Micrometer Observation as an abstraction layer. You write one observation, and Micrometer generates both a metric (timer) and a span. The bridge to OTel handles the export.

4.1. Dependencies

<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>
    <!-- Bridge from Micrometer Tracing to OpenTelemetry -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-tracing-bridge-otel</artifactId>
    </dependency>
    <!-- Export traces via OTLP -->
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-exporter-otlp</artifactId>
    </dependency>
    <!-- Export metrics via OTLP -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-otlp</artifactId>
    </dependency>
    <!-- For testing -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-observation-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

You don’t need to specify versions. Spring Boot manages versions for Micrometer and OpenTelemetry libraries via a BOM.

4.2. Configuration

spring:
  application:
    name: order-service

management:
  tracing:
    sampling:
      probability: 1.0   # dev: sample 100%; production should reduce this
  otlp:
    tracing:
      endpoint: http://localhost:4318/v1/traces
    metrics:
      export:
        url: http://localhost:4318/v1/metrics
        step: 30s

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

Each configuration group’s purpose:

  • spring.application.name becomes the service.name. This is the most important attribute for backends to distinguish services.
  • management.tracing.sampling.probability determines the fraction of traces recorded.
  • Both otlp endpoints point to Collector via OTLP/HTTP, default port is 4318.
  • logging.pattern.level injects traceId and spanId from MDC into each log line. This is the simplest way to correlate logs with traces.

4.3. Instrument business logic using the Observation API

HTTP requests to controllers and calls through RestClient/WebClient are automatically instrumented by Spring. What you need to add manually are important business steps:

public record Order(String id, String paymentMethod, long amount) {}
@FunctionalInterface
public interface PaymentService {
    String charge(Order order);
}
@Service
public class CheckoutService {

    private static final Logger log = LoggerFactory.getLogger(CheckoutService.class);

    private final ObservationRegistry registry;
    private final PaymentService paymentService;

    public CheckoutService(ObservationRegistry registry, PaymentService paymentService) {
        this.registry = registry;
        this.paymentService = paymentService;
    }

    public String checkout(Order order) {
        return Observation.createNotStarted("order.checkout", registry)
                .lowCardinalityKeyValue("payment.method", order.paymentMethod())
                .highCardinalityKeyValue("order.id", order.id())
                .observe(() -> {
                    log.info("Processing order {}", order.id());
                    return paymentService.charge(order);
                });
    }
}

Key points in this code:

  • Observation.createNotStarted("order.checkout", registry) creates an observation named order.checkout.
  • lowCardinalityKeyValue adds an attribute with few values (CARD, CASH, WALLET). This attribute appears in both metrics and spans.
  • highCardinalityKeyValue adds an attribute with many possible values like order IDs. It only attaches to spans, not metrics.
  • observe(...) starts the span, measures time, records exceptions if any, then closes the span when the supplier returns.

When running, logs will look like:

 INFO [order-service,4bf92f3577b34da6a3ce929d0e0e4736,a1b2c3d4e5f60718] ... Processing order A-1001

The backend receives two things:

  • A span order.checkout under the HTTP request’s span.
  • A timer metric order.checkout, split by payment.method. From this metric you can calculate call count, total time, and max time.

Note: Prefer the Observation API over calling io.opentelemetry.api.trace.Tracer directly in Spring code. Your code won’t depend on a specific tracing library. One measurement produces both a metric and a trace.

4.4. Alternative: Java agent

If you don’t want to modify code or add dependencies, you can use the OpenTelemetry Java agent. It automatically instruments many popular libraries like JDBC, HTTP clients, and Kafka:

export OTEL_SERVICE_NAME=order-service
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1

java -javaagent:./opentelemetry-javaagent.jar -jar order-service.jar

The agent is suitable for legacy applications or when the ops team wants centralized instrumentation control. The tradeoff is harder fine-grained control, increased startup time, and the agent acts as a “black box” modifying bytecode.

Note: Don’t run both the Java agent and the Micrometer bridge for the same signal type. You’ll get duplicate spans or misaligned context.

5. Running OpenTelemetry Collector

Applications should send data to Collector rather than directly to the backend. Collector is where you batch, filter, and route data. Below is a minimal configuration:

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

processors:
  batch: {}
  attributes/strip-pii:
    actions:
      - key: customer.email
        action: delete

exporters:
  debug:
    verbosity: detailed

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [attributes/strip-pii, batch]
      exporters: [debug]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [debug]
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [debug]

This configuration has four sections:

  • receivers.otlp accepts data via both gRPC (4317) and HTTP (4318).
  • attributes/strip-pii removes the customer.email attribute before data leaves your infrastructure.
  • batch collects data before sending to reduce request count.
  • The debug exporter prints data to console for inspection. In production, replace it with your backend’s exporter. Older Collector versions use logging as the exporter name.

Start Collector via Docker:

docker run --rm -p 4317:4317 -p 4318:4318 \
  -v "$(pwd)/otel-collector.yaml:/etc/otelcol-contrib/config.yaml" \
  otel/opentelemetry-collector-contrib

Call the checkout API, then check Collector’s console. You’ll see a span like:

Span #0
    Trace ID       : 4bf92f3577b34da6a3ce929d0e0e4736
    Parent ID      : 9a8b7c6d5e4f3a2b
    Name           : order.checkout
    Attributes:
         -> payment.method: Str(CARD)
         -> order.id: Str(A-1001)

6. Testing instrumentation

Instrumentation is code too, so it needs testing. TestObservationRegistry lets you verify observations without starting a Spring context or Collector:

import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class CheckoutServiceTest {

    @Test
    void whenCheckout_thenObservationIsRecordedWithPaymentMethod() {
        TestObservationRegistry registry = TestObservationRegistry.create();
        PaymentService paymentService = order -> "PAID-" + order.id();
        CheckoutService service = new CheckoutService(registry, paymentService);

        String result = service.checkout(new Order("A-1001", "CARD", 250_000));

        assertThat(result).isEqualTo("PAID-A-1001");
        TestObservationRegistryAssert.assertThat(registry)
                .hasObservationWithNameEqualTo("order.checkout")
                .that()
                .hasLowCardinalityKeyValue("payment.method", "CARD")
                .hasHighCardinalityKeyValue("order.id", "A-1001")
                .hasBeenStarted()
                .hasBeenStopped();
    }
}

This test asserts three things:

  • The business result is correct.
  • The observation order.checkout was created with the right attributes.
  • The observation was started then stopped. A span that forgot to close is a hard-to-detect production bug.

7. Common mistakes

  • Cardinality explosion: putting userId, orderId, or URL-embedded parameters into metric tags causes the metric series count to explode and storage costs to climb linearly. These values should only go into spans.
  • 100% sampling in production: data volume and cost scale linearly with traffic. Lower probability, or use tail sampling in Collector to retain error traces and slow traces.
  • Losing context in async execution: with @Async, CompletableFuture, or custom thread pools, context doesn’t automatically follow to the new thread. You must wrap the executor with context propagation, e.g., ContextExecutorService from the context-propagation library, or use Spring-managed executors that are pre-configured.
  • Leaking sensitive data: emails, card numbers, or tokens can end up in attributes or logs. Filter in your code first, then add another filter layer in Collector.
  • Arbitrary naming: ignoring semantic conventions makes dashboards and alerts hard to reuse across services.
  • Skipping Collector: sending directly from the app to the backend means you must redeploy all services each time you change backends.

8. When NOT to use (or not yet)

  • Small application, single instance, few dependencies: structured logs plus a few metrics from Actuator are often enough. Distributed tracing adds little value when there’s nothing to distribute.
  • Team lacks a backend and operations: OTel is just a pipeline. If no one views the data, you’re only paying in CPU, network, and storage.
  • System critically sensitive to latency or resources: for example, embedded systems or hot paths measured in microseconds. Measure the overhead before enabling instrumentation widely.
  • Expecting OTel to replace your entire existing toolkit in one go: signal maturity and language support vary. Check support status for what you plan to use and migrate step by step, starting with tracing.

9. Conclusion

OpenTelemetry unifies how traces, metrics, and logs are created and transmitted through a common API, OTLP protocol, and Collector. This lets you decouple instrumentation code from backend choice.

With Spring Boot 3, the cleanest path has four steps. First, use Micrometer Observation for one measurement yielding both a metric and a span. Second, export via OTLP to Collector. Third, inject traceId into logs. Fourth, write tests for important observations. In parallel, control cardinality, sampling, and sensitive data from the start.

If your system is still small, there’s no rush. As services grow and debugging starts taking hours, OTel is an investment worth making.

opentelemetryobservabilityspring-boottracingtech-trends