Most instrumentation starts with whatever the SDK hands you and grows outward. That produces spans that describe a request well and answer almost nothing about a fleet. The reverse order works better: write the five queries your team actually runs when something is slow, then admit only the attributes those queries need.
For an LLM service the short list usually looks like this:
- p95 latency by model family over the last hour.
- p95 latency and error rate by tenant tier, to catch noisy neighbors.
- Latency and outcome when retrieval hit versus missed.
- Error rate by outcome class (success, client error, provider error, rate limited).
- Token volume by operation, to explain cost swings.
Each of those queries names a grouping key. Those keys are your attribute budget. Anything that does not appear in a GROUP BY, a filter, or a comparison is a candidate for a log line instead.
The reason this matters is economic as much as aesthetic. A trace is a collection of structured logs with context and hierarchy baked in, as OpenTelemetry's own documentation puts it (traces concept). Every distinct combination of attribute values becomes a series your backend has to index and store. Structured logging guidance makes the same point from the logging side: equality filters and percentile calculations are easy over named fields and impossible over free text (structured logging best practices). Spans are the same discipline with a hierarchy attached.
A simple test before you add an attribute: name the query it enables. If you cannot, you are buying storage without buying an answer.



