Monitoring tells you a number crossed a line. Observability is whether a stranger on-call can ask a new question and get an answer from the telemetry you already emit. That is a data-model problem first, a vendor dashboard problem last.
███████╗██╗ ██████╗ ███╗ ██╗ █████╗ ██╗ ███████╗ ██╔════╝██║██╔════╝ ████╗ ██║██╔══██╗██║ ██╔════╝ ███████╗██║██║ ███╗██╔██╗ ██║███████║██║ ███████╗ ╚════██║██║██║ ██║██║╚██╗██║██╔══██║██║ ╚════██║ ███████║██║╚██████╔╝██║ ╚████║██║ ██║███████╗███████║ ╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚══════╝
Metrics answer "is it bad, and how bad." Traces answer "which hop." Logs answer "what exactly." If those three do not share a correlation id, you have three products, not a system. OpenTelemetry exists to make the shared context boring: one SDK family, one context propagator, exporters you can swap.
Do not start by picking a commercial backend. Start by listing the questions you must answer at 3 a.m.: error rate by route, latency by dependency, the trace for this request id, the log line that named the customer-facing order id. Then instrument until those queries are cheap.
APP / WORKER / EDGE
traces (spans)
metrics (counters, histograms)
logs (structured records)
│ W3C Trace Context + Baggage
▼
OTEL SDK (batch, sample, redact)
│ OTLP
▼
COLLECTOR (receive → process → export)
│
├── traces → store / APM
├── metrics → TSDB
└── logs → log store
One collector graph per environment.
Apps do not speak five vendor SDKs.
RED (Tom Wilkie) fits request-driven services. USE (Brendan Gregg) fits disks, queues, and thread pools. A service that is "green" on RED can still be saturated on USE (thread pool at 100%, queue depth climbing). Instrument both classes where they apply.
traceparent carries version, trace-id, parent-id, and flags. Every hop that does not forward it breaks the tree. That includes the queue worker that consumed the outbox, the cron that retried the payment, and the edge that terminated TLS. If you only instrument HTTP handlers, you will debug half a system.
sequenceDiagram participant B as Browser participant E as Edge participant A as API participant Q as Worker B->>E: request E->>A: traceparent A->>A: span handler A->>Q: enqueue + trace context Q->>Q: span process-order Note over B,Q: One trace-id across hops
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
tracestate: vendorname=opaque
# 00 version
# 0af765... trace-id (16 bytes hex)
# b7ad6b... parent-id
# 01 sampled
Head sampling decides at the root span. Tail sampling decides after you have the tree (keep errors and slow traces). Production defaults: sample a fraction of healthy traffic, keep all errors, keep a higher fraction of traces that touch a payment or auth path. Do not sample by "whatever the SDK default was on the day someone copied a snippet."
A metric label with user id, email, or request url unbounded values explodes time series. That is not a pricing debate. It is a storage and query-latency fact. High-cardinality identity belongs on spans and log fields, not on metric labels.
Safe metric labels: service, route template (/orders/:id not the raw path), status class, dependency name, region. Unsafe: user id, raw URL, email, session token, full user-agent.
| Field | Metrics | Spans / logs |
|---|---|---|
| service.name | Yes | Yes |
| http.route | Yes (template) | Yes |
| user.id | No | Yes, if policy allows |
| order.id | No | Yes |
| http.status_code | Yes (or class) | Yes |
The OpenTelemetry Collector can drop, hash, or flatten attributes before they hit a backend. Put the policy there so application teams cannot invent a label that pages finance. Tail sampling belongs here too.
Google's SRE workbook is still the clean model: an SLI is a measurement (ratio of successful probes, or the fraction of requests faster than a threshold). An SLO is a target for that SLI over a window. An error budget is the remainder. Alerts fire on budget burn, not on "CPU > 80" unless CPU is the SLI you meant.
Do not invent a fake 99.999% target in a document. Pick a window and a measurement you can actually compute from your metrics. Multi-window, multi-burn-rate alerts (also from that workbook) beat a single threshold that pages on blips and sleeps through slow burns.
OpenTelemetry semantic conventions name HTTP, DB, messaging, and FaaS attributes. Use them. http.route not endpoint. db.system not database_type. Folklore attributes make every dashboard a translation layer.
import { trace } from "@opentelemetry/api";
const tracer = trace.getTracer("orders");
export async function placeOrder(input: OrderInput) {
return tracer.startActiveSpan("orders.place", async (span) => {
span.setAttribute("http.route", "POST /orders");
span.setAttribute("order.id", input.id);
try {
const result = await writeOrder(input);
span.setStatus({ code: 1 }); // OK
return result;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: 2, message: (err as Error).message });
throw err;
} finally {
span.end();
}
});
}
HTTP and DB auto-instrumentation gives you the skeleton. The span a human wants is orders.place or stk.callback.apply. If the only spans you have are GET and SELECT, you will still not know which business step failed.
No secrets. No session tokens. No passwords. No raw authorization headers. No health identifiers (this site does not process PHI; if you do, keep clinical fields out of logs and spans). Prefer resource ids over payloads. If you must log a payload for a short debug window, gate it behind a flag with an expiry.
Vendor-neutral rule: the commercial observability backend is another attacker surface. Treat it like a replica of production data and minimize what you send.
Putting a trace id in a user-facing error page is useful. Putting the request body in that same page is a leak. Same for support tools. Share the id. Fetch the span from a system that already has access control.
traceparent through HTTP, queues, and crons.