Software Engineering WikiSE Wiki

OpenTelemetry

Configure OpenTelemetry SDKs and Collector pipelines, propagate trace context, choose a sampling strategy and find where telemetry is lost.

Reviewed MarkdownEdit

On this page

Cheatsheet#

TaskCommand or setting
Point an SDK at a Collector over gRPCOTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 OTEL_EXPORTER_OTLP_PROTOCOL=grpc
Point an SDK at a Collector over HTTPOTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
Name the serviceOTEL_SERVICE_NAME=my-app
Sample 10% of new traces, follow the parent otherwiseOTEL_TRACES_SAMPLER=parentbased_traceidratio OTEL_TRACES_SAMPLER_ARG=0.1
Turn off one signalOTEL_METRICS_EXPORTER=none
Turn off the SDK entirelyOTEL_SDK_DISABLED=true
Validate Collector configotelcol validate --config=config.yaml
Print what the Collector receivesadd the debug exporter with verbosity: detailed
Collector’s own metricscurl -s localhost:8888/metrics
Health check extensioncurl -s localhost:13133
Pipeline internals (zpages)curl -s localhost:55679/debug/tracez
Trace header on the wirecurl -v and look for traceparent

Replace otelcol with your distribution’s binary (otelcol-contrib, otelcol-k8s). The OTLP defaults are port 4317 for gRPC and 4318 for HTTP.

Signals, resources and context#

Three signals share one resource and one context.

  • A trace is a tree of spans with a shared 16-byte trace ID. Each span records one operation with start and end time, attributes, events and a status.
  • A metric is an aggregated measurement: counter, up-down counter, gauge or histogram.
  • A log is a timestamped record. When emitted inside an active span it carries that span’s trace and span ID.

Trace IDs attached to logs and to metric exemplars are what let a backend jump from a latency spike to the trace behind it.

A resource describes the producer: service.name, service.version, deployment.environment.name, k8s.pod.name. It is attached to every signal from that process. Backends group by service.name, so a missing or wrong value makes data hard to find; SDKs fall back to unknown_service (plus the process name in some languages).

Note

Semantic conventions 1.27 renamed deployment.environment to deployment.environment.name. Check which one your backend and dashboards expect. See semantic conventions.

Context propagation carries the trace across process boundaries in the W3C traceparent header, with optional tracestate and baggage headers.

traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
             version-trace id (32 hex)-parent span id (16 hex)-flags (01 = sampled)

A trace breaks exactly where propagation breaks: an uninstrumented hop, a proxy or queue that drops headers, async work that loses the context object, or two services configured with different propagators (tracecontext against b3, for example).

SDK configuration with environment variables#

All official SDKs read the same environment variables. Set them in the deployment rather than in code so they can change without a rebuild.

OTEL_SERVICE_NAME=my-app
OTEL_RESOURCE_ATTRIBUTES=service.version=1.4.2,deployment.environment.name=prod
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
OTEL_PROPAGATORS=tracecontext,baggage

Two details cause most “no data” reports:

  • The specification default protocol is http/protobuf, but some SDKs default to grpc. Sending gRPC to 4318 or HTTP to 4317 fails. Set OTEL_EXPORTER_OTLP_PROTOCOL explicitly and match the port.
  • With HTTP, OTEL_EXPORTER_OTLP_ENDPOINT is a base URL and the SDK appends /v1/traces, /v1/metrics or /v1/logs. The signal-specific variables such as OTEL_EXPORTER_OTLP_TRACES_ENDPOINT are used as-is, so they must include the path.

See the OTLP exporter specification and SDK environment variables.

Manual spans#

Auto-instrumentation covers HTTP servers and clients, database drivers and messaging libraries. Add manual spans for business operations the framework cannot name.

tr := otel.Tracer("checkout")
ctx, span := tr.Start(ctx, "reserve_inventory")
defer span.End()
span.SetAttributes(attribute.String("sku", sku), attribute.Int("qty", qty))
if err != nil {
    span.RecordError(err)
    span.SetStatus(codes.Error, "reservation failed")
}

Pass ctx onward. A child span started from a different context starts a new trace.

High-cardinality span attributes (user ID, order ID) are normal in traces; that is what traces are for. Do not copy them onto metric attributes, where each value creates a new series (see Prometheus cardinality). Never record credentials, tokens or personal data; redact in the Collector if the SDK cannot be changed.

SDK setup in Go#

There is no agent for Go; the SDK is wired in main. The exporter constructors read OTEL_EXPORTER_OTLP_* from the environment, resource.Default() reads OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES, and the default sampler reads OTEL_TRACES_SAMPLER, so code only needs to choose the exporter package and register the provider. See Go for module conventions.

import (
    "context"

    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" // otlptracegrpc for 4317
    "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.26.0"
)

func setupTracing(ctx context.Context) (func(context.Context) error, error) {
    exp, err := otlptracehttp.New(ctx)          // endpoint, headers and TLS from OTEL_EXPORTER_OTLP_* variables
    if err != nil {
        return nil, err
    }
    res, err := resource.Merge(resource.Default(), // env detector: OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES
        resource.NewWithAttributes(semconv.SchemaURL, semconv.ServiceVersion("1.4.2")))
    if err != nil {
        return nil, err
    }
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exp),               // BatchSpanProcessor; exports every 5s or 512 spans by default
        sdktrace.WithResource(res),
    )
    otel.SetTracerProvider(tp)
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{}, propagation.Baggage{}))
    return tp.Shutdown, nil                      // call with a timeout on exit or the last batch is lost
}

// Server: one span per request, named after the route pattern when the mux provides one
mux := http.NewServeMux()
srv := &http.Server{Addr: ":8080", Handler: otelhttp.NewHandler(mux, "server")}

// Client: injects traceparent into every outbound request
client := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}

Two omissions account for most silent Go setups: forgetting SetTextMapPropagator (the default propagator is a no-op, so nothing is injected or extracted) and exiting without Shutdown, which drops whatever the batch processor still holds. go.opentelemetry.io/contrib/exporters/autoexport picks the exporter from OTEL_TRACES_EXPORTER (otlp, console, none) if you want that decided at deploy time.

SDK setup in Python#

Python has zero-code instrumentation through a launcher and a manual SDK for code you own. The launcher patches supported libraries at import time and configures exporters from the standard variables.

uv pip install opentelemetry-distro opentelemetry-exporter-otlp   # SDK, launcher and both OTLP exporters
opentelemetry-bootstrap -a install       # detects installed libraries (Flask, requests, psycopg, ...) and installs their instrumentations
OTEL_SERVICE_NAME=my-app OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 \
  opentelemetry-instrument gunicorn my_app:app   # wraps the interpreter; no code change
opentelemetry-instrument --traces_exporter console --metrics_exporter none python main.py   # spans to stdout for a local check

The Python opentelemetry-exporter-otlp package defaults to gRPC on 4317; set OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf to use 4318. OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=redis,urllib3 turns off individual instrumentations, OTEL_PYTHON_EXCLUDED_URLS=healthz,metrics stops health probes creating a span per scrape, and OTEL_PYTHON_LOG_CORRELATION=true writes trace and span IDs into standard logging records. Frameworks that fork workers (gunicorn, uWSGI) need the provider created in the post-fork hook, otherwise every worker shares one exporter connection inherited from the parent.

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

provider = TracerProvider(resource=Resource.create({"service.name": "my-app", "service.version": "1.4.2"}))
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))   # endpoint from OTEL_EXPORTER_OTLP_ENDPOINT
trace.set_tracer_provider(provider)

tracer = trace.get_tracer("checkout")
with tracer.start_as_current_span("reserve_inventory") as span:      # context manager ends the span and restores the parent
    span.set_attribute("sku", sku)
    try:
        reserve(sku)
    except ReservationError as exc:
        span.record_exception(exc)
        span.set_status(trace.StatusCode.ERROR, "reservation failed")
        raise

set_tracer_provider can be called once per process; later calls log a warning and are ignored, which is why a library must never call it. Threads started with threading.Thread do not inherit the active span; pass context.get_current() in and call context.attach in the worker.

Semantic conventions#

Conventions name attributes so that dashboards and backends work across languages. Stable HTTP spans use http.request.method, url.path, url.scheme and http.route on servers, url.full, server.address and server.port on clients, and http.response.status_code, network.protocol.version and error.type on both. The span name is {method} {route} when a low-cardinality route is known and {method} alone otherwise; a span named after the raw path with IDs in it creates one operation per user in every backend. Metrics follow the same pattern: http.server.request.duration is a histogram in seconds with http.request.method, http.route and http.response.status_code attributes.

Older instrumentations emit the pre-1.21 names (http.method, http.status_code, net.peer.name). Java, .NET and Python read OTEL_SEMCONV_STABILITY_OPT_IN=http to emit the stable names, or http/dup to emit both during a migration. Custom attributes belong under a namespace you own (myorg.order.id), never under http. or db. where a future convention can collide.

Exporting to Jaeger, Tempo and Prometheus#

Every current backend accepts OTLP directly, so the SDK-side exporter is always OTLP and the choice happens in the Collector. Jaeger removed its own exporter from the Collector once Jaeger accepted OTLP natively (Jaeger 1.35, and Jaeger 2 is itself built on the Collector).

# Jaeger all-in-one for local work: UI on 16686, OTLP on 4317 and 4318
podman run --rm -p 16686:16686 -p 4317:4317 -p 4318:4318 cr.jaegertracing.io/jaegertracing/jaeger:2.21.0
# Tempo's OTLP receiver listens on the same ports; grpc endpoints take host:port, HTTP takes a URL
exporters:
  otlp_grpc/jaeger:
    endpoint: jaeger.observability:4317
    tls: { insecure: true }              # plaintext inside the cluster; omit for TLS
  otlp_http/tempo:
    endpoint: https://tempo.example.com   # /v1/traces is appended
    headers: { X-Scope-OrgID: my-tenant }
  prometheus:                            # pull: exposes /metrics for Prometheus to scrape
    endpoint: 0.0.0.0:9464
    metric_expiration: 5m                # series without updates disappear after this
    enable_open_metrics: true            # required for exemplars
  prometheus_remote_write:               # push: Prometheus with remote-write receiver, Mimir, Thanos
    endpoint: https://mimir.example.com/api/v1/push
    resource_to_telemetry_conversion: { enabled: true }   # copy resource attributes to labels

OTLP metrics carry resource attributes separately from data point attributes. Without resource_to_telemetry_conversion, service.name and k8s.pod.name end up in a target_info series rather than on every metric, and two pods reporting the same metric collide into one series. Dots become underscores in Prometheus (http.server.request.duration becomes http_server_request_duration_seconds), and delta-temporality counters must pass through the deltatocumulative processor before Prometheus will accept them. See Prometheus for the scrape side.

Dropping and sampling in the Collector#

The filter processor removes whole records that match an OTTL condition; transform edits records in place. Both use the same expression language.

processors:
  filter:
    error_mode: ignore
    trace_conditions:
      - span.attributes["url.path"] == "/healthz"          # drop probe spans
      - resource.attributes["service.name"] == "load-test"
    log_conditions:
      - log.severity_number < SEVERITY_NUMBER_WARN            # keep warnings and above
    metric_conditions:
      - metric.name == "go_gc_duration_seconds"
  probabilistic_sampler:      # head-style sampling in the Collector, consistent by trace ID
    sampling_percentage: 15
    mode: proportional        # honours a previous sampler's decision recorded in tracestate

probabilistic_sampler hashes the trace ID, so every Collector instance with the same hash_seed makes the same decision and traces stay whole without trace-aware load balancing. It cannot keep errors preferentially; that needs tail_sampling. Dropping a span in the middle of a trace leaves its children orphaned in the backend, so filter on resource attributes or on leaf spans such as health checks.

Collector configuration#

The Collector wires receivers, processors and exporters into named pipelines per signal. A component is declared at the top level and does nothing until a pipeline under service references it. Processors run in the order listed in each pipeline.

receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }     # default is localhost; 0.0.0.0 is needed in a container
      http: { endpoint: 0.0.0.0:4318 }

processors:
  memory_limiter:                 # first: refuses data before the process is OOM-killed
    check_interval: 1s
    limit_percentage: 80
    spike_limit_percentage: 25
  k8s_attributes:                 # adds pod, namespace, node and workload attributes
    auth_type: serviceAccount
    extract:
      metadata: [k8s.namespace.name, k8s.pod.name, k8s.deployment.name, k8s.node.name]
  resource_detection:
    detectors: [env, system]
  transform:
    error_mode: ignore
    trace_statements:
      - context: span
        statements:
          - delete_key(attributes, "http.request.header.authorization")
  batch:                          # after memory_limiter and any sampling
    timeout: 5s
    send_batch_size: 8192

exporters:
  otlp_http/tempo:
    endpoint: https://tempo.example.com
    retry_on_failure: { enabled: true, max_elapsed_time: 300s }
    sending_queue: { enabled: true, queue_size: 5000 }
  prometheus_remote_write:
    endpoint: https://mimir.example.com/api/v1/push
  debug:
    verbosity: detailed           # troubleshooting only: logs every record

extensions:
  health_check: { endpoint: 0.0.0.0:13133 }
  zpages: {}

service:
  extensions: [health_check, zpages]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, k8s_attributes, transform, batch]
      exporters: [otlp_http/tempo]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, k8s_attributes, batch]
      exporters: [prometheus_remote_write]
  telemetry:
    metrics:
      level: detailed
      readers:
        - pull:
            exporter:
              prometheus: { host: 0.0.0.0, port: 8888 }

Component names changed in 2026

Collector v0.144.0 renamed otlp (exporter) to otlp_grpc and otlphttp to otlp_http. Contrib components followed, for example k8sattributes to k8s_attributes, resourcedetection to resource_detection and prometheusremotewrite to prometheus_remote_write. The old names remain as deprecated aliases that log a warning. Older Collectors only accept the old names, so match the names to the version you run. The otlp receiver kept its name.

service.telemetry.metrics.address is ignored from v0.123.0; use readers as above. Without any reader configuration the Collector serves its metrics on 127.0.0.1:8888.

Put memory_limiter first so it can refuse data at the receiver, which returns a retryable error to the sender. Put batch after memory_limiter and after any sampling processor, so data that will be dropped is not batched first. Recent Collectors can also batch in the exporter with sending_queue.batch; the batch processor is still supported.

The exporter sending_queue is in memory by default. Queued data is lost on restart unless the queue is given a storage extension such as file_storage.

Deployment patterns#

PatternUse
Agent (DaemonSet)Receives from pods on the node, adds node and pod attributes, forwards to a gateway
Gateway (Deployment)Central processing: tail sampling, redaction, fan-out to several backends, backend credentials
SidecarA workload needs an isolated pipeline or its own credentials

Agent plus gateway is the usual Kubernetes layout. The agent sees the pod’s source IP, which k8s_attributes uses to look up metadata. The gateway holds backend credentials in one place and is where tail sampling can see a whole trace.

The OpenTelemetry Operator can inject auto-instrumentation without changing images:

apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
  name: default
spec:
  exporter:
    endpoint: http://otel-collector:4318
  propagators: [tracecontext, baggage]
  sampler:
    type: parentbased_traceidratio
    argument: "0.1"
# Pod template annotation; also -python, -nodejs, -dotnet, -go
metadata:
  annotations:
    instrumentation.opentelemetry.io/inject-java: "true"

Injection happens at pod creation, so existing pods need a restart. Check the injected environment with kubectl get pod <pod> -o yaml | grep OTEL_ and see Kubernetes if the pod fails to start after injection.

Sampling#

Head sampling decides at the root span and is cheap, but it cannot know whether the request will fail. parentbased_* samplers make child services follow the root’s decision (the 01 flag in traceparent), so a trace is either complete or absent.

Tail sampling buffers every span of a trace in the Collector and decides after decision_wait. It is the only way to keep every error and every slow request while dropping most healthy traffic.

processors:
  tail_sampling:
    decision_wait: 10s        # default 30s
    num_traces: 100000        # traces held in memory; default 50000
    policies:
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow
        type: latency
        latency: { threshold_ms: 1000 }
      - name: baseline
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

Every span of a trace must reach the same Collector instance. Scale tail sampling with two tiers: a first tier using the load-balancing exporter routed by trace ID, and a second tier running tail_sampling. Plain round-robin load balancing splits traces and each instance decides on fragments.

decision_wait must exceed the duration of your longest normal trace. Spans that arrive after the decision are buffered and decided again on their own (unless a decision cache is configured), which produces traces with missing tails at the slowest service. See the tail sampling README.

Sending a test span#

{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"curl-test"}}]},"scopeSpans":[{"scope":{"name":"manual"},"spans":[{"traceId":"5b8efff798038103d269b633813fc60c","spanId":"eee19b7ec3c1b174","name":"test-span","kind":1,"startTimeUnixNano":"1700000000000000000","endTimeUnixNano":"1700000001000000000"}]}]}]}
# Save the JSON above as span.json, then post it to the OTLP/HTTP receiver
curl -sS -w '\n%{http_code}\n' -X POST http://localhost:4318/v1/traces \
  -H 'Content-Type: application/json' -d @span.json
{"partialSuccess":{}}
200

Some versions return {} instead; either body with status 200 means the receiver accepted the span. With the debug exporter in the pipeline, the span appears in the Collector log. If it appears there but not in the backend, the problem is on the export side.

Collector checks#

# Validate before rolling out
otelcol validate --config=config.yaml

# Data refused, failed to send, or failed to enqueue
curl -s localhost:8888/metrics | grep -E 'otelcol_(receiver_refused|exporter_send_failed|exporter_enqueue_failed)'

# Queue fill level against capacity
curl -s localhost:8888/metrics | grep -E 'otelcol_exporter_queue_(size|capacity)'

# Spans accepted per receiver
curl -s localhost:8888/metrics | grep otelcol_receiver_accepted_spans

# Collector memory
curl -s localhost:8888/metrics | grep -E 'otelcol_process_(memory_rss|runtime_heap_alloc_bytes)'

# Confirm a service forwards the trace header (look for traceparent on outbound calls in its logs or proxy)
curl -sv -H 'traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' https://api.example.com/healthz 2>&1 | grep -i traceparent

# Watch records flowing through the debug exporter
kubectl logs -n observability deploy/otel-collector -f | grep -m5 'InstrumentationScope'

# What an SDK process will read from its environment
env | grep ^OTEL_

# Which receivers, processors, exporters and extensions this binary contains
otelcol components | grep -A40 '^processors:'

# Validate with an inline override layered on the file (the yaml: provider)
otelcol validate --config=config.yaml --config='yaml:service::telemetry::logs::level: debug'

# Merge a local override on top of the deployed config (later files win)
otelcol --config=config.yaml --config=overrides.yaml

# Is the health check extension happy (200) or not (503)
curl -s -o /dev/null -w '%{http_code}\n' localhost:13133

# Spans dropped by tail sampling, per policy decision
curl -s localhost:8888/metrics | grep -E 'otelcol_processor_tail_sampling_(count_traces_sampled|sampling_decision_latency)'

# Data the memory_limiter refused
curl -s localhost:8888/metrics | grep otelcol_processor_refused

# Batch sizes actually sent, to tune send_batch_size
curl -s localhost:8888/metrics | grep otelcol_processor_batch_batch_send_size

# Export throughput per exporter and signal
curl -s localhost:8888/metrics | grep -E 'otelcol_exporter_sent_(spans|metric_points|log_records)'

# Late spans that arrived after a tail-sampling decision
curl -s localhost:8888/metrics | grep otelcol_processor_tail_sampling_late_span_age

# Follow sampled trace fragments through zpages for one span name
curl -s 'localhost:55679/debug/tracez?zspanname=GET%20/api&ztype=1&zsubtype=0' | sed 's/<[^>]*>//g' | grep -v '^\s*$' | head -40

# Post a test metric to the OTLP/HTTP receiver
curl -sS -X POST localhost:4318/v1/metrics -H 'Content-Type: application/json' -d '{"resourceMetrics":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"curl-test"}}]},"scopeMetrics":[{"metrics":[{"name":"test.counter","sum":{"aggregationTemporality":2,"isMonotonic":true,"dataPoints":[{"asInt":"1","timeUnixNano":"'$(date +%s%N)'"}]}}]}]}]}'

# Post a test log record with a trace ID so the backend can link it
curl -sS -X POST localhost:4318/v1/logs -H 'Content-Type: application/json' -d '{"resourceLogs":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"curl-test"}}]},"scopeLogs":[{"logRecords":[{"timeUnixNano":"'$(date +%s%N)'","severityNumber":9,"severityText":"INFO","body":{"stringValue":"hello"},"traceId":"5b8efff798038103d269b633813fc60c","spanId":"eee19b7ec3c1b174"}]}]}]}'

# Check the gRPC receiver speaks OTLP without an SDK: any grpc-status header back (even an error) proves the service is there
curl -sS --http2-prior-knowledge -X POST -H 'content-type: application/grpc' -D - -o /dev/null http://localhost:4317/opentelemetry.proto.collector.trace.v1.TraceService/Export | grep -i grpc-status

# Generate a valid random traceparent for a manual request
printf 'traceparent: 00-%s-%s-01\n' "$(openssl rand -hex 16)" "$(openssl rand -hex 8)"

# Send a request with a fresh trace and print the IDs so you can search the backend for them
tp="00-$(openssl rand -hex 16)-$(openssl rand -hex 8)-01"; echo "$tp"; curl -sS -o /dev/null -H "traceparent: $tp" https://api.example.com/orders

# Print the spans the Go SDK would export, without a Collector
OTEL_TRACES_EXPORTER=console go run ./cmd/my-app

# Same for Python, one span per line
opentelemetry-instrument --traces_exporter console --metrics_exporter none --logs_exporter none python main.py

# Which instrumentations the Python launcher will activate
opentelemetry-bootstrap -a requirements

# Pods with auto-instrumentation injected by the Operator
kubectl get pods -A -o json | jq -r '.items[] | select(.metadata.annotations["instrumentation.opentelemetry.io/inject-java"]=="true" or .metadata.annotations["instrumentation.opentelemetry.io/inject-python"]=="true") | "\(.metadata.namespace)/\(.metadata.name)"'

# OTEL_ variables the Operator injected into a running pod
kubectl exec -n my-namespace my-pod -c app -- env | grep ^OTEL_ | sort

# Collector pods and their restart counts
kubectl get pods -n observability -l app.kubernetes.io/name=opentelemetry-collector -o custom-columns='POD:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount,REASON:.status.containerStatuses[0].lastState.terminated.reason'

# Collector config as deployed, from its ConfigMap (the Helm chart stores it under the key "relay")
kubectl get cm -n observability otel-collector -o jsonpath='{.data.relay}' > deployed.yaml && otelcol validate --config=deployed.yaml

# Reload Collector pods after a ConfigMap change (they do not watch the file)
kubectl rollout restart deploy/otel-collector -n observability

# Errors and forbidden responses in the Collector log in the last 10 minutes
kubectl logs -n observability deploy/otel-collector --since=10m | grep -E '"level":"(error|warn)"' | head

# Search Tempo for the trace you just generated
curl -sS "https://tempo.example.com/api/traces/${tp:3:32}" -H "X-Scope-OrgID: my-tenant" | jq '.batches[].resource'

# Search Jaeger for recent traces from one service
curl -sS 'http://localhost:16686/api/traces?service=my-app&limit=5' | jq '.data[].traceID'

Internal metric names can carry a _total suffix depending on the Prometheus exporter settings, so match on the prefix as above. See internal telemetry.

Scripts#

Post a test span and confirm the receiver counted it, so a “no data” report can be split into ingest and export halves from inside the cluster.

#!/usr/bin/env bash
# usage: otel-ingest-check.sh [collector-host] ; needs curl and the Collector's 8888 port reachable
set -euo pipefail
host=${1:-localhost}
accepted() { curl -sf "http://$host:8888/metrics" | awk '/^otelcol_receiver_accepted_spans/ {s += $2} END {print s + 0}'; }
before=$(accepted)
tid=$(openssl rand -hex 16); sid=$(openssl rand -hex 8); now=$(date +%s%N)
body=$(printf '{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"ingest-check"}}]},"scopeSpans":[{"spans":[{"traceId":"%s","spanId":"%s","name":"ingest-check","kind":1,"startTimeUnixNano":"%s","endTimeUnixNano":"%s"}]}]}]}' "$tid" "$sid" "$now" "$now")
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "http://$host:4318/v1/traces" -H 'Content-Type: application/json' -d "$body")
[[ $code == 200 ]] || { printf 'receiver returned %s\n' "$code" >&2; exit 1; }
sleep 2
after=$(accepted)
printf 'trace %s accepted; receiver counter %s -> %s\n' "$tid" "$before" "$after"
(( after > before )) || { printf 'counter did not move: is 8888 the right Collector?\n' >&2; exit 1; }

Summarise a Collector’s health from its internal metrics: refused, failed and queued data per component, so the failing stage is obvious before reading logs.

#!/usr/bin/env bash
# usage: otel-collector-report.sh [collector-host]
set -euo pipefail
host=${1:-localhost}
m=$(curl -sf --max-time 5 "http://$host:8888/metrics") || { echo "cannot scrape $host:8888" >&2; exit 1; }
health=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://$host:13133" || true)
printf 'health_check: %s\n\n' "${health:-unreachable}"
printf '%-34s %-28s %12s\n' METRIC COMPONENT VALUE
printf '%s\n' "$m" | awk '
  /^otelcol_(receiver_refused|exporter_send_failed|exporter_enqueue_failed|processor_refused|exporter_queue_size|exporter_queue_capacity)/ {
    name = $1; sub(/\{.*/, "", name)
    comp = ""; if (match($0, /(receiver|exporter|processor)="[^"]*"/)) comp = substr($0, RSTART, RLENGTH)
    if ($2 + 0 > 0) printf "%-34s %-28s %12d\n", name, comp, $2
  }'
printf '\nrss: %s MiB\n' "$(printf '%s\n' "$m" | awk '/^otelcol_process_memory_rss/ {printf "%d", $2 / 1048576}')"

Generate a linked parent and child span with fresh IDs and post them over OTLP/HTTP using only the Python standard library, for checking a backend’s trace view without installing an SDK.

#!/usr/bin/env python3
"""usage: otel-test-trace.py [http://localhost:4318]"""
import json, secrets, sys, time, urllib.request

endpoint = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:4318").rstrip("/")
trace_id, parent_id, child_id = secrets.token_hex(16), secrets.token_hex(8), secrets.token_hex(8)
now = time.time_ns()

def span(span_id, name, start, end, parent=None, kind=2):
    s = {"traceId": trace_id, "spanId": span_id, "name": name, "kind": kind,
         "startTimeUnixNano": str(start), "endTimeUnixNano": str(end),
         "attributes": [{"key": "http.request.method", "value": {"stringValue": "GET"}}]}
    if parent:
        s["parentSpanId"] = parent
    return s

payload = {"resourceSpans": [{
    "resource": {"attributes": [{"key": "service.name", "value": {"stringValue": "test-trace"}}]},
    "scopeSpans": [{"scope": {"name": "manual"}, "spans": [
        span(parent_id, "GET /orders", now - 300_000_000, now),
        span(child_id, "SELECT orders", now - 250_000_000, now - 50_000_000, parent=parent_id, kind=3),
    ]}]}]}
req = urllib.request.Request(f"{endpoint}/v1/traces", data=json.dumps(payload).encode(),
                             headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=10) as resp:
    print(resp.status, resp.read().decode())
print(f"trace_id={trace_id}")

Troubleshooting#

SymptomLikely causeCheck
No data from an SDKProtocol and port mismatch, wrong endpoint path, SDK disabledenv | grep ^OTEL_; SDK debug logging; send a test span from the same pod
Collector fails to startUnknown component name or key, often a contrib component in a core buildotelcol validate; otelcol components lists what the binary contains
Deprecation warnings at startOld component names on v0.144.0 or laterRename to otlp_grpc, otlp_http, k8s_attributes and so on
connection refused from the SDKReceiver bound to localhost inside a containerSet receiver endpoint: 0.0.0.0:4317
Service appears as unknown_serviceservice.name not setSet OTEL_SERVICE_NAME
Traces split into several tracesPropagation lost at a hop, or mismatched propagatorsLog incoming traceparent at each hop; align OTEL_PROPAGATORS
Traces with missing spansHead sampling not parent-based, or tail sampling instances each saw part of the traceUse parentbased_*; route by trace ID before tail_sampling
otelcol_receiver_refused_* risingmemory_limiter refusing dataCollector memory metrics; raise limits or scale out
otelcol_exporter_queue_size at capacityBackend slow or downotelcol_exporter_send_failed_*; backend health
k8s_attributes adds nothingRBAC missing, or the agent sees a proxy IP instead of the pod IPCollector logs for forbidden errors; send from pods directly to the node agent
Metrics duplicated or missing in PrometheusTwo Collectors write the same series, or resource attributes droppedCompare job and instance; check resource_to_telemetry_conversion
Go service exports spans but nothing links across servicesotel.SetTextMapPropagator never called; the default propagator is a no-opSet the composite TraceContext and Baggage propagator; check outbound requests for traceparent
Last spans before exit never arriveProcess exited without TracerProvider.Shutdown (Go) or before the batch processor flushed (Python atexit skipped by os._exit)Call shutdown with a timeout on SIGTERM; set OTEL_BSP_SCHEDULE_DELAY lower for short-lived jobs
Python: Overriding of current TracerProvider is not allowedset_tracer_provider called twice, usually once by opentelemetry-instrument and once in codeRemove the manual call under the launcher, or drop the launcher
One span per health-check scrapeProbes instrumented like any requestOTEL_PYTHON_EXCLUDED_URLS=healthz,metrics; otelhttp.WithFilter in Go; or a filter processor on url.path
Same service shows as two in the backendDifferent service.name from env and code, or one pod with the old deployment.environment and one with .namekubectl exec -- env | grep OTEL_; otelcol debug exporter output for the resource
Attribute names differ between servicesMixed old and stable HTTP semantic conventionsOTEL_SEMCONV_STABILITY_OPT_IN=http or http/dup on the older SDKs; upgrade instrumentation
Every metric has the same value across pods, or series flapResource attributes not converted to labels, so pods overwrite one anotherresource_to_telemetry_conversion on the exporter or resource_constant_labels on prometheus
Delta counters missing after a Collector upgradePrometheus exporters drop delta temporalitydeltatocumulative processor before the exporter, or OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=cumulative in the SDK
Collector memory climbs until OOM with tail_samplingnum_traces times average trace size exceeds the limit, or decision_wait too longotelcol_processor_tail_sampling_* metrics; lower num_traces or add replicas behind the load-balancing exporter
Config change has no effectCollector does not reload on ConfigMap updateskubectl rollout restart the Collector; confirm with otelcol_process_uptime

Further reading#