Writing TraceQL Queries for Latency Outliers
Set the duration threshold just above the endpoint’s p95 rather than sorting by slowest, then narrow structurally — “slow and contains a slow database span” — because the slowest traces in any window are almost always pathological cases that explain nothing about what users experienced.
Context and when it matters
The default investigative move is to sort by duration and open the top result. It is the wrong move often enough to be worth unlearning. The slowest trace in a busy window is typically a request that hit a cold cache during a deploy, a retry storm against a briefly unavailable dependency, or an internal batch job that happens to share the endpoint. Fixing what it shows you fixes something nobody experienced.
What you want is a trace from the population that did degrade: slower than normal, but not so extreme that it represents a different phenomenon. That is the band between roughly the 95th and 99th percentile, and querying for it deliberately is one line of TraceQL.
Anchoring the threshold
# Read the percentile first, then query the band it defines.
{ resource.service.name = "checkout-api" && span:kind = server
&& span.http.route = "/checkout/{orderId}" }
| quantile_over_time(span:duration, .95, .99)
# Then open traces from between those two numbers, not above them.
{ resource.service.name = "checkout-api" && span:kind = server
&& span.http.route = "/checkout/{orderId}"
&& span:duration > 1.9s && span:duration < 4.2s }
Narrowing structurally
A duration filter alone still returns a mixed population. Adding a structural condition splits it into hypotheses you can test one at a time.
# Slow AND the database was slow — the "database is the cause" hypothesis
{ resource.service.name = "checkout-api" && span:duration > 1.9s }
>> { span.db.system.name = "postgresql" && span:duration > 800ms }
# Slow AND the database was NOT slow — everything else
{ resource.service.name = "checkout-api" && span:duration > 1.9s }
&& !{ span.db.system.name = "postgresql" && span:duration > 800ms }
# Slow AND a downstream service was slow — with the peer named
{ resource.service.name = "checkout-api" && span:duration > 1.9s }
>> { span:kind = client && span:duration > 500ms }
| by(span.server.address) | count()
Running the first two and comparing counts is often the whole investigation. If 90% of slow traces contain a slow database span, the cause is the database; if 10% do, looking at database dashboards is a detour.
Comparing against a healthy baseline
The single most useful habit is to run every diagnostic query twice — once against the degraded population, once against a healthy one — and compare. A difference is a finding; identical results mean you are looking at the wrong dimension.
# Degraded population: what do downstream calls cost?
{ span.http.route = "/checkout/{orderId}" && span:duration > 1.9s }
>> { span:kind = client } | by(span.server.address) | avg(span:duration)
# Healthy population, same query
{ span.http.route = "/checkout/{orderId}" && span:duration < 400ms }
>> { span:kind = client } | by(span.server.address) | avg(span:duration)
Turning a finding into a permanent check
Once a query has explained an incident, it is worth keeping — with the healthy answer written next to it, so the next person can tell at a glance whether the number they are looking at is normal.
# Runbook entry: "Is the inventory dependency degraded?"
# Query:
{ span.http.route = "/checkout/{orderId}" }
>> { span:kind = client && span.server.address = "inventory.internal" }
| quantile_over_time(span:duration, .95)
# Healthy: under 120 ms. Above 400 ms, checkout p95 starts to move.
The “healthy” line is what makes this usable under pressure. A query that returns 380 ms means nothing on its own; the same number next to “healthy: under 120 ms” is a diagnosis.
Query cost and how to keep it low
Latency queries are among the more expensive shapes because a duration filter cannot be answered from an index — the engine must read spans. Three habits keep them fast: always lead with resource.service.name so whole blocks can be skipped; keep the time window as narrow as the question allows, starting at fifteen minutes around the event; and prototype the query on a narrow window before widening it. An aggregate over six hours can be orders of magnitude more expensive than the same aggregate over fifteen minutes, and during an incident that difference is the difference between an answer and a timeout.
It is also worth knowing when not to use a trace query at all. If the question is “when did p95 start rising”, a metric answers it instantly and a trace query answers it slowly. Traces are for explaining a change, metrics for detecting it — see generating RED metrics from spans for how to have both from the same data.
The shapes latency queries reveal
Once you have a set of traces from the degraded band, they tend to fall into a small number of shapes, and recognising which one you have determines the next query rather than the next guess.
A single dominant span. One child accounts for most of the parent’s duration. The cause is that operation, and the next question is why — a slow query, a saturated dependency, a lock. This is the easy case and the one people expect.
Uniform inflation. Every span is proportionally slower, with no single culprit. That pattern points at something affecting the whole process: CPU throttling, garbage collection, a noisy neighbour, or a node with degraded networking. Grouping the slow traces by host or pod usually confirms it in one query, and it is the shape most often misdiagnosed as an application problem.
Gaps between spans. The spans are fast, the total is slow, and the time is in the spaces between them. That is un-instrumented work — serialization, template rendering, business logic with no span — and the fix is instrumentation before it is optimisation. It is also the shape that makes people distrust tracing, because the trace appears to show that nothing took any time.
Many small spans, none significant alone. A run of short operations that add up. This is the N+1 family, and the diagnostic is count rather than duration, as covered in detecting N+1 queries from trace waterfalls.
Each shape has a different follow-up query, and misreading one for another sends the investigation in the wrong direction for an hour. The habit worth building is to classify the shape before forming a hypothesis about the cause.
Latency is a distribution, not a number
One habit underpins everything on this page: treat latency as a distribution at every step. A single trace is a sample from it, a percentile is a summary of it, and a change in latency is a change in its shape.
That framing prevents the two most common analytical errors. The first is generalising from one trace, which is sampling of size one. The second is comparing averages, which hides exactly the bimodal patterns — a fast path and a slow path, a cache hit and a cache miss — that most latency investigations are ultimately about. When the p50 is unchanged and the p95 has doubled, some population got slower and others did not, and finding which population is the entire investigation.
Common pitfalls
- Sorting by slowest. Reliably returns the least representative traces in the window.
- Using a round-number threshold. One second means nothing until you know whether this endpoint’s p95 is 80 ms or 900 ms.
- Omitting the healthy comparison. Without it, every number looks suspicious and nothing is confirmed.
- Confusing
{A && B}with{A} && {B}. The first needs one span matching both conditions; the second needs two spans in the same trace. Silently different results. - Widening the window to find more results. If a narrow window returns nothing, the more likely explanation is sampling or a wrong attribute name, not an insufficiently large search.
Related
- Querying traces with TraceQL and Jaeger search — the query model these patterns build on
- Finding latency bottlenecks with critical path analysis — what to do with the trace once you have found it
- Detecting N+1 queries from trace waterfalls — a specific pattern these queries surface constantly