Tracesspansattributesspan kindsstatusschema

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.

Follow the diagnosis

Frame the diagnosis

Performance work starts from a symptom and a signal — never from a resource dashboard.

Diagnostic question
What exactly is recorded for one unit of work, and which facts about it belong in the span rather than in a log or a metric?
Symptom
The traces exist but nobody queries them: spans are named `handler`, carry either nothing useful or an entire request body, and the one attribute you need to filter by — tenant, route, cache hit — was never recorded.
Signal
The span schema itself. If you cannot write the query "p99 of `db.query` spans where `db.table = orders`" then the attributes are wrong, no matter how complete the traces look.
SymptomSignalMeasurementHypothesisEvidenceRoot CauseChangeValidationRegression Check

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.

One span, as stored. ILLUSTRATIVE, in the shape most backends use.
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.

Span kinds and the question each one makes answerable
KindWhat it marksWhat it makes queryable
SERVERAn inbound request this process handledPer-service RED metrics; the entry point of the trace
CLIENTAn outbound call this process waited onDependency latency as *the caller* experienced it
PRODUCERWork handed to a queue or topicEnqueue rate and the moment work was deferred
CONSUMERWork picked up from a queueQueue wait time: consumer start minus producer time (Depth Is Not an Emergency; Age Is)
INTERNALLocal work worth timing on its ownSelf 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).

A span used as a log line and a debugger
1span.setAttributes({
2 'http.request.body': JSON.stringify(req.body), // 2 KB, every request
3 'http.request.headers': JSON.stringify(req.headers), // contains Authorization
4 'user.email': user.email, // PII in a widely-readable store
5 'user.id': user.id, // unbounded: fine as attribute,
6 // catastrophic if it reaches a metric label
7 'debug.note': 'trying the new pricing path v3',
8})
9span.setName(`POST /orders/${order.id}`) // a million distinct span names
Attributes chosen for the queries you will actually run
1span.updateName('POST /orders/{id}') // groupable
2span.setAttributes({
3 'http.route': '/orders/{id}',
4 'http.response.status_code': res.statusCode,
5 'order.pricing_path': 'v3', // bounded set: v1 | v2 | v3
6 'cache.hit': false,
7 'tenant.tier': tenant.tier, // bounded: free | pro | enterprise
8})
9span.addEvent('pricing.fallback', { reason: 'timeout' })
10// bulk detail goes to a log carrying trace_id; totals come from a counter
11logger.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.

  1. 1
    Instrumentation → span: the handler names its span from the raw path, so every order id becomes a distinct operation name.
  2. 2
    Span → backend: the backend groups by name and finds a million groups of one, so no aggregate view is possible.
  3. 3
    Engineer → backend: during an incident, the query "which route is slow" returns nothing usable, so the trace data goes unread.
  4. 4
    Team → conclusion: "tracing did not help us", when what failed was the span schema, not tracing.
What this evidence makes people conclude — wrongly
  • "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.

How to measure it
  • • 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]]).
What actually fixes it
  • • 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.
How you know it worked
  • • 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.
What it costs
  • • 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.
Stop it coming back
  • 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 Authorization never reaches an exporter.

Accuracy

Performance numbers are conditional. These are the conditions.

What these numbers depend on
  • 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.

Misconceptions

Claim
“Auto-instrumentation gives you a complete span schema.”
Reality
It gives you transport-level facts — method, route, status. It cannot know your domain, so the attributes that make a trace useful during *your* incident (pricing path, cache hit, tenant tier) are always manual.
Claim
“High-cardinality attributes are always bad.”
Reality
On metrics, yes — each combination is a new time series. On spans, a user id is just a field on one record; it is a storage and privacy question, not a cardinality explosion.
Claim
“Span status is set automatically when something fails.”
Reality
Only for failures the instrumentation sees. An exception you caught and turned into a fallback leaves a span that looks perfectly healthy unless you set the status yourself.