OpenTelemetry โ The Complete Guide
A single-read, end-to-end reference for OpenTelemetry (OTel): enough to instrument a new service, design a Collector pipeline, or walk into an SRE interview. Organized as a lookup you can also read top-to-bottom.
๐ Quick reference: OpenTelemetry โ IntermediatePrerequisites: Observability (Grafana & Prometheus)
- Learn the three signals โ traces, metrics, logs โ and how they correlate
- Understand API vs SDK vs Collector, and what each is responsible for
- Try auto-instrumentation on one service before writing manual spans
- Read the Sampling section before turning on tracing in production
- Explain traces, metrics, and logs and how they correlate
- Distinguish the API, SDK, and Collector and what each does
- Instrument a service with auto and manual instrumentation
- Choose a sampling strategy and debug common pitfalls
The app never talks to a backend directly โ it emits three signal types that all funnel through the Collector, which batches, filters, and re-exports them so the storage backend can change underneath the app without touching a single line of instrumentation code.
1. What OpenTelemetry Is and Why It Existsโ
OpenTelemetry is a CNCF-hosted, vendor-neutral standard (APIs, SDKs, data formats, and a Collector) for generating, collecting, and exporting traces, metrics, and logs โ collectively "telemetry" โ from your applications and infrastructure.
It formed by merging two earlier CNCF projects: OpenTracing (distributed tracing API) and OpenCensus (Google's stats + tracing library). The merger's goal: stop forcing engineers to choose an instrumentation library that locks them to one backend (Jaeger vs. Zipkin vs. Datadog vs. New Relic).
The core value propositionโ
Instrument once, export anywhere.
You instrument your code against OTel's vendor-neutral API. Where the data goes (Jaeger, Prometheus, Datadog, Honeycomb, Grafana Tempo/Loki/Mimir, Splunk, etc.) is a Collector/exporter configuration decision, not a code change. Swapping observability vendors no longer means re-instrumenting every service.
The three pillars (signals)โ
| Signal | What it captures | Answers |
|---|---|---|
| Traces | The path of a single request across services, as a tree of timed spans | "Where did this specific slow/failed request spend its time?" |
| Metrics | Aggregated numeric measurements over time (counters, gauges, histograms) | "What's the overall error rate / p99 latency / throughput right now?" |
| Logs | Discrete timestamped event records, structured or unstructured | "What exactly happened at this moment, in detail?" |
OTel's differentiator is correlating these three โ a trace ID embedded in your logs and linked from your metrics lets you pivot from "latency spiked" (metric) โ "here are the slow traces" (trace) โ "here's the exact error stack" (log) without leaving your observability tool.
Why this matters operationally: MTTD, MTTR, MTBF, MTTFโ
Observability isn't collected for its own sake โ it exists to protect a system's availability, reliability, and performance, and its payoff is measurable in four reliability metrics:
| Metric | Definition | What Reduces It |
|---|---|---|
| MTTD (Mean Time to Detect) | Average time between a failure occurring and someone/something noticing | Monitoring โ alerts, dashboards |
| MTTR (Mean Time to Repair) | Average time to fix an issue after it's detected | Observability โ root-cause context (correlated traces/logs/metrics) |
| MTBF (Mean Time Between Failures) | Average time between distinct failures | Observability โ fixes informed by root cause prevent repeat incidents |
| MTTF (Mean Time to Failure) | Total operational time from one failure to the next | A composite outcome: MTTF = MTTD + MTTR + MTBF |
Key distinction: monitoring (a dashboard telling you something is wrong) mainly improves MTTD. Observability (the ability to ask arbitrary questions of correlated telemetry) is what improves MTTR and MTBF too โ because it gives you the "why," not just the "that." This is the real argument for adopting OTel over a pile of disconnected logging/metrics tools: the win isn't detecting faster, it's repairing faster and not repeating the same incident.
This is the same idea as the classic observability feedback loop:
1. Measure โ Collect telemetry from the system (SDK + Collector)
2. Analyze โ Interpret it to understand internal state (dashboards, alerting, trace search)
3. Control โ Act to restore/maintain desired state (rollback, scale, page a human)
A pipeline that only measures โ ships telemetry to a backend nobody looks at or alerts on โ isn't delivering observability yet; it's just data collection. The Collector and SDK are the "measure" stage; everything in sections 7โ9 below (correlation, backends, debugging) is what turns that into "analyze" and "control."
2. Core Concepts by Signalโ
2.1 Tracesโ
- Span โ a single timed unit of work (e.g., "handle HTTP request", "query database", "call payment-service"). Has a name, start/end time, attributes (key-value metadata), events (timestamped log-like points within the span), and a status (OK/Error).
- Trace โ a tree/DAG of spans sharing a single Trace ID, representing one end-to-end request across however many services it touched.
- SpanContext โ the propagated identifiers (
trace_id,span_id,trace_flags) that let a downstream service attach its spans to the same trace as the upstream caller. - Parent/child spans โ a span created inside another span's scope becomes its child, building the trace tree.
Trace (trace_id=abc123)
โโ span: HTTP GET /checkout (gateway-service) 120ms
โโ span: validate-cart (cart-service) 15ms
โโ span: charge-card (payment-service) 80ms
โ โโ span: POST stripe.com 70ms
โโ span: send-confirmation (notification-service) 10ms
2.2 Context Propagationโ
The mechanism that stitches spans across process/service boundaries. The
active SpanContext is injected into outgoing request headers (HTTP, gRPC
metadata, message queue headers) and extracted on the receiving side so the
new span becomes a child of the caller's span.
- W3C Trace Context (
traceparent,tracestateheaders) โ the modern default standard, interoperable across vendors. - Legacy formats you'll still encounter: B3 (Zipkin-originated,
X-B3-TraceIdetc.), Jaeger's own propagation format.
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
^^ version ^^^^^^^^^^^ trace-id ^^^^^^^^^^^^ ^^ span-id ^^ ^^ flags
Propagation must be consistent across every hop โ one service using B3 while downstream expects W3C breaks trace continuity (you'll see disconnected "orphan" traces).
2.3 Metricsโ
- Counter โ monotonically increasing value (
http_requests_total). - UpDownCounter โ can increase or decrease (
active_connections). - Gauge โ a point-in-time value (
cpu_temperature). - Histogram โ distribution of values into buckets (
http_request_duration_seconds) โ essential for percentile latency (p50/p95/p99). - Asynchronous instruments (
ObservableGauge, etc.) โ pulled on-demand via callback instead of pushed inline with request handling, good for system-level metrics (memory, queue depth).
2.4 Logsโ
OTel's logging model wraps existing logs (structured JSON, plain text) with a
common LogRecord schema and โ critically โ automatically attaches the
active trace/span ID to each log line emitted during a traced operation.
This is what enables "click a slow trace โ jump straight to its logs" in
tools like Grafana or Datadog.
2.5 Resource & Semantic Conventionsโ
- Resource โ metadata identifying what produced the telemetry:
service.name,service.version,deployment.environment,k8s.pod.name,cloud.region, etc. Attached once per process, applied to everything it emits. - Semantic Conventions โ a standardized attribute-naming spec (e.g.
http.request.method,db.system,messaging.destination.name) so a span from a Python service and one from a Java service use the same field names โ critical for dashboards/queries to work uniformly across a polyglot fleet.
2.6 Baggageโ
A fourth, less-discussed signal: key-value pairs propagated across service
boundaries alongside (but distinct from) trace context, carried in a
baggage HTTP header:
baggage: user.tier=enterprise,feature.flag.new-checkout=true
Unlike a span attribute (which only exists on the span it's set on), baggage
travels with the request, hop to hop, so any downstream service can read
values set far upstream โ without needing to look anything up. Common
real-world use: a gateway sets user.tier=enterprise in baggage, and a
downstream service reads it to decide to trace this specific request at
100% instead of the default 1% sample rate, or to branch feature-flag
behavior. Baggage is not automatically attached to spans/logs/metrics โ
you have to explicitly read it and set it as a span attribute if you want it
to show up in your backend; propagating it does not, by itself, export it
anywhere.
2.7 Signal Maturity (as of 2025)โ
Not all signals are equally production-ready across every language SDK โ worth checking before you commit to one in a new stack:
| Signal | Status |
|---|---|
| Traces | Stable across most languages |
| Metrics | Stable across most languages |
| Logs | Experimental in some language SDKs |
| Profiling | Still in development (newest addition to the OTel signal set) |
3. Architecture: API, SDK, and Collectorโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Your Application Process โ
โ โ
โ OTel API (no-op if unconf.)โ โ what your code calls
โ โ โ
โ OTel SDK (impl + config) โ โ sampling, batching, resource, processors
โ โ โ
โ Exporter (OTLP) โ
โโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโ
โ OTLP (gRPC/HTTP)
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ OpenTelemetry Collector โ
โ Receivers โ Processors โ Exporters โ
โโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโ
โ
โผ
Backends: Jaeger / Tempo / Prometheus / Mimir /
Loki / Datadog / Honeycomb / Splunk / etc.
- API โ the interface your application code depends on (
tracer.start_span(...)). Stable, minimal, and a no-op by default if no SDK is registered โ so libraries can instrument themselves without forcing a dependency on any specific telemetry backend. - SDK โ the actual implementation: sampling logic, span/metric processors, batching, resource detection. You configure the SDK once at app startup.
- Exporter โ serializes and ships telemetry out, typically via OTLP (OpenTelemetry Protocol, gRPC or HTTP/protobuf) โ the standard wire format. Vendor-specific exporters (Jaeger, Zipkin, Prometheus remote-write) also exist for direct-to-backend shipping without a Collector.
- Collector โ a standalone, vendor-agnostic proxy/pipeline process (deployed as agent, sidecar, or gateway) that receives, processes, and re-exports telemetry. Decouples "how my app emits data" from "where it ultimately lands."
Why use a Collector instead of exporting straight from the app?โ
- Change backends (or add a second one) via config, zero code redeploys.
- Centralize batching, retries, PII scrubbing, sampling โ instead of duplicating that logic in every service/language.
- Buffer telemetry so a backend outage doesn't back-pressure your application.
- Fan-out โ send the same data to two backends simultaneously (e.g., migrating vendors).
4. Instrumentation: Auto vs. Manualโ
In practice there are three approaches, and a recommended hybrid:
| Approach | Code Changes | Control | Setup Speed | When |
|---|---|---|---|---|
| Zero-code (auto) | None | Low | Minutes | Baseline coverage, legacy code you can't touch, quick PoC |
| Library-based | Import + configure | Medium | Hours | Popular frameworks needing more control than auto gives |
| Code-based (manual) | Explicit SDK/API calls | Full | Days | Business-specific spans/metrics/attributes |
| Hybrid (recommended default) | Mix of all three | Full where it matters | โ | Auto for framework coverage, manual only where it leaves a real blind spot |
Auto-instrumentationโ
Language agents/packages that patch common libraries (HTTP frameworks, DB drivers, message queues) to emit spans/metrics with zero code changes.
# Python example
opentelemetry-bootstrap -a install
opentelemetry-instrument --traces_exporter otlp --metrics_exporter otlp \
python app.py
# Java example โ attach a javaagent at startup
java -javaagent:opentelemetry-javaagent.jar \
-Dotel.service.name=checkout-service \
-Dotel.exporter.otlp.endpoint=http://collector:4317 \
-jar app.jar
// Node.js example โ tracing.js, required BEFORE your app code loads
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');
const { Resource } = require('@opentelemetry/resources');
const { SemanticResourceAttributes: S } = require('@opentelemetry/semantic-conventions');
const sdk = new NodeSDK({
resource: new Resource({
[S.SERVICE_NAME]: 'order-service',
[S.DEPLOYMENT_ENVIRONMENT]: 'production',
}),
traceExporter: new OTLPTraceExporter({ url: 'http://collector:4317' }),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
node -r ./tracing.js server.js
Auto-instrumentation gets you HTTP server/client spans, DB query spans, and basic metrics essentially for free โ the fastest path to baseline coverage.
Node-specific trap: auto-instrumentation patches libraries by
intercepting require(). If server.js (which requires Express/pg/etc.)
loads before tracing.js runs, the patch never attaches โ you get zero
spans, with no error. This is the single most common Node OTel mistake:
// WRONG โ express is already loaded before tracing.js can patch it
require('./server.js');
require('./tracing.js');
Always load the tracing setup first, either via node -r ./tracing.js server.js (as above) or as the literal first require/import in your
entrypoint.
Manual instrumentationโ
For business-meaningful spans/attributes auto-instrumentation can't know about:
from opentelemetry import trace
tracer = trace.get_tracer("checkout-service")
def charge_card(order):
with tracer.start_as_current_span("charge-card") as span:
span.set_attribute("order.id", order.id)
span.set_attribute("payment.provider", "stripe")
try:
result = stripe_client.charge(order.total)
span.set_attribute("payment.status", "success")
return result
except PaymentError as e:
span.record_exception(e)
span.set_status(trace.StatusCode.ERROR, str(e))
raise
from opentelemetry import metrics
meter = metrics.get_meter("checkout-service")
orders_counter = meter.create_counter("orders.processed")
checkout_latency = meter.create_histogram("checkout.duration.ms")
orders_counter.add(1, {"payment.provider": "stripe"})
Best practice: use auto-instrumentation for baseline coverage, add manual spans/attributes only where they carry business meaning (order ID, tenant ID, feature flag state) that generic library instrumentation can't infer.
5. Samplingโ
Capturing every trace at scale is often too expensive (storage + backend cost). Sampling decides what to keep.
| Strategy | How it works | Trade-off |
|---|---|---|
| Head-based (probabilistic) | Decide at trace start (e.g., keep 10%), decision propagates to all child spans | Cheap, simple, but may drop the one trace representing a rare failure |
| Tail-based | Buffer complete traces at the Collector, then decide based on outcome (keep all errors + slow traces + a sample of the rest) | Never miss an error/slow trace, but requires buffering full traces (memory/complexity cost), and needs all spans routed to the same Collector instance |
| Rate-limiting | Cap spans/sec regardless of traffic spikes | Protects backend cost predictably; can silently drop signal during incidents (ironic timing) |
Tail-based sampling is usually implemented in the Collector, not the SDK, since it needs to see the whole trace before deciding.
Cost optimization, with rough numbersโ
Sampling is one lever; combined with Collector-side filtering/aggregation, teams commonly cut observability spend significantly without losing visibility into failures:
| Strategy | Approach | Typical savings |
|---|---|---|
| Tail sampling | Keep 100% of errors/slow traces, sample 1โ10% of the rest | 90โ99% less trace volume |
| Metric aggregation | Aggregate in the Collector before export; prefer histograms over raw gauge samples | 80โ95% less metric volume |
| Log filtering | Drop DEBUG/INFO in the Collector, keep WARN/ERROR | 70โ90% less log volume |
| Hybrid backend | OSS stack (Jaeger/Prometheus/Loki) for dev/staging, commercial vendor only for prod | 50โ70% cost reduction |
The non-negotiable rule underneath all of these: never sample away errors or slow requests to save cost โ savings should come out of the volume of normal, successful, fast traffic, which is by definition the least interesting data you're storing. And do the filtering/aggregation in the Collector, not the backend โ otherwise you're still paying to ingest data you intended to discard.
6. The OpenTelemetry Collector in Depthโ
Configured as a pipeline: receivers โ processors โ exporters, per
signal type.
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
prometheus:
config:
scrape_configs:
- job_name: 'app'
scrape_interval: 15s
static_configs:
- targets: ['app:9090']
processors:
batch: {} # batch before export โ reduces network overhead
memory_limiter:
limit_mib: 512 # protect the Collector itself from OOM
resourcedetection:
detectors: [env, ec2, k8snode] # auto-tag with cloud/k8s metadata
attributes:
actions:
- key: user.email
action: delete # scrub PII before it leaves the cluster
tail_sampling:
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow-traces
type: latency
latency: { threshold_ms: 500 }
- name: sample-the-rest
type: probabilistic
probabilistic: { sampling_percentage: 5 }
exporters:
otlp/tempo:
endpoint: tempo:4317
prometheusremotewrite:
endpoint: http://mimir:9009/api/v1/push
loki:
endpoint: http://loki:3100/loki/api/v1/push
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, resourcedetection, attributes, tail_sampling, batch]
exporters: [otlp/tempo]
metrics:
receivers: [otlp, prometheus]
processors: [memory_limiter, batch]
exporters: [prometheusremotewrite]
logs:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [loki]
Deployment patternsโ
| Pattern | Description | When to use |
|---|---|---|
| Agent (sidecar/DaemonSet) | One Collector per host/pod, close to the app | Local buffering, resource tagging, low-latency handoff; scales with your fleet |
| Gateway | Central Collector cluster all agents forward to | Central sampling policy (esp. tail-based), central PII scrubbing, single fan-out point to backends |
| Agent + Gateway (both) | Agents do local enrichment/buffering, forward to a gateway tier for sampling/routing | Most common in production at scale |
7. Correlating the Three Pillarsโ
The payoff of adopting OTel across the board is exemplars and trace-log correlation:
- Metrics โ Traces: Prometheus/Mimir "exemplars" attach a sample trace ID to a histogram bucket, so a latency spike on a dashboard links directly to an actual slow trace that fell in that bucket.
- Traces โ Logs: because the SDK injects
trace_id/span_idinto the logging context automatically, log backends (Loki, Elastic) can filter "all logs for this trace" instantly. - Logs โ Traces: reverse lookup โ from an error log line, jump to the full distributed trace it was part of.
This loop โ metric alerts you โ trace shows where โ logs show why โ is the practical reason OTel-based observability stacks (Grafana LGTM: Loki, Grafana, Tempo, Mimir; or Datadog/Honeycomb equivalents) outperform three disconnected tools.
8. Common Backends & How OTel Plugs Inโ
| Backend | Signal(s) | Notes |
|---|---|---|
| Jaeger | Traces | Original distributed tracing UI; accepts OTLP natively now |
| Grafana Tempo | Traces | Object-storage-backed, designed for OTel-native ingestion |
| Prometheus / Mimir | Metrics | Pull-based (Prometheus) vs. push/remote-write (Mimir) at scale |
| Grafana Loki | Logs | Label-indexed, low-cost log aggregation |
| Datadog / New Relic / Honeycomb / Splunk | All three | Accept OTLP directly โ vendor lock-in reduced to a config endpoint change |
| Zipkin | Traces | Legacy but still OTLP-compatible |
9. Common Pitfalls & Debuggingโ
- Broken trace continuity โ a service in the call chain doesn't propagate context (missing middleware, async job boundary, message queue hop without header propagation) โ traces fragment into disconnected pieces.
- Cardinality explosions in metrics โ putting high-cardinality values
(user ID, order ID, raw URL with path params) as metric labels instead of
span attributes โ blows up Prometheus/Mimir storage and query cost. Rule
of thumb: unbounded values belong on spans/logs, not metric labels.
Use route templates (
/orders/{id}) not raw paths for HTTP metric labels. - Over-sampling cost vs. under-sampling blind spots โ pure head-based sampling at a flat 1% can mean the one trace behind a critical bug never gets captured; tail-based sampling on errors/latency avoids this but costs more Collector resources.
- Missing
service.nameโ telemetry with no resource attributes is nearly unusable in a multi-service environment; always set it explicitly even when relying on auto-instrumentation. - Collector memory pressure โ always configure
memory_limiteras the first processor; otherwise a traffic spike can OOM-kill the Collector and silently drop all telemetry during the exact incident you need visibility into. - Clock skew across hosts can make spans appear to start before their parent โ NTP hygiene matters for trace tree correctness.
- Node: SDK initialized after app code โ Node auto-instrumentation
patches modules via
require()interception; if Express/pg/etc. load before the tracing SDK does, the patch never attaches and you silently get zero spans. Alwaysrequire/importthe tracing setup first, or launch withnode -r ./tracing.js server.js.
10. Interview-Ready Q&Aโ
Q: What problem does OpenTelemetry actually solve? A: It decouples instrumentation from the observability backend. Before OTel, adopting Datadog vs. Jaeger vs. New Relic meant instrumenting your code against that vendor's SDK; switching vendors meant re-instrumenting everything. OTel gives one vendor-neutral API/SDK, and the destination is a Collector config decision.
Q: Explain the difference between the OTel API and SDK.
A: The API is the stable interface application code and libraries call
(start_span, create_counter); it's a no-op until an SDK is registered.
The SDK is the actual implementation โ sampling, batching, exporters,
resource attribution โ configured once at process startup. This split lets
library authors instrument their code without forcing a specific backend
dependency on consumers.
Q: When would you choose tail-based sampling over head-based? A: When you need to guarantee errors and slow requests are never dropped, which head-based (decided before the outcome is known) can't guarantee. Tail-based costs more โ it requires buffering full traces at a Collector gateway until the outcome is known โ but it's the only way to sample intelligently by outcome rather than randomly.
Q: Why not put user_id as a metric label?
A: High-cardinality labels multiply the number of unique time series a
metrics backend has to store and index โ with millions of users this can
explode storage/query cost and even crash the backend. High-cardinality data
belongs in span attributes or log fields, which are designed for that; it
can then be correlated to metrics via exemplars/trace IDs instead.
Q: A trace is missing a hop โ one service's spans aren't connecting to the caller's trace. What's your first hypothesis? A: Context propagation is broken at that hop โ most commonly, the intermediate call is async (background job, message queue) and isn't injecting/extracting trace headers, or two services disagree on propagation format (one on W3C Trace Context, another still on B3). Check the outgoing headers first, then confirm both sides use the same propagator.
Q: Why deploy an OpenTelemetry Collector instead of exporting directly from each app to the backend? A: Centralizes cross-cutting concerns โ batching, retries, PII scrubbing, tail-based sampling, and backend fan-out โ in one place instead of duplicating that logic per service/language, and it decouples "where telemetry goes" from application code so backend migrations don't require redeploying every service.
Q: How do metrics, traces, and logs work together in practice during an
incident?
A: A metric dashboard/alert shows that something's wrong (e.g., p99
latency spike); an exemplar or dashboard drill-down surfaces a representative
slow trace showing where time was spent across services; the trace's
trace_id, automatically attached to log lines emitted during that request,
lets you pull the exact logs explaining why โ end to end without
manually correlating timestamps across three separate tools.
Q: What's the difference between what monitoring improves and what observability improves, in reliability terms? A: Monitoring โ alerts and dashboards telling you something broke โ mainly shortens MTTD (mean time to detect). Observability โ the ability to correlate traces/logs/metrics and ask arbitrary questions of the data โ is what shortens MTTR (you have root-cause context instead of guessing) and raises MTBF (fixes based on real root cause stop the same failure from recurring). If a team only ships dashboards and alerts, they've bought faster detection, not faster or fewer incidents.
Q: What is baggage, and how is it different from a span attribute?
A: Baggage is a key-value context (baggage header) that propagates across
every hop of a request, independent of any single span โ a downstream
service can read a value set several services upstream without an extra
lookup. A span attribute, by contrast, only exists on the span it was set
on. Baggage isn't automatically exported anywhere; a common pattern is
reading a baggage value (e.g. user.tier=enterprise) and using it to drive
a local decision, like sampling this request at 100% or copying it onto a
span attribute so it's actually visible in the backend.
Q: A Node.js service has zero spans even though auto-instrumentation is
installed and configured correctly. What do you check first?
A: Whether the tracing SDK is being loaded before the instrumented
libraries. Node auto-instrumentation patches modules by intercepting
require()/import; if server.js (and therefore Express/pg/etc.) loads
before the SDK initializes, the patch never attaches โ and there's no error,
just silent zero spans. Fix is to load the tracing bootstrap first,
typically via node -r ./tracing.js server.js or as the literal first line
of the entrypoint.
In a Python virtual environment, run
pip install opentelemetry-sdk, then create a span that fails and print it to the console:from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
tracer = provider.get_tracer("checkout-service")
try:
with tracer.start_as_current_span("charge-card") as span:
try:
raise ValueError("card declined")
except ValueError as e:
span.record_exception(e)
span.set_status(trace.StatusCode.ERROR, str(e))
raise
except ValueError:
passDone when: the printed span has a
status_codeofERRORand at least oneexceptionevent carrying the message.Stretch: Count the exception events on the span and work out why there may be more than one.
Sample 10% of traces at the start and count what survives, including one deliberately failing request:
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
exporter = InMemorySpanExporter()
provider = TracerProvider(sampler=ParentBased(TraceIdRatioBased(0.10)))
provider.add_span_processor(SimpleSpanProcessor(exporter))
tracer = provider.get_tracer("demo")
for i in range(1000):
with tracer.start_as_current_span("request") as span:
if i == 500:
span.set_status(trace.StatusCode.ERROR, "the one failing request")
kept = exporter.get_finished_spans()
print(len(kept), any(s.status.status_code == trace.StatusCode.ERROR for s in kept))Run it several times.
Done when: it keeps roughly 10% each run (expect somewhere around 80 to 120 of 1000), the failing request is missing in most runs, and you can explain why tail-based sampling in the Collector is what keeps errors, and why you never sample errors away to save cost.
Illustrative scenario. Checkout traces look healthy from the gateway through the order service, but the payment step appears as a separate, unrelated trace. Nobody can follow a slow order end to end.
The order service handed the payment work to a message queue, and the trace context was not passed along in the message headers. The consumer started a brand-new trace, so the request fragmented at the async boundary.
Context has to be propagated across every hop, including queues and background jobs. To verify, follow a single trace ID through every service and treat any point where a new trace begins as a propagation bug.
- Ask an assistant to add manual spans to a function, then check by hand that the attributes carry business meaning such as order ID or tenant ID, and that none of them is a high-cardinality metric label.
- Paste a Collector pipeline config and ask it to explain each receiver, processor, and exporter. Confirm the pipeline actually lists the processors you expect for each signal.
- Have it propose a tail-sampling policy that keeps all errors and slow traces plus a small sample of the rest, and test it against recorded traces before rollout. Never let it drop errors to save cost.
11. One-Line Summaryโ
OpenTelemetry standardizes how traces, metrics, and logs are generated and correlated โ instrument once against a vendor-neutral API/SDK, route data through a Collector, and change backends via config instead of code.