Which shape got slow¶
Search latency went up this afternoon. Your dashboards show the p95 climbing and cannot tell you which of the four hundred queries your application sends is responsible. The log index can.
The obvious query, and why it lies¶
Rank the shapes by p95 and take the worst:
{
"size": 0,
"aggs": {
"shapes": {
"terms": { "field": "os.hash", "size": 3, "order": { "slow.95": "desc" } },
"aggs": {
"slow": { "percentiles": { "field": "took", "percents": [95] } },
"shape": { "terms": { "field": "os.sig", "size": 1 } }
}
}
}
}
q5:e9794c1be608 p95=1484ms n=30 orders | q=(country:? and status:(? or ? or ? or ? or ? or +3))
q5:63a1ca5c80b9 p95=1019ms n=360 logs-* | q=(@timestamp >= ? and env:?) | aggs=terms(host,10)>…
q5:4dde138a2ad7 p95= 30ms n=90 products-v3 | q=(image_embedding:knn(k=?) and in_stock:? …)
The top answer is wrong. q5:e9794c1be608 is a report over orders that takes
a second and a half — and took a second and a half yesterday, and will tomorrow.
It is the slowest shape you run, and it is not what changed. Ranking by p95
finds what is slow, and "slow" is a property, not an event.
Note the "order": { "slow.95": "desc" }: ordering a terms aggregation on a
percentile needs the metric named, not just the aggregation. { "slow": "desc" }
fails with Invalid aggregation order path, which is a clearer error than most.
Rank by change instead¶
Split each shape's own history into two windows and compare it to itself:
{
"size": 0,
"aggs": {
"shapes": {
"terms": { "field": "os.hash", "size": 50 },
"aggs": {
"before": {
"filter": { "range": { "@timestamp": { "lt": "2026-08-19T14:00:00Z" } } },
"aggs": { "p95": { "percentiles": { "field": "took", "percents": [95] } } }
},
"after": {
"filter": { "range": { "@timestamp": { "gte": "2026-08-19T14:00:00Z" } } },
"aggs": { "p95": { "percentiles": { "field": "took", "percents": [95] } } }
},
"established": {
"bucket_selector": {
"buckets_path": { "b": "before>_count" },
"script": "params.b > 0"
}
},
"slowdown": {
"bucket_script": {
"buckets_path": { "b": "before>p95[95.0]", "a": "after>p95[95.0]" },
"script": "params.b == 0 ? 0 : params.a / params.b"
}
},
"worst": {
"bucket_sort": { "sort": [ { "slowdown": { "order": "desc" } } ], "size": 3 }
},
"shape": { "terms": { "field": "os.sig", "size": 1 } }
}
}
}
}
q5:63a1ca5c80b9 x21.5 50 → 1074ms n=360
logs-* | q=(@timestamp >= ? and env:?) | aggs=terms(host,10)>{p95(latency_ms), sum(error_count)} | size=0
q5:fe168406e702 x1.0 9 → 9ms n=7200
logs-* | q=(@timestamp >= ? and @timestamp < ? and not status:? and service:?) | size=50 sort=@timestamp:desc
q5:e9794c1be608 x1.0 1484 → 1484ms n=30
orders | q=(country:? and status:(? or ? or ? or ? or ? or +3))
Twenty-one times slower, and the signature says what it is without your
having to go and find the code: a dashboard aggregating p95(latency_ms) and
sum(error_count) per host. The always-slow report is still there at ×1.0,
which is exactly where it belongs — unchanged.
The trap in that query¶
established is not decoration. Drop it and the request fails:
"caused_by": {
"type": "null_pointer_exception",
"reason": "Cannot invoke \"java.lang.Double.doubleValue()\" because \"bucketValue\" is null"
}
A shape with no documents in the before window has no before>p95, so
bucket_script produces no value, and bucket_sort dereferences it. The
aggregation breaks on precisely the bucket you would most want to see — a query
shape that is brand new. bucket_selector drops those buckets before the sort
reaches them, and they are the subject of
their own page anyway. "gap_policy":
"insert_zeros" on the bucket_script also stops the crash, but it keeps new
shapes in the list at ×0, sorted last, where they read as "fine".
Which one is worth fixing¶
"What regressed" and "what costs you" are different questions, and the second has a more surprising answer. Rank by the total time each shape spent:
{
"size": 0,
"aggs": {
"shapes": {
"terms": { "field": "os.hash", "size": 5, "order": { "total_ms": "desc" } },
"aggs": {
"total_ms": { "sum": { "field": "took" } },
"median": { "percentiles": { "field": "took", "percents": [50] } },
"shape": { "terms": { "field": "os.sig", "size": 1 } }
}
}
}
}
q5:63a1ca5c80b9 n=360 median= 47ms total=118.6s
q5:fe168406e702 n=7200 median= 8ms total= 57.6s
q5:e9794c1be608 n=30 median=1316ms total= 39.5s
q5:4dde138a2ad7 n=90 median= 25ms total= 2.3s
The query you run most is not the query that costs you most. The workhorse ran 7200 times and spent 57.6 seconds. The dashboard aggregation ran 360 times — twenty times less — and spent 118.6 seconds, twice as much. Meanwhile the report everyone complains about, the one that takes a second and a half, accounts for 39.5 seconds all afternoon: it is the slowest query you have and close to the cheapest thing on this list to ignore.
Sorting by sum(took) is what turns "which query is slow" into "where does the
cluster's afternoon actually go".
Then read it, or run it¶
You have a signature, which is usually enough to recognise the query. When it is
not, os.q is the same line with the real values still in it — paste the q=(…)
segment into the Dashboards search bar and you are looking at the query itself.
logs-* | q=(@timestamp >= now-15m and env:prod) | aggs=terms(host,10)>{p95(latency_ms), sum(error_count)} | size=0
────────────────────────────────
paste this part into Dashboards
And if you want to know why the signature says what it says — why a value was
erased, why a clause is missing — explain() answers per clause. See
How the fingerprint works.