
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Debugging latency spikes or silent failures in distributed JVM applications is nearly impossible without unified telemetry. Observability for Java with OpenTelemetry solves this by standardizing how you collect traces, metrics, and logs across Spring Boot, Quarkus, and legacy stacks. Instead of vendor-locked agents, you gain portable, high-fidelity data that works with any backend.
How does observability for Java with OpenTelemetry work?
At its core, OpenTelemetry decouples data generation from data analysis. For Java, this happens through two primary mechanisms: bytecode manipulation and API injection. The Java Agent uses the Java Instrumentation API to modify classes at runtime, injecting context propagation and span creation into supported libraries like Hibernate, Kafka, and Tomcat without touching your source code. This is distinct from older APM tools because the data format is open and vendor-neutral.
The agent acts as a bridge between your application internals and the outside world. It automatically propagates W3C Trace Context headers across HTTP, gRPC, and messaging boundaries. When a request enters your Spring Boot controller, the agent creates a server span. When that controller calls a PostgreSQL database, it creates a child client span. These are linked by a shared trace ID, enabling end-to-end visibility even in complex microservices architectures common in Nepal's growing fintech sector where transaction integrity is paramount.
How do you configure the OpenTelemetry Java Agent?
The most reliable path to instrumenting an app with OpenTelemetry is the zero-code Java Agent. Download the latest opentelemetry-javaagent.jar from the official GitHub releases. Attach it to your JVM startup command. This method survives framework upgrades and requires no build-time dependencies.
java -javaagent:./opentelemetry-javaagent.jar \
-Dotel.service.name=payment-service \
-Dotel.exporter.otlp.endpoint=http://otel-collector:4317 \
-Dotel.metrics.exporter=prometheus \
-jar payment-service-1.0.0.jar Essential configuration properties
- otel.service.name: Mandatory logical name. Use consistent naming conventions (e.g.,
env-team-service) to avoid dashboard fragmentation. - otel.exporter.otlp.endpoint: Your OTel Collector or backend ingestion URL. Prefer gRPC (port 4317) over HTTP/protobuf for lower overhead in high-throughput Java apps.
- otel.instrumentation.[name].enabled: Disable noisy or problematic instrumentations. For example,
-Dotel.instrumentation.jdbc.enabled=falseif you use a custom connection pool that conflicts. - otel.traces.sampler: Control volume. Use
parentbased_tracealwaysfor debugging ortraceidratiowith a 0.1 value for high-traffic production systems to manage cost.
In containerized environments like Kubernetes, pass these as environment variables rather than JVM flags. This keeps your Dockerfile generic. Set OTEL_SERVICE_NAME via the Downward API to automatically tag pods with their deployment name, ensuring metadata accuracy without manual intervention.
When should you use manual instrumentation in Java?
Auto-instrumentation covers infrastructure and framework boundaries, but it cannot understand business semantics. You need the OpenTelemetry SDK when tracking domain-specific operations like "calculate-tax" or "validate-kyc-document". Manual spans provide the context that turns raw traces into actionable intelligence during incident response.
Add the opentelemetry-api dependency to your project. Never add the full SDK as a compile dependency; let the agent inject it at runtime to prevent version conflicts. Use the global tracer provider to create spans around critical paths.
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
Tracer tracer = GlobalOpenTelemetry.getTracer("payment-domain");
public void processRefund(String orderId) {
Span span = tracer.spanBuilder("process-refund")
.setAttribute("order.id", orderId)
.setAttribute("refund.type", "FULL")
.startSpan();
try (var scope = span.makeCurrent()) {
// Business logic here
refundGateway.execute(orderId);
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR);
throw e;
} finally {
span.end();
}
} Always use try-with-resources or explicit makeCurrent() scopes. Failing to close scopes causes context leaks that corrupt downstream traces and create memory pressure in long-running JVM processes. In my experience auditing financial systems, missing span.end() calls are the #1 cause of incomplete trace data.
How do you correlate logs and metrics with Java traces?
Traces alone don't tell the full story. True correlation between metrics, logs, and traces requires injecting trace context into your logging MDC and metric attributes. The OTel Java Agent handles this automatically for SLF4J/Logback and Micrometer, but you must verify the configuration.
| Signal Type | Correlation Mechanism | Key Configuration | Common Pitfall |
|---|---|---|---|
| Logs | MDC Injection (trace_id, span_id) | -Dotel.logs.exporter=otlp | Missing MDC pattern in logback.xml |
| Metrics | Exemplars + Resource Attributes | -Dotel.metrics.exemplar.filter=trace_based | Prometheus scraping ignoring exemplars |
| Traces | W3C Context Propagation | Default enabled | Custom thread pools breaking context |
For Logback, update your pattern layout to include %X{trace_id}-%X{span_id}. This allows you to jump directly from a log line in Grafana Loki to the exact span in Tempo or Jaeger. For metrics, enable exemplars to attach trace IDs to histogram buckets. This is invaluable when investigating p99 latency spikes—you can see which specific requests contributed to the tail latency rather than guessing.
What are common performance pitfalls with OTel in Java?
Observability has a cost. Without guardrails, monitoring golden signals can itself degrade the signals you're trying to measure. The Java Agent adds overhead to every instrumented call. In high-throughput systems processing thousands of TPS, unbounded sampling and synchronous exports will increase GC pressure and CPU usage.
- Unbounded Cardinality: Never put user IDs, session tokens, or IP addresses as span attributes or metric labels. This explodes backend storage costs and slows queries. Use allowlists for attribute keys.
- Synchronous Exporters: Always use async exporters with batching. The default OTLP exporter batches by time and size, but misconfigured timeouts can block application threads during network partitions.
- Context Propagation Failures: Custom executors and reactive streams often break ThreadLocal context. Wrap your runnables with
Context.current().wrap(runnable)or use the OTel-provided concurrent utilities. - Over-Instrumentation: Disable instrumentations for internal health checks, readiness probes, and admin endpoints. These generate noise and consume quota without adding debugging value.
Implement tail-based sampling in your OTel Collector to keep 100% of error traces while sampling only 1% of successful ones. This preserves debugging fidelity for failures while controlling costs for happy paths. For Java specifically, monitor the otel.javaagent thread group in your profiler. If you see excessive allocation in io.opentelemetry.sdk.trace.export.BatchSpanProcessor, increase the batch size or reduce the export interval.
Next steps for production-ready Java observability
Start with the Java Agent in staging. Validate that trace context propagates correctly across all service boundaries before enabling in production. Define meaningful SLIs and SLOs based on the telemetry you collect—don't just dashboards for dashboards' sake. Treat your observability configuration as code: version your agent configs, sampler rules, and collector pipelines alongside your application.
If you're struggling with high-cardinality explosions, context loss in async flows, or tuning OTel for compliance-heavy environments, reach out for a consultation. I help teams build observable Java systems that survive audits and scale without breaking the bank.