Command line¶
The package ships a binary, so a query pasted out of a slow log does not need a scratch PHP file:
$ 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: q5:5b2210eb5318
--explain appends the rules table, --json emits the digest object, --hash
emits nothing but the fingerprint. Every Options key has a flag of the same
name, so --normalization=structural --max-values=none is the CLI spelling of
the array above.
The reason it exists is --ndjson: one query per input line, one line of output
each. Point it at a log of search bodies and "which kind of query is slow" is
a uniq -c away:
$ os-query-digest --ndjson --hash < slow.ndjson | sort | uniq -c | sort -rn
3 q5:5b2210eb5318
1 q5:f70c7bc21a0f
Those three are not three slow queries to read: they are one shape, hit on two
different days, with two different service values.
A malformed line is reported on stderr and skipped, so one mangled record does
not cost you the rest of the file. Exit codes: 0 ok, 1 an input could not be
parsed, 2 a bad invocation. --help lists every flag.
Without the package¶
The binary does not need the library installed beside it: every release ships
the CLI as one .phar and as an image, and
without installing PHP at all
has both commands. A slow log is rarely on a machine anyone would install a PHP
toolchain on.
Only two things differ from vendor/bin/os-query-digest:
--versionnames the build. An installed copy is identified by thecomposer.lockthat installed it; a file someone copied onto a host is identified by nothing, so the phar carries the release it was built from and says so.- The image reads standard input unless you mount something. It runs as
nobody, which cannot read a root-owned log — piping avoids the question entirely.
The fingerprints are the same: CI digests one query with the image and with the checkout it was built from, and compares the two.
From the log your cluster already writes¶
--ndjson still asks you for a file of query bodies, which you probably do not
have yet. Your cluster does: index.search.slowlog is on in most of them, and
os-query-digest slowlog reads it as it is.
$ vendor/bin/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 q5:fe168406e702
logs-* | q=(@timestamp >= ? and @timestamp < ? and not status:? and service:?) | size=50 sort=@timestamp:desc
6 5,978 996 1,325 1,325 q5:6b6fb17c6640
orders-* | q=(sku:(? or ? or ?)) | aggs=date_histogram(created,day)
12 730 61 86 86 q5:810928290c12
catalog-* | q=(title:~?) | size=10
No application change, nothing to deploy, no index to create — and the question the whole library exists for is answered on the file you already have.
Ranked by total time, not by the slowest record. A query that took 1.3
seconds once is a bad afternoon; a query that takes 166 ms forty-one times is
the afternoon, and a slow log lists the second one forty-one times without ever
adding them up. --sort takes count, mean, p95 or max when you want the
other reading — --sort=p95 puts that date_histogram on top, which is the
answer to a different and equally real question.
The second line of each row is the signature, not one record's values: under
a count of forty-one, a single sample's service and timestamps would read as
the group's. --json carries both, and labels the sample as one — plus the
timestamps the group spans, which is how you tell a shape that has always been
there from one that arrived with this morning's deploy.
$ os-query-digest slowlog --json --top=1 slowlog.log | jq '.[0] | {count, p95_ms, first, last}'
{
"count": 41,
"p95_ms": 246,
"first": "2026-08-20T14:00:03,970",
"last": "2026-08-20T14:02:03,355"
}
Both appenders are read, the plain one and the JSON one beside it. Files are read a line at a time, so a rotated log of any size is fine, and every fingerprint flag above applies here too: group the report under the same rules your application logs under, or the two are about different things.
Records whose JSON keys are namespaced — …slowlog.source rather than source
— are read as well. That is tolerance for a layout, not a claim: OpenSearch is
what this library certifies, and it is the only thing it promises.
Lines that hold no search record are skipped in silence. A slow log also
holds allocation notices and stack traces, and a tool that refused the file over
them would be useless exactly where it is pointed. One line is not treated as
noise: a record whose source[ never closes, which is what log rotation does to
a line. That is reported, because staying quiet about it would understate the
shape it belonged to.
If nothing in the input is a slow log record, that is an error rather than an empty table — pointing this at the wrong file should not look like a healthy cluster.
What a slow log record actually holds¶
Three things about the file itself, each of which changes how the report reads.
They are not deductions: the reader is tested against records captured from
OpenSearch 2.19.6 and 3.8.0, committed under tests/slowlog/.
A search is logged once per phase. The query phase and the fetch phase each
write a record, and both carry the same body — so counting both doubles every
number. One phase is read at a time and query, the one you tune, is the
default; --phase=fetch or --phase=both when you want the others. The summary
line says how many records the other phase held, so the choice is never silent.
The body is the query the shard ran, not the one your client sent. It has
been rewritten by then: boost and adjust_pure_negative appear, a term
becomes {"value": …, "boost": 1.0}, a range that matches nothing on that shard
collapses to match_none, and a range every document on it satisfies keeps the
field while losing its bounds — so @timestamp >= now-15m reaches the log as
@timestamp:*, an unbounded range, which is genuinely all the record says. The
older from/to spelling every rewritten range uses is read as of v0.10.0;
before that the whole clause came out as range(?).
So a time window is not part of the shape here. now-15m and now-7d over
the same field reach the log identically and share one hash — the bounds were
resolved away before the record was written, and no reading of it brings them
back. Every other kind of value survives; this one does not, and it is worth
knowing before you conclude that two dashboards are running the same search.
Slow log fingerprints are not your application's fingerprints
The same request digests differently from the two sides, because the shard rewrote it. The hash still tells you which shape, and it does that on both sides — but group slow log records against slow log records, and application digests against application digests. The two sets do not join.
Records are per shard. A search over five shards writes five of them, so
count is shards touched rather than requests served. That is the right number
for what is this shape costing the cluster and the wrong one for how often was
it called.
One more, in the same spirit of not deducing: OpenSearch 3 escapes the body twice in the JSON layout, from the same configuration file 2.19.6 escapes it once with. The extra layer comes off through the JSON decoder rather than by stripping backslashes, so a query holding an escaped quote survives it.