Instrumenting Applications
Instrumentation is the code and configuration that makes an application emit telemetry.
No instrumentation, no signals β backends like Prometheus, Loki, or Tempo only store what something already produced.
ποΈ Five ways to instrument
Instrumentation is a spectrum, ordered below from most manual to most automated.
| # | Approach | Where it lives | Effort to add | Typical coverage |
|---|---|---|---|---|
| 1 | Manual SDK | Your own code (tracer.startSpan, counters, log attributes) |
Code everywhere it matters | Business logic, domain events |
| 2 | Instrumentation libraries | NuGet/Maven/pip packages registered at startup | A few lines in bootstrap | HTTP in/out, DB drivers, messaging β you pick which |
| 3 | Zero-code / agent | Runtime hook (-javaagent, CLR profiler, Node --require, Python sitecustomize) or a build-time rewrite (Go otelc) |
Env vars, no code change | Same as above, whatever the agent supports |
| 4 | Proxy / service mesh | Sidecar or gateway (Envoy, Istio, ingress) | Platform config; pod restart to inject the sidecar | Service-to-service HTTP/gRPC |
| 5 | eBPF / OBI | Kernel probes outside the process | Platform config; no code change, no restart | Network-level spans and RED metrics |
The gradient is who does the work: at 1 a developer writes every span, at 5 nobody touches the application at all. Depth of coverage runs the other way.
These stack. A realistic service runs 2 or 3 + 1, sits inside a mesh (4), and is profiled by eBPF (5) at the same time.
What each one cannot do
| # | Approach | Blind spot |
|---|---|---|
| 1 | Manual SDK | Nothing technically β but every gap is engineering time |
| 2 | Instrumentation libraries | Anything without a library, plus you must add new ones as dependencies change |
| 3 | Zero-code / agent | Anything the agent does not support; your own business semantics |
| 4 | Proxy / service mesh | Everything inside the pod; in-process calls, DB queries, cache hits |
| 5 | eBPF / OBI | Encrypted payloads it cannot decode, in-process logic, βwhich customerβ |
π§ͺ What each one looks like
Minimal, runnable shapes for all five, each followed by how well it is supported per language β same language list as the maturity table in What is Observability. Java and .NET agent setup is in OTel Architecture, so the examples below use other stacks.
Legend: β production-ready Β· β οΈ works with caveats or still moving Β· β not available. Support moves fast β verify against the OpenTelemetry registry before committing to a stack.
1. Manual SDK
Only you can say what a span means in business terms. Everything below is invisible to approaches 2, 3, 4, and 5.
Go β a domain span with attributes and a recorded error:
var tracer = otel.Tracer("checkout")
func Checkout(ctx context.Context, order Order) error {
ctx, span := tracer.Start(ctx, "checkout.process")
defer span.End()
span.SetAttributes(
attribute.String("tenant.tier", order.Tenant.Tier), // yours, not conventional
attribute.String("payment.provider", order.Provider),
attribute.Int("cart.item_count", len(order.Items)),
)
if err := charge(ctx, order); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "charge failed")
return err
}
return nil
}
C# β ActivitySource is the OTel tracer under a .NET name:
private static readonly ActivitySource Source = new("Checkout");
using var activity = Source.StartActivity("checkout.process");
activity?.SetTag("tenant.tier", order.Tenant.Tier);
activity?.SetTag("payment.provider", order.Provider);
A business metric β a counter no agent would ever invent (Python):
meter = metrics.get_meter("checkout")
orders = meter.create_counter("orders.completed", unit="{order}")
orders.add(1, {"payment.provider": provider, "tenant.tier": tier}) # low-cardinality labels only
Structured log correlated with the trace β emit trace/span IDs so Loki and Tempo line up:
span = trace.get_current_span().get_span_context()
logger.info("order completed", extra={
"order_id": order.id,
"trace_id": format(span.trace_id, "032x"),
"span_id": format(span.span_id, "016x"),
})
Language support β manual instrumentation is limited only by the API/SDK maturity of the language:
| Language | Traces | Metrics | Logs | Hand-writing telemetry is⦠|
|---|---|---|---|---|
| Java | Stable | Stable | Stable | β Complete |
| .NET | Stable | Stable | Stable | β
Complete β ActivitySource and Meter ship in the BCL |
| Python | Stable | Stable | Development | β Complete for traces and metrics |
| Go | Stable | Stable | Release candidate | β
Complete; logs through the slog bridge |
| JavaScript/Node.js | Stable | Stable | Development | β Complete for traces and metrics |
| C++ | Stable | Stable | Stable | β Complete |
| PHP | Stable | Stable | Stable | β Complete |
| Rust | Beta | Beta | Beta | β οΈ Usable; API still churns, most teams bridge from tracing |
| Ruby | Stable | Development | Development | β οΈ Traces in practice, metrics not yet |
| Erlang/Elixir | Stable | Development | Development | β οΈ Traces in practice, metrics not yet |
| Swift (iOS) | Stable | Development | Development | β οΈ Traces in practice |
| Kotlin (Android) | Development | Development | Development | β οΈ Early on every signal |
2. Instrumentation libraries
You choose each instrumentation and register it in bootstrap code. In Go this is the usual route: Go has no runtime agent, because there is no interpreter or VM to hook at startup.
Go β wrap the handler and the HTTP client:
import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
)
// inbound: every request handled here produces a server span
handler := otelhttp.NewHandler(mux, "checkout-api")
http.ListenAndServe(":8080", handler)
// outbound: propagates traceparent, produces a client span
client := http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}
resp, err := client.Get("http://payment:8080/charge")
Python β the same idea, explicit instead of launcher-discovered:
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor
FlaskInstrumentor().instrument_app(app)
RequestsInstrumentor().instrument()
Psycopg2Instrumentor().instrument() # every SQL query becomes a child span
Registering an instrumentation library and running the zero-code agent (3) produces every span twice. Pick one per library.
Language support β what matters here is the breadth of the contrib ecosystem:
| Language | Library breadth | Representative packages |
|---|---|---|
| Java | β Very broad (100+) | Usually consumed through the agent (3) rather than wired by hand |
| .NET | β Broad | ASP.NET Core, HttpClient, EF Core, SqlClient, gRPC |
| Python | β Broad | Django, Flask, FastAPI, requests, psycopg, SQLAlchemy, Celery |
| Go | β Broad, and the usual route | otelhttp, otelgrpc (official); otelsql, otelgorm (third-party) |
| JavaScript/Node.js | β Broad | http, Express, Fastify, pg, mysql2, redis, kafkajs |
| Ruby | β Good | Rails, Rack, Sidekiq, pg, Net::HTTP |
| Erlang/Elixir | β Good | opentelemetry_phoenix, opentelemetry_ecto, opentelemetry_bandit (or _cowboy pre-Phoenix 1.7) |
| PHP | β Growing | Laravel, Symfony, Slim, PDO, Guzzle |
| C++ | β οΈ Narrow | A handful; most teams fall back to manual spans (1) |
| Rust | β οΈ Narrow | tracing-opentelemetry plus per-framework middleware (axum, actix) |
| Swift (iOS) | β οΈ Narrow | URLSession |
| Kotlin (Android) | β οΈ Narrow | OkHttp and activity lifecycle, early |
3. Zero-code / agent
Nothing in the repository changes. You wrap the process and set environment variables.
Python β the opentelemetry-instrument launcher discovers installed libraries at startup:
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install # pulls instrumentation for what you already import
OTEL_SERVICE_NAME=checkout \
OTEL_EXPORTER_OTLP_ENDPOINT=http://alloy-collector:4318 \
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
opentelemetry-instrument python manage.py runserver --noreload # the autoreloader forks and double-instruments
Node.js β a preloaded module, no require in your source:
npm install @opentelemetry/auto-instrumentations-node
OTEL_SERVICE_NAME=frontend \
OTEL_EXPORTER_OTLP_ENDPOINT=http://alloy-collector:4318 \
node --require @opentelemetry/auto-instrumentations-node/register server.js
Kubernetes β the OpenTelemetry Operator injects the agent as an init container, so even the wrapper disappears from your image:
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout
spec:
template:
metadata:
annotations:
instrumentation.opentelemetry.io/inject-python: "otel/default" # or inject-java, inject-nodejs, inject-dotnet
Language support β this is the approach where support is simply absent for some languages. A runtime agent needs an interpreter or VM to hook into at startup; a compiled binary offers none, so the equivalent has to happen at build time:
| Language | Agent | Mechanism | Operator annotation |
|---|---|---|---|
| Java | β Best in class | -javaagent, bytecode rewriting at class load |
inject-java |
| .NET | β Yes | CLR profiler + startup hook | inject-dotnet |
| Python | β Yes | opentelemetry-instrument launcher, monkey-patching |
inject-python |
| JavaScript/Node.js | β Yes | --require preload hook |
inject-nodejs |
| PHP | β Yes | opentelemetry PECL extension + auto packages |
β |
| Ruby | β οΈ Partial | opentelemetry-instrumentation-all β one require, not a true agent |
β |
| Kotlin (Android) | β οΈ Partial | Android agent covers lifecycle and network, in development | β |
| Go | β οΈ Yes, but not at runtime | Compile-time: otelc go build rewrites during the build (stable since v1.0.0). Also eBPF (5) |
β οΈ inject-go β eBPF sidecar, needs privileged: true and runAsUser: 0 |
| C++ | β None | Compiled, and no compile-time tooling either | β |
| Rust | β None | Compiled, and no compile-time tooling either | β |
| Erlang/Elixir | β None | Instrumentation must be wired explicitly (2) | β |
| Swift (iOS) | β None | Not a server runtime | β |
The Operator also injects instrumentation into Apache HTTPD and Nginx, which have no OTel SDK of their own.
4. Proxy / service mesh
The sidecar already sees every request; you only tell it where to send traces.
Istio β tracing is a mesh-wide setting:
apiVersion: telemetry.istio.io/v1
kind: Telemetry
metadata:
name: mesh-default
namespace: istio-system
spec:
tracing:
- providers:
- name: otel
randomSamplingPercentage: 10
ingress-nginx β one ConfigMap, and every request entering the cluster gets a root span:
data:
enable-opentelemetry: "true"
otlp-collector-host: "alloy-collector.monitoring.svc"
otlp-collector-port: "4317"
otel-sampler: "TraceIdRatioBased" # without this the sampler stays AlwaysOn
otel-sampler-ratio: "0.1"
otel-samplerdefaults toAlwaysOn, andotel-sampler-ratiois ignored unless you switch it. Setting the ratio alone traces 100% of ingress traffic.ingress-nginx reached end of life in March 2026. The snippet still describes a great many running clusters; for new ones the equivalent is a Gateway API controller (Envoy Gateway, Traefik, Cilium), which exports OTLP through the same mechanism.
Spans stop at the pod boundary: you see frontend β checkout took 400 ms, but not which SQL query caused it.
Language support β the proxy reads bytes on the wire, so span capture is identical in every language. What stays language-dependent is whether your application forwards the incoming traceparent on its outbound calls; without that, one trace becomes one disconnected span per service:
| Language | Mesh-captured spans | Forwarding traceparent onward |
|---|---|---|
| Java, .NET, Python, JavaScript/Node.js, PHP | β Identical for all | Free β the agent (3) or an instrumentation library (2) already does it |
| Go, Ruby, Erlang/Elixir | β Identical for all | Free once the HTTP instrumentation library (2) is wired in |
| C++, Rust | β Identical for all | β οΈ Usually manual β read and re-send the header yourself |
| Swift (iOS), Kotlin (Android) | β Outside the mesh | The client must originate the header before the request reaches the cluster |
5. eBPF / OBI
Configuration lives next to the platform, not the application. The workshop cluster instruments the llm service this way, in alloy-collector.values.yaml:
beyla.ebpf "llm" {
discovery {
instrument {
name = "llm"
open_ports = "8000" // match by listening port
}
}
ebpf {
context_propagation = "disabled" // "headers" nests spans, but rewrites proxied HTTP on the node
}
attributes {
kubernetes { enable = "true" } // decorate with pod, namespace, node
}
metrics {
features = ["application", "application_span", "application_service_graph"]
}
output {
traces = [otelcol.processor.batch.beyla.input]
}
}
The llm service was never modified, never rebuilt, and never restarted β yet it reports RED metrics, spans, and a service-graph edge.
Language support β eBPF attaches to the kernel and to the processβs own symbols, so what matters is the runtime, not the SDK. Coverage is broad but shallow everywhere except Go:
| Language | Spans + RED metrics | TLS traffic | Context propagation | CPU profiles (separate eBPF profiler) |
|---|---|---|---|---|
| Go | β Full β runtime structures read directly | β | β Native | β Symbols in the binary |
| C++ | β Network level | β OpenSSL uprobes | β οΈ Headers mode only | β |
| Rust | β Network level | β οΈ Only when built against OpenSSL, not rustls |
β οΈ Headers mode only | β |
| .NET | β Network level | β OpenSSL on Linux | β οΈ Headers mode only | β οΈ Needs a runtime unwinder |
| JavaScript/Node.js | β Network level | β OpenSSL | β
Via an injected Node agent (nodejs.enabled, on by default) |
β οΈ Needs a runtime unwinder |
| Python | β Network level | β OpenSSL | β οΈ Headers mode only | β οΈ Partial |
| Ruby, PHP, Erlang/Elixir | β Network level | β OpenSSL | β οΈ Headers mode only | β οΈ Partial |
| Java | β Network level | β
Via an injected Java agent (javaagent.enabled, on by default) β not OpenSSL uprobes |
β Via the same injected agent | β οΈ Via async-profiler, not eBPF |
| Swift (iOS), Kotlin (Android) | β Not applicable | β | β | β |
- βNetwork levelβ means no in-process detail. You get
POST /checkout 503 420ms, never the SQL query or the method that threw. - Java and Node.js are special cases. The JVM terminates TLS inside itself (JSSE), so the OpenSSL uprobes that work everywhere else have nothing to attach to. OBI closes the gap by injecting a small agent at runtime β
javaagent.enabledandnodejs.enabled, both on by default. The Java agent needs HotSpot or OpenJ9, Java 8+, and a writable container filesystem; a read-only root filesystem silently disables it. - OBI does not profile. It emits spans and RED metrics only. The CPU-profile column above describes eBPF profilers in general β in this cluster that is
pyroscope.ebpf, a separate component in the same Alloy DaemonSet.
βοΈ Coverage vs. control
The trade-off is the same in every language:
- Automatic approaches give breadth cheaply. One env var and 40 services report HTTP latency. Nobody had to open a pull request.
- Manual instrumentation gives depth expensively.
order.value,tenant.id,payment.providerexist only because somebody wrote them.
Auto-instrumentation tells you that checkout is slow. Manual instrumentation tells you it is slow for premium customers paying with provider X.
Practical rollout order:
- Turn on zero-code instrumentation everywhere. Accept whatever it gives you.
- Fix naming (
service.name,service.namespace,deployment.environment.name) before adding anything else. - Add manual spans and attributes only at points where an incident review showed you were blind.
Step 3 without step 2 produces data nobody can correlate.
π§ Choosing an approach
| Situation | Start with |
|---|---|
| Legacy service, no build pipeline you control | eBPF / OBI, or a mesh sidecar |
| Greenfield service, team owns the code | Instrumentation libraries + manual spans on business logic |
| 40 services, need coverage this quarter | Zero-code agents everywhere, manual later |
| Language with weak SDK support (Rust, Elixir) | eBPF for breadth, manual SDK for the paths you care about |
| Go service you build yourself | Compile-time otelc go build, plus manual spans on business logic |
| Third-party binary you cannot modify | eBPF / OBI, or its own exporter if one exists |
| You need βwhich customer was affectedβ | Manual, always β no automatic approach knows your domain |
Concrete setup β agent flags, SDK bootstrap code, and OBI β is in OTel Architecture.
Naming, context propagation, and the mistakes that make instrumentation unusable are the next chapter: Instrumentation Conventions.