Skip to content

OpenTelemetry

BoxOffice is written in Java (gateway, order, inventory, payment, catalog), Python (the AI assistant), runs Nginx at the edge, Kafka in the middle and PostgreSQL at the bottom. Ten years ago, tracing a checkout across all of that meant a vendor agent per language, a proprietary header per vendor, and a full re-instrumentation the day the company changed vendors. Worse, a trace broke at the first component that spoke a different dialect. OpenTelemetry (OTel) fixed this by standardising the boring parts β€” the API, the wire protocol, the context header and the names of things β€” so that every library and every backend can speak the same language1. (The ideas themselves β€” trace IDs, spans, parent links, sampling β€” come from Google's Dapper paper4.)


What OpenTelemetry is (and is not)

OpenTelemetry is a CNCF project that defines how telemetry is produced and transported. It is not a backend: it does not store or display anything. You still choose where the data goes β€” Prometheus, Tempo, Jaeger, Loki, a cloud service or a vendor β€” and you can change that choice without touching application code.

Piece What it is BoxOffice example
API interfaces to create spans, metrics and logs; safe no-ops if no SDK is installed Tracer, Meter used inside payment
SDK the implementation: sampling, batching, resource detection, exporters configured by the Java agent from environment variables
Instrumentation libraries ready-made spans/metrics for frameworks: Spring MVC, JDBC, Kafka clients, FastAPI, HTTP clients the SELECT seat_holds span, created without any code
Semantic conventions standard names for attributes and metrics: http.route, db.system, messaging.destination.name every service names the route the same way, so one dashboard works for all
OTLP the OpenTelemetry Protocol, over gRPC (port 4317) or HTTP/protobuf (port 4318) all services export to the Collector over OTLP
Collector a standalone process that receives, processes and exports telemetry redacts card numbers, applies tail sampling, fans out to backends

The signals have matured at different speeds: traces and metrics are stable, logs became stable later (OTel mostly bridges existing logging libraries, adding the trace context), and profiles were added as the fourth signal and are still maturing.

flowchart LR
    subgraph services ["BoxOffice services"]
        J["Java services<br/>OTel Java agent"]
        PY["assistant (Python)<br/>OTel SDK"]
        NG["Nginx<br/>OTel module"]
    end
    subgraph col ["OTel Collector"]
        R["receivers<br/>otlp"] --> PR["processors<br/>memory_limiter, k8s attrs,<br/>redaction, tail sampling, batch"] --> E["exporters"]
    end
    J -- OTLP --> R
    PY -- OTLP --> R
    NG -- OTLP --> R
    E --> TEMPO[("Tempo / Jaeger<br/>traces")]
    E --> PROM[("Prometheus / Mimir<br/>metrics")]
    E --> LOKI[("Loki<br/>logs")]

Context propagation

A trace exists only if every hop knows which trace it belongs to. Context propagation is the mechanism: the caller injects the current trace context into the outgoing request, and the callee extracts it and creates its own span as a child.

The traceparent header

OpenTelemetry uses the W3C Trace Context standard2 by default. One header carries the context:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             β”‚  β”‚                                β”‚                └─ flags: 01 = sampled
             β”‚  β”‚                                └─ parent-id: the caller's current span (64 bits)
             β”‚  └─ trace-id: same for the whole request (128 bits)
             └─ version

On every outgoing call, a service keeps the trace-id and the flags and replaces the parent-id with the ID of its own current span. No service needs to understand the whole trace; the tree is rebuilt by the backend from the parent links. A second header, tracestate, carries vendor-specific data, and baggage (baggage: tenant=festival-x,channel=mobile) carries business key-value pairs that every downstream service can read β€” useful, but every byte travels on every request, so keep it small and never put secrets in it.

Following a checkout

Play the diagram and watch the parent-id change at each hop while the trace-id stays the same β€” including across Kafka, where the context travels in a message header:

sequenceDiagram
    autonumber
    participant GW as gateway
    participant O as order
    participant I as inventory
    participant P as payment
    participant X as payment provider
    participant K as Kafka
    participant N as notification
    GW->>O: POST /orders<br/>traceparent: 00-4bf9…-a1a1…-01
    O->>I: PUT /holds/h7/confirm<br/>traceparent: 00-4bf9…-b2b2…-01
    I-->>O: 200 OK (90 ms)
    O->>P: POST /charges<br/>traceparent: 00-4bf9…-c3c3…-01
    P->>X: POST /v1/charges (CLIENT span recorded by payment)
    X-->>P: 200 OK (1.0 s)
    P-->>O: 201 Created
    O->>K: produce OrderConfirmed<br/>header traceparent: 00-4bf9…-d4d4…-01
    O-->>GW: 201 Created
    K->>N: consume OrderConfirmed (CONSUMER span, parent d4d4…)
    N->>N: send e-mail and push

Three details matter in practice:

  • The external provider is a black box. It will not send its spans to you. The CLIENT span recorded by payment around the call is all you will ever see of it β€” which is why that span's duration and status are worth an alert of their own. Many teams also strip traceparent and baggage at egress, so internal IDs do not leak to third parties.
  • Asynchronous hops propagate through message headers. A consumer that handles one message continues the producer's trace (parent-child). A consumer that processes a batch of 500 messages cannot have 500 parents; it starts its own trace and adds span links to each producer's span.
  • The trace ends where propagation breaks. One component that drops the header splits every trace in two.

Where propagation usually breaks

Place Symptom Fix
A proxy or gateway that rebuilds requests and drops unknown headers traces start at the service behind it forward traceparent, tracestate, baggage; instrument the proxy
Work handed to another thread (@Async, executors, reactive code) child spans appear as separate traces use instrumented executors (the Java agent wraps common ones) or pass Context explicitly
Messaging without headers (old clients, custom envelopes) consumers start new traces use headers; or carry the context in the message envelope
A homemade HTTP client the call has no CLIENT span and the callee starts a new trace use an instrumented client or inject the context manually

In the BoxOffice code: the trace through the outbox (lab 8)

The agent propagates the context over HTTP and in Kafka headers by itself, but BoxOffice's events do not go straight to Kafka: since lab 7, order writes them to an outbox table in the same transaction as the order, and a relay publishes them later, on a scheduler thread β€” a hop the agent cannot see, which split every order into two unrelated traces. Hands-on 8 β€” Instrument everything carries the traceparent with the data: one new column (V2__outbox_traceparent.sql), inject when the row is written, then extract and makeCurrent() before the relay sends, so the agent's producer span becomes a child of the original request. In the lab's test run the result is one trace: the request took 9 ms, the relay published 22 ms after it started, and notification spent 67 ms.

services/order/src/main/java/boxoffice/order/outbox/Outbox.java (write the event and its traceparent)
@Transactional(propagation = Propagation.MANDATORY)
public void append(UUID eventId, String aggregateType, UUID aggregateId, String eventType, Object payload) {
    String json = mapper.writeValueAsString(payload); // Jackson 3: exceptions are unchecked
    // capture the trace context of the current request (null without the agent)
    Map<String, String> carrier = new HashMap<>();
    W3CTraceContextPropagator.getInstance().inject(Context.current(), carrier, (c, k, v) -> c.put(k, v));

    jdbc.sql("""
            INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload, traceparent)
            VALUES (?, ?, ?, ?, ?::jsonb, ?)
            """)
            .params(eventId, aggregateType, aggregateId, eventType, json, carrier.get("traceparent"))
            .update();
}
services/order/src/main/java/boxoffice/order/outbox/OutboxRelay.java (publish inside the original context)
for (Pending p : batch) {
    ProducerRecord<String, String> record =
            new ProducerRecord<>(topic, p.aggregateId().toString(), p.payload());
    record.headers().add("eventId", p.id().toString().getBytes(StandardCharsets.UTF_8));
    record.headers().add("eventType", p.eventType().getBytes(StandardCharsets.UTF_8));
    // continue the trace of the request that created the event
    Context origin = W3CTraceContextPropagator.getInstance().extract(Context.root(), p, ROW);
    try (Scope ignored = origin.makeCurrent()) {
        acks.add(kafka.send(record)); // the agent's producer span is a child of the request
    }
}

Instrumenting BoxOffice

Zero-code: the Java agent

The OpenTelemetry Java agent attaches to the JVM and instruments hundreds of libraries β€” Spring MVC/WebFlux, JDBC, HikariCP, Kafka clients, HTTP clients, Logback β€” by bytecode injection, with no code changes. It also puts trace_id and span_id into the logging context (MDC), so structured logs carry them automatically.

# Dockerfile (excerpt) β€” pin a version; check the latest release before upgrading
ADD https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.32.0/opentelemetry-javaagent.jar /otel/opentelemetry-javaagent.jar
ENTRYPOINT ["java", "-javaagent:/otel/opentelemetry-javaagent.jar", "-jar", "/app/app.jar"]
# docker-compose.yaml (excerpt) β€” the order service
environment:
  OTEL_SERVICE_NAME: order
  OTEL_RESOURCE_ATTRIBUTES: service.namespace=boxoffice,service.version=1.4.2,deployment.environment.name=dev
  OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318   # agent 2.x default protocol: http/protobuf
  OTEL_TRACES_SAMPLER: parentbased_always_on                 # keep all; the Collector decides (tail sampling)
  OTEL_METRIC_EXPORT_INTERVAL: "15000"

service.name is the most important attribute in OpenTelemetry: without it, every service appears as unknown_service:java and no dashboard can tell them apart. service.version and deployment.environment.name let you compare a canary with the stable version, or staging with production.

In the BoxOffice code: one agent for every Java service (lab 8)

Hands-on 8 β€” Instrument everything does not bake the agent into the images, as the Dockerfile above does: scripts/get-otel-agent.sh downloads a pinned version (2.32.0) once into infra/otel/, and every Java service mounts it read-only and loads it through JAVA_TOOL_OPTIONS. The settings live in one YAML anchor merged into each service, so the services cannot drift apart; only OTEL_SERVICE_NAME changes β€” three catalog replicas, one service. OTEL_ON=false runs the same image without the agent, which is how the lab measures what the agent costs; metrics are exported every 10 s instead of 60 s, so the dashboards react within a short experiment.

docker-compose.yaml (the x-otel anchor and the catalog replicas)
# lab 8: OpenTelemetry settings shared by every Java service (merged into each "environment")
x-otel: &otel
  JAVA_TOOL_OPTIONS: -javaagent:/otel/opentelemetry-javaagent.jar
  OTEL_JAVAAGENT_ENABLED: ${OTEL_ON:-true}          # false = same image, agent off
  OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-lgtm:4318
  OTEL_METRIC_EXPORT_INTERVAL: "10000"              # 10 s instead of 60 s
  OTEL_RESOURCE_ATTRIBUTES: service.namespace=boxoffice,deployment.environment.name=local
  OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_always_on}
  OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-1.0}
  LOGGING_PATTERN_CORRELATION: "[%X{trace_id:-},%X{span_id:-}] "   # trace id in console logs
x-catalog: &catalog
  build: ./services/catalog
  image: boxoffice-catalog
  volumes:
    - ./infra/otel:/otel:ro          # lab 8: the Java agent
  environment:
    <<: *otel
    OTEL_SERVICE_NAME: catalog       # three replicas, ONE service

Zero-code: the Python assistant

uv add opentelemetry-distro opentelemetry-exporter-otlp
uv run opentelemetry-bootstrap -a requirements > otel-instrumentations.txt   # instrumentations for the libraries found (FastAPI, httpx, psycopg…)
uv add -r otel-instrumentations.txt
OTEL_SERVICE_NAME=assistant \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
uv run opentelemetry-instrument uvicorn app.main:app --host 0.0.0.0 --port 8000

Manual spans where the agent cannot see

Automatic instrumentation sees I/O. It cannot see business steps or waiting inside your code β€” the connection pool of the payment adapter in the Signals waterfall. A few manual spans in the right places are worth more than hundreds of automatic ones:

private static final Tracer tracer = GlobalOpenTelemetry.getTracer("boxoffice.payment");

ProviderConnection acquire() throws TimeoutException {
    Span span = tracer.spanBuilder("pool.acquire").startSpan();
    try (Scope ignored = span.makeCurrent()) {
        long start = System.nanoTime();
        ProviderConnection c = pool.borrow(Duration.ofMillis(500));   // bounded wait, never forever
        span.setAttribute("pool.wait_ms", (System.nanoTime() - start) / 1_000_000);
        return c;
    } catch (TimeoutException e) {
        span.recordException(e);
        span.setStatus(StatusCode.ERROR, "pool exhausted");
        throw e;
    } finally {
        span.end();
    }
}

With the agent attached, the simplest alternative is an annotation on a method: @WithSpan("seatmap.render"), with @SpanAttribute on the parameters you want recorded.

Attributes: rich, but not dangerous

Span attributes can have high cardinality β€” order.id, event.id, customer.tier are what make a trace searchable. They must not contain secrets or personal data: no card numbers, tokens or full e-mails. Redact at the source and add a redaction processor in the Collector as a second barrier.


The Collector

Services could export directly to each backend, but then every service must know every backend, its credentials and its retry policy. The Collector decouples them: services send OTLP to one place, and the Collector's pipelines decide what happens next. A pipeline has three stages per signal: receivers (how data gets in), processors (what is done to it, in order), exporters (where it goes).

# otel-collector.yaml β€” gateway Collector for BoxOffice (sketch)
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }

processors:
  memory_limiter:                 # first: refuse data instead of being OOM-killed
    check_interval: 1s
    limit_percentage: 80
    spike_limit_percentage: 20
  k8sattributes: {}               # add k8s.pod.name, k8s.namespace.name, k8s.deployment.name
  attributes/redact:
    actions:
      - key: payment.card_number
        action: delete
      - key: enduser.email
        action: hash
  tail_sampling:
    decision_wait: 10s            # wait for the trace's spans before deciding
    policies:
      - name: keep-errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: keep-slow
        type: latency
        latency: { threshold_ms: 2000 }
      - name: some-of-the-rest
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }
  batch: {}                       # last: send in batches

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls: { insecure: true }
  otlphttp/prometheus:
    endpoint: http://prometheus:9090/api/v1/otlp
  otlphttp/loki:
    endpoint: http://loki:3100/otlp

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, k8sattributes, attributes/redact, tail_sampling, batch]
      exporters: [otlp/tempo]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, k8sattributes, batch]
      exporters: [otlphttp/prometheus]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, k8sattributes, attributes/redact, batch]
      exporters: [otlphttp/loki]

The order of processors matters: memory_limiter first, so an overloaded Collector sheds data instead of crashing; batch last, so it batches what survived.

Deployment patterns

Pattern Where it runs Good for Watch out
No Collector (SDK β†’ backend) β€” demos, one backend every service holds backend config and credentials
Agent DaemonSet (one per node) or sidecar local batching, host metrics, adding node/pod attributes per-node resources; cannot see whole traces
Gateway a Deployment behind a Service, scaled horizontally central policy: redaction, tail sampling, routing, credentials a new critical component: size it and monitor it
Agent + gateway (two tiers) both large clusters tail sampling needs all spans of a trace on the same gateway instance β€” route by trace ID (loadbalancing exporter)

The Collector is production infrastructure

During the flash sale, telemetry volume rises with traffic. A Collector without memory_limiter, without enough replicas, or exporting synchronously to a slow backend becomes a bottleneck β€” and if SDKs block on export, it can even slow the services. SDKs export asynchronously and drop data when their queue is full: losing telemetry is acceptable; slowing checkout to report on it is not. Monitor the Collector's own metrics (accepted vs refused vs dropped spans, queue size).


Sampling

Keeping every span of every request is rarely affordable (see what telemetry costs). Sampling keeps a subset of traces β€” complete traces, not random spans, otherwise the trees break.

Strategy Where the decision is made Pros Cons
Head sampling (e.g. parentbased_traceidratio 10%) at the first span, from the trace ID; travels in the sampled flag so every service agrees cheap; no buffering; consistent across services blind: it drops 90% of the rare error and slow traces too
Tail sampling3 in the Collector, after the whole trace has arrived (decision_wait) keeps interesting traces: every error, every slow request, plus a sample of the rest every span must reach the Collector; memory to buffer; all spans of a trace on one instance
Rate limiting SDK or Collector: at most N traces per second predictable cost during a spike, almost everything is dropped β€” exactly when you need it

Head and tail sampling are applied in sequence: a trace dropped at the head can never be rescued by the tail. The comparison below uses illustrative numbers for a busy BoxOffice day β€” 2,000 requests/s, 8 spans per request of 500 bytes, 1% of requests failing and 2% slower than the 2 s threshold:

Strategy Traces kept Storage per day Error traces kept per day
keep everything 100.0% 691.2 GB 1,728,000 (100%)
head sampling 10% 10.0% 69.1 GB 172,800 (10%)
head sampling 1% 1.0% 6.9 GB 17,280 (1%)
tail: all errors + all slow + 5% of the rest 7.8% 54.3 GB 1,728,000 (100%)

Tail sampling stores less than 10% head sampling and still keeps every error trace β€” at the price of shipping 100% of the spans over the network to the Collector and buffering them for the decision window. A common production recipe: head sampling at 100% (or a high ratio) in the services, tail sampling in a gateway Collector, and metrics computed from all requests before sampling, so dashboards and SLOs are exact even though most traces are discarded.

Exemplars: from a metric to a trace

An exemplar is a trace_id attached to a sample of a metric β€” for instance, to the latency histogram bucket where a 9 s checkout landed. In Grafana, the latency panel shows exemplars as dots; clicking one opens that exact trace in Tempo. It is the bridge between "p99 is high" and "here is one request that was slow" β€” make sure the exemplar's trace was kept by your sampler (tail sampling of slow requests guarantees it).


Backends and a local stack

OpenTelemetry leaves the backend to you. The same OTLP data can go to any of them:

Grafana LGTM: Loki (logs), Grafana (UI), Tempo (traces), Mimir or Prometheus (metrics), plus Pyroscope (profiles). Alternatives: Jaeger for traces, OpenSearch for logs, ClickHouse-based stacks (SigNoz) for everything. You operate the storage.

CloudWatch (metrics, logs, alarms), X-Ray / CloudWatch Application Signals (traces), Amazon Managed Service for Prometheus and Managed Grafana. AWS Distro for OpenTelemetry (ADOT) packages the Collector.

Cloud Monitoring, Cloud Logging and Cloud Trace (Google Cloud Observability); Managed Service for Prometheus. Accepts OTLP through the Collector.

Azure Monitor: Application Insights (traces, requests), Log Analytics (logs), Managed Prometheus and Managed Grafana. The Azure Monitor OpenTelemetry distro instruments applications.

Datadog, Honeycomb, New Relic, Grafana Cloud, Dynatrace, Elastic Cloud and others accept OTLP. You pay per volume (spans, series, GB of logs) β€” the sampling and cardinality decisions of this class are also cost decisions.

For development β€” and in Hands-on 8 β€” a single container bundles a Collector with the whole LGTM stack:

# docker-compose.yaml (excerpt)
services:
  otel-lgtm:
    image: grafana/otel-lgtm
    ports:
      - "3000:3000"   # Grafana
      - "4317:4317"   # OTLP gRPC
      - "4318:4318"   # OTLP HTTP

Trade-offs

Decision Option A Option B Guidance
Instrumentation zero-code agent manual SDK calls start with the agent; add manual spans for business steps and internal waits
Export path SDK β†’ backend SDK β†’ Collector β†’ backend a Collector as soon as there are two services or one policy (redaction, sampling)
Collector topology agent per node central gateway agents for local enrichment; a gateway for tail sampling and credentials
Sampling head tail head for cost simplicity; tail to keep every error and slow trace
Protocol OTLP gRPC (4317) OTLP HTTP (4318) either; HTTP is friendlier to proxies and browsers
Backend self-hosted managed / SaaS managed until the bill or data-residency rules say otherwise

In the interview

"How do you trace a request across microservices?" β€” "Every service is instrumented with OpenTelemetry; the W3C traceparent header (and Kafka message headers for async hops) propagates the trace ID; spans go over OTLP to a Collector that applies tail sampling β€” keep all errors and slow traces, a few percent of the rest β€” and exports to a tracing backend. Logs carry the trace ID, and the latency histograms carry exemplars, so I can jump from a dashboard to a trace to its logs." Then add the cost angle: metrics are computed from 100% of the traffic; traces are sampled.

Best practices

  • Pin the agent and SDK versions, and upgrade on purpose β€” a new instrumentation release can rename metrics and attributes (semantic conventions evolve), silently breaking dashboards and alerts. In BoxOffice: scripts/get-otel-agent.sh pins 2.32.0.
  • Configure OpenTelemetry once, through environment variables shared by every service β€” exporter, sampler, resource attributes; only OTEL_SERVICE_NAME differs per service. In BoxOffice: the x-otel anchor (box above).
  • Use a parent-based sampler at the head β€” the root decides once and every hop follows it, so a trace is kept or dropped whole, never in fragments. In BoxOffice: OTEL_TRACES_SAMPLER=parentbased_always_on, and parentbased_traceidratio at 10% in experiment B of Hands-on 8 β€” Measure it.
  • Carry traceparent across every asynchronous hop you own β€” outbox rows, job tables, custom envelopes; each hop without it splits every trace in two. In BoxOffice: the traceparent column of the outbox (box above).
  • Strip traceparent and baggage at the egress to third parties β€” internal ids and business context should not leak to a payment provider that will never send you its spans.
  • Monitor the Collector itself β€” its own metrics (refused and failed exports, queue size, memory) are the only sign that telemetry is being dropped; a silent Collector looks exactly like a quiet system.

Common pitfalls

  • Protocol and port mismatch: the Java agent 2.x defaults to OTLP over HTTP (4318); pointing it at 4317 without OTEL_EXPORTER_OTLP_PROTOCOL=grpc silently produces no data.
  • Missing service.name: everything shows up as unknown_service.
  • Head sampling at 1% and no tail sampling: the one trace you need during the incident was dropped.
  • Tail sampling behind a round-robin load balancer: spans of the same trace land on different Collectors, and every decision is made on fragments.
  • No memory_limiter: the Collector is OOM-killed exactly during the traffic peak.
  • Secrets and personal data in attributes or baggage: telemetry is copied to many systems and kept for weeks.

How it connects

flowchart LR
    KH["Kafka message headers,<br/>span links β†’ Messaging"] --> CTX["context propagation:<br/>traceparent, baggage"]
    CTX --> SVC["services: Java agent,<br/>Python SDK, Nginx"]
    SVC -->|"OTLP 4317 / 4318"| COL["Collector:<br/>receivers β†’ processors β†’ exporters"]
    COL --> RD["redaction:<br/>no cards, no e-mails"]
    COL --> TS["tail sampling:<br/>errors + slow + 5%"]
    TS --> TR["traces backend<br/>Tempo / Jaeger"]
    COL --> MET["metrics from all traffic<br/>β†’ SLOs, Alerting"]
    COL --> LG["logs backend<br/>Loki"]
    MET -->|"exemplars"| TR
    COL --> OPS["memory_limiter, gateway replicas<br/>β†’ Cloud-native"]

Palette components: tracing Β· metrics Β· logs Β· app (instrumented services) Β· apigw (must forward traceparent) Β· kafka (context in message headers) Β· payment (a black box: only the CLIENT span). Scenarios: observability-stack, event-fanout (traces that cross Kafka). Continues in: Signals, Alerting, Messaging β€” Kafka, Cloud-native β€” Kubernetes (Collector as DaemonSet or Deployment), Hands-on 8 β€” instrument everything.


Key takeaways

  • OpenTelemetry standardises the API, SDK, semantic conventions, protocol (OTLP) and Collector β€” not the backend; change backends without re-instrumenting.
  • Context propagation with W3C traceparent (and message headers for Kafka) is what makes a trace; one hop that drops it splits every trace.
  • Start with zero-code instrumentation, then add manual spans for business steps and for time spent waiting inside services.
  • The Collector pipelines (receivers β†’ processors β†’ exporters) centralise redaction, enrichment, sampling and routing; treat it as production infrastructure.
  • Tail sampling keeps every error and slow trace at a fraction of the storage; compute metrics from all traffic before sampling, and use exemplars to jump from metrics to traces.


  1. OPENTELEMETRY. What is OpenTelemetry? and Collector documentation. ↩

  2. W3C. Trace Context. W3C Recommendation. See also W3C Baggage. ↩

  3. OPENTELEMETRY COLLECTOR CONTRIB. Tail Sampling Processor. ↩

  4. SIGELMAN, B. H. et al. Dapper, a Large-Scale Distributed Systems Tracing Infrastructure. Google Technical Report, 2010. ↩