ObservabilityLVII · Docker ObservabilityDockerObs
Docker Traces
What you'll learn
- Explain how the OpenTelemetry SDK ships spans from inside a container to Tempo
- Choose between an agent-on-host and a per-container sidecar based on the workload
- Configure resource attributes, sampling, and propagation so traces from every container correlate
- Diagnose the four most common Docker tracing failure modes
Prerequisites
Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13
A checkout service is slow. The on-call engineer opens Tempo, looks up a recent trace by ID, and sees a single span: the incoming HTTP request from the load balancer. The downstream calls to the payment service, the database, and the cache are all missing. The trace was supposed to be a complete picture of the request; it is not. The container’s environment variables were set at the wrong level; the OpenTelemetry SDK inside the container never received the OTLP endpoint and never initialised a span exporter. The trace was generated and discarded.
This lesson is about the OTLP exporter path from inside a container to Tempo: what the SDK inside the container does, what the agent on the host does, and the right pattern per workload.
What it is
A trace from a Docker container starts with the OpenTelemetry SDK inside the container. The SDK instruments the application’s HTTP client, gRPC client, database driver, and other I/O libraries; generates spans; and ships them to a collector. The collector batches and forwards them to Tempo.
application process (inside container)
|
| OpenTelemetry SDK
| - auto-instrumentation
| - manual instrumentation
| - batch span processor
v
OTLP exporter
| gRPC :4317 or HTTP :4318
+--------------+--------------+
|
+--------+--------+
| |
agent on host sidecar per container
(Alloy or OTel (Alloy or OTel
Collector) Collector)
| |
+--------+--------+
|
v
Tempo
The two deployment patterns are:
- Agent on the host. A single collector runs as a long-lived process on the host (or as a host-network container). Every application container exports to it via the host network. The collector forwards to Tempo.
- Sidecar per container. Every application container has its own collector sidecar. The sidecar is on a dedicated network namespace with the application container and forwards to Tempo.
Both patterns reach the same destination; the trade is in operational cost, isolation, and protocol compatibility.
Why a sysadmin cares
The right pattern for a given workload is the difference between “every container ships spans to Tempo” and “we cannot figure out which container’s traces are missing”. A team that picks the wrong pattern finds out at the first multi-tenant incident.
Three production shapes put the pattern choice on the critical path:
- A polyglot fleet. Different runtimes (Java, Python, Node, Go) have different auto-instrumentation quirks. A sidecar pattern keeps the SDK inside the container; the collector is language-agnostic and the application configures only the OTLP endpoint.
- A multi-tenant host. Two teams sharing a Docker host must not be able to read each other’s traces before the collector forwards them. The agent-on-host pattern is single-tenant by default; the sidecar pattern needs additional isolation.
- A trace budget. Tempo’s storage cost is bounded by the sampling rate. An agent-on-host enforces sampling centrally; per-container sampling is harder to reason about.
How it works
The OpenTelemetry SDK inside the container does three things:
- Auto-instrumentation. The SDK patches the application’s HTTP client, gRPC client, database driver, and other I/O libraries at startup. Each patched library generates a span for the work it does. The patches are language-specific; Java uses a Java agent, Python uses a sitecustomize.py, Node uses a require hook, Go uses the otelhttp middleware.
- Span context propagation. The SDK reads the W3C
traceparentheader on incoming requests and writes it on outgoing requests. This is how a trace from the load balancer is connected to a span in the payment service. - Export. The SDK batches spans and exports them via OTLP (gRPC on port 4317, HTTP on port 4318). The batch span processor handles retries, queue size, and timeout.
The collector on the other side receives the spans, applies sampling and redaction, and forwards to Tempo. The collector is the place where sampling decisions are enforced centrally and where batched exports to Tempo happen.
container A container B
+-------------------+ +-------------------+
| app + OTel SDK | | app + OTel SDK |
| spans -- OTLP --> | | spans -- OTLP --> |
+--------- ---------+ +--------- ---------+
| |
+---------------+----------------+
|
v
collector (Alloy or OTel)
|
v
Tempo
How to configure it
Three configuration shapes cover most production needs.
Pattern A: agent on the host
# docker-compose.yml
services:
app:
image: registry.example.com/checkout:v3.4.1
environment:
OTEL_SERVICE_NAME: checkout
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
OTEL_RESOURCE_ATTRIBUTES: service.name=checkout,deployment.environment=prod,service.version=3.4.1
OTEL_TRACES_SAMPLER: parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG: "0.1"
depends_on:
- collector
network_mode: bridge
collector:
image: grafana/alloy:latest
volumes:
- ./alloy.river:/etc/alloy/config.river:ro
network_mode: host
restart: unless-stopped
# alloy.river
otelcol.receiver.otlp "default" {
grpc {
endpoint = "0.0.0.0:4317"
}
http {
endpoint = "0.0.0.0:4318"
}
output {
traces = [otelcol.exporter.otlp.tempo.input]
}
}
otelcol.exporter.otlp "tempo" {
client {
endpoint = "tempo.internal:4317"
}
}
Severity: CONFIGURATION. Compose up to apply.
The agent runs on the host network; the application container exports to the host’s loopback. The collector is the single choke point for sampling and redaction.
Pattern B: sidecar per container
# docker-compose.yml
services:
app:
image: registry.example.com/checkout:v3.4.1
environment:
OTEL_SERVICE_NAME: checkout
OTEL_EXPORTER_OTLP_ENDPOINT: http://collector-sidecar:4317
networks:
- app-net
collector-sidecar:
image: grafana/alloy:latest
volumes:
- ./alloy-sidecar.river:/etc/alloy/config.river:ro
networks:
- app-net
restart: unless-stopped
# alloy-sidecar.river
otelcol.receiver.otlp "default" {
grpc {
endpoint = "0.0.0.0:4317"
}
output {
traces = [otelcol.exporter.otlp.tempo.input]
}
}
otelcol.exporter.otlp "tempo" {
client {
endpoint = "tempo.internal:4317"
}
}
Severity: CONFIGURATION. Compose up to apply.
The sidecar runs on the same network as the application. The
OTLP endpoint is http://collector-sidecar:4317. The
sidecar pattern keeps every container’s network traffic on a
dedicated network namespace.
Pattern C: SDK inside the container, direct OTLP to Tempo
# docker-compose.yml
services:
app:
image: registry.example.com/checkout:v3.4.1
environment:
OTEL_SERVICE_NAME: checkout
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo.internal:4317
OTEL_RESOURCE_ATTRIBUTES: service.name=checkout,deployment.environment=prod
Severity: CONFIGURATION. Compose up to apply.
The SDK ships directly to Tempo. This is the simplest pattern and is appropriate when Tempo is reachable, the network policy permits the connection, and there is no need for centralised sampling or redaction at a collector.
Java auto-instrumentation
# Build the image with the OpenTelemetry Java agent.
docker build -t checkout:v3.4.1 \
--build-arg OTEL_AGENT_VERSION=2.10.0 \
--build-arg OTEL_SERVICE_NAME=checkout .
docker run -d \
--name checkout \
-e OTEL_EXPORTER_OTLP_ENDPOINT=http://host.docker.internal:4317 \
-e OTEL_SERVICE_NAME=checkout \
-e OTEL_RESOURCE_ATTRIBUTES=service.name=checkout,deployment.environment=prod \
checkout:v3.4.1
Severity: CONFIGURATION. Image build and container start.
The OpenTelemetry Java agent is loaded via
-javaagent:/path/to/opentelemetry-javaagent.jar. It patches
the JVM at startup and instruments every supported library
without code changes. The agent’s behaviour is controlled by
the same OTEL_* environment variables as the SDK.
Python auto-instrumentation
docker run -d \
--name checkout \
-e OTEL_EXPORTER_OTLP_ENDPOINT=http://host.docker.internal:4317 \
-e OTEL_SERVICE_NAME=checkout \
-e OTEL_RESOURCE_ATTRIBUTES=service.name=checkout,deployment.environment=prod \
-e OTEL_PYTHON_AUTO_INSTRUMENTATION_ENABLED=true \
registry.example.com/checkout:v3.4.1 \
opentelemetry-instrument python -m checkout
Severity: CONFIGURATION. Container start with the
opentelemetry-instrument launcher.
The Python launcher activates auto-instrumentation via the
OTEL_PYTHON_AUTO_INSTRUMENTATION_ENABLED flag and the
opentelemetry-instrument command. The launcher patches
supported libraries at import time.
How to validate it
Five checks, cheapest first.
# Substitute your own value before running:
TRACE_ID=4bf92f3577b34da6a3ce929d0e0e4736
# READ-ONLY: the container exports to the expected endpoint.
docker exec checkout \
sh -c 'echo $OTEL_EXPORTER_OTLP_ENDPOINT'
# http://host.docker.internal:4317
# READ-ONLY: the SDK has initialised.
docker logs checkout 2>&1 | grep -i 'opentelemetry\|otel'
# OpenTelemetry: Java agent v2.10.0 attached
# OpenTelemetry: default exporter is OTLP
# READ-ONLY: a synthetic span from the SDK.
docker exec checkout \
curl -fsS http://localhost:8080/healthz
# READ-ONLY: the collector received the spans.
curl -fsS http://host.docker.internal:8889/metrics \
| grep '^otelcol_exporter_sent_spans'
# otelcol_exporter_sent_spans{exporter="otlp/tempo"} 42
# READ-ONLY: Tempo has the trace.
curl -fsS "http://tempo.internal:3200/api/traces/$TRACE_ID" \
| jq -r '.resourceSpans[0].scopeSpans[0].spans[0].name'
# "GET /checkout"
A clean validation: the container’s environment points at the right endpoint, the SDK has initialised, a synthetic span is generated, the collector has forwarded it, and Tempo has the trace. Each failure mode below maps to one of these signals failing.
How it can fail
- The OTLP endpoint points at the wrong host. Cause:
OTEL_EXPORTER_OTLP_ENDPOINTis set to a host the container cannot reach (oftenlocalhostinside the container’s network namespace). The SDK logs a connection refused on every batch. Detection:otelcol_exporter_send_failed_spansrises; the container’s own logs show OTLP errors. - The service name is auto-detected as the binary
name. Cause:
OTEL_SERVICE_NAMEis not set, and the SDK falls back to the binary name orunknown_service. Every container’s spans arrive grouped under the wrong service. Detection: Tempo’s service dropdown showsunknown_serviceorpython3. - Spans are lost on container shutdown. Cause: the
application does not call the SDK’s shutdown hook on
SIGTERM. The batch span processor’s queue is flushed
only on graceful shutdown. Detection: a batch job with
OTEL_BSP_SCHEDULE_DELAY=5000and no SIGTERM handler loses up to 5 seconds of spans per exit. - Trace context propagation is broken at the load
balancer. Cause: the load balancer strips the W3C
traceparentheader. The trace is broken at the first hop. Detection: every trace in Tempo has a single span from the load balancer’s vantage point; downstream spans arrive as separate traces with no parent. - The sidecar collector is down. Cause: the sidecar container has crashed or been restarted. The application cannot reach the sidecar. Detection: the application’s logs show OTLP connection refused.
- Sampling drops all traces. Cause: the sampler is
configured with a ratio of
0for one environment. The collector receives spans but drops them. Detection:otelcol_processor_tail_sampling_traces_droppedrises without a corresponding rise inotelcol_exporter_sent_spans.
How to troubleshoot it
- Is the OTLP endpoint reachable from the container?
docker exec <container> curl -v $OTEL_EXPORTER_OTLP_ENDPOINT/v1/traces -d '\{\}'. A 200 from the collector confirms the path. - Did the SDK initialise?
docker logs <container> | grep -i 'opentelemetry\|otel'. A clean initialisation logs the version and the configured endpoint. - Are spans reaching the collector? The collector’s
/metricsendpoint exposesotelcol_exporter_sent_spans. A non-zero count confirms the path. - Are spans reaching Tempo? Tempo’s
/api/searchreturns traces by service name and tags. A query for theservice.namereturns recent traces if the path is intact. - Is trace context propagation intact? Open a request
from a known trace and follow the
traceparentheader through the load balancer to the application. A stripped header produces a broken trace; the fix is at the load balancer.
Security implications
- The collector is an unauthenticated receiver by default. Anyone who can reach port 4317 can send spans. The mitigation is network isolation, not authentication at the receiver.
- Span attributes can carry sensitive values. The application emits attributes such as customer IDs, payment amounts, and database query parameters. Tempo stores the attributes verbatim. Audit the application’s instrumentation.
- Resource attributes reveal topology. The
service.namespaceanddeployment.environmentattributes can be inferred from the labels; a careless service name leaks the deployment shape. - Sidecar collectors share fate with the application. A sidecar that fails restarts with the application; a sidecar that is compromised exposes the application. Treat the sidecar as a privileged neighbour.
Performance implications
- SDK overhead. The auto-instrumentation adds a span per I/O operation. A busy service can generate tens of thousands of spans per second. The cost is CPU on the application container.
- Network cost. OTLP/gRPC compresses spans; OTLP/HTTP does not. For high-volume services, gRPC is materially cheaper.
- Sampling. A 10 percent sampler drops 90 percent of spans at the collector. The cost of storage is paid on the 10 percent that survive. Set the sampling rate based on the budget.
Production guidance
- Choose the agent-on-host pattern for a single-tenant host with a small number of services. The operational cost is one collector per host.
- Choose the sidecar pattern for a multi-tenant host or a host with strict isolation requirements. The cost is one collector per application container.
- Set
OTEL_SERVICE_NAMEexplicitly per container. Never rely on auto-detection. - Set
OTEL_TRACES_SAMPLERandOTEL_TRACES_SAMPLER_ARGdeliberately. A 10 percent ratio is a reasonable starting point; adjust based on the storage budget. - Configure a
preStophook or a SIGTERM handler that flushes the batch span processor on shutdown. The default behaviour loses spans. - Validate the OTLP endpoint at startup. A Prometheus alert
on
otelcol_exporter_send_failed_spans > 0catches the common failure mode.
Verification
You should now be able to answer:
- What three things does the OpenTelemetry SDK do inside a container?
- What is the operational difference between agent-on-host and sidecar?
- Why must
OTEL_SERVICE_NAMEbe set explicitly? - What is the most common cause of lost spans on container shutdown?
- How do you verify that spans are reaching Tempo?
Quiz
Knowledge check · 8 questions
Q1. What is the role of the OpenTelemetry SDK inside a container?
Q2. The agent-on-host pattern is appropriate for a single-tenant host with a small number of services.
Q3. Which of these are valid reasons to choose the sidecar pattern?
Q4. OTEL_SERVICE_NAME is not set on a container. What happens?
Q5. Name the environment variable that sets the trace sampling ratio for an OpenTelemetry SDK.
Q6. A batch job exits with SIGTERM. The batch span processor had a 5-second schedule delay. What happens to spans in the queue?
Q7. The W3C traceparent header must be preserved across every hop in a request path for the trace to be intact.
Q8. Which of these are valid choices for the OTLP exporter transport?
Passing score: 75%. Answers are checked in this browser.