Known limitations
Querdian is deliberately strict. Wherever it cannot prove that a request is handled exactly as the
backend would handle it, or cannot express the exact union of a user’s policies, it rejects the
request instead of passing it through or guessing. This page collects those choices so that you
can tell an intended rejection from a bug. Most of them return 400 bad_data (parse_error,
unsupported or bad_request) or 403 forbidden; the reason codes are explained in
troubleshooting.md. Each item is tied to the code that
enforces it. If something here blocks a legitimate use case, open an issue; relaxing a rule always
needs a parser or rewriter change plus tests.
Contents: general / PromQL / LogQL / TraceQL and Tempo / denied labels / approximate results / policy model / authentication
General request handling#
- Default deny. Any path not in the endpoint tables is
403 endpoint_not_allowed, as is a known path used with a wrong method. Denied on purpose: write and ingest paths (push,write,otlp,read), rules and alerts, admin and status pages,targets,scrape_pools, Prometheusmetadata(it cannot be restricted by label),search/*,notifications,features,/openapi.yaml, Lokidelete, deprecated/api/prom/*, Tempo v1search/tagsandsearch/tag/<name>/values(noqsupport),v2/traces/diff,overrides,mcp, and the internal endpoints of all three. The inventory is in endpoints.md. - Paths must be canonical. Escaped slashes (
%2f), backslashes,%00, any%left after decoding,./..segments, empty segments (//), trailing slashes, control characters and invalid UTF-8 are400 bad_path. - Parameters are allow-listed per route and unknown ones are rejected (
400 bad_request), not silently dropped. NotablystoreMatch[](Thanos) is never forwarded. Allowed Thanos extras:dedup,partial_response,replicaLabels,replicaLabels[],max_source_resolution. - A single-valued enforcement parameter (
query,q) given more than once, in URL and form body together or repeated, is400 bad_request. Backends disagree about which value wins; Querdian refuses to guess. POSTbodies must beapplication/x-www-form-urlencodedand at most 1 MiB; JSON bodies are rejected. The rewritten request is always re-encoded by the proxy (GET: URL query,POST: form body).- Merged responses are JSON only. Plans that fan out or buffer (all Loki fan-out endpoints,
Tempo search) send
Accept: application/jsonupstream. Loki Parquet and Tempoapplication/vnd.grafana.llmare never merged. Single-request streamed plans pass the client’sAcceptthrough. Prometheuslabels/serieswith denied labels, and trace by ID, buffer and rewrite the body; a body they cannot decode (unexpected media type, compression other than gzip for traces, malformed JSON) is a502, never an unfiltered pass-through. - Unrestricted users are not parsed. A user whose effective policy is unrestricted (or has a term without matchers) and who has no denied labels gets their query forwarded unchanged, apart from the tenant header and the parameter allow-list. Syntax errors then come from the backend.
- Errors from a single streamed upstream request (for example a Loki
400for a log query on the instantqueryendpoint) are passed through with the upstream status and are counted asallowinquerdian_requests_total. Any failure of a fan-out branch becomes502for the whole request. GETis required forlabel/<name>/valuesand Lokilabels,label/<name>/values,tail,patterns; most other query endpoints acceptGETand formPOST.- Grafana features that need denied endpoints degrade: for example the Prometheus metrics browser
help text (
metadata) is unavailable. Tempo gRPC streaming search is unsupported; disable streaming in the Grafana Tempo data source. - Nothing is rate limited or queued inside Querdian (see operations.md).
- Querdian does not terminate TLS and does not authenticate to backends other than by setting the tenant header. Backends must be isolated at network level.
PromQL#
Queries are parsed with the Prometheus parser using default options: experimental functions,
duration expressions, extended range selectors and fill modifiers are not enabled, so queries using
them are 400 parse_error. A rewritten query is parsed again; if it does not parse, the request
is 400 unsupported.
With one policy term every query that parses is supported. With several terms the query is
or-lifted (promql-enforcement.md), which is only exact for some shapes.
These are rejected with 400 unsupported:
- a range vector that is not the direct argument of a function, for example the whole query
up[5m]; - a function over a range vector with another argument that contains a selector, for example
quantile_over_time(scalar(x), y[1h]); - a function with several range-vector arguments;
- any expression node type the lifter does not know (future syntax fails closed).
Further limits:
absent()/absent_over_time()edge cases. Prometheus derives the labels of the result ofabsent(m{job="x"})from the equality matchers of the selector. With several terms the argument becomes anorexpression, so the result has no labels; with one term the labels include the policy’s equality matchers. The value (1 if no series exists in the union) stays exact, and no data is leaked.absent_over_time(m[5m])is rewritten toabsent(last_over_time(m{t1}[5m]) or ...).- A function that drops the metric name makes Prometheus fail when two series differ only in their
name. Across terms
orresolves such a collision silently. This needs a term that matches on__name__. - An or-lifted query contains (selectors x terms) selector copies; more than 2048 is
403 query_too_complex. Prometheus metadata endpoints expand (usermatch[]x terms) selectors; more than 256 is403 query_too_complex. match[]selectors need at least one matcher that does not match the empty string; if neither the user nor the policy supplies one,__name__=~".+"is added (this can only narrow).seriesandlabelsresponses are buffered and filtered when labels are denied, up to 16 MiB.query_exemplarsreturns the union of the exemplars of the series matched by the lifted selectors.
LogQL#
pkg/logql is a clean-room parser written from the public documentation and black-box tests
(docs/research/logql.md). The parts it does not need to understand (log pipelines) are
tokenised and structurally validated, not interpreted. Anything it does not recognise is a
400 parse_error; there is no lenient mode. The strictness choices:
Lexer
- Strings: only the escapes
\" \\ \a \b \f \n \r \t \v \xHH \NNN(octal at most 255).\u,\U,\'and all other escapes are errors. Single-quoted strings, raw control characters (except tab) and invalid UTF-8 are errors. Backtick strings are accepted. - Identifiers are
[A-Za-z_][A-Za-z0-9_]*;:and.are not identifier characters. A stray one is an error, so a label such ask8s.namespace.namecannot be written (use the normalisedk8s_namespace_name). - Numbers are decimals with optional fraction and exponent; no hex,
InforNaNliterals. - Durations: number+unit groups with
ns us µs μs ms s m h d w y, lower case, no spaces. Bytes: one number and one unit out ofB k kB KB Ki KiB M MB Mi MiB G GB Gi GiB T TB Ti TiB;1kb,2PB, compound bytes are rejected.1mis a duration and1Mbytes. - Flags such as
--strictare lexed as separate tokens and reassembled by the parser.
Parser
- Range selectors and
offsettake integer durations without theusunit:[1.5m],[1us]and subqueries ([5m:1m]) are errors.offsetmust directly follow the range inside the function. - Literals as operands of
and,or,unlessare errors;boolis only valid on comparisons;group_left/group_rightrequireonorignoringand are not allowed on set operators. Precedence ofandversusunlessis assumed to be the same level. - A stream selector needs at least one
=or=~matcher that cannot match the empty string (or!= ""), and regexes must compile (as in Loki). logfmtaccepts exactly--strictand--keep-empty(each at most once);jsonaccepts no flags and every parameter needs="expr".unwrap: only label filters may follow it; an unwrapped range function needsunwrapandunwrapneeds an unwrapped range function.rate(... | unwrap x [r])is rejected because it is not documented. Grouping after the parentheses is only accepted for unwrapped functions.- Supported functions only: range aggregations
rate,count_over_time,bytes_rate,bytes_over_time,absent_over_time,rate_counter,sum_over_time,avg_over_time,max_over_time,min_over_time,stdvar_over_time,stddev_over_time,quantile_over_time,first_over_time,last_over_time; vector aggregationssum avg min max count stddev stdvar topk bottomk sort sort_desc;vectorandlabel_replace.distinct,approx_topk,abs,count_valuesand every other function are rejected. topk/bottomk/vectortake plain numbers;label_replacetakes exactly five string literals.- A log query must start with
{; a bare or parenthesised selector as operand of a metric expression is an error. Nesting deeper than 100 levels is rejected. - Keywords are case insensitive and may be label names where unambiguous.
Enforcement
- With several policy terms, metric queries are or-lifted per range aggregation, and range
aggregations with a
by/withoutclause cannot be lifted (400 unsupported). Rewrite such queries as an outersum by (...)over a plain range aggregation. When a pipeline collapses distinct streams to the same label set (drop,keep), the counts of terms after the first can be under-reported becauseorkeeps only the first of identical label sets. This never grants more than the policy allows. - Log queries with several terms are fanned out, one request per term, up to
maxFanout(otherwise403 query_too_complex). Entries are deduplicated within identical streams by(timestamp, line), ordered by direction and cut tolimit. All branches share the same parameters; invalid or inconsistent upstream responses fail the request with502. - The optional
queryoflabels,label/<name>/values,detected_*,index/*,patternsandmatch[]ofseriesmust be a log selector (with optional pipeline), not a metric query (400 unsupported). If it is omitted, restricted users get the policy selector instead; unrestricted users get no selector (anddetected_fieldsthen fails upstream, as in Loki). - Under
tenantIdLabel, every selector must keep a matcher other than__tenant_id__(Loki rejects a selector with only that matcher). A policy-only selector built for a term that consists only of the tenant binding is therefore400 unsupported. tail: only log queries (not metric queries); only the parametersquery,limit,startanddelay_for(limit >= 1,delay_for0 to 5 seconds) are accepted; browser clients must come from the same host (cross-origin WebSocket handshakes are refused); each upstream frame is limited to 4 MiB and duplicates are removed with a memory of the last 10 000 entries; an invalid frame closes the tail. The tail runs under the policy and token it started with.patternsreturns 404 upstream unless the pattern ingester is enabled; the non-empty response shape is unverified against a live Loki.- The
X-Loki-Response-Encoding-Flags: categorize-labelsencoding is preserved by the stream merge; branches that disagree on it fail the request.
Findings from the end-to-end suite (Loki 3.5.0)
- Under a tenant-bound policy (rules that differ per tenant, so terms carry a
__tenant_id__matcher) the metadata endpointslabels,label/<name>/values,series,index/statsandindex/volumedo not work reliably: Loki 3.5.0 ignores a negative__tenant_id__matcher there and treats a positive one as matching nothing. Results are empty or wrong, never wider than the policy. Metric queries are unaffected (they bind with a negative matcher). A proper fix needs one request per tenant. - Multi-tenant grants (header
t1|t2) do not work fortail(Loki: “multiple org IDs present”), forseries(empty result) or fordetected_labels. - Duplicate collapse. When a pipeline gives different streams identical labels and lines (
keep,drop,line_format), the fan-out merge for several-term log queries and theorof an or-lifted metric query collapse them: fewer entries or lower counts than the union of the per-term results. The result never contains data outside the policy. - Rejected although Loki accepts them: a range aggregation with
by/withoutwhen the policy has several terms (for examplequantile_over_time(... | unwrap x [5m]) by (app), alsomax_over_time,min_over_time,stddev_over_time,last_over_time); parenthesised log queries (( {app="web"} |= "x" ));boolon arithmetic operators; selectors with no positive matcher anywhere. absent_over_timeunder several terms returns one series per term.
TraceQL and Tempo#
pkg/traceql is a clean-room front end. It only has to locate spanset filters and attributes, so it
validates more than it interprets. Where Tempo accepts something undocumented or divergent, Querdian
rejects it (400 parse_error), because a token stream that differs from Tempo’s reading could hide
an attribute from the policy checks.
Lexer
- Comments (
#,//,/* */) are errors. - Strings and quoted names accept only the escapes
\"and\\; backtick strings, single-quoted strings, control characters and invalid UTF-8 are errors. - Unquoted attribute names are letters, digits,
_and.; a name followed by-or/is an error (“quote the name”) because it could be arithmetic. - A scope before
.must bespan,resource,event,linkorinstrumentation;parent.*is rejected and so are upper-case or unknown scopes. A scope before:must be a known intrinsic scope with a known intrinsic name. - Durations are one number plus one unit (
ns us ms s m h d);1h30m, glued numbers and exponents are errors. - A lone
&is an error.
Parser
- A query is a pipeline of stages: spanset expressions, scalar filters,
by(attr),select(...),coalesce(), one metrics function (optionally followed bytopk/bottomk) and a trailingwith (...). - Filters must be boolean, comparisons need an attribute or intrinsic, regex operators need an
attribute on the left and a string literal on the right.
nilonly asattr != nil. Enum values (error,ok,client, …) only againststatus/kindwith=or!=.minInt/maxIntand unary!are rejected. - Mixing different spanset operators on one level without parentheses is rejected, parentheses may
only wrap spanset expressions, and arithmetic between metrics queries
(
100 * ({..} | rate()) / ({} | rate())) is rejected. by()takes exactly one attribute;select()and metrics arguments are single attributes;withvalues are literals. Nesting beyond 100 levels is rejected.
Enforcement
- The policy is injected into every spanset filter, including those inside structural operators,
pipelines,
select, metrics functions andcompare(...):{ X }becomes{ (X) && (dnf) },{}becomes{ dnf }, and a query with no filter is prefixed with{ dnf } |.&&and||have equal precedence in Tempo, so the injected text is fully parenthesised. - The legacy
tags,minDurationandmaxDurationsearch parameters are400 unsupported, with or withoutq. qis required on themetrics/*endpoints (400 unsupported); it is optional on search and tag endpoints, where its absence yields a policy-only filter for restricted users.- Tenant-bound terms are unsupported on Tempo (
400 unsupported): there is no__tenant_id__attribute. Cross-tenant unions with differing terms are403 cross_tenant_union_unsupported. - Missing-attribute semantics. A span without an attribute satisfies none of
=,!=,=~,!~; a non-string attribute never satisfies a string comparison. PolicyNotEqual/RegexNoMatchtherefore admits less on Tempo than on Prometheus/Loki, and a policy cannot select “attribute absent”. Policy attribute names must be scoped (resource.x,span.x). - Search response redaction. Tempo reports trace-level data (root span name and service, per
service span and error counts, trace start and duration) even if only a child span matched. For
every user who is not unrestricted (and has no term without matchers),
rootServiceName,rootTraceNameandserviceStatsare removed, andstartTimeUnixNanoanddurationMsare recomputed from the returned matching spans only (earliest start; duration from earliest start to latest end, rounded up to whole milliseconds). They are therefore not the real trace start or duration, and the Grafana result table shows no root name for such users. OnlytraceID,spanSet,spanSetsand the two recomputed fields survive per trace, and onlytracesandmetricsat the top level; fields added by future Tempo versions are dropped. The root span is deliberately not treated as covered by a matching policy, because spans of one trace can carry different resource attributes. The body is buffered (up tomaxMergeBytes). - Trace-level intrinsics are rejected unless the root is covered.
rootName,rootServiceName,traceDuration(and theirtrace:forms),compare()and the tag-value lookup of the root names return400for users whose policy does not cover the root span (not unrestricted, no term without matchers), because they would let the user probe redacted root data (security/threat-model.md). - Trace by ID (
traceByID: filter):qand every parameter exceptstart/endis400; only JSON and protobuf (application/protobuf) responses are filtered and gzip is decoded; anything else is502. Spans outside the policy are dropped, then empty scope and resource groups; if nothing remains the answer is404with an empty body, also onv2where Tempo itself answers 200 with an empty trace. Denied attributes are removed from the spans that remain. The filter evaluates string attributes of the span and its resource; unknown protobuf fields of the wrapperTracemessage are dropped; the buffered body is capped atmaxMergeBytes. The default mode isdeny. - Tag endpoints:
api/v2/search/tagslists tags of spans that satisfy the injected policy; denied tags are filtered out. Tag values are computed over policy-matching spans and returned as is. - Only the HTTP API is proxied; gRPC and streaming search are out of scope.
Denied labels#
- Denied labels hide the ability to filter, group or join on a label and remove it from label/series listings. They do not remove the label from query results (except trace by ID). See policy-guide.md.
- The union rule is conservative: a label denied by any matched rule is denied for all terms.
- Loki
seriesresponses are not filtered (Prometheusseriesis).
Denied-label scan false positives#
The PromQL check works on the syntax tree and has no false positives. The LogQL check is a token and string scan and over-rejects deliberately:
- A denied name is matched against every identifier and keyword token, wherever it appears in
the query. A label that is named like a keyword or function (
json,count,rate,levelin an unrelated clause) will block legitimate uses of that word; avoid such label names. line_formatandlabel_formattemplate strings are rejected when they contain the denied name as a substring: withappdenied,| line_format "{{.application}}"or a literal text such as"appointment"is rejected.- Templates that can read all labels without naming them (
{{ . }},range,with,template,block,define,$variables) are rejected as soon as any label is denied, even if the denied label is irrelevant to the query. Plain field access ({{ .app }}) stays allowed. - The
dstandsrcstring arguments oflabel_replaceare compared with denied names; the regex and replacement are not. - TraceQL: a denied scoped name denies the unscoped
.xform and vice versa, and denied tag lists treat the intrinsic scope as “any scope”, which can hide more tags than strictly necessary.
Approximate results#
When the effective terms are not provably disjoint, a record that matches several terms is
counted once per term in summed results. Disjointness is only recognised for different tenants or
for Equal matchers on the same label with different values; regex terms that happen to be
disjoint are not detected, so such results are marked approximate although they may be exact.
- Loki
index/stats,index/volume,index/volume_rangeandpatterns: counters are summed per term. If the terms are not provably disjoint the response gets the headerX-Querdian-Approximate: trueand the server spanmerge.approximate=true; the body keeps Loki’s format. Truncation tolimitafter summation is exact only if no branch cut off series. Denied labels are stripped from the reported label sets, which can merge series. - Loki
statsblocks in query responses are summed over fan-out branches; shared work is counted once per branch. - Loki
detected_labels/detected_fieldscardinalities are summed per label: exact for disjoint terms, otherwise an upper bound. Conflicting field types becomestring. - Loki log stream merges are exact (the first
limitentries of the union are the union of each branch’s firstlimitentries).
Policy model#
- One invalid rule disables its whole policy on all its backends.
- An invalid or unknown-backend policy grants nothing; there is no partial application.
- Policy compilation uses namespace labels only for
policy.querdian.dev/tenant; changing thenamespaceLabelof a backend needs a restart. - A user’s effective policy with more than
maxPolicyTermsterms is403 policy_too_complex; the DNF simplification only merges terms that differ in oneEqual/RegexMatchlabel and drops terms whose matcher set is a superset of another’s, it does not reason about overlapping regexes. - No deny policies exist; to exclude data from a broad rule, write the rule narrower.
- Cross-tenant unions with differing terms are only possible on backends with
tenantFederation: tenantIdLabel(see policy-guide.md).
Authentication and sessions#
- Bearer tokens only. Authorization is evaluated per request against the token’s claims; there is no session, no token introspection and no revocation list, so a stolen token works until it expires.
- An open Loki tail keeps working after the token expires or the policy changes, until either side closes it.
- Group membership comes only from the token. A change in the identity provider takes effect when the user obtains a new token.