Observability Beyond Logs: Distributed Tracing with OpenTelemetry in Java
In the age of microservices and cloud-native systems, debugging with logs alone is like navigating a maze with a flashlight—you only see a piece of the puzzle. Distributed tracing offers a complete map.
In this article, you’ll learn how to integrate OpenTelemetry tracing into a Java (Spring Boot) application, send traces to Jaeger or Grafana Tempo, and dramatically improve your observability and debugging capabilities.
The Problem: Logs Don’t Tell the Whole Story
Logs are useful, but:
- They’re often siloed per service.
- Correlating log lines across services is painful.
- Latency issues and errors may not surface clearly.
As systems get more distributed, observability must evolve. Enter OpenTelemetry.
What is OpenTelemetry?
OpenTelemetry (OTel) is a vendor-neutral standard for collecting telemetry data—traces, metrics, and logs—from your applications.
In this article, we’ll focus on distributed tracing, which allows you to:
- See how a single request flows through services.
- Understand bottlenecks via latency visualization.
- Automatically correlate logs, traces, and metrics.
Step 1: Add OpenTelemetry to Your Spring Boot Project
Dependencies
Use OpenTelemetry Java agent for auto-instrumentation, or manually add dependencies for full control.
Here’s a manual example using opentelemetry-sdk and opentelemetry-exporter-otlp:
<!-- pom.xml --> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-api</artifactId> <version>1.34.0</version> </dependency> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-sdk</artifactId> <version>1.34.0</version> </dependency> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-exporter-otlp</artifactId> <version>1.34.0</version> </dependency>
Step 2: Configure OpenTelemetry in Your Application
Initialize OpenTelemetry with OTLP (OpenTelemetry Protocol) exporter:
public class TracingConfig {
public static OpenTelemetry initOpenTelemetry() {
OtlpGrpcSpanExporter spanExporter = OtlpGrpcSpanExporter.builder()
.setEndpoint("http://localhost:4317")
.build();
SdkTracerProvider tracerProvider = SdkTracerProvider.builder()
.addSpanProcessor(SimpleSpanProcessor.create(spanExporter))
.setResource(Resource.getDefault()
.merge(Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, "my-java-service"))))
.build();
OpenTelemetrySdk openTelemetry = OpenTelemetrySdk.builder()
.setTracerProvider(tracerProvider)
.build();
return openTelemetry;
}
}
Call initOpenTelemetry() at application startup.
Tip: Use environment variables or config files in production, not hardcoded endpoints.
Step 3: Add Tracing to Your Code
Grab a tracer and create spans manually:
OpenTelemetry openTelemetry = TracingConfig.initOpenTelemetry();
Tracer tracer = openTelemetry.getTracer("my-app");
@GetMapping("/hello")
public String hello() {
Span span = tracer.spanBuilder("handleHelloRequest").startSpan();
try (Scope scope = span.makeCurrent()) {
// business logic here
return "Hello, tracing!";
} finally {
span.end();
}
}
Or let Spring auto-instrument it using OpenTelemetry instrumentation libraries.
Step 4: Export Traces to Jaeger or Tempo
Option 1: Jaeger
Spin up Jaeger locally:
docker run -d --name jaeger \ -e COLLECTOR_OTLP_ENABLED=true \ -p 16686:16686 \ -p 4317:4317 \ jaegertracing/all-in-one:latest
- Visit
http://localhost:16686to view traces. - Set endpoint to
http://localhost:4317in your exporter.
Option 2: Grafana Tempo
Tempo works well with Grafana dashboards and Loki logs.
Spin it up using Docker Compose:
tempo:
image: grafana/tempo:latest
ports:
- "4317:4317" # OTLP gRPC
Use Grafana to visualize traces alongside logs and metrics.
Step 5: Correlate Spans Across Services
OpenTelemetry automatically injects context headers (like traceparent) into HTTP requests.
In a multi-service setup:
- Client Service starts a span and sends HTTP request.
- Server Service extracts trace context and continues the trace.
Enable context propagation like so:
TextMapPropagator propagator = W3CTraceContextPropagator.getInstance();
Context context = propagator.extract(Context.current(), headers, MapGetter.INSTANCE);
Span span = tracer.spanBuilder("downstreamCall").setParent(context).startSpan();
Frameworks like Spring Cloud Sleuth (deprecated in favor of Micrometer Tracing) or OpenTelemetry’s Java Agent can do this automatically.
Step 6: Analyze and Debug with Traces
Once your application is emitting traces:
- Open Jaeger or Grafana
- Search by service name or trace ID
- Click into a trace to:
- View duration and breakdown
- Spot bottlenecks
- Trace errors through stack
🔗 You can even correlate trace IDs in logs by injecting them using
MDCor structured logging.
Real-World Use Case: Debugging a Slow API
Imagine a client calls /checkout, which calls:
/inventory→/pricing→/email
With tracing:
- You see the end-to-end latency in Jaeger.
- Notice
/pricingadds 2 seconds delay. - Drill into the span to view tags and logs.
You just saved hours of log spelunking.
Clean Up and Best Practices
- Name spans meaningfully (
"loadUser","saveToDatabase") - Use attributes (tags) to enrich spans:
span.setAttribute("user.id", userId);
span.setAttribute("operation", "checkout");
- Don’t trace everything. Use sampling for high-throughput services.
- Avoid sensitive data in spans (e.g., passwords, PII).
Final Thoughts
Distributed tracing isn’t just a nice-to-have—it’s a game-changer for cloud-native observability. With OpenTelemetry, you get a standardized, vendor-neutral way to:
- Understand request flows
- Pinpoint performance issues
- Reduce mean-time-to-resolution (MTTR)
And when integrated with tools like Jaeger or Grafana Tempo, your traces become a powerful storytelling tool about what your app is really doing.

