For years, observability meant three disconnected silos and a different agent for each vendor. OpenTelemetry (OTel) is the industry-wide answer: a single set of APIs, SDKs, and a wire protocol (OTLP) for traces, metrics, and logs. Instrument your code once, then point the data at Jaeger, Prometheus, Grafana, Datadog, or whatever you switch to next year, no re-instrumentation.
The three signals
Traces: the path of a request across services, broken into spans. This is how you answer "why was this request slow?"
Metrics: aggregated numbers over time, request rate, error rate, latency percentiles, queue depth.
Logs: discrete events, now correlatable to traces via a shared trace ID.
The magic is correlation. Click a slow trace, jump to the exact logs emitted during that span, and see the metrics for that service, all linked by context that OTel propagates automatically.
Auto-instrumentation: value in minutes
Before writing a single span by hand, turn on auto-instrumentation. For Node.js it patches common libraries (HTTP, Express, pg, Redis) at load time:
// instrumentation.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({ url: 'http://otel-collector:4318/v1/traces' }),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();Load it before your app with node --require ./instrumentation.js app.js and you get distributed traces across every instrumented library for free.
Custom spans where they matter
Auto-instrumentation covers the plumbing. Add manual spans around business logic you actually care about:
import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('orders');
async function processOrder(order) {
return tracer.startActiveSpan('process-order', async (span) => {
span.setAttribute('order.id', order.id);
span.setAttribute('order.total', order.total);
try {
await chargePayment(order);
await reserveInventory(order);
span.setStatus({ code: 1 }); // OK
} catch (err) {
span.recordException(err);
span.setStatus({ code: 2, message: err.message }); // ERROR
throw err;
} finally {
span.end();
}
});
}The Collector: your observability control plane
Do not export straight from apps to a backend. Run the OpenTelemetry Collector in between. It receives, processes (batch, filter, redact PII, sample), and exports to one or more destinations. Swapping vendors becomes a config change, not a redeploy.
receivers:
otlp:
protocols:
http:
grpc:
processors:
batch:
# Drop noisy health-check spans
filter/health:
traces:
span:
- 'attributes["http.target"] == "/healthz"'
exporters:
otlphttp/tempo:
endpoint: http://tempo:4318
prometheus:
endpoint: 0.0.0.0:8889
service:
pipelines:
traces:
receivers: [otlp]
processors: [filter/health, batch]
exporters: [otlphttp/tempo]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheus]Sampling: you cannot store every trace
At scale, keeping 100% of traces is expensive and mostly redundant. Use tail-based sampling in the Collector: buffer spans, then keep the interesting ones, every error, every slow request, and a small percentage of normal traffic.
Head sampling decides before it knows the outcome. Tail sampling decides after, so you never drop the trace that actually explains the outage.
Migration strategy
Turn on auto-instrumentation for one service and ship traces to a Collector.
Add the Collector as a buffer in front of your existing backend.
Migrate metrics and logs to OTLP once traces are stable.
Add custom spans and tail sampling as maturity grows.
OpenTelemetry ends vendor lock-in for observability. Instrument once against an open standard, and keep your data, and your options, portable.



