Overview
In the era of cloud-native and microservices, a single request may traverse dozens of service nodes. Traditional monitoring scatters Metrics, Logs, and Traces across different systems — Prometheus for metrics, ELK for log search, Jaeger for tracing — with no unified way to correlate them. When an online incident occurs, you need to switch between three systems, manually piecing together correlated information, which is highly inefficient.
OpenTelemetry (OTel) is the CNCF-led unified observability standard, aiming to use a single SDK/API to collect all three signals (Metrics, Logs, Traces), process them through a unified Collector, and send them to any backend. It doesn’t replace backend storage and visualization — it solves the problem of “fragmentation at the data collection layer.” This article provides an in-depth guide to OTel’s specifications, architecture, practices, and migration strategies.
Reference: OpenTelemetry Official Documentation, CNCF OpenTelemetry Specification
I. Why OpenTelemetry
1.1 The Observability Fragmentation Problem
Traditional observability architecture (fragmented):
Application code
├── Prometheus Client (Metrics)
│ └── → Prometheus → Grafana
├── Logback + Filebeat (Logs)
│ └── → Elasticsearch → Kibana
└── Jaeger Client (Traces)
└── → Jaeger → Jaeger UI
Problems:
1. Three SDKs, three configurations, three operations stacks
2. No correlation between Metrics / Logs / Traces (TraceID not linked to logs)
3. High cost to switch backends (switching from Prometheus to Datadog requires code changes)
4. Each language needs different client libraries maintained
1.2 OpenTelemetry’s Approach
OpenTelemetry unified architecture:
Application code
└── OpenTelemetry SDK (unified collection)
├── Metrics
├── Logs (with TraceID)
└── Traces
│
▼
OpenTelemetry Collector (unified processing)
├── Filter / Aggregate / Sample
├── Format conversion
└── Distribute to backends
│
┌───────┼───────┐
▼ ▼ ▼
Prometheus ELK Jaeger
(Grafana) (Kibana)(Jaeger UI)
Core values:
| Value | Description |
|---|---|
| Unified collection | One SDK collects all three signals |
| Backend-agnostic | Switching backends requires no code changes |
| Context correlation | TraceID automatically links Logs and Metrics |
| Multi-language support | Official SDKs for 11 languages |
| Standardization | CNCF specification, industry consensus |
II. OpenTelemetry Specification
2.1 Core Concepts
| Concept | Description |
|---|---|
| Signal | Observability data type: Metrics, Logs, Traces |
| Resource | Description of the monitored entity (service name, version, hostname) |
| InstrumentationScope | Collection scope identifier (library name, package name) |
| Context | Request propagation info (TraceID, SpanID) |
| Baggage | Cross-service key-value pairs (e.g., tenant_id) |
| Span | A record of one operation (method call, HTTP request) |
| Log Record | A log record (with Trace correlation) |
| Meter / Tracer / Logger | Collector interfaces for the three signals |
2.2 The Three Signals
┌──────────────────────────────────────────────────────────┐
│ OpenTelemetry Three Signals │
│ │
│ Traces │
│ ├── Records the complete path of a request in a │
│ │ distributed system │
│ ├── Each Span contains: operation name, time, status, │
│ │ attributes │
│ └── Links Spans across services via TraceID │
│ │
│ Metrics │
│ ├── Records aggregatable numeric data │
│ ├── Types: Counter / Gauge / Histogram / Summary │
│ └── Includes Resource and Attributes (labels) │
│ │
│ Logs │
│ ├── Records discrete events │
│ ├── Correlated with TraceID / SpanID (key feature) │
│ └── Includes Severity, Body, Attributes │
└──────────────────────────────────────────────────────────┘
2.3 Data Models
Span data model:
{
"trace_id": "7b3cf5b0123456789abcdef012345678",
"span_id": "0123456789abcdef",
"parent_span_id": "fedcba9876543210",
"name": "GET /api/orders",
"kind": "SERVER",
"start_time": "2026-07-10T10:00:00.123456789Z",
"end_time": "2026-07-10T10:00:00.456789123Z",
"status": {
"code": "ERROR",
"description": "Database connection timeout"
},
"attributes": {
"http.method": "GET",
"http.url": "/api/orders",
"http.status_code": 500,
"db.system": "mysql",
"db.statement": "SELECT * FROM orders"
},
"events": [
{
"name": "exception",
"time": "2026-07-10T10:00:00.400Z",
"attributes": {
"exception.type": "java.sql.SQLException",
"exception.message": "Connection timeout"
}
}
],
"resource": {
"service.name": "order-service",
"service.version": "1.2.3",
"host.name": "order-pod-abc123"
}
}
Log data model:
{
"timestamp": "2026-07-10T10:00:00.500Z",
"trace_id": "7b3cf5b0123456789abcdef012345678",
"span_id": "0123456789abcdef",
"severity_text": "ERROR",
"severity_number": 17,
"body": "Failed to process order: Database connection timeout",
"attributes": {
"order_id": "ORD-12345",
"user_id": "USR-67890",
"retry_count": 3
},
"resource": {
"service.name": "order-service",
"service.version": "1.2.3"
}
}
Key point: The
trace_idandspan_idin Logs allow direct correlation to Traces — this is one of OTel’s core values: “click the TraceID in a log to jump directly to the Trace view.”
III. OpenTelemetry Collector
3.1 Collector Architecture
The Collector is OTel’s data pipeline core, responsible for receiving, processing, and exporting telemetry data.
┌──────────────────────────────────────────────────────┐
│ OTel Collector Architecture │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Receiver │ │ Receiver │ │ Receiver │ ← Receive │
│ │ (OTLP) │ │ (Jaeger) │ │ (Prom) │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └────────────┼────────────┘ │
│ ▼ │
│ ┌──────────┐ │
│ │ Processor│ ← Process (filter/sample/enrich) │
│ └────┬─────┘ │
│ │ │
│ ┌───────────┼───────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Exporter│ │ Exporter│ │ Exporter│ ← Export │
│ │ (OTLP) │ │ (Prom) │ │ (ES/Loki)│ │
│ └─────────┘ └─────────┘ └─────────┘ │
└──────────────────────────────────────────────────────┘
3.2 Collector Configuration
# otel-collector-config.yaml
receivers:
# Receive OTLP protocol data (SDK direct)
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
# Receive Jaeger format data
jaeger:
protocols:
grpc:
endpoint: 0.0.0.0:14250
thrift_http:
endpoint: 0.0.0.0:14268
# Receive Prometheus scrape
prometheus:
config:
scrape_configs:
- job_name: 'otel-collector'
scrape_interval: 15s
static_configs:
- targets: ['localhost:8888']
processors:
# Batch processing
batch:
timeout: 5s
send_batch_size: 1000
send_batch_max_size: 2000
# Memory limiter
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
# Filtering
filter:
traces:
span:
- 'attributes["http.route"] == "/health"' # Filter health check Spans
metrics:
metric:
- 'name == "process.runtime.jvm.gc.time"'
# Attribute processing
attributes:
actions:
- key: environment
value: production
action: upsert
- key: http.request_header.authorization
action: delete # Remove sensitive info
# Sampling (tail-based)
tail_sampling:
decision_wait: 10s
policies:
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
- name: slow
type: latency
latency:
threshold_ms: 1000
- name: sample
type: probabilistic
probabilistic:
sampling_percentage: 10
# Resource processing
resource:
attributes:
- key: deployment.environment
value: production
action: upsert
extensions:
health_check:
endpoint: 0.0.0.0:13133
zpages:
endpoint: 0.0.0.0:55679
exporters:
# Export to Jaeger
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
# Export to Prometheus (via remote_write)
prometheusremotewrite:
endpoint: http://prometheus:9090/api/v1/write
# Export to Loki
loki:
endpoint: http://loki:3100/loki/api/v1/push
# Export to Elasticsearch
elasticsearch:
endpoints:
- http://es:9200
index: otel-logs
service:
extensions: [health_check, zpages]
pipelines:
traces:
receivers: [otlp, jaeger]
processors: [memory_limiter, filter, tail_sampling, resource, batch]
exporters: [otlp/jaeger]
metrics:
receivers: [otlp, prometheus]
processors: [memory_limiter, filter, resource, batch]
exporters: [prometheusremotewrite]
logs:
receivers: [otlp]
processors: [memory_limiter, filter, attributes, resource, batch]
exporters: [loki, elasticsearch]
3.3 Collector Deployment Modes
| Mode | Deployment Location | Use Case | Resource Consumption |
|---|---|---|---|
| Agent | Alongside application nodes | Collect local data | Low |
| Gateway | Standalone cluster | Centralized processing and forwarding | Medium-High |
| Sidecar | Inside Pod | Alongside K8s apps | Low |
Production recommendation: Agent + Gateway two-tier architecture:
Application nodes → Agent Collector → Gateway Collector → Backends
(local collection) (centralized processing)
3.4 K8s Deployment
# DaemonSet mode (Agent Collector)
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: otel-collector-agent
namespace: monitoring
spec:
selector:
matchLabels:
app: otel-collector-agent
template:
metadata:
labels:
app: otel-collector-agent
spec:
containers:
- name: collector
image: otel/opentelemetry-collector-contrib:0.103.0
args: ["--config=/etc/otel/config.yaml"]
ports:
- containerPort: 4317 # OTLP gRPC
- containerPort: 4318 # OTLP HTTP
- containerPort: 13133 # Health check
volumeMounts:
- name: config
mountPath: /etc/otel
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
volumes:
- name: config
configMap:
name: otel-collector-config
IV. SDK Auto-Instrumentation
4.1 Go Example
package main
import (
"context"
"log"
"net/http"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func initTracer() func() {
// Create OTLP exporter
exporter, err := otlptracehttp.New(context.Background(),
otlptracehttp.WithEndpoint("otel-collector:4318"),
otlptracehttp.WithInsecure(),
)
if err != nil {
log.Fatalf("Failed to create exporter: %v", err)
}
// Create Resource (identifies current service)
res, _ := resource.New(context.Background(),
resource.WithAttributes(
semconv.ServiceName("order-service"),
semconv.ServiceVersion("1.0.0"),
semconv.DeploymentEnvironment("production"),
),
)
// Create TracerProvider
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.1)), // 10% sampling
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{})
return func() {
tp.Shutdown(context.Background())
}
}
func main() {
shutdown := initTracer()
defer shutdown()
// Use otelhttp for auto-instrumenting HTTP requests
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracer := otel.Tracer("order-service")
ctx, span := tracer.Start(r.Context(), "processOrder",
trace.WithAttributes(
semconv.HTTPMethod(r.Method),
semconv.HTTPTarget(r.URL.Path),
),
)
defer span.End()
// Simulate business logic
processOrder(ctx, r.URL.Query().Get("order_id"))
w.Write([]byte(`{"status": "ok"}`))
})
wrappedHandler := otelhttp.NewHandler(handler, "HTTP")
http.ListenAndServe(":8080", wrappedHandler)
}
func processOrder(ctx context.Context, orderID string) {
tracer := otel.Tracer("order-service")
_, span := tracer.Start(ctx, "processOrder",
trace.WithAttributes(
attribute.String("order.id", orderID),
),
)
defer span.End()
// Simulate database query
queryDatabase(ctx, orderID)
}
func queryDatabase(ctx context.Context, query string) {
tracer := otel.Tracer("order-service")
_, span := tracer.Start(ctx, "db.query",
trace.WithAttributes(
attribute.String("db.system", "mysql"),
attribute.String("db.statement", query),
),
)
defer span.End()
// Database query logic...
}
4.2 Auto-Instrumentation Libraries
OTel provides auto-instrumentation libraries for multiple languages and frameworks, requiring no business code changes:
| Language | HTTP Framework | Database | RPC |
|---|---|---|---|
| Go | net/http, Gin, Echo | database/sql, gorm | gRPC |
| Java | Spring, Servlet | JDBC, Hibernate | gRPC |
| Python | Flask, Django | SQLAlchemy, psycopg2 | gRPC |
| Node.js | Express, Fastify | mysql, pg | gRPC |
| .NET | ASP.NET Core | ADO.NET, EF Core | gRPC |
Java Spring Boot auto-instrumentation example:
// Add dependency
// build.gradle
dependencies {
implementation 'io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter:1.32.0'
}
// application.yml
otel:
exporter:
otlp:
endpoint: http://otel-collector:4318
service:
name: order-service
version: 1.0.0
traces:
sampler:
type: parentbased_traceidratio
arg: 0.1
Simply add the dependency and configuration — Spring Boot’s HTTP requests, database queries, Kafka consumption, etc. will automatically produce Spans without modifying any business code.
4.3 Log-Trace Correlation
// Java uses MDC to auto-inject TraceID
import org.slf4j.MDC;
// OTel SDK automatically writes TraceID to MDC
// Log format includes %X{trace_id} and %X{span_id}
// logback.xml
<pattern>
%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level [%X{trace_id},%X{span_id}] %logger - %msg%n
</pattern>
// Output example:
// 2026-07-10 10:00:00 [http-nio-8080-exec-1] ERROR [7b3cf5b0123456789abcdef012345678,0123456789abcdef] OrderService - Failed to process order
Searching for this TraceID in Kibana or Loki allows direct navigation to Jaeger’s Trace view.
V. Backend Integration
5.1 Integration Matrix
| Backend | Traces | Metrics | Logs | Protocol |
|---|---|---|---|---|
| Jaeger | ✓ | ✗ | ✗ | OTLP / Jaeger |
| Zipkin | ✓ | ✗ | ✗ | OTLP / Zipkin |
| Tempo | ✓ | ✗ | ✗ | OTLP |
| Prometheus | ✗ | ✓ | ✗ | remote_write |
| Mimir | ✗ | ✓ | ✗ | remote_write |
| VictoriaMetrics | ✗ | ✓ | ✓ | OTLP / remote_write |
| Elasticsearch | ✓ | ✓ | ✓ | OTLP / ES API |
| Loki | ✗ | ✗ | ✓ | OTLP / Loki API |
| Datadog | ✓ | ✓ | ✓ | OTLP / Datadog API |
| New Relic | ✓ | ✓ | ✓ | OTLP |
| Honeycomb | ✓ | ✓ | ✓ | OTLP |
| Grafana Cloud | ✓ | ✓ | ✓ | OTLP |
5.2 Grafana Full-Stack Integration
OTel + Grafana full-stack is the most common open-source combination:
Application code (OTel SDK)
│
▼
OTel Collector
│
├── Traces → Tempo
├── Metrics → Mimir / Prometheus
└── Logs → Loki
│
▼
Grafana (unified visualization)
In Grafana, the three signals are correlated via TraceID:
- See error rate spike in Metrics dashboard
- Click the link for the anomalous time period, jump to Logs search
- Find the error log in Logs, click the TraceID
- Jump to Traces view, pinpoint the specific service and method
5.3 Commercial Backend Integration
OTel also supports sending to commercial observability platforms:
exporters:
# Datadog
datadog:
api:
key: ${DD_API_KEY}
site: datadoghq.com
# New Relic
otlp/newrelic:
endpoint: otlp.nr-data.net:4317
headers:
api-key: ${NEW_RELIC_LICENSE_KEY}
# Honeycomb
otlp/honeycomb:
endpoint: api.honeycomb.io:4317
headers:
"x-honeycomb-team": ${HONEYCOMB_API_KEY}
Value of backend-agnosticism: With OTel, migrating from an open-source backend to a commercial one only requires changing the Collector configuration — no application code changes needed.
VI. Sampling Strategies
6.1 Head Sampling vs Tail Sampling
| Strategy | Sampling Location | Advantage | Disadvantage |
|---|---|---|---|
| Head sampling | At request entry | Simple, low resource consumption | Can’t sample based on results (may miss error requests) |
| Tail sampling | After request completes | Can sample based on results (keep all error requests) | Requires caching complete Traces, high resource consumption |
6.2 Tail Sampling Configuration
processors:
tail_sampling:
decision_wait: 10s # Wait 10s to collect complete Trace
num_traces: 50000 # Traces cached in memory
expected_new_traces_per_sec: 1000
policies:
# Policy 1: Keep all error requests
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
# Policy 2: Keep slow requests (> 1s)
- name: slow
type: latency
latency:
threshold_ms: 1000
# Policy 3: Keep requests from critical services
- name: critical-service
type: string_attribute
string_attribute:
key: service.name
values: ["payment-service", "order-service"]
# Policy 4: Sample 10% of the rest
- name: sample
type: probabilistic
probabilistic:
sampling_percentage: 10
Effect of tail sampling:
1000 requests → tail sampling → retained Traces:
- 5 error requests → all kept
- 20 slow requests → all kept
- 100 critical service requests → all kept
- Remaining 875 × 10% → 87 sampled
Total: 5 + 20 + 100 + 87 = 212 Traces (21.2%)
VII. Migration Strategies
7.1 Migrating to OTel from Existing Solutions
| Existing Solution | Migration Path |
|---|---|
| Jaeger Client → OTel | OTel SDK replaces Jaeger Client, Collector forwards to Jaeger |
| Prometheus Client → OTel | OTel Metrics SDK + Collector remote_write to Prometheus |
| Logback/Log4j → OTel | OTel Logs SDK + Collector exports to ELK/Loki |
| Datadog → OTel | OTel SDK replaces Datadog Agent, Collector exports to Datadog |
7.2 Phased Migration Steps
Phase 1: Deploy Collector (no app changes)
→ Collector receives existing format data, forwards to existing backends
→ Validate Collector stability
Phase 2: Adopt OTel SDK (Traces first)
→ New services use OTel SDK
→ Gradually replace old services
→ Collector receives both old and new formats simultaneously
Phase 3: Integrate Metrics and Logs
→ Application logs correlate with TraceID
→ Metrics collected via OTel SDK
Phase 4: Unify backends
→ All data processed through Collector
→ Switch backends as needed
7.3 Compatibility Configuration
The Collector can receive both old and new format data simultaneously, enabling smooth migration:
receivers:
# New format: OTel SDK sends
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
# Old format: Jaeger Client sends
jaeger:
protocols:
thrift_http:
endpoint: 0.0.0.0:14268
# Old format: Prometheus scrape
prometheus:
config:
scrape_configs:
- job_name: 'legacy-apps'
static_configs:
- targets: ['app-1:9090']
VIII. Performance and Resource Considerations
8.1 SDK Performance Impact
| Factor | Impact | Recommendation |
|---|---|---|
| Span count | CPU and memory | Reasonable sampling, filter health checks |
| Attribute count | Network and storage | Limit attributes per Span |
| Batch size | Network efficiency | Tune batch size and timeout |
| Sampling rate | Data volume and resources | 1-10% sampling in production |
8.2 Collector Resource Planning
| Scale | CPU | Memory | Replicas |
|---|---|---|---|
| < 10K Span/s | 1 core | 512MB | 2 |
| 10-100K Span/s | 2 cores | 1GB | 3 |
| 100-500K Span/s | 4 cores | 2GB | 3-5 |
| > 500K Span/s | 8 cores | 4GB | 5+ |
8.3 Collector Tuning
processors:
batch:
timeout: 5s # Batch timeout
send_batch_size: 1000 # Batch size
send_batch_max_size: 2000 # Max batch
memory_limiter:
check_interval: 1s # Check interval
limit_mib: 1024 # Memory limit
spike_limit_mib: 256 # Burst memory
service:
telemetry:
logs:
level: info # Log level
metrics:
address: 0.0.0.0:8888 # Self-metrics port
IX. Production Practices
9.1 Resource Attribute Standards
# Resource attribute standards
resource:
attributes:
# Required attributes
service.name: "order-service" # Service name
service.version: "1.2.3" # Version
# Recommended attributes
deployment.environment: "production" # Environment
host.name: "order-pod-abc" # Hostname
k8s.namespace.name: "production" # K8s namespace
k8s.pod.name: "order-pod-abc" # K8s Pod name
# Optional attributes
service.instance.id: "uuid-xxxx" # Instance ID
cloud.provider: "aws" # Cloud provider
cloud.region: "us-east-1" # Region
9.2 Monitoring OTel Itself
# Prometheus scraping Collector metrics
scrape_configs:
- job_name: 'otel-collector'
static_configs:
- targets: ['otel-collector:8888']
# Key alerts
groups:
- name: otel
rules:
- alert: OTelCollectorDroppingData
expr: rate(otelcol_processor_refused_spans[5m]) > 0
for: 5m
labels:
severity: critical
annotations:
summary: "OTel Collector is dropping Span data"
- alert: OTelCollectorQueueFull
expr: otelcol_exporter_queue_size / otelcol_exporter_queue_capacity > 0.9
for: 5m
labels:
severity: warning
annotations:
summary: "OTel Collector export queue nearly full"
Summary
OpenTelemetry is becoming the unified standard for observability:
- Unified standard: One SDK/API collects all three signals (Metrics/Logs/Traces), eliminating collection-layer fragmentation
- Backend-agnostic: Collector decouples collection from backends; switching backends only requires config changes, not code changes
- Context correlation: TraceID automatically links logs and metrics, enabling correlated analysis across all three signals
- Auto-instrumentation: Rich language/framework libraries support auto-instrumentation, lowering adoption cost
- Sampling strategies: Tail sampling retains all error and slow requests while reducing storage costs for normal requests
- Phased migration: Collector is compatible with both old and new formats, enabling smooth migration without disrupting existing monitoring
OTel doesn’t replace backends (Prometheus/Jaeger/ELK each remain in their roles) — it solves the unification problem at the data collection layer. If you’re building a new observability system, OTel should be the default choice for the collection layer. Existing systems can also migrate gradually through the Collector, enjoying the long-term benefits of a unified standard.
References & Acknowledgments
This article referenced the following materials during writing. We thank the original authors for their contributions:
- OpenTelemetry Official Documentation — OpenTelemetry Authors, referenced for OpenTelemetry Official Documentation
- CNCF OpenTelemetry Specification — GitHub, referenced for CNCF OpenTelemetry Specification