Repository navigation
fix: keep parser captures in the series of a metric query - #587
Conversation
PR Quality ReportCompared against base branch Coverage and tests
Compatibility
Performance smokeLower CPU cost (
State
|
ac3e8d7 to
1c8981b
Compare
1c8981b to
2dfaa23
Compare
2dfaa23 to
93d5ca3
Compare
93d5ca3 to
44a6532
Compare
44a6532 to
f18c576
Compare
f18c576 to
fe7de25
Compare
Live verification on the shared e2e stack (head
|
fe7de25 to
b755d99
Compare
b755d99 to
5d28695
Compare
5d28695 to
fe5a577
Compare
Loki names a metric series with the stream labels plus every label the
pipeline extracted, so count_over_time({app="x"} | regexp
"job_id=(?P<jid>\w+)" [1m]) returns one series per captured value (450 on
the e2e generator) where the proxy returned one per stream (30), without
the label. `| pattern` had the same gap.
Both parsers name their captures in the query, so the captures now join
the stream in the series identity: the stats-bucket route groups by them
in VictoriaLogs, the row-evaluated route keeps them as labels, and the
/hits route, which groups by one field, is skipped for such queries. No
extra backend work: the grouping stays inside VictoriaLogs.
The keys of | json and | logfmt are known only once a line is read, so
their labels can only join the identity by evaluating the query from
rows. That stays off by default and is documented as the remaining
deviation; -exact-parser-series-identity opts in per deployment.
fe5a577 to
1e8857d
Compare
Summary
Loki names a metric series with the stream labels plus every label the pipeline extracted.
| regexpand| patternname their captures in the query itself, so the proxy can keep them without reading a single extra row — it did not, and collapsed them into the stream:app="worker-service", e2e stack)count_over_time({…} | regexp "job_id=(?P<jid>\w+)" [1m])jidpresentjidjidpresentcount_over_time({…} | pattern "<_> job_id=<jid> <_>" [1m])count_over_time({…} | regexp … [2m]), range, 10 minThe captures now join the stream in the series identity: the stats-bucket route groups by them in VictoriaLogs, the row-evaluated route keeps them as labels, and the
/hitsroute — which groups by a single field — is skipped for those queries. The aggregation stays in the backend.(The range figures come from a build run with the compat stack's
-max-stats-query-series=1000000, the value #581 sets for the compose proxies; the code default stays 500, which is Loki's own default. With 500 the same query is capped as before.)The
| json/| logfmthalf, and a new opt-in flagTheir key set is known only once a line is read, so matching Loki's identity means evaluating the query from rows instead of pushing it down to VictoriaLogs stats. That is off by default:
docs/KNOWN_ISSUES.mdwith the measured numbers (| logfmt: Loki 450 series, proxy 30;| json: Loki 1050 on a busier app).-exact-parser-series-identity=true(HelmextraArgs.exact-parser-series-identity, docs row inconfiguration.md) names those series with the parsed labels, as Loki does, and routes them to the row evaluator bounded by the manual row budget. Measured on the stack:count_over_time({app="worker-service"} | logfmt [1m])returns Loki's 450 series with the flag on, 69 without.Verification
go test ./internal/... ./cmd/...,go vet,gofmtandgolangci-lint(v2.13.2, Go 1.27.1) are clean. New unit tests cover the capture extraction (| regexpnamed and unnamed groups,| patternplaceholders,| json/| logfmt, unparsable queries), the emitted grouping, and the bucket route turning capture fields into labels.TestCompat_ParserCaptureSeriesIdentity(in thecoreshard) pushes three job ids to Loki and VictoriaLogs and compares| regexp,| patternandbytes_over_timeseries with Loki's own answers. It passes on this build and fails onmain.| regexp "(?P<m>.)"/| pattern "<m>"as stand-ins for "a parser"; with captures in the identity their fake backend no longer models the grouping, so they now use| logfmt, which is what they were testing (route selection), and the capture behaviour has its own tests.Grouping sweep (15 shapes, instant and range, 15m/1h/6h/24h)
VictoriaLogs
RestartCount3 before and 3 after.None of the sweep's shapes uses
| regexpor| pattern, so the two builds should be equal, and they are except fortopk(3, …), where places 3-5 are tied and membership is arbitrary on both sides.Backend safety
12 concurrent 24h/7d queries for 120s against this build with the code defaults: VictoriaLogs restart count 3 before and 3 after,
/labelsprobe 200 on all 120 samples, 740 × 200 and 105 × 400 (Loki-parity query errors), peak VictoriaLogs memory 2812 MiB. PASS.One finding for #581: the same run with
-max-stats-query-series=1000000fails, becausesum by (pod) (rate({service_name="api-gateway"}[5m]))over 24h/7d then exceeds the 64 MB manual response cap and returnsmanual metric response exceeds 67108864 bytes, which names no operator flag. Raising the series cap on the compose proxies without raising (or naming) that byte cap turns the old truncation into a hard 502 on wideby (pod)queries.Conformance registry
loki_api_v1_queryloki_api_v1_query_rangeparsed-label-series-identityparsed-label-series-identityis new: it carries the measured numbers, records the| json/| logfmtdeviation as an owner decision with the waiverparsed-identity-dynamic-keys(expiry 2027-03-31) and names-exact-parser-series-identityas the opt-in.python3 scripts/ci/check_conformance.pypasses.Flag documentation
-exact-parser-series-identityis indocs/configuration.md,docs/KNOWN_ISSUES.md,examples/loki-vl-proxy-full.env(flag-only list),examples/k8s/proxy-config-configmap.yaml(commented arg),charts/loki-vl-proxy/README.mdandcharts/loki-vl-proxy/values.yaml(commented example only — the chart does not pin false defaults).scripts/ci/validate_helm_flags.shpasses.docs/logql-parser.mdanddocs/translation-reference.mddescribe the series identity and link the deviation instead of repeating it, anddocs/real-window-compatibility-gaps.mdlists the| regexp/| patternchange as a correction that returns more series than before.Sizing guidance:
-max-stats-query-seriesand capture cardinalityKeeping
| regexpand| patterncaptures in the series identity multiplies aquery's series count by the number of distinct captured values, so the default
-max-stats-query-seriesof 500 is reached sooner than before oncapture-bearing queries. This is the intended Loki behaviour — Loki returns one
series per captured value — but it changes the sizing arithmetic:
count_over_time({app="x"} | regexp "…(?P<jid>…)" [1m])cost roughlyone series per stream.
request id, job id or trace id is effectively unbounded and will hit the cap.
Measured on the e2e stack (15m, step 15s,
count_over_time({app="api-gateway"} | regexp "(?P<mth>GET|POST|PUT)" [5m])):Loki returns 6 927 series; at the default cap both this build and
mainreturnmaximum metric series exceeded (500). The cap bites identically on both, sincethe generator's rotating pod names already exceed 500 streams — but with
captures it will bite at a proportionally smaller stream count.
Operator guidance: size
-max-stats-query-seriesasexpected streams × expected distinct capture values, and prefer captures withbounded value sets (HTTP method, status class, route template) over unbounded
ones (ids) in metric queries. The limit is per-query and configurable via the
flag and
extraArgs.max-stats-query-series; hitting it returns a Loki-styleerror naming the flag rather than silently truncating. Raising it on wide
by (pod)queries should be done together with the manual response byte capnoted in the backend-safety section above, or the truncation becomes a 502.