Public API¶
Twenty-three classes. Everything else in src/ is @internal and may move in a
patch release — see what counts as public for
why the line is drawn there.
ApiBoundaryTest fails the build if this list and the annotations ever
disagree, so what follows cannot quietly drift from the code.
Entry point¶
Formatter¶
static create(?Options $options = null): Formatter
options(): Options
describe(array|string $request, ?string $index = null): Digest
explain(array|string $request, ?string $index = null): Explanation
lazy(array|string $request, ?string $index = null): LazyDigest
$request is a search body, an ['index' => …, 'body' => …] envelope as
produced by opensearch-php, or the JSON string of either. $index overrides
whatever the envelope carried.
describe() does the work immediately. lazy() defers all of it until
something reads the result — use it when the record may be filtered out.
explain() returns the digest plus every normalisation rule that fired.
Throws
InvalidQueryException when $request is neither an array nor decodable
JSON. A body it cannot understand does not throw — it renders as
type(?).
Results¶
Digest¶
index(): string
kind(): Kind
text(): string
signature(): string
hash(): string
notes(): array
toArray(): array
Implements JsonSerializable and __toString() (which returns text()).
toArray() gives the compact {idx, kind, q, sig, hash, notes} object — without
q under Options::withText(false), where text() returns the signature
because there is no literal line to return. kind stays: it holds no literal.
Kind¶
static suggest(): Kind
static aggregate(): Kind
static scan(): Kind
static lookup(): Kind
static browse(): Kind
static unknown(): Kind
name(): string
is(string $name): bool
Constants: SUGGEST, AGGREGATE, SCAN, LOOKUP, BROWSE, UNKNOWN, and
KINDS — the six of them, in the order they are decided. Implements
__toString(). What each one means, and how it is read off a request, is in
Kinds.
LazyDigest¶
digest(): Digest
Implements JsonSerializable and __toString(). Nothing is parsed until one of
those is called; both memoise, so reading it twice costs once.
Explain\Explanation¶
digest(): Digest
rules(): Rule[]
has(string $rule): bool
ruleIds(): string[]
toArray(): array
has() takes a Rule:: constant. Casting to string gives the human-readable
table.
Explain\Rule¶
id(): string
count(): int
details(): string[]
description(): string
One rule that actually fired, with how many times and on what. A rule that had
nothing to do is never reported. The 22 Rule:: constants name every rule —
MUST_FILTER_MERGED, BOOST_DROPPED, TERMS_LOOKUP, EXTENSION_RENDERED and
the rest.
Configuration¶
Options¶
Immutable; every with*() returns a copy.
static create(): Options
static fromArray(array $spec): Options
withNormalization(Normalization $normalization): Options
withMaxClauses(?int $maxClauses): Options
withMaxValues(?int $maxValues): Options
withMaxFields(?int $maxFields): Options
withMaxAggs(?int $maxAggs): Options
withMaxLength(?int $maxLength): Options
withIndexNormalizer(IndexNormalizer $indexNormalizer): Options
withRedactor(?callable $redactor): Options
withAggNames(bool $includeAggNames): Options
withText(bool $emitText): Options
withHashLength(int $hashLength): Options
withHashVersion(string $hashVersion): Options
withClauseRenderer(string $type, ClauseRenderer $renderer): Options
Each has a matching getter. fromArray() accepts the eleven keys in
Options::KEYS and throws InvalidOptionException on an unknown key or a wrong
type — the redactor and clause renderers have no array form, being callables and
objects.
The redactor is called as fn(string $field, mixed $value): mixed before a
value is rendered.
withText(false) drops the readable line: it is never rendered, toArray()
emits idx / sig / hash, and text() returns the signature. See
when the values may not leave the building.
See Options for what each one does to the output.
Normalization¶
static none(): Normalization
static values(): Normalization // the default
static structural(): Normalization
static fromLevel(string $level): Normalization
level(): string
erasesValues(): bool
erasesCardinality(): bool
erasesPagination(): bool
Constants: NONE, VALUES, STRUCTURAL.
IndexNormalizer¶
static datePatterns(): IndexNormalizer // the default
static identity(): IndexNormalizer
static custom(callable $rewrite): IndexNormalizer
static fromMode(string $mode): IndexNormalizer
normalize(string $index): string
Constants: DATE_PATTERNS, IDENTITY. datePatterns() collapses
logs-2026.08.13 to logs-*, so a daily index does not mint a new fingerprint
every midnight.
custom() takes fn(string $index): string, called once per name in a
comma-separated list. Your rule runs first, then datePatterns(). It has no
array form and is not a MODES entry — a callable cannot come out of a
configuration file, the same line withRedactor() sits on. See
an index name only you can read.
RecordLayout¶
static nested(string $key = 'dsl'): RecordLayout // the default
static flat(string $prefix = 'dsl_'): RecordLayout
isFlat(): bool
apply(array $context, LazyDigest $digest): array
Constant: FIELDS, every key a flat record writes. Where a digest's fields go
in a log record — under one key, or beside it:
nested {"dsl": {"idx": "…", "kind": "…", "hash": "q6:…"}, "took": 12}
flat {"dsl_idx": "…", "dsl_kind": "…", "dsl_hash": "q6:…", "took": 12}
Both spellings carry the same fields under the same names, so dsl.hash and
dsl_hash hold the same value. Passed to Http\LoggingObserver or
Monolog\DigestProcessor; the shipped dashboard pack maps the nested one. A
flat record is still lazy — its keys share one parse — and turns notes into a
;-joined string, a nested object being exactly what its reader cannot query.
See when your collector cannot read a nested
field.
Extension¶
Extension\ClauseRenderer¶
render(array $body): ?RenderedClause
Implement it to teach the library a query type it leaves opaque, and register it
with Options::withClauseRenderer(). Return null for a body you do not
recognise — the clause then stays type(?), which is true, where a guess would
be a fingerprint built on a misreading.
A renderer is only ever consulted for a type the library does not model natively. See teaching it a query type.
Extension\RenderedClause¶
static on(string $field, string $label): RenderedClause
static fieldless(string $label): RenderedClause
withParam(string $name, scalar|null $value): RenderedClause
field(): string
label(): string
params(): array
Immutable. Parameter names survive into the signature, their values do not — the
same rule every other clause follows. A numeric parameter name throws
InvalidOptionException.
Failures¶
Exception\InvalidQueryException¶
Thrown by describe(), explain() and lazy() when the input is neither an
array nor decodable JSON. Extends InvalidArgumentException.
Exception\InvalidOptionException¶
Thrown by Options::fromArray() on an unknown key or a wrong type, by
Normalization::fromLevel() and IndexNormalizer::fromMode() on an unknown
name, and by RenderedClause::withParam() on a numeric name.
Transport¶
See Capture at the transport for which of the three to use and why — the answer depends on what your client transports over, not on its name.
Http\DigestingClient¶
__construct(ClientInterface $inner, SearchObserver $observer, ?Formatter $formatter = null, string $basePath = '')
sendRequest(RequestInterface $request): ResponseInterface
A PSR-18 client that digests every _search and _msearch passing through it.
Anything else passes straight through. $basePath is the path prefix the cluster
is mounted under, if it is behind a proxy — without it that prefix is read as the
index name.
Http\Guzzle\DigestMiddleware¶
__construct(SearchObserver $observer, ?Formatter $formatter = null, string $basePath = '')
__invoke(callable $handler): callable
The same capture as a Guzzle middleware — $stack->push(new
DigestMiddleware($observer)). Needed for the requests a PSR-18 decorator cannot
see: asynchronous and pooled ones.
Http\Ring\DigestingHandler¶
__construct(callable $next, SearchObserver $observer, ?Formatter $formatter = null, string $basePath = '')
__invoke(array $request): mixed
The same capture as a ezimuel/ringphp handler — $builder->setHandler(new
DigestingHandler(ClientBuilder::defaultHandler(), $observer)). Needed for the
clients that transport over ringphp, which predates PSR-7 and so has neither a
sendRequest() to decorate nor a handler stack to push onto:
elasticsearch-php 7.x and opensearch-php ≤ 2.3.
Whatever the wrapped handler returns is passed back — an array untouched, a future proxied, so an asynchronous request stays asynchronous.
Http\SearchObserver¶
observe(ObservedSearch $search): void
What to do with a search once it has been seen. It may throw: all three integrations catch everything an observer does, because a digest is never worth a failed request.
Http\ObservedSearch¶
digest(): LazyDigest
tookMillis(): ?int
elapsedMillis(): float
statusCode(): ?int
position(): ?int
One search seen going out, and what came back. The digest is still lazy, so an
observer that drops this search has not paid to parse it. tookMillis() is null
for every line of an _msearch, whose response reports one took for the whole
batch, and when the response body could not be read without disturbing it.
statusCode() is null when the request never got a response.
Http\LoggingObserver¶
__construct(LoggerInterface $logger, string $level = LogLevel::INFO, string $message = 'opensearch.search')
Writes one PSR-3 record per search, with dsl and took in the shape the
dashboard pack maps.
A fourth argument, RecordLayout, chooses where the digest's fields go.
Analysis¶
Analysis\Report¶
record(Digest $digest, ?float $millis = null, ?string $timestamp = null, ?string $tenant = null): void
records(): int
count(): int
total(): float
shape(string $hash): ?Shape
tenants(): Tenant[]
rank(string $by = Report::TOTAL): Shape[]
top(int $count, string $by = Report::TOTAL): Shape[]
Searches grouped by fingerprint and ranked by what they cost — what the
slowlog command does with a cluster's slow log, on whatever stream you have.
Constants: TOTAL, COUNT, P95, MAX, MEAN, TENANTS, and KEYS. An unknown key
throws InvalidOptionException rather than falling back to the default.
Implements JsonSerializable, which serialises to the ranked shapes.
records() is how many searches went in, count() how many distinct shapes came
out. It holds one object per shape, so a million searches over forty shapes is
forty objects.
Analysis\Shape¶
hash(): string
signature(): string
index(): string
kind(): Kind
count(): int
measured(): int
total(): float
mean(): ?float
p95(): ?float
max(): ?float
tenants(): int
record(Digest $digest, ?float $millis, ?string $timestamp, ?string $tenant): void
Every search sharing one fingerprint, and what they cost. count() is all of
them, measured() only those that carried a duration — so a stream without
took still counts, and returns null for the statistics rather than zero.
p95() is nearest rank: on a handful of records it lands on the maximum, which
is the honest answer.
tenants() is how many distinct names played this shape, zero unless the
records carried one. It is what separates the application's own query from one
customer's: both can total twenty seconds, and only one of them is a product
bug.
Implements JsonSerializable. That object carries slowest.text, the slowest
record's readable line, which is the one field here that can hold a literal
— under withText(false)
it holds the signature instead. Show the signature for the group and label the
sample as a sample: under a count of twenty-eight, one record's values read as
the group's, and they are not.
Analysis\Tenant¶
name(): string
count(): int
measured(): int
shapes(): int
total(): float
mean(): ?float
record(string $hash, ?float $millis = null): void
The same stream along its other axis — one object per name
Report::record() was given, ranked costliest first by Report::tenants().
"Tenant" is this library's word for it; the records may call it customer, site,
instance or service, and nothing is minted from the value or validated against
anything.
shapes() is how many distinct fingerprints that name played — a tenant
running one expensive report and a tenant running a hundred pages can cost the
same milliseconds, and that number is the difference. Implements
JsonSerializable; the object holds no literal.
Monolog¶
Monolog\DigestProcessor¶
__construct(?Formatter $formatter = null, string $requestKey = 'query', string $indexKey = 'index')
__invoke(array|LogRecord $record)
One class for both Monolog 2 and 3. Push it once and every log record carrying a
search body under $requestKey gets it replaced by the digest.
Monolog\SafeDigest¶
The object that actually lands in your log context — a LazyDigest that cannot
take the record down with it. If parsing fails while Monolog is formatting, it
serialises the error rather than throwing, because losing a digest is acceptable
and losing the log line is not.