Logs, metrics, and traces — three questions, not three formats
coreintermediateThe three signals are not three ways of writing down the same thing. A **log** is a record of one event, with as much detail as you like — it answers "what happened in this request". A **metric** is a number aggregated over time, cheap to keep and cheap to query — it answers "is this happening more than usual". A **trace** follows one request across services and shows where its time went. You need all three because each is bad at the others' job: you cannot alert on logs affordably, you cannot debug a specific failure from a metric, and neither shows you which downstream service was slow.
Think of it as
Choose the signal by the question, and let cost decide the shape. Metrics are pre-aggregated, so the cost does not grow with traffic — it grows with the number of distinct label combinations, which is why putting a user id or a URL with an id in it into a metric label is the classic way to make a monitoring system fall over. That is the same property that makes them the right thing to alert on: querying "error rate over the last five minutes" is a cheap lookup, not a search. Logs are the opposite: cost grows directly with volume, and their value is detail — the parameters, the user, the exact exception. So logs are where you go once you already know something is wrong and want to know what, and that is also why they should be structured, since searching by field beats grepping for a substring. Traces sit between the two: they carry the shape of a request across process boundaries, which neither of the others can, and they are usually sampled because keeping every one is expensive. The thing that turns three separate tools into one system is a shared identifier. If the log line, the trace and the error report all carry the same `request_id`, an alert on a metric leads to a trace, which leads to the exact log lines, which lead to the stack trace. Without it you have three search boxes and no way to line up their answers, which in practice means people use whichever one they know and guess about the rest.
What we're doing: Emit all three from one request, with labels that stay bounded and a shared id that ties them together.
- 2–4
- The span is the only place a high-cardinality id belongs as an attribute. Traces are stored per request, so an order id there costs nothing extra — unlike the same id on a metric.
- 10
- One metric name, one bounded label: `result` takes a handful of values whatever the traffic. This is the shape that can be alerted on.
- 11–14
- The log carries the detail the metric deliberately does not, plus the `request_id` that lets someone jump from the metric to this exact line.
- 18–19
- A counter and a timing from the same event. The counter answers "how often", the timing answers "how slow" — and neither can substitute for the other.
- 26–28
- The two forms that break a metrics backend: an id in the metric *name*, and an id or an email in a *label*. Both create an unbounded number of series, and the second also puts personal data into a system that is rarely access-controlled like your database.
Why this works: The same event produces a cheap aggregate, a detailed record and a span — each carrying what it is good at, and all three carrying the id that lets you walk between them.
Putting an unbounded value in a metric label
Wrong
Better
What you see: The metrics backend slows down, then starts dropping data or billing sharply more, and dashboards that used to load instantly time out. The cause is usually weeks old by the time it is noticed.
Why: A metrics system stores one time series per unique combination of name and labels, so a label whose values are unbounded creates unbounded series — this is called a cardinality explosion, and it degrades the whole backend rather than one dashboard. `request.path` contains ids; `resolver_match.route` is the URL *pattern*, which takes as many values as you have routes. The general rule is that a label value must come from a small, fixed set you can name. High-cardinality context still has two good homes: trace attributes and structured log fields.
- Three panels side by side, one per observability signal.
- LOGS: answers "what happened in this one request?", high detail, and its cost grows with event volume.
- METRICS (highlighted): answers "is it happening more than usual?", aggregated, and its cost grows with the number of label combinations rather than with traffic.
- TRACES: answers "where did the time go, across services?", normally sampled, and its cost grows with the sample rate.
- Below the three panels, a horizontal accent line marks the shared request_id field that joins them, with the working order: alerts fire on metrics, you debug in traces, and you confirm in logs.
Which signal answers which question
Together
Remember: Alert on metrics, debug in traces, confirm in logs — the ordering follows from what each one costs. Metric cost grows with label cardinality, so labels must come from a small fixed set: use the URL route, never the path; put ids on trace attributes and in structured log fields instead, where they are free. Traces are the only signal that crosses a service boundary. And give all three the same `request_id`, because that single shared field is the difference between one system and three search boxes.
See also: request ids correlation ids and error tracking · the signals worth measuring · json logs and context fields

