LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

OpenTelemetry: One Instrumentation to Rule Them All

opentelemetryobservabilitytracingmetricslogginggopythondevops

Before OpenTelemetry, instrumenting a service meant picking a vendor — Datadog, New Relic, Honeycomb, Jaeger — and writing code that was coupled to that vendor’s SDK. Switching backends meant rewriting instrumentation. Running multiple backends meant running multiple agents. The observability space was a collection of proprietary silos.

OpenTelemetry (OTel) is the CNCF project that standardizes how telemetry data — traces, metrics, and logs — is collected, processed, and exported. You instrument once against the OTel API, and the backend is a configuration decision, not a code decision. The same instrumented service can send traces to Jaeger today, Tempo tomorrow, and Honeycomb next month without touching a line of application code.

This guide covers the full stack: the OTel data model, instrumenting Go, Python, and Node.js services with both manual and auto-instrumentation, building a Collector pipeline for processing and fan-out, and integrating with the open-source observability stack (Jaeger, Prometheus, Loki, Grafana).

The OpenTelemetry Data Model

OTel defines three telemetry signals. Understanding their structure clarifies why the SDK works the way it does.

Traces

A trace represents one end-to-end request as it flows through a distributed system. It’s a directed acyclic graph of spans — each span is one unit of work (a function call, a database query, an HTTP request).

Trace ID: 4bf92f3577b34da6a3ce929d0e0e4736
│
├── Span: HTTP GET /api/orders (root span)
│   duration: 145ms
│   attributes: {http.method: GET, http.url: /api/orders, http.status_code: 200}
│   │
│   ├── Span: validateAuth
│   │   duration: 8ms
│   │   parent: HTTP GET /api/orders
│   │   │
│   │   └── Span: redis.get (token lookup)
│   │       duration: 2ms
│   │
│   └── Span: fetchOrders
│       duration: 130ms
│       │
│       ├── Span: postgres.query (SELECT orders...)
│       │   duration: 95ms
│       │   attributes: {db.statement: "SELECT...", db.rows_affected: 47}
│       │
│       └── Span: http.POST downstream-service/enrich
│           duration: 28ms

Each span has:

  • Trace ID: same across all spans in one request (128-bit hex)
  • Span ID: unique to this span (64-bit hex)
  • Parent Span ID: links to the parent span
  • Name: human-readable operation name
  • Timestamps: start and end (nanosecond precision)
  • Attributes: key-value pairs (semantic conventions define standard names)
  • Events: timestamped annotations within the span (“cache miss”, “retrying”)
  • Status: Unset, Ok, or Error (with optional error message)
  • Kind: Server, Client, Producer, Consumer, or Internal

The trace ID propagates across service boundaries via context propagation — typically the W3C traceparent header in HTTP, or metadata in gRPC.

Metrics

OTel metrics align with Prometheus’s data model but add more instrument types:

Instrument Type Use case
Counter Monotonically increasing Request counts, bytes sent
UpDownCounter Can increase or decrease Active connections, queue depth
Histogram Distribution of values Request latency, payload size
Gauge Current point-in-time value CPU usage, memory, temperature
ObservableCounter Async counter (callback) System-level counters you poll
ObservableGauge Async gauge (callback) Memory from /proc/meminfo

OTel metrics export to Prometheus via the OTLP Prometheus exporter — they appear as standard Prometheus metrics you can query with PromQL.

Logs

OTel’s log data model adds structure that plain text logs lack:

  • Timestamp: nanosecond precision
  • Observed Timestamp: when the collector received it (differs from timestamp for buffered logs)
  • Trace Context: TraceId, SpanId, TraceFlags — automatically correlating logs to the trace that generated them
  • Severity: structured severity (Trace, Debug, Info, Warn, Error, Fatal) with numeric values
  • Body: the log message
  • Attributes: structured key-value pairs
  • Resource: service metadata (service name, version, host)

The trace context fields are the killer feature: if your logs include the trace ID from the current request, you can jump directly from a Loki log line to the Tempo trace — no manual correlation.

Resources

Every piece of telemetry is tagged with a Resource — metadata describing the entity producing it:

1
2
3
4
5
6
7
8
9
{
  "service.name": "order-api",
  "service.version": "2.3.1",
  "service.instance.id": "pod-abc123",
  "deployment.environment": "production",
  "host.name": "k8s-node-7",
  "k8s.pod.name": "order-api-7d4b9f-xkw8p",
  "k8s.namespace.name": "production"
}

Resource attributes are attached automatically by the SDK (from environment variables and auto-detection) and appear on every span, metric, and log from that process.

The OpenTelemetry Collector

The Collector is a vendor-agnostic agent that receives, processes, and exports telemetry. It decouples your application from the backend:

Application (SDK) ──OTLP──► Collector ──► Jaeger (traces)
                                      ──► Prometheus (metrics)
                                      ──► Loki (logs)
                                      ──► S3 (archive)

The Collector is built as a pipeline of three component types:

  • Receivers: accept telemetry (OTLP, Jaeger, Zipkin, Prometheus scrape, Fluentd, syslog…)
  • Processors: transform telemetry (batch, filter, add attributes, sample, redact PII…)
  • Exporters: send telemetry to backends (OTLP, Jaeger, Prometheus, Loki, Datadog, Honeycomb…)

Collector Docker Compose Setup

 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
# docker-compose.yml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    restart: unless-stopped
    command: ["--config=/etc/otel/config.yaml"]
    volumes:
      - ./otel/collector-config.yaml:/etc/otel/config.yaml:ro
    ports:
      - "4317:4317"    # OTLP gRPC
      - "4318:4318"    # OTLP HTTP
      - "8889:8889"    # Prometheus metrics exporter (scrape this)
      - "13133:13133"  # Health check
    depends_on:
      - jaeger
      - loki

  jaeger:
    image: jaegertracing/all-in-one:latest
    restart: unless-stopped
    environment:
      COLLECTOR_OTLP_ENABLED: "true"
    ports:
      - "16686:16686"  # Jaeger UI
      - "4317"         # OTLP gRPC (internal)

  loki:
    image: grafana/loki:latest
    restart: unless-stopped
    command: -config.file=/etc/loki/local-config.yaml
    ports:
      - "3100:3100"

  prometheus:
    image: prom/prometheus:latest
    restart: unless-stopped
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
    ports:
      - "9090:9090"

  grafana:
    image: grafana/grafana:latest
    restart: unless-stopped
    environment:
      GF_SECURITY_ADMIN_PASSWORD: admin
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
    ports:
      - "3000:3000"

Collector 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
 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
# otel/collector-config.yaml

receivers:
  # Accept OTLP from instrumented applications
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

  # Pull host metrics (like Node Exporter — useful for all-in-one setups)
  hostmetrics:
    collection_interval: 15s
    scrapers:
      cpu:
      memory:
      disk:
      filesystem:
      network:
      load:

  # Scrape Prometheus endpoints (replaces running a separate Prometheus)
  prometheus:
    config:
      scrape_configs:
        - job_name: self
          static_configs:
            - targets: ['localhost:8888']  # Collector's own metrics

processors:
  # Batch spans before sending (reduces network overhead)
  batch:
    timeout: 5s
    send_batch_size: 1024
    send_batch_max_size: 2048

  # Add resource attributes from environment
  resource:
    attributes:
      - key: deployment.environment
        value: production
        action: upsert

  # Redact sensitive data from span attributes
  redaction:
    allow_all_keys: true
    blocked_values:
      - "4[0-9]{12}(?:[0-9]{3})?"    # Credit card numbers
      - "[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}"  # IBAN

  # Memory limiter: prevent OOM
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
    spike_limit_mib: 128

  # Probabilistic sampling: keep 10% of non-error traces
  probabilistic_sampler:
    sampling_percentage: 10

  # Tail-based sampling: always keep error traces, sample the rest
  tail_sampling:
    decision_wait: 10s
    num_traces: 100000
    expected_new_traces_per_sec: 1000
    policies:
      - name: always-keep-errors
        type: status_code
        status_code:
          status_codes: [ERROR]
      - name: keep-slow-traces
        type: latency
        latency:
          threshold_ms: 1000
      - name: sample-rest
        type: probabilistic
        probabilistic:
          sampling_percentage: 5

  # Add Kubernetes metadata to all telemetry
  k8sattributes:
    auth_type: serviceAccount
    extract:
      metadata:
        - k8s.pod.name
        - k8s.pod.uid
        - k8s.deployment.name
        - k8s.namespace.name
        - k8s.node.name

exporters:
  # Send traces to Jaeger
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true

  # Export metrics in Prometheus format (Prometheus scrapes this)
  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
    default_labels_enabled:
      exporter: false
      job: true
    labels:
      resource:
        service.name: "service_name"
        deployment.environment: "environment"
      attributes:
        level: "level"

  # Debug exporter (prints to stdout — useful during development)
  debug:
    verbosity: detailed

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, resource, redaction, tail_sampling, batch]
      exporters: [otlp/jaeger]

    metrics:
      receivers: [otlp, hostmetrics, prometheus]
      processors: [memory_limiter, resource, batch]
      exporters: [prometheus]

    logs:
      receivers: [otlp]
      processors: [memory_limiter, resource, batch]
      exporters: [loki]

Tail-based sampling is the most useful processor for production: it buffers traces and makes sampling decisions after seeing the full trace, so it can always keep errors and slow traces while sampling healthy fast ones. This gives you 100% visibility into problems at a fraction of the storage cost of keeping everything.

Instrumenting Go

Manual Instrumentation

  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
package main

import (
    "context"
    "fmt"
    "net/http"
    "time"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "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.uber.org/zap"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
)

// initTracer sets up the OTel trace provider and returns a shutdown function.
func initTracer(ctx context.Context) (func(context.Context) error, error) {
    // Connect to the OTel Collector
    conn, err := grpc.DialContext(ctx, "otel-collector:4317",
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        grpc.WithBlock(),
    )
    if err != nil {
        return nil, fmt.Errorf("failed to connect to collector: %w", err)
    }

    // OTLP trace exporter over gRPC
    exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
    if err != nil {
        return nil, fmt.Errorf("failed to create trace exporter: %w", err)
    }

    // Resource attributes — describe this service
    res, err := resource.Merge(
        resource.Default(),
        resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceName("order-api"),
            semconv.ServiceVersion("2.3.1"),
            attribute.String("deployment.environment", "production"),
        ),
    )
    if err != nil {
        return nil, err
    }

    // Trace provider with batch export
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(res),
        // In production, use sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1)))
        // for head-based sampling, or leave unset for tail-based at the Collector.
        sdktrace.WithSampler(sdktrace.AlwaysSample()),
    )

    // Register as global provider
    otel.SetTracerProvider(tp)

    // Register W3C traceparent propagator for context propagation
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{},
        propagation.Baggage{},
    ))

    return tp.Shutdown, nil
}

// tracer is the package-level tracer for this service.
var tracer = otel.Tracer("order-api")

// fetchOrder demonstrates manual span creation.
func fetchOrder(ctx context.Context, orderID string) (*Order, error) {
    // Create a child span
    ctx, span := tracer.Start(ctx, "fetchOrder",
        oteltrace.WithAttributes(
            attribute.String("order.id", orderID),
        ),
    )
    defer span.End()

    // Add an event (timestamped annotation)
    span.AddEvent("querying database")

    order, err := db.QueryOrder(ctx, orderID)
    if err != nil {
        // Record the error — this marks the span as failed
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        return nil, err
    }

    // Add result attributes
    span.SetAttributes(
        attribute.String("order.status", order.Status),
        attribute.Float64("order.total", order.Total),
    )
    span.SetStatus(codes.Ok, "")

    return order, nil
}

HTTP Middleware (Propagation)

 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
import (
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func main() {
    shutdown, err := initTracer(context.Background())
    if err != nil {
        log.Fatal(err)
    }
    defer shutdown(context.Background())

    // Wrap the HTTP handler — extracts traceparent from incoming requests,
    // creates a server span, and propagates context.
    handler := otelhttp.NewHandler(
        http.DefaultServeMux,
        "order-api",
        otelhttp.WithMessageEvents(otelhttp.ReadEvents, otelhttp.WriteEvents),
    )

    http.Handle("/api/orders", http.HandlerFunc(ordersHandler))
    http.ListenAndServe(":8080", handler)
}

// Outbound HTTP calls: inject traceparent into the request
func callDownstream(ctx context.Context, url string) (*http.Response, error) {
    client := &http.Client{
        Transport: otelhttp.NewTransport(http.DefaultTransport),
    }
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    return client.Do(req)
}

Database Instrumentation

 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
import (
    "go.opentelemetry.io/contrib/instrumentation/database/sql/otelsql"
    "go.opentelemetry.io/otel/semconv/v1.24.0/dbconv"
)

// Wrap the database driver with OTel instrumentation
func initDB() *sql.DB {
    driverName, err := otelsql.Register("postgres",
        otelsql.WithAttributes(
            semconv.DBSystemPostgreSQL,
            attribute.String("db.name", "orders"),
        ),
        otelsql.WithSpanOptions(otelsql.SpanOptions{
            DisableErrSkip: true,   // Don't create spans for sql.ErrNoRows
            RecordError: func(err error) bool {
                return err != sql.ErrNoRows
            },
        }),
    )
    if err != nil {
        log.Fatal(err)
    }

    db, err := sql.Open(driverName, os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }

    // Record connection pool stats as OTel metrics
    otelsql.RecordStats(db,
        otelsql.WithAttributes(semconv.DBSystemPostgreSQL),
    )

    return db
}

OTel Metrics in Go

 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
import (
    "go.opentelemetry.io/otel/metric"
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
    sdkmetric "go.opentelemetry.io/otel/sdk/metric"
)

func initMeter(ctx context.Context) (func(context.Context) error, error) {
    exporter, err := otlpmetricgrpc.New(ctx,
        otlpmetricgrpc.WithEndpoint("otel-collector:4317"),
        otlpmetricgrpc.WithInsecure(),
    )
    if err != nil {
        return nil, err
    }

    mp := sdkmetric.NewMeterProvider(
        sdkmetric.WithReader(
            sdkmetric.NewPeriodicReader(exporter,
                sdkmetric.WithInterval(15*time.Second),
            ),
        ),
        sdkmetric.WithResource(res),  // same resource as tracer
    )
    otel.SetMeterProvider(mp)
    return mp.Shutdown, nil
}

var meter = otel.Meter("order-api")

func setupMetrics() {
    // Counter
    requestCounter, _ := meter.Int64Counter(
        "http.server.request.total",
        metric.WithDescription("Total HTTP requests"),
        metric.WithUnit("{request}"),
    )

    // Histogram for latency
    requestDuration, _ := meter.Float64Histogram(
        "http.server.request.duration",
        metric.WithDescription("HTTP request duration"),
        metric.WithUnit("s"),
        metric.WithExplicitBucketBoundaries(
            0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
        ),
    )

    // Observable gauge (callback-based, no need to call Record)
    _, _ = meter.Int64ObservableGauge(
        "process.goroutines",
        metric.WithDescription("Number of goroutines"),
        metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
            o.Observe(int64(runtime.NumGoroutine()))
            return nil
        }),
    )

    // Use the instruments in your handlers
    _ = requestCounter
    _ = requestDuration
}

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
import (
    "go.opentelemetry.io/otel/trace"
    "go.uber.org/zap"
)

// fromContext extracts trace context from the span in the context
// and adds it to a zap logger.
func loggerFromContext(ctx context.Context, base *zap.Logger) *zap.Logger {
    span := trace.SpanFromContext(ctx)
    if !span.IsRecording() {
        return base
    }
    sc := span.SpanContext()
    return base.With(
        zap.String("trace_id", sc.TraceID().String()),
        zap.String("span_id", sc.SpanID().String()),
        zap.Bool("trace_sampled", sc.IsSampled()),
    )
}

// Usage in a handler:
func ordersHandler(w http.ResponseWriter, r *http.Request) {
    log := loggerFromContext(r.Context(), globalLogger)
    log.Info("handling order request",
        zap.String("user_id", userID),
    )
    // This log line will contain trace_id and span_id,
    // enabling direct correlation to the Jaeger trace.
}

Instrumenting Python

Auto-Instrumentation (Zero Code Changes)

Python’s auto-instrumentation works via sitecustomize.py hooks — the OTel SDK patches common libraries (Django, Flask, FastAPI, SQLAlchemy, Redis, requests, psycopg2) at import time.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
pip install \
    opentelemetry-distro \
    opentelemetry-exporter-otlp-proto-grpc

# Auto-instrument an existing application — no code changes required
opentelemetry-instrument \
    --traces_exporter otlp_proto_grpc \
    --metrics_exporter otlp_proto_grpc \
    --logs_exporter otlp_proto_grpc \
    --exporter_otlp_endpoint http://otel-collector:4317 \
    --service_name order-api \
    --deployment_environment production \
    uvicorn main:app --host 0.0.0.0 --port 8080

Or via environment variables (cleaner for Docker/Kubernetes):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Dockerfile
FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt && \
    pip install opentelemetry-distro opentelemetry-exporter-otlp-proto-grpc && \
    opentelemetry-bootstrap --action=install  # Auto-installs instrumentation for detected libraries

COPY . .
CMD ["opentelemetry-instrument", "uvicorn", "main:app", "--host", "0.0.0.0"]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Kubernetes deployment env vars
env:
  - name: OTEL_SERVICE_NAME
    value: order-api
  - name: OTEL_EXPORTER_OTLP_ENDPOINT
    value: http://otel-collector.monitoring.svc.cluster.local:4317
  - name: OTEL_EXPORTER_OTLP_PROTOCOL
    value: grpc
  - name: OTEL_RESOURCE_ATTRIBUTES
    value: deployment.environment=production,service.version=2.3.1
  - name: OTEL_TRACES_SAMPLER
    value: parentbased_always_on
  - name: OTEL_PYTHON_LOG_CORRELATION
    value: "true"   # Inject trace IDs into Python log records
  - name: OTEL_PYTHON_LOG_LEVEL
    value: info

Manual Instrumentation in Python

 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
from opentelemetry import trace, metrics
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace import Status, StatusCode
import logging

# Initialize tracer
resource = Resource.create({
    SERVICE_NAME: "order-api",
    SERVICE_VERSION: "2.3.1",
    "deployment.environment": "production",
})

provider = TracerProvider(resource=resource)
provider.add_span_processor(
    BatchSpanProcessor(
        OTLPSpanExporter(endpoint="http://otel-collector:4317", insecure=True)
    )
)
trace.set_tracer_provider(provider)

tracer = trace.get_tracer("order-api")

# Add trace context to Python log records
from opentelemetry.instrumentation.logging import LoggingInstrumentor
LoggingInstrumentor().instrument(set_logging_format=True)

# Now every log record includes trace_id and span_id automatically
logger = logging.getLogger(__name__)


def fetch_order(ctx, order_id: str) -> dict:
    with tracer.start_as_current_span(
        "fetch_order",
        attributes={
            "order.id": order_id,
            SpanAttributes.DB_SYSTEM: "postgresql",
        },
    ) as span:
        try:
            span.add_event("querying database")
            order = db.query("SELECT * FROM orders WHERE id = %s", order_id)

            span.set_attribute("order.status", order["status"])
            span.set_attribute("order.total", float(order["total"]))
            span.set_status(Status(StatusCode.OK))

            logger.info(
                "fetched order",
                extra={"order_id": order_id, "status": order["status"]}
            )
            # Log output includes: trace_id=4bf92f35... span_id=a3ce929d...
            return order

        except Exception as e:
            span.record_exception(e)
            span.set_status(Status(StatusCode.ERROR, str(e)))
            raise


# FastAPI middleware for automatic span creation
from fastapi import FastAPI, Request
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor

app = FastAPI()
FastAPIInstrumentor.instrument_app(app)  # Auto-instruments all routes

Context Propagation in Python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import httpx
from opentelemetry.propagate import inject

async def call_downstream(ctx, url: str) -> dict:
    """Make an outbound HTTP call with trace context propagation."""
    headers = {}
    inject(headers)  # Adds traceparent and tracestate headers
    # headers now contains: {"traceparent": "00-4bf92f35...-a3ce929d...-01"}

    async with httpx.AsyncClient() as client:
        response = await client.get(url, headers=headers)
        return response.json()

Instrumenting Node.js

Auto-Instrumentation Setup

1
2
3
4
5
npm install \
    @opentelemetry/sdk-node \
    @opentelemetry/auto-instrumentations-node \
    @opentelemetry/exporter-trace-otlp-grpc \
    @opentelemetry/exporter-metrics-otlp-grpc
 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 — import this FIRST before any other requires
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 { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');

const sdk = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'order-api',
    [SemanticResourceAttributes.SERVICE_VERSION]: '2.3.1',
    'deployment.environment': process.env.NODE_ENV || 'production',
  }),

  traceExporter: new OTLPTraceExporter({
    url: 'http://otel-collector:4317',
  }),

  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: 'http://otel-collector:4317',
    }),
    exportIntervalMillis: 15000,
  }),

  instrumentations: [
    getNodeAutoInstrumentations({
      // Auto-instruments: http, express, fastify, pg, mysql, redis,
      // mongodb, graphql, grpc, aws-sdk, and many more
      '@opentelemetry/instrumentation-http': {
        ignoreIncomingRequestHook: (req) => {
          // Don't trace health check endpoints
          return req.url === '/health' || req.url === '/metrics';
        },
      },
      '@opentelemetry/instrumentation-pg': {
        enhancedDatabaseReporting: true,  // Include query text in spans
        dbStatementSerializer: (operation, query) => {
          // Sanitize SQL — don't log parameter values
          return query.text;
        },
      },
    }),
  ],
});

sdk.start();
console.log('OpenTelemetry initialized');

process.on('SIGTERM', () => {
  sdk.shutdown().then(() => process.exit(0));
});
 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
// server.js — require tracing before anything else
require('./tracing');  // Must be first

const express = require('express');
const { trace, metrics, context, propagation } = require('@opentelemetry/api');

const app = express();
const tracer = trace.getTracer('order-api');
const meter = metrics.getMeter('order-api');

// Custom metrics
const requestCounter = meter.createCounter('http.server.request.total', {
  description: 'Total HTTP requests',
});

const requestDuration = meter.createHistogram('http.server.request.duration', {
  description: 'HTTP request duration',
  unit: 's',
  advice: {
    explicitBucketBoundaries: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
  },
});

app.get('/api/orders/:id', async (req, res) => {
  const start = Date.now();

  // Get the current span (created by the auto-instrumentation middleware)
  const span = trace.getActiveSpan();
  span?.setAttribute('order.id', req.params.id);

  try {
    // Create a child span for a specific operation
    const order = await tracer.startActiveSpan('fetchOrder', async (childSpan) => {
      try {
        const result = await db.query('SELECT * FROM orders WHERE id = $1', [req.params.id]);
        childSpan.setAttribute('db.rows_affected', result.rowCount);
        return result.rows[0];
      } finally {
        childSpan.end();
      }
    });

    requestCounter.add(1, { 'http.status_code': 200, 'http.route': '/api/orders/:id' });
    res.json(order);
  } catch (err) {
    span?.recordException(err);
    span?.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
    requestCounter.add(1, { 'http.status_code': 500, 'http.route': '/api/orders/:id' });
    res.status(500).json({ error: 'Internal server error' });
  } finally {
    requestDuration.record((Date.now() - start) / 1000, {
      'http.route': '/api/orders/:id',
    });
  }
});

Kubernetes: Operator-Based Instrumentation

For Kubernetes deployments, the OpenTelemetry Operator injects auto-instrumentation into pods without modifying their container images:

1
kubectl apply -f https://github.com/open-telemetry/opentelemetry-operator/releases/latest/download/opentelemetry-operator.yaml
 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
# Define an Instrumentation resource
apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
  name: otel-instrumentation
  namespace: production
spec:
  exporter:
    endpoint: http://otel-collector.monitoring.svc.cluster.local:4317

  propagators:
    - tracecontext
    - baggage

  sampler:
    type: parentbased_traceidratio
    argument: "0.25"  # Sample 25% of new traces

  python:
    env:
      - name: OTEL_PYTHON_LOG_CORRELATION
        value: "true"

  nodejs:
    image: ghcr.io/open-telemetry/opentelemetry-operator/autoinstrumentation-nodejs:latest

  java:
    image: ghcr.io/open-telemetry/opentelemetry-operator/autoinstrumentation-java:latest

  go:
    image: ghcr.io/open-telemetry/opentelemetry-operator/autoinstrumentation-go:latest
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Annotate any pod to enable auto-instrumentation
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-api
  namespace: production
spec:
  template:
    metadata:
      annotations:
        instrumentation.opentelemetry.io/inject-python: "true"
        # Or for Node.js:
        # instrumentation.opentelemetry.io/inject-nodejs: "true"
        # Or for Java:
        # instrumentation.opentelemetry.io/inject-java: "true"

The operator injects an init container that copies the auto-instrumentation SDK into the pod and configures it via environment variables. Zero application code changes required.

Grafana Integration: Connecting the Dots

With Prometheus (metrics), Jaeger/Tempo (traces), and Loki (logs) all receiving OTel data, Grafana ties it together:

 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
# grafana/provisioning/datasources/otel.yaml
apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    url: http://prometheus:9090
    isDefault: true

  - name: Jaeger
    type: jaeger
    url: http://jaeger:16686
    jsonData:
      tracesToLogsV2:
        datasourceUid: loki
        filterByTraceID: true        # Link spans to Loki logs with same trace ID
        filterBySpanID: true

  - name: Loki
    type: loki
    url: http://loki:3100
    jsonData:
      derivedFields:
        - name: TraceID
          matcherRegex: '"trace_id":"(\w+)"'
          url: '$${__value.raw}'
          datasourceUid: jaeger       # Clicking trace ID opens Jaeger

With these datasource links configured:

  • From a Grafana metrics panel showing elevated error rate → click a data point → “View traces” → Jaeger shows you the failing traces
  • From a Jaeger trace span → click “Logs for this span” → Loki shows all log lines from that exact span
  • From a Loki log line with a trace_id field → click the trace ID → jumps to Jaeger

This three-way linking between metrics, traces, and logs is what turns OTel from “just another observability tool” into a genuine debugging superpower. The trace ID is the thread connecting every piece of telemetry from a single request.

Semantic Conventions

OTel defines semantic conventions — standard attribute names for common operations. Using them ensures compatibility with pre-built dashboards, analysis tools, and backend queries.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Instead of this:
span.SetAttributes(attribute.String("db.query", "SELECT ..."))

// Use semantic conventions:
import semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
span.SetAttributes(
    semconv.DBSystemPostgreSQL,
    semconv.DBStatement("SELECT * FROM orders WHERE id = ?"),
    semconv.DBName("orders"),
    semconv.DBOperationName("SELECT"),
)

Key convention namespaces:

Namespace Covers
http.* HTTP clients and servers
db.* Database calls
rpc.* gRPC and other RPC frameworks
messaging.* Kafka, RabbitMQ, SQS
faas.* Serverless functions
k8s.* Kubernetes resources
cloud.* Cloud provider metadata
exception.* Exception recording

Following conventions means Jaeger, Tempo, and other backends can render specialized UI for your spans — database spans show the query, HTTP spans show method/status/URL — without any special configuration.

Sampling Strategies

At high throughput, storing every trace is expensive. Sampling reduces volume while preserving visibility into what matters.

Head-Based Sampling

Decision made at trace start (root span). Simple but can’t guarantee capturing errors.

1
2
3
4
5
6
7
# In SDK: sample 10% of all traces
OTEL_TRACES_SAMPLER=traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1

# parent-based: respect sampling decision from upstream services
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1

Tail-Based Sampling (Collector)

Decision made after the complete trace is received. Can always keep errors and slow traces.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Collector config (already shown above — the tail_sampling processor)
tail_sampling:
  decision_wait: 10s
  policies:
    - name: errors          # Always keep
      type: status_code
      status_code: {status_codes: [ERROR]}
    - name: slow            # Keep if > 1 second
      type: latency
      latency: {threshold_ms: 1000}
    - name: sample-healthy  # Sample 5% of fast, healthy traces
      type: probabilistic
      probabilistic: {sampling_percentage: 5}

Tail-based sampling requires the Collector to buffer complete traces — it needs enough memory to hold decision_wait × expected_new_traces_per_sec traces.

Consistent Probability Sampling

For multi-service architectures, ensure consistent sampling across services (if the root span is sampled, all downstream spans should be too):

1
2
3
4
5
6
// Use ParentBased sampler — respects the sampling decision from the parent span
sdktrace.WithSampler(
    sdktrace.ParentBased(
        sdktrace.TraceIDRatioBased(0.1),  // 10% for root spans
    ),
)

The ParentBased sampler samples a new trace with the root probability but follows the parent’s decision for all subsequent spans — ensuring complete traces are always either fully sampled or fully dropped.

Production Checklist

SDK Setup:
  ☐ Resource attributes: service.name, service.version, deployment.environment
  ☐ W3C traceparent propagator registered globally
  ☐ Graceful SDK shutdown on SIGTERM
  ☐ Batch exporter (not sync) for production

Instrumentation:
  ☐ All HTTP entry points create server spans
  ☐ All outbound HTTP/gRPC calls propagate context
  ☐ Database calls instrumented (otelsql, psycopg2, pg driver)
  ☐ Message queue producers/consumers instrumented
  ☐ Errors recorded with span.RecordError()
  ☐ Semantic conventions used for standard attributes
  ☐ Log records include trace_id and span_id

Collector:
  ☐ Memory limiter processor (prevent OOM)
  ☐ Batch processor (reduce network calls)
  ☐ Tail-based sampling (keep errors, sample healthy)
  ☐ PII redaction for sensitive attributes
  ☐ k8sattributes processor for Kubernetes metadata

Backends:
  ☐ Jaeger/Tempo for traces
  ☐ Prometheus for metrics (via OTel Collector prometheus exporter)
  ☐ Loki for logs
  ☐ Grafana datasource links between metrics → traces → logs

Alerting:
  ☐ Error rate alert (from OTel metrics)
  ☐ P99 latency alert
  ☐ Collector pipeline backpressure alert

The payoff is a complete picture of every request that has ever touched your system: where it went, how long each step took, what errors occurred, and every log line emitted along the way — all correlated automatically by the trace ID that OTel propagated across every service boundary.

Comments