Everything for OpenTelemetry in one place — pick a section below. 37 reviewed items across 4 content types, plus a hands-on tutorial.
Quick Answer
OpenTelemetry is an open-source observability framework that provides a unified set of APIs, SDKs, and tools for collecting telemetry data (traces, metrics, and logs) from applications. It was created by merging OpenTracing and OpenCensus to provide a single, vendor-neutral standard for instrumenting distributed systems.
Detailed Answer
Think of OpenTelemetry as a universal adapter for observability. Before it existed, every monitoring vendor had its own proprietary agent and data format, so switching from one tool to another meant re-instrumenting your entire codebase. OpenTelemetry removes that lock-in by providing a single, vendor-neutral way to generate and export telemetry data.
OpenTelemetry (often abbreviated as OTel) is a Cloud Native Computing Foundation (CNCF) incubating project that provides a comprehensive observability framework. It defines a standard for how telemetry data (traces, metrics, and logs) is generated, collected, processed, and exported. The project delivers language-specific SDKs for Go, Java, Python, JavaScript, .NET, Ruby, and others, along with the OpenTelemetry Collector, a standalone binary that receives, processes, and forwards telemetry data to any supported backend. In a production environment running services like payments-api, user-auth-service, and order-processing-service, OpenTelemetry instruments each service identically regardless of whether you send data to Jaeger, Prometheus, Datadog, or Grafana Cloud.
The history behind OpenTelemetry is important context for interviews. Two earlier projects tackled distributed tracing independently: OpenTracing (a vendor-neutral tracing API) and OpenCensus (a Google-led project covering tracing and metrics). Both gained significant adoption, but having two competing standards fragmented the ecosystem. Library authors had to choose which one to support, and users sometimes needed both in the same application. In 2019, the two projects merged to form OpenTelemetry, combining the best ideas from each. OpenTracing contributed its flexible span-based tracing model, while OpenCensus brought metrics support and the Collector architecture. This merger gave the community a single project to rally around.
In production environments, OpenTelemetry solves a critical problem for platform teams managing microservices. Consider a company running checkout-service, inventory-sync, and payments-api across Kubernetes clusters. Without a standard, each team might instrument differently: one uses the Datadog agent, another uses Prometheus client libraries, and a third uses custom logging. OpenTelemetry unifies this by providing a single instrumentation layer that every service adopts. The data can then be routed to any combination of backends through the Collector, meaning the observability backend becomes a deployment decision rather than a code decision.
A key architectural principle is the separation of instrumentation from export. Your application code uses OpenTelemetry APIs to create spans, record metrics, and emit logs. The SDK handles sampling, batching, and exporting. If you later switch from Jaeger to Tempo for trace storage, you change a configuration file in the Collector rather than modifying application code. This decoupling is why platform teams at companies running services like user-auth-service and order-processing-service adopt OpenTelemetry as their internal standard, even when individual teams might prefer different analysis tools.
Code Example
# Install the OpenTelemetry Python SDK and OTLP exporter pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp # Install automatic instrumentation for common libraries pip install opentelemetry-instrumentation-requests opentelemetry-instrumentation-flask # Bootstrap auto-instrumentation (detects installed libraries) opentelemetry-bootstrap -a install # Run a Python app with automatic instrumentation enabled opentelemetry-instrument \ --service_name payments-api \ --exporter_otlp_endpoint http://otel-collector:4317 \ python app.py # Verify OpenTelemetry packages are installed correctly pip list | grep opentelemetry # Check the OTLP endpoint is reachable from the application curl -v http://otel-collector:4317
Interview Tip
A junior engineer typically says 'OpenTelemetry is a monitoring tool' or confuses it with a backend like Jaeger or Datadog. This is the most common mistake in interviews. OpenTelemetry does not store or visualize data; it only generates and transports telemetry. Interviewers want to hear about the OpenTracing and OpenCensus merger, the vendor-neutral design philosophy, and the separation between instrumentation and backend. Mention that OpenTelemetry is a CNCF project, which signals ecosystem maturity. If you can explain why decoupling instrumentation from export matters for platform teams managing dozens of microservices, you demonstrate architectural thinking that sets you apart from candidates who only know the definition.
◈ Architecture Diagram
┌─────────────────────────────────────────────────┐ │ OpenTelemetry Architecture │ │ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ OpenTracing │ │ OpenCensus │ │ │ │ (Tracing) │ │(Tracing+Met) │ │ │ └──────┬───────┘ └──────┬───────┘ │ │ │ Merged 2019 │ │ │ └────────┬─────────┘ │ │ ↓ │ │ ┌──────────────────────────────┐ │ │ │ OpenTelemetry │ │ │ │ ┌────────┐┌───────┐┌─────┐ │ │ │ │ │ Traces ││Metrics││Logs │ │ │ │ │ └────────┘└───────┘└─────┘ │ │ │ └──────────────┬───────────────┘ │ │ ↓ │ │ ┌──────────────────────────────┐ │ │ │ OTel Collector │ │ │ └───┬──────────┬───────────┬───┘ │ │ ↓ ↓ ↓ │ │ ┌───────┐ ┌────────┐ ┌─────────┐ │ │ │Jaeger │ │Prometheus│ │Datadog │ │ │ └───────┘ └────────┘ └─────────┘ │ └─────────────────────────────────────────────────┘
💬 Comments
Quick Answer
The three pillars of observability are traces (recording the path of a request across services), metrics (numerical measurements of system behavior over time), and logs (timestamped records of discrete events). OpenTelemetry unifies all three under a single framework with correlated data.
Detailed Answer
Imagine diagnosing a car problem. Traces are like the GPS route showing where the car went and how long each leg took. Metrics are the dashboard gauges showing speed, RPM, and fuel level over time. Logs are the mechanic's notes recording specific events like 'engine light turned on at 45,000 miles.' You need all three together to understand what happened and why.
Traces in OpenTelemetry represent the end-to-end journey of a request as it flows through a distributed system. When a user clicks 'Place Order' on checkout-service, that request might flow to order-processing-service, then to inventory-sync to check stock, then to payments-api to charge the card. A trace captures this entire journey as a tree of spans, where each span represents a unit of work in a specific service. The trace gives you latency breakdowns showing that order-processing-service took 50ms, inventory-sync took 120ms, and payments-api took 300ms. Without traces, debugging a slow request across 15 microservices becomes a guessing game of searching through logs on each service independently.
Metrics are numerical values that measure system behavior over time. OpenTelemetry supports three primary metric instruments: counters (monotonically increasing values like total_requests or total_errors), gauges (values that go up and down like active_connections or queue_depth), and histograms (distributions of values like request_duration or response_size). Metrics are lightweight and designed for aggregation. Instead of recording every single request, you record summaries: the average latency, the 99th percentile response time, the error rate per minute. In a production environment, metrics from payments-api might show that the p99 latency increased from 200ms to 800ms over the last hour, which triggers an alert. Metrics tell you that something is wrong and help you identify the affected service.
Logs are timestamped text records of discrete events. A log entry might say '2024-03-15T10:23:45Z ERROR payments-api: Failed to charge card ending in 4242, timeout after 30s.' OpenTelemetry's logging support correlates logs with traces by embedding trace IDs and span IDs into log entries. This correlation is the key differentiator: when user-auth-service logs an authentication failure, you can click the trace ID and see the full request path that led to that failure. Without correlation, logs from checkout-service, order-processing-service, and payments-api exist in separate silos, and connecting them requires manual timestamp matching and guesswork.
The power of OpenTelemetry comes from unifying all three pillars under a single framework with shared context. A trace ID generated when a request enters checkout-service propagates through every downstream service. Metrics recorded during that request carry the same service metadata. Logs emitted during processing embed the trace ID. This means you can start with a metric alert (p99 latency spike on inventory-sync), drill into traces to find slow requests, and then examine the logs for those specific traces to understand the root cause. This workflow, moving seamlessly from metrics to traces to logs, is what observability practitioners call the 'three pillars' working together.
Code Example
# Create a trace span in Python using the OpenTelemetry API
from opentelemetry import trace
# Get a tracer instance named after the service
tracer = trace.get_tracer("order-processing-service")
# Start a new span to represent a unit of work
with tracer.start_as_current_span("process_order") as span:
# Add attributes to the span for searchability
span.set_attribute("order.id", "ORD-78923")
# Record a metric counter for total orders processed
# meter.create_counter("orders.processed").add(1)
# Emit a structured log with trace context automatically attached
# logger.info("Order processed successfully", extra={"order_id": "ORD-78923"})
# Record a histogram metric for request duration
from opentelemetry import metrics
# Get a meter instance for the service
meter = metrics.get_meter("payments-api")
# Create a histogram to track payment processing duration
duration_histogram = meter.create_histogram(
name="payment.duration",
description="Time taken to process payment",
unit="ms"
)
# Record a duration observation with attributes
duration_histogram.record(342, {"payment.method": "credit_card"})Interview Tip
A junior engineer typically lists traces, metrics, and logs as separate concepts without explaining how they connect. The correlation between the three pillars is what interviewers really want to hear about. Explain that a single trace ID flows through all three: traces carry it as the root identifier, metrics can be exemplified with trace IDs pointing to specific slow requests, and logs embed the trace ID for drill-down. Describe the investigation workflow: start from a metric alert on payments-api showing elevated error rates, pivot to traces to find failing requests, then examine logs attached to those traces to find the root cause. This narrative shows you understand observability as a practice, not just as three separate data types that happen to coexist.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐ │ Three Pillars of Observability │ │ │ │ ┌───────────┐ ┌──────────┐ ┌─────────────┐ │ │ │ Traces │ │ Metrics │ │ Logs │ │ │ │ │ │ │ │ │ │ │ │ Request │ │ Counters │ │ Timestamped │ │ │ │ path flow │ │ Gauges │ │ events with │ │ │ │ across │ │ Histograms│ │ trace ID │ │ │ │ services │ │ │ │ │ │ │ └─────┬─────┘ └────┬─────┘ └──────┬──────┘ │ │ │ │ │ │ │ └────────────┼──────────────┘ │ │ ↓ │ │ ┌────────────────────────┐ │ │ │ Correlated via │ │ │ │ Trace ID + Span ID │ │ │ │ ● Alert → Trace →Log │ │ │ └────────────────────────┘ │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
A span represents a single unit of work within a trace, such as an HTTP request, a database query, or a function call. Each span contains a name, trace ID, span ID, parent span ID, start and end timestamps, status, attributes (key-value metadata), and events (timestamped annotations).
Detailed Answer
Think of a span as a single entry in a detailed travel itinerary. If a trace is the complete trip from New York to London, then each span is a leg: taxi to JFK airport, check-in at the counter, flight to Heathrow, customs processing. Each leg has a start time, end time, and notes about what happened. Some legs contain sub-legs, like the flight leg containing takeoff, cruising, and landing as child spans.
A span is the fundamental building block of distributed tracing in OpenTelemetry. Every span has a unique span ID and belongs to a trace identified by a trace ID. When checkout-service receives a user request, it creates a root span. When checkout-service calls order-processing-service, a new child span is created with the checkout-service span as its parent. This parent-child relationship forms a tree structure that visualizes the entire request flow. The span's start and end timestamps tell you exactly how long each operation took, making it straightforward to identify which service or operation is the bottleneck.
Each span carries several categories of information. The span name identifies the operation, like 'POST /api/orders' or 'SELECT orders WHERE user_id = ?' for a database query. Attributes are key-value pairs that add context: http.method=POST, http.status_code=200, db.system=postgresql, or custom attributes like order.total=149.99. The span kind indicates the role: CLIENT for outgoing calls, SERVER for incoming requests, PRODUCER for async message sends, CONSUMER for message processing, and INTERNAL for in-process operations. The status field indicates whether the operation succeeded (OK), encountered an error (ERROR), or is unset. When an error occurs, the status description provides a human-readable explanation.
Spans also support events and links. Events are timestamped annotations within a span, useful for recording notable moments without creating a child span. For example, a span in inventory-sync might record an event 'cache miss for SKU-45678' at timestamp T1 and 'fetched from database' at timestamp T2. Links connect spans across different traces, which is valuable for batch processing: a span in order-processing-service that processes 50 orders might link to the 50 individual order creation spans from checkout-service. Events and links are often overlooked by beginners but are essential for modeling real-world distributed systems.
In production, span attributes are critical for debugging. When payments-api returns a 500 error, the span attributes tell you the payment method (credit_card), the provider (stripe), the customer ID, and the error message. Platform teams typically define attribute naming conventions following OpenTelemetry semantic conventions, which standardize attribute names across services. For example, all HTTP spans use http.request.method instead of each team inventing their own attribute name. This standardization enables consistent querying across services: 'show me all spans where http.response.status_code >= 500' works across checkout-service, payments-api, and user-auth-service because they all use the same attribute names.
Code Example
# Create spans with attributes, events, and status in Python
from opentelemetry import trace
from opentelemetry.trace import StatusCode
# Get a tracer for the payments-api service
tracer = trace.get_tracer("payments-api")
# Create a root span for processing a payment
with tracer.start_as_current_span("process_payment") as parent_span:
# Set attributes with key-value metadata on the span
parent_span.set_attribute("payment.method", "credit_card")
# Add the order ID for correlation with other services
parent_span.set_attribute("order.id", "ORD-90124")
# Record an event marking when validation started
parent_span.add_event("payment.validation.started")
# Create a child span for the downstream API call
with tracer.start_as_current_span("call_stripe_api") as child_span:
# Set the span kind attribute for the outgoing call
child_span.set_attribute("rpc.system", "stripe")
# Record the amount being charged
child_span.set_attribute("payment.amount", 149.99)
# Record an event with attributes for detailed context
child_span.add_event("stripe.response_received", {"stripe.charge_id": "ch_3abc"})
# Set status to OK when the operation succeeds
child_span.set_status(StatusCode.OK)
# Set an error status if something goes wrong
# parent_span.set_status(StatusCode.ERROR, "Card declined by issuer")Interview Tip
A junior engineer typically defines a span as 'a piece of a trace' without explaining what data it carries. Interviewers want you to enumerate the key fields: trace ID, span ID, parent span ID, name, timestamps, attributes, events, status, and span kind. Walk through a concrete example: when checkout-service calls payments-api, describe the root span on checkout-service (kind=SERVER), the client span for the outgoing call (kind=CLIENT), and the server span on payments-api (kind=SERVER). Mention semantic conventions as the standardized naming for attributes like http.request.method and db.system. If you can explain the difference between attributes (static key-value pairs) and events (timestamped annotations), you demonstrate deeper understanding than most beginner candidates. Bring up span links if you want to show extra depth.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐ │ Trace (trace_id: abc123) │ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ Span: POST /checkout (root, SERVER) │ │ │ │ span_id: s1 parent: none │ │ │ │ start: T0 end: T0+500ms │ │ │ │ attrs: http.method=POST │ │ │ │ │ │ │ │ ┌──────────────────────────────────┐ │ │ │ │ │ Span: call order-processing-svc │ │ │ │ │ │ span_id: s2 parent: s1 (CLIENT)│ │ │ │ │ │ start: T0+10ms end: T0+200ms │ │ │ │ │ └──────────────────────────────────┘ │ │ │ │ ┌──────────────────────────────────┐ │ │ │ │ │ Span: call payments-api │ │ │ │ │ │ span_id: s3 parent: s1 (CLIENT)│ │ │ │ │ │ start: T0+210ms end: T0+490ms │ │ │ │ │ │ event: card.charged at T0+480ms │ │ │ │ │ └──────────────────────────────────┘ │ │ │ └─────────────────────────────────────────┘ │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
The OpenTelemetry Collector is a vendor-agnostic proxy that receives, processes, and exports telemetry data. It acts as a centralized pipeline between your applications and observability backends, handling tasks like batching, filtering, sampling, and format conversion without requiring changes to application code.
Detailed Answer
Think of the OpenTelemetry Collector as a mail sorting facility. Letters (telemetry data) arrive from thousands of homes (services), get sorted, filtered, and routed to the correct destinations (backends). The sorting facility can handle mail from any post office (receiver) and deliver to any carrier (exporter), and you can add processing steps like removing junk mail (filtering) or combining small packages into larger shipments (batching).
The OpenTelemetry Collector is a standalone binary that forms the backbone of most production OpenTelemetry deployments. It is built around three core components: receivers, processors, and exporters, connected through pipelines. Receivers accept telemetry data in various formats. The OTLP receiver handles OpenTelemetry's native protocol, but the Collector also supports Jaeger, Zipkin, Prometheus, and other formats. This means you can migrate from Jaeger to OpenTelemetry incrementally: existing services send Jaeger-format spans to the Collector, which converts and exports them alongside native OTLP data. In a microservices environment with payments-api, checkout-service, and user-auth-service, every service sends its telemetry to the Collector rather than directly to the backend.
Processors sit between receivers and exporters and transform telemetry data in flight. The batch processor groups telemetry into larger payloads to reduce network overhead, which is essential in high-throughput environments. The memory_limiter processor prevents the Collector from consuming too much memory during traffic spikes. The filter processor drops unwanted telemetry, like health check spans that add noise without value. The attributes processor can add, modify, or delete attributes, such as injecting the Kubernetes namespace or removing sensitive data like user email addresses before export. In production at scale, processors are where platform teams implement their telemetry governance policies.
Exporters send processed telemetry to one or more backends. The OTLP exporter sends data to any OTLP-compatible backend like Grafana Tempo, Jaeger, or Datadog. The Prometheus exporter exposes metrics in Prometheus format for scraping. The logging exporter prints telemetry to stdout for debugging. A single Collector instance can export the same data to multiple backends simultaneously: traces to Jaeger for developers, metrics to Prometheus for SREs, and everything to an S3 bucket for long-term storage. This fan-out capability is one of the strongest reasons to use the Collector rather than exporting directly from applications.
The Collector can be deployed in two patterns: as an agent (sidecar or DaemonSet alongside applications) and as a gateway (centralized cluster receiving from multiple agents). In Kubernetes, a common pattern deploys the Collector as a DaemonSet where each node runs an agent that receives telemetry from pods on that node. The agents forward data to a gateway Collector deployment that handles heavy processing, sampling, and export. This two-tier architecture reduces the blast radius of Collector failures and keeps resource-intensive processing off application nodes. For a team running order-processing-service and inventory-sync across a 50-node Kubernetes cluster, the agent handles lightweight batching on each node while the gateway handles tail-based sampling and export to Grafana Cloud.
Code Example
# Download and install the OpenTelemetry Collector curl -LO https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v0.96.0/otelcol_0.96.0_linux_amd64.tar.gz # Extract the collector binary from the archive tar -xzf otelcol_0.96.0_linux_amd64.tar.gz # Verify the collector binary runs correctly ./otelcol --version # Run the collector with a custom configuration file ./otelcol --config otel-collector-config.yaml # Example otel-collector-config.yaml structure: # receivers: # otlp: # Accept OTLP protocol on gRPC and HTTP # protocols: # grpc: # endpoint: 0.0.0.0:4317 # gRPC endpoint for SDKs # http: # endpoint: 0.0.0.0:4318 # HTTP endpoint for SDKs # processors: # batch: # Group telemetry into batches # timeout: 5s # Flush batch every 5 seconds # memory_limiter: # Prevent out-of-memory crashes # limit_mib: 512 # Hard memory limit at 512MB # exporters: # otlp: # Export to an OTLP-compatible backend # endpoint: tempo.internal:4317 # Grafana Tempo gRPC endpoint # prometheus: # Expose metrics for Prometheus scraping # endpoint: 0.0.0.0:8889 # Prometheus metrics endpoint # service: # pipelines: # traces: # Pipeline for trace data # receivers: [otlp] # Receive from OTLP # processors: [memory_limiter, batch] # Process in order # exporters: [otlp] # Export to Tempo # metrics: # Pipeline for metric data # receivers: [otlp] # Receive from OTLP # processors: [batch] # Batch before export # exporters: [prometheus] # Expose for Prometheus
Interview Tip
A junior engineer typically describes the Collector as 'a thing that collects data from apps.' Interviewers want to hear the receiver-processor-exporter pipeline model, specific processor examples (batch, memory_limiter, filter, attributes), and the two deployment patterns (agent vs gateway). Explain why the Collector exists instead of having applications export directly: it centralizes configuration, enables fan-out to multiple backends, offloads processing from application pods, and allows format conversion. Walk through a concrete scenario where checkout-service sends OTLP to a DaemonSet agent Collector, which forwards to a gateway Collector that exports traces to Tempo and metrics to Prometheus. Mentioning memory_limiter as a production necessity shows you understand operational concerns, not just the happy path.
◈ Architecture Diagram
┌──────────────────────────────────────────────┐ │ OpenTelemetry Collector Pipeline │ │ │ │ ┌──────────┐ ┌───────────┐ ┌───────────┐ │ │ │Receivers │→ │Processors │→ │ Exporters │ │ │ ├──────────┤ ├───────────┤ ├───────────┤ │ │ │ OTLP │ │ batch │ │ OTLP │ │ │ │ Jaeger │ │ filter │ │ Prometheus│ │ │ │ Prometheus│ │ memory_ │ │ Jaeger │ │ │ │ Zipkin │ │ limiter │ │ Logging │ │ │ └──────────┘ └───────────┘ └───────────┘ │ │ │ │ Deployment Patterns: │ │ ┌────────┐ ┌────────┐ ┌─────────┐ │ │ │ App │──→ │ Agent │──→ │ Gateway │──→ │ │ │payments│ │(DaemonS│ │(Deploym)│ Backend │ │ -api │ │ et) │ │ │ │ │ └────────┘ └────────┘ └─────────┘ │ └──────────────────────────────────────────────┘
💬 Comments
Quick Answer
Automatic instrumentation uses agents or library hooks to capture telemetry from common frameworks and libraries without code changes. Manual instrumentation requires developers to explicitly create spans, record metrics, and add attributes using the OpenTelemetry API. Most production setups use both approaches together.
Detailed Answer
Think of automatic instrumentation like a dashcam that records everything your car does without you pressing any buttons. Manual instrumentation is like a passenger taking notes about specific landmarks and events along the route. The dashcam captures the broad picture, but the passenger's notes add context and meaning that a camera cannot capture on its own.
Automatic instrumentation (also called auto-instrumentation or zero-code instrumentation) works by hooking into popular libraries and frameworks to capture telemetry without modifying application code. In Python, the opentelemetry-instrument command wraps your application and automatically instruments libraries like Flask, Django, requests, SQLAlchemy, and psycopg2. In Java, a Java agent JAR is attached to the JVM at startup, intercepting method calls in frameworks like Spring Boot, gRPC, and JDBC. In Node.js, the @opentelemetry/auto-instrumentations-node package registers instrumentation hooks before your application loads its dependencies. The result is that a payments-api built with Flask and PostgreSQL immediately gets spans for every HTTP request and every database query without a single line of instrumentation code.
Manual instrumentation gives developers explicit control over what telemetry is generated. Using the OpenTelemetry API, a developer in order-processing-service can create a span named 'validate_inventory' that wraps the specific business logic for checking stock levels against inventory-sync. They can add custom attributes like order.item_count=5 and order.total=249.99, record events like 'backorder_triggered' when stock is insufficient, and set the span status to ERROR with a descriptive message when validation fails. None of this business-specific context can be captured by automatic instrumentation because the auto-instrumenter only sees HTTP calls and database queries, not the business meaning behind them.
In production, the standard approach is to layer both techniques. Automatic instrumentation provides the baseline: every HTTP request to checkout-service generates a span, every database query in user-auth-service is traced, and every outgoing HTTP call to payments-api creates a client span. Manual instrumentation then enriches this baseline with business context. A developer adds a custom span around the fraud detection logic in payments-api, sets attributes for the risk score and decision, and records events for each fraud rule evaluated. The auto-generated spans provide the structural skeleton of the trace, while manual spans fill in the business logic details that matter for debugging.
There are important tradeoffs to understand. Automatic instrumentation requires minimal effort but produces generic telemetry. Every Flask endpoint gets a span named 'POST /api/payments,' but without manual enrichment, you cannot tell whether the payment was for a subscription renewal or a one-time purchase. Manual instrumentation captures exactly what you need but requires developer discipline and ongoing maintenance. If a team adds a new payment provider to payments-api but forgets to add manual spans, that code path becomes a blind spot. Platform teams address this by establishing instrumentation guidelines and reviewing spans as part of the code review process, treating observability as a first-class concern alongside testing.
Code Example
# AUTOMATIC INSTRUMENTATION - Node.js example
# Install the auto-instrumentation package for Node.js
npm install @opentelemetry/auto-instrumentations-node
# Install the OTLP exporter for sending data to the collector
npm install @opentelemetry/exporter-trace-otlp-http
# Run the app with auto-instrumentation enabled via environment variable
NODE_OPTIONS="--require @opentelemetry/auto-instrumentations-node/register" \
OTEL_SERVICE_NAME=checkout-service \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
node server.js
# MANUAL INSTRUMENTATION - Python example
# Import the trace module from the OpenTelemetry API
# from opentelemetry import trace
# Get a tracer instance named after the service component
# tracer = trace.get_tracer("inventory-sync.stock-checker")
# Create a custom span around business logic
# with tracer.start_as_current_span("check_stock_level") as span:
# span.set_attribute("product.sku", "SKU-45678")
# span.set_attribute("warehouse.id", "us-east-1-primary")
# available = query_inventory(sku="SKU-45678")
# span.set_attribute("stock.available", available)
# if available < threshold:
# span.add_event("low_stock_alert", {"threshold": threshold})Interview Tip
A junior engineer typically describes auto-instrumentation as 'easier' and manual as 'harder' without explaining when each is appropriate. Interviewers want to hear the layered approach: auto-instrumentation for structural coverage of HTTP, database, and messaging frameworks; manual instrumentation for business-specific context like order values, customer tiers, and fraud scores. Explain the tradeoff: auto-instrumentation captures every database query but cannot tell you the business operation behind it, while manual instrumentation captures exactly what matters but requires developer effort. Mention that auto-instrumentation in Java uses a Java agent (-javaagent flag), Python uses the opentelemetry-instrument wrapper, and Node.js uses --require hooks. If you can describe a real scenario where auto-instrumentation alone was insufficient and manual spans revealed the root cause, that story demonstrates practical experience.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐ │ Automatic vs Manual Instrumentation │ │ │ │ Automatic (zero-code): │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ HTTP │ │ Database │ │ gRPC │ │ │ │ requests │ │ queries │ │ calls │ │ │ │ (auto) │ │ (auto) │ │ (auto) │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ │ │ │ Manual (code): │ │ ┌──────────────────────────────────────┐ │ │ │ ● validate_inventory (custom span) │ │ │ │ ● fraud_check (custom span) │ │ │ │ ● order.total=249.99 (attribute) │ │ │ │ ● low_stock_alert (event) │ │ │ └──────────────────────────────────────┘ │ │ │ │ │ │ │ └──────────────┼──────────────┘ │ │ ↓ │ │ ┌──────────────────┐ │ │ │ Rich, complete │ │ │ │ trace with both │ │ │ │ structure and │ │ │ │ business context│ │ │ └──────────────────┘ │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
Exporters are components that send collected telemetry data from your application or the OpenTelemetry Collector to observability backends. Common exporters include OTLP (the native protocol for OpenTelemetry-compatible backends), Jaeger, Zipkin, Prometheus, and vendor-specific exporters for Datadog, New Relic, and Grafana Cloud.
Detailed Answer
Think of exporters as shipping carriers for your telemetry data. Your application packages the data (spans, metrics, logs), and the exporter is the carrier (FedEx, UPS, DHL) that delivers it to the destination. Different destinations accept different carriers, but OTLP is like a universal carrier that most modern destinations accept. You can even ship to multiple destinations simultaneously using different carriers for the same package.
Exporters in OpenTelemetry are the final stage of the telemetry pipeline, responsible for serializing and transmitting data to external systems. They exist in two places: within the application SDK and within the OpenTelemetry Collector. SDK exporters are configured in your application code and send data directly to a backend or to a Collector. Collector exporters are configured in the Collector's YAML file and send processed data to one or more backends. In a production setup with payments-api, checkout-service, and order-processing-service, each application typically uses the OTLP exporter to send data to the Collector, and the Collector uses backend-specific exporters to forward data to its final destinations.
The OTLP (OpenTelemetry Protocol) exporter is the recommended default. OTLP is OpenTelemetry's native protocol, supporting traces, metrics, and logs over both gRPC (port 4317) and HTTP/protobuf (port 4318). All major observability backends now support OTLP natively: Grafana Tempo, Jaeger, Datadog, New Relic, Honeycomb, Splunk, and Elastic APM. Using OTLP means you are sending data in OpenTelemetry's own format without any conversion, which preserves all metadata and avoids lossy translations. When inventory-sync sends a span with 20 attributes and 5 events via OTLP, every attribute and event arrives intact at the backend.
Beyond OTLP, several protocol-specific exporters exist for backward compatibility and specific use cases. The Jaeger exporter sends traces in Jaeger's Thrift or gRPC format, useful when migrating from a Jaeger-native setup. The Zipkin exporter sends traces in Zipkin's JSON format. The Prometheus exporter is unique because it does not push data; instead, it exposes a metrics endpoint that Prometheus scrapes at regular intervals. This pull-based model matches how Prometheus fundamentally works. For cloud vendors, there are exporters for AWS X-Ray, Google Cloud Trace, Azure Monitor, Datadog, and New Relic. These vendor exporters handle the format translation and authentication required by each platform.
In production, the choice of exporter is usually made at the Collector level, not in application code. Applications export to the Collector using OTLP, and the Collector handles the fan-out. This pattern is powerful because changing your observability backend requires updating only the Collector configuration, not redeploying every service. Consider a team running user-auth-service and checkout-service that decides to switch from self-hosted Jaeger to Grafana Cloud. With the Collector in place, they change the exporter in the Collector config from Jaeger to OTLP pointing at Grafana Cloud's endpoint, redeploy the Collector, and every service is migrated without touching application code. During migration, they can even export to both backends simultaneously by adding two exporters to the same pipeline, running old and new systems in parallel until validation is complete.
Code Example
# Configure OTLP exporter in Python to send traces to the Collector
# Install the OTLP gRPC exporter package
pip install opentelemetry-exporter-otlp-proto-grpc
# Python SDK configuration for OTLP export
# from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
# from opentelemetry.sdk.trace.export import BatchSpanProcessor
# Create an OTLP exporter pointing to the Collector's gRPC endpoint
# exporter = OTLPSpanExporter(endpoint="http://otel-collector:4317", insecure=True)
# Use BatchSpanProcessor for efficient batched export
# provider.add_span_processor(BatchSpanProcessor(exporter))
# Configure exporters in the Collector YAML file
# exporters:
# otlp/tempo: # Export traces to Grafana Tempo
# endpoint: tempo.internal:4317
# tls:
# insecure: false # Enable TLS for production
# otlp/datadog: # Export to Datadog via OTLP
# endpoint: api.datadoghq.com:4317
# headers:
# dd-api-key: ${DD_API_KEY} # API key from environment variable
# prometheus: # Expose metrics for Prometheus scraping
# endpoint: 0.0.0.0:8889 # Prometheus-compatible metrics endpoint
# debug: # Print telemetry to stdout for debugging
# verbosity: detailed # Show full span and metric detailsInterview Tip
A junior engineer typically lists exporter names without explaining the architecture that makes them powerful. Interviewers want to hear about the two-tier export pattern: applications export via OTLP to the Collector, and the Collector exports to backends using protocol-specific exporters. Explain why this matters: changing backends becomes a Collector config change, not an application code change. Mention OTLP as the default recommendation over legacy protocols like Jaeger Thrift. Highlight the Prometheus exporter's pull model versus the push model of all other exporters, because this often comes up as a follow-up question. If you can describe a migration scenario where the Collector's fan-out capability allowed running two backends in parallel during a transition, that demonstrates real operational experience with observability platforms.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐ │ Exporter Architecture │ │ │ │ ┌──────────────┐ OTLP │ │ │ payments-api │──────────→┐ │ │ └──────────────┘ │ │ │ ┌──────────────┐ OTLP │ │ │ │checkout-svc │──────────→┤ │ │ └──────────────┘ │ │ │ ┌──────────────┐ OTLP │ │ │ │inventory-sync│──────────→┤ │ │ └──────────────┘ │ │ │ ┌──────┴───────┐ │ │ │ Collector │ │ │ └──┬───┬───┬───┘ │ │ │ │ │ │ │ OTLP │ │ │ Prometheus │ │ ↓ │ ↓ ↓ │ │ ┌───────┐ │ ┌──────────┐ │ │ │ Tempo │ │ │Prometheus│ │ │ └───────┘ │ └──────────┘ │ │ OTLP │ │ │ ↓ │ │ │ ┌─────────┐ │ │ │ Datadog │ │ │ └─────────┘ │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
Context propagation is the mechanism that carries trace context (trace ID, span ID, and trace flags) across service boundaries so that spans from different services can be correlated into a single end-to-end trace. It works by injecting context into transport headers (like HTTP headers) on outgoing requests and extracting it on incoming requests.
Detailed Answer
Think of context propagation like a relay race baton. When the first runner (checkout-service) starts the race (trace), they carry a baton (trace context) with the race ID written on it. When they hand off to the second runner (order-processing-service), the baton transfers, and the second runner continues the same race. Without the baton handoff, each runner would think they are running a separate race, and you would never know they were part of the same event.
Context propagation is the foundation that makes distributed tracing work across service boundaries. When checkout-service receives a user request, the OpenTelemetry SDK generates a trace ID (a 128-bit identifier like 4bf92f3577b34da6a3ce929d0e0e4736) and creates a root span. When checkout-service makes an HTTP call to order-processing-service, the SDK's propagator injects the trace ID and current span ID into the outgoing HTTP headers. The most common format is W3C Trace Context, which uses two headers: traceparent (containing the trace ID, parent span ID, and trace flags) and tracestate (carrying vendor-specific data). When order-processing-service receives the request, its SDK extracts the trace context from the headers and creates a new span with the propagated trace ID and the incoming span ID as the parent.
OpenTelemetry supports multiple propagation formats through its propagator API. The W3C Trace Context format is the default and the recommended standard, defined in a W3C specification. The B3 format (used by Zipkin) is supported for backward compatibility with systems already using B3 headers. The Jaeger propagation format uses the uber-trace-id header. You can configure the SDK to use multiple propagators simultaneously, which is essential during migrations: if payments-api uses W3C format but a legacy inventory-sync still sends B3 headers, the Collector or SDK can understand both. The propagation format is typically set globally for all services in an organization to ensure consistency.
Context propagation is not limited to HTTP. OpenTelemetry propagates context through gRPC metadata, message queue headers (Kafka record headers, RabbitMQ message properties), and even custom transports. When order-processing-service publishes a message to a Kafka topic, the producer instrumentation injects the trace context into the Kafka record headers. When inventory-sync consumes that message, the consumer instrumentation extracts the context and creates a span linked to the original trace. This means the entire flow from user click through synchronous HTTP calls through asynchronous message processing appears as a single connected trace.
In production, broken context propagation is one of the most common reasons traces appear fragmented. If a proxy, API gateway, or load balancer strips custom headers, the trace context is lost and downstream services start new traces instead of continuing the existing one. Platform teams at companies running services like user-auth-service and checkout-service debug this by checking whether the traceparent header survives through every hop. Another common issue is mixing propagation formats: if checkout-service injects W3C headers but payments-api only extracts B3 format, the trace breaks at that boundary. Setting a consistent propagation format across all services and ensuring that infrastructure components (Nginx, Envoy, AWS ALB) forward trace headers are essential operational steps.
Code Example
# Configure W3C Trace Context propagation in Python # from opentelemetry.propagators.b3 import B3MultiFormat # from opentelemetry.propagate import set_global_textmap # from opentelemetry.propagators.composite import CompositePropagator # from opentelemetry.propagators.textmap import TraceContextTextMapPropagator # Set W3C as the global propagator for all outgoing requests # set_global_textmap(TraceContextTextMapPropagator()) # Or use composite propagator for multi-format support during migration # set_global_textmap(CompositePropagator([TraceContextTextMapPropagator(), B3MultiFormat()])) # Verify trace context headers are being propagated via curl curl -v http://checkout-service:8080/api/orders \ -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" \ -H "tracestate: rojo=00f067aa0ba902b7" # Check if an Nginx proxy forwards trace headers correctly # Add to nginx.conf to preserve trace context headers: # proxy_set_header traceparent $http_traceparent; # proxy_set_header tracestate $http_tracestate; # Environment variable to set propagator format globally export OTEL_PROPAGATORS=tracecontext,baggage # For B3 compatibility during migration from Zipkin export OTEL_PROPAGATORS=tracecontext,baggage,b3multi
Interview Tip
A junior engineer typically says 'context propagation passes the trace ID between services' which is correct but shallow. Interviewers want to hear the mechanics: how traceparent and tracestate headers carry the trace ID, span ID, and sampling decision; the difference between W3C Trace Context and B3 formats; and how propagation works beyond HTTP (Kafka, gRPC, RabbitMQ). Describe a real debugging scenario: traces appearing fragmented because an API gateway stripped the traceparent header, and how you fixed it by configuring the gateway to forward custom headers. Mention that propagation format consistency across all services is a platform team responsibility, not an individual developer decision. If asked about the traceparent header format, recite the structure: version-traceId-parentSpanId-traceFlags (e.g., 00-4bf92f...-00f067...-01). This precision signals hands-on experience.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐ │ Context Propagation Flow │ │ │ │ ┌──────────────┐ traceparent header │ │ │checkout-svc │──────────────────────→┐ │ │ │ trace: abc123│ 00-abc123-span1-01 │ │ │ │ span: span1 │ │ │ │ └──────────────┘ │ │ │ ┌──────┴──┐ │ │ │order- │ │ │ │process │ │ │ │trace:abc│ │ │ │span:span2│ │ │ │parent:s1│ │ │ └────┬────┘ │ │ Kafka header │ │ │ ┌────┴────┐ │ │ │inventory│ │ │ │-sync │ │ │ │trace:abc│ │ │ │span:sp3 │ │ │ │parent:s2│ │ │ └─────────┘ │ │ │ │ ● Same trace ID (abc123) across all three │ │ ● Parent-child chain: span1→span2→span3 │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
For Python, install the OpenTelemetry SDK and auto-instrumentation packages, then run your app with the opentelemetry-instrument wrapper. For Node.js, install the SDK and auto-instrumentation packages, then set the NODE_OPTIONS environment variable to require the instrumentation module at startup. Both approaches send telemetry to an OTLP endpoint without modifying application code.
Detailed Answer
Setting up OpenTelemetry is like installing a security camera system. You choose the cameras (SDK and instrumentation libraries), connect them to a recording station (the Collector), and configure where the footage is stored (the backend). The cameras start recording automatically once installed, but you can also add extra cameras (manual instrumentation) in areas where you need more detailed coverage.
For a Python application like payments-api built with Flask and PostgreSQL, the setup involves three steps. First, install the core packages: opentelemetry-api and opentelemetry-sdk provide the tracing and metrics foundation, opentelemetry-exporter-otlp provides the exporter to send data to the Collector, and opentelemetry-instrumentation packages provide auto-instrumentation for specific libraries. Second, run opentelemetry-bootstrap -a install, which scans your installed Python packages and automatically installs the matching instrumentation libraries (e.g., it detects Flask and installs opentelemetry-instrumentation-flask). Third, wrap your application startup command with opentelemetry-instrument, which initializes the SDK, activates all installed instrumentations, and runs your app. You configure the service name and exporter endpoint through environment variables like OTEL_SERVICE_NAME and OTEL_EXPORTER_OTLP_ENDPOINT.
For a Node.js application like checkout-service built with Express and MongoDB, the setup is similar but uses a different mechanism. Install @opentelemetry/api, @opentelemetry/sdk-node, @opentelemetry/auto-instrumentations-node, and @opentelemetry/exporter-trace-otlp-http. The auto-instrumentations-node package is a meta-package that bundles instrumentations for Express, HTTP, MongoDB, Redis, gRPC, and many other libraries. To activate auto-instrumentation without code changes, set the NODE_OPTIONS environment variable to --require @opentelemetry/auto-instrumentations-node/register. This loads the instrumentation before your application code, ensuring that library imports are intercepted and wrapped with tracing logic. Alternatively, you can create a tracing.js file that programmatically configures the SDK with custom settings like sampling rate and resource attributes, then require it before your app starts.
In production, environment variables are the preferred configuration method because they keep instrumentation settings out of application code. The key variables are OTEL_SERVICE_NAME (identifies the service in traces), OTEL_EXPORTER_OTLP_ENDPOINT (the Collector address), OTEL_TRACES_SAMPLER (controls sampling strategy), and OTEL_RESOURCE_ATTRIBUTES (adds metadata like deployment.environment=production). For Kubernetes deployments of order-processing-service or inventory-sync, these variables are set in the Deployment manifest or injected via ConfigMaps. The OpenTelemetry Operator for Kubernetes can even inject auto-instrumentation automatically by adding an annotation to the pod spec, eliminating the need to modify Dockerfiles or deployment scripts.
Common setup pitfalls include forgetting to install instrumentation libraries for specific frameworks (Flask spans will not appear without opentelemetry-instrumentation-flask), pointing the exporter to the wrong Collector port (gRPC uses 4317, HTTP uses 4318), and not running opentelemetry-bootstrap after adding new dependencies. Another frequent mistake is testing instrumentation without a running Collector: the SDK will silently drop telemetry if the exporter endpoint is unreachable, making it look like instrumentation is not working when the issue is actually a networking problem between user-auth-service and the Collector pod.
Code Example
# PYTHON SETUP - Install OpenTelemetry packages for payments-api pip install opentelemetry-api opentelemetry-sdk # Install the OTLP exporter for sending data to the collector pip install opentelemetry-exporter-otlp-proto-grpc # Auto-detect installed libraries and install matching instrumentations opentelemetry-bootstrap -a install # Run the Python app with auto-instrumentation and environment config OTEL_SERVICE_NAME=payments-api \ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 \ OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,service.version=2.1.0 \ opentelemetry-instrument python app.py # NODE.JS SETUP - Install OpenTelemetry packages for checkout-service npm install @opentelemetry/api @opentelemetry/sdk-node # Install the auto-instrumentation meta-package for common libraries npm install @opentelemetry/auto-instrumentations-node # Install the OTLP HTTP exporter for trace export npm install @opentelemetry/exporter-trace-otlp-http # Run the Node.js app with auto-instrumentation via environment variable NODE_OPTIONS="--require @opentelemetry/auto-instrumentations-node/register" \ OTEL_SERVICE_NAME=checkout-service \ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \ node server.js # Verify telemetry is reaching the collector by checking collector logs kubectl logs -l app=otel-collector -n monitoring | grep checkout-service
Interview Tip
A junior engineer typically describes the setup as 'install some packages and run the app' without mentioning the specific packages, environment variables, or the bootstrap step. Interviewers want to hear a precise sequence: install the API, SDK, and exporter; run opentelemetry-bootstrap (Python) or install the auto-instrumentations meta-package (Node.js); configure via environment variables (OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT); and run with the instrumentation wrapper. Mention the difference between gRPC port 4317 and HTTP port 4318, because confusing them is a common debugging issue. If asked about Kubernetes, mention the OpenTelemetry Operator and its ability to inject instrumentation via pod annotations, which eliminates the need to modify Dockerfiles. Demonstrating that you know the specific packages and the common pitfalls like missing library-specific instrumentation packages proves you have actually set this up rather than just read about it.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐ │ OpenTelemetry Setup Flow │ │ │ │ Step 1: Install │ │ ┌─────────────────────────────────────┐ │ │ │ pip install opentelemetry-sdk │ │ │ │ npm install @opentelemetry/sdk-node │ │ │ └─────────────────┬───────────────────┘ │ │ ↓ │ │ Step 2: Bootstrap/Configure │ │ ┌─────────────────────────────────────┐ │ │ │ opentelemetry-bootstrap -a install │ │ │ │ Set OTEL_SERVICE_NAME │ │ │ │ Set OTEL_EXPORTER_OTLP_ENDPOINT │ │ │ └─────────────────┬───────────────────┘ │ │ ↓ │ │ Step 3: Run with Instrumentation │ │ ┌──────────────┐ ┌────────────────┐ │ │ │ Python: │ │ Node.js: │ │ │ │ otel-instru- │ │ NODE_OPTIONS= │ │ │ │ ment python │ │ --require auto │ │ │ │ app.py │ │ node server.js │ │ │ └──────┬───────┘ └──────┬─────────┘ │ │ └───────┬───────────┘ │ │ ↓ │ │ ┌──────────────────────────────┐ │ │ │ OTel Collector :4317/:4318 │ │ │ └──────────────┬───────────────┘ │ │ ↓ │ │ ┌──────────────────────────────┐ │ │ │ Backend (Jaeger/Tempo/etc) │ │ │ └──────────────────────────────┘ │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
OpenTelemetry sampling controls which traces are recorded and exported to reduce overhead and storage costs. Head-based sampling makes the decision at the start of a trace (e.g., sample 10% of all traces), while tail-based sampling defers the decision until the trace is complete, allowing you to keep all error traces or high-latency traces regardless of ratio.
Detailed Answer
Think of sampling like a quality control inspector at a factory. Head-based sampling is like randomly pulling every 10th item off the conveyor belt for inspection before it is even assembled. Tail-based sampling is like letting all items go through the entire assembly line, then inspecting only the finished products that have defects or took too long to build. Both approaches reduce the volume you need to examine, but tail-based gives you much smarter filtering at the cost of temporarily storing everything.
Head-based sampling is the default and simplest approach in OpenTelemetry. The sampling decision is made at the root span of a trace, before any work is done. The SDK provides several built-in samplers: AlwaysOn (sample everything), AlwaysOff (sample nothing), TraceIdRatioBased (sample a percentage based on the trace ID hash), and ParentBased (respect the sampling decision of the parent span). The ParentBased sampler is particularly important in microservice architectures because it ensures that if the payments-api starts a trace and decides to sample it, all downstream services like order-processing-service and inventory-sync will also sample their spans for that trace. Without ParentBased, you end up with broken traces where some services recorded spans and others did not. The trace ID hash ensures deterministic sampling, meaning the same trace ID always produces the same sampling decision across all services.
Tail-based sampling requires the OpenTelemetry Collector because the decision cannot be made at the SDK level. The Collector receives all spans from all services, assembles complete traces in memory, and then applies policies to decide which traces to keep. Common policies include keeping all traces that contain an error status, keeping traces where the total duration exceeds a threshold like 2 seconds, keeping traces that touch specific services like checkout-service, or applying a probabilistic ratio to the remaining traces. The tail_sampling processor in the Collector supports combining multiple policies with AND and OR logic. For example, you might keep 100% of error traces, 100% of traces slower than 5 seconds, and 1% of everything else.
In production, the choice between head-based and tail-based sampling has significant infrastructure implications. Head-based sampling is lightweight because services discard unsampled spans immediately, reducing CPU, memory, and network overhead at the source. However, it means you lose visibility into rare errors that happen to fall in the unsampled portion. Tail-based sampling captures every interesting trace but requires the Collector to buffer all spans in memory until the trace is complete, which demands significant RAM. A typical tail-sampling Collector handling the traffic from services like payments-api, user-auth-service, and order-processing-service might need 4-8 GB of RAM. The wait_duration parameter controls how long the Collector waits for late-arriving spans before making a decision, typically 30-60 seconds. If a span arrives after the decision window, it is either dropped or sampled probabilistically.
A critical production gotcha is the interaction between head-based and tail-based sampling. If you configure a 10% head-based sampler in your SDKs and also run tail-based sampling in the Collector, you are sampling from an already-sampled stream, which means your tail-based policies only see 10% of traces. The correct setup for tail-based sampling is to use AlwaysOn in the SDK and let the Collector make all sampling decisions. Another common mistake is running a single tail-sampling Collector instance, which creates a single point of failure and limits throughput. In production, you need a load-balancing Collector tier in front of tail-sampling Collectors, using the loadbalancing exporter to route all spans from the same trace to the same tail-sampling instance. Without this, spans from the same trace land on different Collectors, and neither has the complete picture to make an accurate decision.
Code Example
# Configure head-based sampling in OpenTelemetry Python SDK
from opentelemetry import trace
# Import the tracing SDK and sampling modules
from opentelemetry.sdk.trace import TracerProvider
# Import specific sampler implementations
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased, ParentBased
# Create a ratio-based sampler that samples 10% of root spans
root_sampler = TraceIdRatioBased(0.1)
# Wrap it in ParentBased so child spans respect parent decisions
sampler = ParentBased(root=root_sampler)
# Initialize the TracerProvider with the configured sampler
provider = TracerProvider(sampler=sampler)
# Set this provider as the global tracer provider
trace.set_tracer_provider(provider)
# --- OpenTelemetry Collector tail-based sampling configuration ---
# otel-collector-config.yaml
receivers:
otlp: # Receive spans via OTLP protocol
protocols:
grpc: # Listen on gRPC port 4317
endpoint: 0.0.0.0:4317
http: # Listen on HTTP port 4318
endpoint: 0.0.0.0:4318
processors:
tail_sampling: # Tail-based sampling processor
decision_wait: 30s # Wait 30 seconds for all spans in a trace
num_traces: 100000 # Max number of traces to hold in memory
expected_new_traces_per_sec: 1000 # Expected trace arrival rate
policies: # Define sampling policies
- name: error-traces # Keep all traces with errors
type: status_code # Filter by span status code
status_code:
status_codes: [ERROR] # Match spans with ERROR status
- name: high-latency # Keep slow traces
type: latency # Filter by trace duration
latency:
threshold_ms: 2000 # Keep traces longer than 2 seconds
- name: checkout-traces # Keep all checkout-service traces
type: string_attribute # Filter by attribute value
string_attribute:
key: service.name # Match on service name attribute
values: [checkout-service] # Keep all checkout-service traces
- name: baseline-sampling # Sample remaining traces at 5%
type: probabilistic # Probabilistic sampling
probabilistic:
sampling_percentage: 5 # Keep 5% of remaining traces
exporters:
otlp: # Export sampled traces to Jaeger or Tempo
endpoint: tempo.monitoring:4317 # Backend endpoint
tls:
insecure: true # Disable TLS for internal traffic
service:
pipelines:
traces: # Define the traces pipeline
receivers: [otlp] # Receive from OTLP
processors: [tail_sampling] # Apply tail sampling
exporters: [otlp] # Export to backendInterview Tip
A junior engineer typically says 'sampling means you only keep some traces' without distinguishing head-based from tail-based or explaining the infrastructure implications of each. To stand out, explain that head-based sampling is a deterministic decision at trace creation time using the trace ID hash, which means all services in the request path independently arrive at the same decision without coordination. Then contrast tail-based sampling, which requires the Collector to buffer complete traces in memory before deciding, creating significant RAM requirements. Mention the load-balancing Collector pattern needed for production tail-based sampling, where a routing tier ensures all spans from the same trace reach the same tail-sampling instance.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐ │ OpenTelemetry Sampling Strategies │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ HEAD-BASED SAMPLING (Decision at trace start) │ │ ┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ │ │ │ payments-api │───→│ TraceIdRatio │───→│ Sample? ✓/✗ │ │ │ │ (root span) │ │ Based(0.1) │ │ 10% kept │ │ │ └─────────────┘ └─────────────────┘ └──────┬───────┘ │ │ │ │ │ ParentBased propagation downstream │ │ │ ↓ │ │ ┌──────────────────┐ ┌───────────────────┐ │ │ │ order-processing │───→│ inventory-sync │ ✓ All sampled │ │ │ service │ │ service │ together │ │ └──────────────────┘ └───────────────────┘ │ │ │ │ TAIL-BASED SAMPLING (Decision after trace completes) │ │ ┌─────────────┐ │ │ │ payments-api │──┐ │ │ └─────────────┘ │ ┌──────────────────┐ ┌──────────┐ │ │ ┌─────────────┐ ├──→│ OTel Collector │──→│ Backend │ │ │ │ checkout-svc│──┤ │ ● Buffer traces │ │ (Tempo/ │ │ │ └─────────────┘ │ │ ● Apply policies │ │ Jaeger) │ │ │ ┌─────────────┐ │ │ ● Error → ✓ keep │ └──────────┘ │ │ │ user-auth │──┘ │ ● Slow → ✓ keep │ │ │ └─────────────┘ │ ● Other → 5% ✓ │ │ │ └──────────────────┘ │ └──────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
The OpenTelemetry Collector pipeline consists of three stages: receivers (ingest data via protocols like OTLP, Jaeger, Prometheus), processors (transform, filter, batch, or enrich data), and exporters (send data to backends like Jaeger, Tempo, or Prometheus). Pipelines are defined per signal type (traces, metrics, logs) in the service.pipelines configuration section.
Detailed Answer
Think of the OpenTelemetry Collector pipeline like a water treatment plant. Water comes in from multiple sources through intake pipes (receivers), passes through filtration and purification stages (processors), and is distributed to homes and businesses through output pipes (exporters). Each stage is independent and modular. You can add new intake sources without changing the treatment stages, or add new distribution targets without modifying the intake. The Collector acts as a vendor-agnostic telemetry processing hub.
Receivers are the entry points for telemetry data. The Collector supports multiple receiver types simultaneously. The OTLP receiver accepts data via the OpenTelemetry Protocol over gRPC (port 4317) and HTTP (port 4318), and is the primary receiver for applications instrumented with OpenTelemetry SDKs. The Jaeger receiver accepts Jaeger-native formats for legacy applications. The Prometheus receiver scrapes Prometheus-format metrics endpoints, allowing the Collector to replace or augment a Prometheus server. The Kafka receiver consumes telemetry from Kafka topics for high-throughput buffered ingestion. The hostmetrics receiver collects system-level metrics like CPU, memory, and disk from the host. Each receiver can be configured independently with its own ports, TLS settings, and protocol options. The payments-api might send traces via OTLP while the legacy order-processing-service sends Jaeger-format spans.
Processors sit between receivers and exporters and form the core transformation layer. The batch processor is almost always required in production because it groups individual spans or metrics into batches before export, dramatically reducing the number of outbound network calls. The memory_limiter processor prevents the Collector from consuming unbounded memory by applying backpressure when usage exceeds a threshold. The attributes processor adds, modifies, or deletes span attributes, which is useful for injecting environment metadata or stripping sensitive data like user email addresses. The filter processor drops entire spans or metrics matching specific criteria, such as health check endpoints that generate noise. The resource processor enriches all telemetry with resource attributes like deployment environment or cluster name. Processors execute in the order they are listed in the pipeline configuration, so ordering matters. The memory_limiter should always be first to prevent OOM before other processors consume memory.
Exporters send processed telemetry to one or more backends. The OTLP exporter sends data to any OTLP-compatible backend like Grafana Tempo, Jaeger, or another Collector in a multi-tier deployment. The Prometheus exporter exposes metrics in Prometheus format on an HTTP endpoint for scraping. The logging exporter writes telemetry to stdout, useful for debugging. The loadbalancing exporter distributes traces across multiple downstream Collectors by routing spans with the same trace ID to the same instance, essential for tail-based sampling. Multiple exporters can be configured in a single pipeline, allowing you to fan out traces to both Jaeger for real-time viewing and a data lake for long-term analysis.
In production, Collector deployment follows two common patterns. The Agent pattern deploys a Collector as a DaemonSet or sidecar on every node, receiving telemetry from local applications with minimal network latency. The Gateway pattern deploys a centralized Collector cluster that receives data from agents, performs heavy processing like tail-based sampling, and exports to backends. Many organizations use both in a two-tier architecture: lightweight agents on every node forward to a gateway cluster. The Collector configuration is typically managed via ConfigMaps in Kubernetes, and the Collector supports hot-reloading when the ConfigMap changes. A common pitfall is forgetting to configure the batch processor, which results in one HTTP request per span and overwhelms both the Collector and the backend. Another gotcha is not setting memory_limiter, causing the Collector to OOM under traffic spikes from services like checkout-service during flash sales.
Code Example
# otel-collector-config.yaml - Full production pipeline configuration
# This config handles traces, metrics, and logs from multiple services
receivers:
otlp: # Primary receiver for OTel-instrumented services
protocols:
grpc: # gRPC endpoint for SDKs (payments-api, checkout-service)
endpoint: 0.0.0.0:4317
max_recv_msg_size_mib: 4 # Max message size in MiB
http: # HTTP endpoint for browser/mobile clients
endpoint: 0.0.0.0:4318
cors:
allowed_origins: ["*"] # Allow cross-origin requests from frontends
jaeger: # Legacy receiver for order-processing-service
protocols:
thrift_http: # Jaeger Thrift over HTTP
endpoint: 0.0.0.0:14268
prometheus: # Scrape Prometheus metrics from inventory-sync
config:
scrape_configs:
- job_name: inventory-sync # Scrape inventory-sync metrics endpoint
scrape_interval: 30s # Scrape every 30 seconds
static_configs:
- targets: ["inventory-sync:9090"] # Target host and port
processors:
memory_limiter: # MUST be first processor to prevent OOM
check_interval: 5s # Check memory usage every 5 seconds
limit_mib: 1500 # Hard memory limit in MiB
spike_limit_mib: 512 # Allow temporary spikes up to this amount
batch: # Batch telemetry before export
send_batch_size: 1024 # Send when batch reaches 1024 items
send_batch_max_size: 2048 # Never exceed 2048 items per batch
timeout: 5s # Send after 5 seconds even if batch is small
attributes: # Modify span attributes
actions:
- key: user.email # Remove PII from spans
action: delete # Delete the attribute entirely
- key: deployment.environment # Add environment context
value: production # Set value to production
action: upsert # Insert or update
filter: # Drop noisy telemetry
traces:
span:
- 'attributes["http.target"] == "/healthz"' # Drop health check spans
- 'attributes["http.target"] == "/readyz"' # Drop readiness check spans
resource: # Enrich with resource attributes
attributes:
- key: k8s.cluster.name # Add cluster name to all telemetry
value: prod-us-east-1 # Cluster identifier
action: upsert # Insert or update
exporters:
otlp/tempo: # Export traces to Grafana Tempo
endpoint: tempo.monitoring:4317 # Tempo gRPC endpoint
tls:
insecure: true # Internal network, no TLS needed
otlp/jaeger: # Export traces to Jaeger for real-time viewing
endpoint: jaeger.monitoring:4317 # Jaeger OTLP endpoint
tls:
insecure: true # Internal network
prometheusremotewrite: # Export metrics to Prometheus/Mimir
endpoint: http://mimir.monitoring:9009/api/v1/push # Remote write endpoint
logging: # Debug exporter to stdout
loglevel: warn # Only log warnings and errors
service:
pipelines:
traces: # Traces pipeline definition
receivers: [otlp, jaeger] # Accept OTLP and Jaeger traces
processors: [memory_limiter, filter, attributes, batch] # Process in order
exporters: [otlp/tempo, otlp/jaeger] # Fan out to Tempo and Jaeger
metrics: # Metrics pipeline definition
receivers: [otlp, prometheus] # Accept OTLP and Prometheus metrics
processors: [memory_limiter, batch] # Limit memory and batch
exporters: [prometheusremotewrite] # Send to Mimir
logs: # Logs pipeline definition
receivers: [otlp] # Accept OTLP logs
processors: [memory_limiter, resource, batch] # Enrich and batch
exporters: [otlp/tempo] # Send to Tempo for trace-log correlationInterview Tip
A junior engineer typically lists receivers, processors, and exporters without explaining the execution order or why certain processors like memory_limiter and batch are non-negotiable in production. Demonstrate operational maturity by explaining that memory_limiter must be the first processor in the pipeline because it applies backpressure before downstream processors consume memory. Explain that the batch processor reduces outbound HTTP calls from per-span to per-batch, which can be the difference between a Collector handling 10,000 spans per second and one that overwhelms the backend. Mention the two-tier Agent-Gateway deployment pattern, where lightweight DaemonSet agents forward to a centralized Gateway cluster for heavy processing like tail-based sampling.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐ │ OpenTelemetry Collector Pipeline Architecture │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ RECEIVERS PROCESSORS EXPORTERS │ │ (Data In) (Transform) (Data Out) │ │ │ │ ┌──────────┐ ┌─────────────────┐ ┌──────────────┐ │ │ │ OTLP │──┐ │ memory_limiter │ ┌─→│ otlp/tempo │ │ │ │ gRPC:4317│ │ │ (OOM protect) │ │ │ (Grafana) │ │ │ └──────────┘ │ ├─────────────────┤ │ └──────────────┘ │ │ ┌──────────┐ ├─→│ filter │──┤ │ │ │ Jaeger │──┤ │ (drop noise) │ │ ┌──────────────┐ │ │ │ :14268 │ │ ├─────────────────┤ ├─→│ otlp/jaeger │ │ │ └──────────┘ │ │ attributes │ │ │ (real-time) │ │ │ ┌──────────┐ │ │ (enrich/strip) │ │ └──────────────┘ │ │ │Prometheus│──┘ ├─────────────────┤ │ │ │ │ scrape │ │ batch │ │ ┌──────────────┐ │ │ └──────────┘ │ (group items) │──┘ │ promremote │ │ │ └─────────────────┘ ┌─→│ write (Mimir)│ │ │ │ └──────────────┘ │ │ Pipeline Flow: │ │ │ ● traces: otlp,jaeger → processors → otlp/tempo,otlp/jaeger │ │ ● metrics: otlp,prom → processors → prometheusremotewrite │ │ ● logs: otlp → processors → otlp/tempo │ │ │ │ Two-Tier Deployment: │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ │ │ App Pod │───→│ Agent │───→│ Gateway │───→│ Backend │ │ │ │ │ │(DaemonSet│ │(Cluster) │ │(Tempo/ │ │ │ │ │ │ per node)│ │ sampling │ │ Jaeger) │ │ │ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │ └──────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Resource attributes are key-value metadata that describe the entity producing telemetry, such as the service name, version, deployment environment, and host. They are attached to all spans, metrics, and logs emitted by a service and are essential for filtering, grouping, and correlating telemetry across a distributed system.
Detailed Answer
Think of resource attributes like the return address on an envelope. No matter what is inside the envelope (a trace, a metric, a log), the return address tells you exactly who sent it, from where, and in what context. Without a return address, you receive thousands of envelopes with no way to sort them by sender. In a microservices architecture with dozens of services like payments-api, user-auth-service, and order-processing-service, resource attributes are what make telemetry data actionable rather than an undifferentiated flood of signals.
Resource attributes are defined by the OpenTelemetry Semantic Conventions, which provide a standardized vocabulary for describing telemetry sources. The most critical attribute is service.name, which uniquely identifies the logical service. Without it, backends like Jaeger or Grafana Tempo cannot group spans into service-level views. Other essential attributes include service.version (the deployed version, critical for correlating deployments with performance changes), service.namespace (grouping related services like the payments domain), deployment.environment (production, staging, development), and service.instance.id (distinguishing between replicas of the same service). Cloud-specific attributes like cloud.provider, cloud.region, and cloud.availability_zone are standardized under the semantic conventions for infrastructure correlation.
In the OpenTelemetry SDK, resource attributes are configured when creating the TracerProvider, MeterProvider, or LoggerProvider. They are set once at startup and apply to all telemetry emitted by that provider. The SDK automatically detects some resource attributes through resource detectors. For example, the AWS resource detector populates cloud.provider, cloud.region, and cloud.account.id. The Kubernetes resource detector fills in k8s.pod.name, k8s.namespace.name, and k8s.node.name. The process resource detector captures process.pid and process.runtime.name. You can combine multiple detectors with manually specified attributes, where manual attributes take precedence over detected ones. Environment variables like OTEL_RESOURCE_ATTRIBUTES and OTEL_SERVICE_NAME provide a configuration mechanism that works without code changes, making it easy to set attributes in Kubernetes manifests or Docker Compose files.
In production, resource attributes are the foundation of effective observability. When the checkout-service experiences a latency spike, you filter traces by service.name=checkout-service and deployment.environment=production. When you deploy a new version, you compare metrics between service.version=2.3.1 and service.version=2.4.0 to detect regressions. When a specific availability zone has network issues, you filter by cloud.availability_zone=us-east-1a to isolate the blast radius. The OpenTelemetry Collector can also enrich telemetry with additional resource attributes using the resource processor, adding attributes like k8s.cluster.name that the SDK might not know about. This is particularly useful in multi-cluster deployments where the same service runs in multiple Kubernetes clusters.
A common gotcha is forgetting to set service.name, which causes backends to group all telemetry under a generic name like unknown_service. Another pitfall is inconsistent naming across services. If the payments-api team uses deployment.environment=prod while the user-auth-service team uses deployment.environment=production, dashboards and queries that filter on environment will miss half the data. Establishing a naming convention and enforcing it through the Collector's resource processor or through shared SDK configuration libraries prevents this fragmentation. High-cardinality resource attributes like service.instance.id are useful for debugging but should not be used as primary grouping dimensions in metrics backends, as they create a new time series per instance.
Code Example
# Configure resource attributes in OpenTelemetry Python SDK
from opentelemetry import trace
# Import the SDK resource module
from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION
# Import the tracing provider
from opentelemetry.sdk.trace import TracerProvider
# Import OTLP exporter for sending traces
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
# Import the batch processor for efficient export
from opentelemetry.sdk.trace.export import BatchSpanProcessor
# Define resource attributes that identify this service
resource = Resource.create({
SERVICE_NAME: "payments-api", # Logical service name (required)
SERVICE_VERSION: "2.4.0", # Deployed version for regression detection
"service.namespace": "payments", # Domain grouping
"service.instance.id": "payments-api-7f8d",# Unique instance identifier
"deployment.environment": "production", # Environment label
"cloud.provider": "aws", # Cloud provider
"cloud.region": "us-east-1", # Deployment region
"cloud.availability_zone": "us-east-1a", # Availability zone
"k8s.cluster.name": "prod-east", # Kubernetes cluster name
"k8s.namespace.name": "payments-ns", # Kubernetes namespace
"team": "payments-team", # Team ownership for alert routing
})
# Create the TracerProvider with resource attributes
provider = TracerProvider(resource=resource)
# Configure OTLP exporter pointing to the Collector
exporter = OTLPSpanExporter(endpoint="otel-collector:4317", insecure=True)
# Use BatchSpanProcessor for production (not SimpleSpanProcessor)
provider.add_span_processor(BatchSpanProcessor(exporter))
# Set as the global tracer provider
trace.set_tracer_provider(provider)
# --- Alternative: Set resource attributes via environment variables ---
# No code changes required, ideal for Kubernetes deployments
# Set in pod spec or Docker Compose
# OTEL_SERVICE_NAME=payments-api
# OTEL_RESOURCE_ATTRIBUTES=service.version=2.4.0,deployment.environment=production
# --- Kubernetes Deployment with env-based resource attributes ---
# payments-api-deployment.yaml
apiVersion: apps/v1 # Kubernetes API version
kind: Deployment # Deployment resource
metadata:
name: payments-api # Deployment name
spec:
template:
spec:
containers:
- name: payments-api # Container name
image: payments-api:2.4.0 # Container image with version tag
env:
- name: OTEL_SERVICE_NAME # Set service name via env var
value: "payments-api" # Matches the deployment name
- name: OTEL_RESOURCE_ATTRIBUTES # Set additional resource attributes
value: "service.version=2.4.0,deployment.environment=production,team=payments-team"
- name: OTEL_EXPORTER_OTLP_ENDPOINT # Collector endpoint
value: "http://otel-collector:4317" # Points to Collector service
- name: POD_NAME # Inject pod name for instance ID
valueFrom:
fieldRef:
fieldPath: metadata.name # Use Kubernetes downward APIInterview Tip
A junior engineer typically sets service.name and stops there, not realizing that resource attributes are the primary mechanism for filtering, grouping, and correlating telemetry across an entire distributed system. Demonstrate depth by explaining the semantic conventions that standardize attribute names across the industry, preventing each team from inventing their own keys. Mention that resource detectors automatically populate cloud and Kubernetes attributes, reducing manual configuration. Discuss the practical impact: when you deploy version 2.4.0 of the payments-api and latency increases, you compare metrics filtered by service.version to confirm the regression. Emphasize that inconsistent naming across teams like prod versus production breaks dashboard filters, and the Collector resource processor can enforce naming standards centrally.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐ │ OpenTelemetry Resource Attributes │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Resource (per service) │ │ │ │ │ │ │ │ service.name = payments-api ← Identity │ │ │ │ service.version = 2.4.0 ← Versioning │ │ │ │ service.namespace = payments ← Domain │ │ │ │ deployment.environment = production← Environment │ │ │ │ cloud.region = us-east-1 ← Infrastructure│ │ │ │ k8s.cluster.name = prod-east ← Cluster │ │ │ │ team = payments-team ← Ownership │ │ │ └──────────────┬──────────────────────────────────────┘ │ │ │ │ │ │ Attached to ALL signals │ │ │ │ │ ┌─────────┼─────────┐ │ │ ↓ ↓ ↓ │ │ ┌─────────┐ ┌────────┐ ┌──────┐ │ │ │ Traces │ │Metrics │ │ Logs │ │ │ │ (spans) │ │(counters│ │ │ │ │ │ │ │ gauges) │ │ │ │ │ └────┬────┘ └───┬────┘ └──┬───┘ │ │ │ │ │ │ │ └──────────┼─────────┘ │ │ ↓ │ │ ┌──────────────────────────────────┐ │ │ │ Backend Queries │ │ │ │ │ │ │ │ ● Filter by service.name │ │ │ │ ● Compare service.version │ │ │ │ ● Group by cloud.region │ │ │ │ ● Route alerts by team │ │ │ └──────────────────────────────────┘ │ │ │ │ Attribute Sources: │ │ ┌────────────┐ ┌────────────┐ ┌────────────────┐ │ │ │ SDK Code │ │ Env Vars │ │ Resource │ │ │ │ Resource │ │ OTEL_* │ │ Detectors │ │ │ │ .create() │ │ │ │ (AWS/K8s/Host) │ │ │ └────────────┘ └────────────┘ └────────────────┘ │ └──────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
OpenTelemetry provides three primary metric instrument types: Counters (monotonically increasing values like request counts), Histograms (distribution of measurements like latency), and Gauges (point-in-time values like queue depth). You create instruments via a Meter instance, record measurements with attributes, and the SDK periodically exports aggregated data to backends.
Detailed Answer
Think of OpenTelemetry metrics instruments like different tools in a workshop. A counter is like a tally clicker that only goes up, counting events as they happen. A histogram is like a measuring tape that records each measurement and automatically sorts them into buckets for percentile analysis. A gauge is like a thermometer that reports the current temperature at any given moment. Choosing the wrong instrument is like using a thermometer to count cars passing by. It technically produces a number, but the number is meaningless for your purpose.
Counters are the most common instrument type, used for values that only increase over time, such as the total number of HTTP requests handled, bytes transferred, or errors encountered. OpenTelemetry provides two counter variants: Counter (synchronous) where you explicitly call add() in your code, and ObservableCounter (asynchronous) where you register a callback that the SDK invokes at collection time. In the payments-api, you would use a synchronous counter to count processed payments, incrementing it each time a payment completes. The counter value itself is not very useful on its own; you typically apply a rate function in your backend (like rate() in Prometheus) to compute payments per second. UpDownCounters are a variant that can both increase and decrease, useful for tracking values like active connections or items in a queue where the count fluctuates.
Histograms record the distribution of measurements, making them ideal for latency, request sizes, and other values where you care about percentiles rather than averages. When you record a value with a histogram, the SDK assigns it to a bucket based on configurable boundaries. The default bucket boundaries in OpenTelemetry are [0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000] milliseconds. For the checkout-service, you might configure custom boundaries like [10, 50, 100, 200, 500, 1000, 2000, 5000] to get better resolution in the latency range you care about. Exponential histograms are a newer feature that automatically adjust bucket boundaries based on the data, providing high accuracy without manual boundary configuration.
Gauges represent point-in-time values that can go up or down arbitrarily, like CPU utilization, memory usage, queue depth, or active sessions. OpenTelemetry primarily supports gauges through the ObservableGauge (asynchronous) instrument, where a callback function returns the current value at collection time. This design prevents the common mistake of setting a gauge value in a loop and exporting stale data. For the inventory-sync service, you might use a gauge to report the current number of items awaiting synchronization, with the callback querying the actual queue depth at each collection interval.
In production, the choice of instrument type affects how backends store and query the data. Counters are stored as cumulative values and require rate() to be useful in queries. Histograms generate multiple time series per instrument (one per bucket boundary plus sum and count), so cardinality management is critical. If your histogram has 10 buckets and you record with 5 attribute dimensions, each with 10 possible values, you generate 10 * 10^5 = 1,000,000 time series, which will overwhelm most backends. Attributes should be low-cardinality: use http.method (GET, POST, PUT, DELETE) and http.status_code (200, 400, 500), not request_id or user_id. The OpenTelemetry SDK supports Views, which let you override default aggregation settings per instrument, such as changing histogram bucket boundaries or dropping specific attributes before export.
A common gotcha is using a gauge when you need a counter. If you want to count the total number of payments processed, use a counter, not a gauge that you increment manually. Counters handle process restarts correctly because backends compute rate over the cumulative value, while a manually incremented gauge loses its value on restart. Another pitfall is not configuring histogram bucket boundaries for your specific use case. The default boundaries assume millisecond-scale latency, but if your checkout-service responds in microseconds or takes minutes for batch operations, the default buckets provide zero resolution in the ranges that matter.
Code Example
# Create custom metrics using OpenTelemetry Python SDK
from opentelemetry import metrics
# Import the SDK metrics module
from opentelemetry.sdk.metrics import MeterProvider
# Import the OTLP metrics exporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
# Import periodic reader for scheduled export
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
# Import View for customizing aggregation
from opentelemetry.sdk.metrics.view import View, ExplicitBucketHistogramAggregation
# Define custom histogram bucket boundaries for latency in milliseconds
latency_view = View(
instrument_name="checkout.request.duration", # Match this specific instrument
aggregation=ExplicitBucketHistogramAggregation( # Override default buckets
boundaries=[10, 50, 100, 200, 500, 1000, 2000, 5000] # Boundaries in ms
),
)
# Configure the OTLP exporter to send metrics to the Collector
exporter = OTLPMetricExporter(endpoint="otel-collector:4317", insecure=True)
# Create a periodic reader that exports every 30 seconds
reader = PeriodicExportingMetricReader(exporter, export_interval_millis=30000)
# Initialize the MeterProvider with custom views
provider = MeterProvider(metric_readers=[reader], views=[latency_view])
# Set as the global meter provider
metrics.set_meter_provider(provider)
# Get a Meter instance for the checkout-service
meter = metrics.get_meter("checkout-service", version="2.4.0")
# --- COUNTER: Track total number of processed orders ---
order_counter = meter.create_counter(
name="checkout.orders.total", # Metric name
description="Total number of orders processed", # Human-readable description
unit="orders", # Unit of measurement
)
# Record a successful order with attributes
order_counter.add(1, { # Increment by 1
"payment.method": "credit_card", # Low-cardinality attribute
"order.status": "completed", # Order outcome
"currency": "USD", # Currency type
})
# --- HISTOGRAM: Track request latency distribution ---
latency_histogram = meter.create_histogram(
name="checkout.request.duration", # Metric name (matches View above)
description="Request duration for checkout operations",# Description
unit="ms", # Milliseconds
)
# Record a latency measurement with attributes
latency_histogram.record(235.7, { # Record 235.7ms latency
"http.method": "POST", # HTTP method
"http.route": "/api/v1/checkout", # API route (not full URL)
"http.status_code": 200, # Response status code
})
# --- UP-DOWN COUNTER: Track items in processing queue ---
queue_counter = meter.create_up_down_counter(
name="checkout.queue.depth", # Metric name
description="Number of items in the checkout queue", # Description
unit="items", # Unit
)
# Increment when item is added to queue
queue_counter.add(1, {"queue.name": "payment-processing"}) # Item enqueued
# Decrement when item is processed
queue_counter.add(-1, {"queue.name": "payment-processing"}) # Item dequeued
# --- OBSERVABLE GAUGE: Report current inventory levels ---
def get_inventory_level(options): # Callback function
# Query actual inventory database for current count
current_level = 4250 # Simulated database query
options.observe(current_level, { # Report the current value
"warehouse": "us-east-1", # Warehouse location
"product.category": "electronics", # Product category
})
# Register the gauge with its callback function
meter.create_observable_gauge(
name="inventory.stock.level", # Metric name
description="Current stock level in warehouse", # Description
unit="items", # Unit
callbacks=[get_inventory_level], # Callback list
)Interview Tip
A junior engineer typically creates metrics without thinking about cardinality or choosing the right instrument type. Demonstrate mastery by explaining the cardinality explosion problem: a histogram with 10 buckets and high-cardinality attributes like user_id can generate millions of time series that overwhelm your metrics backend. Explain that counters require rate() at query time because the raw value is a monotonically increasing total. Discuss why OpenTelemetry uses ObservableGauge with callbacks rather than a settable gauge, preventing the stale-data problem where a gauge reports a value from minutes ago because the setter code has not run recently. Mention Views as the mechanism for overriding histogram bucket boundaries per instrument, which is essential when the default boundaries do not match your service latency profile.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐ │ OpenTelemetry Metric Instrument Types │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ COUNTER (Monotonically Increasing) │ │ ┌─────────────────────────────────────────┐ │ │ │ checkout.orders.total │ │ │ │ │ │ │ │ Value: 0 → 1 → 2 → 3 → 5 → 8 → 13 │ ● Only goes up │ │ │ ──────────────────────────────▶ time │ ● Use rate() to │ │ │ │ get orders/sec │ │ └─────────────────────────────────────────┘ │ │ │ │ HISTOGRAM (Distribution of Values) │ │ ┌─────────────────────────────────────────┐ │ │ │ checkout.request.duration │ │ │ │ │ │ │ │ Buckets: 10 50 100 200 500 1000 │ │ │ │ Counts: ▶5 ▶12 ▶35 ▶28 ▶8 ▶2 │ ● Captures P50, │ │ │ │ │ │ │ │ │ │ P95, P99 │ │ │ └───┴───┴────┴────┴────┘ │ ● Custom buckets │ │ └─────────────────────────────────────────┘ │ │ │ │ GAUGE (Point-in-Time Value) │ │ ┌─────────────────────────────────────────┐ │ │ │ inventory.stock.level │ │ │ │ │ │ │ │ Value: 4250 → 4100 → 4300 → 3900 │ ● Goes up and down │ │ │ ──────────────────────────────▶ time │ ● Callback-based │ │ │ │ (ObservableGauge)│ │ └─────────────────────────────────────────┘ │ │ │ │ Cardinality Impact: │ │ ┌────────────────────────────────────────────────┐ │ │ │ Histogram(10 buckets) x Attributes(method, │ │ │ │ route, status) = 10 x 4 x 5 x 5 = 1000 │ │ │ │ time series per service instance │ │ │ │ │ │ │ │ ✓ Low-cardinality: http.method, status_code │ │ │ │ ✗ High-cardinality: user_id, request_id │ │ │ └────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Baggage is a mechanism for propagating key-value pairs across service boundaries along with the trace context, making them available to all downstream services. Unlike span attributes, which are local to a single span and not transmitted to downstream services, baggage travels with every outgoing request, allowing upstream context like tenant ID or feature flags to be accessed by any service in the request chain.
Detailed Answer
Think of baggage like a shipping label attached to a package as it moves through a delivery network. Every warehouse and sorting facility along the route can read the label to make decisions: route this package via express, handle with care, deliver to the third floor. Span attributes, by contrast, are like internal notes that each warehouse writes in its own logbook. The next warehouse never sees those notes. Baggage propagates with the request across service boundaries; span attributes stay local to the service that created them.
Baggage in OpenTelemetry is part of the W3C Baggage specification and is propagated via HTTP headers (the baggage header) or gRPC metadata alongside the trace context (traceparent and tracestate headers). When the payments-api sets a baggage entry like tenant.id=acme-corp, every downstream service that handles the same request, including order-processing-service, inventory-sync, and user-auth-service, can read that value without the payments-api needing to explicitly pass it as a parameter. This is particularly powerful for cross-cutting concerns that affect multiple services but originate at the edge, such as tenant identification in multi-tenant systems, A/B test cohort assignment, feature flag values, or request priority levels.
Internally, baggage is stored in the OpenTelemetry context and injected into outgoing requests by the configured propagator. The SDK provides the W3CBaggagePropagator, which serializes baggage entries into the baggage HTTP header as a comma-separated list of key=value pairs. When a downstream service receives the request, the propagator extracts the baggage from the header and makes it available via the baggage API. Importantly, baggage entries are not automatically added to spans. This is a deliberate design decision because baggage can contain sensitive data, and automatically attaching it to every span would export that data to your tracing backend. If you want baggage values to appear as span attributes, you must explicitly copy them, either in application code or using the Collector's baggage processor.
In production, baggage is invaluable for multi-tenant observability. When the checkout-service receives a request, it sets tenant.id in baggage. Every downstream service can then use that tenant ID to add it as a span attribute, include it in log messages, or use it as a metric dimension, all without modifying the API contracts between services. Another common use case is request prioritization: the API gateway sets priority=high in baggage for premium customers, and the order-processing-service reads this to route the request to a dedicated processing queue. Feature flags are another natural fit. If the frontend assigns a user to the new-checkout-flow experiment, it sets experiment.checkout=variant-b in baggage, and every downstream service can record which variant was active, enabling end-to-end A/B test analysis across the entire request path.
The critical gotcha with baggage is that it is propagated to every downstream service, including third-party APIs and external vendors. Sensitive data like user emails, account numbers, or authentication tokens must never be placed in baggage because any service in the chain, including services you do not control, can read and log the values. The baggage header is transmitted in plaintext in HTTP requests, making it visible in network logs and proxy access logs. Another pitfall is baggage size. Each baggage entry adds bytes to every HTTP header on every request. If you add large values or too many entries, you can exceed HTTP header size limits (typically 8 KB in nginx, 16 KB in many load balancers), causing requests to fail with 431 Request Header Fields Too Large errors. Keep baggage entries small, use short keys, and limit the total number of entries to under 10.
Code Example
# Using OpenTelemetry Baggage in Python for multi-tenant context
from opentelemetry import baggage, trace, context
# Import the baggage propagator for cross-service propagation
from opentelemetry.propagators.composite import CompositePropagator
# Import W3C propagators for trace context and baggage
from opentelemetry.propagators.textmap import DefaultTextMapPropagator
# Import the baggage API for setting and getting entries
from opentelemetry.baggage import set_baggage, get_baggage, get_all
# Import requests library for HTTP calls
import requests
# --- Setting baggage at the API gateway / edge service ---
# This runs in the checkout-service when a request arrives
# Get the current tracer for creating spans
tracer = trace.get_tracer("checkout-service")
# Set tenant ID in baggage (propagated to all downstream services)
ctx = set_baggage("tenant.id", "acme-corp") # Tenant identifier
# Set request priority for downstream routing decisions
ctx = set_baggage("request.priority", "high", ctx) # Priority level
# Set experiment variant for A/B test tracking
ctx = set_baggage("experiment.checkout", "variant-b", ctx) # Feature flag
# Start a span and make downstream call with baggage context
with trace.get_tracer("checkout-service").start_as_current_span(
"process-checkout",
context=ctx # Use context with baggage
) as span:
# Explicitly copy baggage to span attributes (not automatic)
tenant_id = get_baggage("tenant.id", ctx) # Read baggage value
span.set_attribute("tenant.id", tenant_id) # Add as span attribute
# Make HTTP call to order-processing-service
# Baggage is automatically injected into HTTP headers by the propagator
response = requests.post( # Downstream HTTP call
"http://order-processing-service:8080/api/v1/orders", # Target service
json={"items": [{"sku": "WIDGET-001", "qty": 2}]}, # Request body
)
# --- Reading baggage in a downstream service (order-processing-service) ---
# The propagator automatically extracts baggage from incoming HTTP headers
# Framework middleware (Flask, FastAPI) handles extraction
from flask import Flask, request
# Import OpenTelemetry Flask instrumentation
from opentelemetry.instrumentation.flask import FlaskInstrumentor
app = Flask(__name__) # Create Flask app
FlaskInstrumentor().instrument_app(app) # Auto-instrument Flask
@app.route("/api/v1/orders", methods=["POST"]) # Order creation endpoint
def create_order():
# Read baggage set by the upstream checkout-service
tenant_id = get_baggage("tenant.id") # Get tenant from baggage
priority = get_baggage("request.priority") # Get priority from baggage
experiment = get_baggage("experiment.checkout") # Get experiment variant
# Add baggage values as span attributes for this service's span
current_span = trace.get_current_span() # Get the active span
current_span.set_attribute("tenant.id", tenant_id) # Attach to span
current_span.set_attribute("request.priority", priority) # Attach priority
# Use priority for routing decisions
if priority == "high": # Check priority value
# Route to dedicated high-priority processing queue
process_high_priority_order(request.json) # Priority queue handler
else:
process_standard_order(request.json) # Standard queue handler
return {"status": "created"}, 201 # Return response
# --- HTTP Header format (W3C Baggage) ---
# Request from checkout-service to order-processing-service:
# baggage: tenant.id=acme-corp,request.priority=high,experiment.checkout=variant-bInterview Tip
A junior engineer typically confuses baggage with span attributes, or has never heard of baggage at all. To stand out, clearly articulate the fundamental difference: span attributes are local metadata visible only in the current span and exported to the tracing backend, while baggage is propagated to every downstream service via HTTP headers but is not automatically attached to spans. Explain why this distinction matters for security: baggage travels in plaintext HTTP headers, so sensitive data must never be placed in baggage. Discuss the practical use case of multi-tenant systems where the API gateway sets tenant.id in baggage and every downstream service like order-processing-service and inventory-sync can read it without requiring it as an API parameter. Mention the HTTP header size limit risk when too many baggage entries are added.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐ │ Baggage vs Span Attributes Propagation │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ SPAN ATTRIBUTES (Local to each service) │ │ ┌──────────────┐ ┌───────────────────┐ ┌──────────────┐ │ │ │ checkout-svc │ │ order-processing │ │ inventory │ │ │ │ │ │ │ │ -sync │ │ │ │ attr: ✓ │ │ attr: ✓ │ │ attr: ✓ │ │ │ │ (local only) │───→│ (local only) │───→│ (local only) │ │ │ │ │ │ ✗ Cannot see │ │ ✗ Cannot see │ │ │ │ │ │ upstream attrs │ │ upstream │ │ │ └──────────────┘ └───────────────────┘ └──────────────┘ │ │ │ │ BAGGAGE (Propagated across all services) │ │ ┌──────────────┐ ┌───────────────────┐ ┌──────────────┐ │ │ │ checkout-svc │ │ order-processing │ │ inventory │ │ │ │ │ │ │ │ -sync │ │ │ │ SET baggage: │ │ READ baggage: │ │ READ baggage:│ │ │ │ tenant.id= │───→│ tenant.id= │───→│ tenant.id= │ │ │ │ acme-corp │ │ acme-corp ✓ │ │ acme-corp ✓ │ │ │ │ priority= │ │ priority= │ │ priority= │ │ │ │ high │ │ high ✓ │ │ high ✓ │ │ │ └──────────────┘ └───────────────────┘ └──────────────┘ │ │ │ │ HTTP Headers (W3C Baggage): │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ traceparent: 00-trace_id-span_id-01 │ │ │ │ baggage: tenant.id=acme-corp,priority=high │ │ │ │ │ │ │ │ ● Plaintext → no sensitive data │ │ │ │ ● Size limit → keep entries small (<10 keys) │ │ │ │ ● Not auto-attached to spans → explicit copy needed │ │ │ └──────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
OpenTelemetry provides auto-instrumentation libraries for popular HTTP and gRPC frameworks that automatically create spans for incoming and outgoing requests, propagate trace context via headers, and record standard attributes like http.method, http.status_code, and rpc.method. You install framework-specific instrumentation packages and initialize them at application startup, with optional manual instrumentation for business logic spans.
Detailed Answer
Think of auto-instrumentation like installing a dashcam in your car. Once it is mounted, it automatically records every trip without you pressing any buttons. You still have the option to narrate important events by pressing a button (manual instrumentation), but the baseline recording of every start, stop, turn, and speed happens automatically. OpenTelemetry auto-instrumentation works the same way for HTTP and gRPC calls: install the library, and every request is automatically traced with standardized attributes.
For HTTP services, OpenTelemetry provides instrumentation libraries for virtually every popular framework. In Python, opentelemetry-instrumentation-flask instruments Flask applications, opentelemetry-instrumentation-fastapi handles FastAPI, and opentelemetry-instrumentation-django covers Django. In Java, the OpenTelemetry Java agent is a single JAR file that instruments dozens of frameworks automatically via bytecode manipulation, requiring zero code changes. In Go, instrumentation is provided through middleware for net/http, gin, echo, and other frameworks. When auto-instrumentation is active, every incoming HTTP request creates a server span with standardized semantic convention attributes: http.method (GET, POST), http.url or http.route, http.status_code, http.request_content_length, net.host.name, and net.peer.ip. Outgoing HTTP calls via libraries like requests in Python or HttpClient in Java automatically create client spans and inject the trace context (traceparent header) into the outgoing request.
For gRPC services, OpenTelemetry instruments both the client and server sides. The instrumentation registers interceptors on the gRPC channel (client) or server that create spans for each RPC call. Standard attributes include rpc.system (grpc), rpc.service (the protobuf service name like payments.PaymentService), rpc.method (the method name like ProcessPayment), rpc.grpc.status_code (OK, CANCELLED, DEADLINE_EXCEEDED), and net.peer.name. The trace context is propagated through gRPC metadata, which is functionally equivalent to HTTP headers. In the payments-api calling the user-auth-service via gRPC, the client interceptor creates a client span, injects the trace context into the gRPC metadata, and the server interceptor on user-auth-service extracts the context and creates a server span linked to the same trace.
Combining auto-instrumentation with manual instrumentation gives you the best of both worlds. Auto-instrumentation captures the infrastructure layer: every HTTP request, every gRPC call, every database query. Manual instrumentation captures business logic: processing a payment, validating inventory, applying a discount. In the checkout-service, auto-instrumentation automatically creates spans for the incoming POST /api/v1/checkout HTTP request and the outgoing gRPC call to payments-api. You then add manual spans inside the request handler for validate-cart, calculate-tax, and apply-discount, which appear as child spans under the auto-instrumented HTTP span. This gives you a complete picture: the HTTP layer shows you network latency, and the business logic spans show you where time is spent within the service.
In production, there are several important considerations. First, auto-instrumentation can generate high span volume if every health check and readiness probe creates a span. Use the Collector filter processor or SDK-level span filtering to suppress spans for paths like /healthz and /readyz. Second, the http.url attribute can contain sensitive query parameters. Use http.route (the URL template like /users/:id) instead of the full URL with parameters. Most instrumentation libraries support URL sanitization configuration. Third, gRPC streaming RPCs create spans that can remain open for minutes or hours. Configure appropriate timeouts and understand that long-lived spans consume memory in the SDK until they are exported. Fourth, the order of instrumentation initialization matters. The tracing provider must be configured before instrumentation libraries are applied, or spans will be silently dropped.
Code Example
# Instrument a Python Flask HTTP service with OpenTelemetry
# Install: pip install opentelemetry-instrumentation-flask opentelemetry-instrumentation-requests
from flask import Flask, request, jsonify
# Import OpenTelemetry tracing API
from opentelemetry import trace
# Import the SDK tracer provider
from opentelemetry.sdk.trace import TracerProvider
# Import the OTLP exporter
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
# Import batch processor for production use
from opentelemetry.sdk.trace.export import BatchSpanProcessor
# Import resource for service identification
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
# Import Flask auto-instrumentation
from opentelemetry.instrumentation.flask import FlaskInstrumentor
# Import requests auto-instrumentation for outbound HTTP
from opentelemetry.instrumentation.requests import RequestsInstrumentor
# Step 1: Configure the tracer provider BEFORE instrumenting
resource = Resource.create({SERVICE_NAME: "checkout-service"}) # Service identity
provider = TracerProvider(resource=resource) # Create provider
exporter = OTLPSpanExporter(endpoint="otel-collector:4317", insecure=True) # OTLP export
provider.add_span_processor(BatchSpanProcessor(exporter)) # Batch for efficiency
trace.set_tracer_provider(provider) # Set globally
# Step 2: Create the Flask app
app = Flask(__name__) # Initialize Flask
# Step 3: Apply auto-instrumentation to Flask and requests library
FlaskInstrumentor().instrument_app(app) # Auto-instrument inbound HTTP
RequestsInstrumentor().instrument() # Auto-instrument outbound HTTP
# Step 4: Get a tracer for manual business logic spans
tracer = trace.get_tracer("checkout-service", "2.4.0") # Named tracer instance
@app.route("/api/v1/checkout", methods=["POST"]) # Checkout endpoint
def checkout():
# Flask instrumentation auto-creates a span for this HTTP request
# Attributes: http.method=POST, http.route=/api/v1/checkout, etc.
# Manual span for business logic: cart validation
with tracer.start_as_current_span("validate-cart") as span: # Child span
cart = request.json.get("cart", []) # Parse request body
span.set_attribute("cart.item_count", len(cart)) # Business attribute
validate_cart_items(cart) # Validation logic
# Manual span for tax calculation
with tracer.start_as_current_span("calculate-tax") as span: # Another child span
tax = calculate_tax(cart) # Tax computation
span.set_attribute("tax.amount", tax) # Record tax amount
# Auto-instrumented outbound HTTP call to payments-api
import requests as http_requests # HTTP client library
payment_response = http_requests.post( # Outbound call
"http://payments-api:8080/api/v1/charge", # payments-api endpoint
json={"amount": 99.99, "currency": "USD"}, # Payment payload
) # Auto-creates client span with traceparent header injected
return jsonify({"status": "completed"}), 200 # Return response
# --- gRPC instrumentation for payments-api (server side) ---
# Install: pip install opentelemetry-instrumentation-grpc
import grpc
# Import gRPC server instrumentation
from opentelemetry.instrumentation.grpc import GrpcInstrumentorServer
# Import gRPC client instrumentation
from opentelemetry.instrumentation.grpc import GrpcInstrumentorClient
# Instrument the gRPC server (payments-api receiving calls)
GrpcInstrumentorServer().instrument() # Auto-instrument server
# Instrument the gRPC client (calling user-auth-service)
GrpcInstrumentorClient().instrument() # Auto-instrument client
# Create gRPC server with automatic span creation
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))# gRPC server
# Every incoming RPC auto-creates a span with rpc.method, rpc.service attributesInterview Tip
A junior engineer typically stops at auto-instrumentation without understanding what attributes are captured or how to layer manual spans on top. Demonstrate depth by explaining the semantic conventions for HTTP spans (http.method, http.route, http.status_code) and gRPC spans (rpc.system, rpc.service, rpc.method, rpc.grpc.status_code). Explain the critical initialization order: the TracerProvider must be configured before instrumentation libraries are applied, or all spans are silently dropped because the no-op default provider discards them. Discuss the practical pattern of combining auto-instrumentation for infrastructure visibility with manual spans for business logic, giving the example of a checkout endpoint where Flask auto-creates the HTTP span and you manually create child spans for validate-cart, calculate-tax, and apply-discount.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐ │ HTTP and gRPC Instrumentation with OpenTelemetry │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ HTTP Auto-Instrumentation (checkout-service) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ POST /api/v1/checkout │ │ │ │ ┌──────────────────────────────────────────────┐ │ │ │ │ │ Server Span (auto-created by Flask) │ │ │ │ │ │ http.method=POST │ │ │ │ │ │ http.route=/api/v1/checkout │ │ │ │ │ │ http.status_code=200 │ │ │ │ │ │ │ │ │ │ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ │ │ │ │ validate-cart│ │calculate-tax │ ← Manual │ │ │ │ │ │ │ (manual span)│ │(manual span) │ spans │ │ │ │ │ │ └──────────────┘ └──────────────┘ │ │ │ │ │ │ │ │ │ │ │ │ ┌────────────────────────────────────┐ │ │ │ │ │ │ │ Client Span (auto-created) │ │ │ │ │ │ │ │ POST payments-api:8080/charge │ │ │ │ │ │ │ │ + traceparent header injected │ │ │ │ │ │ │ └──────────────┬─────────────────────┘ │ │ │ │ │ └─────────────────┼─────────────────────────────┘ │ │ │ └────────────────────┼──────────────────────────────────┘ │ │ │ │ │ ↓ HTTP + traceparent header │ │ │ │ gRPC Auto-Instrumentation (payments-api) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ ┌──────────────────────────────────────────────┐ │ │ │ │ │ Server Span (auto by gRPC interceptor) │ │ │ │ │ │ rpc.system=grpc │ │ │ │ │ │ rpc.service=PaymentService │ │ │ │ │ │ rpc.method=ProcessPayment │ │ │ │ │ │ │ │ │ │ │ │ ┌───────────────────────────────────┐ │ │ │ │ │ │ │ Client Span → user-auth-service │ │ │ │ │ │ │ │ rpc.method=ValidateToken │ │ │ │ │ │ │ │ + trace context in gRPC metadata │ │ │ │ │ │ │ └───────────────────────────────────┘ │ │ │ │ │ └──────────────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Span events are timestamped annotations within a span that record notable occurrences like exceptions, retries, or cache hits without creating separate child spans. Span links create associations between spans in different traces or between spans that do not have a direct parent-child relationship, such as connecting a message producer span to a consumer span in async messaging systems.
Detailed Answer
Think of span events as sticky notes placed on a timeline. If a span represents a road trip from New York to Boston, events are the sticky notes that say 'flat tire at mile 87' or 'stopped for gas at mile 150.' They mark important moments during the journey without creating a separate trip for each event. Span links, by contrast, are like cross-references between different trips. If the road trip was triggered by a phone call (a separate trace), a link connects the call to the trip, saying 'this trip happened because of that call,' even though the call and the trip are recorded separately.
Span events are lightweight annotations added to an existing span using the add_event() method. Each event has a name, a timestamp (automatically set to the current time), and optional attributes. The most common use case is exception recording. When the payments-api catches a transient error and retries, recording the exception as a span event preserves the error details (exception type, message, stack trace) within the span timeline without marking the entire span as failed. If the retry succeeds, the span status remains OK, but the event provides diagnostic context showing that a retry occurred. Other common events include cache hits and misses (event: cache.hit with attributes for the cache key), circuit breaker state changes (event: circuit_breaker.open), retry attempts (event: retry with the attempt number), and validation results. Events are lightweight because they do not create separate span objects, which keeps the trace viewer clean and reduces export overhead.
Span links create explicit relationships between spans that exist in different traces or that do not follow the standard parent-child hierarchy. The canonical example is asynchronous message processing. When the checkout-service publishes an order event to Kafka, the publish operation creates a span in trace A. When the order-processing-service consumes that message minutes later, it starts a new trace B (because the consumer may batch multiple messages or process them independently). A span link from the consumer span in trace B to the producer span in trace A creates a navigable connection between the two traces, allowing an engineer to click from the consumer span to the original producer span in the trace viewer. Links carry the full trace context (trace ID, span ID) of the linked span plus optional attributes describing the relationship.
Other important use cases for span links include fan-in operations, where a single span aggregates results from multiple parallel traces. For example, the inventory-sync service might trigger parallel stock checks across five warehouses, each in its own trace. The aggregation span links to all five warehouse traces, creating a hub-and-spoke navigation pattern. Batch processing is another fit: when a cron job processes 1000 orders from a queue, creating parent-child relationships with all 1000 original order traces would create an impossibly deep trace tree. Instead, the batch processing span links to a representative sample of the original traces. Scheduled jobs triggered by previous operations also benefit from links, connecting the scheduled job trace to the trace that created the schedule.
In production, the distinction between events and links matters for trace viewer usability. Excessive child spans create deep, cluttered trace trees that are hard to navigate. Events keep the trace compact while preserving diagnostic detail. Links keep traces independent and reasonably sized while enabling cross-trace navigation. A common gotcha is recording exceptions as separate child spans instead of events. This inflates the span count, makes the trace tree harder to read, and can trigger false alerts if error detection is based on span status rather than event analysis. Another pitfall is not capturing the producer span context at publish time. If the checkout-service publishes to Kafka without extracting the current span context and attaching it to the message headers, the consumer has nothing to link to.
Code Example
# Demonstrate span events and span links in Python OpenTelemetry SDK
from opentelemetry import trace
# Import span status codes for error handling
from opentelemetry.trace import StatusCode, Link
# Import the tracing SDK
from opentelemetry.sdk.trace import TracerProvider
# Import resource for service identification
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
import traceback
# Configure the tracer provider for checkout-service
resource = Resource.create({SERVICE_NAME: "checkout-service"}) # Service identity
trace.set_tracer_provider(TracerProvider(resource=resource)) # Set provider
tracer = trace.get_tracer("checkout-service", "2.4.0") # Get tracer
# --- SPAN EVENTS: Recording notable occurrences within a span ---
# Process a payment with retry logic and event recording
with tracer.start_as_current_span("process-payment") as span:
span.set_attribute("payment.amount", 149.99) # Payment amount
span.set_attribute("payment.currency", "USD") # Currency
# Record a cache lookup event
span.add_event("cache.lookup", attributes={ # Cache check event
"cache.key": "customer:pricing-tier", # What we looked up
"cache.hit": True, # Cache hit result
"cache.backend": "redis", # Cache system
})
# Simulate a transient failure and retry
for attempt in range(3): # Retry loop
try:
result = call_payment_gateway(amount=149.99) # Call external API
span.add_event("payment.succeeded", attributes={ # Success event
"attempt": attempt + 1, # Which attempt succeeded
"gateway.response_ms": 234, # Gateway latency
})
break # Exit retry loop
except ConnectionError as e:
# Record exception as an event (not a separate span)
span.add_event("payment.retry", attributes={ # Retry event
"attempt": attempt + 1, # Attempt number
"error.type": type(e).__name__, # Exception class
"error.message": str(e), # Error message
})
# Record full exception with stack trace
span.record_exception(e) # OTel exception event
if attempt == 2: # Final attempt failed
span.set_status(StatusCode.ERROR, str(e)) # Mark span as error
raise # Re-raise exception
# --- SPAN LINKS: Connecting async producer and consumer spans ---
# Producer side: checkout-service publishes order to Kafka
with tracer.start_as_current_span("publish-order-event") as producer_span:
producer_span.set_attribute("messaging.system", "kafka") # Messaging system
producer_span.set_attribute("messaging.destination", "orders-topic") # Topic
# Capture the producer span context for the consumer to link to
producer_context = producer_span.get_span_context() # Get span context
# In real code, serialize this into Kafka message headers
# The trace_id and span_id travel with the message
publish_to_kafka("orders-topic", order_data, producer_context) # Publish
# Consumer side: order-processing-service consumes from Kafka
# This runs in a separate service, potentially minutes later
def process_kafka_message(message):
# Extract the producer span context from Kafka message headers
producer_ctx = extract_context_from_headers(message.headers) # Extract context
# Create a new span with a LINK to the producer span
link = Link(
context=producer_ctx, # Link to producer
attributes={"messaging.operation": "receive"}, # Link metadata
)
# Start a new trace with the link (not a child span)
with tracer.start_as_current_span(
"process-order",
links=[link], # Attach the link
) as consumer_span:
consumer_span.set_attribute("messaging.system", "kafka") # System
consumer_span.set_attribute("messaging.operation", "process")# Operation
consumer_span.set_attribute("order.id", message.order_id) # Business attr
# Process the order
fulfill_order(message.order_id) # Business logicInterview Tip
A junior engineer typically creates a new child span for every notable occurrence within a span, leading to cluttered trace trees with dozens of tiny spans for things like cache lookups, retries, and validation checks. Demonstrate that you understand the span hierarchy design principle: use child spans for discrete operations that have meaningful duration and might fail independently (like an HTTP call to another service), and use events for timestamped annotations within an existing span (like a cache hit, a retry attempt, or an exception that was caught and handled). For span links, explain the Kafka producer-consumer pattern: the producer span is in trace A, the consumer creates a new trace B because processing is asynchronous, and a link connects them for end-to-end navigation. Mention that links are essential for fan-in scenarios where multiple input traces converge into a single processing span.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐ │ Span Events vs Span Links │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ SPAN EVENTS (Timestamped annotations within a span) │ │ ┌────────────────────────────────────────────────────┐ │ │ │ Span: process-payment (checkout-service) │ │ │ │ ─────────────────────────────────────────────▶ time│ │ │ │ │ │ │ │ │ │ │ │ ● ● ● ● │ │ │ │ cache payment.retry payment.retry payment │ │ │ │ .lookup attempt=1 attempt=2 .succeeded │ │ │ │ hit=true error=timeout error=timeout attempt=3 │ │ │ │ │ │ │ │ ✓ Lightweight: no separate span objects │ │ │ │ ✓ Keeps trace tree clean and readable │ │ │ │ ✓ Preserves timeline of notable moments │ │ │ └────────────────────────────────────────────────────┘ │ │ │ │ SPAN LINKS (Cross-trace relationships) │ │ ┌────────────────────────────────────────────────────┐ │ │ │ │ │ │ │ Trace A (checkout-service) │ │ │ │ ┌──────────────────────────┐ │ │ │ │ │ publish-order-event │ │ │ │ │ │ messaging.system=kafka │ │ │ │ │ └──────────┬───────────────┘ │ │ │ │ │ │ │ │ │ │ ←──── Span Link ────→ │ │ │ │ │ (not parent-child) │ │ │ │ ↓ │ │ │ │ Trace B (order-processing-service) │ │ │ │ ┌──────────────────────────┐ │ │ │ │ │ process-order │ │ │ │ │ │ link → Trace A producer │ │ │ │ │ │ messaging.operation= │ │ │ │ │ │ process │ │ │ │ │ └──────────────────────────┘ │ │ │ │ │ │ │ │ ✓ Connects async/batch operations │ │ │ │ ✓ Enables cross-trace navigation │ │ │ │ ✓ Fan-in: many traces → one processing span │ │ │ └────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Distributed tracing with OpenTelemetry works by generating a unique trace ID at the entry point, propagating it across service boundaries via HTTP headers (W3C Trace Context) or gRPC metadata, and having each service create spans that reference the same trace ID. The OpenTelemetry SDK handles context propagation automatically through configured propagators and instrumentation libraries.
Detailed Answer
Think of distributed tracing like a detective following a suspect across multiple cities. At each city, a local officer picks up the tail and writes a report (a span) that includes the case number (trace ID) and who handed off the surveillance (parent span ID). When the detective later assembles all reports by case number, they get a complete timeline of the suspect's journey across every city. Without the case number, each city's report would be an isolated document with no connection to the others.
Distributed tracing in OpenTelemetry relies on three core components working together: context creation, context propagation, and span collection. When a request enters the system at the edge service, typically an API gateway or the first microservice to receive the external request, the OpenTelemetry SDK generates a trace ID (a 128-bit random identifier) and creates the root span. This trace ID is the thread that ties together all spans across all services that participate in handling this request. The root span also gets a span ID (a 64-bit identifier) that child spans will reference as their parent. If the incoming request already carries a traceparent header (because it originated from an already-instrumented system), the SDK extracts the existing trace ID instead of generating a new one, ensuring the trace continues seamlessly.
Context propagation is the mechanism that carries the trace ID and parent span ID from one service to the next. OpenTelemetry supports multiple propagation formats, with W3C Trace Context being the default. The traceparent header carries the trace ID, parent span ID, and trace flags in a standardized format: 00-{trace_id}-{span_id}-{trace_flags}. When the checkout-service makes an HTTP call to the payments-api, the OpenTelemetry instrumentation automatically injects the traceparent header into the outgoing request. When the payments-api receives the request, its instrumentation extracts the header and creates a new span with the same trace ID and the incoming span ID as its parent. This automatic inject-and-extract cycle repeats at every service boundary, creating a connected tree of spans that represents the complete request flow from checkout-service through payments-api, user-auth-service, and inventory-sync.
The span tree is assembled by the tracing backend, not by the services themselves. Each service independently exports its spans to the OpenTelemetry Collector (or directly to a backend like Jaeger or Grafana Tempo). The backend groups all spans with the same trace ID into a single trace view. The parent-child relationships between spans are reconstructed using the parent span ID references. This decoupled design means that services do not need to know about each other's spans. The checkout-service does not wait for the payments-api to finish its spans. Each service exports independently, and the backend assembles the complete picture. The Collector acts as a central aggregation point, receiving spans from all services, batching them, and forwarding them to the backend.
In production, several challenges arise with distributed tracing at scale. Clock skew between services can make span timelines appear incorrect, with child spans starting before their parent. NTP synchronization across all hosts is essential. Missing spans create gaps in traces, which can be caused by services that are not instrumented, network failures that prevent span export, or SDK misconfiguration. The health of the tracing pipeline itself needs monitoring. Track metrics like otelcol_exporter_sent_spans and otelcol_receiver_refused_spans on the Collector to detect data loss. Service mesh proxies like Envoy in Istio can handle context propagation at the infrastructure layer, but the application must still forward incoming headers on outgoing requests. A common mistake is the checkout-service receiving a traceparent header from the client, creating its own new trace ID instead of using the propagated one, and then calling the payments-api with the new trace ID. This produces two disconnected traces instead of one continuous trace. Ensuring that all services use the same propagator configuration (W3C Trace Context) and that framework instrumentation is initialized before any requests are handled prevents this issue.
Code Example
# End-to-end distributed tracing setup across three microservices
# Service 1: checkout-service (entry point)
# --- checkout-service (Python/Flask) ---
from opentelemetry import trace
# Import SDK components for tracer configuration
from opentelemetry.sdk.trace import TracerProvider
# Import resource for service identification
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
# Import OTLP exporter for sending spans to Collector
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
# Import batch processor for efficient span export
from opentelemetry.sdk.trace.export import BatchSpanProcessor
# Import W3C propagator for trace context propagation
from opentelemetry.propagators.textmap import set_global_textmap
# Import composite propagator for multiple propagation formats
from opentelemetry.propagate import set_global_textmap
# Import Flask instrumentation
from opentelemetry.instrumentation.flask import FlaskInstrumentor
# Import requests instrumentation for outbound HTTP calls
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from flask import Flask
import requests as http_client
# Step 1: Configure the tracer provider with service identity
resource = Resource.create({
SERVICE_NAME: "checkout-service", # Service name
"service.version": "2.4.0", # Version for regression tracking
"deployment.environment": "production", # Environment
})
provider = TracerProvider(resource=resource) # Create the provider
# Configure OTLP export to the Collector
exporter = OTLPSpanExporter(
endpoint="http://otel-collector.monitoring:4317", # Collector gRPC endpoint
insecure=True, # No TLS for internal traffic
)
provider.add_span_processor(BatchSpanProcessor(exporter)) # Batch spans before export
trace.set_tracer_provider(provider) # Set as global provider
# Step 2: Instrument Flask (inbound) and requests (outbound)
app = Flask(__name__) # Create Flask app
FlaskInstrumentor().instrument_app(app) # Auto-create spans for HTTP in
RequestsInstrumentor().instrument() # Auto-inject traceparent on HTTP out
# Step 3: Application code with manual business spans
tracer = trace.get_tracer("checkout-service") # Get a named tracer
@app.route("/api/v1/checkout", methods=["POST"]) # Checkout endpoint
def checkout():
# Flask auto-creates: span "POST /api/v1/checkout"
# traceparent extracted from incoming headers automatically
with tracer.start_as_current_span("validate-order") as span:
span.set_attribute("order.item_count", 3) # Business context
# Call payments-api - traceparent header auto-injected
payment = http_client.post( # Outbound HTTP call
"http://payments-api:8080/api/v1/charge", # payments-api URL
json={"amount": 99.99}, # Payment payload
)
# Call inventory-sync - traceparent header auto-injected
inventory = http_client.post( # Outbound HTTP call
"http://inventory-sync:8080/api/v1/reserve", # inventory-sync URL
json={"sku": "WIDGET-001", "qty": 2}, # Inventory payload
)
return {"status": "completed"}, 200 # Return response
# --- Kubernetes deployment with OTel environment configuration ---
# deploy-checkout.yaml
apiVersion: apps/v1 # Kubernetes API version
kind: Deployment # Deployment resource
metadata:
name: checkout-service # Service name
spec:
template:
spec:
containers:
- name: checkout-service # Container name
image: checkout-service:2.4.0 # Container image
env:
- name: OTEL_SERVICE_NAME # Service identity
value: "checkout-service" # Service name value
- name: OTEL_EXPORTER_OTLP_ENDPOINT # Collector endpoint
value: "http://otel-collector:4317" # gRPC endpoint
- name: OTEL_PROPAGATORS # Propagation format
value: "tracecontext,baggage" # W3C Trace Context + Baggage
- name: OTEL_TRACES_SAMPLER # Sampling strategy
value: "parentbased_traceidratio" # Parent-based ratio sampler
- name: OTEL_TRACES_SAMPLER_ARG # Sampling ratio
value: "0.1" # Sample 10% of root tracesInterview Tip
A junior engineer typically describes distributed tracing as 'each service creates spans and they get connected somehow' without explaining the propagation mechanism or the role of trace IDs and parent span IDs. To stand out, walk through the complete lifecycle: the first service generates a 128-bit trace ID, creates a root span, and when making downstream calls, the instrumentation injects the traceparent header containing the trace ID and current span ID. The downstream service extracts this header, creates a new span with the same trace ID and the upstream span ID as its parent, and repeats the process for its own downstream calls. Explain that the backend assembles the complete trace by grouping spans with the same trace ID. Mention the clock skew problem across services and why NTP synchronization matters for accurate timelines. Discuss the common misconfiguration where a service generates a new trace ID instead of using the propagated one, producing disconnected traces.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐ │ Distributed Tracing Across Microservices │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ Client Request │ │ │ │ │ ↓ trace_id: abc123 (generated at entry) │ │ ┌──────────────────┐ │ │ │ checkout-service │ Root Span: POST /checkout │ │ │ span_id: s1 │ parent: none │ │ │ │ │ │ │ ┌──────────────┐ │ │ │ │ │validate-order│ │ Child span (manual) │ │ │ └──────────────┘ │ │ │ └────┬─────────┬────┘ │ │ │ │ │ │ │ HTTP │ HTTP │ │ │ + traceparent: 00-abc123-s1-01 │ │ ↓ ↓ │ │ ┌─────────┐ ┌──────────────┐ │ │ │payments │ │inventory-sync│ │ │ │-api │ │ │ │ │ │span: s2 │ │span: s3 │ │ │ │parent:s1│ │parent: s1 │ │ │ └────┬────┘ └──────────────┘ │ │ │ │ │ │ gRPC + trace context in metadata │ │ ↓ │ │ ┌─────────────────┐ │ │ │ user-auth-svc │ │ │ │ span: s4 │ │ │ │ parent: s2 │ │ │ └─────────────────┘ │ │ │ │ All spans exported independently to Collector: │ │ ┌──────────┐ ┌───────────────┐ ┌──────────────┐ │ │ │ Span s1 │ │ │ │ Backend │ │ │ │ Span s2 │───→│ OTel Collector│───→│ (Jaeger/ │ │ │ │ Span s3 │ │ (batch + │ │ Tempo) │ │ │ │ Span s4 │ │ export) │ │ │ │ │ └──────────┘ └───────────────┘ │ Groups by │ │ │ │ trace_id: │ │ │ trace_id=abc123 across all services │ abc123 │ │ │ Assembled into complete trace view └──────────────┘ │ └──────────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
A production-grade deployment uses a two-tier architecture: agent-mode Collectors run as DaemonSets on every node to collect and pre-process telemetry locally, then forward to gateway-mode Collectors running as a Deployment behind a load balancer for centralized processing, routing, and export. This separates concerns and allows independent scaling of each tier.
Detailed Answer
Think of the OpenTelemetry Collector like a postal system. Agent-mode Collectors are the local mailboxes on every street corner (every Kubernetes node) where residents (applications) drop off their letters (telemetry data). Gateway-mode Collectors are the central post offices that sort mail by destination, apply business rules (like filtering junk mail), and route to the final destinations (backends like Jaeger, Prometheus, or a cloud vendor). You would never have every house send mail directly to the national sorting center, and you would never have just one mailbox for the entire city.
The agent-mode Collector runs as a Kubernetes DaemonSet, meaning one instance per node. Applications on that node send telemetry (traces, metrics, logs) to localhost or the node's IP, which is fast and avoids cross-network hops. The agent handles lightweight tasks: receiving data via OTLP, adding resource attributes like node name and pod name using the k8sattributes processor, performing basic filtering to drop health-check spans, and batching data before forwarding. Because it runs on every node, it must be resource-efficient, typically limited to 200-500MB RAM and 0.5 CPU. If the agent crashes, only that node's telemetry is affected, not the entire pipeline.
The gateway-mode Collector runs as a Kubernetes Deployment with multiple replicas behind a ClusterIP or LoadBalancer service. This tier handles heavy processing: tail-based sampling decisions across the entire fleet (which requires seeing all spans of a trace in one place), complex attribute transformations, routing to multiple backends based on signal type, and rate limiting. Gateway replicas can be autoscaled with HPA based on CPU, memory, or custom metrics like the queue size of the exporter. A typical production setup uses 3-5 gateway replicas, each with 2-4GB RAM.
Scaling is the critical design concern. Agent-mode Collectors scale automatically with nodes (DaemonSet), so you only worry about per-node resource limits. Gateway-mode Collectors need careful capacity planning. The key bottleneck is usually the exporter queue: if the backend (say Tempo or Jaeger) is slow, the queue fills up, backpressure propagates to agents, and telemetry is dropped. You mitigate this with the sending_queue configuration (persistent queue backed by file storage so data survives restarts), retry_on_failure, and proper timeout settings. Monitoring the Collector itself is essential: expose its internal metrics (otelcol_exporter_queue_size, otelcol_receiver_refused_spans) to Prometheus and alert on queue saturation.
A common production pattern at companies like the ones running payments-api, order-processing-service, and checkout-service is to have separate gateway pools per signal type: one gateway Deployment for traces (which need tail-based sampling), another for metrics (which need different batching and aggregation), and a third for logs (which have different volume characteristics). This prevents a log spike from starving trace processing. Each pool has its own HPA policy tuned to its specific workload profile.
Code Example
# Agent-mode Collector DaemonSet configuration
# otel-agent-config.yaml
receivers:
otlp: # Receive OTLP from apps on this node
protocols:
grpc:
endpoint: 0.0.0.0:4317 # Standard OTLP gRPC port
http:
endpoint: 0.0.0.0:4318 # Standard OTLP HTTP port
processors:
batch: # Batch telemetry for efficiency
send_batch_size: 1024 # Send after 1024 items
timeout: 5s # Or after 5 seconds
memory_limiter: # Prevent OOM on the node
check_interval: 1s # Check memory every second
limit_mib: 400 # Hard limit at 400MB
spike_limit_mib: 100 # Allow 100MB spike
k8sattributes: # Enrich with Kubernetes metadata
extract:
metadata: # Pull pod/node info automatically
- k8s.pod.name
- k8s.namespace.name
- k8s.node.name
- k8s.deployment.name
exporters:
otlp: # Forward to gateway tier
endpoint: otel-gateway.observability:4317 # Gateway service DNS
tls:
insecure: false # Use TLS in production
ca_file: /certs/ca.crt # CA certificate for mTLS
sending_queue:
enabled: true # Enable persistent queue
storage: file_storage # Survive agent restarts
queue_size: 5000 # Buffer up to 5000 batches
service:
pipelines:
traces: # Trace pipeline: receive, process, export
receivers: [otlp]
processors: [memory_limiter, k8sattributes, batch]
exporters: [otlp]
metrics: # Metrics pipeline
receivers: [otlp]
processors: [memory_limiter, k8sattributes, batch]
exporters: [otlp]
# Gateway-mode Collector Deployment configuration
# otel-gateway-config.yaml
receivers:
otlp: # Receive from agent tier
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
tail_sampling: # Sampling decisions at gateway
decision_wait: 10s # Wait 10s for all spans
policies:
- name: errors-policy # Always keep error traces
type: status_code
status_code: {status_codes: [ERROR]}
- name: latency-policy # Keep slow traces
type: latency
latency: {threshold_ms: 2000}
exporters:
otlp/tempo: # Export traces to Grafana Tempo
endpoint: tempo.observability:4317
prometheusremotewrite: # Export metrics to Mimir
endpoint: http://mimir.observability:8080/api/v1/push
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
processors: []
exporters: [prometheusremotewrite]Interview Tip
A junior engineer typically says 'deploy the Collector as a Deployment and point apps to it.' For a senior role, explain the two-tier architecture: agent-mode DaemonSets on every node for low-latency local collection and Kubernetes metadata enrichment, gateway-mode Deployments for centralized sampling and routing. Cover the scaling concerns: agents scale with nodes automatically, gateways need HPA on queue depth. Mention persistent queues with file_storage to survive restarts, memory_limiter to prevent OOM, and the pattern of separate gateway pools per signal type so a log surge cannot starve trace processing. Discuss backpressure propagation and how sending_queue plus retry_on_failure protect against backend slowdowns.
◈ Architecture Diagram
Two-Tier OTel Collector Architecture:
┌─── Node 1 ────────────┐ ┌─── Node 2 ────────────┐
│ ┌──────────┐ │ │ ┌──────────┐ │
│ │payments │──┐ │ │ │order-proc│──┐ │
│ │ -api │ │ │ │ │ -service │ │ │
│ └──────────┘ │ OTLP │ │ └──────────┘ │ OTLP │
│ ┌──────────┐ │ │ │ ┌──────────┐ │ │
│ │checkout │──┤ │ │ │inventory │──┤ │
│ │ -service │ │ │ │ │ -sync │ │ │
│ └──────────┘ ▼ │ │ └──────────┘ ▼ │
│ ┌───────────┐ │ │ ┌───────────┐ │
│ │Agent Coll.│ │ │ │Agent Coll.│ │
│ │(DaemonSet)│ │ │ │(DaemonSet)│ │
│ └─────┬─────┘ │ │ └─────┬─────┘ │
└──────────────┼─────────┘ └──────────────┼─────────┘
│ │
▼ OTLP ▼
┌──────────────────────────────────────────────────┐
│ Gateway Collectors (Deployment) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Gateway │ │ Gateway │ │ Gateway │ │
│ │ Pod 1 │ │ Pod 2 │ │ Pod 3 │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
└──────────┼────────────┼────────────┼─────────────┘
│ │ │
┌──────▼──┐ ┌─────▼───┐ ┌─────▼───┐
│ Tempo │ │ Mimir │ │ Loki │
│ (traces)│ │(metrics)│ │ (logs) │
└─────────┘ └─────────┘ └─────────┘💬 Comments
Quick Answer
Tail-based sampling makes sampling decisions after collecting all spans of a trace, allowing you to keep 100% of error traces and slow traces while sampling down healthy traffic. It requires a gateway-mode Collector with the tail_sampling processor, where all spans of a trace must route to the same Collector instance using a load balancer with trace-ID-based consistent hashing.
Detailed Answer
Think of tail-based sampling like a film editor reviewing complete movie takes. Head-based sampling is like randomly deciding before filming whether to keep the take or not, meaning you might discard a take that turns out to contain a brilliant performance. Tail-based sampling waits until the entire take is filmed, watches it through, and then decides whether to keep it based on its content. You keep all the takes with great performances (errors, high latency) and discard a percentage of the routine takes (successful, fast requests).
Head-based sampling, which is the default in most SDK configurations, makes the sampling decision at the root span before any processing occurs. You set a probability (say 10%) and randomly keep 1 in 10 traces. The problem is that errors and slow requests happen in less than 1% of traffic, so a 10% head-based sample might miss critical traces entirely. You could set sampling to 100% to catch everything, but that generates enormous volume and cost. Tail-based sampling solves this by deferring the decision until all spans of a trace have been collected, then applying intelligent policies.
The tail_sampling processor in the OpenTelemetry Collector works by buffering spans in memory for a configurable decision_wait period (typically 10-30 seconds). During this window, all spans arriving with the same trace ID are grouped together. After the wait period expires, the processor evaluates the complete trace against a set of policies: did any span have an ERROR status code? Did the total trace duration exceed a latency threshold? Does the trace contain spans from a specific service? Based on these policy evaluations, the trace is either kept or dropped. Composite policies allow you to combine rules, for example keeping 100% of error traces, 100% of traces over 2 seconds, 50% of traces from the payments-api service, and only 5% of everything else.
The critical architectural requirement is that all spans belonging to a single trace must arrive at the same Collector instance. If span A of trace-123 goes to Gateway-1 and span B goes to Gateway-2, neither Collector sees the complete trace and both make incorrect sampling decisions. This is solved using the loadbalancing exporter in the agent-mode Collector, which implements consistent hashing on the trace ID to route all spans of the same trace to the same gateway. The load balancing exporter discovers gateway instances via DNS or Kubernetes service endpoints and distributes traces deterministically.
Memory management is the biggest operational challenge. The Collector must buffer all spans during the decision_wait period. If your system generates 50,000 spans per second and the decision wait is 30 seconds, the Collector buffers 1.5 million spans simultaneously. Each span consumes roughly 1-3KB of memory, so you need 1.5-4.5GB of RAM just for the sampling buffer. You must tune decision_wait (shorter wait means less memory but risk of incomplete traces), num_traces (maximum concurrent traces in the buffer), and expected_new_traces_per_sec. Monitoring otelcol_processor_tail_sampling_count_traces_sampled versus otelcol_processor_tail_sampling_count_traces_dropped is essential to verify your policies are working correctly.
Code Example
# Agent-mode: loadbalancing exporter for trace-ID routing
# otel-agent-config.yaml
receivers:
otlp: # Receive spans from applications
protocols:
grpc:
endpoint: 0.0.0.0:4317 # Listen on standard port
exporters:
loadbalancing: # Route by trace ID
routing_key: traceID # Hash on trace ID
protocol:
otlp: # Forward via OTLP gRPC
tls:
insecure: true # Use TLS in production
resolver:
dns: # Discover gateways via DNS
hostname: otel-gateway-headless.observability # Headless service
port: 4317 # Gateway OTLP port
service:
pipelines:
traces: # Only traces need load balancing
receivers: [otlp]
exporters: [loadbalancing] # Send to hashed gateway
# Gateway-mode: tail_sampling processor
# otel-gateway-config.yaml
receivers:
otlp: # Receive from agents
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
tail_sampling: # Make decisions after trace completes
decision_wait: 15s # Wait 15s for all spans to arrive
num_traces: 200000 # Max concurrent traces in buffer
expected_new_traces_per_sec: 5000 # Expected throughput for sizing
policies:
- name: errors-always-keep # Policy 1: keep all errors
type: status_code # Check span status
status_code:
status_codes: [ERROR] # Keep any trace with ERROR
- name: high-latency-keep # Policy 2: keep slow traces
type: latency # Check trace duration
latency:
threshold_ms: 2000 # Keep traces > 2 seconds
- name: payments-higher-rate # Policy 3: payments gets more
type: and # Combine conditions
and:
and_sub_policy:
- name: payments-service # Match payments-api service
type: string_attribute
string_attribute:
key: service.name
values: [payments-api]
- name: payments-sample # Sample 50% of payments traces
type: probabilistic
probabilistic:
sampling_percentage: 50
- name: default-low-rate # Policy 4: everything else at 5%
type: probabilistic
probabilistic:
sampling_percentage: 5 # Keep 5% of normal traffic
exporters:
otlp: # Export sampled traces to Tempo
endpoint: tempo.observability:4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling] # Apply tail sampling
exporters: [otlp]Interview Tip
A junior engineer typically says 'set the sampling rate in the SDK to 10 percent.' For a senior role, explain why head-based sampling is insufficient for production: it randomly discards traces, so error traces and slow traces are lost proportionally. Tail-based sampling defers decisions until the trace is complete, enabling policies like 'keep all errors and all traces over two seconds.' Emphasize the critical architectural constraint: all spans of one trace must reach the same Collector, which requires a loadbalancing exporter with trace-ID consistent hashing in the agent tier. Discuss the memory implications of buffering spans during decision_wait and how to size num_traces based on throughput. Mention composite policies that let you set different sampling rates per service.
◈ Architecture Diagram
Head-Based vs Tail-Based Sampling:
Head-Based (random, at SDK):
┌────────────┐
│ Request │── 10% chance ──→ ✓ Keep
│ arrives │── 90% chance ──→ ✗ Drop
│ (before │
│ processing)│ Problem: errors dropped randomly!
└────────────┘
Tail-Based (intelligent, at Collector):
┌────────────┐ ┌───────────────────┐
│ All spans │────→│ Collector buffers │
│ 100% sent │ │ for 15 seconds │
└────────────┘ └────────┬──────────┘
│ evaluate
▼
┌───────────────────┐
│ Policy Engine │
│ │
│ ERROR? ──→ ✓ 100% │
│ Slow? ──→ ✓ 100% │
│ payments? → ✓ 50% │
│ other? ──→ ✓ 5% │
└───────────────────┘
Trace-ID Routing (required):
┌────────┐ trace-abc ┌───────────┐
│Agent 1 │────────────→│ Gateway 1 │
│ │ trace-xyz │ (has all │
│ │──────┐ │ spans of │
└────────┘ │ │ trace-abc)│
┌────────┐ │ └───────────┘
│Agent 2 │──────┼─────→┌───────────┐
│ │ └─────→│ Gateway 2 │
│trace-xyz│ │ (has all │
└────────┘ │ spans of │
│ trace-xyz)│
└───────────┘💬 Comments
Quick Answer
Exemplars attach trace IDs to specific metric data points, allowing you to jump from a spike on a metric graph directly to the exact trace that caused it. Trace context propagation (W3C Trace Context headers) links logs to traces by injecting trace_id and span_id into log records. Together, these create a unified observability experience where you can navigate between signals seamlessly.
Detailed Answer
Think of the three observability signals as three different views of the same city. Metrics are the satellite view showing traffic density on every road (aggregate data). Traces are the GPS tracking of individual cars showing their exact route through the city (request-level detail). Logs are the dashcam recordings inside each car (event-level detail). Without correlation, you see traffic jams on the satellite but cannot find the specific cars causing them, and you have thousands of dashcam clips with no way to know which car or road they belong to. Correlation connects all three views so you can zoom from satellite to GPS to dashcam seamlessly.
Exemplars are the bridge between metrics and traces. When the payments-api records a histogram observation for request latency, the OpenTelemetry SDK can attach the current trace ID and span ID as an exemplar on that data point. In Prometheus terms, an exemplar is metadata attached to a histogram bucket that says 'this specific metric sample was produced during trace abc123.' When you see a latency spike on a Grafana dashboard, you click on the spike, Grafana reads the exemplar, extracts the trace ID, and links you directly to the trace in Tempo or Jaeger. This eliminates the manual process of searching for traces in a time window and guessing which one corresponds to the anomaly.
For metrics-to-traces correlation using exemplars, you need three things configured. First, the OpenTelemetry SDK must be configured with both a TracerProvider and a MeterProvider, and the metric instruments must have exemplar filters enabled. In newer SDK versions, the default exemplar filter is TraceBasedExemplarFilter, which automatically attaches trace context to metric recordings when a sampled trace is active. Second, the metrics backend must support exemplars: Prometheus added native exemplar support in 2.26, and Mimir supports them as well. Third, Grafana must have the exemplar data source configured to link from the Prometheus data source to the Tempo data source.
For traces-to-logs correlation, the OpenTelemetry SDK automatically injects trace_id and span_id into log records when the Logs SDK is configured alongside the Traces SDK. If you are using a logging framework like Python's logging, Java's Log4j2, or Go's slog, the OpenTelemetry instrumentation bridges inject trace context into log messages. When you view a trace in Grafana Tempo, you can click 'Logs for this span' and it queries Loki with the trace_id filter, showing exactly the log lines emitted during that span's execution. This works because both the trace and the log carry the same W3C Trace Context identifiers.
In a real production scenario at a company running the order-processing-service, checkout-service, and user-auth-service, the workflow looks like this: an SRE sees a p99 latency spike on the order-processing-service Grafana dashboard. They click the spike, see the exemplar, and jump to a specific trace in Tempo showing that the order-processing-service waited 8 seconds for the inventory-sync service. They click on the inventory-sync span, open its logs in Loki, and see a database connection pool exhaustion error. The entire investigation took 60 seconds instead of 20 minutes of manual log searching. This connected experience is what makes OpenTelemetry's unified data model fundamentally superior to siloed monitoring tools.
Code Example
# Python: Configure OpenTelemetry with exemplars and log correlation
# pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp
from opentelemetry import trace, metrics # Import trace and metrics APIs
from opentelemetry.sdk.trace import TracerProvider # SDK tracer provider
from opentelemetry.sdk.metrics import MeterProvider # SDK meter provider
from opentelemetry.sdk.trace.export import BatchSpanProcessor # Batch span export
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader # Periodic metric export
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter # OTLP trace exporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter # OTLP metric exporter
from opentelemetry.sdk.metrics.view import ExplicitBucketHistogramAggregation # Histogram config
import logging # Python standard logging
# Set up tracing with OTLP export
trace_provider = TracerProvider() # Create tracer provider
trace_provider.add_span_processor( # Add batch processor
BatchSpanProcessor(OTLPSpanExporter(endpoint="otel-collector:4317")) # Export to Collector
)
trace.set_tracer_provider(trace_provider) # Register globally
# Set up metrics with exemplar support
metric_reader = PeriodicExportingMetricReader( # Create metric reader
OTLPMetricExporter(endpoint="otel-collector:4317"), # Export to Collector
export_interval_millis=10000 # Export every 10 seconds
)
meter_provider = MeterProvider(metric_readers=[metric_reader]) # Create meter provider
metrics.set_meter_provider(meter_provider) # Register globally
# Create instruments
tracer = trace.get_tracer("order-processing-service") # Named tracer
meter = metrics.get_meter("order-processing-service") # Named meter
latency_histogram = meter.create_histogram( # Histogram for latency
name="order.processing.duration", # Metric name
unit="ms", # Unit in milliseconds
description="Time to process an order" # Human-readable description
)
# Log correlation: inject trace context into logs
logging.basicConfig( # Configure logging format
format='%(asctime)s %(levelname)s [trace_id=%(otelTraceID)s span_id=%(otelSpanID)s] %(message)s'
) # Include trace/span IDs in every log line
logger = logging.getLogger(__name__) # Get logger instance
# Application code: trace, metric, and log are auto-correlated
with tracer.start_as_current_span("process-order") as span: # Start a traced span
logger.info("Processing order #12345") # Log auto-includes trace_id
duration_ms = 245.7 # Measured processing time
latency_histogram.record(duration_ms) # Exemplar auto-attached with trace_id
span.set_attribute("order.id", "12345") # Add business context to span
# Grafana data source config for exemplar linking:
# Prometheus data source → Internal link → Tempo
# URL: ${__value.raw} (extracts trace_id from exemplar)
# Query: ${__value.raw} (Tempo trace lookup)Interview Tip
A junior engineer typically says 'we use traces for requests and metrics for dashboards.' For a senior role, explain the three-way correlation mechanism. Exemplars bridge metrics to traces by attaching trace_id to histogram observations, so clicking a latency spike on a Grafana panel takes you directly to the offending trace. Trace context bridges traces to logs by injecting W3C Trace Context headers (trace_id, span_id) into log records via the SDK's logging integration. Walk through a real incident: see a metric spike, click the exemplar, jump to the trace, open the slow span's logs, find the root cause. Mention that exemplar support requires Prometheus 2.26 or later and that Grafana needs internal linking configured between data sources.
◈ Architecture Diagram
Three-Signal Correlation:
┌─── Metrics (Prometheus/Mimir) ───┐
│ │
│ order.processing.duration │
│ ┌─────────────────────────────┐ │
│ │ ● │ │
│ │ ╱ ╲ ← spike here │ │
│ │ ╱ ╲ exemplar attached │ │
│ │╱ ╲──────────────────────│ │
│ └─────────────────────────────┘ │
│ │ click exemplar │
└────────┼──────────────────────────┘
│ trace_id=abc123
▼
┌─── Traces (Tempo/Jaeger) ─────────┐
│ │
│ trace_id: abc123 │
│ ┌─────────────────────────────┐ │
│ │ order-processing [245ms] │ │
│ │ └─ inventory-sync [8200ms] │ │
│ │ └─ db-query [8100ms] │ │
│ └─────────────────────────────┘ │
│ │ click span │
└────────┼───────────────────────────┘
│ trace_id + span_id filter
▼
┌─── Logs (Loki) ───────────────────┐
│ │
│ [trace_id=abc123 span_id=def456] │
│ ERROR: connection pool exhausted │
│ WARN: retry attempt 3 of 5 │
│ ERROR: timeout after 8000ms │
└────────────────────────────────────┘
Flow: Metric spike → Exemplar → Trace → Logs
60-second investigation ✓💬 Comments
Quick Answer
OTLP is OpenTelemetry's native wire protocol for transmitting traces, metrics, and logs in a single unified format. Unlike Jaeger's Thrift/gRPC and Zipkin's JSON protocols which only handle traces, OTLP supports all three signals natively, uses Protocol Buffers for efficient serialization, and is the only protocol guaranteed to preserve full OpenTelemetry semantic conventions without data loss.
Detailed Answer
Think of telemetry protocols like languages for shipping packages. Jaeger's protocol is like a specialized courier that only delivers letters (traces) using a specific packaging format (Thrift). Zipkin's protocol is another courier for letters but uses different packaging (JSON). OTLP is like a universal shipping system that delivers letters (traces), packages (metrics), and large crates (logs) using the same standardized container format. If you send a richly labeled package via the Jaeger courier, some labels might fall off because Jaeger's format does not support them. OTLP preserves every label because the format was designed for it.
OTLP is defined as a Protocol Buffers schema with three sub-protocols: one for traces, one for metrics, and one for logs. It supports two transport mechanisms: gRPC (on port 4317 by default) and HTTP/protobuf (on port 4318). The gRPC transport provides streaming, bidirectional communication, and built-in backpressure handling. The HTTP transport is simpler and works better through proxies, load balancers, and firewalls that may not support HTTP/2. Both transports serialize data identically using Protocol Buffers, which is significantly more compact than JSON (typically 3-5x smaller on the wire) and faster to serialize and deserialize.
The key advantage of OTLP over Jaeger and Zipkin protocols is semantic fidelity. OpenTelemetry's data model includes rich concepts like span events (structured logs attached to spans), span links (connecting causally related traces that are not parent-child), resource attributes (describing the entity producing telemetry), and instrumentation scope (identifying which library generated the data). When you export via OTLP, all of these concepts are preserved exactly. When you export via the Jaeger exporter, span events get converted to Jaeger logs (losing some structure), span links may be partially supported, and instrumentation scope is dropped. When you export via the Zipkin exporter, the data loss is even greater because Zipkin's model is simpler: no native span events, no span links, and annotations replace structured attributes.
For teams running services like payments-api, user-auth-service, and checkout-service on the OpenTelemetry SDK, the recommendation is clear: always use OTLP as the primary export protocol. Configure SDKs to send OTLP to the OpenTelemetry Collector, then let the Collector export to whatever backend you use. If your backend is Jaeger, the Collector's Jaeger exporter handles the translation. This means you never compromise your SDK-side data model for a backend's protocol limitations, and switching backends requires only a Collector configuration change, not an application code change.
Performance-wise, OTLP with gRPC transport is the fastest option for high-throughput environments. In benchmarks, OTLP/gRPC handles 2-3x more spans per second than the Jaeger Thrift compact protocol on the same hardware, primarily due to Protocol Buffers' efficient binary serialization versus Thrift's overhead. OTLP/HTTP is slightly slower than gRPC but still faster than Zipkin's JSON format. The batch processing built into the OpenTelemetry SDK's BatchSpanProcessor amortizes connection overhead across thousands of spans, making OTLP even more efficient in practice. For the order-processing-service handling 10,000 requests per second, the difference between OTLP and Zipkin JSON can mean the difference between 2% and 8% CPU overhead for telemetry export.
Code Example
# OTLP gRPC export from application SDK (environment variables)
# Configure payments-api to export via OTLP gRPC
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 # Collector gRPC endpoint
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc # Use gRPC transport
export OTEL_SERVICE_NAME=payments-api # Service identifier
export OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production # Resource attributes
# OTLP HTTP export (alternative, better for proxies/firewalls)
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 # Collector HTTP endpoint
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf # Use HTTP+protobuf transport
# Collector config: receive OTLP, export to multiple backends
# otel-collector-config.yaml
receivers:
otlp: # Receive OTLP from SDKs
protocols:
grpc: # Port 4317 for gRPC clients
endpoint: 0.0.0.0:4317
http: # Port 4318 for HTTP clients
endpoint: 0.0.0.0:4318
jaeger: # Also accept legacy Jaeger format
protocols:
thrift_compact: # Jaeger agent UDP port 6831
endpoint: 0.0.0.0:6831
grpc: # Jaeger gRPC port 14250
endpoint: 0.0.0.0:14250
zipkin: # Also accept legacy Zipkin format
endpoint: 0.0.0.0:9411 # Zipkin HTTP port
exporters:
otlp/tempo: # Export to Tempo via OTLP (preferred)
endpoint: tempo.observability:4317 # Full fidelity, no data loss
jaeger: # Export to Jaeger (some data loss)
endpoint: jaeger.observability:14250 # Span links partially supported
zipkin: # Export to Zipkin (most data loss)
endpoint: http://zipkin.observability:9411/api/v2/spans # No span events
service:
pipelines:
traces:
receivers: [otlp, jaeger, zipkin] # Accept all protocols
processors: [] # No processing needed
exporters: [otlp/tempo] # Export via OTLP for full fidelity
# Compare wire sizes (approximate for 1000 spans):
# OTLP/protobuf: ~180KB (binary, compact) # Most efficient
# Jaeger/Thrift: ~350KB (binary, verbose) # 2x larger
# Zipkin/JSON: ~550KB (text, verbose) # 3x largerInterview Tip
A junior engineer typically says 'OTLP is just another format like Jaeger or Zipkin.' For a senior role, explain the fundamental difference: OTLP is the only protocol that carries the full OpenTelemetry data model without loss. Jaeger and Zipkin protocols predate OpenTelemetry and cannot represent span links, instrumentation scope, or structured span events faithfully. The architectural insight is that SDKs should always export OTLP to the Collector, and the Collector handles protocol translation to whatever backend you use. This decouples your application from your backend choice. Mention the performance advantage of Protocol Buffers over Thrift and JSON, and cite the two transport options: gRPC for high throughput with streaming and backpressure, HTTP/protobuf for environments with proxy or firewall constraints.
◈ Architecture Diagram
Protocol Comparison:
┌─── OTLP ─────────────────────────────┐
│ Signals: Traces ✓ Metrics ✓ Logs ✓ │
│ Format: Protocol Buffers (binary) │
│ Transport: gRPC (4317) HTTP (4318) │
│ Fidelity: Full OTel data model ✓ │
│ Span Events: ✓ Span Links: ✓ │
│ Wire Size: ~180KB / 1000 spans │
└───────────────────────────────────────┘
┌─── Jaeger ────────────────────────────┐
│ Signals: Traces ✓ Metrics ✗ Logs ✗ │
│ Format: Thrift / gRPC │
│ Fidelity: Partial data loss │
│ Span Events: ● (as Jaeger logs) │
│ Span Links: ● (partial) │
│ Wire Size: ~350KB / 1000 spans │
└───────────────────────────────────────┘
┌─── Zipkin ────────────────────────────┐
│ Signals: Traces ✓ Metrics ✗ Logs ✗ │
│ Format: JSON │
│ Fidelity: Significant data loss │
│ Span Events: ✗ Span Links: ✗ │
│ Wire Size: ~550KB / 1000 spans │
└───────────────────────────────────────┘
Recommended Architecture:
┌──────────┐ OTLP ┌───────────┐ OTLP ┌──────┐
│ SDK │────────→│ Collector │───────→│ Tempo│
│(payments │ full │ translates│ full │ │
│ -api) │ data │ if needed │ data │ │
└──────────┘ └───────────┘ └──────┘
Never export Jaeger/Zipkin
from SDK directly ✗💬 Comments
Quick Answer
High-cardinality attributes like user IDs, request IDs, or session tokens create millions of unique metric time series, exploding storage costs and query latency. Control this by using Views in the SDK to drop or aggregate attributes on metrics, using the attributes processor in the Collector to remove high-cardinality span attributes, and by separating high-cardinality data into traces while keeping metrics low-cardinality.
Detailed Answer
Think of telemetry cost control like managing a library's catalog system. If every book checkout creates a unique catalog card with the borrower's full biography, shoe size, and breakfast preference, the card catalog becomes unmanageable. Instead, the catalog card records the book title, checkout date, and a reference number (trace ID) that links to the borrower's full file stored separately. In OpenTelemetry terms, metrics are the catalog cards (low cardinality, aggregate data) and traces are the full borrower files (high cardinality, detailed data). The mistake teams make is putting the full biography on every catalog card.
Cardinality is the number of unique combinations of attribute values on a metric. A metric like http_request_duration with attributes method (4 values), status_code (5 values), and endpoint (20 values) produces 4 times 5 times 20 equals 400 time series, which is manageable. Add user_id with 1 million unique users, and suddenly you have 400 million time series. At roughly 2 bytes per sample and 15-second scrape intervals, that is 3.2 GB per hour just for one metric. Backends like Prometheus, Mimir, or Datadog charge proportionally to series count, so a single high-cardinality attribute can multiply your observability bill by 1000x.
The first defense is at the SDK level using OpenTelemetry's Views API. A View lets you customize how a metric instrument's measurements are aggregated and which attributes are retained. For the payments-api service, you might define a View that keeps only method, status_code, and route on the HTTP request duration histogram, explicitly dropping user_id, session_id, and request_id. These high-cardinality identifiers remain on spans (where each span is a single event, not an aggregated time series), so you can still find individual user requests via traces. Views are powerful because they operate at the SDK level before data ever leaves the application, reducing both network bandwidth and backend cost.
The second defense is at the Collector level using processors. The attributes processor can delete, hash, or truncate specific attributes from spans and metrics. The filter processor can drop entire metric series matching a pattern. The transform processor can use OTTL (OpenTelemetry Transformation Language) to conditionally modify attributes, for example hashing user_id into 100 buckets to reduce cardinality while preserving some analytical value. For the order-processing-service generating 50,000 spans per second, the Collector can use the probabilistic sampler to reduce volume while the attributes processor strips attributes like db.statement (which often contains unique query parameters).
The third defense is architectural: use the right signal for the right purpose. Metrics should answer aggregate questions (what is the p99 latency of checkout-service?) with low-cardinality attributes. Traces should answer specific questions (why was this particular request slow?) with high-cardinality attributes. Logs should capture detailed event data. When a developer wants to track per-user latency, guide them to record user_id as a span attribute (which has trace-level cardinality) and use exemplars to link from the aggregate metric to specific traces. This pattern gives you both the aggregate view and the per-user detail without the cost explosion.
Monitoring your telemetry pipeline's cost is itself an observability challenge. Track otelcol_processor_dropped_metric_points, otelcol_exporter_sent_spans, and backend-specific ingestion metrics. Set up alerts when cardinality exceeds thresholds, and implement a governance process where new instrumentation must be reviewed for cardinality impact before deployment, much like code review for the inventory-sync service would catch a developer adding order_line_item_sku as a metric attribute.
Code Example
# Python SDK: Use Views to control metric cardinality
# Only keep low-cardinality attributes on metrics
from opentelemetry.sdk.metrics import MeterProvider # Meter provider for metrics
from opentelemetry.sdk.metrics.view import View # View for attribute control
from opentelemetry.sdk.metrics.view import ExplicitBucketHistogramAggregation # Histogram buckets
# Define a View that drops high-cardinality attributes from HTTP metrics
http_duration_view = View( # Create view for HTTP duration
instrument_name="http.server.request.duration", # Match this specific metric
attribute_keys=["http.request.method", "http.response.status_code", "http.route"], # Keep ONLY these
# user_id, session_id, request_id are automatically excluded
aggregation=ExplicitBucketHistogramAggregation( # Custom histogram buckets
boundaries=[5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000] # ms boundaries
),
)
# Register the view with the MeterProvider
meter_provider = MeterProvider(views=[http_duration_view]) # Apply view globally
# Collector config: strip high-cardinality attributes
# otel-collector-config.yaml
processors:
attributes/strip-high-cardinality: # Remove dangerous attributes
actions:
- key: user.id # Drop user ID from metrics
action: delete
- key: session.id # Drop session ID
action: delete
- key: db.statement # Drop SQL queries (unique per call)
action: delete
- key: http.url # Hash full URLs to reduce cardinality
action: hash # SHA256 hash preserves grouping
transform/bucket-user-ids: # OTTL: bucket instead of drop
metric_statements:
- context: datapoint # Operate on metric data points
statements:
- replace_pattern(attributes["user.region"], # Normalize region strings
"us-(east|west)-[0-9]+", "us-$$1") # us-east-1 becomes us-east
filter/drop-debug-metrics: # Drop noisy metrics entirely
metrics:
exclude:
match_type: regexp # Use regex matching
metric_names: # Drop these metric families
- "debug\..*" # All debug metrics
- "test\..*" # All test metrics
service:
pipelines:
metrics:
receivers: [otlp] # Receive from SDKs
processors: # Chain processors
- attributes/strip-high-cardinality # Strip attributes first
- transform/bucket-user-ids # Bucket remaining
- filter/drop-debug-metrics # Drop unwanted metrics
exporters: [prometheusremotewrite] # Export cleaned metrics
# Cardinality impact calculation:
# Before: method(4) x status(5) x route(20) x user_id(1M) = 400M series ✗
# After: method(4) x status(5) x route(20) = 400 series ✓
# Cost reduction: 99.9999%Interview Tip
A junior engineer typically says 'just add all the labels we need to the metrics.' For a senior role, explain the cardinality explosion problem: adding a 1-million-unique-value attribute to a metric multiplies the series count by one million, directly impacting storage cost and query performance. Present the three-layer defense: SDK Views that explicitly whitelist attributes on metrics, Collector processors that strip or hash high-cardinality attributes, and architectural separation where high-cardinality data lives in traces and low-cardinality data lives in metrics, connected via exemplars. Calculate the cost impact: a 400-series metric becomes 400-million-series when user_id is added. Mention the governance angle, where instrumentation changes are code-reviewed for cardinality impact before deployment, showing you think about operational sustainability.
◈ Architecture Diagram
Cardinality Explosion:
┌─── Low Cardinality (safe) ──────────┐
│ http.request.duration │
│ │
│ method × status × route │
│ (4 vals) (5 vals) (20 vals) │
│ = 400 time series ✓ │
└──────────────────────────────────────┘
┌─── High Cardinality (dangerous) ────┐
│ http.request.duration │
│ │
│ method × status × route × user_id │
│ (4) (5) (20) (1,000,000) │
│ = 400,000,000 time series ✗ │
│ = $$$$$$ per month │
└──────────────────────────────────────┘
Three-Layer Defense:
┌──────────┐ ┌───────────┐ ┌──────────┐
│ SDK │───→│ Collector │───→│ Backend │
│ │ │ │ │ │
│ Views: │ │ Processor:│ │ Limits: │
│ whitelist│ │ delete/ │ │ per-tenant│
│ attrs │ │ hash attrs│ │ series │
│ │ │ │ │ cap │
└──────────┘ └───────────┘ └──────────┘
Layer 1 Layer 2 Layer 3
Right Signal for Right Data:
┌─────────────────────────────────────┐
│ Metrics: aggregate, low-cardinality │
│ "p99 latency of checkout = 450ms" │
│ │ exemplar (trace_id) │
│ ▼ │
│ Traces: per-request, high-cardinality│
│ "user abc123 took 2300ms" │
└─────────────────────────────────────┘💬 Comments
Quick Answer
Custom span processors implement the SpanProcessor interface with on_start and on_end methods, allowing you to enrich spans with business context, filter sensitive data, or route spans to different exporters based on attributes. Custom processors sit in the SDK pipeline between instrumentation and export, giving you full control over span lifecycle without modifying application code.
Detailed Answer
Think of span processors like customs checkpoints at an airport. Every piece of luggage (span) passes through the checkpoint before boarding the plane (exporter). The default SimpleSpanProcessor is like a checkpoint that waves everything through immediately. The BatchSpanProcessor is like a checkpoint that groups luggage onto pallets for efficient loading. A custom span processor is like adding your own checkpoint that inspects each piece of luggage: stamping it with a destination tag, removing prohibited items (sensitive data), routing oversized luggage to a special handler, or rejecting luggage entirely based on specific criteria.
The SpanProcessor interface in OpenTelemetry has four methods. on_start is called when a span begins, giving you a mutable reference to add or modify attributes before any application code runs within that span's context. on_end is called when a span finishes, with the completed span including its duration, status, events, and all attributes. shutdown is called once during application teardown for cleanup. force_flush ensures all pending spans are exported. Most custom logic lives in on_start and on_end. You can chain multiple processors together, and each span passes through every processor in order.
A common production use case at companies running the payments-api and checkout-service is a PII redaction processor. The on_end method inspects every span attribute and event attribute, replacing values matching patterns like credit card numbers, Social Security numbers, or email addresses with redacted placeholders. This ensures that even if a developer accidentally logs sensitive data as a span attribute during checkout-service instrumentation, it never reaches the tracing backend. Another use case is a business-context enrichment processor: the on_start method reads a thread-local or context variable containing the current customer tier (premium, standard, free) and adds it as a span attribute, enabling the SRE team to filter traces by customer tier without requiring every service to manually add the attribute.
For the order-processing-service, a routing processor can examine the service.name and span status to decide which exporter receives the span. Error spans go to a high-retention exporter (keeping them for 30 days), while successful spans go to a low-retention exporter (keeping them for 7 days). This is implemented by wrapping multiple exporters and calling the appropriate one's export method from within the processor's on_end. This pattern is sometimes called a fan-out processor and gives you fine-grained cost control.
Performance is critical because custom processors run in the hot path of every request. The on_start method is called synchronously within the request thread, so it must be fast, ideally under 1 microsecond. Heavy operations like database lookups or HTTP calls must never happen in on_start. The on_end method in a BatchSpanProcessor runs asynchronously in a background thread, so it has slightly more latitude, but should still avoid blocking I/O. If your custom processor needs external data, cache it locally and refresh periodically. Always benchmark your processor under realistic load: a processor adding 50 microseconds per span at 10,000 requests per second adds 500 milliseconds of cumulative overhead per second across the fleet.
Testing custom processors requires the OpenTelemetry SDK's InMemorySpanExporter, which captures exported spans in a list for assertion. You create a TracerProvider with your custom processor chained with the InMemorySpanExporter, generate test spans, and verify that attributes were added, redacted, or filtered as expected. This is essential for the PII redaction processor where missing a pattern means a compliance violation for the user-auth-service.
Code Example
# Python: Custom SpanProcessor for PII redaction
import re # Regular expressions for pattern matching
from opentelemetry.sdk.trace import SpanProcessor # Base SpanProcessor interface
from opentelemetry.sdk.trace import TracerProvider # Provider to register processor
from opentelemetry.sdk.trace.export import BatchSpanProcessor # Standard batch processor
from opentelemetry.sdk.trace.export import ConsoleSpanExporter # Console exporter for demo
from opentelemetry.sdk.trace.export import InMemorySpanExporter # For testing
class PIIRedactionProcessor(SpanProcessor): # Custom processor class
"""Redacts PII from span attributes before export.""" # Docstring for the processor
PII_PATTERNS = { # Patterns to detect and redact
'credit_card': re.compile(r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b'), # CC numbers
'ssn': re.compile(r'\b\d{3}-\d{2}-\d{4}\b'), # Social Security numbers
'email': re.compile(r'\b[\w.-]+@[\w.-]+\.\w+\b'), # Email addresses
}
def on_start(self, span, parent_context=None): # Called when span begins
pass # No action needed at start
def on_end(self, span): # Called when span completes
for key, value in span.attributes.items(): # Iterate all span attributes
if isinstance(value, str): # Only check string values
for pii_type, pattern in self.PII_PATTERNS.items(): # Check each PII pattern
if pattern.search(value): # If PII found in value
span._attributes[key] = f"[REDACTED-{pii_type}]" # Replace with marker
def shutdown(self): # Called at application shutdown
pass # Cleanup if needed
def force_flush(self, timeout_millis=None): # Force export pending spans
return True # Nothing to flush
class CustomerTierEnrichmentProcessor(SpanProcessor): # Enrichment processor
"""Adds customer tier from context to every span.""" # Business context enrichment
def __init__(self, tier_cache): # Accept a tier lookup cache
self._tier_cache = tier_cache # Store reference to cache
def on_start(self, span, parent_context=None): # Called at span creation
customer_id = span.attributes.get('customer.id') # Read customer ID if present
if customer_id: # If customer ID exists
tier = self._tier_cache.get(customer_id, 'unknown') # Look up tier from cache
span.set_attribute('customer.tier', tier) # Add tier to span attributes
def on_end(self, span): # Called at span completion
pass # No action at end
def shutdown(self): # Cleanup handler
pass
def force_flush(self, timeout_millis=None): # Flush handler
return True
# Wire up the processor chain
provider = TracerProvider() # Create tracer provider
provider.add_span_processor(PIIRedactionProcessor()) # First: redact PII
provider.add_span_processor(CustomerTierEnrichmentProcessor( # Second: enrich with tier
tier_cache={'cust-001': 'premium', 'cust-002': 'standard'} # Pre-loaded cache
))
provider.add_span_processor(BatchSpanProcessor( # Third: batch and export
ConsoleSpanExporter() # Export to console (use OTLP in prod)
))
# Testing the PII processor
def test_pii_redaction(): # Unit test function
exporter = InMemorySpanExporter() # Capture spans in memory
test_provider = TracerProvider() # Test provider
test_provider.add_span_processor(PIIRedactionProcessor()) # Add PII processor
test_provider.add_span_processor(BatchSpanProcessor(exporter)) # Add in-memory exporter
tracer = test_provider.get_tracer('test') # Get test tracer
with tracer.start_as_current_span('checkout') as span: # Create test span
span.set_attribute('user.email', '[email protected]') # Set PII attribute
test_provider.force_flush() # Flush to exporter
assert exporter.get_finished_spans()[0].attributes['user.email'] == '[REDACTED-email]' # Verify redactionInterview Tip
A junior engineer typically says 'we add attributes in the application code where we create spans.' For a senior role, explain the SpanProcessor interface and its four lifecycle methods. Demonstrate understanding of where processors sit in the pipeline: between instrumentation and export, allowing cross-cutting concerns like PII redaction and business context enrichment without modifying application code. Emphasize performance constraints: on_start runs synchronously in the request thread and must be sub-microsecond, so never do I/O there. Describe the chaining model where multiple processors execute in order. Show testing methodology with InMemorySpanExporter. Mention the fan-out pattern for routing error spans to high-retention storage while normal spans go to low-retention, demonstrating cost-aware architecture.
◈ Architecture Diagram
SpanProcessor Pipeline:
┌──────────────────┐
│ Application Code │
│ │
│ tracer.start_ │
│ as_current_span │
└────────┬─────────┘
│ span created
▼
┌──────────────────┐
│ PIIRedaction │ on_start: (no-op)
│ Processor │ on_end: scan attributes
│ │ redact CC/SSN/email
└────────┬─────────┘
│
▼
┌──────────────────┐
│ CustomerTier │ on_start: lookup tier
│ Enrichment │ add customer.tier
│ Processor │ on_end: (no-op)
└────────┬─────────┘
│
▼
┌──────────────────┐
│ BatchSpan │ Batches spans
│ Processor │ Exports async
└────────┬─────────┘
│
▼
┌──────────────────┐
│ OTLP Exporter │
│ → Collector │
└──────────────────┘
Fan-Out Pattern:
┌──────────────────┐
│ Routing │
│ Processor │
│ │
│ ERROR span? ─────┼──→ High-Retention Exporter
│ │ (30 days)
│ OK span? ────────┼──→ Low-Retention Exporter
│ │ (7 days)
└──────────────────┘💬 Comments
Quick Answer
The OpenTelemetry Operator is a Kubernetes operator that manages Collector instances via the OpenTelemetryCollector CRD and automatically injects instrumentation into application pods via the Instrumentation CRD. It supports auto-instrumentation for Java, Python, Node.js, .NET, and Go, meaning applications get tracing without code changes by adding a single annotation to the pod spec.
Detailed Answer
Think of the OpenTelemetry Operator like a building superintendent who manages the heating system and installs smart thermometers in every apartment. Without the superintendent, every tenant (development team) would need to buy, install, and maintain their own thermometer (SDK instrumentation) and connect it to the building's heating system (Collector). With the superintendent, tenants just put a sign on their door saying 'please install a thermometer' (add an annotation), and the superintendent handles everything: installs the right thermometer model, wires it to the central system, and manages the heating infrastructure.
The OpenTelemetry Operator is installed via Helm and runs as a controller in the cluster. It watches for two custom resources: OpenTelemetryCollector and Instrumentation. The OpenTelemetryCollector CRD lets you declare Collector deployments in Kubernetes-native YAML instead of manually managing Deployments, DaemonSets, ConfigMaps, and Services. You specify the mode (deployment, daemonset, statefulset, or sidecar), the Collector configuration, resource limits, replicas, and autoscaling parameters. The Operator creates and manages all underlying Kubernetes objects, handles configuration updates with rolling restarts, and supports version upgrades.
The Instrumentation CRD is the most powerful feature. It defines auto-instrumentation configuration for a specific language: which OTLP endpoint to send to, which propagators to use (W3C Trace Context, B3), resource attributes to add, and sampler configuration. Once created, you enable instrumentation for any pod by adding an annotation like instrumentation.opentelemetry.io/inject-java: "true" to the pod spec. The Operator's mutating webhook intercepts pod creation and injects an init container that downloads the appropriate instrumentation agent (like the Java JAVAAGENT JAR), modifies environment variables to activate it, and mounts the agent into the application container. The payments-api service written in Java gets full distributed tracing, metric collection, and log correlation without changing a single line of application code.
For a production Kubernetes environment running the checkout-service, order-processing-service, user-auth-service, and inventory-sync, the typical setup involves three Operator-managed components. First, an OpenTelemetryCollector in daemonset mode for agent-level collection on every node, configured with the k8sattributes processor for Kubernetes metadata enrichment. Second, an OpenTelemetryCollector in deployment mode with 3 replicas for the gateway tier, configured with tail-based sampling and multi-backend export. Third, multiple Instrumentation resources, one per language (Java, Python, Node.js), each configured with the appropriate SDK settings. Teams onboard by adding a single annotation to their Deployment spec, and within minutes their service appears in the tracing backend.
The sidecar mode is worth special mention. When you set mode: sidecar on an OpenTelemetryCollector resource and annotate a pod with sidecar.opentelemetry.io/inject: "true", the Operator injects a Collector container directly into the application pod. This gives each pod its own dedicated Collector, which is useful for multi-tenant clusters where teams want isolated telemetry pipelines, or for services with extremely high telemetry volume that would overwhelm a shared DaemonSet agent. The trade-off is higher resource consumption since every pod runs an additional container. For the inventory-sync service that generates 100,000 spans per minute during peak sync operations, a sidecar Collector prevents it from starving other services of telemetry bandwidth on the node.
Operational considerations include RBAC configuration (the Operator needs permissions to mutate pods and create resources), certificate management for webhook TLS, and careful resource limit tuning on both Collectors and injected init containers. Upgrades should be tested in a staging cluster first, as Operator version changes can affect how instrumentation agents are injected.
Code Example
# Install the OpenTelemetry Operator via Helm
helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts # Add OTel Helm repo
helm install otel-operator open-telemetry/opentelemetry-operator \ # Install the operator
--namespace observability \ # Into observability namespace
--set admissionWebhooks.certManager.enabled=true \ # Use cert-manager for TLS
--set manager.collectorImage.tag=0.96.0 # Pin Collector version
# Create Collector in DaemonSet mode (agent tier)
# otel-collector-agent.yaml
apiVersion: opentelemetry.io/v1beta1 # OTel Operator API version
kind: OpenTelemetryCollector # CRD kind
metadata:
name: otel-agent # Collector instance name
namespace: observability # Deploy in observability ns
spec:
mode: daemonset # One per node
resources:
limits:
cpu: 500m # CPU limit per agent pod
memory: 512Mi # Memory limit per agent
config:
receivers:
otlp: # Receive OTLP from apps
protocols:
grpc:
endpoint: 0.0.0.0:4317 # Standard gRPC port
processors:
k8sattributes: # Enrich with K8s metadata
extract:
metadata: [k8s.pod.name, k8s.namespace.name] # Add pod and namespace
batch: # Batch for efficiency
send_batch_size: 1024 # Batch size
exporters:
otlp: # Forward to gateway
endpoint: otel-gateway-collector.observability:4317 # Gateway service
service:
pipelines:
traces: # Trace pipeline
receivers: [otlp]
processors: [k8sattributes, batch]
exporters: [otlp]
# Create Instrumentation resource for Java auto-instrumentation
# otel-instrumentation-java.yaml
apiVersion: opentelemetry.io/v1alpha1 # Instrumentation API version
kind: Instrumentation # Instrumentation CRD
metadata:
name: java-instrumentation # Instrumentation name
namespace: production # Namespace for apps
spec:
exporter:
endpoint: http://otel-agent-collector.observability:4317 # Send to agent Collector
propagators:
- tracecontext # W3C Trace Context
- baggage # W3C Baggage
sampler:
type: parentbased_traceidratio # Parent-based sampling
argument: "1.0" # 100% (tail sample at gateway)
java:
image: ghcr.io/open-telemetry/opentelemetry-operator/autoinstrumentation-java:2.1.0 # Java agent image
# Annotate application Deployment to enable auto-instrumentation
# payments-api-deployment.yaml
apiVersion: apps/v1 # Kubernetes Deployment
kind: Deployment
metadata:
name: payments-api # Service name
namespace: production # Production namespace
spec:
template:
metadata:
annotations:
instrumentation.opentelemetry.io/inject-java: "true" # Enable Java auto-instrumentation
spec:
containers:
- name: payments-api # Application container
image: myregistry/payments-api:v2.3.1 # Application image
# No OTel SDK code changes needed! # Instrumentation is injectedInterview Tip
A junior engineer typically says 'install the SDK in every application and configure it to send to the Collector.' For a senior role, explain the Operator's two CRDs: OpenTelemetryCollector for managing Collector infrastructure as Kubernetes-native resources (supporting daemonset, deployment, statefulset, and sidecar modes), and Instrumentation for zero-code-change auto-instrumentation via pod annotation injection. Walk through how the mutating admission webhook intercepts pod creation, injects an init container with the language-specific agent, and modifies environment variables. Discuss when to use sidecar mode versus daemonset mode: sidecar for high-volume or multi-tenant isolation, daemonset for cluster-wide efficiency. Mention RBAC requirements, cert-manager integration for webhook TLS, and the operational pattern of testing Operator upgrades in staging before production.
◈ Architecture Diagram
OpenTelemetry Operator Architecture: ┌─── Kubernetes Cluster ─────────────────────────┐ │ │ │ ┌──────────────────────┐ │ │ │ OTel Operator │ │ │ │ (Controller Manager) │ │ │ │ │ │ │ │ Watches: │ │ │ │ ● OTelCollector CRD │ │ │ │ ● Instrumentation CRD│ │ │ │ ● Pod annotations │ │ │ └──────────┬───────────┘ │ │ │ manages │ │ ┌───────┼───────────────┐ │ │ ▼ ▼ ▼ │ │ ┌───────┐ ┌───────┐ ┌──────────┐ │ │ │Agent │ │Agent │ │Gateway │ │ │ │Coll. │ │Coll. │ │Collector │ │ │ │(node1)│ │(node2)│ │(3 replicas)│ │ │ └───┬───┘ └───┬───┘ └────┬─────┘ │ │ │ │ │ │ │ ┌───▼─────────▼───┐ │ export │ │ │ Auto-Instrumented│ ▼ │ │ │ Pods: │ ┌─────────┐ │ │ │ │ │ Tempo │ │ │ │ ┌─────────────┐ │ │ Mimir │ │ │ │ │payments-api │ │ │ Loki │ │ │ │ │ +init-cont │ │ └─────────┘ │ │ │ │ +java-agent │ │ │ │ │ │ annotation: │ │ │ │ │ │ inject-java │ │ │ │ │ │ = true │ │ │ │ │ └─────────────┘ │ │ │ └─────────────────┘ │ └─────────────────────────────────────────────────┘ Auto-Instrumentation Injection: ┌──────────┐ annotate ┌──────────┐ inject ┌──────────┐ │ Dev adds │──────────→│ Webhook │────────→│ Pod runs │ │ annotation│ │ mutates │ │ with │ │ to Deploy │ │ pod spec │ │ OTel │ │ │ │ adds init│ │ agent │ │ │ │ container│ │ active │ └──────────┘ └──────────┘ └──────────┘
💬 Comments
Quick Answer
Missing spans and broken traces are usually caused by context propagation failures where trace context headers (traceparent) are not passed between services, misconfigured or mismatched propagators, SDK initialization issues, or sampling decisions dropping spans. Troubleshoot by checking propagator configuration consistency, verifying W3C Trace Context headers in inter-service requests, examining Collector logs for dropped spans, and using debug exporters to inspect the telemetry pipeline.
Detailed Answer
Think of distributed tracing like a relay race where runners pass a baton. The baton is the trace context (trace ID and parent span ID). If any runner drops the baton or fails to pass it, the chain breaks. A broken trace in your tracing backend looks like disconnected fragments: the checkout-service shows a root span, and the payments-api shows a separate root span, when they should be parent-child. The trace ID is different in each, meaning context was lost at the HTTP call between them. Troubleshooting means finding where the baton was dropped.
The most common cause of broken traces is propagator mismatch. OpenTelemetry supports multiple propagation formats: W3C Trace Context (traceparent header), B3 (X-B3-TraceId headers, used by Zipkin), Jaeger (uber-trace-id header), and AWS X-Ray (X-Amzn-Trace-Id header). If the checkout-service is configured with W3C propagators and sends a traceparent header, but the payments-api is configured with B3 propagators and looks for X-B3-TraceId, the payments-api will not find any incoming context and will start a new trace. The fix is ensuring every service uses the same propagator configuration. The environment variable OTEL_PROPAGATORS=tracecontext,baggage should be set consistently across all services. In the Collector, the propagator configuration is separate from SDK propagation and only matters if the Collector is acting as a proxy.
The second common cause is middleware or framework interceptors that do not propagate context. If the order-processing-service makes an HTTP call to the inventory-sync service using a custom HTTP client wrapper that does not inject trace context headers, the chain breaks at that call. OpenTelemetry's auto-instrumentation libraries handle this for standard HTTP clients (requests, urllib3, axios, HttpClient), but custom wrappers, message queue producers, or async task dispatchers often need manual context injection. You can verify by inspecting the outgoing HTTP headers: if traceparent is missing, the instrumentation library is not wrapping the HTTP client, or the span context is not active in the current thread or async context.
The third cause is sampling. If the checkout-service uses a head-based sampler at 10% and the payments-api uses a separate head-based sampler at 10%, the probability that both services sample the same trace is only 1% (10% times 10%). The fix is to use a parent-based sampler: the root service makes the sampling decision, and all downstream services respect the parent's decision by reading the sampled flag from the traceparent header. Set OTEL_TRACES_SAMPLER=parentbased_traceidratio and OTEL_TRACES_SAMPLER_ARG=0.1 on every service.
For systematic troubleshooting, follow a five-step process. Step one: enable debug logging on the SDK by setting OTEL_LOG_LEVEL=debug, which logs every span creation, context injection, and export attempt. Step two: add the debug exporter to the Collector pipeline, which prints every received span to stdout, letting you verify spans actually arrive at the Collector. Step three: check the Collector's internal metrics: otelcol_receiver_accepted_spans versus otelcol_receiver_refused_spans tells you if spans are being rejected, and otelcol_exporter_send_failed_spans tells you if export is failing. Step four: manually inspect HTTP headers between services using curl or tcpdump to verify traceparent headers are present and correctly formatted. Step five: compare trace IDs across services in your backend: if two services show different trace IDs for what should be the same request, the break is at the call between them.
Async context loss is a subtle variant that affects Node.js and Python applications heavily. In Node.js, if the user-auth-service uses callbacks instead of async/await, the OpenTelemetry context (stored in AsyncLocalStorage) may be lost when the callback executes in a different async context. In Python, using thread pools or asyncio.run_in_executor can lose the context unless you explicitly propagate it. The symptom is spans that exist but are orphaned, having no parent despite being generated within what should be a child operation. The fix involves using context.with_current_context wrappers around callbacks and executor submissions.
Code Example
# Step 1: Enable SDK debug logging to see span lifecycle
export OTEL_LOG_LEVEL=debug # Enable debug logs in SDK
export OTEL_TRACES_EXPORTER=console,otlp # Export to console AND Collector
export OTEL_PROPAGATORS=tracecontext,baggage # Ensure W3C propagators
export OTEL_TRACES_SAMPLER=parentbased_traceidratio # Parent-based sampling
export OTEL_TRACES_SAMPLER_ARG=1.0 # 100% for debugging
# Step 2: Verify traceparent header is present between services
curl -v http://payments-api:8080/api/v1/charge \ # Call payments-api
-H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" # Manual traceparent
# Look for traceparent in response headers or downstream logs # Verify it propagated
# Step 3: Add debug exporter to Collector for troubleshooting
# otel-collector-debug.yaml
receivers:
otlp: # Receive from SDKs
protocols:
grpc:
endpoint: 0.0.0.0:4317 # Standard port
exporters:
debug: # Print spans to stdout
verbosity: detailed # Show full span details
sampling_initial: 10 # First 10 spans in detail
sampling_thereafter: 5 # Then every 5th span
otlp/tempo: # Also export to Tempo
endpoint: tempo:4317 # Production backend
service:
telemetry:
logs:
level: debug # Collector internal debug logs
pipelines:
traces:
receivers: [otlp]
exporters: [debug, otlp/tempo] # Both debug AND production
# Step 4: Check Collector internal metrics for dropped spans
curl -s http://otel-collector:8888/metrics | grep otelcol # Scrape Collector metrics
# otelcol_receiver_accepted_spans{receiver="otlp"} 15420 # Spans received successfully
# otelcol_receiver_refused_spans{receiver="otlp"} 0 # Spans rejected (should be 0)
# otelcol_exporter_sent_spans{exporter="otlp/tempo"} 14890 # Spans exported to backend
# otelcol_exporter_send_failed_spans{exporter="otlp/tempo"} 530 # Export failures!
# otelcol_processor_dropped_spans{processor="tail_sampling"} 8200 # Dropped by sampling
# Step 5: Python - Fix async context propagation loss
from opentelemetry import context # Context management API
from opentelemetry import trace # Trace API
import concurrent.futures # Thread pool executor
def process_in_thread(ctx, order_id): # Worker function
context.attach(ctx) # Reattach context in thread
tracer = trace.get_tracer("order-processing-service") # Get tracer
with tracer.start_as_current_span("process-order"): # Span now has correct parent
pass # Business logic here
# WRONG: context lost in thread pool
with concurrent.futures.ThreadPoolExecutor() as executor: # Thread pool
executor.submit(process_in_thread, None, "order-123") # Context is None = broken trace
# RIGHT: capture and propagate context
current_ctx = context.get_current() # Capture current context
with concurrent.futures.ThreadPoolExecutor() as executor: # Thread pool
executor.submit(process_in_thread, current_ctx, "order-123") # Pass context explicitlyInterview Tip
A junior engineer typically says 'check if the SDK is configured correctly.' For a senior role, present a systematic troubleshooting framework. First, check propagator consistency: every service must use the same OTEL_PROPAGATORS setting, otherwise traceparent headers are not read by downstream services. Second, verify HTTP headers between services using curl or tcpdump to confirm traceparent is present and correctly formatted. Third, check sampling: independent head-based samplers across services multiply their drop rates, so use parentbased sampling where downstream services respect the parent's decision. Fourth, examine Collector metrics for otelcol_receiver_refused_spans and otelcol_exporter_send_failed_spans. Fifth, investigate async context loss in Node.js (AsyncLocalStorage) and Python (ThreadPoolExecutor) as a subtle cause of orphaned spans. Walk through a real scenario: checkout-service and payments-api show different trace IDs for the same request, tracing the cause to a custom HTTP client that does not inject traceparent.
◈ Architecture Diagram
Broken Trace Diagnosis:
Expected (healthy):
┌─────────────────────────────────────────────┐
│ trace_id: abc123 │
│ │
│ checkout-service ─────────────────── 450ms │
│ └─ payments-api ──────────────── 200ms │
│ └─ user-auth-service ────── 50ms │
└─────────────────────────────────────────────┘
Broken (context lost between checkout and payments):
┌─────────────────────┐ ┌────────────────────┐
│ trace_id: abc123 │ │ trace_id: xyz789 │
│ │ │ │
│ checkout-service │ │ payments-api │
│ └─ (no child) │ │ └─ user-auth │
└─────────────────────┘ └────────────────────┘
Two separate traces ✗ Should be one trace!
Common Causes:
┌────────────────────────────────────────────┐
│ 1. Propagator Mismatch │
│ checkout: W3C (traceparent) │
│ payments: B3 (X-B3-TraceId) │
│ → Fix: OTEL_PROPAGATORS=tracecontext │
│ │
│ 2. Missing Header Injection │
│ Custom HTTP client skips traceparent │
│ → Fix: Use instrumented HTTP client │
│ │
│ 3. Independent Sampling │
│ checkout: 10% × payments: 10% = 1% │
│ → Fix: parentbased_traceidratio │
│ │
│ 4. Async Context Loss │
│ ThreadPool/callback loses context │
│ → Fix: context.attach(captured_ctx) │
└────────────────────────────────────────────┘
Troubleshooting Flow:
debug exporter → check headers → check metrics
│ │ │
▼ ▼ ▼
Spans arriving? traceparent refused_spans
YES/NO present? YES/NO > 0? YES/NO💬 Comments
Context
Shopify processes over 10,000 transactions per second during flash sales. Their monolith-to-microservices migration created blind spots in request tracing across 280+ services, causing checkout failures that cost an estimated $2.3M per hour during peak traffic events.
Problem
After decomposing their monolithic Rails application into over 280 microservices, Shopify lost end-to-end visibility into checkout request flows. Each service used a different observability approach: some used Jaeger, others used Zipkin, and many had no tracing at all. When checkout latency spiked during Black Friday 2024, the platform engineering team spent over four hours isolating the root cause because they could not follow a single request across service boundaries. The inventory service was making redundant calls to the pricing engine, but this was invisible without correlated traces. Additionally, the lack of standardized context propagation meant that when the payment gateway service called the fraud detection service, trace context was dropped entirely, creating orphaned spans that could not be connected to their parent transactions. The team was flying blind during the most critical revenue period of the year, and existing vendor-specific instrumentation locked them into a single backend that could not scale cost-effectively.
Solution
Shopify adopted OpenTelemetry as their unified observability framework across all 280+ microservices. They began by deploying the OpenTelemetry Collector as a DaemonSet on every Kubernetes node, configured with the OTLP receiver to accept traces from all services regardless of language. For their Ruby services, they integrated the opentelemetry-ruby SDK with auto-instrumentation for Rails, Sidekiq, and ActiveRecord. For Go services, they used the otel-go SDK with gRPC interceptors that automatically propagated W3C TraceContext headers. They established a centralized collector pipeline that performed tail-based sampling, keeping 100% of error traces and high-latency traces while sampling 10% of successful fast traces. The collector exported to both Tempo for trace storage and Prometheus for derived RED metrics. They implemented custom span attributes for business context such as merchant_id, cart_value, and checkout_step, enabling business-level queries. A critical change was deploying the OpenTelemetry Operator for Kubernetes, which handled auto-injection of instrumentation sidecars and automatic SDK configuration via pod annotations. This eliminated the need for individual teams to manually configure trace exporters.
Commands
helm install otel-operator open-telemetry/opentelemetry-operator --namespace otel-system --create-namespace # Install the OTel Operator in K8s
kubectl apply -f otel-collector-daemonset.yaml # Deploy collector as DaemonSet on every node
kubectl annotate deployment checkout-svc instrumentation.opentelemetry.io/inject-ruby='true' # Auto-inject Ruby instrumentation
otelcol-contrib --config=collector-config.yaml # Start collector with tail-based sampling pipeline
curl -s http://localhost:4317/v1/traces | jq '.resourceSpans[].scopeSpans[].spans[] | .name' # Verify spans arriving via OTLP gRPC
Outcome
MTTR dropped from 4.2 hours to 18 minutes during peak events. Checkout latency P99 reduced from 3.8s to 1.1s after identifying redundant service calls. Observability vendor costs reduced by 62% through tail-based sampling. Trace coverage went from 34% to 99.7% of all services.
Lessons Learned
Tail-based sampling at the collector level is far more effective than head-based sampling at the SDK level because you can make decisions based on the complete trace. Auto-instrumentation via the OTel Operator dramatically accelerates adoption because teams do not need to change application code. Business-context span attributes (like cart value) unlock queries that pure infrastructure metrics cannot answer.
◈ Architecture Diagram
┌──────────────┐ ┌──────────────┐ ┌──────────────┐\n│ Checkout Svc │───→│ Inventory Svc│───→│ Pricing Svc │\n│ (Ruby+OTel) │ │ (Go+OTel) │ │ (Go+OTel) │\n└──────┬───────┘ └──────┬───────┘ └──────┬───────┘\n │ │ │\n ▼ ▼ ▼\n┌─────────────────────────────────────────────────────┐\n│ OTel Collector (DaemonSet per node) │\n│ ┌─────────┐ ┌──────────────┐ ┌────────────────┐ │\n│ │OTLP Recv│→ │Tail Sampling │→ │ OTLP Exporter │ │\n│ └─────────┘ └──────────────┘ └────────────────┘ │\n└─────────────────────┬───────────────────────────────┘\n │\n ┌───────────┴───────────┐\n ▼ ▼\n ┌────────────┐ ┌────────────┐\n │ Tempo │ │ Prometheus │\n │ (Traces) │ │ (Metrics) │\n └────────────┘ └────────────┘
💬 Comments
Context
Netflix operates over 1,000 microservices across multiple AWS regions serving 260 million subscribers. They were locked into a proprietary observability vendor costing $18M annually, and needed to migrate to a multi-backend strategy without disrupting production observability during the transition.
Problem
Netflix had standardized on a single proprietary observability platform five years ago, which now consumed $18M of their annual infrastructure budget. As their service count grew from 400 to over 1,000 microservices, the cost scaled linearly while the platform struggled with their data volume of 45 billion spans per day. The vendor's pricing model penalized high-cardinality dimensions, forcing teams to drop valuable metadata like user_id and session_id from their telemetry. When the engineering leadership decided to evaluate alternative backends including Grafana Cloud, Datadog, and self-hosted options, they discovered that every service was deeply coupled to the vendor's proprietary SDK. Migration would require touching code in all 1,000+ services, coordinated across 200+ engineering teams. The estimated migration timeline was 18 months with significant risk of observability gaps. Furthermore, different teams had different backend preferences based on their specific needs: the streaming team wanted real-time analytics capabilities, the infrastructure team preferred Prometheus-compatible backends, and the security team needed long-term trace retention that the current vendor did not support cost-effectively.
Solution
Netflix deployed OpenTelemetry Collectors as a gateway layer that decoupled their services from any specific observability backend. The migration strategy was phased: first, they deployed OTel Collectors configured to receive the proprietary vendor's format and translate it to OTLP internally. This meant zero application changes were needed initially. The collector pipeline used processors for metric aggregation, attribute enrichment, and intelligent routing. They configured the fan-out exporter to send the same telemetry data to multiple backends simultaneously, allowing teams to evaluate alternatives without losing existing dashboards. Over the next six months, teams gradually replaced proprietary SDKs with OpenTelemetry SDKs at their own pace, guided by a shared instrumentation library that wrapped the OTel SDK with Netflix-specific conventions. The collector pipeline included a transform processor that normalized span names and attribute keys across all services, ensuring consistency regardless of which SDK version or language a service used. They implemented a tiered storage strategy: hot traces went to Grafana Tempo, metrics to Mimir, and cold traces to S3 via the AWS S3 exporter for compliance retention. Resource detection processors automatically tagged all telemetry with AWS metadata including region, availability zone, instance type, and ECS task ID.
Commands
kubectl apply -f otel-collector-gateway.yaml # Deploy collector gateway with multi-backend fan-out
otelcol-contrib validate --config=gateway-config.yaml # Validate collector config before deployment
kubectl rollout restart daemonset/otel-collector -n observability # Rolling restart after config update
promtool query instant 'otelcol_exporter_sent_spans{exporter="otlp/tempo"}' # Check span export rates to Tempokubectl logs -l app=otel-collector -n observability --tail=100 | grep -i 'dropped\|error' # Monitor for data loss
Outcome
Annual observability costs reduced from $18M to $6.8M (62% reduction). Migration completed in 7 months instead of estimated 18 months. Zero observability gaps during the transition. High-cardinality dimensions restored, enabling per-user trace queries that were previously cost-prohibitive.
Lessons Learned
The collector gateway pattern enables vendor migration without application code changes, which is the single most important architectural decision for observability at scale. Fan-out exporters let you run backends in parallel during evaluation, eliminating the need for big-bang migrations. Normalizing telemetry at the collector layer is more maintainable than enforcing SDK-level conventions across hundreds of teams.
◈ Architecture Diagram
┌─────────────────────────────────────────────────┐\n│ Service Fleet (1000+ svcs) │\n│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │\n│ │Svc+Prop│ │Svc+OTel│ │Svc+Prop│ │Svc+OTel│ │\n│ │ SDK │ │ SDK │ │ SDK │ │ SDK │ │\n│ └───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘ │\n└──────┼───────────┼───────────┼───────────┼──────┘\n └───────────┴─────┬─────┴───────────┘\n ▼\n ┌───────────────────────────────┐\n │ OTel Collector Gateway │\n │ ┌────────┐ ┌─────────────┐ │\n │ │Receiver│→│ Transform │ │\n │ │(multi) │ │ + Normalize │ │\n │ └────────┘ └──────┬──────┘ │\n │ ┌─────┴──────┐ │\n │ │ Fan-Out │ │\n │ │ Exporter │ │\n │ └─┬────┬───┬─┘ │\n └────────────────┼────┼───┼────┘\n ┌─────┘ │ └─────┐\n ▼ ▼ ▼\n ┌──────────┐ ┌───────┐ ┌──────┐\n │ Tempo │ │ Mimir │ │ S3 │\n │ (Traces) │ │(Metr.)│ │(Cold)│\n └──────────┘ └───────┘ └──────┘
💬 Comments
Context
Stripe processes billions of dollars in payments monthly across 35 countries. A subtle intermittent payment failure affecting 0.3% of transactions in the EU region was causing $4.7M in monthly revenue loss, but the root cause was invisible because logs, metrics, and traces lived in separate silos with no correlation.
Problem
Stripe's observability stack had evolved organically over seven years. Application logs went to Elasticsearch, metrics were stored in a custom time-series database, and traces were captured by an internal distributed tracing system. When payment failures spiked intermittently in the EU region, the on-call team could see the error rate increase in metrics dashboards, find related error logs in Elasticsearch, and sometimes locate relevant traces in the tracing UI. However, correlating these three signals for a single failed payment required manual effort: copying timestamps from metrics alerts, searching logs within that time window, then hunting for trace IDs mentioned in log entries. The failure pattern was particularly insidious because it only occurred when a specific combination of conditions aligned: the payment currency was EUR, the acquiring bank was in Germany, and the request hit a particular partition of the tokenization service. Without correlated observability, each team saw only their slice of the problem. The payments team saw increased 3DS challenge rates, the infrastructure team saw occasional latency spikes in the tokenization service, and the networking team saw sporadic TCP retransmissions to the German acquiring bank. It took six weeks of cross-team investigation to connect these dots manually, during which the issue continued to impact customers.
Solution
Stripe implemented OpenTelemetry's unified telemetry model to correlate logs, traces, and metrics through shared context. They deployed the OpenTelemetry SDK across their Java and Ruby services, configuring the log bridge API to automatically inject trace_id and span_id into every log entry. This meant that when an engineer found a suspicious log line, they could instantly jump to the full distributed trace that produced it. They configured the OTel Collector with the span metrics connector, which automatically derived RED metrics (Rate, Error, Duration) from trace data, ensuring that metric anomalies could be drilled down into the exact traces that caused them. The collector pipeline included a logs-to-traces connector that could reconstruct partial traces from structured log events when full trace instrumentation was not yet available. They built custom exemplar support that attached trace IDs to Prometheus metric samples, so clicking on a spike in a Grafana dashboard opened the exact traces that contributed to that spike. For the EUR payment issue specifically, they added semantic conventions for payment processing: span attributes included payment.currency, payment.acquirer.country, and payment.tokenization.partition. The OTel Collector used a group-by-trace processor to ensure all spans from a single payment flow were processed together, enabling accurate tail-based sampling that kept all spans from failed payments. They also implemented baggage propagation to carry payment context across all service boundaries without requiring each service to understand payment-specific attributes.
Commands
export OTEL_LOGS_EXPORTER=otlp # Enable OTLP log export alongside traces and metrics
export OTEL_RESOURCE_ATTRIBUTES=service.name=payment-gateway,deployment.environment=production # Set resource attributes
java -javaagent:opentelemetry-javaagent.jar -Dotel.instrumentation.log4j-appender.enabled=true -jar payment-svc.jar # Auto-inject trace context into logs
kubectl apply -f spanmetrics-connector-config.yaml # Deploy span-to-metrics connector in collector
logcli query '{service_name="payment-gateway"} | json | trace_id=`abc123def456`' # Query logs by trace ID via LokiOutcome
The EUR payment issue was identified in 45 minutes after correlation was enabled, compared to the 6 weeks of manual investigation prior. MTTR for payment-related incidents dropped by 87%. The correlated view revealed that the tokenization service's partition 7 had a misconfigured TLS certificate for the German acquiring bank connection, causing intermittent handshake failures.
Lessons Learned
Log-trace correlation is the single highest-ROI observability improvement because it bridges the gap between the two most commonly used debugging tools. Exemplars on metrics are underappreciated but transform metrics from 'something is wrong' to 'here is exactly what went wrong.' Baggage propagation eliminates the need for every service in the chain to understand domain-specific context, which is essential for cross-cutting concerns like payment tracking.
◈ Architecture Diagram
┌──────────────────────────────────────────────┐\n│ Payment Request Flow │\n│ │\n│ ┌─────────┐ ┌──────────┐ ┌───────────┐ │\n│ │ Gateway │──→│ Token Svc│──→│ Acquirer │ │\n│ │ │ │ Part. 7 │ │ (Germany) │ │\n│ └────┬────┘ └────┬─────┘ └─────┬─────┘ │\n└───────┼─────────────┼───────────────┼────────┘\n │ │ │\n ┌────┴────┐ ┌────┴────┐ ┌─────┴────┐\n │ Traces │ │ Logs │ │ Metrics │\n │(span_id)│ │(trace_id│ │(exemplar │\n │ │ │ injected│ │ trace_id)│\n └────┬────┘ └────┬────┘ └─────┬────┘\n │ │ │\n └─────────────┼───────────────┘\n ▼\n ┌───────────────────────┐\n │ OTel Collector │\n │ ┌─────────────────┐ │\n │ │ SpanMetrics Conn│ │\n │ │ + Log Bridge │ │\n │ │ + Exemplars │ │\n │ └────────┬────────┘ │\n └───────────┼──────────┘\n ▼\n ┌───────────────────────┐\n │ Grafana (Correlated │\n │ Traces+Logs+Metrics) │\n └───────────────────────┘
💬 Comments
Context
Uber operates 4,500+ microservices written in Go, Java, Python, and Node.js. Manual instrumentation coverage was only 22% because teams resisted adding tracing code, creating massive observability blind spots during the migration from their legacy Jaeger-based system to a standardized OpenTelemetry deployment.
Problem
Uber's platform engineering team had been trying to increase distributed tracing coverage for three years. Despite providing SDKs and documentation, only 22% of their 4,500 microservices had any tracing instrumentation. The primary reason was developer friction: adding tracing required importing libraries, initializing tracer providers, creating spans around key operations, and propagating context across async boundaries. For their Go services, this meant wrapping every HTTP handler and gRPC method with span creation code. For Java services, developers needed to configure Spring interceptors and add annotations. Python services required decorators or context managers around every traced operation. Most teams viewed instrumentation as toil that provided no direct benefit to their feature development velocity. When incidents occurred, the 78% of uninstrumented services appeared as black holes in trace visualizations, making root cause analysis a guessing game. The situation was compounded by Uber's polyglot environment: each language had different instrumentation patterns, different SDK versions, and different levels of community support. A single request from a rider's phone could traverse Go API gateways, Java matching services, Python ML models, and Node.js notification services, with trace context frequently lost at language boundaries. The legacy Jaeger client libraries were also showing their age, with several reaching end-of-life status and accumulating unpatched CVEs.
Solution
Uber adopted OpenTelemetry's auto-instrumentation capabilities combined with the OpenTelemetry Operator for Kubernetes to achieve near-universal tracing coverage without requiring application code changes. For Java services, they used the OpenTelemetry Java Agent, a JAR file that uses bytecode manipulation to automatically instrument popular frameworks including Spring Boot, gRPC, JDBC, Kafka, and Redis clients. The agent was injected via the OTel Operator's Instrumentation CRD, which modified pod specs to add the agent JAR as an init container and set the JAVA_TOOL_OPTIONS environment variable. For Go services, they leveraged eBPF-based auto-instrumentation using the OpenTelemetry Go auto-instrumentation project, which attaches to running Go binaries and instruments net/http, google.golang.org/grpc, and database/sql calls without recompilation. For Python services, the opentelemetry-instrument CLI wrapper auto-patched Django, Flask, requests, SQLAlchemy, and celery. For Node.js, the @opentelemetry/auto-instrumentations-node package provided automatic instrumentation for Express, Fastify, pg, and ioredis. The OTel Operator managed all of this through Kubernetes annotations: teams simply added instrumentation.opentelemetry.io/inject-java: 'true' to their deployment manifests. The operator handled SDK version management, configuration propagation, and graceful restarts. They implemented a phased rollout using Kubernetes namespace labels, instrumenting one business domain at a time over eight weeks. The collector pipeline included a probabilistic sampler that started at 1% and gradually increased to 15% as they validated backend capacity.
Commands
kubectl apply -f instrumentation-crd.yaml # Create Instrumentation CR with SDK configs for all languages
kubectl label namespace ride-matching instrumentation=enabled # Enable auto-instrumentation for namespace
kubectl annotate deployment matching-svc instrumentation.opentelemetry.io/inject-java='true' --overwrite # Inject Java agent
kubectl annotate deployment pricing-svc instrumentation.opentelemetry.io/inject-python='true' --overwrite # Inject Python auto-instrumentation
kubectl get pods -l app=matching-svc -o jsonpath='{.items[0].spec.initContainers[*].name}' # Verify init container injectionOutcome
Tracing coverage increased from 22% to 96% across all 4,500 microservices in 8 weeks. Zero application code changes were required for 89% of services. Mean time to detect cross-service issues dropped from 34 minutes to 3 minutes. The migration from Jaeger to OTel was completed 5 months ahead of the original 12-month schedule.
Lessons Learned
Auto-instrumentation is not a compromise on quality; for common frameworks, it captures the same spans a developer would manually create, plus many they would forget. The OpenTelemetry Operator transforms instrumentation from a per-team responsibility to a platform-level capability, which fundamentally changes adoption dynamics. eBPF-based instrumentation for compiled languages like Go is a game-changer but requires careful kernel version compatibility testing.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────┐\n│ Kubernetes Cluster │\n│ │\n│ ┌──────────────────────────────────────────────────┐ │\n│ │ OTel Operator (watches annotations) │ │\n│ └──────────┬──────────┬──────────┬─────────────────┘ │\n│ │ │ │ │\n│ ┌─────▼────┐ ┌───▼────┐ ┌──▼───────┐ │\n│ │Java Pods │ │Go Pods │ │Python │ │\n│ │+Agent JAR│ │+eBPF │ │Pods+CLI │ │\n│ │(auto-inj)│ │(auto) │ │(auto-inj)│ │\n│ └─────┬────┘ └───┬────┘ └──┬───────┘ │\n│ │ │ │ │\n│ └──────────┼─────────┘ │\n│ ▼ │\n│ ┌──────────────────────────────────────────────┐ │\n│ │ OTel Collector (DaemonSet) │ │\n│ │ OTLP Recv → Probabilistic Sampler → Export │ │\n│ └──────────────────────┬───────────────────────┘ │\n└─────────────────────────┼────────────────────────────┘\n ▼\n ┌───────────────┐\n │ Trace Backend │\n └───────────────┘
💬 Comments
Context
Airbnb serves 150 million users with 800+ microservices. Their SLO (Service Level Objective) monitoring was based on synthetic checks that missed real user experience degradation. During a major booking outage, SLOs showed green while 12% of real users experienced failures, eroding trust in the SLO framework entirely.
Problem
Airbnb's SLO framework was built on synthetic monitoring: automated bots performed test bookings every 60 seconds from five global locations and measured success rates and latency. This approach had a fundamental flaw: synthetic checks tested a single happy path that did not represent the diversity of real user interactions. When a caching layer failed for users searching in Japanese language locales, the synthetic monitors continued reporting 99.99% availability because they only tested English-language searches. The 12% of real users affected by the outage saw search timeouts and booking failures for over four hours before customer support volume triggered a manual investigation. Beyond this specific incident, the SLO framework could not answer basic questions like 'What is the P99 latency for users booking luxury properties in the EU?' because the synthetic checks did not carry real business context. The platform team tried to build SLO monitoring from application logs, but parsing unstructured log messages across 800 services in five languages proved unreliable and fragile. Metrics-based SLOs existed but lacked the dimensionality needed for meaningful error budgets: a service-level error rate of 0.1% could mask a 15% error rate for a specific customer segment. The disconnect between reported SLOs and actual user experience had eroded engineering leadership's confidence in the entire reliability program.
Solution
Airbnb rebuilt their SLO monitoring pipeline on OpenTelemetry, using real request telemetry instead of synthetic checks. They instrumented all user-facing services with the OpenTelemetry SDK, adding custom span attributes that captured business dimensions: booking.property_type, search.locale, user.tier (guest/superhost/plus), and payment.method. The OTel Collector's span metrics connector automatically derived RED metrics from these enriched spans, producing Prometheus metrics with the business dimensions preserved as labels. They defined SLOs using these real-user metrics in Sloth, an SLO framework that generates Prometheus recording rules and alerting rules from SLO definitions. For example, the search SLO was defined as 'P99 latency under 800ms for 99.5% of real search requests, sliced by locale and property_type.' The OTel Collector pipeline included a groupbyattrs processor that pre-aggregated metrics by SLO-relevant dimensions before export, preventing cardinality explosion in Prometheus. They implemented the OTel SDK's View API to define histogram bucket boundaries optimized for their latency distribution, replacing the default buckets with boundaries at 50ms, 100ms, 250ms, 500ms, 800ms, 1500ms, and 5000ms that aligned with their SLO thresholds. Exemplars on histogram observations linked every SLO violation to specific traces, enabling instant drill-down from an SLO burn rate alert to the exact requests that breached the objective. They also deployed the OpenTelemetry Collector's tail sampling processor with an SLO-aware policy: any request that would constitute an SLO violation was sampled at 100%, while healthy requests were sampled at 5%.
Commands
kubectl apply -f slo-spanmetrics-connector.yaml # Deploy span-to-metrics connector with business dimensions
sloth generate -i search-slo.yaml | kubectl apply -f - # Generate and apply Prometheus SLO recording rules
promql 'slo:sli_error:ratio_rate5m{sloth_service="search",locale="ja"}' # Query SLI error ratio for Japanese localeotel-cli exec --name 'booking-flow' --attrs 'booking.property_type=luxury,user.tier=plus' -- curl booking-api/reserve # Test trace with business attributes
kubectl port-forward svc/grafana 3000:3000 # Access SLO dashboard with real-user burn rates
Outcome
SLO accuracy improved from 67% correlation with real user experience to 99.2%. The Japanese locale search issue would have triggered an SLO burn rate alert within 8 minutes instead of going undetected for 4 hours. Error budget consumption became actionable: teams could see exactly which user segments were affected and by how much. False positive SLO alerts dropped by 91%.
Lessons Learned
SLOs based on real user telemetry are fundamentally different from synthetic monitoring SLOs, and organizations should not conflate the two. The span metrics connector is the bridge between tracing richness and metrics efficiency, enabling business-dimensional SLOs without the cost of high-cardinality metric storage. Tail-sampling policies should be SLO-aware so that every violation is captured with full trace detail for post-incident analysis.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐\n│ Real User Requests │\n│ locale=ja property=luxury tier=plus │\n└──────────────────┬──────────────────────────┘\n ▼\n┌──────────────────────────────────────────────┐\n│ Services with OTel SDK + Business Attributes │\n│ span.attrs: locale, property_type, tier │\n└──────────────────┬──────────────────────────┘\n ▼\n┌──────────────────────────────────────────────┐\n│ OTel Collector Pipeline │\n│ ┌─────────────┐ ┌──────────────────────┐ │\n│ │Tail Sampling│───→│ SpanMetrics Connector │ │\n│ │(SLO-aware) │ │ (business dimensions)│ │\n│ └─────────────┘ └──────────┬───────────┘ │\n└─────────────────────────────────┼────────────┘\n ┌─────────────┴──────┐\n ▼ ▼\n ┌───────────┐ ┌─────────────┐\n │Prometheus │ │ Tempo │\n │(SLO metr.)│ │ (Traces) │\n └─────┬─────┘ └─────────────┘\n ▼\n ┌───────────┐\n │ Sloth + │\n │ Grafana │\n │ (SLO burn │\n │ rate) │\n └───────────┘
💬 Comments
Context
Datadog, while offering its own proprietary agents, needed to support the growing number of customers sending telemetry via OTLP. Internally, their own infrastructure of 600+ services needed to dogfood OTLP ingestion at a scale of 2.1 trillion spans per day, exposing performance bottlenecks in their ingestion pipeline that affected all OTLP customers.
Problem
Datadog's internal services generated 2.1 trillion spans per day, which were collected by their proprietary dd-agent and sent to the ingestion pipeline using a custom binary protocol. As OpenTelemetry adoption accelerated among their customers, OTLP became the most requested ingestion format. However, the OTLP ingestion path had not been optimized to the same degree as the proprietary protocol. Customer-reported issues included 3x higher CPU usage on the collector when using OTLP compared to the dd-agent protocol, dropped spans under high load due to gRPC backpressure, and inconsistent attribute mapping between OTLP semantic conventions and Datadog's internal data model. The engineering team could not reproduce these issues at scale using synthetic load generators because the traffic patterns did not match real-world polyglot microservice architectures. They needed to run their own production infrastructure on OTLP to identify and fix the bottlenecks that customers were experiencing. The challenge was significant: their internal services used deeply integrated dd-agent features like runtime metrics, continuous profiling integration, and live process monitoring that had no direct OTLP equivalent. Simply swapping the agent would lose these capabilities. Additionally, the migration needed to be invisible to their customers: any regression in their own infrastructure could cause cascading failures in the monitoring platform that millions of users depended on.
Solution
Datadog built a dual-pipeline migration strategy using a custom OpenTelemetry Collector distribution that ran both the proprietary dd-agent protocol and OTLP receivers simultaneously. During the migration phase, each service sent telemetry to both pipelines, with an automated comparison system that detected discrepancies between the two data streams. They contributed upstream fixes to the OpenTelemetry Collector's gRPC receiver to handle backpressure more gracefully, implementing an adaptive batching algorithm that dynamically adjusted batch sizes based on downstream pipeline capacity. The OTLP-to-Datadog attribute mapping was formalized as a public specification document, ensuring that OTLP semantic conventions translated deterministically to Datadog's tag namespace. They discovered and fixed a critical memory leak in the OTLP receiver's protobuf deserialization path that only manifested at sustained throughput above 500K spans per second. For capabilities that had no OTLP equivalent, such as continuous profiling correlation, they extended the OTel SDK using custom span processors that attached profiling labels to span context, allowing their profiling agent to correlate profiles with OTLP traces. The migration was executed service-by-service over twelve weeks using a canary deployment strategy: each service first sent 1% of traffic via OTLP, verified parity, then gradually increased to 100%. They open-sourced the comparison tooling as 'otel-parity-checker' so other vendors could validate their own OTLP implementations against reference outputs.
Commands
export OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-intake.datadoghq.com # Point OTel SDK to Datadog's OTLP endpoint
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc # Use gRPC for high-throughput span submission
export OTEL_RESOURCE_ATTRIBUTES=deployment.environment=internal-prod,service.version=$(git rev-parse --short HEAD) # Tag with deploy context
dd-otel-collector --config=dual-pipeline.yaml # Run dual-pipeline collector during migration
curl -s http://localhost:8888/metrics | grep otelcol_receiver_accepted_spans # Monitor OTLP receiver throughput
Outcome
OTLP ingestion CPU overhead reduced from 3x to 1.15x compared to the proprietary protocol. Zero spans dropped under sustained load of 2.1 trillion spans/day. Fixed 14 OTLP implementation bugs that affected all customers, not just internal systems. OTLP became the recommended ingestion path for new Datadog customers, with 40% of total ingestion volume now arriving via OTLP.
Lessons Learned
Dogfooding at production scale reveals issues that no synthetic benchmark can catch, especially around memory behavior under sustained high throughput. The dual-pipeline comparison approach is essential for protocol migrations because it catches subtle semantic differences that unit tests miss. Contributing fixes upstream to the OpenTelemetry project benefits the entire ecosystem and reduces the maintenance burden of carrying internal patches.
◈ Architecture Diagram
┌──────────────────────────────────────────────────┐\n│ Datadog Internal Service │\n│ ┌──────────────────────────────────────────────┐ │\n│ │ Application Code │ │\n│ └──────────┬──────────────────┬─────────────────┘ │\n│ │ │ │\n│ ┌──────▼──────┐ ┌──────▼──────┐ │\n│ │ dd-agent │ │ OTel SDK │ │\n│ │ (proprietary│ │ (OTLP/gRPC)│ │\n│ └──────┬──────┘ └──────┬──────┘ │\n└─────────────┼─────────────────┼──────────────────┘\n │ │\n ▼ ▼\n ┌────────────────────────────────────┐\n │ Dual-Pipeline Collector │\n │ ┌──────────┐ ┌──────────────┐ │\n │ │DD Recv │ │ OTLP Recv │ │\n │ └────┬─────┘ └──────┬───────┘ │\n │ │ ┌──────┐ │ │\n │ └────→│Parity│←──┘ │\n │ │Check │ │\n │ └──┬───┘ │\n │ ▼ │\n │ ┌────────────┐ │\n │ │ Exporter │ │\n │ └────────────┘ │\n └────────────────────────────────────┘\n │\n ▼\n ┌──────────────────┐\n │ Datadog Backend │\n │ (Ingestion) │\n └──────────────────┘
💬 Comments
Symptom
Checkout traces became sparse during peak traffic. Application logs showed requests completing, but the backend received only a fraction of spans from the Kubernetes cluster.
Error Message
otelcol_processor_dropped_spans increased while exporter queue latency and collector memory usage climbed.
Root Cause
A new Collector pipeline added transformation and filtering logic without capacity testing. The Collector receives, processes, and exports telemetry, so expensive processors and undersized queues can turn the observability layer into the bottleneck. The deployment had no internal Collector telemetry alert for dropped spans or exporter queue pressure.
Diagnosis Steps
Solution
Scale the Collector deployment, tune batching and memory limits, and move expensive processing to a gateway tier where capacity can be managed separately. Add internal telemetry dashboards for receiver accepted/refused data, processor drops, exporter queue behavior, and process memory. Load test configuration changes before applying them to all workloads.
Commands
kubectl -n observability logs deploy/otel-collector --since=30m
kubectl -n observability top pod -l app=otel-collector
kubectl -n observability get configmap otel-collector-conf -o yaml
kubectl -n observability rollout undo deploy/otel-collector
Prevention
Treat Collector configuration like production code. Review pipeline changes, canary high-volume namespaces, monitor Collector internal telemetry, and keep a rollback path for observability config.
◈ Architecture Diagram
Receivers ↓ Processors ↓ pressure/drop Exporters ↓ Backend gaps
💬 Comments