Skip to content

Options

use MrDlef\OsQueryDigest\{Formatter, Normalization, Options};
use MrDlef\OsQueryDigest\IndexNormalizer;

$formatter = Formatter::create(
    Options::create()
        ->withNormalization(Normalization::structural())
        ->withMaxValues(5)          // status:(500 or 502 or 503 or +8)
        ->withMaxClauses(12)        // a and b and … +4 more
        ->withMaxFields(3)          // title^10|content|tags|+3 more
        ->withMaxAggs(3)            // terms(brand,20), terms(colour,20), +4 more
        ->withMaxLength(512)        // hard cap on the line, never on the hash
        ->withIndexNormalizer(IndexNormalizer::datePatterns())
        ->withRedactor(fn ($field, $value) => $field === 'email' ? '<redacted>' : $value)
        ->withAggNames(false)
        ->withText(true)            // false: emit idx/sig/hash only
        ->withHashLength(12)
);

The same options as a plain array — for a YAML file, a framework config block, or a CLI flag:

$formatter = Formatter::create(Options::fromArray([
    'normalization' => 'structural',
    'maxValues'     => 5,
    'aggNames'      => true,
    'text'          => false,
]));

Unknown keys and wrong types throw InvalidOptionException instead of being ignored: an option that silently does nothing is the bug you find months later, in a dashboard that was never grouped the way the config claimed. Types are taken as JSON gives them — "5" is rejected, because a front end that guesses at "5" also accepts "five". redactor has no array form; a callable cannot be expressed there.

The level, and the question you are asking

values(), the default, erases literals and keeps the rest — including pagination and how many values a terms clause holds. That is what you want when the question is latency, and the wrong grouping when it is volume: structural() is the setting that puts page 1 and page 3 of one search on one row. Which level answers which question compares the two on the same search.

Why the field list is capped so hard

A multi_match — and query_string, and more_like_this — carries the list of fields it searches, boosts included. On a real search built over one, that list is more than half of the rendered line, and it is the half that says nothing: the fields are the schema, near enough the same on every search the application makes, while the filters beside them are what actually vary.

So it is capped at three, tighter than the twelve clauses and the five terms values, and the rest is summarised the same way:

q=(title^10|subtitle^5|content|+3 more:~?)
q=(title^10|subtitle^5|content|tags|author|isbn:~?)

The second line is the same search under withMaxFields(null). Both have the same hash — like every limit on this page, this one is spent on the line, and the fingerprint is computed before any of them apply. Two searches over different field lists stay two fingerprints even when they print identically.

A facet list is capped as hard, and for a sharper reason

A faceted page sends one aggregation per facet, and a rendered aggregation is the most expensive thing that can go on the line — 78 characters at the median against a clause's 54, measured over a day of real traffic. Ten facets is the whole line, and the filters that say what the page was actually asking for never reach the budget.

aggs=terms(brand,20), terms(colour,20), terms(country,20), +2 more | size=0
aggs=terms(brand,20), terms(colour,20), terms(country,20), terms(material,20), terms(size_label,20) | size=0

The second line is the same request under withMaxAggs(null), and both have the same hash.

The cap holds at every level, not just the outer one: the sub-aggregations hanging off a facet are a sibling list too — terms(brand,20)>{avg(price), +3 more} — and that is where a facet page grows first. A cap the children escaped would be a cap on nothing in particular.

Where the character cap lands

withMaxLength() is the limit of last resort, and the only one that knows nothing about what it is cutting. It does not stop at the character the budget ran out at — it backs off to the last separator before it, and keeps it:

q=(locale:? and published_at >= ? and status:? and …

Ending on taxon_text_p… instead would name a field no cluster has, and the one promise the readable line makes is that you can paste it into a search bar. So the line gives up whatever sat between that separator and the budget — at most one clause, and it is cut where it was when a single clause is longer than the whole budget, because the cap has to hold.

This buys back a readable line, not a more precise one: two searches that differ only past the cut printed one line before and print one line now. Telling them apart is the hash's job, and the cap is lifted before the hash input is rendered.

withText(false), and what it does not promise

It removes the readable line from the digest — never rendered, so no accessor on the object can hand out a value, and toArray() emits idx / sig / hash. When the values may not leave the building is the case it exists for.

On the command line it is --no-text, which every sub-command takes: it is what makes a --json run over production requests, or a slowlog report of what users actually typed, shareable as it stands.

It is not, on its own, a guarantee that no literal is emitted. Normalization::none() makes the signature equal the readable line, values included, so the pair that emits none is withText(false) with any normalization above none — which is the default, and the only combination worth calling value-free.

An index name only you can read

datePatterns() collapses what any cluster does — dates, and standalone numeric segments, which covers rolling indices and, pleasantly, multi-tenant numeric prefixes:

tenant_0178_members  →  tenant_*_members
logs-2026.08.13  →  logs-*

What it cannot collapse is a suffix whose meaning is yours: a content-versioned index, where the physical name carries a hash of the mapping and the alias moves over it on reindex. Left alone, every mapping change mints a fresh fingerprint for every query shape, and every dashboard built on the hash resets on the next deploy — the thing the normalizer exists to prevent.

use MrDlef\OsQueryDigest\IndexNormalizer;

Options::create()->withIndexNormalizer(IndexNormalizer::custom(
    fn (string $index): string => preg_replace('/_[0-9a-f]{32}$/', '', $index),
));
tenant_0178_members_4f171971a955af948fae1c7a964c49b8  →  tenant_*_members
tenant_0179_members_0000000000000000000000000000ffff  →  tenant_*_members

Your rule runs first, then the shipped one. The hook is for stripping what this library cannot know is meaningless; dates and numeric segments are then collapsed exactly as always. So the example lands on tenant_*_members, not on tenant_0178_members — you do not reimplement what already works.

Three things it is worth knowing:

  • It is called once per name. A request against a,b gets two calls, and the deduplicating and sorting of a comma-separated list stays where it is. A rule may return '' to drop one name from the list.
  • It is not trusted to return a string. Anything non-scalar reads as an erased name rather than throwing: this runs in a logging path, where a TypeError out of a closure would cost the log line and not just the digest.
  • A rule changes fingerprints — that is the point — so roll it out the way you would a prefix bump, not quietly on a Friday.

There is no shipped mode for this, deliberately. A rule that collapsed long hex runs generally would have to decide what a hash is — how long, which alphabet — and would move the fingerprint of every index name with hex anywhere in it. So the shipped rules stop where the cluster's own conventions stop: the tenant number is a number and collapses, and the suffix is left alone whatever it happens to be made of.

tenant_0178_members_4f171971a955af948fae1c7a964c49b8  →  tenant_*_members_4f171971a955af948fae1c7a964c49b8
tenant_0179_members_9999999999999999999999999999aaaa  →  tenant_*_members_9999999999999999999999999999aaaa

Only you know where your suffix begins, which is why this is a callable rather than a third mode.