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-*",
"q": "logs-* | q=(@timestamp >= now-15m and service:api) | size=0",
"sig": "logs-* | q=(@timestamp >= ? and service:?) | size=0",
"hash": "q5:b7cc218cda09"
}
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": …, "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 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-*",
"sig": "logs-* | q=(@timestamp >= ? and service:?) | size=0",
"hash": "q5: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 os.hash and read os.sig. os.q appears only in the index
pattern's field list, so it simply has no values.
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.