Berserk Docs

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
}
FieldWhat it says
token, classThe marker and what kind of thing it replaced
occurrencesHow many times it appeared in the query
stage, phaseWhich pipeline stage it first appeared in, and whether that was before or after the first aggregating operator
lenFor a string constant: how many characters it had
shapeFor a string constant: coarse labels from a fixed list — hex, uuid, ipv4, digits, dotted, has_space, wildcard, non_ascii, empty
bucketFor 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

FieldWhat it says
outcomecompleted, failed, or abandoned (a client hang-up, a cancel, or a deadline)
error_codeThe error's class — ParseError, Timeout, … — never its message
(no warnings)Query warnings are deliberately absent: they name columns and quote predicates
surface, duration_ms, rowsWhich entry point served it, how long it took, how many rows came back
partial_failuresSegments the query could not read, so an incomplete result is visible as such
planned_qualityThe quality score the planner expected before reading a chunk
qualityThe score the client was actually shown. Its distance from planned_quality is the planner's calibration error
quality_inputsThe primitives behind the planned score: bloom selectivity, range and shard term counts, reducer weight
planA name-free digest of the plan that ran: source kind, mapper operator kinds in order, reducer, time bounds
statsThe 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.json

verify-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 count stays project 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.

On this page