Practical OpenTelemetry — Part 4: Auto-Instrumentation, Zero Code Changes
There’s a moment in every OpenTelemetry adoption where someone asks the question that decides how hard the whole project will be: “Do we have to instrument all of this by hand?”
Chapter 4 of Practical OpenTelemetry answers it with one of the most practical chapters in the book: no — the fastest, most reliable path is auto-instrumentation, where a language agent injects telemetry into your application and its libraries without a single code change. This post covers how it works, the two adoption models, and the extension points for when the defaults aren’t right.
Two Ways to Turn On Instrumentation
The book distinguishes two initialization models:
Zero-touch model: application code stays completely unchanged. An external component initializes the SDK and instrumentation libraries:
- Java: attach
opentelemetry-javaagent.jarvia-javaagent - Python: run
opentelemetry-instrument python app.py - .NET: CLR profiler
Implementation model: you add the SDK and instrumentation libraries to your code and initialize them at startup. More flexible, but more code, more maintenance, and (in Java) fewer covered libraries than bytecode injection.
The book’s guidance is unambiguous: prefer zero-touch wherever it’s available. Less maintenance, easier SDK upgrades (upgrade the agent, not your dependencies), and no risk of half-configured initialization. Save the implementation model for cases where an agent genuinely can’t be used — serverless restrictions, exotic runtimes, or deep customization needs.
For the rest of this post I’ll use Java as the running example, since the book does, but the mechanics transfer: every language achieves the same effect through its idiomatic trick — bytecode injection in Java, monkey patching in Python, method wrapping in JavaScript.
Figure: one JVM flag gets you spans, metrics, and context propagation across 122 libraries — no code changes.
What You Get Out of the Box — a Real Walkthrough
The book doesn’t just describe this — it runs a full worked example, a Dropwizard REST service called dropwizard-example backed by a database, with a local OTel Collector, Jaeger, and Prometheus stood up via Docker Compose. Worth walking through verbatim, because the entire “before” state is: an ordinary, uninstrumented JAR.
# Download the OpenTelemetry Java agent
curl -o ./opentelemetry-javaagent.jar \
-L https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v1.21.0/opentelemetry-javaagent.jar
# Start the application — this is the entire "instrumentation" step
java -javaagent:opentelemetry-javaagent.jar \
-Dotel.service.name=dropwizard-example \
-jar target/dropwizard-example-2.1.1.jar \
server example.yml
Exercise it with an ordinary request:
curl -H "Content-Type: application/json" -X POST \
-d '{"fullName":"Other Person","jobTitle":"Other Title"}' \
http://localhost:8080/people
With zero code changes, opening the Jaeger UI (localhost:16686) and selecting dropwizard-example shows a full trace for the /people operation, down to individual database calls — spans contributed independently by the Jetty, JAX-RS, Hibernate, and JDBC instrumentation libraries, each carrying the right semantic-convention attributes (HTTP status, DB statements) automatically. Opening Prometheus (localhost:9090) shows metrics for the same libraries: http_server_*, db_client_*, process_runtime_jvm_* (memory, GC, threads), plus the SDK’s own self-monitoring metrics like otlp_exporter_*. Context propagation (W3C TraceContext + Baggage headers, by default) is wired up the same way — nothing in the application knows any of this is happening.
The configuration surface is deliberately small and layered. Precedence, highest to lowest: system properties → environment variables → a configuration file (otel.javaagent.configuration-file / OTEL_JAVAAGENT_CONFIGURATION_FILE) → the ConfigPropertySource SPI. The two properties you’ll touch most:
OTEL_SERVICE_NAME=payment-service # sets service.name
OTEL_RESOURCE_ATTRIBUTES=deployment.env=prod,team=payments
OTEL_RESOURCE_ATTRIBUTES accepts W3C Baggage format — comma-separated key=value pairs — and overwrites any detected values. That last word matters: you can correct what automatic resource detection got wrong.
When the Defaults Are Wrong: Extensions
The book’s worked example here is excellent, because it’s a failure mode every team with a cache has hit:
Your cache service returns 404 for uncached resources. By default, the HTTP client span gets marked
otel.status_code: ERRORfor every cache miss. Your error ratios are garbage, and worse, error-based sampling now biases toward… cache misses.
The fix isn’t “don’t use the agent” — it’s an extension: a custom SpanProcessor that adds an identifying attribute at span creation (processors can’t modify finished spans), paired with a Collector rule that unsets the error status for those specific 404s. The span creation attribute gives the Collector the signal it needs to apply the fix downstream.
Extensions in Java are implemented via the AutoConfigurationCustomizerProvider SPI and loaded with otel.javaagent.extensions — a comma-separated list of extension JARs. Here’s the real shape of that extension, assuming a CacheClientSpanProcessor class already implements the tagging logic:
public class CacheClientCustomizer
implements AutoConfigurationCustomizerProvider {
@Override
public void customize(AutoConfigurationCustomizer customizer) {
customizer.addTracerProviderCustomizer(this::configureTracerProvider);
}
private SdkTracerProviderBuilder configureTracerProvider(
SdkTracerProviderBuilder builder,
ConfigProperties config) {
return builder.addSpanProcessor(new CacheClientSpanProcessor());
}
}
Packaging is entirely SPI-driven — the extension JAR declares its provider under META-INF/services/, named after the SPI interface itself:
./META-INF/MANIFEST.MF
./META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider
./com/example/CacheClientCustomizer.class
./com/example/CacheClientSpanProcessor.class
— and the contents of that services file is nothing more than the fully-qualified class name: com.example.CacheClientCustomizer. Loading it back into the dropwizard-example from earlier is one more flag on the same command:
java -javaagent:opentelemetry-javaagent.jar \
-Dotel.service.name=dropwizard-example \
-Dotel.javaagent.extensions=cache-client-processor.jar \
-jar target/dropwizard-example-2.1.1.jar \
server example.yml
The same SPI mechanism powers more exotic needs too — custom samplers, custom resource providers — all wired in through the same AutoConfigurationCustomizer entry point, none of it requiring the agent’s own source to change.
Turning Things Off (Deliberately)
The counterintuitive part of auto-instrumentation: at scale, suppression is a feature. The book lists the knobs:
otel.instrumentation.[name].enabled=false— disable one library’s instrumentationotel.instrumentation.common.default-enabled=false— opt-in instead of opt-out; safer for agents baked into shared base imagesotel.javaagent.enabled=false— kill the agent entirely
Why would you suppress? Two recurring reasons:
- Cost — controller-layer spans (JAX-RS wrappers that add nothing beyond the underlying server span) inflate volume without adding debugging value. Disable them and keep the operation-name extraction.
- Correctness — some instrumentations misrepresent specific use cases (the cache-404 problem above is a milder cousin of this).
The book’s advice: suppressing instrumentation or trimming resource attributes can cut meaningful cost without hurting observability — and often improves it by reducing noise. Chapter 10 (sampling) and Chapter 12 (keeping telemetry valuable) return to this theme; the instinct should already be forming: the default is a starting point, not the destination.
Beyond Java: The Same Idea, Different Mechanism
This section is outside the book’s scope — Practical OpenTelemetry stays in Java throughout, for good reason: bytecode instrumentation is a genuinely Java-specific mechanism. It’s worth a detour here because the “one flag, zero code” promise doesn’t travel identically to every language, and knowing why helps set the right expectations if your stack isn’t Java.
The Java agent works by rewriting bytecode as classes load — there’s no equivalent hook in every runtime, so “auto-instrumentation” means something different per language.
Python comes closest to Java’s experience. opentelemetry-bootstrap -a install inspects your installed packages and pulls in matching instrumentors, then you launch through a wrapper instead of a -javaagent flag:
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install
opentelemetry-instrument --service_name my-service python app.py
Same zero-code-change deal, same config-via-environment-variable pattern (OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT) as the Java agent’s system properties.
Node.js has no bytecode weaving either, but require-hook patching gets close: @opentelemetry/auto-instrumentations-node monkey-patches known modules (Express, pg, Redis, …) when preloaded before your app’s own code runs:
npm install @opentelemetry/auto-instrumentations-node
node --require '@opentelemetry/auto-instrumentations-node/register' app.js
Go is the honest exception. Go compiles to a static binary with no bytecode to rewrite at load time, so historically “auto-instrumentation” in Go meant manually wrapping calls with helpers like otelhttp.NewHandler(...) — not zero-code at all. The OpenTelemetry project’s answer is opentelemetry-go-instrumentation, which uses eBPF to attach probes to a running, unmodified binary from outside the process — a fundamentally different mechanism from bytecode injection, achieving a similar zero-code-change result by intercepting at the kernel/syscall boundary instead.
The pattern that does travel cleanly across all of them, because it’s a wire-format and data-model guarantee rather than a language mechanism: whichever agent, wrapper, or eBPF probe generates the telemetry, it lands on the same OTLP wire format and the same semantic conventions from Part 3 — a Collector pipeline built once, as in Part 9, doesn’t care which of these produced the spans it’s receiving.
One Line Changes Everything
In Java, at least, there’s something almost anticlimactic about how little zero-touch requires — one JVM flag — compared to what it delivers: spans across your entire stack, metrics for every service, and correct context propagation, all following semantic conventions you didn’t have to memorize. The chapter’s lesson isn’t technical so much as strategic: the cheapest instrumentation is the one someone else maintains. Every line you write by hand is a line you have to keep correct across library upgrades; every bytecode injection the agent makes is automatically kept in sync by the OpenTelemetry community.
The tradeoff is control. In Part 5, we’ll look at what happens between services — context, baggage, and propagators — which is where the automatic correlation actually earns its keep.
Next: Practical OpenTelemetry — Part 5: Context, Baggage, and Propagators
References
Have thoughts on this?
I read every email. If something resonated, felt wrong, or made you think — I'd love to hear from you.
Comments