What the hash is not for¶
The fingerprint is a good answer to "which queries are the same shape". People reach for it to answer three other questions, and one of those quietly returns the wrong data to the wrong user.
Not a cache key¶
The signature erases literal values. That is what makes it useful for grouping, and it makes it disqualifying as a cache key:
$digest = Formatter::create()->describe([
'query' => ['bool' => ['filter' => [
['term' => ['tenant_id' => $tenant]],
['range' => ['@timestamp' => ['gte' => 'now-1d']]],
]]],
'size' => 20,
], 'invoices');
tenant 41 q5:b9b2fe44f67a invoices | q=(@timestamp >= now-1d and tenant_id:41) | size=20
tenant 42 q5:b9b2fe44f67a invoices | q=(@timestamp >= now-1d and tenant_id:42) | size=20
tenant 9999 q5:b9b2fe44f67a invoices | q=(@timestamp >= now-1d and tenant_id:9999) | size=20
One hash, three tenants. Cache on it and tenant 42 is served tenant 41's invoices. This is not a subtle failure mode you would catch in review — it is a cache that works perfectly in every single-tenant test you write and leaks across customers the first time two of them are active at once.
There is no option that fixes this. Normalization::NONE keeps values in the
rendered text, not in the hash; the hash is a fingerprint of the signature by
definition. If you want a cache key, hash the request body — sha1(json_encode($request))
is two functions and answers the question you are actually asking.
Not an anonymised query¶
The hash is safe to put anywhere. os.q is not, and they arrive together:
{
"idx": "logs-*",
"q": "logs-* | q=(email:alice@example.com and status:shipped) | size=20",
"sig": "logs-* | q=(email:? and status:?) | size=20",
"hash": "q5:614ecdff8fdf"
}
At the default normalisation the rendered line keeps literal values, because
being able to paste it into Dashboards is the point of having it. So a term on
an email address puts that address wherever the line goes — and log shipping
tends to go further than people remember.
sig and hash never carry values. If your log index leaves your perimeter, log
those two and drop q; every query on
these pages works without it. The
threat model
is blunt about this being the one real risk in the library.
Not a cost estimate¶
Two requests with the same hash can differ by orders of magnitude in took. The
shape is identical; the work is not:
q5:5b2210eb5318 logs-* | q=(service:?) | size=50 ← service:api 1.2M matches
q5:5b2210eb5318 logs-* | q=(service:?) | size=50 ← service:cron-jobs 3 matches
Same signature, same hash, wildly different queries to execute. This is why the regression page compares a shape against its own history rather than against other shapes, and why the percentile matters more than the mean. A p95 within one shape is a real signal. A hash's p95 compared to another hash's p95 tells you about two workloads that happen to be named similarly.
It also means a shape whose latency moved is not necessarily a shape whose query changed. The values it is being called with may have changed instead — a filter that used to match three documents now matching a million is invisible in the signature, on purpose. The signature tells you what was asked; it does not tell you how much data answered.
What it is for¶
For completeness, the three questions it does answer:
- Which shapes exist, and which appeared or vanished between two releases — what a deploy changed.
- How one shape behaves over time, compared to itself — which shape got slow.
- Where the time goes, by summing
tookper shape rather than counting hits.
And one property worth knowing, which is the reverse of the cache-key problem: two queries written differently but asking the same thing land on the same hash.
filter: [ status:open, team:core ] → q5:62a3e27e69de
filter: [ team:core, status:open ] → q5:62a3e27e69de
Clause order does not survive normalisation, so a refactor that reorders a bool
produces no new shape. How the fingerprint works
covers what else is erased and why.