Testing Distributed Tracing with OpenTelemetry
Distributed tracing is one of the most valuable observability tools in a microservices architecture. When a request touches ten services before returning a response, traces are what tell you where the time was spent, where the errors happened, and which service is the actual bottleneck.
But tracing instrumentation is code. Code can have bugs. And those bugs are particularly insidious because they fail silently — your service works fine, but the traces are malformed, missing context propagation, or not emitting at all. You discover this at 2am when you actually need the traces to debug an incident.
This post covers how to test your OpenTelemetry instrumentation — making sure it emits the spans you expect, propagates context correctly, and records the right attributes.
Why Tracing Instrumentation Needs Tests
Consider what can go wrong silently:
- Broken context propagation: A service extracts the
traceparentheader but doesn't inject it into outgoing requests. All downstream spans become orphaned root spans. Your traces are split across multiple trace IDs with no way to correlate them. - Missing spans: A critical database call or external API call isn't instrumented. You see the parent span taking 2 seconds with no explanation why.
- Wrong span names: Automated instrumentation names spans after HTTP method and path (
GET /users/:id), but your cardinality blows up when user IDs end up in span names (GET /users/12345,GET /users/67890, ...). - Dropped attributes: Error details, user IDs, or business context aren't being attached to spans. Traces are present but not useful.
- Wrong span status: Errors are being swallowed without marking the span as failed, so error analysis in Jaeger or Tempo is wrong.
None of these cause your service to misbehave. They only matter when you try to use traces to diagnose a problem.
OpenTelemetry Architecture Overview
OpenTelemetry provides:
- API: The interfaces your code calls (
tracer.Start(),span.SetAttribute(), etc.) - SDK: The implementation that actually records spans and exports them
- Exporters: Plugins that send spans to backends (Jaeger, Zipkin, OTLP-compatible backends like Tempo, Honeycomb, etc.)
- Collector: An optional intermediary that receives, processes, and exports telemetry
For testing, the key insight is that the SDK is pluggable. You can swap the exporter for an in-memory exporter that collects spans during tests, then assert on them.
Testing with the In-Memory Exporter (Java)
The OpenTelemetry Java SDK ships with an in-memory exporter designed for testing. Here's how to use it:
// pom.xml dependency
// <dependency>
// <groupId>io.opentelemetry</groupId>
// <artifactId>opentelemetry-sdk-testing</artifactId>
// <scope>test</scope>
// </dependency>
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension;
import io.opentelemetry.sdk.trace.data.SpanData;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OrderServiceTracingTest {
@RegisterExtension
static final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create();
private final OrderService orderService = new OrderService(
otelTesting.getOpenTelemetry()
);
@Test
void createOrder_emitsSpanWithCorrectAttributes() {
Order order = orderService.createOrder("user-123", List.of("product-456"));
List<SpanData> spans = otelTesting.getSpans();
assertThat(spans).hasSize(1);
SpanData span = spans.get(0);
assertThat(span.getName()).isEqualTo("order.create");
assertThat(span.getKind()).isEqualTo(SpanKind.INTERNAL);
assertThat(span.getStatus().getStatusCode()).isEqualTo(StatusCode.OK);
// Verify business context is recorded
assertThat(span.getAttributes().get(
io.opentelemetry.api.common.AttributeKey.stringKey("user.id")
)).isEqualTo("user-123");
assertThat(span.getAttributes().get(
io.opentelemetry.api.common.AttributeKey.stringKey("order.id")
)).isEqualTo(order.getId());
}
@Test
void createOrder_marksSpanAsErrorOnFailure() {
// Simulate a payment failure
assertThatThrownBy(() -> orderService.createOrder("invalid-user", List.of()))
.isInstanceOf(OrderCreationException.class);
List<SpanData> spans = otelTesting.getSpans();
assertThat(spans).isNotEmpty();
SpanData rootSpan = spans.stream()
.filter(s -> s.getName().equals("order.create"))
.findFirst()
.orElseThrow();
assertThat(rootSpan.getStatus().getStatusCode()).isEqualTo(StatusCode.ERROR);
assertThat(rootSpan.getStatus().getDescription()).contains("invalid user");
// Verify the exception was recorded
assertThat(rootSpan.getEvents()).anyMatch(event ->
event.getName().equals("exception") &&
event.getAttributes().get(
io.opentelemetry.api.common.AttributeKey.stringKey("exception.type")
).contains("OrderCreationException")
);
}
@Test
void createOrder_createsChildSpanForDatabaseCall() {
orderService.createOrder("user-123", List.of("product-456"));
List<SpanData> spans = otelTesting.getSpans();
// Find the database span
SpanData dbSpan = spans.stream()
.filter(s -> s.getKind() == SpanKind.CLIENT)
.filter(s -> s.getName().startsWith("INSERT"))
.findFirst()
.orElseThrow(() -> new AssertionError("No database span found"));
// Verify it's a child of the order.create span
SpanData parentSpan = spans.stream()
.filter(s -> s.getName().equals("order.create"))
.findFirst()
.orElseThrow();
assertThat(dbSpan.getParentSpanId()).isEqualTo(parentSpan.getSpanId());
// Verify db.system attribute (semantic conventions)
assertThat(dbSpan.getAttributes().get(
io.opentelemetry.api.common.AttributeKey.stringKey("db.system")
)).isEqualTo("postgresql");
}
}Testing Context Propagation (Go)
Context propagation is the most commonly broken part of distributed tracing. This Go test verifies that your HTTP client correctly injects trace context into outgoing requests:
package tracing_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
"go.opentelemetry.io/otel/trace"
)
func setupTestTracer(t *testing.T) (*tracetest.SpanRecorder, trace.Tracer) {
t.Helper()
recorder := tracetest.NewSpanRecorder()
provider := sdktrace.NewTracerProvider(
sdktrace.WithSpanProcessor(recorder),
)
otel.SetTracerProvider(provider)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
t.Cleanup(func() {
provider.Shutdown(context.Background())
})
return recorder, provider.Tracer("test")
}
func TestHTTPClientPropagatesTraceContext(t *testing.T) {
recorder, tracer := setupTestTracer(t)
// Set up a downstream server that captures what headers it receives
var receivedTraceParent string
downstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedTraceParent = r.Header.Get("traceparent")
w.WriteHeader(http.StatusOK)
}))
defer downstream.Close()
// Create a root span
ctx, rootSpan := tracer.Start(context.Background(), "test-root")
defer rootSpan.End()
// Make an instrumented HTTP call
client := NewInstrumentedHTTPClient() // your instrumented client
req, _ := http.NewRequestWithContext(ctx, "GET", downstream.URL+"/api/data", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// Verify the traceparent header was injected
if receivedTraceParent == "" {
t.Error("downstream server did not receive traceparent header — context propagation is broken")
}
// Verify the trace ID matches the root span
traceID := rootSpan.SpanContext().TraceID().String()
if !containsTraceID(receivedTraceParent, traceID) {
t.Errorf("traceparent header %q does not contain root trace ID %q", receivedTraceParent, traceID)
}
// Verify an outgoing span was created
spans := recorder.Ended()
outgoingSpans := filterByKind(spans, trace.SpanKindClient)
if len(outgoingSpans) == 0 {
t.Error("no CLIENT span was created for the outgoing HTTP request")
}
// Verify HTTP semantic conventions
outgoing := outgoingSpans[0]
assertAttribute(t, outgoing, string(semconv.HTTPMethodKey), "GET")
assertAttribute(t, outgoing, string(semconv.HTTPStatusCodeKey), "200")
}
func TestHTTPServerExtractsTraceContext(t *testing.T) {
recorder, _ := setupTestTracer(t)
// Start your actual HTTP server (or use httptest)
handler := NewInstrumentedHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The handler should have a span in context
span := trace.SpanFromContext(r.Context())
if !span.SpanContext().IsValid() {
t.Error("no valid span in request context")
}
w.WriteHeader(http.StatusOK)
}))
server := httptest.NewServer(handler)
defer server.Close()
// Send request with traceparent header
req, _ := http.NewRequest("GET", server.URL+"/api/health", nil)
req.Header.Set("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
spans := recorder.Ended()
serverSpans := filterByKind(spans, trace.SpanKindServer)
if len(serverSpans) == 0 {
t.Fatal("no SERVER span was created")
}
serverSpan := serverSpans[0]
// Verify the server span's parent is the upstream trace
if serverSpan.Parent().TraceID().String() != "4bf92f3577b34da6a3ce929d0e0e4736" {
t.Errorf("server span trace ID %q does not match upstream trace ID",
serverSpan.SpanContext().TraceID())
}
}
func filterByKind(spans []sdktrace.ReadOnlySpan, kind trace.SpanKind) []sdktrace.ReadOnlySpan {
var result []sdktrace.ReadOnlySpan
for _, s := range spans {
if s.SpanKind() == kind {
result = append(result, s)
}
}
return result
}Testing Span Cardinality (Preventing High-Cardinality Names)
High-cardinality span names are a common performance problem in tracing backends. This test verifies your instrumentation uses parameterized names rather than including variable data:
@Test
void httpHandler_usesLowCardinalitySpanNames() {
// Simulate requests with different user IDs
orderService.getOrder("order-001");
orderService.getOrder("order-002");
orderService.getOrder("order-999");
List<SpanData> spans = otelTesting.getSpans();
Set<String> spanNames = spans.stream()
.map(SpanData::getName)
.collect(Collectors.toSet());
// All three requests should produce the same span name
assertThat(spanNames).containsOnly("order.get");
// The order ID should be in an attribute, not the name
spans.forEach(span -> {
String orderId = span.getAttributes().get(
AttributeKey.stringKey("order.id")
);
assertThat(orderId).isNotNull();
assertThat(span.getName()).doesNotContain(orderId);
});
}Integration Testing with a Real Collector
Unit tests with in-memory exporters cover instrumentation correctness. But you also want to verify that your OTel collector configuration is correct and that spans actually reach your backend. For this, use a test collector:
package integration_test
import (
"context"
"testing"
"time"
"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/otelcol"
// ... collector dependencies
)
func TestSpansReachCollector(t *testing.T) {
// Start a minimal OTel collector with an in-memory exporter
// This verifies your OTLP export configuration is correct
collectorAddr := startTestCollector(t)
// Configure your service to export to this collector
tp := newTracerProviderWithOTLP(t, collectorAddr)
defer tp.Shutdown(context.Background())
tracer := tp.Tracer("integration-test")
ctx, span := tracer.Start(context.Background(), "integration-test-span")
span.SetAttributes(attribute.String("test.id", "integration-001"))
span.End()
ctx.Done()
// Force flush
tp.ForceFlush(context.Background())
// Give the collector time to receive
time.Sleep(500 * time.Millisecond)
// Query the test collector's captured spans
receivedSpans := getCollectorSpans(t, collectorAddr)
if len(receivedSpans) == 0 {
t.Fatal("no spans received by collector")
}
}Testing Baggage Propagation
OpenTelemetry Baggage allows you to propagate key-value pairs across service boundaries. This is useful for passing request context (tenant ID, feature flags, A/B test variant) through a trace. Test it explicitly:
@Test
void baggageIsForwardedToDownstreamServices() {
Baggage baggage = Baggage.builder()
.put("tenant.id", "acme-corp")
.put("feature.flag", "new-checkout-flow")
.build();
Context ctxWithBaggage = baggage.storeInContext(Context.current());
try (Scope scope = ctxWithBaggage.makeCurrent()) {
orderService.createOrder("user-123", List.of("product-456"));
}
List<SpanData> spans = otelTesting.getSpans();
// Downstream spans should have baggage values available
// (your instrumentation should copy important baggage to span attributes)
SpanData orderSpan = spans.stream()
.filter(s -> s.getName().equals("order.create"))
.findFirst()
.orElseThrow();
assertThat(orderSpan.getAttributes().get(
AttributeKey.stringKey("tenant.id")
)).isEqualTo("acme-corp");
}Sampling Strategy Testing
Not all spans should be exported — at scale, 100% sampling is expensive. Test that your sampling configuration is working as intended:
@Test
void parentBasedSampling_samplesForcedTraces() {
// Simulate an upstream service that forces sampling on
TraceFlags sampledFlags = TraceFlags.getSampled();
SpanContext upstreamCtx = SpanContext.createFromRemoteParent(
TraceId.fromLongs(1L, 1L),
SpanId.fromLong(1L),
sampledFlags,
TraceState.getDefault()
);
Context ctx = Context.root().with(Span.wrap(upstreamCtx));
try (Scope scope = ctx.makeCurrent()) {
orderService.createOrder("user-123", List.of("product-456"));
}
// All spans should be sampled because parent was sampled
List<SpanData> spans = otelTesting.getSpans();
assertThat(spans).isNotEmpty();
spans.forEach(span ->
assertThat(span.getSpanContext().isSampled()).isTrue()
);
}Semantic Conventions Compliance
OpenTelemetry defines semantic conventions — standard attribute names for common operations. Validating compliance ensures your spans are compatible with standard dashboards and queries:
func TestHTTPSpanFollowsSemanticConventions(t *testing.T) {
recorder, _ := setupTestTracer(t)
// Make a request to your service
resp, _ := http.Get("http://localhost:8080/api/orders/123")
defer resp.Body.Close()
spans := recorder.Ended()
serverSpan := findSpanByKind(spans, trace.SpanKindServer)
if serverSpan == nil {
t.Fatal("no server span")
}
attrs := spanAttrsToMap(serverSpan)
// Required HTTP server attributes per OTel semantic conventions
required := []string{
"http.method",
"http.route", // Must be parameterized: /api/orders/:id, not /api/orders/123
"http.status_code",
"net.host.name",
}
for _, key := range required {
if _, ok := attrs[key]; !ok {
t.Errorf("missing required attribute: %s", key)
}
}
// http.route must NOT contain the actual order ID (cardinality)
route, _ := attrs["http.route"].(string)
if route == "/api/orders/123" {
t.Error("http.route contains actual ID — should be parameterized as /api/orders/:id")
}
}Running Tracing Tests in CI
Tracing tests split into two categories for CI:
Fast unit tests (in-memory exporter): Run on every commit. No external dependencies. Catch instrumentation bugs, missing spans, wrong attributes.
Integration tests (test collector): Run in CI on a dedicated stage with a Docker Compose or Kubernetes environment. Verify OTLP export, collector pipeline, and backend ingestion.
# .github/workflows/test.yaml
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tracing unit tests
run: ./gradlew test --tests "*TracingTest"
integration-tests:
runs-on: ubuntu-latest
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
ports:
- 4317:4317
- 4318:4318
steps:
- uses: actions/checkout@v4
- name: Run tracing integration tests
env:
OTEL_EXPORTER_OTLP_ENDPOINT: http://localhost:4317
run: ./gradlew integrationTest --tests "*TracingIntegrationTest"Wrapping Up
Distributed tracing is only useful if the instrumentation is correct. Broken context propagation, missing spans, and wrong attributes fail silently — they don't break your service, they just make your traces useless when you actually need them.
The in-memory exporter pattern makes tracing instrumentation testable without any external infrastructure. The investment is low: a few dozen lines of test code. The payoff is confidence that your traces will actually help you when a production incident requires them.
Start with three tests for every service: one verifying the happy path emits the expected spans, one verifying errors are correctly marked as failures, and one verifying context propagation works correctly. Build from there.