Repository navigation
fix: label_replace adds its label to instant answers and to post-aggregated series, like Loki - #744
Conversation
🟢 Performance A/B: faster, no regression181 shape×range — 1 🟢 faster · 180 ⚪ same · results vs Loki: 172 same, 0 new difference(s), 9 pre-existing Loki profiles compared (like for like):
¹ re-measured with 7 runs, on windows the first pass did not use, after it moved beyond noise. 180 unchanged shape×range (within noise)
Cold (first-run) timings — what a user opening a panel sees
† Loki's results cache answered this cold run (its own counters, or identical answers for windows a minute apart): not a computed time, so it is in no ratio. ¹ (Loki cold) the confirm pass's cold run was cache-served, so every cold column of the row is the first pass's. ↺ Loki cold taken from the first set that ran the same query; Loki's cache answered this one. 13 shape×range above 2× Loki already (listed, or as slow on the base build)
Why these shapes
base |
PR Quality ReportCompared against base branch Coverage and tests
Compatibility
Performance smokeLower CPU cost (
State
|
✅ Visual smoke: passed with warningsBase Fix proofCaptures of the exact cases this pull request adds or fixes (registry case, base | PR | Loki). The verdict compares the PR with Loki where the base did not match. The gate fails a case unless one capture is fixed or improved, or, beyond the Loki data window, the base failed where Loki and the PR both answered with data (the label does not excuse it; only
Regression check
Gate: a base-vs-PR data difference passes when the PR is closer to Loki, and fails when the PR diverges from Loki or Loki cannot decide it (at a range Loki does not hold, a difference passes when the same page proved that query to answer like Loki at a shorter range; otherwise label the pull request dd-fields-1h: history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-28T19%3A38%3
dd-service-logs-1h: history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-28T19%3A38%3
explore-label-replace-over-binary-6h: no Loki data at this range; the same differences match Loki at a shorter range (1): query ('label_replace(sum by (level)
lvl-dd-field-pipeline-pill-1h: history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-28T19%3A38%3
lvl-dd-label-pod-pill-1h: history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-28T19%3A38%3
All montages of this run: |
3f174b0 to
a52da44
Compare
17c6e6b to
45187a3
Compare
45187a3 to
c9c6d52
Compare
…reads escaped regexps The transform ran on range matrices only, so an instant vector came back without the new label. It now rewrites the labels of a vector and of a matrix alike, keeps the rest of the answer, expands the replacement as Loki does and files the points of series that end with one label set under one series on a range query, as JoinSampleVector does. The string arguments are unquoted like the LogQL lexer does, a call without the four string arguments is a 400 parse error, topk/sort/stddev over a label_replace no longer send the translator's marker to VictoriaLogs, a label_replace around a binary expression on query_range routes by the expression under it, and the without marker no longer swallows the markers that follow it.
… the expression under it, and reports Loki's argument errors The series of a label_replace answer are ordered by label set after the transform (a range result, and an instant vector unless the query sorts by value). The request is cloned once, above the routing chain, when a label transform wraps the expression. A malformed call reports the token names and the string start column of Loki's parser. label_join is no longer claimed as a feature of the transform. Ranking over duplicate label sets that a label_replace produced is registered as an open row.
…n query, so each operand keeps its parser
…stream, and around a json metric keeps the parser-error semantics An instant label_replace(count_over_time(...)) lost the grouping by stream (the check looked for a query that starts with the function) and answered one unlabeled total. A label_replace around a metric over | json skipped the evaluator that owns JSON parser errors and answered 200 for lines Loki fails or drops. Both now follow the expression under the transform; the e2e leaves the structured metadata Loki adds to a bare range function's series (an owner-kept deviation) out of the comparison and ranks one series under topk so ties do not decide the shape.
…th the label_replace A/B results
… regenerate the reports
… JSON content type
…range-binary label_replace rows The rows semantics/label-replace-instant-vector and semantics/label-replace-range-binary-marker were opened on main while this change was open, with tests that pinned the old answers. The rebased change answers both as Loki does (pkg/logql/evaluator.go:385-388, LabelReplaceEvaluator.Next at :1279-1309): an instant label_replace around a vector carries the destination label, and query_range routes a label_replace around a binary by the binary and sends no marker to VictoriaLogs. TestInstantLabelReplaceOnAVectorAddsNoLabel and TestRangeLabelReplaceOfABinaryExpressionSendsTheMarker were converted in the rebase to assert Loki's answer (both fail on main); the label_replace wrapper of TestWithoutWrappedByAnOperator drops its instant exception. Both rows are marked fixed by #744, wired to TestCompat_LabelReplaceNewLabel (its "row" shape on query and its "over a binary of two aggregations" shape on query_range), and the range-binary row gets a visual fix-proof page. Reports regenerated.
…e-binary rows L7 is a label_replace around a binary of two sums by level on query_range (semantics/label-replace-range-binary-marker); L8 the instant label_replace over a sum by level of the registry request (semantics/label-replace-instant-vector). Generated performance evidence regenerated.
…apes cover Main now requires a perf: block on every registry item an A/B shape covers (conformance gate, perf acceptance). The label_replace shape sets cover semantics/label-replace-new-label, label-replace-instant-vector, label-replace-range-binary-marker and pipeline-error-text-parity. The blocks were written by `bench/ab/over2x.py locks seed`: the default bound (2.0x Loki max, 1.0x target, cold/warm/refresh), not locked, because the daily results of 2026-10-28 have no rows for these shapes yet.
a24484b to
ddaf56e
Compare
…indow hit the extent cache; make the label_replace e2e hermetic topk and bottomk over a grouped count or rate ranked every series per step with the three-grid window recurrence over /select/logsql/query. That endpoint has no extent cache, so the L3 shape (topk over label_replace over sum by count, 15s step, 1h) asked VictoriaLogs three full-range questions on every request: 235 ms cold against 129 ms before the label_replace handling moved it onto the ranked route, 227 ms on a repeat against 9 ms, 1.83x Loki. Before that handling the query went through the stats route, which answered quickly but sent the translator's marker to VictoriaLogs as the stats alias and ranked left-edge buckets. Ranking now reads the stats_query_range buckets of the unranked metric (one call cold; the stats extent cache answers an identical repeat with 0 calls and a window moved back a minute with the 4 buckets it lacks) when they hold every series, and falls back to the recurrence for more series than -max-stats-query-series, a Drilldown request that keeps the busiest series, a range equal to the step, or a metric with no exact bucket grid. The caller's ranking runs on the transformed series, as Loki ranks after label_replace. Go benchmark of the L3 request on a fake VictoriaLogs (benchstat, n=6, GOMAXPROCS=2), base / branch head / this change: cold 2.47 ms / 9.53 ms (3 calls) / 2.54 ms (1 call) repeat 0.62 ms / 8.93 ms (3 calls) / 1.02 ms (0 calls) moved 0.65 ms / 9.18 ms (3 calls) / 1.01 ms (0 calls) The label_replace e2e now ingests its own streams under a unique probe label and selects only them, asserts the fixture answers exactly three series on Loki and on the proxy before comparing, and checks the series count of the bare and aggregated shapes. An instant bare range function lacks detected_level=unknown on a stream with no level (also on the base commit, with no label_replace): registered open as semantics/instant-bare-range-detected-level-unknown and pinned by a unit test; the e2e leaves that label out of the bare instant shapes until it is fixed.
…ked topk route more; rank before the label_replace around it Follow-up to the ranked topk bucket route: - a VictoriaLogs failure on the bucket read is the client's answer, as it is for the window recurrence, instead of three more full-range grid queries against a backend that is already failing; only the series limit, a body over the bound and a VictoriaLogs without stats_query_range fall back; - a grouping over -max-stats-query-series is remembered for five minutes (the key of the Drilldown over-limit memo: tenant, limit, query), so its heavy bucket read is not repeated on every request; - topk and bottomk without a grouping rank before the label_replace around them renames the series, as Loki ranks the samples a label_replace leaves apart (two series that end with one label set stay two samples) and files the survivors afterwards; ranking after the merge gave two points per step under one series; - the differential covers count_over_time, rate, bytes_over_time and bytes_rate, and the label_replace e2e duplicates test selects its own fixture streams.
… buckets are unreadable The bucket route treated a 2xx stats_query_range answer that is not a Prometheus success body (empty object, not JSON, no status) as a backend failure and answered 502 with its text. TestSlidingWindowStats_TopKRanksExactWindowsWithoutRawFetch (168h, a 604.8 s step, whose emulator serves /select/logsql/query only) hit that in CI. Such an answer now falls back to the recurrence, as every case the old route handled does; a backend failure is still the client's answer and a readable answer keeps the cached route. Covered by a table over four unreadable answers and a whole-second and a 604.8 s step, and the differential now draws the 604.8 s step too.
…y have one ranking to agree on
under_topk/query_range failed in CI: Loki answered {app=api-gateway} and
{app=db-main}, the proxy only api-gateway. The fixture logged all three
streams at the same instants every 10 seconds, so every 5 minute window held
the same number of lines per stream and topk(1, ...) was a tie at every step
(16 of 16 steps in the unit reproduction); Loki's heap keeps the first tied
series it meets, the proxy another, and both are right. The ranked route
itself is exact: with distinct counts it equals the per-step reference
strictly.
The streams now log every 5, 10 and 15 seconds (60, 30 and 20 lines per
window, give or take one), starting 22 minutes back so no window of a 15
minute query is partial. The comparison stays strict. On a local isolated
stack the old fixture fails under_topk/query_range 3 of 3 runs and the new
one passes TestCompat_LabelReplace(NewLabel|DuplicatesBeforeRanking) 5 of 5
(72 subtests, none skipped). TestLabelReplaceE2EFixtureTopK pins both facts
without a stack.










Summary
Gap
semantics/label-replace-new-label(cluster a9d4f85c, instant queries). On Loki 3.7.7,label_replaceworks per step on both a vector and a matrix (pkg/logql/evaluator.go:1275-1309). The proxy'sapplyLabelReplace/applyLabelJoindecoded only a matrix, so an instantvectorcame back without the new label.Fixed, matching Loki:
transformSeriesLabelsrewrites labels on a vector or a matrix and keeps the rest of the answer, e.g.data.stats.ExpandString. A series with no match is unchanged, and an empty expansion removes the destination label.JoinSampleVector/vectorsToSeries(engine.go:440-470, 530-600): an instant query keeps both samples, and a range query files the points under one series.syntax/lex.go:191-198)."\\."is now the regexp\., and escaped quotes work.topk/bottomk/sort/stddevover alabel_replacesent an internal marker to VictoriaLogs and got a 400. The transform now runs on the inner expression before the ranking, as Loki evaluates it.label_replacearound a binary expression now routes by the inner expression, as fix: an instant query keeps its failure status and always answers a resultType #735 does for instant.label_replace(sum without (level)(...))no longer loses its transform.VictoriaLogs still does all aggregation. The proxy only rewrites labels on the aggregated series.
Tests
TestLabelReplaceEdgeTable: 29 rules × matrix and vector.TestLabelReplaceDifferential: random nested transforms on both endpoints. Its reference is independent for the plumbing (translator markers, both endpoints, nested chains) and for Loki's duplicate merge; the per-series rule reuses Loki'sExpandStringsequence.TestCompat_LabelReplaceNewLabel, about 38 shapes ×query_rangeandquery.Local proofs (e2e vs Loki 3.7.7 on VictoriaLogs v1.52.0 and v1.47.0, A/B base e9b5ef1 vs branch)
Changes since the first push
The first e2e run in CI found two product bugs and two test problems; all are fixed here.
label_replace(count_over_time({env="production"}[5m]), ...)(any bare range function) was one unlabeled{}total instead of one series per stream. The grouping by stream was added only for a query that itself starts with the function; it now follows the expression under the transform (preserveMetricStreamIdentity, and the same check in the two handlers).label_replacearound a metric over| jsonskipped the evaluator that owns JSON parser errors: a line that is not JSON no longer failed the query, and| json | __error__=""kept every stream (14 apps) instead of the ones that parse (8, as Loki). It is now answered by that evaluator and the transform is applied to its answer.metric-identity/structured-metadata-splits-series, owner-kept), so the two bare shapes leave those label names out of both sides;app_short,buzz, the stream labels anddetected_levelare still compared.topk(2, ...)over tied values (several apps at 6) lets Loki pick different ties per step; the shape is nowtopk(1, ...), where api-gateway is the unique maximum.Unit tests:
TestLabelReplaceOverBareRangeFunctionKeepsOneSeriesPerStreamandTestLabelReplaceOverJSONMetricUsesTheOrderedJSONEvaluator; both fail without the fixes.go test -shortofinternal/translator,internal/proxy,internal/logql,go vet(also-tags e2e) and golangci-lint v2.14.0 are clean.e2e vs Loki 3.7.7, fresh isolated stack per VictoriaLogs version
VictoriaLogs RestartCount 0 before and after each run (no OOM).
TestCompat_LabelReplaceNewLabelTestCompat_LabelReplaceDuplicatesBeforeRankingTestMissingOps_LabelReplacelabel_replace_sum, skipped by the test itself: the aggregation-over-label_replace gap, now registered)TestCompat_InstantResultTypeAlwaysSetTestLogQL_Exhaustive_ErrorParity/_QueryParity/_KnownGapsTestHardeningLive_ParserError*(4 tests, JSON parser-error parity, regression set for fix 2)Notes:
TestCompat_LabelReplaceDuplicatesBeforeRankingpasses on this data althoughsemantics/label-replace-duplicates-before-rankingis open: the shape it ranks does not reproduce a point-count difference here. The row stays open.two series with one label set,all labels removed, query_range) pass on a fresh stack and fail (153 points on Loki, 140 on the proxy) on every re-run against the same stack: a re-run ingests the fixture again, and VictoriaLogs counts the duplicates where Loki deduplicates them. They are not loosened; run the group once per fresh stack, as CI does.A/B (base = e9b5ef1, branch = 4e21fc8, which holds the fixes; 1h with Loki, 5 runs, warm p50, one stack, targets interleaved)
Affected shapes (set
label-replace):{}total)| json | __error__=""sum by| json | __error__=""sum bytopk(1)).label_replacetakes 531 ms on the base and 509 ms on the branch for a 1h range, and 40 ms and 41 ms instant. The A/B flags L6 as the one slower shape; I did not hide it. On this generator data everyapi-gatewayline is valid JSON, so base and branch give the same series here; on a stream with invalid lines the base answered wrongly (14 apps against Loki's 8 in the e2e fixtures).Control set (20 shapes), 1h with Loki: 20 of 20 same, VictoriaLogs restarts 0 to 0, new differences from Loki 0, pre-existing 1 (topk rate by app, also on base). Largest absolute increases: instant json filtered 150 to 163 ms, drilldown service landing 10 to 14 ms, logs | logfmt 42 to 45 ms.
Saved results:
bench/ab/results/2026-10-12-labelreplace-1h.jsonand2026-10-12-labelreplace-control-1h.json; shape setlabel-replace.Registry
semantics/label-replace-new-label(text and planned tests updated).semantics/label-replace-aggregation-over-transform(an aggregation over a label_replace is a 400 on the proxy and answers on Loki) andsemantics/label-replace-around-ranking-and-vector(a label_replace around topk, sort orvector()). Both answer the same on the base, so neither is new. The PR body's draft about a binary operand (label_replace(...) * 2) no longer reproduces and is not registered.label_joinis accepted by the proxy but not part of Loki 3.7.7's grammar; it is handled by fix: reject group(), label_join() and other names Loki does not have, and accept keywords in any case, like Loki #748 and only noted in the row text here.visual: none(the visible difference is on instant queries, which the visual harness does not capture; the json difference needs a stream with invalid lines).Update: rebased onto main (2026-10-28)
Rebased onto
main; this is the reconciled branch, with the conflicts resolved and the generated reports regenerated. One new commit adds theperf:blocks that main's conformance gate now requires for the fourlabel_replacerows the A/B shapes cover. The CHANGELOG entries are under[Unreleased]only.Checks:
GOMEMLIMIT=2GiB GOMAXPROCS=4 go test -short -count=1 -p 1 ./internal/proxy/ ./internal/translator/passes.go vet ./...andgo vet -tags e2e ./test/e2e-compat/pass.Open before ready:
L6 label_replace over json sum byat 1h measured 0.54 s cold against Loki's 0.11 s (about 5×) and 0.55 s warm against 0.01 s. That is above the 2× Loki bound, so it is fixed or documented as high effort before this is ready.#741 (aggregation over a binary) is stacked on this PR.