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
traceIdandspanIdto 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.namebecomes theservice.name. This is the most important attribute for backends to distinguish services.management.tracing.sampling.probabilitydetermines the fraction of traces recorded.- Both
otlpendpoints point to Collector via OTLP/HTTP, default port is 4318. logging.pattern.levelinjectstraceIdandspanIdfrom 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 namedorder.checkout.lowCardinalityKeyValueadds an attribute with few values (CARD,CASH,WALLET). This attribute appears in both metrics and spans.highCardinalityKeyValueadds 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.checkoutunder the HTTP request’s span. - A timer metric
order.checkout, split bypayment.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.Tracerdirectly 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.otlpaccepts data via both gRPC (4317) and HTTP (4318).attributes/strip-piiremoves thecustomer.emailattribute before data leaves your infrastructure.batchcollects data before sending to reduce request count.- The
debugexporter prints data to console for inspection. In production, replace it with your backend’s exporter. Older Collector versions useloggingas 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.checkoutwas 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.,ContextExecutorServicefrom 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.