Query Usage Reporting
The anonymized per-query usage record — what it contains, why it cannot contain your data, and where it goes
Every query that finishes — completed, failed, cancelled or abandoned — produces one usage record: the shape of the query, how well the engine ran it, and what it cost. Records exist so that operators can see how their cluster is actually used, and so that a cluster owner can share that picture with the Berserk team without sharing any data.
A record contains no query text, no table names, no field names, no literal values, and no user identity. It cannot: the code that writes a record has no access to your query text at all. That guarantee is spelled out under Why a record cannot contain your data.
What a record looks like
Given this query:
otel-logs
| where resource['service.name'] == "checkout"
and severity_text == "ERROR"
and timestamp > ago(1h)
| summarize errors = count() by resource['service.name'], bin(timestamp, 5m)the record's query.shape is:
T1 - F1 | where F2.F3.F4 == "S1" and F5 == "S2" and F6 > ago(timespan(TS1))
| summarize A1 = count() by F2.F3.F4, bin(F6, timespan(TS2))Operators, keywords and built-in function names survive. Everything else
becomes a marker: T for a table, F for a field, A for an alias, L for a
let binding, FN for a function that is not built in, and S / N / TS /
DT / G for a string, number, timespan, datetime or guid constant.
Markers are assigned per name, not per position, so one name gets one marker
everywhere it appears. Above, F2.F3.F4 is the same field in the where and
in the summarize by — which is the point of the exercise: it shows that this
query filtered on the field it later grouped by, without saying which field
that is.
Alongside the shape, a dictionary says what each marker stood for, in derived facts only:
{
"token": "S1",
"class": "string_const",
"occurrences": 1,
"stage": 1,
"phase": "pre_reduce",
"len": 8
}| Field | What it says |
|---|---|
token, class | The marker and what kind of thing it replaced |
occurrences | How many times it appeared in the query |
stage, phase | Which pipeline stage it first appeared in, and whether that was before or after the first aggregating operator |
len | For a string constant: how many characters it had |
shape | For a string constant: coarse labels from a fixed list — hex, uuid, ipv4, digits, dotted, has_space, wildcard, non_ascii, empty |
bucket | For a number or timespan: a magnitude band from a fixed list, e.g. <1e3, 1h..1d |
len and phase together are how a support conversation can explain a slow
query without seeing it: a three-character literal cannot feed a five-gram
bloom filter, and a literal in a post_reduce stage never reached the scan at
all, so no index could have helped it.
The rest of the record
| Field | What it says |
|---|---|
outcome | completed, failed, or abandoned (a client hang-up, a cancel, or a deadline) |
error_code | The error's class — ParseError, Timeout, … — never its message |
| (no warnings) | Query warnings are deliberately absent: they name columns and quote predicates |
surface, duration_ms, rows | Which entry point served it, how long it took, how many rows came back |
partial_failures | Segments the query could not read, so an incomplete result is visible as such |
planned_quality | The quality score the planner expected before reading a chunk |
quality | The score the client was actually shown. Its distance from planned_quality is the planner's calibration error |
quality_inputs | The primitives behind the planned score: bloom selectivity, range and shard term counts, reducer weight |
plan | A name-free digest of the plan that ran: source kind, mapper operator kinds in order, reducer, time bounds |
stats | The engine's execution-stats bag — chunks scanned and skipped, bytes, timings, cache reuse — filtered to numbers and booleans |
plan and quality_inputs describe what the engine did; query.shape
describes what the user asked for. Both are kept because they answer
different questions, and because a query that reads well and plans badly is the
most useful thing this stream can find.
Where the records go
Nothing leaves your cluster unless you configure it to. The second destination below is off by default, and even when enabled it points wherever you tell it to — normally back into your own Berserk.
1. The service log stream (always on). Each record is emitted by the query
service as a log record with body and target query.usage, carrying the whole
record as nested attributes. It goes wherever your query-service logs already
go. If you export Berserk's own telemetry back into Berserk (see
Observability), the records are queryable
immediately:
bzrk search "query.usage" --since "24h ago" \
| where resource['service.name'] == "query" \
| summarize count() by tostring(attributes.outcome)2. A dedicated usage stream (off by default). Point the query service at an OTLP endpoint and an ingest token, and each record is also shipped there — normally your own ingest service, with a token bound to its own table, so usage records get their own retention and are trivially separable from operational logs:
query:
config:
usageReportEndpoint: "http://ingest:4317"Create the table and token the same way as any other ingest target, and put the token in a secret — the chart reads it from there into the service's environment, so it never lands in a ConfigMap:
bzrk table create bzrk-usage
bzrk ingest-token create usage_reporting --table bzrk-usage --simple
kubectl create secret generic query-usage-report --from-literal=token="<token>"The secret's name and key are query.config.usageReportTokenSecret and
usageReportTokenSecretKey. Without a token the records go to the endpoint's
default table.
Export failures never affect queries: records ride a bounded queue drained in the background, and are dropped rather than allowed to slow a query down.
Sending records to the Berserk team
There is no automatic upload — reporting to us is a copy you make and send. Read the records first; they are only markers, numbers and labels.
bzrk -P <profile> search "query.usage" --since "7d ago" --no-stream --json > usage.json
bzrk admin verify-usage-report usage.jsonverify-usage-report re-reads every query shape in the file and insists each
one is built only from KQL vocabulary and markers. It exits non-zero if
anything in the file is not anonymized — in which case do not send it, and
please tell us, because that is a bug in Berserk.
The verbatim companion line
Separately from the usage record, the query service logs one query.completed
line per query that does contain the query verbatim, along with the calling
principal. That line predates usage reporting, stays inside your install, and
is what makes "who ran what" answerable during an incident. It is a normal log
record, so if you do not want query text in your logs at all, filter or drop
the query.completed target in your log pipeline — the usage stream does not
depend on it.
Why a record cannot contain your data
The anonymizer is built so that the guarantee is checkable rather than promised. Its output text is produced by exactly one function, and that function takes no query text — only a list of atoms, each of which is one of:
- a token kind, printed from a compiled-in table of keyword and operator spellings;
- a built-in function name, printed from the compiled-in function registry;
- a marker class plus an ordinal, printed as a fixed prefix and a decimal number.
Every byte a shape can contain therefore comes from a table compiled into the binary or from a counter. The half of the code that reads your query produces only atoms and derived numbers; it cannot print. A separate check re-reads each shape and insists every token in it is either compiled-in vocabulary or a well-formed marker, and the test suite runs that check over deliberately hostile inputs: marker-shaped literals, nested quotes, comment tricks, unterminated strings, and bytes the lexer cannot read at all.
The stats bag gets the same treatment from the other direction. It is filtered to numbers and booleans: every string is dropped, as a value and — by dropping un-allowlisted maps whole — as a key. That matters because a scan error tally is keyed by the error's message, and a message can quote the row that failed to convert. A counter added to the engine still shows up in the record the day it lands; a string never does, and a newly added string-keyed map is excluded until someone reads it and allows it explicitly.
Two further consequences are worth knowing before you rely on this:
- A field whose name is also a KQL keyword renders as that keyword.
project countstaysproject count. The bytes are still compiled-in, but the shape does reveal that a field with that name exists. Only the ~110 words in the KQL vocabulary can appear this way. - A shape is a template, not a runnable query. It lexes cleanly and mostly parses, so it can be analyzed as a query, but a position that syntactically requires a literal carries a marker word instead.
Turning it off
Leave usageReportEndpoint unset and the second destination never opens. To
stop the records entirely, filter the query.usage target out of your log
pipeline, or set the query service's log level so that target is suppressed.