Log your queries¶
describe() takes a search body, an ['index' => …, 'body' => …] envelope as
produced by opensearch-php, or the JSON string of either.
use MrDlef\OsQueryDigest\Formatter;
$formatter = Formatter::create();
$logger->info('opensearch.search', [
'q' => $formatter->lazy($request, $index), // nothing is parsed…
'took' => $response['took'],
]);
lazy() returns a JsonSerializable that only parses when something reads it —
so a debug-level log filtered out by your handler costs nothing.
The digest serialises to a compact object:
{
"idx": "logs-*",
"kind": "aggregate",
"q": "logs-* | q=(@timestamp >= now-15m and service:api) | size=0",
"sig": "logs-* | q=(@timestamp >= ? and service:?) | size=0",
"hash": "q6:b7cc218cda09"
}
kind is what the request is for — this one asks for buckets and no
documents. It is read off the shape, holds no value, and is the field to group
by when the question is what your application searches for rather than which
search is slow. The six of them are in Kinds.
With Monolog¶
If your application already logs its request bodies, you do not 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": …, "kind": …, "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, because a processor that guessed would corrupt your log
lines.
It stays lazy: the parse happens when a handler serialises the record, so one
buffered by a FingersCrossedHandler that never triggers costs nothing. And
because the parse then happens inside Monolog's formatting, a request the
library cannot read yields {"error": "…"} in place of the digest rather than
an exception. You lose the digest, never the log line.
Monolog is a suggested dependency, never a required one — the library itself
still has none. Both major versions work: ^2.0 on PHP 7.4, ^3.0 from 8.1.
When your collector cannot read a nested field¶
Some log platforms group and chart only on top-level keys. Rather than have everyone on one of those write the same flattener by hand, ask for the flat spelling:
use MrDlef\OsQueryDigest\RecordLayout;
$logger->pushProcessor(new DigestProcessor(null, 'query', 'index', RecordLayout::flat()));
{
"dsl_idx": "logs-*",
"dsl_kind": "aggregate",
"dsl_q": "logs-* | q=(@timestamp >= now-15m and service:api) | size=0",
"dsl_sig": "logs-* | q=(@timestamp >= ? and service:?) | size=0",
"dsl_hash": "q6:b7cc218cda09",
"dsl_notes": null,
"dsl_error": null
}
Same fields, same values, same fingerprint: dsl.hash and dsl_hash are one
contract in two spellings, so a hash logged either way compares with the other.
Three things worth knowing before you switch:
- The key that held the request is removed, since a flat record has nowhere to put a sub-object — leaving it would leave the raw body in the line.
notesbecomes a;-joined string. A collector that cannot query a nested object cannot query an array either.- Every key is always present,
nullwhere this record has no such field — the keys are chosen before anything is parsed.dsl_erroris the one that fills in when a request cannot be read, and it is null on every request that can.
RecordLayout::flat('q_') picks another prefix; the separator is part of it, so
dsl- or no prefix at all are equally available. RecordLayout::nested('search')
does the same for the nested spelling.
Laziness survives either way: the seven keys share one parse, and it still only happens if a handler serialises the record.
When the values may not leave the building¶
q is search input. Names, addresses, e-mail addresses — whatever a user typed
into the box. 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.
sig and hash are already value-free, and everything this library is for
reads them: which shape got slow, which one the deploy added, which one to group
a dashboard by. q is the convenience of pasting into Dashboards. So it can be
turned off:
$formatter = Formatter::create(Options::create()->withText(false));
The record is then three fields:
{
"idx": "logs-*",
"kind": "aggregate",
"sig": "logs-* | q=(@timestamp >= ? and service:?) | size=0",
"hash": "q6:b7cc218cda09"
}
The same hash as with the line on — what a shape is called does not depend on what is emitted beside it, so a dashboard built before the switch keeps matching after it.
Three things worth knowing:
- The line is never rendered, not rendered and dropped. So a blanket
redactor —
withRedactor(fn ($field, $value) => '?')— is not the same trade: it renders the same line twice to throw one away, and a per-field redactor is one forgotten field away from a leak. Here there is no literal anywhere in the digest, andtext()returns the signature so that nothing reading the wrong accessor can find one either. qis omitted, not emptied. Aqthat duplicatedsigwould still have to be inspected before those logs could ship. It is also the longest of the four fields, so this is the cheapest log-volume win the library offers.- It does not, by itself, mean no literal is emitted. Under
Normalization::none()the signature is the readable line. The default —Normalization::values()— is what makessigvalue-free, and the two together are what a regulated deployment wants.
The shipped dashboard pack needs no change: its panels group and
aggregate on dsl.hash and read dsl.sig. dsl.q appears only in the index
pattern's field list, so it simply has no values.
Ranking what you log, without a log index¶
Shipping digests to an index and building panels on them is one way to read them. The other is to add them up where they are made, which needs no index and no dashboard:
use MrDlef\OsQueryDigest\Analysis\Report;
$report = new Report();
// wherever a search comes back
$report->record($digest, (float) $response['took']);
// at the end of the request, the job, the test run
foreach ($report->top(5) as $shape) {
printf("%-16s %5d × %8.1f ms %s\n",
$shape->hash(), $shape->count(), $shape->total(), $shape->signature());
}
Report is the grouping and the ranking the slowlog command runs on a
cluster's slow log — same class, fed from the other end. It holds one object per
shape, so a long-running worker accumulating a million searches over forty
shapes holds forty of them.
Ranking the log you already have¶
The third way needs no code at all. Point report at the file your application
is already writing and it ranks what is in it:
os-query-digest report /var/log/app/*.log
It reads one JSON object per line, ignores anything before the {, skips every
line carrying no fingerprint, and re-parses nothing — the hash stored when
each search ran is the one records are grouped by, so a year of them stays
comparable however the rules have moved since.
The defaults read what
RecordLayout::flat()
writes. Anything else is a matter of naming keys, and a dotted name walks into a
nested object:
# the nested spelling this library writes by default
os-query-digest report --key-prefix=dsl. app.log
# a record your application flattened itself
os-query-digest report --key-prefix=q_ --text-key=q --took-key=duration_ms app.log
Only --hash-key is required in practice: a record with a fingerprint and
nothing else still counts and still groups. Without a duration the ranking falls
back to counts, and says so in its header rather than showing a column of
zeroes.
It is the natural place to answer "what does this page actually search for":
group the top by kind() and a request that fires
eleven searches turns into two autocompletes, one lookup and eight browses.
If it does not log its bodies¶
This one still asks something of the application: that the request already reaches a log call. If it does not, there is a way in that asks nothing at all — wrap the HTTP client instead, and no call site changes. See Capture at the transport.
Reading the line¶
logs-* | q=(service:api and status:(500 or 502)) | post=(host:web-1) | aggs=terms(host,10)>p95(rt) | size=0 sort=@timestamp:desc | +highlight
└ index └ DQL query └ post_filter └ aggregation pipeline └ options └ notes
The q=(…) segment is DQL: select it and paste it into OpenSearch Dashboards.
Aggregations use > to read as "then, per bucket".
post=(…) is the post_filter, kept apart from q=(…) on purpose: it runs
after the aggregations, so it narrows the hits while the buckets keep counting
the whole result set. That is the faceted-search pattern, and folding the two
together would describe a query nobody sent.
The last segment lists what was acknowledged but not rendered inline — a
boost-only should group, an unsupported top-level section. Nothing is ever
dropped silently.