You Have Logs. You Don't Have Observability.
Most teams add structured JSON logging and call it observability. Without trace context propagating across service boundaries, you still can't answer why a request failed at 2am. Here's how to actually fix it with OpenTelemetry.

Most teams graduate from console.log to structured JSON logging and consider the problem solved. Datadog dashboards, Grafana Loki, whatever — the logs are searchable, they're in JSON, that's observability, right?
Then 2am arrives. Checkout is 500ing. You filter Datadog for the error... it's in the payment service. But *why*? Payment called inventory, inventory called the database, something timed out somewhere. Each service has beautiful JSON logs. None of them share a request ID. You're correlating by timestamp, squinting at three tabs, reconstructing a failure story that should have been handed to you automatically.
This is the structured-logging trap. Logs are correct. Observability is broken.
The mistake: logs without identity
The problem isn't log format — it's **identity propagation**. A distributed request is a single logical operation crossing multiple process boundaries. Without a shared identifier that flows through every hop, your logs are isolated snapshots. Each one is accurate. Together they tell you nothing.
This isn't a new insight. Google published the Dapper paper in 2010. Zipkin shipped in 2012, Jaeger in 2016. The W3C finalized the traceparent header standard in 2019. And teams are *still* adding structured logging in 2026 and wondering why debugging distributed systems feels like archaeology.
The W3C Traceparent header looks like this:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01That's: version, 128-bit trace ID (the whole request across all services), 64-bit span ID (this service's segment), sampling flags.
Every service reads this header on inbound requests, creates a child span, attaches the trace context to every log line it emits, then forwards the updated header to whatever it calls next. When you're debugging the incident, you filter your log aggregator for trace_id=4bf92f... and see the entire call chain: API gateway received the request, checkout picked it up, inventory timed out on a Postgres query, timeout propagated as a 503, checkout surfaced it as a 500.
The whole story. One filter.
Trace propagation in action
Every hop adds a child span with the same trace ID. Every log line on every hop includes that trace ID. Filter once, see everything.
OpenTelemetry: instrument once, export anywhere
OpenTelemetry (OTel) is the CNCF standard for emitting traces, metrics, and logs. The pitch: instrument your service once with the OTel SDK and export to Jaeger, Grafana Tempo, Honeycomb, Datadog — they all speak OTLP. No vendor lock-in at the instrumentation layer.
For Node.js, auto-instrumentation patches http, fetch, pg, redis, and most popular libraries automatically:
// tracing.js — load first: node --require ./tracing.js server.js
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();Incoming HTTP requests get root spans automatically. Outgoing HTTP calls propagate traceparent automatically. Database queries become child spans with query text attached. Zero manual instrumentation work.
The one thing you still need to do manually: attach trace context to your log records.
import { trace } from '@opentelemetry/api';
import pino from 'pino';
const logger = pino({
mixin() {
const span = trace.getActiveSpan();
if (!span) return {};
const { traceId, spanId } = span.spanContext();
return { traceId, spanId };
},
});Now every logger.error('stock check failed') emits {"traceId":"abc...","spanId":"03","msg":"stock check failed"}. Logs and traces are correlated automatically — no manual threading of request IDs through every function call.
The collection pipeline
Spans and logs flow from your services to the **OTel Collector**, a standalone daemon that receives OTLP, runs processors (redact PII, enrich with metadata, sample), then routes to your backends.
The collector decouples your services from backend decisions. Switch from Jaeger to Tempo without touching a single service. Add a new backend without redeploying anything.
Sampling: the part everyone ignores until the bill arrives
At 1000 RPS, recording every span generates a million spans per minute. Storage costs compound fast.
**Head-based sampling** decides at the entry point — sample 1% of requests — and propagates that decision downstream via the traceparent flags. Simple, low overhead. The problem: you can't know at request start whether this will be an error worth keeping.
**Tail-based sampling** is the right call. Buffer spans at the collector for 30 seconds; if the trace ends with an error or high latency, keep everything. Otherwise apply probabilistic sampling.
processors:
tail_sampling:
decision_wait: 30s
policies:
- name: keep-errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: keep-slow
type: latency
latency: { threshold_ms: 1000 }
- name: sample-rest
type: probabilistic
probabilistic: { sampling_percentage: 1 }You get 100% of errors and slow traces, 1% of everything else. Full coverage of what matters, a fraction of the storage cost.
Tradeoffs
**Cardinality**: Don't put high-cardinality values (user IDs, order IDs) in metric label values — that OOMs Prometheus. Put them in span attributes and log fields instead. Metrics measure rates; traces explain individual requests.
**Overhead**: Auto-instrumentation adds 2–5ms per request. Fine for most workloads. For sub-10ms SLOs, instrument manually with targeted spans over the specific operations you care about.
**Edge gaps**: Edge functions and short-lived workers frequently drop context. If your CDN doesn't forward
traceparentto your origin, the trace breaks at the boundary. Most edge runtimes support it if you explicitly allow it — check your config before assuming propagation works end to end.**Alerts still live on metrics**: Traces explain *why* an alert fired; they don't replace it. A spike in error rate triggers on a Prometheus metric; the trace tells you which request failed and at which span. Both layers coexist. Don't try to consolidate them.
The shift in mindset: logs answer "what happened on this machine." Traces answer "what happened to this request." Without both, you have half a picture. But if you're starting from zero, a working trace context pipeline buys you more debugging leverage than any amount of additional log verbosity.