Trace, Span, Attribute, Status
A span is a timed operation with a parent, a status and a bag of attributes. Which facts belong in attributes, which belong in span events, and which belong in a metric instead is the difference between a trace you can query and a very expensive log line.
Frame the diagnosis
Performance work starts from a symptom and a signal — never from a resource dashboard.
The fields, and what each one is for
A span is a small, fixed record. Trace id groups it with everything else caused by the same request. Span id identifies it. Parent span id points at the work that caused it, which is what makes the tree. Start time and duration place it on the timeline. Status says whether the operation succeeded. Attributes are typed key/value facts about *this* operation. Everything expensive about tracing follows from the fact that this record is emitted once per operation per request.
Two fields carry more weight than people expect. Name should be a low-cardinality operation identifier — GET /orders/{id}, not GET /orders/8812 — because the name is what you group by; putting the id in the name gives you a million distinct operations and no aggregation, the same cardinality mistake covered in Cardinality: The Label That Took Down Monitoring. Status should be set deliberately: a span that returned HTTP 404 is usually not an error of the span, while a span that threw is. Sloppy status handling makes error-rate queries over traces useless.
Span events are the underused field. An event is a timestamped annotation *inside* a span — "connection acquired", "retry 1 scheduled", "cache miss" — which lets you explain a 300 ms span without creating three more spans. Events are cheaper than child spans and better than logs for anything whose only meaning is relative to this operation.
trace_id 4bf92f3577b34da6a3ce929d0e0e4736 span_id 00f067aa0ba902b7 parent_id a3ce929d0e0e4736 name orders-db INSERT <- low cardinality: no ids in the name kind CLIENT <- this process called out to something start 2026-08-25T14:03:11.482Z duration 40.2ms status OK attributes db.system postgresql db.operation INSERT db.table orders db.rows_affected 1 net.peer.name orders-db.prod tenant.tier enterprise <- bounded set: useful to group by events 14:03:11.484 connection acquired from pool (waited 1.8ms) 14:03:11.521 statement executed
Span kinds, and why the tree needs them
Kind tells the backend what role this span played: SERVER (this process handled an inbound request), CLIENT (it called out and waited), PRODUCER / CONSUMER (it put work on or took work off a queue), INTERNAL (local work worth timing). The pairing matters: a CLIENT span in one service and the SERVER span it caused in another are the two halves of one network hop, and the difference between their durations is exactly the network plus queueing time — which is often where a mystery 60 ms lives.
Producer/consumer is the pair that trips teams up, because a queued job is not a child of the request that enqueued it in any useful sense: the request finished, possibly minutes earlier. Modelling it as a child produces a trace whose root span is somehow 4 minutes long. The correct shape uses a link instead, which is the subject of Parents, Children and Links.
Getting kinds right is not bureaucracy — backends use them to compute service maps, to decide what counts as an entry point for RED metrics, and to detect the caller/callee latency gap above. Kind-less spans still render, and quietly disable half your tooling.
| Kind | What it marks | What it makes queryable |
|---|---|---|
SERVER | An inbound request this process handled | Per-service RED metrics; the entry point of the trace |
CLIENT | An outbound call this process waited on | Dependency latency as *the caller* experienced it |
PRODUCER | Work handed to a queue or topic | Enqueue rate and the moment work was deferred |
CONSUMER | Work picked up from a queue | Queue wait time: consumer start minus producer time (Depth Is Not an Emergency; Age Is) |
INTERNAL | Local work worth timing on its own | Self time attribution inside a fat span |
Attributes, events, logs or metrics — choosing the right home
The reflex to put everything on the span is expensive and, for some fields, dangerous. Attributes are stored per span per request: a 2 KB serialized request body on a span emitted 8,000 times a second is 16 MB/s of telemetry to teach you nothing you could not have sampled. Worse, request and response bodies are exactly where bearer tokens, session cookies and personal data live, and a tracing backend is usually readable by a far wider audience than your production database — the same trap as What You Just Wrote Into a Log Half the Company Can Read.
The useful rule is to ask what the field is *for*. If you will group or filter by it and it has a bounded set of values (route, table, cache hit/miss, tenant tier, model name), it is an attribute. If it is a timestamped thing that happened during the operation, it is a span event. If it is a full payload or a long free-text message, it belongs in a log correlated by trace id — logs are searched, not aggregated, and that is the right access pattern for bulk detail. If you need it aggregated across all requests including unsampled ones, it must be a metric, because sampled traces cannot produce trustworthy totals.
That last point is the one that bites teams late. Traces are sampled; metrics are not. Any number you plan to alert on, put in an SLO, or bill against has to come from a metric, no matter how faithfully your spans record it (Sampling Without Throwing Away the Evidence).
1span.setAttributes({2 'http.request.body': JSON.stringify(req.body), // 2 KB, every request3 'http.request.headers': JSON.stringify(req.headers), // contains Authorization4 'user.email': user.email, // PII in a widely-readable store5 'user.id': user.id, // unbounded: fine as attribute,6 // catastrophic if it reaches a metric label7 'debug.note': 'trying the new pricing path v3',8})9span.setName(`POST /orders/${order.id}`) // a million distinct span names1span.updateName('POST /orders/{id}') // groupable2span.setAttributes({3 'http.route': '/orders/{id}',4 'http.response.status_code': res.statusCode,5 'order.pricing_path': 'v3', // bounded set: v1 | v2 | v36 'cache.hit': false,7 'tenant.tier': tenant.tier, // bounded: free | pro | enterprise8})9span.addEvent('pricing.fallback', { reason: 'timeout' })10// bulk detail goes to a log carrying trace_id; totals come from a counter11logger.info({ trace_id: ctx.traceId, body: req.body })The second version answers "p99 of the v3 pricing path for enterprise tenants on cache miss" with one query. The first version answers nothing, costs an order of magnitude more to store, and puts an Authorization header into a system half the company can read.
Key points
- A span is trace id, span id, parent id, name, kind, start, duration, status and attributes — emitted once per operation per request, which is why every field has a cost.
- Span names must be low cardinality (
GET /orders/{id}); ids in names destroy the ability to aggregate, exactly as ids in metric labels do. - Span kind is not decoration: CLIENT/SERVER pairs expose network and queue wait, PRODUCER/CONSUMER model async work, and backends build service maps from them.
- Attributes are for bounded values you will group by; span events for things that happened mid-operation; logs for bulk detail; metrics for anything you will alert on.
- Request bodies and headers on spans are both the largest storage cost and the most common way credentials leak into telemetry.
Follow the diagnosis
The causal chain, hop by hop — and the readings that invite the wrong conclusion.
- 1Instrumentation → span: the handler names its span from the raw path, so every order id becomes a distinct operation name.
- 2Span → backend: the backend groups by name and finds a million groups of one, so no aggregate view is possible.
- 3Engineer → backend: during an incident, the query "which route is slow" returns nothing usable, so the trace data goes unread.
- 4Team → conclusion: "tracing did not help us", when what failed was the span schema, not tracing.
- • "More attributes means better observability." More attributes means a larger bill and, past a point, a slower backend. Attributes you never filter by are pure cost.
- • "The span has the status field set to OK, so the operation succeeded." Many auto-instrumentations leave status unset or set it from HTTP codes only; a handled exception can leave a green span.
- • "We can compute our error rate from traces." Not if traces are sampled. Sampled data gives you shapes and examples, not totals.
- • "Putting the user id on the span is the same mistake as putting it in a metric label." It is not — spans are individual records, not time series. High-cardinality attributes are fine on spans and fatal on metrics (Cardinality: The Label That Took Down Monitoring).
Measure, fix, validate
An optimization is not finished until the metric that motivated it has moved.
- • Try to write the three queries you would want during an incident ("p99 by route", "error rate by dependency", "latency on cache miss") and see which attributes are missing.
- • Count distinct span names in the last hour: if it is in the thousands, ids are leaking into names.
- • Measure average span payload size (bytes/span) — it is the multiplier on your entire tracing bill.
- • Grep a sample of stored spans for `authorization`, `token`, `password`, `email` before anyone else does ([[logs-and-secrets]]).
- • Adopt a naming convention with route templates, and enforce it in review — the cheapest fix and the one that unlocks aggregation.
- • Define a small required attribute set per span kind (route, status, dependency, tenant tier) so every service is queryable the same way.
- • Move bulk payloads out of attributes into logs correlated by trace id, and mark sensitive fields for redaction at the SDK, not the backend.
- • Set span status deliberately in error paths rather than relying on auto-instrumentation defaults.
- • Re-run the three incident queries; they should now return grouped results without hand-editing.
- • Distinct span names should collapse from thousands to dozens after route templating.
- • Telemetry bytes per request should drop measurably once payloads leave attributes — track it as its own metric.
- • Re-run the secret grep over stored spans and confirm zero hits.
- • Route templating loses the exact id; you recover it from a log correlated by trace id, which is one hop more work during debugging.
- • A required attribute set is real instrumentation effort per service and needs enforcement to survive.
- • Redaction at the SDK is safer and slightly slower on the hot path than redaction at the backend.
- • Span events are cheaper than child spans but do not show up on the waterfall as duration, so genuinely long sub-operations still deserve spans.
- • Lint span names in CI: fail the build on names containing digits or UUID-shaped segments.
- • Cap attribute count and value length in the SDK so a well-meaning debug attribute cannot ship a payload.
- • Alert on telemetry bytes per second the way you alert on any other cost line.
- • Add a redaction test to the instrumentation test suite that asserts
Authorizationnever reaches an exporter.
Accuracy
Performance numbers are conditional. These are the conditions.
- ILLUSTRATIVEThe span record shown is a generic shape; exact field names and semantic conventions vary by SDK and backend.
- ENVIRONMENT-SPECIFICWhich attributes are cheap depends on the backend: some index every attribute, some only indexed ones, and the cost model differs by an order of magnitude between them.