Observability used to mean wiring three separate systems: a metrics library, a tracing SDK, and a logging framework — each with its own agent, its own wire format, and its own vendor lock-in. OpenTelemetry (OTel) changes this by providing a single, vendor-neutral API and SDK for all three signals, plus a standalone Collector daemon that decouples your application from your backends.
This guide goes from zero to a working OTel pipeline: instrumenting apps in Python, Go, and Node.js; building a Collector configuration that processes and routes telemetry; and connecting everything to Jaeger, Tempo, Prometheus, and Loki.
What OpenTelemetry Actually Is
OpenTelemetry is not a backend — it doesn’t store or visualize anything. It is:
- A specification — standard data models for traces, metrics, and logs
- APIs — language-specific interfaces for instrumentation (stable, won’t break)
- SDKs — implementations of the API with exporters, samplers, and processors
- The Collector — a standalone agent/gateway for receiving, processing, and exporting telemetry
- Semantic conventions — standard attribute names so tooling can be built on top
Your App Collector Backends
┌────────────┐ ┌──────────────────┐ ┌──────────────┐
│ OTel SDK │─OTLP──▶│ Receivers │ │ Jaeger/Tempo │
│ (traces) │ │ Processors │──▶│ Prometheus │
│ (metrics) │ │ Exporters │ │ Loki │
│ (logs) │ └──────────────────┘ │ Elastic │
└────────────┘ └──────────────┘
OTLP (OpenTelemetry Protocol) is the wire format — gRPC or HTTP/protobuf. Every backend worth using supports OTLP today, which means you can swap backends without touching application code.
The Three Signals
| Signal |
What it captures |
Use case |
| Traces |
Request flow across services, latency per operation |
Debugging slow requests, finding where errors originate |
| Metrics |
Aggregated numeric measurements over time |
Alerting, capacity planning, SLO burn rate |
| Logs |
Structured event records with context |
Detailed debugging, audit trails |
The power of OTel is correlation — a trace ID can be automatically injected into logs, and exemplars link Prometheus metrics back to individual traces.
Setting Up the Stack
Docker Compose Development Stack
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
# docker-compose.yml — full observability stack for development
services:
# OpenTelemetry Collector
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
volumes:
- ./otel-collector.yaml:/etc/otelcol-contrib/config.yaml
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
- "8888:8888" # Collector metrics (self-monitoring)
- "8889:8889" # Prometheus exporter (scrape endpoint)
- "13133:13133" # Health check
depends_on:
- jaeger
- prometheus
# Jaeger — trace backend
jaeger:
image: jaegertracing/all-in-one:latest
environment:
- COLLECTOR_OTLP_ENABLED=true
ports:
- "16686:16686" # Jaeger UI
- "4317" # OTLP gRPC (internal)
# Prometheus — metrics backend
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
# Grafana — visualization
grafana:
image: grafana/grafana:latest
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
volumes:
- ./grafana/datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml
- ./grafana/dashboards:/etc/grafana/provisioning/dashboards
ports:
- "3000:3000"
# Loki — log backend
loki:
image: grafana/loki:latest
ports:
- "3100:3100"
command: -config.file=/etc/loki/local-config.yaml
|
Collector Configuration
The Collector is configured with a pipeline model: receivers → processors → exporters.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
|
# otel-collector.yaml
receivers:
# Accept OTLP from applications
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
# Scrape Prometheus metrics from services that expose /metrics
prometheus:
config:
scrape_configs:
- job_name: 'otel-collector'
scrape_interval: 10s
static_configs:
- targets: ['0.0.0.0:8888']
processors:
# Add resource attributes to all telemetry
resource:
attributes:
- action: insert
key: deployment.environment
value: development
# Batch telemetry to reduce export overhead
batch:
timeout: 1s
send_batch_size: 1024
send_batch_max_size: 2048
# Memory limiter — prevent OOM if traffic spikes
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
# Filter out noisy health check spans
filter/drop_health_checks:
error_mode: ignore
traces:
span:
- 'attributes["http.target"] == "/health"'
- 'attributes["http.target"] == "/metrics"'
- 'attributes["http.route"] == "/health"'
# Add k8s metadata (when running in Kubernetes)
k8sattributes:
auth_type: "serviceAccount"
passthrough: false
extract:
metadata:
- k8s.namespace.name
- k8s.deployment.name
- k8s.pod.name
- k8s.node.name
labels:
- tag_name: app.version
key: app.kubernetes.io/version
from: pod
# Tail-based sampling — only keep interesting traces
tail_sampling:
decision_wait: 10s
num_traces: 50000
expected_new_traces_per_sec: 100
policies:
# Always keep errors
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
# Always keep slow traces (>1s)
- name: slow-traces
type: latency
latency: { threshold_ms: 1000 }
# Sample 5% of everything else
- name: probabilistic
type: probabilistic
probabilistic: { sampling_percentage: 5 }
exporters:
# Send traces to Jaeger
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
# Send metrics to Prometheus (via scrape endpoint)
prometheus:
endpoint: "0.0.0.0:8889"
namespace: otel
send_timestamps: true
metric_expiration: 180m
# Send logs to Loki
loki:
endpoint: http://loki:3100/loki/api/v1/push
labels:
resource:
- service.name
- service.namespace
# Debug exporter — logs telemetry to stdout (development only)
debug:
verbosity: detailed
sampling_initial: 5
sampling_thereafter: 200
extensions:
health_check:
endpoint: 0.0.0.0:13133
pprof:
endpoint: 0.0.0.0:1777
service:
extensions: [health_check, pprof]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, filter/drop_health_checks, resource, batch, tail_sampling]
exporters: [otlp/jaeger]
metrics:
receivers: [otlp, prometheus]
processors: [memory_limiter, resource, batch]
exporters: [prometheus]
logs:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [loki]
|
Instrumenting Applications
Python: Auto-Instrumentation + Manual Spans
Python’s auto-instrumentation is the fastest way to get traces for common frameworks (Flask, FastAPI, Django, requests, SQLAlchemy, Redis, etc.) with zero code changes.
1
2
|
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install # Installs framework-specific instrumentation
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# app.py — FastAPI application
from fastapi import FastAPI, HTTPException
import httpx
import asyncio
app = FastAPI()
@app.get("/users/{user_id}")
async def get_user(user_id: int):
async with httpx.AsyncClient() as client:
resp = await client.get(f"http://user-service/api/users/{user_id}")
if resp.status_code == 404:
raise HTTPException(status_code=404, detail="User not found")
return resp.json()
|
1
2
3
4
5
6
7
|
# Run with auto-instrumentation — no code changes needed
OTEL_SERVICE_NAME=api-gateway \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,service.version=1.2.3" \
OTEL_TRACES_SAMPLER=parentbased_traceidratio \
OTEL_TRACES_SAMPLER_ARG=0.1 \
opentelemetry-instrument uvicorn app:app --host 0.0.0.0 --port 8080
|
Manual instrumentation gives you control over span names, attributes, and events:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
|
from opentelemetry import trace, metrics, baggage
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION
from opentelemetry.trace import Status, StatusCode
import time
import logging
# --- Setup ---
resource = Resource(attributes={
SERVICE_NAME: "order-service",
SERVICE_VERSION: "2.1.0",
"deployment.environment": "production",
"host.name": "web-01",
})
# Trace provider
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True))
)
trace.set_tracer_provider(tracer_provider)
tracer = trace.get_tracer(__name__)
# Metric provider
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint="http://localhost:4317", insecure=True),
export_interval_millis=15000, # Export every 15 seconds
)
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
meter = metrics.get_meter(__name__)
# --- Define metrics instruments ---
order_counter = meter.create_counter(
"orders.created.total",
unit="1",
description="Total number of orders created",
)
order_value = meter.create_histogram(
"orders.value.dollars",
unit="USD",
description="Distribution of order values in dollars",
)
active_orders = meter.create_up_down_counter(
"orders.active",
unit="1",
description="Number of currently active orders",
)
db_query_duration = meter.create_histogram(
"db.query.duration",
unit="ms",
description="Duration of database queries",
)
# --- Application code ---
async def create_order(user_id: str, items: list, db) -> dict:
with tracer.start_as_current_span("order.create") as span:
# Add semantic attributes
span.set_attribute("user.id", user_id)
span.set_attribute("order.item_count", len(items))
# Validate inventory
with tracer.start_as_current_span("inventory.check") as inv_span:
for item in items:
inv_span.add_event("checking_item", {"item.sku": item["sku"]})
available = await check_inventory(item["sku"], item["qty"])
if not available:
inv_span.set_status(Status(StatusCode.ERROR, "Item out of stock"))
raise ValueError(f"Item {item['sku']} is out of stock")
# Calculate total
total = sum(item["price"] * item["qty"] for item in items)
span.set_attribute("order.total_usd", total)
# Write to database
order_id = await write_order_to_db(user_id, items, total, db)
span.set_attribute("order.id", order_id)
# Record metrics
order_counter.add(1, {"payment.method": "card", "region": "us-east"})
order_value.record(total, {"product.category": items[0].get("category", "other")})
active_orders.add(1)
span.add_event("order_created", {"order.id": order_id})
return {"order_id": order_id, "total": total}
async def write_order_to_db(user_id, items, total, db):
start = time.monotonic()
with tracer.start_as_current_span("db.insert") as span:
span.set_attribute("db.system", "postgresql")
span.set_attribute("db.name", "orders")
span.set_attribute("db.operation", "INSERT")
span.set_attribute("db.sql.table", "orders")
try:
order_id = await db.execute(
"INSERT INTO orders (user_id, items, total) VALUES ($1, $2, $3) RETURNING id",
user_id, items, total
)
return order_id
except Exception as e:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
raise
finally:
duration_ms = (time.monotonic() - start) * 1000
db_query_duration.record(duration_ms, {
"db.operation": "INSERT",
"db.table": "orders",
"db.status": "ok" if not span.status.is_ok else "error",
})
|
Structured logging with trace correlation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
import logging
import json
from opentelemetry import trace
class OTelJsonFormatter(logging.Formatter):
"""Inject trace context into log records for Loki correlation."""
def format(self, record):
span = trace.get_current_span()
ctx = span.get_span_context()
log_entry = {
"timestamp": self.formatTime(record),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if ctx.is_valid:
log_entry["trace_id"] = format(ctx.trace_id, "032x")
log_entry["span_id"] = format(ctx.span_id, "016x")
log_entry["trace_flags"] = ctx.trace_flags
if record.exc_info:
log_entry["exception"] = self.formatException(record.exc_info)
return json.dumps(log_entry)
# Configure logging
handler = logging.StreamHandler()
handler.setFormatter(OTelJsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
logger = logging.getLogger(__name__)
# Now logs automatically include trace_id and span_id
logger.info("Processing order", extra={"order_id": "ord_123"})
# Output: {"timestamp": "...", "level": "INFO", "message": "Processing order",
# "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7"}
|
Go: Manual Instrumentation
Go requires explicit setup but gives full control. The idiomatic pattern is to initialize OTel in main() and pass a tracer through context.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
// telemetry/setup.go
package telemetry
import (
"context"
"fmt"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type SDK struct {
TracerProvider *sdktrace.TracerProvider
MeterProvider *sdkmetric.MeterProvider
Shutdown func(context.Context) error
}
func NewSDK(ctx context.Context, collectorAddr string) (*SDK, error) {
conn, err := grpc.NewClient(collectorAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
return nil, fmt.Errorf("grpc dial: %w", err)
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName("payment-service"),
semconv.ServiceVersion("3.0.1"),
semconv.DeploymentEnvironment("production"),
),
)
if err != nil {
return nil, fmt.Errorf("resource: %w", err)
}
// Trace exporter
traceExp, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
if err != nil {
return nil, fmt.Errorf("trace exporter: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExp,
sdktrace.WithBatchTimeout(1*time.Second),
sdktrace.WithMaxExportBatchSize(512),
),
sdktrace.WithSampler(sdktrace.ParentBased(
sdktrace.TraceIDRatioBased(0.1), // 10% head sampling
)),
sdktrace.WithResource(res),
)
// Metric exporter
metricExp, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithGRPCConn(conn))
if err != nil {
return nil, fmt.Errorf("metric exporter: %w", err)
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp,
sdkmetric.WithInterval(15*time.Second),
)),
sdkmetric.WithResource(res),
)
// Set global providers
otel.SetTracerProvider(tp)
otel.SetMeterProvider(mp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, // W3C Trace Context (traceparent header)
propagation.Baggage{},
))
sdk := &SDK{
TracerProvider: tp,
MeterProvider: mp,
Shutdown: func(ctx context.Context) error {
if err := tp.Shutdown(ctx); err != nil {
return err
}
return mp.Shutdown(ctx)
},
}
return sdk, nil
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
|
// payment/handler.go
package payment
import (
"context"
"fmt"
"net/http"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"go.opentelemetry.io/otel/trace"
)
var (
tracer = otel.Tracer("payment-service")
meter = otel.Meter("payment-service")
)
type PaymentHandler struct {
paymentProcessed metric.Int64Counter
paymentLatency metric.Float64Histogram
paymentsFailed metric.Int64Counter
}
func NewPaymentHandler() (*PaymentHandler, error) {
processed, err := meter.Int64Counter("payments.processed.total",
metric.WithDescription("Total number of payments processed"),
metric.WithUnit("1"),
)
if err != nil {
return nil, err
}
latency, err := meter.Float64Histogram("payments.duration.seconds",
metric.WithDescription("Payment processing duration"),
metric.WithUnit("s"),
metric.WithExplicitBucketBoundaries(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5),
)
if err != nil {
return nil, err
}
failed, err := meter.Int64Counter("payments.failed.total",
metric.WithDescription("Total number of failed payments"),
metric.WithUnit("1"),
)
if err != nil {
return nil, err
}
return &PaymentHandler{
paymentProcessed: processed,
paymentLatency: latency,
paymentsFailed: failed,
}, nil
}
func (h *PaymentHandler) ProcessPayment(ctx context.Context, req PaymentRequest) (*PaymentResult, error) {
ctx, span := tracer.Start(ctx, "payment.process",
trace.WithAttributes(
attribute.String("payment.method", req.Method),
attribute.String("payment.currency", req.Currency),
attribute.Float64("payment.amount", req.Amount),
attribute.String("customer.id", req.CustomerID),
),
)
defer span.End()
start := time.Now()
attrs := []attribute.KeyValue{
attribute.String("payment.method", req.Method),
attribute.String("payment.currency", req.Currency),
}
// Validate
ctx, validationSpan := tracer.Start(ctx, "payment.validate")
if err := validatePayment(req); err != nil {
validationSpan.RecordError(err)
validationSpan.SetStatus(codes.Error, "validation failed")
validationSpan.End()
h.paymentsFailed.Add(ctx, 1, metric.WithAttributes(
append(attrs, attribute.String("failure.reason", "validation"))...
))
return nil, fmt.Errorf("validation: %w", err)
}
validationSpan.End()
// Charge via payment gateway
ctx, chargeSpan := tracer.Start(ctx, "payment_gateway.charge",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
semconv.RPCSystem("grpc"),
semconv.RPCService("PaymentGateway"),
semconv.RPCMethod("Charge"),
),
)
result, err := h.chargeGateway(ctx, req)
if err != nil {
chargeSpan.RecordError(err)
chargeSpan.SetStatus(codes.Error, err.Error())
chargeSpan.End()
h.paymentsFailed.Add(ctx, 1, metric.WithAttributes(
append(attrs, attribute.String("failure.reason", "gateway"))...
))
return nil, fmt.Errorf("gateway charge: %w", err)
}
chargeSpan.SetAttributes(attribute.String("payment.transaction_id", result.TransactionID))
chargeSpan.End()
duration := time.Since(start).Seconds()
h.paymentProcessed.Add(ctx, 1, metric.WithAttributes(attrs...))
h.paymentLatency.Record(ctx, duration, metric.WithAttributes(attrs...))
span.SetAttributes(attribute.String("payment.transaction_id", result.TransactionID))
span.SetStatus(codes.Ok, "")
return result, nil
}
// HTTP middleware: extract trace context from incoming requests
func OTelMiddleware(next http.Handler) http.Handler {
propagator := otel.GetTextMapPropagator()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract W3C traceparent from incoming headers
ctx := propagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
ctx, span := tracer.Start(ctx, r.Method+" "+r.URL.Path,
trace.WithSpanKind(trace.SpanKindServer),
trace.WithAttributes(
semconv.HTTPMethod(r.Method),
semconv.HTTPTarget(r.URL.Path),
semconv.HTTPScheme(r.URL.Scheme),
semconv.NetHostName(r.Host),
attribute.String("http.client_ip", r.RemoteAddr),
),
)
defer span.End()
rw := &responseWriter{ResponseWriter: w, statusCode: 200}
next.ServeHTTP(rw, r.WithContext(ctx))
span.SetAttributes(semconv.HTTPStatusCode(rw.statusCode))
if rw.statusCode >= 500 {
span.SetStatus(codes.Error, http.StatusText(rw.statusCode))
}
})
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
|
// main.go
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"myapp/telemetry"
"myapp/payment"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func main() {
ctx := context.Background()
collectorAddr := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
if collectorAddr == "" {
collectorAddr = "localhost:4317"
}
sdk, err := telemetry.NewSDK(ctx, collectorAddr)
if err != nil {
log.Fatalf("failed to initialize OTel: %v", err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sdk.Shutdown(ctx); err != nil {
log.Printf("error shutting down OTel: %v", err)
}
}()
handler, err := payment.NewPaymentHandler()
if err != nil {
log.Fatalf("failed to create handler: %v", err)
}
mux := http.NewServeMux()
mux.Handle("/api/payments", otelhttp.NewHandler(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// handler logic
}),
"payment.api",
))
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Graceful shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)
}()
log.Println("Starting server on :8080")
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}
|
Node.js: Auto-Instrumentation
1
2
3
4
5
6
|
npm install @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-grpc \
@opentelemetry/exporter-metrics-otlp-grpc \
@opentelemetry/resources \
@opentelemetry/semantic-conventions
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
// tracing.js — loaded before your app via --require
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');
const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-grpc');
const { PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics');
const { Resource } = require('@opentelemetry/resources');
const { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } = require('@opentelemetry/semantic-conventions');
const { TraceIdRatioBasedSampler, ParentBasedSampler } = require('@opentelemetry/sdk-trace-base');
const sdk = new NodeSDK({
resource: new Resource({
[SEMRESATTRS_SERVICE_NAME]: 'notification-service',
[SEMRESATTRS_SERVICE_VERSION]: '1.5.2',
'deployment.environment': process.env.NODE_ENV || 'development',
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317',
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317',
}),
exportIntervalMillis: 15000,
}),
sampler: new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(0.1),
}),
instrumentations: [
getNodeAutoInstrumentations({
// Auto-instruments: http, express, pg, redis, mongodb, grpc, aws-sdk, etc.
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
// Don't trace health checks
return req.url === '/health' || req.url === '/metrics';
},
},
'@opentelemetry/instrumentation-express': { enabled: true },
'@opentelemetry/instrumentation-pg': { dbStatementSerializer: (query) => query.text },
'@opentelemetry/instrumentation-redis': { enabled: true },
}),
],
});
sdk.start();
// Graceful shutdown
process.on('SIGTERM', () => {
sdk.shutdown().then(() => process.exit(0)).catch(() => process.exit(1));
});
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
|
// app.js — manual spans and metrics
const { trace, metrics, context, propagation } = require('@opentelemetry/api');
const tracer = trace.getTracer('notification-service');
const meter = metrics.getMeter('notification-service');
// Define metric instruments
const emailsSent = meter.createCounter('notifications.emails.sent.total', {
description: 'Total emails sent',
unit: '1',
});
const notificationDuration = meter.createHistogram('notifications.send.duration.seconds', {
description: 'Time to send notifications',
unit: 's',
boundaries: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
});
async function sendNotification(userId, template, channel) {
return tracer.startActiveSpan(`notification.send.${channel}`, async (span) => {
span.setAttributes({
'notification.channel': channel,
'notification.template': template,
'user.id': userId,
});
const start = Date.now();
try {
const user = await fetchUser(userId);
span.setAttributes({ 'user.email': user.email, 'user.locale': user.locale });
const rendered = await renderTemplate(template, user);
const result = await deliverViaChannel(channel, user, rendered);
span.setStatus({ code: 1 }); // SpanStatusCode.OK
emailsSent.add(1, { channel, template, status: 'success' });
return result;
} catch (err) {
span.recordException(err);
span.setStatus({ code: 2, message: err.message }); // SpanStatusCode.ERROR
emailsSent.add(1, { channel, template, status: 'error' });
throw err;
} finally {
const duration = (Date.now() - start) / 1000;
notificationDuration.record(duration, { channel, template });
span.end();
}
});
}
// Context propagation for outgoing HTTP calls
async function callDownstreamService(url, payload, parentCtx) {
const headers = {};
propagation.inject(parentCtx || context.active(), headers);
// headers now contains: { traceparent: '00-4bf92f...', tracestate: '' }
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(payload),
});
return response.json();
}
|
1
2
|
# Start with auto-instrumentation loaded
node --require ./tracing.js app.js
|
Context Propagation
Distributed tracing only works when trace context is carried between services. OTel uses the W3C Trace Context standard (the traceparent header):
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ ^^
v trace-id (128-bit hex) span-id (64-bit) flags
Service A (Python) Service B (Go) Service C (Node.js)
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Span: api.call │─────▶│ Extract │ │ │
│ trace-id: 4bf9 │ │ traceparent hdr│ │ │
│ span-id: 00f0 │ │ Span: rpc.call │─────▶│ Extract │
│ │ │ parent: 00f0 │ │ traceparent hdr│
└────────────────┘ └────────────────┘ │ Span: db.query │
│ parent: rpc.call│
└────────────────┘
All three spans share trace-id 4bf9 → one complete trace in Jaeger
Propagating through message queues (Kafka, RabbitMQ, SQS) requires injecting context into message headers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
from opentelemetry import trace, propagate
from opentelemetry.trace import SpanKind
# Producer: inject trace context into message headers
def publish_event(topic: str, payload: dict, producer):
with tracer.start_as_current_span(f"kafka.send {topic}",
kind=SpanKind.PRODUCER) as span:
span.set_attribute("messaging.system", "kafka")
span.set_attribute("messaging.destination", topic)
headers = {}
propagate.inject(headers) # Adds traceparent to headers dict
# headers = {"traceparent": "00-4bf9...", "tracestate": ""}
producer.send(topic, value=payload, headers=list(headers.items()))
# Consumer: extract trace context from message headers
def consume_event(message, consumer_group: str):
# Reconstruct the context from the producer's span
headers = dict(message.headers)
ctx = propagate.extract(headers) # Recovers the producer's trace context
with tracer.start_as_current_span(
f"kafka.receive {message.topic}",
context=ctx,
kind=SpanKind.CONSUMER,
) as span:
span.set_attribute("messaging.system", "kafka")
span.set_attribute("messaging.source", message.topic)
span.set_attribute("messaging.consumer.group", consumer_group)
span.set_attribute("messaging.message.offset", message.offset)
process_event(message.value)
|
Sampling Strategies
Sampling is critical — you cannot afford to store every trace at high traffic volumes.
Head sampling: decided at trace start (before outcome is known)
Tail sampling: decided after trace completes (can sample based on errors/latency)
At 10,000 req/s with avg 5 spans per request = 50,000 spans/s
At 1KB per span = 50MB/s = 4.3TB/day — clearly need sampling
Head Sampling in the SDK
1
2
3
4
5
6
7
8
9
10
11
12
|
from opentelemetry.sdk.trace.sampling import (
ParentBased,
TraceIdRatioBased,
ALWAYS_ON,
ALWAYS_OFF,
)
# Sample 10% of new traces, follow parent decision for child spans
sampler = ParentBased(root=TraceIdRatioBased(0.10))
# For high-volume services, sample more aggressively
sampler = ParentBased(root=TraceIdRatioBased(0.01)) # 1%
|
Tail Sampling in the Collector
Tail sampling lets you keep 100% of errors and slow traces while sampling the happy path:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
# In otel-collector.yaml (shown above) — tail_sampling processor
tail_sampling:
decision_wait: 10s # Wait up to 10s for all spans of a trace to arrive
num_traces: 100000 # Max traces held in memory awaiting decision
expected_new_traces_per_sec: 1000
policies:
# 100% of traces with errors
- name: errors
type: status_code
status_code: {status_codes: [ERROR]}
# 100% of traces slower than 500ms
- name: slow
type: latency
latency: {threshold_ms: 500}
# 100% of traces from specific high-value users
- name: priority-users
type: string_attribute
string_attribute:
key: user.tier
values: [enterprise, trial]
# 1% of everything else
- name: base-rate
type: probabilistic
probabilistic: {sampling_percentage: 1}
|
Metrics: Instruments and Temporality
OTel metrics have strict instrument types — choosing the right one matters for correct aggregation:
| Instrument |
Type |
Use when |
Counter |
Monotonically increasing |
Request counts, bytes sent |
UpDownCounter |
Can increase or decrease |
Queue depth, active connections |
Histogram |
Distribution of values |
Latency, request sizes |
Gauge |
Point-in-time value |
CPU %, memory usage |
ObservableCounter |
Async counter (push at export time) |
OS-level counters |
ObservableGauge |
Async gauge |
Memory from /proc/meminfo |
Temporality governs how values are reported:
- Cumulative: always report the total since process start (Prometheus-style)
- Delta: report the change since last export (statsd-style)
Prometheus expects cumulative; configure your exporter accordingly:
1
2
3
4
5
6
7
8
9
10
11
12
|
from opentelemetry.sdk.metrics.export import AggregationTemporality
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
# Use cumulative temporality for Prometheus compatibility
exporter = OTLPMetricExporter(
endpoint="http://localhost:4317",
preferred_temporality={
Counter: AggregationTemporality.CUMULATIVE,
UpDownCounter: AggregationTemporality.CUMULATIVE,
Histogram: AggregationTemporality.CUMULATIVE,
}
)
|
Prometheus Exemplars
Exemplars attach a trace ID to a specific metric data point, allowing you to jump from a Prometheus alert or dashboard to the exact trace that caused it:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
from opentelemetry.sdk.metrics._internal.exemplar import AlwaysOnExemplarFilter
# Enable exemplars in the MeterProvider
meter_provider = MeterProvider(
resource=resource,
metric_readers=[reader],
views=[
View(
instrument_type=Histogram,
exemplar_filter=AlwaysOnExemplarFilter(),
)
]
)
|
In Grafana, exemplars appear as diamonds on histograms — click one to jump directly to the Tempo trace:
1
2
|
# Histogram query with exemplar display enabled
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, service))
|
Kubernetes: The OTel Operator
The OpenTelemetry Operator for Kubernetes automates instrumentation injection and Collector lifecycle management.
1
2
3
4
5
|
# Install cert-manager (required by the operator)
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml
# Install the OTel Operator
kubectl apply -f https://github.com/open-telemetry/opentelemetry-operator/releases/latest/download/opentelemetry-operator.yaml
|
Deploy a Collector as a DaemonSet (one per node):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata:
name: otel-daemonset
namespace: observability
spec:
mode: daemonset
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
config:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch: {}
memory_limiter:
check_interval: 1s
limit_mib: 400
k8sattributes:
auth_type: serviceAccount
extract:
metadata: [k8s.namespace.name, k8s.pod.name, k8s.deployment.name, k8s.node.name]
exporters:
otlp:
endpoint: otel-gateway-collector:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, batch]
exporters: [otlp]
metrics:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, batch]
exporters: [otlp]
|
Auto-instrumentation injection — annotate a namespace and the operator injects the OTel SDK automatically:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
name: auto-instrumentation
namespace: default
spec:
exporter:
endpoint: http://otel-daemonset-collector:4317
propagators:
- tracecontext
- baggage
sampler:
type: parentbased_traceidratio
argument: "0.1"
python:
env:
- name: OTEL_LOGS_EXPORTER
value: otlp
java:
image: ghcr.io/open-telemetry/opentelemetry-operator/autoinstrumentation-java:latest
nodejs:
env:
- name: OTEL_NODEJS_DEBUG
value: "false"
dotnet:
env: []
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
# Annotate a pod to get auto-instrumentation
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-python-app
namespace: default
spec:
template:
metadata:
annotations:
instrumentation.opentelemetry.io/inject-python: "true"
# The operator injects OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME,
# PYTHONPATH, and runs opentelemetry-instrument automatically
spec:
containers:
- name: app
image: my-python-app:latest
env:
- name: OTEL_SERVICE_NAME
value: "my-python-app"
- name: OTEL_RESOURCE_ATTRIBUTES
value: "service.version=1.2.3,deployment.environment=production"
|
Grafana: Correlating Signals
With all three signals flowing, Grafana becomes the single pane of glass.
Datasource Configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
# grafana/provisioning/datasources/datasources.yml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
url: http://prometheus:9090
isDefault: true
jsonData:
exemplarTraceIdDestinations:
- name: trace_id
datasourceUid: tempo # Link exemplars to Tempo traces
- name: Tempo
type: tempo
uid: tempo
url: http://tempo:3200
jsonData:
tracesToLogsV2:
datasourceUid: loki
filterByTraceID: true
filterBySpanID: true
tags: [{key: "service.name", value: "service"}]
tracesToMetrics:
datasourceUid: prometheus
tags: [{key: "service.name", value: "job"}]
queries:
- name: Request rate
query: rate(http_requests_total{$__tags}[5m])
serviceMap:
datasourceUid: prometheus # Use Prometheus for service map
nodeGraph:
enabled: true
- name: Loki
type: loki
url: http://loki:3100
jsonData:
derivedFields:
- name: TraceID
matcherRegex: '"trace_id":"(\w+)"'
url: "${__value.raw}"
datasourceUid: tempo # Click trace_id in logs → open trace in Tempo
|
Key Dashboards
Service overview:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
# Request rate by service
sum(rate(http_server_duration_milliseconds_count[5m])) by (service_name)
# P99 latency by service
histogram_quantile(0.99,
sum(rate(http_server_duration_milliseconds_bucket[5m])) by (le, service_name)
)
# Error rate
sum(rate(http_server_duration_milliseconds_count{http_status_code=~"5.."}[5m])) by (service_name)
/
sum(rate(http_server_duration_milliseconds_count[5m])) by (service_name)
# Apdex score (satisfied <100ms, tolerating <500ms)
(
sum(rate(http_server_duration_milliseconds_bucket{le="100"}[5m])) by (service_name) +
sum(rate(http_server_duration_milliseconds_bucket{le="500"}[5m])) by (service_name) / 2
) /
sum(rate(http_server_duration_milliseconds_count[5m])) by (service_name)
|
TraceQL in Grafana Tempo:
# Find all traces with errors in the payment service
{ resource.service.name = "payment-service" && status = error }
# Find slow database queries
{ span.db.system = "postgresql" && duration > 500ms }
# Find traces affecting a specific user
{ span.user.id = "usr_abc123" && status = error }
# Count spans by operation name (structural query)
{ resource.service.name = "api-gateway" } | count() by (name)
Common Pitfalls
1. Not setting service name:
1
2
|
# Without this, all your telemetry shows up as "unknown_service"
OTEL_SERVICE_NAME=my-service # Always set this
|
2. Forgetting to shut down the SDK gracefully:
1
2
3
4
5
6
|
# At process exit, flush buffered spans/metrics
tracer_provider.shutdown()
meter_provider.shutdown()
# Or use atexit:
import atexit
atexit.register(lambda: tracer_provider.shutdown())
|
3. Creating high-cardinality metric attributes:
1
2
3
4
5
6
7
|
# BAD: user_id as a metric attribute → millions of time series
request_counter.add(1, {"user_id": user_id})
# GOOD: put user_id on spans (traces can handle high cardinality)
span.set_attribute("user.id", user_id)
# Metrics use low-cardinality dimensions only
request_counter.add(1, {"region": region, "plan": user.plan})
|
4. Sampling too aggressively, missing errors:
1
2
3
4
5
6
|
# WRONG: pure ratio sampling drops errors too
sampler = TraceIdRatioBased(0.001) # 0.1% — you'll miss most errors
# RIGHT: always keep errors, sample the rest
sampler = ParentBased(root=TraceIdRatioBased(0.001))
# Plus tail sampling in the Collector for error policy
|
5. Blocking the request path with synchronous export:
1
2
3
4
5
6
7
|
# BAD: synchronous exporter blocks on network I/O
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
provider.add_span_processor(SimpleSpanProcessor(exporter))
# GOOD: batch processor is asynchronous
from opentelemetry.sdk.trace.export import BatchSpanProcessor
provider.add_span_processor(BatchSpanProcessor(exporter))
|
Production Readiness Checklist
Instrumentation
☐ Service name set via OTEL_SERVICE_NAME
☐ Service version and environment in resource attributes
☐ All HTTP servers/clients instrumented
☐ All DB calls instrumented with db.system, db.operation, db.statement
☐ All message queue producers/consumers propagate context
☐ Errors recorded with span.record_exception()
☐ SDK shut down gracefully on SIGTERM
Collector
☐ memory_limiter processor enabled (before all others)
☐ batch processor configured
☐ Health check extension enabled
☐ Collector self-metrics scraped by Prometheus
☐ Tail sampling configured with error/latency policies
☐ Health check probe configured in Kubernetes
Metrics
☐ Low-cardinality attributes only
☐ Correct instrument type (Counter vs Histogram vs Gauge)
☐ Exemplars enabled to link metrics to traces
☐ Metric naming follows semantic conventions
Tracing
☐ Span names are low-cardinality (use attributes for IDs)
☐ Span kind set correctly (SERVER, CLIENT, PRODUCER, CONSUMER)
☐ W3C traceparent propagation enabled
☐ Sampling rate appropriate for traffic volume
Operations
☐ OTel Collector horizontal scaling for high throughput
☐ Collector persistence queue for backend outages
☐ Retention policies set in trace/metric backends
☐ Dashboards covering latency, error rate, throughput
What to Read Next
OpenTelemetry has reached stability for traces, metrics, and logs in all major languages. The vendor-neutral API means your instrumentation investment is permanent — switching from Jaeger to Tempo, or from Prometheus to Mimir, requires only a Collector configuration change.
Filed under: Observability Deep Dives
Comments