All lessons
DevOps & cloudAdvanced12 min readWorth knowing

OpenTelemetry

One open standard for traces, metrics and logs, so you can follow a request across every service.

  • Traces
  • Spans
  • Collector
  • Context propagation
Step 1/6
Checkout takes 8 seconds and users are fuming. Time to call the detectives: metrics, traces and logs.

In 30 seconds

OpenTelemetry (OTel) is an open-source, vendor-neutral standard for collecting telemetry: traces (the path of one request through your system), metrics (numbers over time) and logs. You instrument your code once with OTel SDKs, send data in the OTLP format to a Collector, and from there export to any backend: Grafana, Jaeger, Datadog, Honeycomb, New Relic, Elastic or a cloud provider. It is a CNCF project supported by almost every observability vendor.

Not in my daily stack — I know the concepts and would learn the details when a project needs it.

Why it matters

  • In a system with several services, queues and databases, logs alone cannot tell you why one request was slow; a distributed trace shows every hop and its timing.
  • It ends vendor lock-in: instrumentation is written once against an open API, and switching backends is a Collector configuration change, not a code rewrite.
  • Auto-instrumentation for HTTP, databases, queues and frameworks gives useful traces in minutes, before you write any custom spans.
  • Teams adopt it when they outgrow simple uptime checks and error tracking and need to answer "where does the time go?" across services.

How it works

01The three signals and how they connect

Telemetry comes in three main signals. A trace is the story of one request: it is a tree of spans, where each span is one unit of work (an HTTP handler, a SQL query, a call to a payment API) with a start time, a duration, a status and attributes such as http.route or db.system. Metrics are numeric measurements aggregated over time: request rate, error rate, latency histograms, queue depth, memory. They are cheap to store and great for dashboards and alerts. Logs are timestamped records of events. The power of OpenTelemetry is correlation: logs written inside a span carry its trace ID and span ID, and metrics can carry exemplars that link to example traces. So an alert on p95 latency leads you to a slow trace, and the trace leads you to the exact log lines. Traces, metrics and logs are stable in the specification; profiling is being added as a fourth signal.

  • Trace = tree of spans for one request.
  • Metrics = cheap aggregates for alerts and dashboards.
  • Logs = events, correlated by trace ID and span ID.

02Architecture: API, SDK, exporters and the Collector

OTel splits responsibilities cleanly. The API is what libraries and your code call to create spans and record metrics; on its own it does nothing, so libraries can depend on it safely. The SDK is the implementation you configure in your application: it decides sampling, batches data and hands it to exporters. Exporters send data out, usually over OTLP (the OpenTelemetry Protocol), on port 4317 for gRPC or 4318 for HTTP. Instrumentation libraries patch popular frameworks and clients (Express, Fastify, Next.js, Laravel, pg, mysql, Redis, HTTP clients) to create spans automatically. The Collector is a separate service that receives telemetry, processes it (batching, filtering, removing sensitive attributes, sampling) and exports it to one or more backends. You can run it as an agent next to each app, as a central gateway, or both. Resources describe who sent the data: service.name, service.version, deployment.environment.name.

03Instrumenting a Node.js service

The quickest start in Node.js is the SDK with the auto-instrumentations bundle. Install @opentelemetry/sdk-node, @opentelemetry/auto-instrumentations-node and an OTLP exporter, then load a small setup file before your application code, using node --import for ES modules. Configure it with standard environment variables rather than hard-coded values, so the same image works in every environment: OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_RESOURCE_ATTRIBUTES and OTEL_TRACES_SAMPLER. Next.js has a built-in instrumentation.ts hook and the @vercel/otel helper. Laravel and PHP use the OpenTelemetry PHP extension plus auto-instrumentation packages. Start with auto-instrumentation, look at real traces, then add custom spans only around important business steps.

js
// instrumentation.mjs: run with  node --import ./instrumentation.mjs server.mjs
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
 
// Endpoint and service name come from OTEL_EXPORTER_OTLP_ENDPOINT / OTEL_SERVICE_NAME
const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter(),
  metricReader: new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter() }),
  instrumentations: [getNodeAutoInstrumentations({ "@opentelemetry/instrumentation-fs": { enabled: false } })],
});
 
sdk.start();
process.on("SIGTERM", () => sdk.shutdown().finally(() => process.exit(0)));

04Custom spans and attributes

Auto-instrumentation shows HTTP and database calls, but not your business logic. Add a span around meaningful steps such as "calculate price", "reserve stock" or "generate invoice PDF", and attach attributes that help you search later: order.items_count, tenant.tier, cache.hit. Follow the semantic conventions for common names (http.request.method, db.system.name, messaging.system) so backends understand them. Keep attribute cardinality in mind: an order ID is fine on a span, which is a single event, but never put user IDs or URLs with IDs into metric attributes, because each unique value creates a new time series. Record exceptions on the span and set an error status so failed requests are easy to find. Never put passwords, tokens or full card numbers in attributes.

ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
 
const tracer = trace.getTracer("checkout");
 
export async function placeOrder(cart: { items: unknown[] }) {
  return tracer.startActiveSpan("checkout.place_order", async (span) => {
    span.setAttribute("order.items_count", cart.items.length);
    try {
      const order = await saveOrder(cart); // pg / HTTP calls become child spans
      span.setAttribute("order.id", order.id);
      return order;
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({ code: SpanStatusCode.ERROR });
      throw err;
    } finally {
      span.end();
    }
  });
}

05Running the Collector

Sending telemetry from apps straight to a vendor works, but a Collector in between gives you control. Apps send OTLP to a local Collector quickly and return to work; the Collector batches, retries and buffers if the backend is slow, so your app is not affected. It is the right place to add environment attributes, drop noisy spans like health checks, remove sensitive data and apply tail sampling. It can also scrape Prometheus metrics and read log files, so it becomes your single telemetry pipeline. Use the contrib distribution (otelcol-contrib) for the full set of processors and exporters, or build a minimal one with the OpenTelemetry Collector Builder. Always include the memory_limiter processor first and batch last in each pipeline, and monitor the Collector's own metrics for dropped data.

yaml
# otel-collector.yaml
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }
processors:
  memory_limiter: { check_interval: 1s, limit_percentage: 80, spike_limit_percentage: 20 }
  attributes/scrub:
    actions: [{ key: http.request.header.authorization, action: delete }]
  batch: {}
exporters:
  otlphttp: { endpoint: https://otlp.example.com }
service:
  pipelines:
    traces: { receivers: [otlp], processors: [memory_limiter, attributes/scrub, batch], exporters: [otlphttp] }
    metrics: { receivers: [otlp], processors: [memory_limiter, batch], exporters: [otlphttp] }

Use it when

  • You run several services, queues or background workers and need to follow a request across them.
  • You want to avoid lock-in to one observability vendor or plan to switch backends later.
  • You need correlated traces, metrics and logs to debug latency and errors in production.
  • Your platform or framework already supports OTel natively (Next.js, Kubernetes, cloud load balancers, many databases).

Skip it when

  • A single small monolith where error tracking, uptime checks and good logs already answer every question.
  • A team with no time to run or pay for a tracing backend; telemetry that nobody looks at is only cost.
  • When a hosted tool's built-in agent already covers your needs and portability does not matter to you.

Alternatives

  • Vendor agents (Datadog, New Relic)

    Faster all-in-one setup with polished UIs, but proprietary instrumentation and usage-based bills. Most now also accept OTLP.

  • Sentry

    Error tracking first, with tracing; its JavaScript SDK is built on OpenTelemetry internally.

  • Prometheus + logs only

    Metrics and logs without traces: simpler and cheap, but you cannot follow one request across services.

  • Cloud-native tools

    AWS X-Ray, Google Cloud Trace, Azure Monitor; all now accept OpenTelemetry data.

Best practices

  1. Set service.name, service.version and deployment.environment.name on every service.
  2. Configure with OTEL_* environment variables so code stays identical across environments.
  3. Start with auto-instrumentation, then add a few custom spans around business-critical steps.
  4. Send data through a Collector with memory_limiter and batch processors.
  5. Sample traces deliberately: head sampling for volume, tail sampling to keep errors and slow requests.
  6. Inject trace IDs into logs so you can jump from a log line to its trace.
  7. Keep metric attribute cardinality low and never record secrets or personal data in attributes.

Common mistakes

  • Loading the instrumentation after the app imports its libraries, so nothing gets patched.
  • Forgetting service.name, so every service shows up as unknown_service.
  • Putting user IDs or full URLs into metric labels and exploding the number of time series.
  • Breaking context propagation across queues or custom HTTP clients, producing disconnected traces.
  • Keeping 100% of traces in production and getting a huge storage bill.

Deep dive

06Tail sampling at scale

Tail sampling keeps whole traces based on what happened in them, but a trace's spans arrive from many services at different times. The Collector holds spans in memory for a decision wait period (often 10–30 seconds) and then evaluates policies: status_code ERROR, latency above a threshold, specific attributes, or a probabilistic rate for the rest. With several Collector replicas, all spans of one trace must reach the same instance, so you run two tiers: agents or a first gateway layer using the load-balancing exporter keyed by trace ID, then a sampling tier. Size memory for traffic × wait time, and watch the processor's dropped-traces metrics. Remember that sampled-away traces also affect metrics derived from spans; generate RED metrics (rate, errors, duration) with the spanmetrics connector before sampling, so dashboards stay accurate.

yaml
processors:
  tail_sampling:
    decision_wait: 15s
    num_traces: 100000
    policies:
      - { name: errors, type: status_code, status_code: { status_codes: [ERROR] } }
      - { name: slow, type: latency, latency: { threshold_ms: 1000 } }
      - { name: baseline, type: probabilistic, probabilistic: { sampling_percentage: 5 } }

07Propagation through queues and async work

Automatic propagation covers synchronous HTTP and gRPC. Asynchronous work needs care. When you publish a job to Redis, RabbitMQ or Kafka, inject the active context into the message headers; when a worker picks it up, extract it and start a consumer span. Semantic conventions model this with producer and consumer span kinds. A long-delayed job raises a design question: should the job's spans be children of the request that created it? For short pipelines yes, but a job that runs an hour later makes a strange, very long trace. The alternative is a new trace with a span link to the original context, which preserves the relationship without stretching the trace. Batch consumers should use links to many producer contexts. Baggage can carry values such as tenant ID across services, but it is sent on every request, so keep it small and never put secrets in it.

ts
import { context, propagation, trace } from "@opentelemetry/api";
 
// Producer: carry the trace context inside the job payload
export function withTraceHeaders<T extends object>(payload: T) {
  const headers: Record<string, string> = {};
  propagation.inject(context.active(), headers);
  return { ...payload, otel: headers };
}
 
// Consumer: continue the trace when the job runs
export function runJob(job: { otel?: Record<string, string> }, work: () => Promise<void>) {
  const parent = propagation.extract(context.active(), job.otel ?? {});
  return context.with(parent, () =>
    trace.getTracer("worker").startActiveSpan("job.process", (span) => work().finally(() => span.end())),
  );
}

08Metrics design: instruments, temporality and cardinality

OTel metrics use instruments: Counter for things that only go up (requests, bytes sent), UpDownCounter for values that rise and fall (queue size, active connections), Histogram for distributions (latency, payload size), and Gauges for sampled current values. Histograms are what give you p95 and p99; exponential histograms give good accuracy with fewer buckets. Temporality matters when exporting: Prometheus-style backends expect cumulative values, while some vendors prefer delta, and the exporter can be configured either way. Cardinality is the main cost driver: each unique combination of attribute values is a separate time series. http.route=/orders/:id is fine; the raw URL /orders/123 is a disaster. Use views to drop or rename attributes and to change histogram buckets without touching instrumentation code.

09Overhead, safety and rollout

Instrumentation is not free, but it is usually small: a few percent of CPU, mostly from creating spans and exporting them. Keep it low by using the batch span processor (never the simple processor in production), sampling, and disabling noisy instrumentations such as file system or DNS spans. Exporting must never block requests: if the backend is down, the SDK drops data after its queue fills rather than slowing the app. Scrub sensitive data at two levels: avoid capturing it in code, and remove headers, query strings and SQL parameters in the Collector. Roll out service by service, starting with the edge and the most critical path, and compare latency before and after. Treat the Collector as production infrastructure: health checks, resource limits, horizontal scaling and alerts on dropped spans.

Real-world scenarios

01

Traces stop at the queue

Problem

Traces show the API request that enqueues a job, but the worker's spans appear as separate traces, so nobody can see the full path of an order.

How to solve it

The context is not being carried through the queue. Check whether an instrumentation exists for your queue library; enable it in both producer and consumer. If not, inject the context manually into the job payload with propagation.inject when enqueuing, and in the worker extract it and run the handler inside context.with. Decide between parent-child and links: for jobs that run within seconds, parent-child gives one readable trace; for delayed or batch jobs, start a new trace with a span link. Add messaging.system and messaging.destination.name attributes. Verify in the backend that the producer span and consumer span now share a trace ID or a link.

02

The tracing bill grows faster than traffic

Problem

After instrumenting all services with default settings, the observability vendor bill doubled. Most stored traces are health checks and fast successful requests.

How to solve it

Add a Collector gateway if there isn't one. Drop health check and readiness probe spans with a filter processor. Add tail sampling: keep 100% of errors and requests slower than your SLO threshold, and 2–10% of everything else. Generate RED metrics with the spanmetrics connector before sampling so dashboards and alerts stay accurate. Disable low-value instrumentations (fs, dns, net) in the SDK. Review attributes for large values like SQL text or payloads and truncate or drop them. Finally, set a budget per service and look at span counts by service to find the noisiest ones.

03

Migrating from a vendor agent to OpenTelemetry

Problem

The company uses a proprietary APM agent and wants to evaluate another backend without losing visibility during the move.

How to solve it

Deploy a Collector with OTLP receivers. Instrument one service with the OTel SDK and auto-instrumentations, keeping the same service names as before. Configure the Collector to export to both the current vendor (most accept OTLP) and the new backend, so you compare them on identical data. Move services one at a time, removing the old agent only after the OTel version shows equal coverage; never run two agents patching the same libraries in one process. Recreate key dashboards and alerts on semantic convention names. After the evaluation, switching or keeping vendors is a Collector configuration change, with no code changes.

Cheat sheet

Install Node SDK and auto-instrumentation
npm i @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-http
Zero-code start for Node.js
node --require @opentelemetry/auto-instrumentations-node/register app.js
Name the service
OTEL_SERVICE_NAME=checkout-api
Point at a Collector
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
Add resource attributes
OTEL_RESOURCE_ATTRIBUTES=service.version=1.4.2,deployment.environment.name=production
Keep 10% of new traces
OTEL_TRACES_SAMPLER=parentbased_traceidratio OTEL_TRACES_SAMPLER_ARG=0.1
Debug: print spans to the console
OTEL_TRACES_EXPORTER=console
Run a local Collector
docker run -p 4317:4317 -p 4318:4318 -v ./otel-collector.yaml:/etc/otelcol-contrib/config.yaml otel/opentelemetry-collector-contrib
Local trace UI
docker run -p 16686:16686 -p 4318:4318 jaegertracing/jaeger
W3C trace header format
traceparent: 00-<32-hex trace-id>-<16-hex span-id>-01

Questions & answers

Q1What is the difference between a trace and a span?

A span is one timed operation: an incoming HTTP request, a database query, a call to another service, or a piece of business logic. It has a name, start and end time, status, attributes and a parent. A trace is the whole tree of spans that share one trace ID, representing the journey of a single request through the system. The root span is usually the first entry point, such as the request at the API gateway, and child spans show the work it caused. Looking at a trace waterfall tells you where the time went and where the error happened, which is something individual logs cannot show.

Q2What is context propagation?

Context propagation is how a trace continues across process boundaries. When service A calls service B, A's SDK injects the current trace ID and span ID into the outgoing request, by default as the W3C traceparent header, plus tracestate and optional baggage. Service B's instrumentation extracts it and creates its spans as children of A's span, so both appear in one trace. For HTTP and gRPC this happens automatically. For message queues, you must carry the context in message headers or attributes and extract it in the consumer, which many messaging instrumentations do. If propagation breaks, you get separate, disconnected traces.

Q3Why use a Collector instead of exporting directly from the app?

The Collector decouples your application from the backend. The app sends to a nearby endpoint and moves on, while the Collector handles batching, retries, compression and buffering when the backend is slow. It centralises processing: adding attributes, filtering health checks, removing sensitive data and applying tail sampling, which needs to see whole traces and cannot be done inside one service. It also lets you change or add backends without redeploying apps, and send the same data to two vendors during a migration. Direct export is fine for a quick start or a single small service, but production systems usually have a Collector.

Q4What is sampling and which kind would you use?

Sampling decides which traces to keep, because storing every trace at high traffic is expensive. Head sampling decides at the start of a trace, for example keeping 10% with the parent-based TraceIdRatio sampler. It is cheap and consistent across services, but it may drop the rare error you care about. Tail sampling decides after the trace is complete, in the Collector, so it can keep all errors, all slow requests and a small percentage of the rest. It needs all spans of a trace to reach the same Collector instance and uses memory. A common mix is light head sampling in the SDK plus tail sampling policies in a gateway Collector.

Q5What does OpenTelemetry not do?

OpenTelemetry collects and transports telemetry; it does not store, visualise or alert on it. You still need a backend: Grafana with Tempo, Prometheus or Mimir and Loki, Jaeger for traces, or a commercial service such as Honeycomb, Datadog or New Relic. It also does not decide what is important: you choose service names, attributes, sampling and which business operations deserve spans. And it does not replace error tracking with grouping, release tracking and user-facing context, which is why many teams pair it with Sentry. Think of it as the standard plumbing that makes the rest of your observability stack interchangeable.

Senior-level questions

S1How would you roll out OpenTelemetry across 40 services owned by different teams?

Treat it as a platform product. Provide a thin internal package or base image per language that configures the SDK, resource attributes, propagators and exporters from OTEL_* variables, so teams add one dependency rather than designing setup. Run Collectors as agents (DaemonSet or sidecar) plus a gateway tier owned by the platform team for sampling, scrubbing and routing. Publish conventions: service naming, required attributes such as tenant tier, which business operations get spans, and cardinality rules for metrics. Start with the request path from the edge to the database so traces are useful immediately, then expand. Measure adoption with span coverage per service. The trade-off is central control versus team autonomy; a paved road with sensible defaults usually wins.

S2Head sampling, tail sampling or no sampling: how do you decide?

Start from questions and budget. If traffic is low, keep everything; it is simplest and you never miss data. At higher volumes, head sampling with a parent-based ratio sampler is cheap, has no Collector memory cost and keeps traces complete across services, but it is blind: rare errors are dropped at the same rate as successes. Tail sampling keeps what matters (errors, slow requests, specific tenants) and drops boring traffic, but needs a stateful Collector tier with trace-ID-aware load balancing, memory sized for the decision window, and it adds latency before data appears. Many teams combine them: generous head sampling to limit volume from very hot services, tail sampling for intelligent selection, and span-derived metrics computed before sampling so aggregate numbers stay correct.

S3How do you correlate OpenTelemetry traces with logs in an existing logging setup?

The goal is that every log line written inside a request contains trace_id and span_id. In Node.js, the instrumentation packages for pino and winston inject them automatically into log records; in PHP, Java and Python there are similar integrations or the OTel logs bridge. Keep your existing logger: OTel's logs API is meant to be bridged from familiar libraries, not called directly. Then either send logs through the Collector over OTLP, or keep shipping them as JSON files and make sure the trace_id field is indexed. In the backend, configure a link from trace view to a logs query filtered by trace_id, and from log lines to traces. The trade-off is volume: sending all logs via OTLP centralises the pipeline but increases Collector load, so filter debug logs early.

Glossary

Span
One timed operation in a trace, with a name, duration, status, attributes and a parent span.
Trace
All spans that share a trace ID, showing one request's path through the system.
OTLP
The OpenTelemetry Protocol for sending traces, metrics and logs, over gRPC (port 4317) or HTTP (port 4318).
Collector
A standalone service that receives, processes and exports telemetry to one or more backends.
Resource
Attributes describing the source of telemetry, such as service.name, version and environment.
Semantic conventions
Standard attribute names (http.request.method, db.system.name) so tools understand telemetry from any source.
Context propagation
Passing trace and span IDs between services, usually in the W3C traceparent header.
Tail sampling
Choosing which traces to keep after they finish, for example keeping all errors and slow requests.
Cardinality
The number of unique attribute value combinations in a metric; high cardinality multiplies storage cost.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.