Releases: mrDlef/php-os-query-digest
Release list
v0.13.0
the parameters beside body, a record that may leave the building, an index
name only you can read and one no longer half-collapsed, a third way in at the
transport that is now checked against the clients themselves, one file to
download, and the examples nothing read
Fingerprints: q4: → q5: — a search whose size, from or sort sits
beside body rather than inside it now says so. Only the prefix moved: all
eighteen fixtures kept their twelve hex characters.
The search parameters an envelope carries beside body
['index' => …, 'body' => …] is how both clients take a search, and size,
from and sort are as legitimate beside body as inside it: they are three of
the fifty-three parameters the search endpoint whitelists, where they travel as
query string rather than as JSON. The parser descended into body and dropped
everything next to it, so these two produced the same fingerprint:
$f->describe(['index' => 'members', 'body' => $body, 'size' => 20, 'from' => 40]);
$f->describe(['index' => 'members', 'body' => $body]);They are not two spellings of one search. One pages forty documents deep, the
other takes the default ten — and sharing a hash meant "which shape is hurting
us" answered with a shape that has no size in it, while the deep-paging one,
usually the expensive one, was invisible.
Both are read now, and the conflict rules are the cluster's rather than the
plausible ones:
sizeandfrom: the envelope wins. The cluster parses the body and
then applies the query string, so the outer value overrides the inner one —
the opposite of what the nesting suggests.sort: the envelope is appended. The query string sorts are added after
the body's rather than replacing them, so the body keeps the primary key.
Neither rule came from the clients' documentation. Both were read off a live
node — a thirty-document index, size and from and sort set to different
values in the two places, and the hits counted — and
tests/fixtures/19-envelope-search-params pins the answer.
One consequence worth naming: an envelope sort is a query string parameter, so
it carries the URI syntax — last_name:desc, comma-joined — not the body's
structural form. Read as a body sort it would have minted a field called
last_name:desc and claimed ascending.
A digest that carries no search input
Digest::toArray() always emitted four fields, and q is the readable line —
which is user-typed search input: names, addresses, e-mail addresses. The moment
those records leave for a hosted log collector or a third-party SIEM, that is the
difference between "we may ship these logs" and "we may not", and it is decided
per field.
Formatter::create(Options::create()->withText(false));
// {"idx": "logs-*", "sig": "logs-* | q=(@timestamp >= ? and service:?) | size=0", "hash": "q5:…"}Everything the library is for reads sig and hash, which were already
value-free: which shape got slow, which one the deploy added, which one to group
a dashboard by. q is the convenience of pasting into Dashboards, and it is the
first thing a regulated deployment gives up. It is also the longest of the four
fields, so this is the cheapest log-volume win on offer.
Four things it was worth being careful about:
- The line is never rendered, not rendered and dropped. A blanket redactor
came close before this —withRedactor(fn ($field, $value) => '?')— but it
renders the same line twice to throw one away, and a per-field redactor is
one forgotten field away from a leak. There is now no literal anywhere in the
digest, andDigest::text()returns the signature rather than an empty string
so that nothing reading the wrong accessor, including__toString(), can find
one either. qis omitted, not emptied. Aqthat duplicatedsigwould still have
to be inspected before those logs could ship, which is not what "decided per
field" means.- The hash does not move. What is emitted beside a fingerprint does not
change what the shape is called, so a dashboard built before the switch keeps
matching after it. Asserted, not assumed. - It is not on its own a promise that no literal is emitted, and the
docblock, the guide and a test all say so: underNormalization::none()the
signature is the readable line. The pair that emits none iswithText(false)
with any normalization abovenone— which is the default.
One processor and one observer, unchanged: both emit toArray(), so both follow.
The shipped dashboard pack needs no change either — its panels group on
os.hash and read os.sig, and os.q appears only in the index pattern's field
list.
text is a fromArray() key like the rest, since this is a decision a
deployment makes in configuration rather than in code, and the playground gained
the toggle — where turning it on removes the text row from the result rather
than blanking it, which is what the record does.
A numeric index segment no longer collapses halfway
datePatterns() collapsed a standalone numeric segment up to eight digits and
then, from nine, only part of it:
8 digits logs-99999999 → logs-*
9 digits logs-999999999 → logs-*9
10 digits logs-9999999999 → logs-*99
The leftover is the digits past the first eight, so it varied with the value.
An epoch-seconds suffix is ten digits, which made every rollover its own
fingerprint:
logs-1756259400 → logs-*00 q4:ebd17811af9b
logs-1756259412 → logs-*12 q4:228ebfe1e5b7
logs-1756345837 → logs-*37 q4:a908898fa64a
Three names of one query shape, three fingerprints — precisely the failure this
class exists to prevent, while the dated form right beside it did the right
thing. And logs-*99 reads as a pattern, so nothing in a dashboard suggested
the grouping was broken. A name that looks collapsed is worse than one that
plainly is not.
The cause is two rules and their order. The date rule matches eight bare digits,
because logs-20260813 is a real index name; it ran first, ate the leading eight
of a longer run, and left *99 — which the numeric-segment rule can no longer
match, being anchored to segment boundaries.
The fix is two lookarounds, and the obvious guard was the wrong one. Anchoring
the date rule to segment boundaries fixes the digit run and breaks a date with a
time on it: orders-2026.08.13T00 would collapse to orders-*.13T00, worse than
before. Bounding it by digits instead — (?<!\d)…(?!\d) — refuses the front of
a longer run while still accepting eight digits followed by anything that is not
one:
logs-999999999 → logs-* (was logs-*9)
logs-1756259400 → logs-* (was logs-*00)
logs-000000001 → logs-* (was logs-*1)
logs-20260813T00 → logs-*T00 unchanged
orders-2026.08.13T00 → orders-*T00 unchanged
No fixture moved. All eighteen keep their hashes, because no pinned example
carried a segment of nine digits or more. Two things did move and had to: the
block in the options guide that documented the mangling, and the test that pinned
it — both now say the suffix is left alone, which was always the argument for
custom() being a callable.
This is why the fix is in this release rather than the next one: v0.13.0 already
moves the prefix, so it rides along inside that bump. After the tag it would have
cost a q6: of its own, for two lookarounds.
An index name only you can read
IndexNormalizer::datePatterns() collapses what any cluster does — dates, and
standalone numeric segments, which covers rolling indices and, pleasantly,
multi-tenant numeric prefixes. 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. Every mapping change then
minted a fresh fingerprint for every query shape, and every dashboard built on
the hash reset on the next deploy — the thing the class exists to prevent.
Options::create()->withIndexNormalizer(IndexNormalizer::custom(
fn (string $index): string => preg_replace('/_[0-9a-f]{32}$/', '', $index),
));
// tenant_0178_members_4f171971a955af948fae1c7a964c49b8 → tenant_*_membersA callable, not a third mode. A mode that collapsed long hex runs 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. 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 is made of. Only the
application knows where its own suffix begins.
Three decisions inside the hook:
- Your rule runs first, then the shipped one. So the example lands on
tenant_*_membersrather thantenant_0178_members: the hook strips what this library
cannot know is meaningless, and dates and numbers are collapsed afterwards as
always. Nobody reimplements what already works. - It is called once per name.
normalize()also splits a comma-separated
list, deduplicates it and sorts it — machinery, not policy — so a request
againsta,bgets two calls and that part 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, the same trade the redactor makes: this runs
in a logging path, where aTypeErrorout of a closure would cost the log line
and not just the digest.
Like the redactor, it has no fromArray() key and no MODES entry — a callable
cannot come out of a configuration file — so fromMode('custom') throws, and the
playground's mode list is unchanged. Nothing moves for anyone who does not ask:
the default is the same rule it was.
Which clients can be captured is a test now, not a paragraph
Every other claim this library makes ab...
v0.12.0 — wrap the client, change no call site
wrap the client, change no call site
Fingerprints: q4: unchanged.
The integration that needs no integration
Every way in until now asked the application to hand the library a request. The
Monolog processor is the cheapest of them and it still needs an application that
already logs its search bodies; describe() needs a call site. So the two ends
of the adoption path did not meet: slowlog lets you try the tool before writing
any code, and then writing the code was the next thing you had to do.
Wrap the HTTP client your OpenSearch library already uses instead:
use MrDlef\OsQueryDigest\Http\{DigestingClient, LoggingObserver};
$client = new DigestingClient($client, new LoggingObserver($logger));Every _search and _msearch it sends is now digested, and the record it writes
carries os and took in exactly the shape
the dashboard pack
maps. Anything that is not a search passes straight through.
Two of them, because a Guzzle client hides half its traffic
Http\DigestingClient is a PSR-18 decorator and Http\Guzzle\DigestMiddleware
is a handler-stack middleware. A Guzzle client is a PSR-18 client, so the
decorator works on one — for the requests it sends synchronously. Libraries that
send asynchronously never call sendRequest() at all, and opensearch-php is
one of them, so the middleware is not a convenience wrapper over the decorator.
Both are still zero runtime dependencies: psr/http-client and
guzzlehttp/guzzle are suggested, the way monolog/monolog is.
Nothing it does can fail your search
This sits in the path of every query the application makes, so the interesting
part is the list of things it declines to do.
- A body that cannot be put back is not read. An unseekable stream is one the
client has not sent yet; consuming it for a digest would send the request
without its query. And a stream is returned to the position it was found at,
not to the start — the position is the client's, not ours. - A failed request is still counted, with a null status. The shape that times
out is the shape worth finding. The exception reaches your error handling
unchanged, including the case where a promise was rejected with something that
is not aThrowable. - An observer that throws costs one digest, per search rather than per
request: one bad line of a batch does not take the rest of the batch with it.
What the URL is allowed to mean
The index is in the signature, so reading it wrongly is a wrong fingerprint
rather than a cosmetic slip — and four things about an OpenSearch URL make that
easy to get wrong.
- The endpoint has to be the last segment.
_searchis a prefix of three
endpoints that carry no query:_search/scrollsends a scroll id,
_search/templatean id and its params,_search/point_in_timenothing at
all. - An index expression can start with an underscore.
_allis one, which is
why the endpoint is found from the right — a rule that took the first
underscored segment for the endpoint would read/_all/_searchas an endpoint
named_alland digest none of it. That one was found by a mutant, not by a
test. - An index name can contain a slash. Date-math names arrive percent-encoded
—%3Clogs-%7Bnow%2Fd%7D%3Efor<logs-{now/d}>— so the path is split on/
first and each segment decoded after. /proxy/_searchand/logs/_searchare the same URL. A cluster behind a
path prefix has to say so, hence the$basePathargument; without it the
prefix lands in every fingerprint as the index name.
The removed mapping-type form, /logs/type/_search, is declined rather than
guessed at: no supported version accepts it, and picking one of the two segments
to call the index would put a name of our choosing in someone's dashboard.
Two things a mock cannot show, so a node was asked
tookis read off the front of the response. A search response opens with
it, so a fixed peek at the first bytes finds it without decoding a body that
may hold a megabyte of hits. That is an observation about OpenSearch's
serialiser and not a rule anybody published, so
tests/Integration/TransportCaptureTest.phpasserts it against real 2.19.6 and
3.8.0 nodes. A version that stopped doing it fails that test instead of
quietly reporting null for every search.- A body read for its digest still arrives. No mock can show this — it never
sends anything. The same test runs a real filtered search and fails if the node
answers with the hit count of amatch_all, which is what an emptied body
would have become.
Both nodes also confirmed what a batch reports: an _msearch response opens with
the took of the whole batch and carries one per line further in, past the hits
of the line before it. So each line reports null rather than the batch's
figure repeated — that number in a took aggregation would count once per line
of every batch — and position() says which line it was.
The public surface is nineteen classes
Five more: the two integrations, the SearchObserver interface, the
ObservedSearch it is handed, and the LoggingObserver that ships. Widening it
is a line in ApiBoundaryTest, as ever.
v0.11.0 — the dashboard is written already
the dashboard is written already
Fingerprints: q4: unchanged.
An importable pack, in resources/dashboards/
The Use cases pages answer four questions and ask you to paste four
aggregations into a console to see them. The same four questions now ship as an
index template and a dashboard:
curl -XPUT localhost:9200/_index_template/os-query-digest \
--data-binary @resources/dashboards/index-template.json -H 'Content-Type: application/json'
curl -XPOST 'localhost:5601/api/saved_objects/_import?overwrite=true' -H 'osd-xsrf: true' \
--form file=@resources/dashboards/os-query-digest-opensearch-2.x.ndjsonWhere the time goes, p95 by shape over time, what regressed, and what the last
release added. The first two are ordinary visualisations, so they can be edited
with a mouse. The last two have to be Vega: they ask their question with
bucket_script, bucket_selector and bucket_sort, and no classic
visualisation can express a pipeline aggregation.
Why there are two files
Dashboards 2.x bundles vega-lite 4 and 3.x bundles vega-lite 6, and the
plugin refuses a specification whose $schema names the other. Neither version
can read one file, so both are written — generated from the same source, and a
test asserts they differ in that URL and in nothing else, which is what stops
one of them becoming a fork nobody maintains.
Generated from the pages, and executed
make dashboards builds the pack from the <!-- verified: … --> blocks the Use
cases pages already carry, so a panel cannot drift from the aggregation those
pages prove against a live cluster. What a page pins the pack cannot: 14:00 is
one afternoon, while a panel follows the time picker, so the two fixed windows
become %timefilter% and the same window shifted back an hour — the one
substitution, in one place.
What is checked, beyond the pack being what the generator writes today:
- each Vega panel's aggregation is executed against 2.19.6 and 3.8.0 on the
scenario the pages describe, and has to answer with the shape they say; - the shipped index template is applied by a real cluster, and
os.hash
has to come out akeyword— the difference between an aggregation and a pile
of word fragments; - every field the pack names exists in that template and among the fields the
digest emits, so a panel cannot aggregate on something the library stopped
producing; - no panel carries a fixed date.
And then a real Dashboards was pointed at it
make dashboards-check boots one Dashboards of each major, imports the pack
through the saved-objects API, opens the dashboard in a browser and asserts that
all four panels render, carry data and report nothing. It also writes the
screenshot the guide shows, so that picture is the output of a run rather than
something taken once.
It was worth the two images. Every one of these was in the pack, and none of
them is visible without a browser — an import reports success on all of them:
| What was wrong | What it looked like |
|---|---|
a panel with no version |
the whole dashboard app throws before drawing |
a search source with no indexRefName |
Trying to initialize aggs without index pattern |
| no field list on the index pattern | fine on 2.x, Could not locate that index-pattern-field on 3.x |
%context% beside a body query |
must not be used when url.body.query is set |
%dashboard_context-*% written as objects |
Bad Request from the cluster |
%timefilter% with shift: 1 |
compares the window with the hour after it, so nothing ever regressed |
a nested value addressed as slowdown.value |
an axis of [Infinity, -Infinity], or bars normalised to 1 |
The last two are the ones worth remembering: both drew a chart. A panel that
answers the wrong question confidently is worse than one that fails, and neither
an import nor a test of the aggregation would have caught either.
Whether a chart is readable is still yours to judge; the check only proves it
drew, with data, and said nothing.
A sentence the slow log guide was missing
A rewritten range reaches a slow log without its bounds, which v0.10.0 described
and then stopped short of. The consequence is the one real loss of information in
that whole feature: now-15m and now-7d over the same field share a hash
there, because the shard resolved the bounds away before writing the record.
Every other kind of value survives; this one does not. It was misread the other
way by the person who wrote the code, which is about as good a reason to write a
sentence as there is.
One mapping instead of three
The mapping existed three times: in the Use cases prose, in the integration
test, and now in the pack. The template file is the only copy left — the page
includes it, and the scenario index the pages are measured on is created from
it. The numbers on those pages are therefore produced under the mapping a reader
installs.
v0.10.0 — the report you can run before you integrate anything
the report you can run before you integrate anything
Fingerprints: q3: → q4: — a range written the older way is now read
rather than shrugged at. Every hex is unchanged: a signature that did not
move kept its twelve characters, so q3:fe168406e702 and q4:fe168406e702
describe the same query, and a dashboard grouping by hash needs its stored
values re-prefixed, not recomputed.
os-query-digest slowlog
Everything else here asks you to log digests before you can find out whether
they tell you anything. This does not: index.search.slowlog is already on in
most clusters, so the CLI reads what is already on disk and ranks it.
$ os-query-digest slowlog /var/log/opensearch/*_index_search_slowlog.log
60 lines, 59 records, 3 shapes, 13,515 ms total
count total ms* mean p95 max shape
41 6,807 166 246 258 q4:fe168406e702
logs-* | q=(@timestamp >= ? and @timestamp < ? and not status:? and service:?) | size=50 sort=@timestamp:desc
6 5,978 996 1,325 1,325 q4:6b6fb17c6640
orders-* | q=(sku:(? or ? or ?)) | aggs=date_histogram(created,day)
No application change, no index to create, nothing to deploy.
Ranked by total time rather than by the slowest record, because that is the
number a slow log cannot give you: it lists the 166 ms query forty-one times
without ever adding them up, and reading it top-down puts the one bad afternoon
above the shape that is the afternoon. --sort takes count, mean, p95
or max for the other readings — on the file above, --sort=p95 promotes that
date_histogram, which is a different and equally real answer.
The table prints the signature of each group, never one record's values.
Under a count of forty-one, a single sample's service and timestamps read as
the group's, and they are not. --json carries the slowest sample labelled as a
sample, beside the timestamps the group spans — which is how a shape that has
always been there is told from one that arrived with this morning's deploy.
Both appenders are read, the plain one and the JSON one beside it. A layout
that namespaces its keys — …slowlog.source rather than source — is read too,
which is tolerance rather than a promise: OpenSearch remains the only thing
certified here, as it has been since v0.7.0 dropped the elasticsearch keyword.
The root --help was still offering "an OpenSearch / Elasticsearch DSL query",
which that release had already stopped meaning; it says OpenSearch now.
Input is consumed a line at a time rather than slurped, because rotated slow
logs run to gigabytes and the whole premise is that you can point this at the
file you already have.
Two things a slow log does that a query file does not
A ] inside the query does not end the record. The plain appender writes
source[{…}] with nothing escaped, so a terms value of a[1], a regexp or a
field named a[0] all put brackets inside the body. Counting brackets is not
enough; the scan tracks where strings begin and end and stops at the first ]
outside one.
Noise and an unreadable record are told apart. Allocation notices, stack
traces and startup messages are skipped in silence — a tool that refused the
file over them would be useless exactly where it is pointed. A line that opened
source[ and never closed it, which is what rotation does to a record, is
reported instead: staying quiet about it would understate the shape it belonged
to. And a file with no records at all is an error rather than an empty table,
since pointing this at the wrong file should not look like a healthy cluster.
What the real nodes said
The formats above were first read from what the appenders are documented to
emit, which is only ever as right as the person writing it. Four files captured
from OpenSearch 2.19.6 and 3.8.0 — plain appender and JSON, verbatim — are
committed under tests/slowlog/ and read by SlowlogCaptureTest. Every one of
them ranks as the same four shapes, whichever version and whichever appender
wrote it. Three things they said that no amount of reading documentation would
have:
- A search is logged once per phase, query and fetch, both records carrying
the same body — so counting both doubled every number in the report. One phase
is read at a time now,queryby default, and the summary says how many
records the other phase held.--phase=fetch|bothfor the rest. - The body in a record is the query the shard ran, not the one the client
sent:boostandadjust_pure_negativeappear, atermbecomes
{"value": …, "boost": 1.0}, a range matching nothing collapses to
match_none, and a resolved range keeps its shape while losing its bounds.
The consequence is worth stating plainly, and the guide states it: a slow
log fingerprint and an application fingerprint of the same request are not the
same hash. Each groups correctly against its own kind; the two sets do not
join. Records are also per shard, socountis shards touched. - OpenSearch 3 escapes the body twice in the JSON layout, from the same
configuration file 2.19.6 escapes it once with. Every 3.8.0 JSON record was
unreadable until that layer was taken off — through the decoder rather than by
stripping backslashes, so an escaped quote inside the query survives.
A range you could not read, in every slow log record
{"range": {"@timestamp": {"gte": "now-15m"}}} was rendered. The same range
as the shard rewrote it — {"from": …, "to": …, "include_lower": true} — was
not, and came out as range(?), which does not even name the field. Since every
record in a slow log carries the rewritten form, the report was unreadable
exactly where it mattered:
logs-* | q=(not status:? and range(?) and service:?) before
logs-* | q=(@timestamp:* and not status:? and service:?) after
Two rules, both reported by explain():
from/towithinclude_lower/include_upperare read as
gte/gt/lte/lt. The two spellings are one query, and they now share a
fingerprint:{"from": 20, "to": 150}and{"gte": 20, "lte": 150}hash
identically, which is the same tradebool.filter→andmakes.- A range left with no bound at all becomes an
exists. It matches every
document that has the field, which is what@timestamp:*says — and says
better thanrange(?)did. A shard rewrites a range every document satisfies
into exactly that.
A payload naming no bound and no bound setting is still opaque: range(?) is
what "this library failed to read it" looks like, and it should keep meaning
that.
This is why the prefix moved. No committed fixture changed — none of them
used the older spelling — so make release-check had nothing to report, and the
decision could not be a mechanical one. The hashes that do move are those of
queries that read as range(?) until now.
tests/fixtures/18-rewritten-range is a record captured from a live 2.19.6 node,
kept as the fixture for exactly this, and it is a playground preset too.
The fingerprint flags now live in one place
Every sub-command has to accept all of them — a report grouped under different
rules than the application logging them is a report about nothing — so
FingerprintFlags holds the mapping and the help text both commands print.
Two copies of --max-values=none are two chances for one of them to quietly
mean something else.
The mutation job had a ceiling nobody had reached yet
Infection deletes its temporary directory by materialising every file in it into
a single array, so the image's 128 MB limit was a limit on how many mutants may
exist rather than on anything this library does. It was reached at the end of a
green run: the score printed, then a fatal error, then exit 255. The run is given
no memory ceiling now.
Covered MSI is 80%, up from 79, with nothing uncovered — the tests written
for the captured records killed escapes rather than adding any — so the ratchet
in infection.json5 moves 78 → 79.
v0.9.0 — the playground is a page of the documentation
the playground is a page of the documentation
Fingerprints: q3: unchanged.
One page instead of two
The playground was an application parked beside the site: its own document, its
own masthead, its own copy of the links, its own header — sharing a palette with
the documentation and nothing else. It is
a page of it now.
docs/playground.md carries the prose and overrides/playground.html the
markup, so the site's header, footer, search and palette switch are the site's,
declared once. The stylesheet lost body, h1, a, code, the box-sizing
reset and the font stack — eleven bare elements Material already styles — and
everything left is scoped to .playground.
The panes are the same width they were, 586 px each: the page hides the
navigation sidebar and the table of contents, which leaves Material's grid at the
1200-odd pixels the standalone page set for itself.
The dark scheme is now the reader's choice rather than the operating system's.
It was @media (prefers-color-scheme: dark), which ignored the switch in the
header; it follows [data-md-color-scheme="slate"] like the rest of the site.
What instant navigation costs, and what it teaches
navigation.instant swaps the parts of a document Material knows about. It does
not re-run the <head>, and it does not re-run the scripts at the end of the
body — so a stylesheet or a module that only one page declares is missing for
every reader who arrives by a link rather than a reload. The page renders
unstyled, or renders and does nothing, and mkdocs build --strict is perfectly
happy. Both are declared for the whole site instead: the stylesheet is inert
outside .playground, and the module is loaded by nine lines that import it when
a document actually contains the playground.
The module then boots on a signal rather than on being loaded — document$, which
emits on load and on every navigation — and its boot is idempotent, flagged on the
root element rather than in the module, so a document that has just been replaced
boots while the one it replaced cannot boot twice. The interpreter outlives the
page: navigating away and back no longer costs 3.1 MB twice. Every asset resolves
against import.meta.url, because the document's base moves and the module's does
not.
This was found by a test that first passed for the wrong reason. Material
decides which links to intercept from sitemap.xml, whose URLs are absolute and
built from site_url — so on any origin but the published one, every link is an
ordinary page load and instant navigation never happens at all. Served from
127.0.0.1, the new check went green against the very failure it was written for.
It rewrites the sitemap to its own origin now, and the assertion it added is that
the document was never reloaded.
The guards
make playground-check drives the built site, since that is where the page is
assembled — seventeen presets, a permalink, an arrival by internal link — but it
needs node and a browser and is deliberately not in CI. So three things it would
catch are now in the suite that runs everywhere:
- every id the module asks for exists in the markup, which is the failure a
split between a template and a module makes possible. Ids are prefixedpg-
now: the page shares a document with headings whose anchors are slugs, and
body,text,notesandstatusare all slugs waiting to happen; - the stylesheet and the module are declared for the site, not by the page;
- the page selects the template, and no
playground/index.htmlexists to be
copied over the page MkDocs generates at that path.
PaletteTest reads the dark block from a selector at the start of a line rather
than the first mention of one anywhere in the file. The stylesheet now explains in
a comment why the scheme is an attribute and not a media query, and that sentence
was enough to make every light value resolve to nothing.
v0.8.0 — a documentation site, and a playground that answers to nobody
a documentation site, and a playground that answers to nobody
Fingerprints: q3: unchanged.
A documentation site
https://mrdlef.github.io/php-os-query-digest/ — built with MkDocs
Material, published from a release tag like the playground and for the same
reason: a page describing an API nobody can install yet is worse than a page a
few days out of date.
The README had grown to 36 KB across 23 sections, one of which was 39% of the
file on its own. It is now 6 KB: what the library does, the before-and-after,
how to install it, and where to read more. Everything else moved to pages, plus
new material that was missing — a five-minute getting started, and a reference
for the fourteen public classes written from reflection rather than from memory.
The playground moves to
/playground/. The
root now serves the documentation, which is what people arrive looking for. The
composer.json homepage is unchanged; it points at the same URL, which now
answers with docs.
The two share one identity rather than looking like a site and an app that
happen to be neighbours — and that identity is now generated rather than written
twice. Bone is the field, pitch black is the ink, and a lobster marks anything
you can act on: six values, emitted into both stylesheets from
tools/build-palette.php, because they were written twice and the second copy
drifted inside a day.
The lobster needed two of those six. It reads 2.87:1 on bone and 3.88:1 on the
playground's raised pane, so it carries no text itself — light mode takes a
deeper one, dark mode a lifted one, and the hue they are drawn from stays in the
palette as the thing they agree on. Nothing is #fff: Material defaults three
variables to white or an alpha of it, and all three name bone.
Every pairing is measured, not checked once. PaletteTest parses the shipped
stylesheets rather than the tool's own values — which would prove only that the
tool agrees with itself — resolves each var() chain, and reports all 34
documented pairs. The 30 text pairs clear AA with the tightest at 4.73:1 and
fourteen of them at AAA; the four form borders clear the 3:1 that identifies a
control. A colour nudged to taste turns CI red instead of shipping 3.9:1.
The site fetches no fonts. It linked a stylesheet on fonts.googleapis.com
and preconnected to fonts.gstatic.com; it now references neither, naming the
system stacks the playground already used. The playground's corners follow
Material's three radii — 2px for controls, 4px for panes, a pill for the chips
that were already one. Its links to the source and to Packagist carry inline
marks, still fetching nothing from anywhere.
MkDocs lives only in the Pages workflow — not in composer.json, not in the
package. make docs serves the site locally through the same pinned image CI
uses, so a local build and the published one cannot disagree.
Use cases, with every query executed
Four Use cases pages,
placed before the guides because a reader who has just installed this wants to
know what it answers, not what its options are.
The home page already claimed the log index could tell you "which kind of query
got slow this afternoon, which shape runs a thousand times an hour, which one
appeared the day the incident started". Nothing behind it showed how. These pages
do, and the answers are less obvious than the claim:
- Ranking by p95 finds the wrong query. It surfaces the report that takes a
second and a half and took a second and a half yesterday. Ranking each shape
against its own history finds the one that went from 50 ms to 1074 ms. - The query you run most is not the query that costs you most. The workhorse
ran 7200 times for 57.6 s; a dashboard aggregation ran 360 times for 118.6 s. - A deploy marker and a latency bump are not the same event. In the scenario
the slowdown lands at 14:00 and the release at 15:00, so the obvious story —
the release broke search — is wrong and hard to disprove without the hash. - The hash is not a cache key. Three tenants share one fingerprint, because
erasing literals is the whole point. Cache on it and tenant 42 is served
tenant 41's invoices.
Every aggregation on those pages is executed by UseCaseTest against
OpenSearch 2.19.6 and 3.8.0, including the one shown as a counter-example,
which has to keep being wrong. The queries are extracted from the markdown by a
<!-- verified: --> marker rather than copied into the test, so there is one copy
and it is the one a reader sees. The test also fails if a page prints a hash this
library no longer produces, and if a marked block has no test behind it.
That caught two things worth admitting: a page that quoted an invented hash, and
a page that showed status:? inside the q field while explaining that q keeps
its values.
make integration was broken against the 3.x node on any machine with less than
about 100 GB free, and had been. 3.x adds a headroom to the flood-stage watermark,
so the block trips under ~100 GB regardless of the percentage, and the node
answers index creation with a bare 403 index_create_block_exception that
mentions no disk. os2 already disabled the threshold; os3 now does too.
The playground answers to nobody
It used to import() its PHP-in-WebAssembly runtime from a public CDN, which
cost two things. Every visitor was disclosed to a third party the page never
named, and a dynamic import() takes no integrity attribute — so nothing
checked that what arrived was what had been published.
The runtime is now served from this site. tools/fetch-runtime.php downloads
it at build time and verifies all nine files against the SHA-256 hashes in
playground/runtime.lock.json; a substituted artefact fails the deploy rather
than reaching a browser. That verification is the point of the arrangement, and
it was not available before: the usual workaround for an unverifiable dynamic
import — fetching the module and importing a hash-checked blob URL — cannot work
here, because this runtime resolves its wasm with new URL(…, import.meta.url)
and a blob URL breaks that.
The runtime is not committed. 12.5 MB does not belong in the history of a
library whose own package is measured in kilobytes, and the repository's largest
file is 146 KB. It is fetched by make playground-runtime, gitignored, and
downloaded in the Pages workflow beside MkDocs, for the same reason MkDocs lives
only there.
What it costs a visitor: about 3.1 MB instead of 2.8 MB, once — GitHub Pages
gzips wasm where the CDN served brotli. What it buys: the page's claim is now
literally true. Nothing it loads comes from anywhere but the site serving it, and
PlaygroundTest fails if that stops being so.
That guard is worth a note. The first version matched the shape of a loader
call, looking for import('https://…') — and passed the very line it existed to
forbid, because the URL reached import() through a constant. It matches on
hosts now: two are allowed, both anchors, and a link the reader may click is not
a request the page makes.
Also fixed while verifying this in a real browser:
tools/playground-browser-check.mjs had pinned q2:5b2210eb5318 as the hash the
CLI produces. The prefix moved to q3: in v0.6.0 and nothing noticed, because
that script is deliberately not in CI. It asks the CLI now, which is what its
comment always claimed it did.
How to report something, and how to change it
SECURITY.md — how to report a vulnerability (privately, through
GitHub), which versions are supported, and a threat model grounded in what the
library actually does rather than in boilerplate: no file access, no network, no
process execution at runtime, and php plus ext-json for dependencies. It is
blunt about the one real risk, which is that at the default normalisation the
rendered line keeps literal values — so a term on an email puts that email
wherever the line goes. The signature and the hash never do.
CONTRIBUTING.md — the commands, and then the half that
matters: the seven rules that are not guessable from the code. Never regenerate
fixtures without reading the diff, promotions are batched because the prefix is
global, a new class is @internal until someone decides otherwise, PHP 7.4 is
the floor and it constrains the tooling, the mutation score is a ratchet, the
changelog is the source, and new query types must be classified.
v0.7.0 — the pre-1.0 hardening
the pre-1.0 hardening
Fingerprints: q3: unchanged.
Nothing here moves a hash. It is the work of deciding what a 1.0.0 would be
promising, before promising it.
What is public, and what is not
Every class in src/ is now marked @api or @internal, and ApiBoundaryTest
fails the suite if one is marked neither, marked both, or if a public method
hands back an internal type — a type reachable from a public signature is public
whatever its annotation claims.
Fourteen classes are the public surface. The parser, the tree, the
renderers, the canonicaliser and the hasher are not, and that is the point:
those are exactly the classes that change whenever a query type is promoted.
Frozen, every improvement to the rendering would be a major release.
Breaking: IndexNormalizer moves from Support\ to the root namespace,
beside Normalization, the sibling concept it is configured with.
use MrDlef\OsQueryDigest\Support\IndexNormalizer; // before
use MrDlef\OsQueryDigest\IndexNormalizer; // afterThe elasticsearch keyword and mention are gone from composer.json. The DSL
overlaps enough that the library is useful against Elasticsearch; that is not
the same as promising it when no ES-specific type was ever classified or
certified.
Teaching it a query type it does not know
Options::withClauseRenderer() takes an Extension\ClauseRenderer for a type
the library leaves opaque — the Learning-to-Rank plugin's sltr, or a query
type private to your cluster.
before q=(sltr(?))
after q=(_score:sltr(model=ltr_model_v3))
Three properties make it safe: a renderer cannot reach a natively modelled type
(the hook sits in the parser's default branch, so term has already returned);
the hash version is marked q3x: as soon as one is registered, because the
rules are then no longer this library's alone; and explain() reports
extension_rendered.
Would the tests notice?
Mutation testing runs in CI — make mutation locally. Nothing in src/ is
uncovered and the covered score is 79%, guarded so it cannot quietly fall.
It found three real gaps on its first run: ABSORB_MATCH_NONE was recorded from
two branches and tested on one, UNWRAP's guard was unpinned, and Hasher
carried a second copy of the defaults that live in Options — unreachable, and
free to drift from the values actually used.
What it costs
make bench measures the request path against the committed fixtures. The
pitch — that you can afford a digest on every search — was an argument until
now: ~30 µs per request on PHP 8.5, ~37 µs on 7.4, against a search that
takes milliseconds. lazy() costs 0.2 µs, some hundred times less, so a debug
record your handler drops really does parse nothing.
No timing gate in CI: wall-clock on a shared runner is noise, and a threshold
tight enough to catch a regression would fail on a busy afternoon.
This file
CHANGELOG.md is new, and it is the source: release notes are extracted from
it, so they are reviewed in the pull request that ships the change rather than
written after the tag. tools/changelog.php check holds each entry to the
hashes pinned in tests/fixtures.
v0.6.0 — eight query types promoted, and the hash moves to q3
Coverage goes from 38 native / 21 opaque to 46 / 13 of the 59 query types in the OpenSearch specification. The fingerprint prefix moves q2: → q3:.
Breaking: your fingerprints change
Every hash minted by this version carries the q3: prefix. Dashboards grouping on q2: values will not match new digests — that is what the prefix is for, and it is why all eight promotions ship together rather than one per release: the prefix is global, so promoting a single rare type would invalidate exactly as much as promoting eight.
A signature that did not change keeps its twelve hex characters. All 16 pre-existing fixtures moved their prefix and nothing else:
v0.5.0 q2:fe168406e702
v0.6.0 q3:fe168406e702
So q2:abc… and q3:abc… describe the same shape, and a prefix bump reads as a rename rather than a wall of unrelated values.
What was promoted
| type | before | after |
|---|---|---|
hybrid |
hybrid(?) |
(embedding:knn(k=20) or title:"waterproof hiking boots") |
wrapper |
wrapper(?) |
the decoded query, in full |
combined_fields |
combined_fields(?) |
title|body:"connection timeout" |
common |
common(?) |
msg:timeout |
percolate |
percolate(?) |
alerts:percolate() |
rank_feature |
rank_feature(?) |
popularity:rank_feature() |
distance_feature |
distance_feature(?) |
created_at:distance_feature(pivot=7d) |
intervals |
intervals(?) |
msg:intervals() |
hybrid is the one that matters on a modern cluster. The flagship OpenSearch pattern — a lexical clause and a vector clause combined under a normalisation pipeline — used to collapse into a single word that said nothing about a query whose entire point is what it combines. It returns the union of its queries exactly as dis_max does; the two differ only in how scores are blended, and this library already declines to distinguish scoring:
q=(embedding:knn(k=20) or title:"waterproof hiking boots") q3:a8a542c4af15 (hybrid)
q=(embedding:knn(k=20) or title:"waterproof hiking boots") q3:a8a542c4af15 (dis_max)
wrapper recovers rather than summarises. A query passed through base64 as an opaque blob is decoded and parsed, so it fingerprints identically to the same query sent unwrapped:
{"term":{"env":"prod"}} q3:1cc724ddd8ef
{"wrapper":{"query":"eyJ0ZXJtIjp7ImVudiI6InByb2QifX0="}} q3:1cc724ddd8ef
rank_feature and distance_feature stay leaves rather than unwrapping the way function_score does. They read as boosting and sit where a boost would, but a document without the field does not match — so they genuinely restrict the result set. The scoring curve (saturation, log, sigmoid, and a distance origin) is dropped: it reorders, it does not exclude.
intervals keeps the field only — modelling all_of/any_of/max_gaps/ordered would be a parser inside the parser for the rarest type that has a field at all. percolate keeps the field, which says which set of saved queries is being replayed; its indexed-document variant gets the same warning a terms lookup does. combined_fields and common reuse the paths multi_match and fuzzy already take.
What stays opaque, and why that is now settled
The 13 remaining types are a position, not a backlog:
- the
span_*family (9, includingfield_masking_span) — nobody debugs a span query from a log line, and promoting one span without the rest would read worse than promoting none; type— removed with mapping types; no live cluster accepts it;sltr— a Learning-to-Rank plugin absent from the official image, and pure rescoring;template— lives behind/_search/template, not in a query clause;agentic— hands the whole result set to a model deciding outside the DSL.
They are still signalled as type(?), never dropped, and still contribute to the fingerprint.
Certification and compatibility
All eight promoted types were already certified against live clusters, with two documented exceptions: combined_fields is accepted by 3.8.0 and answers unknown query on 2.19.6, and hybrid cannot be probed without a registered search pipeline — its reason is recorded in resources/probes.json.
Runtime requirements are unchanged: php and ext-json, nothing else. Tested on PHP 7.4 through 8.5.
Upgrading
composer require mr-dlef/os-query-digest:^0.6If you store fingerprints, expect q3: on everything minted after the upgrade. Historical q2: values remain correct for the rules that produced them — and where the shape is unchanged, the twelve hex characters let you line the two up.
v0.5.0 — a CLI and a browser playground
Two ways to use the library without writing a line of PHP: a command you can
pipe a slow log through, and a page that runs it in your browser.
No hash moves. Every fingerprint v0.4.0 produced, v0.5.0 produces. This
is additive throughout.
A CLI
$ echo '{"query":{"term":{"service":"api"}},"size":50}' \
| vendor/bin/os-query-digest --index logs-2026.08.13
idx: logs-*
text: logs-* | q=(service:api) | size=50
sig: logs-* | q=(service:?) | size=50
hash: q2:5b2210eb5318--explain appends the rules table, --json emits the digest object, --hash
emits nothing but the fingerprint.
The reason it exists is --ndjson — one query per input line, one line of
output each, which is the shape sort | uniq -c | sort -rn expects:
$ os-query-digest --ndjson --hash < slow.ndjson | sort | uniq -c | sort -rn
3 q2:3109618415cb
1 q2:a3e42b3a6c70Those three are not three slow queries to read: they are one shape, hit on two
different days, with two different service values. That is the question no
amount of reading individual slow queries answers.
A malformed line is reported on stderr and skipped — a slow log is untrusted
input, and stopping at the first mangled record would make the tool useless
exactly where it is needed. Exit codes: 0 ok, 1 an input could not be
parsed, 2 a bad invocation.
A playground
https://mrdlef.github.io/php-os-query-digest/ runs this library on your
query, in your browser, with no server involved: PHP itself compiled to
WebAssembly. Your query never leaves the page — there is nowhere to send it.
It opens on a precomputed example and downloads nothing; the moment you
change the query or an option it fetches a PHP 8.3 and runs the real library.
Measured in Chromium: 2.77 MB transferred, ~300 ms to a working interpreter,
0.5 ms per query after that.
Pin a query as a reference, then edit it: the page tells you whether the
fingerprint moved and which normalisation rule made the difference — the
question you actually have when two queries you thought were different share a
hash. Every state is a permalink, so a bug report can be a link.
What it shows is guarded by the offline suite: the library is shipped to the
browser as one file, and PlaygroundTest executes that file with real PHP
against the golden fixtures. "The browser runs the same library as composer require does" is checked by CI without a browser or a byte of wasm.
Options::fromArray()
Every front end that is not PHP configures through a string map — a CLI flag, a
YAML block, a query string:
Formatter::create(Options::fromArray([
'normalization' => 'structural',
'maxValues' => 5,
'aggNames' => true,
]));Unknown keys and wrong types throw InvalidOptionException rather than 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".
Normalization::fromLevel() and IndexNormalizer::fromMode() are its
counterparts, and Options::KEYS, Normalization::LEVELS and
IndexNormalizer::MODES are public so a help text or a <select> never
hard-codes a vocabulary that then drifts.
Also
- The README leads with the problem and a real before/after, using a fixture as
its showcase — so every number on the front page is pinned by the test suite. - Workflows moved off Node 20 actions.
- Tested on PHP 7.4 → 8.5, PHPStan
level: max, still no runtime dependencies.
v0.4.0 — Monolog processor
If your application already logs its OpenSearch request bodies, you no longer
have to touch every call site. Push one processor and the raw request is
replaced by its digest wherever it appears.
use MrDlef\OsQueryDigest\Monolog\DigestProcessor;
$logger->pushProcessor(new DigestProcessor());
$logger->info('opensearch.search', [
'query' => $request, // → {"idx": …, "q": …, "sig": …, "hash": …}
'index' => 'logs-2026.08.16',
'took' => $response['took'], // untouched, like the rest of the context
]);The keys it reads are configurable — new DigestProcessor($formatter, 'search_body', 'target') — and anything that is not a search request is left
exactly as it was found. A processor that guessed would corrupt your log lines.
Both Monolog versions, one class
- Monolog 2 hands a processor an array; Monolog 3 hands a
LogRecord.
ImplementingProcessorInterfacewould pin the class to one of them, so it
stays a plain callable — which both versions accept wherever a processor
is expected. - Monolog 3 makes
contextreadonly, so it cannot be assigned. The update
goes throughwith(context: …). Named arguments are PHP 8 syntax and this
file has to parse on 7.4, so the same call is written as an unpack of a
string-keyed array, which PHP 8.1 turns into named arguments — and 8.1 is
Monolog 3's own floor. instanceofagainst a class that does not exist is false rather than an
error, and does not autoload, so the Monolog 3 branch never runs under 2.
This matters because a PHP 7.4 user can only have Monolog 2, and that is exactly
who the 7.4 support exists for. Verified on both sides: 7.4 → Monolog 2.11, 8.0
→ Monolog 2, 8.1+ → Monolog 3, with the same 107 tests.
Lazy, and safe
The digest stays lazy, so a record buffered by a FingersCrossedHandler that
never triggers costs nothing.
That laziness moves any parse failure into Monolog's formatting, where an
exception would cost the whole record. So a request the library cannot read
yields {"error": "…"} in place of the digest. You lose the digest, never the
log line.
It deliberately does not fall back to the raw request: that would restore the
wall of nested braces this library exists to keep out, at the size that made it
unloggable in the first place. The error message says what went wrong, which is
the part you can act on.
Dependency
Monolog is a suggested dependency, never a required one. The library itself
still has no runtime dependencies beyond ext-json.
"suggest": {
"monolog/monolog": "^2.0 || ^3.0"
}No hash moves
Nothing under src/Fingerprint, src/Render, src/Parser, src/Normalizer or
src/Tree changed, and no fixture moved. The q2: prefix stays and every
fingerprint published under it remains valid.
Full changelog: v0.3.0...v0.4.0