The vendor-neutral standard for observability — one set of APIs, SDKs, and a Collector to produce traces, metrics, and logs and ship them anywhere (Prometheus, Grafana, Jaeger, any backend). You'll instrument an app, run the Collector, understand the pipeline, propagate context across services, and adopt production patterns. Hands-on throughout.
The journey:
| You need | Why |
|---|---|
| Docker | Run the Collector and a backend locally |
| An app in any language | To instrument (or use auto-instrumentation) |
| Basics of metrics/logs | The Prometheus tutorial helps |
OpenTelemetry (OTel) is a CNCF project and the successor to OpenTracing/OpenCensus. Confirm Docker: docker run --rm hello-world.
Before OTel, every observability vendor had its own agent and SDK — instrument once per vendor, and switching meant re-instrumenting everything. OpenTelemetry standardizes the generation of telemetry: a common API/SDK to create signals, a common wire protocol (OTLP), and a Collector to receive, process, and export to any backend.
The payoff: instrument your code once, and route the data to Prometheus, Grafana, Jaeger, Datadog, or anything else by changing Collector config — no vendor lock-in. OTel owns the producer side; your backend stays pluggable.
OTel unifies the three pillars of observability:
| Signal | Answers | Example |
|---|---|---|
| Traces | Where did time go across services? | A request's path through api → auth → db, with per-span durations |
| Metrics | What are the aggregate numbers? | Request rate, error rate, p99 latency |
| Logs | What happened at a point in time? | "timeout calling db" with a trace_id |
The breakthrough is correlation: a trace has a trace_id that OTel propagates into logs and links to metrics, so you jump from "latency spiked" (metric) to the exact slow request (trace) to its error line (log). A trace is a tree of spans; each span is one operation with a start/end, attributes, and a parent.
Use the SDK to create spans around meaningful operations (Python shown; every language mirrors it):
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint="http://collector:4317")))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
def handle_order(order_id):
with tracer.start_as_current_span("handle_order") as span:
span.set_attribute("order.id", order_id) # attributes = queryable context
charge_card(order_id) # nested spans link automatically
def charge_card(order_id):
with tracer.start_as_current_span("charge_card"):
...
The SDK batches spans and exports them over OTLP to the Collector (:4317 gRPC / :4318 HTTP). start_as_current_span nests spans into a trace automatically. Add attributes (order.id) so you can filter and group later.
The OpenTelemetry Collector is a standalone service that decouples your apps from backends. Its config is a pipeline of receivers → processors → exporters:
receivers:
otlp:
protocols:
grpc: { endpoint: 0.0.0.0:4317 }
http: { endpoint: 0.0.0.0:4318 }
processors:
batch: {} # batch before export (efficiency)
memory_limiter: { check_interval: 1s, limit_mib: 512 }
exporters:
prometheus: { endpoint: 0.0.0.0:8889 } # metrics scraped by Prometheus
otlp/jaeger: { endpoint: jaeger:4317, tls: { insecure: true } }
service:
pipelines:
traces: { receivers: [otlp], processors: [memory_limiter, batch], exporters: [otlp/jaeger] }
metrics: { receivers: [otlp], processors: [memory_limiter, batch], exporters: [prometheus] }
Apps send to the Collector; the Collector fans out to backends. This means you re-route or add a backend by editing Collector config, never your app. Processors also transform (drop noisy spans, add resource attributes, redact PII, tail-sample).
A trace is only useful if it spans services. OTel propagates context — the trace_id and span_id — across process boundaries via HTTP/gRPC headers (the W3C traceparent header):
Service A (span) --HTTP w/ traceparent--> Service B (child span) --> Service C
└──────────────── one trace, three services ────────────────┘
Instrumentation libraries inject traceparent on outgoing requests and extract it on incoming ones automatically, so B's span becomes a child of A's. The result is a single end-to-end trace across your whole request path — the thing that makes distributed systems debuggable. Propagation "just works" with auto-instrumentation for standard HTTP/gRPC clients.
You don't have to hand-write spans. Auto-instrumentation wraps common libraries (web frameworks, HTTP clients, DB drivers) to emit spans with zero code changes:
# Python: install and run under the agent
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4317 \
OTEL_SERVICE_NAME=api \
opentelemetry-instrument python app.py
On Kubernetes, the OpenTelemetry Operator can inject auto-instrumentation into pods via an annotation — no image changes at all. Start with auto-instrumentation for breadth (every HTTP/DB call traced), then add manual spans around your key business operations for depth.
Semantic conventions are OTel's standard attribute names (http.request.method, db.system, service.name) so backends understand your data regardless of language. Use them — custom names lose you built-in dashboards and correlation.
Sampling controls cost. Tracing every request at scale is expensive:
processors:
tail_sampling:
policies:
- name: errors, type: status_code, status_code: { status_codes: [ERROR] }
- name: slow, type: latency, latency: { threshold_ms: 500 }
OTel is producer-only — you choose where data lands:
prometheus exporter) → Grafana.Because everything speaks OTLP into the Collector, adding Grafana Tempo for traces or switching vendors is a config change. This is the whole promise: instrument once, choose (and change) backends freely.
service.name and resource attributes (env, version, k8s metadata) on everything.trace_id into logs so the three signals link.service.name. Telemetry is unattributable; always set it.memory_limiter/batch it can OOM under bursts.batch processor, and a Jaeger/Tempo exporter; view the trace.trace_id matches the trace.Self-check:
You now have the loop: instrument → OTLP to the Collector → process → export anywhere → propagate context → sample smartly. That's vendor-neutral observability done right.