Tracing a Java App Without Touching Its Code: OpenTelemetry Auto-Instrumentation
Attach the OpenTelemetry Java agent at JVM startup to get traces with zero code changes — here's the exact configuration, how to verify spans arrive, and the silent-failure mistakes to avoid.
17 Jul 2026, 08:38 UTC

You have a Java service in production and no tracing. The fastest supported answer is the OpenTelemetry Java agent: a JAR you attach at JVM startup that rewrites bytecode as classes load, wrapping calls to common frameworks (HTTP servers, JDBC, Kafka, gRPC, and dozens more) with spans. No code changes, no recompilation, no new dependencies in your build. The trade-off is that it only sees what it knows how to instrument — your custom business logic stays invisible unless you add manual spans later.
How the agent actually works
The agent registers itself with the JVM via the -javaagent flag before your application's main runs. From there it hooks the class-loading process: when a class from a supported library (say, HttpServlet.service or a JDBC driver) is loaded, the agent transforms its bytecode to start and end spans around the relevant method calls. It also starts an embedded OpenTelemetry SDK configured entirely through environment variables or system properties — there is no otel-config.yaml requirement; environment variables are the standard mechanism.
Because transformation happens at class-load time, anything the agent doesn't recognize passes through untouched. That's the core limitation to keep in mind.
A working startup configuration
Download the agent JAR from the OpenTelemetry Java instrumentation releases (pick a current stable version and note it — you'll want the same version everywhere). Then launch your app like this, as the same user that normally runs the service:
java \
-javaagent:/opt/otel/opentelemetry-javaagent.jar \
-Dotel.service.name=order-service \
-Dotel.traces.exporter=otlp \
-Dotel.exporter.otlp.endpoint=http://otel-collector:4317 \
-Dotel.resource.attributes=deployment.environment=production \
-jar myapp.jarWhat each piece does:
otel.service.name— the single most important setting. Without it, your service shows up asunknown_service:javain the backend and becomes hard to find.otel.traces.exporter=otlp— sends spans over OTLP (the OpenTelemetry Protocol). Port 4317 is gRPC; 4318 is HTTP/protobuf. Match whichever your collector receiver listens on.otel.resource.attributes— extra key-value pairs attached to every span, useful for filtering by environment or version.
If you deploy with Docker or Kubernetes, the same flags go into your container's JAVA_TOOL_OPTIONS environment variable, which the JVM picks up automatically — handy when you can't edit the entrypoint:
JAVA_TOOL_OPTIONS=-javaagent:/opt/otel/opentelemetry-javaagent.jar
OTEL_SERVICE_NAME=order-service
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317Note that environment variables use the uppercase-underscore form (OTEL_SERVICE_NAME) while system properties use dotted lowercase (otel.service.name).
Verifying it actually works
Don't assume silence means success — misconfigured exporters often fail quietly in the background. Check in this order:
- Startup logs. The agent prints an
[otel.javaagent ...]banner early in stdout. If it's missing, the-javaagentflag never took effect (common cause: flag placed after-jar, where it becomes an application argument). - Collector intake. Run the OpenTelemetry Collector with a
debug(orlogging, on older versions) exporter temporarily and watch for incoming spans when you hit your service's endpoints. - Backend inspection. Open your trace viewer (Jaeger, Tempo, or a vendor UI) and confirm spans appear under
order-servicewith HTTP server spans wrapping downstream JDBC or HTTP client spans. A flat list of single spans usually means context propagation isn't crossing a boundary.
Limits and the mistakes that bite people
Custom logic is invisible. The agent instruments libraries, not your code. A span will show "HTTP request took 800ms" but not which internal method consumed it. The supported fix is adding the @WithSpan annotation (from the opentelemetry-instrumentation-annotations artifact) to key methods — the agent picks these up at runtime, so you still avoid wiring an SDK yourself.
Endpoint and protocol mismatches lose data silently. Pointing the gRPC exporter at an HTTP port, or at a collector with no OTLP receiver enabled, produces no obvious application error — traces just never arrive. Verify the collector's receiver config matches the exporter's protocol and port before debugging anything else.
Version skew with an in-app SDK. If your application also bundles the OpenTelemetry SDK or an older agent-shaded dependency, class conflicts can surface as NoClassDefFoundError or spans that never export. Keep one source of truth: either the agent alone, or a manually configured SDK with the agent disabled for that service.
Conflicting resource attributes. Attributes set in both environment variables and application code merge with precedence rules that may surprise you — duplicates or missing values in the backend are the symptom. Define service.name in exactly one place per deployment.
Coverage gaps. Classes loaded through unusual custom class loaders, or work happening on hand-rolled thread pools without context propagation, can produce broken or orphaned spans. If you see traces that stop abruptly at an async boundary, check whether that library is on the agent's supported list before assuming a bug.
Start with the agent, confirm spans flow end to end, then add @WithSpan only where the automatic picture has gaps. That sequence gets you useful traces in an afternoon instead of a migration project.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.