Skip to content

fix: an aggregation over a binary expression aggregates the whole binary, like Loki - #741

Merged
szibis merged 33 commits into
mainfrom
fix/aggregation-over-binary
Oct 29, 2026
Merged

szibis merged 33 commits into
mainfrom
fix/aggregation-over-binary

Conversation

@szibis

@szibis szibis commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem and result

Loki evaluates a binary expression before an outer sum, avg, min, max, or count. The proxy previously dropped the binary operator and sometimes its right operand, returning HTTP 200 with wrong values. For example, sum(rate({app="x"}[1m]) / 2) returned the undivided rate. This PR evaluates both operands, applies Loki-compatible vector matching, then folds the joined series with the requested grouping on instant and range queries.

The branch is rebased on main through #918. It also keeps parser stages local to each operand, preserves Loki's label order, handles label_replace around the aggregation, and fixes named logfmt extraction so unrelated parsed fields do not leak into series labels. Narrow rewrites for sum(rate(selector)/scalar) and sum(rate(selector)/count_over_time(selector)) avoid per-stream backend reads in the B1/B2 performance shapes. The README one-glance comparison and linked performance docs now use the saved 2026-10-29 daily A/B; the June benchmark remains clearly historical.

Visual smoke exposed a 6-hour sum without (pod) (rate(... | logfmt amount | unwrap amount [20s]) > 40) panel error: the per-stream operand crossed the 64 MiB manual response guard before the outer sum reduced it. The proxy now retries that specific oversized response on disjoint evaluation-step ranges and merges the folded series, preserving the existing response limit.

Verification

  • Regression tests cover binary aggregations, grouping and missing labels, parser combinations, transforms, matching, error paths, series ordering, tenant isolation, and the named-field label leak. The B1/B2 tests assert the stats route and output values.
  • The wide-range regression forces a small response budget, compares every step and label with an unrestricted answer, and passes. Local go test -count=1 ./internal/proxy passed on 166837e8 (371.7 s); ./internal/logql and ./internal/translator passed on the earlier functional head.
  • python3 scripts/ci/check_conformance.py, python3 scripts/ci/check_changelog_pr.py --base f91dbc37 --head HEAD, and git diff --check passed on this head.
  • Current head 166837e8: CI passed its race/coverage suite, E2E/UI/compatibility matrix, stress, fuzz, lint, build, and benchmark jobs. Visual smoke passed with proof that the two fixed Explore pages match Loki at 1h and the 6h sum without panel no longer errors. Performance A/B passed 126 shape×range checks with 0 new Loki differences. Quality report reports 18,723 tests (+66), 89.0% coverage, and 100% on its pinned Loki, Drilldown, and VictoriaLogs compatibility tracks. All 67 PR checks pass; GitHub reports no conflicts. Required review remains the merge gate.

Known open differences remain in the conformance registry and generated performance report; this PR does not claim to close them. No release or merge is requested here.

szibis added a commit that referenced this pull request Oct 12, 2026
@github-actions github-actions Bot added size/XL Extra large change scope/proxy Proxy core scope/docs Documentation scope/tests Tests bugfix Bug fix and removed size/XL Extra large change labels Oct 12, 2026
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

🟢 Performance A/B: faster, no regression

126 shape×range — 1 ✅ fixed · 125 ⚪ same · results vs Loki: 118 same, 0 new difference(s), 8 pre-existing

Loki profiles compared (like for like):

  • aligned: not run (no build supports -align-queries-with-step, so every aligned row would be not like-for-like)
  • loki-defaults: 126 shape×range; proxy flag base off, pr off
shape range Loki profile base p50 PR p50 change Loki p50 PR ÷ Loki result vs Loki
✅ B3 sum over a literal times count, instant instant defaults 400 10 ms ✅ fixed 24 ms 0.42× ✅ same
125 unchanged shape×range (within noise)
shape range Loki profile base p50 PR p50 change Loki p50 PR ÷ Loki result vs Loki
⚪ B1 sum by over rate divided by a literal 1h defaults 6 ms 9 ms ⚪ +50% 48 ms 0.19× ✅ same
⚪ B2 sum by over a ratio of two per-stream metrics 1h defaults 8 ms 72 ms ⚪ +800% 465 ms 0.15× ➖ differs (as base)
⚪ B4 outer sum over ignoring(machine) 1h defaults 4 ms 24 ms ⚪ +500% 131 ms 0.18× ✅ same
⚪ bytes_over_time by app 1h defaults 13 ms 15 ms ⚪ +15% 209 ms 0.07× ✅ same
⚪ drilldown service landing 1h defaults 9 ms 8 ms ⚪ -11% 71 ms 0.11× ✅ same
⚪ instant json volume instant defaults 32 ms 34 ms ⚪ +6% 206 ms 0.17× ✅ same
⚪ instant ungrouped rate instant defaults 6 ms 6 ms ⚪ +0% 161 ms 0.04× ✅ same
⚪ json rate by level (no filter, no drop) 1h defaults 400 400 ⚪ -33% 400 — ✅ same
⚪ json volume no filters 1h defaults 42 ms 43 ms ⚪ +2% 106 ms 0.41× ✅ same
⚪ logfmt count by level, filter, drop 1h defaults 13 ms 11 ms ⚪ -15% 101 ms 0.11× ✅ same
⚪ logfmt volume 1h defaults 89 ms 98 ms ⚪ +10% 97 ms 1.01× ✅ same
⚪ logs plain 1h defaults 83 ms 88 ms ⚪ +6% 248 ms 0.35× ✅ same
⚪ logs | json 1h defaults 121 ms 121 ms ⚪ +0% 700 ms 0.17× ✅ same
⚪ logs | logfmt 1h defaults 198 ms 188 ms ⚪ -5% 619 ms 0.30× ✅ same
⚪ stream-label count by app 1h defaults 6 ms 7 ms ⚪ +17% 88 ms 0.08× ✅ same
⚪ topk rate by app 1h defaults 16 ms 14 ms ⚪ -12% 127 ms 0.11× ✅ sum ±0.04%
⚪ ungrouped rate 1h defaults 5 ms 6 ms ⚪ +20% 197 ms 0.03× ✅ same
⚪ DB3 json field breakdown user_id 1h defaults 6 ms 8 ms ⚪ +33% 67 ms 0.12× ✅ same
⚪ DB4 json field breakdown batch_id 1h defaults 48 ms 86 ms ⚪ +79% 65 ms 1.32× ✅ same
⚪ A explore volume, 2 filters, drop 1h defaults 141 ms 162 ms ⚪ +15% 186 ms 0.87× ✅ same
⚪ D volume, no filters 1h defaults 92 ms 95 ms ⚪ +3% 129 ms 0.74× ✅ same
⚪ E field breakdown pipeline (explore) 1h defaults 76 ms 76 ms ⚪ +0% 83 ms 0.92× ✅ same
⚪ F field breakdown service_version (explore) 1h defaults 109 ms 111 ms ⚪ +2% 93 ms 1.19× ✅ same
⚪ G field breakdown pipeline (drilldown) 1h defaults 11 ms 10 ms ⚪ -9% 69 ms 0.14× ✅ same
⚪ H field breakdown service_version (drilldown) 1h defaults 51 ms 52 ms ⚪ +2% 81 ms 0.64× ✅ same
⚪ I grafana volume of B 1h defaults 97 ms 80 ms ⚪ -18% 79 ms 1.01× ✅ same
⚪ L7 label_replace around a binary of two sums by level 1h defaults 23 ms 23 ms ⚪ +0% 18 ms 1.28× ✅ same
⚪ L8 label_replace over sum by level count, instant instant defaults 4 ms 4 ms ⚪ +0% 10 ms 0.40× ✅ same
⚪ LF13 explore volume, level filter before json 1h defaults 38 ms 39 ms ⚪ +3% 64 ms 0.61× ✅ same
⚪ LF15 drilldown field breakdown, level filter 1h defaults 7 ms 7 ms ⚪ +0% 18 ms 0.39× ✅ same
⚪ LF16 drilldown label breakdown, level filter 1h defaults 42 ms 39 ms ⚪ -7% 11 ms 3.55× ✅ same
⚪ LF20 logfmt count by detected_level 1h defaults 9 ms 10 ms ⚪ +11% 14 ms 0.71× ✅ same
⚪ LF21 logfmt count by detected_level, level filter 1h defaults 6 ms 7 ms ⚪ +17% 9 ms 0.78× ✅ same
⚪ LF24 rate of errors by service 1h defaults 14 ms 13 ms ⚪ -7% 105 ms 0.12× ➖ differs (as base)
⚪ LF25 count of warnings, error label dropped 1h defaults 15 ms 14 ms ⚪ -7% 52 ms 0.27× ➖ differs (as base)
⚪ LS7 rate of level stream label by service 1h defaults 5 ms 5 ms ⚪ +0% 108 ms 0.05× ✅ same
⚪ O2 explore log volume (Grafana form), underscore structured-metadata filter 1h defaults 43 ms 40 ms ⚪ -7% 52 ms 0.77× ✅ same
⚪ DL1 drilldown field breakdown json-expr, level filter 1h defaults 17 ms 18 ms ⚪ +6% 62 ms 0.29× ✅ same
⚪ DL2 drilldown field breakdown, level + stream-label filter before json 1h defaults 39 ms 38 ms ⚪ -3% 11 ms 3.45× ✅ same
⚪ DL3 drilldown logfmt field breakdown, level filter 1h defaults 86 ms 109 ms ⚪ +27% 162 ms 0.67× ➖ differs (as base)
⚪ DL4 instant count, level filter, json+logfmt, 1h instant defaults 521 ms 530 ms ⚪ +2% 1.32 s 0.40× ✅ sum ±0.30%
⚪ DL6 explore volume, level filter before json 1h defaults 40 ms 38 ms ⚪ -5% 102 ms 0.37× ✅ same
⚪ N1 sum by app of max by app,pod, count 1h defaults 7 ms 7 ms ⚪ +0% 60 ms 0.12× ✅ same
⚪ N10 instant min of avg, rate instant defaults 6 ms 6 ms ⚪ +0% 88 ms 0.07× ✅ same
⚪ N11 instant three levels instant defaults 5 ms 4 ms ⚪ -20% 62 ms 0.06× ✅ same
⚪ N12 instant json inner, count of sum instant defaults 38 ms 16 ms ⚪ -58% 75 ms 0.21× ✅ same
⚪ N13 instant without under by instant defaults 17 ms 16 ms ⚪ -6% 80 ms 0.20× ✅ same
⚪ N14 sliding sum of max 1h defaults 31 ms 29 ms ⚪ -6% 129 ms 0.22× ✅ same
⚪ N15 sliding sums only 1h defaults 5 ms 6 ms ⚪ +20% 77 ms 0.08× ✅ same
⚪ N16 innermost without under by, tumbling 1h defaults 6 ms 7 ms ⚪ +17% 39 ms 0.18× ✅ same
⚪ N2 max by namespace of sum by namespace,app, rate 1h defaults 5 ms 5 ms ⚪ +0% 35 ms 0.14× ✅ same
⚪ N3 count by namespace of sum by namespace,app 1h defaults 5 ms 4 ms ⚪ -20% 31 ms 0.13× ✅ same
⚪ N4 avg by app of min by app,pod 1h defaults 7 ms 7 ms ⚪ +0% 37 ms 0.19× ✅ same
⚪ N5 three levels, max min sum 1h defaults 4 ms 4 ms ⚪ +0% 33 ms 0.12× ✅ same
⚪ N6 json inner, sum of max 1h defaults 8 ms 8 ms ⚪ +0% 38 ms 0.21× ✅ same
⚪ N7 logfmt inner, max of sum 1h defaults 4 ms 4 ms ⚪ +0% 11 ms 0.36× ✅ same
⚪ N8 without over a named by level, tumbling 1h defaults 7 ms 7 ms ⚪ +0% 40 ms 0.17× ✅ same
⚪ N9 instant sum of max instant defaults 6 ms 6 ms ⚪ +0% 78 ms 0.08× ✅ same
⚪ PR1 count by level, parenthesised json 1h defaults 38 ms 39 ms ⚪ +3% 60 ms 0.65× ✅ same
⚪ PR2 rate, parenthesised line filter 1h defaults 5 ms 4 ms ⚪ -20% 76 ms 0.05× ✅ same
⚪ PR3 bytes by app, parenthesised regexp 1h defaults 5 ms 5 ms ⚪ +0% 82 ms 0.06× ✅ same
⚪ PR4 count by level, parenthesised parser, offset 1h defaults 43 ms 42 ms ⚪ -2% 63 ms 0.67× ✅ same
⚪ PR5 rate by app, pipeline after the range 1h defaults 39 ms 40 ms ⚪ +3% 126 ms 0.32× ✅ same
⚪ PR6 doubly parenthesised range 1h defaults 1 ms 1 ms ⚪ +0% 76 ms 0.01× ✅ same
⚪ PR7 instant, parenthesised line filter instant defaults 7 ms 6 ms ⚪ -14% 70 ms 0.09× ✅ same
⚪ SW1 sum without pod, count_over_time 1h defaults 21 ms 19 ms ⚪ -10% 71 ms 0.27× ✅ same
⚪ SW2 sum without pod, bytes_over_time 1h defaults 12 ms 11 ms ⚪ -8% 50 ms 0.22× ✅ same
⚪ SW3 sum without pod and version, line filter 1h defaults 11 ms 10 ms ⚪ -9% 50 ms 0.20× ✅ same
⚪ SW4 trailing sum without pod 1h defaults 5 ms 5 ms ⚪ +0% 51 ms 0.10× ✅ same
⚪ SW5 sum without pod, rate (control) 1h defaults 21 ms 18 ms ⚪ -14% 49 ms 0.37× ✅ same
⚪ SW6 max without pod (control) 1h defaults 12 ms 13 ms ⚪ +8% 50 ms 0.26× ✅ same
⚪ E1 json duration_ms sum by service 1h defaults 7 ms 6 ms ⚪ -14% 27 ms 0.22× ✅ same
⚪ E2 json duration_ms p95 by service 1h defaults 20 ms 15 ms ⚪ -25% 21 ms 0.71× ✅ same
⚪ E3 logfmt latency_ms rate, production 1h defaults 15 ms 14 ms ⚪ -7% 144 ms 0.10× ➖ differs (as base)
⚪ E4 logfmt size_bytes max by service 1h defaults 6 ms 5 ms ⚪ -17% 18 ms 0.28× ✅ same
⚪ E6 logfmt ttl unit string (Loki 400) 1h defaults 400 400 ⚪ -9% 400 — ✅ same
⚪ E7 json hexadecimal trace_id (Loki 400) 1h defaults 400 400 ⚪ -8% 400 — ✅ same
⚪ E8 json duration_ms sum by service, namespace prod 1h defaults 9 ms 9 ms ⚪ +0% 108 ms 0.08× ✅ same
⚪ E9 logfmt latency_ms sum by service, namespace prod 1h defaults 11 ms 11 ms ⚪ +0% 106 ms 0.10× ✅ same
⚪ D1 logfmt ttl duration_seconds rate by service 1h defaults 17 ms 17 ms ⚪ +0% 12 ms 1.42× ✅ same
⚪ D2 logfmt ttl duration rate by service 1h defaults 2 ms 1 ms ⚪ -50% 10 ms 0.10× ✅ same
⚪ D3 logfmt ttl duration_seconds rate, instant instant defaults 19 ms 19 ms ⚪ +0% 10 ms 1.90× ✅ same
⚪ D4 logfmt ttl duration rate, instant instant defaults 19 ms 19 ms ⚪ +0% 10 ms 1.90× ✅ same
⚪ G1 logfmt amount sum_over_time, no filter 1h defaults 5 ms 6 ms ⚪ +20% 17 ms 0.35× ✅ same
⚪ G10 logfmt latency_ms sum_over_time, production 1h defaults 14 ms 14 ms ⚪ +0% 170 ms 0.08× ➖ differs (as base)
⚪ G11 logfmt amount sum_over_time, instant instant defaults 7 ms 8 ms ⚪ +14% 13 ms 0.62× ✅ same
⚪ G12 json duration_ms sum_over_time, instant instant defaults 35 ms 38 ms ⚪ +9% 30 ms 1.27× ✅ same
⚪ G2 json duration_ms sum_over_time, no filter 1h defaults 7 ms 7 ms ⚪ +0% 38 ms 0.18× ✅ same
⚪ G3 logfmt amount sum_over_time, error filter 1h defaults 5 ms 6 ms ⚪ +20% 13 ms 0.46× ✅ same
⚪ G4 json duration_ms sum_over_time, error filter 1h defaults 7 ms 7 ms ⚪ +0% 31 ms 0.23× ✅ same
⚪ G5 logfmt amount sum by rate 1h defaults 5 ms 6 ms ⚪ +20% 14 ms 0.43× ✅ same
⚪ G6 json duration_ms sum by rate 1h defaults 7 ms 7 ms ⚪ +0% 31 ms 0.23× ✅ same
⚪ G7 duration() of a unit string 1h defaults 35 ms 32 ms ⚪ -9% 22 ms 1.45× ✅ same
⚪ G8 bytes() of a number 1h defaults 14 ms 13 ms ⚪ -7% 10 ms 1.30× ✅ same
⚪ G9 json duration_ms max_over_time 1h defaults 7 ms 7 ms ⚪ +0% 144 ms 0.05× ✅ same
⚪ W1 sum without pod, max_over_time 1h defaults 9 ms 7 ms ⚪ -22% 22 ms 0.32× ✅ same
⚪ W2 sum without pod, sum_over_time 1h defaults 8 ms 8 ms ⚪ +0% 17 ms 0.47× ✅ same
⚪ W3 sum without pod, rate 1h defaults 10 ms 8 ms ⚪ -20% 17 ms 0.47× ✅ same
⚪ W4 max without pod, max_over_time 1h defaults 4 ms 3 ms ⚪ -25% 18 ms 0.17× ✅ same
⚪ W5 sum without pod, max_over_time, instant instant defaults 7 ms 7 ms ⚪ +0% 15 ms 0.47× ✅ same
⚪ W6 sum without pod, rate, instant instant defaults 7 ms 6 ms ⚪ -14% 15 ms 0.40× ✅ same
⚪ W7 topk 5 of sum without pod 1h defaults 4 ms 4 ms ⚪ +0% 18 ms 0.22× ✅ same
⚪ W8 sum without pod, count_over_time (production) 1h defaults 15 ms 12 ms ⚪ -20% 67 ms 0.18× ✅ same
⚪ W9 label_replace control, instant instant defaults 4 ms 4 ms ⚪ +0% 87 ms 0.05× ✅ same
⚪ U1 logfmt amount rate 1h defaults 6 ms 5 ms ⚪ -17% 16 ms 0.31× ✅ same
⚪ U2 json duration_ms rate 1h defaults 6 ms 7 ms ⚪ +17% 33 ms 0.21× ✅ same
⚪ U3 json duration_ms rate, error filter 1h defaults 7 ms 6 ms ⚪ -14% 30 ms 0.20× ✅ same
⚪ U4 logfmt latency_ms rate, production 1h defaults 11 ms 11 ms ⚪ +0% 147 ms 0.07× ➖ differs (as base)
⚪ U5 logfmt amount rate, instant instant defaults 7 ms 7 ms ⚪ +0% 12 ms 0.58× ✅ same
⚪ U6 json duration_ms rate, instant instant defaults 12 ms 11 ms ⚪ -8% 24 ms 0.46× ✅ same
⚪ U7 json duration_ms sum_over_time, instant instant defaults 14 ms 14 ms ⚪ +0% 24 ms 0.58× ✅ same
⚪ U8 logfmt amount rate by service (grouped) 1h defaults 6 ms 5 ms ⚪ -17% 14 ms 0.36× ✅ same
⚪ U9 logfmt amount rate_counter (folded per series) 1h defaults 9 ms 14 ms ⚪ +56% 141 ms 0.10× ✅ same
⚪ U1 json duration_ms sum by service 1h defaults 9 ms 9 ms ⚪ +0% 30 ms 0.30× ✅ same
⚪ U12 json duration_ms over every service 1h defaults 10 ms 10 ms ⚪ +0% 59 ms 0.17× ✅ same
⚪ U13 logfmt latency_ms over every service 1h defaults 16 ms 14 ms ⚪ -12% 80 ms 0.17× ➖ differs (as base)
⚪ U14 duration() of a unit string 1h defaults 18 ms 18 ms ⚪ +0% 9 ms 2.00× ✅ same
⚪ U2 json duration_ms max by service 1h defaults 9 ms 8 ms ⚪ -11% 51 ms 0.16× ✅ same
⚪ U3 json duration_ms sliding sum 1h defaults 9 ms 8 ms ⚪ -11% 42 ms 0.19× ✅ same
⚪ U4 logfmt size_bytes sum by service 1h defaults 9 ms 8 ms ⚪ -11% 10 ms 0.80× ✅ same
⚪ U5 logfmt ttl unit string (no sample) 1h defaults 7 ms 7 ms ⚪ +0% 9 ms 0.78× ✅ same
⚪ U6 missing label, no parser 1h defaults 7 ms 6 ms ⚪ -14% 13 ms 0.46× ✅ same
⚪ U7 missing label, ungrouped sum 1h defaults 6 ms 6 ms ⚪ +0% 12 ms 0.50× ✅ same
⚪ U8 rate of an unwrapped label 1h defaults 7 ms 7 ms ⚪ +0% 18 ms 0.39× ✅ same
⚪ U9 bytes() conversion 1h defaults 14 ms 13 ms ⚪ -7% 13 ms 1.00× ✅ same
Cold (first-run) timings — what a user opening a panel sees
shape range Loki profile base cold PR cold Loki cold PR ÷ Loki cold
B3 sum over a literal times count, instant instant defaults 400 12 ms 23 ms 0.52×
B1 sum by over rate divided by a literal 1h defaults 47 ms 40 ms 294 ms 0.14×
B2 sum by over a ratio of two per-stream metrics 1h defaults 57 ms 743 ms 13.81 s 0.05×
B4 outer sum over ignoring(machine) 1h defaults 14 ms 40 ms 612 ms 0.07×
bytes_over_time by app 1h defaults 125 ms 105 ms 1.91 s 0.06×
drilldown service landing 1h defaults 32 ms 38 ms 748 ms 0.05×
instant json volume instant defaults 33 ms 33 ms 219 ms 0.15×
instant ungrouped rate instant defaults 6 ms 6 ms 166 ms 0.04×
json rate by level (no filter, no drop) 1h defaults 400 400 400 —
json volume no filters 1h defaults 58 ms 61 ms 656 ms ↺ 0.09×
logfmt count by level, filter, drop 1h defaults 453 ms 457 ms 1.50 s 0.30×
logfmt volume 1h defaults 236 ms 254 ms 1.53 s 0.17×
logs plain 1h defaults 104 ms 101 ms 312 ms 0.32×
logs | json 1h defaults 121 ms 123 ms 773 ms 0.16×
logs | logfmt 1h defaults 195 ms 207 ms 643 ms 0.32×
stream-label count by app 1h defaults 39 ms 22 ms 642 ms 0.03×
topk rate by app 1h defaults 21 ms 14 ms 1.04 s 0.01×
ungrouped rate 1h defaults 12 ms 13 ms 877 ms 0.01×
DB3 json field breakdown user_id 1h defaults 59 ms 48 ms 798 ms 0.06×
DB4 json field breakdown batch_id 1h defaults 124 ms 142 ms 846 ms 0.17×
A explore volume, 2 filters, drop 1h defaults 2.20 s 2.64 s 1.26 s 2.09×
D volume, no filters 1h defaults 221 ms 221 ms 656 ms 0.34×
E field breakdown pipeline (explore) 1h defaults 126 ms 111 ms 441 ms 0.25×
F field breakdown service_version (explore) 1h defaults 1.40 s 1.42 s 510 ms 2.77×
G field breakdown pipeline (drilldown) 1h defaults 55 ms 48 ms 395 ms 0.12×
H field breakdown service_version (drilldown) 1h defaults 80 ms 84 ms 459 ms 0.18×
I grafana volume of B 1h defaults 116 ms 122 ms 427 ms 0.29×
L7 label_replace around a binary of two sums by level 1h defaults 56 ms 37 ms 70 ms † —
L8 label_replace over sum by level count, instant instant defaults 5 ms 4 ms 11 ms 0.36×
LF13 explore volume, level filter before json 1h defaults 113 ms 100 ms 530 ms 0.19×
LF15 drilldown field breakdown, level filter 1h defaults 99 ms 98 ms 100 ms 0.98×
LF16 drilldown label breakdown, level filter 1h defaults 49 ms 49 ms 54 ms 0.91×
LF20 logfmt count by detected_level 1h defaults 62 ms 59 ms 65 ms 0.91×
LF21 logfmt count by detected_level, level filter 1h defaults 39 ms 44 ms 50 ms 0.88×
LF24 rate of errors by service 1h defaults 457 ms 448 ms 569 ms 0.79×
LF25 count of warnings, error label dropped 1h defaults 433 ms 432 ms 523 ms 0.83×
LS7 rate of level stream label by service 1h defaults 25 ms 16 ms 812 ms 0.02×
O2 explore log volume (Grafana form), underscore structured-metadata filter 1h defaults 157 ms 154 ms 886 ms 0.17×
DL1 drilldown field breakdown json-expr, level filter 1h defaults 500 ms 496 ms 636 ms 0.78×
DL2 drilldown field breakdown, level + stream-label filter before json 1h defaults 51 ms 51 ms 36 ms 1.42×
DL3 drilldown logfmt field breakdown, level filter 1h defaults 680 ms 679 ms 1.48 s 0.46×
DL4 instant count, level filter, json+logfmt, 1h instant defaults 527 ms 531 ms 1.54 s 0.34×
DL6 explore volume, level filter before json 1h defaults 201 ms 190 ms 880 ms 0.22×
N1 sum by app of max by app,pod, count 1h defaults 55 ms 59 ms 536 ms 0.11×
N10 instant min of avg, rate instant defaults 6 ms 5 ms 83 ms 0.06×
N11 instant three levels instant defaults 4 ms 7 ms 65 ms 0.11×
N12 instant json inner, count of sum instant defaults 16 ms 15 ms 73 ms † —
N13 instant without under by instant defaults 17 ms 17 ms 80 ms 0.21×
N14 sliding sum of max 1h defaults 460 ms 482 ms 1.39 s 0.35×
N15 sliding sums only 1h defaults 23 ms 28 ms 417 ms 0.07×
N16 innermost without under by, tumbling 1h defaults 56 ms 60 ms 1.14 s 0.05×
N2 max by namespace of sum by namespace,app, rate 1h defaults 18 ms 31 ms 384 ms 0.08×
N3 count by namespace of sum by namespace,app 1h defaults 17 ms 22 ms 370 ms † —
N4 avg by app of min by app,pod 1h defaults 52 ms 51 ms 812 ms 0.06×
N5 three levels, max min sum 1h defaults 38 ms 16 ms 377 ms 0.04×
N6 json inner, sum of max 1h defaults 164 ms 159 ms 351 ms 0.45×
N7 logfmt inner, max of sum 1h defaults 28 ms 22 ms 66 ms 0.33×
N8 without over a named by level, tumbling 1h defaults 65 ms 56 ms 961 ms 0.06×
N9 instant sum of max instant defaults 7 ms 6 ms 79 ms 0.08×
PR1 count by level, parenthesised json 1h defaults 210 ms 199 ms 764 ms 0.26×
PR2 rate, parenthesised line filter 1h defaults 47 ms 51 ms 429 ms 0.12×
PR3 bytes by app, parenthesised regexp 1h defaults 50 ms 52 ms 480 ms 0.11×
PR4 count by level, parenthesised parser, offset 1h defaults 45 ms 44 ms 925 ms 0.05×
PR5 rate by app, pipeline after the range 1h defaults 224 ms 227 ms 813 ms 0.28×
PR6 doubly parenthesised range 1h defaults 2 ms 1 ms 425 ms 0.00×
PR7 instant, parenthesised line filter instant defaults 6 ms 7 ms 72 ms 0.10×
SW1 sum without pod, count_over_time 1h defaults 84 ms 76 ms 536 ms ↺ 0.14×
SW2 sum without pod, bytes_over_time 1h defaults 150 ms 126 ms 594 ms 0.21×
SW3 sum without pod and version, line filter 1h defaults 110 ms 106 ms 596 ms 0.18×
SW4 trailing sum without pod 1h defaults 31 ms 33 ms 827 ms 0.04×
SW5 sum without pod, rate (control) 1h defaults 276 ms 305 ms 562 ms 0.54×
SW6 max without pod (control) 1h defaults 196 ms 204 ms 657 ms 0.31×
E1 json duration_ms sum by service 1h defaults 108 ms 87 ms 242 ms 0.36×
E2 json duration_ms p95 by service 1h defaults 209 ms 182 ms 457 ms 0.40×
E3 logfmt latency_ms rate, production 1h defaults 517 ms 543 ms 1.15 s 0.47×
E4 logfmt size_bytes max by service 1h defaults 49 ms 49 ms 198 ms 0.25×
E6 logfmt ttl unit string (Loki 400) 1h defaults 400 400 400 —
E7 json hexadecimal trace_id (Loki 400) 1h defaults 400 400 400 —
E8 json duration_ms sum by service, namespace prod 1h defaults 224 ms 206 ms 703 ms 0.29×
E9 logfmt latency_ms sum by service, namespace prod 1h defaults 331 ms 332 ms 1.08 s 0.31×
D1 logfmt ttl duration_seconds rate by service 1h defaults 78 ms 68 ms 59 ms 1.15×
D2 logfmt ttl duration rate by service 1h defaults 2 ms 2 ms 53 ms 0.04×
D3 logfmt ttl duration_seconds rate, instant instant defaults 19 ms 29 ms 11 ms 2.64×
D4 logfmt ttl duration rate, instant instant defaults 19 ms 19 ms 10 ms 1.90×
G1 logfmt amount sum_over_time, no filter 1h defaults 53 ms 45 ms 66 ms 0.68×
G10 logfmt latency_ms sum_over_time, production 1h defaults 505 ms 532 ms 1.26 s 0.42×
G11 logfmt amount sum_over_time, instant instant defaults 7 ms 8 ms 14 ms 0.57×
G12 json duration_ms sum_over_time, instant instant defaults 37 ms 38 ms 29 ms 1.31×
G2 json duration_ms sum_over_time, no filter 1h defaults 71 ms 86 ms 205 ms 0.42×
G3 logfmt amount sum_over_time, error filter 1h defaults 47 ms 44 ms 62 ms 0.71×
G4 json duration_ms sum_over_time, error filter 1h defaults 84 ms 82 ms 250 ms ↺ 0.33×
G5 logfmt amount sum by rate 1h defaults 25 ms 26 ms 78 ms ↺ 0.33×
G6 json duration_ms sum by rate 1h defaults 94 ms 95 ms 187 ms 0.51×
G7 duration() of a unit string 1h defaults 68 ms 75 ms 75 ms 1.00×
G8 bytes() of a number 1h defaults 86 ms 90 ms 46 ms 1.96×
G9 json duration_ms max_over_time 1h defaults 96 ms 101 ms 1.96 s 0.05×
W1 sum without pod, max_over_time 1h defaults 83 ms 66 ms 104 ms 0.63×
W2 sum without pod, sum_over_time 1h defaults 71 ms 68 ms 76 ms 0.89×
W3 sum without pod, rate 1h defaults 88 ms 75 ms 77 ms 0.97×
W4 max without pod, max_over_time 1h defaults 34 ms 26 ms 78 ms 0.33×
W5 sum without pod, max_over_time, instant instant defaults 7 ms 6 ms 15 ms 0.40×
W6 sum without pod, rate, instant instant defaults 7 ms 6 ms 14 ms 0.43×
W7 topk 5 of sum without pod 1h defaults 34 ms 26 ms 81 ms 0.32×
W8 sum without pod, count_over_time (production) 1h defaults 86 ms 86 ms 536 ms 0.16×
W9 label_replace control, instant instant defaults 4 ms 4 ms 88 ms 0.05×
U1 logfmt amount rate 1h defaults 86 ms 43 ms 73 ms 0.59×
U2 json duration_ms rate 1h defaults 88 ms 105 ms 205 ms 0.51×
U3 json duration_ms rate, error filter 1h defaults 89 ms 107 ms 239 ms 0.45×
U4 logfmt latency_ms rate, production 1h defaults 353 ms 390 ms 1.26 s 0.31×
U5 logfmt amount rate, instant instant defaults 7 ms 7 ms 13 ms 0.54×
U6 json duration_ms rate, instant instant defaults 12 ms 27 ms 25 ms 1.08×
U7 json duration_ms sum_over_time, instant instant defaults 14 ms 33 ms 25 ms 1.32×
U8 logfmt amount rate by service (grouped) 1h defaults 43 ms 45 ms 78 ms 0.58×
U9 logfmt amount rate_counter (folded per series) 1h defaults 68 ms 47 ms 399 ms † —
U1 json duration_ms sum by service 1h defaults 107 ms 94 ms 220 ms 0.43×
U12 json duration_ms over every service 1h defaults 250 ms 247 ms 646 ms 0.38×
U13 logfmt latency_ms over every service 1h defaults 501 ms 497 ms 1.01 s 0.49×
U14 duration() of a unit string 1h defaults 61 ms 65 ms 53 ms 1.23×
U2 json duration_ms max by service 1h defaults 151 ms 122 ms 1.46 s 0.08×
U3 json duration_ms sliding sum 1h defaults 12 ms 14 ms 250 ms 0.06×
U4 logfmt size_bytes sum by service 1h defaults 59 ms 61 ms 51 ms 1.20×
U5 logfmt ttl unit string (no sample) 1h defaults 57 ms 60 ms 51 ms † —
U6 missing label, no parser 1h defaults 22 ms 23 ms 87 ms † —
U7 missing label, ungrouped sum 1h defaults 16 ms 16 ms 71 ms † —
U8 rate of an unwrapped label 1h defaults 89 ms 109 ms 177 ms 0.62×
U9 bytes() conversion 1h defaults 63 ms 63 ms 56 ms 1.12×

† 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 taken from the first set that ran the same query; Loki's cache answered this one.

2 shape×range above 2× Loki already (listed, or as slow on the base build)
profile mode shape range PR Loki PR ÷ Loki base ÷ Loki listed ÷ Loki why
known defaults cold A explore volume, 2 filters, drop 1h 2.64 s 1.26 s 2.09× 1.74× — within noise of base
known defaults cold F field breakdown service_version (explore) 1h 1.42 s 510 ms 2.77× 2.75× — as slow on base
Why these shapes
  • internal/proxy/vector_matching.go → 55: control/stream-label count by app, label-replace/L7 label_replace around a binary of two sums by level, label-replace/L8 label_replace over sum by level count, instant, nested-aggregation/N1 sum by app of max by app,pod, count, nested-aggregation/N10 instant min of avg, rate, nested-aggregation/N11 instant three levels, nested-aggregation/N12 instant json inner, count of sum, nested-aggregation/N13 instant without under by …
  • internal/proxy/query_translation.go → 46: control/bytes_over_time by app, control/instant json volume, control/instant ungrouped rate, control/json rate by level (no filter, no drop), control/json volume no filters, control/logfmt count by level, filter, drop, control/logfmt volume, control/logs | json …
  • internal/proxy/proxy.go → 44: unwrap-conversion-error/E1 json duration_ms sum by service, unwrap-conversion-error/E2 json duration_ms p95 by service, unwrap-conversion-error/E3 logfmt latency_ms rate, production, unwrap-conversion-error/E4 logfmt size_bytes max by service, unwrap-conversion-error/E6 logfmt ttl unit string (Loki 400), unwrap-conversion-error/E7 json hexadecimal trace_id (Loki 400), unwrap-conversion-error/E8 json duration_ms sum by service, namespace prod, unwrap-conversion-error/E9 logfmt latency_ms sum by service, namespace prod …
  • internal/proxy/typed_fold.go → 16: nested-aggregation/N1 sum by app of max by app,pod, count, nested-aggregation/N10 instant min of avg, rate, nested-aggregation/N11 instant three levels, nested-aggregation/N12 instant json inner, count of sum, nested-aggregation/N13 instant without under by, nested-aggregation/N14 sliding sum of max, nested-aggregation/N15 sliding sums only, nested-aggregation/N16 innermost without under by, tumbling …
  • control smoke → 8: control/drilldown service landing, control/instant ungrouped rate, control/json rate by level (no filter, no drop), control/json volume no filters, control/logs plain, control/logs | json, control/stream-label count by app, control/topk rate by app
  • internal/proxy/proxy.go (endpoint handler) → 8: control/drilldown service landing, control/instant ungrouped rate, control/json rate by level (no filter, no drop), control/json volume no filters, control/logs plain, control/logs | json, control/stream-label count by app, control/topk rate by app
  • shape added or edited → 4: aggregation-over-binary/B1 sum by over rate divided by a literal, aggregation-over-binary/B2 sum by over a ratio of two per-stream metrics, aggregation-over-binary/B3 sum over a literal times count, instant, aggregation-over-binary/B4 outer sum over ignoring(machine)

base f91dbc37fc → PR 8515ecfa58 · 4 runs (first is cold), warm p50 · ranges 1h · noise ±30% and 100 ms · VictoriaLogs restarts 0→0 · 5.8 min · phases stack_up 36s, seed_and_build 87s, loki-defaults:loki_profile 0s, loki-defaults:proxies 2s, loki-defaults:matrix 212s. Same runner, one fresh stack, targets interleaved per request. How to read and reproduce: bench/ab/README.md.

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

✅ Visual smoke: passed with warnings

Base f91dbc37fc vs PR 166837e8b7, Grafana through the proxy builds and Loki on one fresh stack; run.
Loaded again, once and more patiently, after a first-pass difference or an unsettled page: explore-sum-by-over-binary 15m, explore-sum-by-over-binary 1h, explore-sum-by-over-binary 6h, explore-sum-without-comparison-inside 1h, explore-sum-without-comparison-inside 6h.
Core set: 5 capture(s) at 1h. Detailed set: 27 entries (explore-sum-by-over-binary, explore-sum-without-comparison-inside, explore-metric-plain, lvl-dd-fields-pill, lvl-dd-field-method-pill, lvl-dd-field-pipeline-pill, lvl-explore-volume-dl-prefilter-json, lvl-explore-volume-logfmt-dl-filter, ...). 37 capture(s), 15m/1h/6h, 24h and 7d stay out of PR runs.
Trimmed to the 1h range for the capture budget: explore-A, explore-E, explore-F, explore-logs-json, explore-logfmt-metric, explore-metric-parenthesized, explore-unwrap-rate-sum, explore-unwrap-rate-counter-one-sample, explore-unwrap-duration-seconds, explore-unwrap-quantile-over-time, explore-unwrap-first-last-under-sum, explore-max-by-count-over-time-sliding, explore-nested-aggregation-outer-by, explore-without-over-inner-aggregation, explore-without-nested-under-aggregation, explore-sliding-sum-without-stream-fold, dd-field-service_version, dd-field-pipeline, explore-sum-without-rate, explore-topk-sum-without, explore-sum-without-count, explore-topk-without-keeps-labels, explore-label-replace-over-binary.
Not run (capture budget): explore-label-replace-over-binary, explore-topk-without-keeps-labels, explore-sum-without-count, explore-topk-sum-without, explore-sum-without-rate, dd-field-pipeline, dd-field-service_version, explore-sliding-sum-without-stream-fold.

Fix proof

Captures 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 visual: none on the case does).

semantics/aggregation-over-binary-dropped: ✅ fixed

page range base = PR (data) vs Loki (PR) verdict
explore-sum-by-over-binary 15m differs (1) identical (1 explained) ✅ fixed
explore-sum-by-over-binary 1h differs (1) identical (1 explained) ✅ fixed
explore-sum-by-over-binary 6h differs (1) n/a (beyond the Loki window) ⚪ undecided
explore-sum-by-over-binary-15m: fixed

explore-sum-by-over-binary-15m

explore-sum-by-over-binary-1h: fixed

explore-sum-by-over-binary-1h

explore-sum-by-over-binary-6h: undecided

explore-sum-by-over-binary-6h

semantics/without-comparison-inside-ignored: ✅ fixed

page range base = PR (data) vs Loki (PR) verdict
explore-sum-without-comparison-inside 15m identical identical (1 explained) ⚠️ not reproduced on base
explore-sum-without-comparison-inside 1h differs (1) identical (1 explained) ✅ fixed
explore-sum-without-comparison-inside 6h differs (1) n/a (beyond the Loki window) ⚪ undecided
explore-sum-without-comparison-inside-15m: not reproduced on base

explore-sum-without-comparison-inside-15m

explore-sum-without-comparison-inside-1h: fixed

explore-sum-without-comparison-inside-1h

explore-sum-without-comparison-inside-6h: undecided

explore-sum-without-comparison-inside-6h

Regression check

page range set base = PR (data) vs Loki (PR) pixel diff result
dd-fields 1h core ✅ identical identical (4 explained, 1 history-dependent) 0.12% ⚠️ history-dependent difference, not gated (1): resource /api/datasources/uid/*/res
dd-landing 1h core ✅ identical identical (3 explained) 0.14% ✅
dd-service-logs 1h core ✅ identical identical (3 explained, 1 history-dependent) 0.12% ⚠️ history-dependent difference, not gated (1): resource /api/datasources/uid/*/res
explore-logs-plain 1h core ✅ identical identical (1 explained) 0.07% ✅
explore-metric-plain 1h core ✅ identical identical (1 explained) 0.04% ✅
explore-A 1h detailed ✅ identical identical (1 explained) 0.04% ✅
explore-E 1h detailed ✅ identical identical (1 explained) 0.04% ✅
explore-F 1h detailed ✅ identical identical (1 explained) 0.04% ✅
explore-logfmt-metric 1h detailed ✅ identical identical (1 explained) 0.04% ✅
explore-logs-json 1h detailed ✅ identical identical (1 explained) 0.07% ✅
explore-max-by-count-over-time-sliding 1h detailed ✅ identical identical (1 explained) 0.04% ✅
explore-metric-parenthesized 1h detailed ✅ identical identical (1 explained) 0.04% ✅
explore-metric-plain 15m detailed ✅ identical identical (1 explained) 0.04% ✅
explore-metric-plain 6h detailed ✅ identical n/a (beyond the Loki window) 0.04% ✅
explore-nested-aggregation-outer-by 1h detailed ✅ identical identical (1 explained) 0.04% ✅
explore-sum-by-over-binary 15m detailed differs (1): query ('sum by (service_name) (rate({service_name="api-gatew identical (1 explained) 0.28% ✅ improved (closer to Loki)
explore-sum-by-over-binary 1h detailed differs (1): query ('sum by (service_name) (rate({service_name="api-gatew identical (1 explained) 1.11% ✅ improved (closer to Loki)
explore-sum-by-over-binary 6h detailed differs (1): query ('sum by (service_name) (rate({service_name="api-gatew n/a (beyond the Loki window) 4.86% ✅ improved (closer to Loki)
explore-sum-without-comparison-inside 15m detailed ✅ identical identical (1 explained) 0.54% ✅
explore-sum-without-comparison-inside 1h detailed differs (1): query ('sum without (pod) (rate({service_name="payment-servi identical (1 explained) 1.19% ✅ improved (closer to Loki)
explore-sum-without-comparison-inside 6h detailed differs (1): query ('sum without (pod) (rate({service_name="payment-servi n/a (beyond the Loki window) 2.37% ✅ improved (closer to Loki)
explore-unwrap-duration-seconds 1h detailed ✅ identical identical (1 explained) 0.04% ✅
explore-unwrap-first-last-under-sum 1h detailed ✅ identical 1 difference(s), 1 on base too; 1 explained 0.04% ✅
explore-unwrap-quantile-over-time 1h detailed ✅ identical identical (1 explained) 0.04% ✅
explore-unwrap-rate-counter-one-sample 1h detailed ✅ identical identical (1 explained) 0.04% ✅
explore-unwrap-rate-sum 1h detailed ✅ identical identical (1 explained) 0.04% ✅
explore-without-nested-under-aggregation 1h detailed ✅ identical identical (1 explained) 0.04% ✅
explore-without-over-inner-aggregation 1h detailed ✅ identical identical (1 explained) 0.04% ✅
lvl-dd-field-method-pill 1h detailed ✅ identical 1 difference(s), 1 on base too; 2 explained 0.11% ✅
lvl-dd-field-pipeline-pill 1h detailed ✅ identical 1 difference(s), 1 on base too; 2 explained, 1 history-dependent 0.12% ⚠️ history-dependent difference, not gated (1): resource /api/datasources/uid/*/res
lvl-dd-fields-pill 1h detailed ✅ identical 25 difference(s), 25 on base too; 4 explained 0.11% ✅
lvl-explore-count-warn-by-service 1h detailed ✅ identical 1 difference(s), 1 on base too; 1 explained 0.04% ✅
lvl-explore-rate-errors-by-service 1h detailed ✅ identical 1 difference(s), 1 on base too; 1 explained 0.04% ✅
lvl-explore-volume-dl-prefilter-json 1h detailed ✅ identical identical (1 explained) 0.04% ✅
lvl-explore-volume-logfmt-dl 1h detailed ✅ identical identical (1 explained) 0.04% ✅
lvl-explore-volume-logfmt-dl-filter 1h detailed ✅ identical identical (1 explained) 0.04% ✅
tail-json-filter live detailed ✅ identical entries in the window: PR 1976, Loki 637, in both 637 0.15% ✅

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 visual-change-expected to accept an intended change). Also failing: a panel empty on the PR but not on the base, an error banner, panel error, error answer or new "No data" panel on the PR, a PR side that never settled, a value difference that did not reproduce on the recapture. A delayed first-pass request and pixel differences only warn.

dd-fields-1h: history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-29T11%3A34%3
  • history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-29T11%3A34%3A00.000Z&query=%7Bservic: body: content differs (rows 4 vs 2)
  • vs Loki, explained: query ('sum by (service_name_extracted) (count_over_time({service_name="otel-collector"} | servi: request only on one side (right) -- explained: Loki's _extracted suffix for a key named like a stream label, where the build does not return it (detected_fields: open, semantics/detected-fields-extracted-suffix; service/service.name: owner decision)
  • vs Loki, explained: resource /api/datasources/uid/*/resources/detected_fields?end=2026-10-29T11%3A34%3A00.000Z&query=%7: body: content differs (rows 13 vs 15) -- explained: Loki's _extracted suffix for a key named like a stream label, where the build does not return it (detected_fields: open, semantics/detected-fields-extracted-suffix; service/service.name: owner decision); the proxy also returns data, status; Loki lists fields in no fixed order
  • vs Loki, explained: resource /api/datasources/uid/*/resources/detected_labels?end=2026-10-29T11%3A34%3A00.000Z&query=%7: body: content differs (rows 9 vs 9) -- explained: sampled cardinality: Loki counts every stream its ingesters hold, not the requested window (pkg/ingester/instance.go LabelsWithValues), the proxy the window's last 5 minutes (metadataMaxFieldNamesWindow); the label names match; the proxy also returns data, limit, status; Loki lists labels in no fixed order
  • vs Loki, explained: resource /api/datasources/uid/*/resources/drilldown-limits: body: content differs (rows 0 vs 0) -- explained: configuration probe: each backend publishes its own limits, version and settings (the proxy's pattern persistence)

dd-fields-1h

dd-service-logs-1h: history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-29T11%3A34%3
  • history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-29T11%3A34%3A00.000Z&query=%7Bservic: body: content differs (rows 4 vs 2)
  • vs Loki, explained: resource /api/datasources/uid/*/resources/detected_fields?end=2026-10-29T11%3A34%3A00.000Z&query=%7: body: content differs (rows 13 vs 15) -- explained: Loki's _extracted suffix for a key named like a stream label, where the build does not return it (detected_fields: open, semantics/detected-fields-extracted-suffix; service/service.name: owner decision); the proxy also returns data, status; Loki lists fields in no fixed order
  • vs Loki, explained: resource /api/datasources/uid/*/resources/detected_labels?end=2026-10-29T11%3A34%3A00.000Z&query=%7: body: content differs (rows 9 vs 9) -- explained: sampled cardinality: Loki counts every stream its ingesters hold, not the requested window (pkg/ingester/instance.go LabelsWithValues), the proxy the window's last 5 minutes (metadataMaxFieldNamesWindow); the label names match; the proxy also returns data, limit, status; Loki lists labels in no fixed order
  • vs Loki, explained: resource /api/datasources/uid/*/resources/drilldown-limits: body: content differs (rows 0 vs 0) -- explained: configuration probe: each backend publishes its own limits, version and settings (the proxy's pattern persistence)

dd-service-logs-1h

explore-sum-by-over-binary-6h: no Loki data at this range; the same differences match Loki at a shorter range (1): query ('sum by (service_name) (rate(
  • no Loki data at this range; the same differences match Loki at a shorter range (1): query ('sum by (service_name) (rate({service_name="api-gateway"}[$__auto]) / 2)', 'range', '1793: {"service_name": "api-gateway"}: value at 1793252040000: 9.3 v
  • pixel diff 4.9%

explore-sum-by-over-binary-6h

explore-sum-without-comparison-inside-6h: no Loki data at this range; the same differences match Loki at a shorter range (1): query ('sum without (pod) (rate({ser
  • no Loki data at this range; the same differences match Loki at a shorter range (1): query ('sum without (pod) (rate({service_name="payment-service"} | logfmt amount | unwrap amount: {"app": "payment-service", "cluster": "us-east-1", "container"

explore-sum-without-comparison-inside-6h

lvl-dd-field-pipeline-pill-1h: history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-29T11%3A34%3
  • history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-29T11%3A34%3A00.000Z&query=%7Bservic: body: content differs (rows 4 vs 2)
  • vs Loki: resource /api/datasources/uid/*/resources/detected_fields?end=2026-10-29T11%3A34%3A00.000Z&query=%7: body: content differs (rows 36 vs 15)
  • vs Loki, explained: resource /api/datasources/uid/*/resources/detected_labels?end=2026-10-29T11%3A34%3A00.000Z&query=%7: body: content differs (rows 9 vs 9) -- explained: sampled cardinality: Loki counts every stream its ingesters hold, not the requested window (pkg/ingester/instance.go LabelsWithValues), the proxy the window's last 5 minutes (metadataMaxFieldNamesWindow); the label names match; the proxy also returns data, limit, status; Loki lists labels in no fixed order
  • vs Loki, explained: resource /api/datasources/uid/*/resources/drilldown-limits: body: content differs (rows 0 vs 0) -- explained: configuration probe: each backend publishes its own limits, version and settings (the proxy's pattern persistence)

lvl-dd-field-pipeline-pill-1h

Core set montages (3): base | PR | Loki

dd-landing-1h

dd-landing-1h

explore-logs-plain-1h

explore-logs-plain-1h

explore-metric-plain-1h

explore-metric-plain-1h

All montages of this run: pr-visuals/pr-741.

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

PR Quality Report

Compared against base branch main.

Coverage and tests

Signal Base PR Delta
Test count 18657 18723 66
Coverage 89.0% 89.0% 0.0% (stable)

Compatibility

Track Base PR Delta
Loki API 100.0% 11/11 (100.0%) 0.0% (stable)
Logs Drilldown 100.0% 17/17 (100.0%) 0.0% (stable)
VictoriaLogs 100.0% 11/11 (100.0%) 0.0% (stable)

Performance smoke

Lower CPU cost (ns/op) is better. Lower benchmark memory cost (B/op, allocs/op) is better. Higher throughput is better. Lower load-test memory growth is better. Benchmark rows are medians from repeated samples.

Signal Base PR Delta
QueryRange cache-hit CPU cost 3378.0 ns/op 3401.0 ns/op +0.7% (stable)
QueryRange cache-hit memory 1256.0 B/op 1256.0 B/op 0.0% (stable)
QueryRange cache-hit allocations 28.0 allocs/op 28.0 allocs/op 0.0% (stable)
QueryRange cache-bypass CPU cost 4038.0 ns/op 3987.0 ns/op -1.3% (stable)
QueryRange cache-bypass memory 1317.0 B/op 1319.0 B/op +0.2% (stable)
QueryRange cache-bypass allocations 28.0 allocs/op 28.0 allocs/op 0.0% (stable)
Labels cache-hit CPU cost 877.0 ns/op 872.7 ns/op -0.5% (stable)
Labels cache-hit memory 48.0 B/op 48.0 B/op 0.0% (stable)
Labels cache-hit allocations 3.0 allocs/op 3.0 allocs/op 0.0% (stable)
Labels cache-bypass CPU cost 1437.0 ns/op 1464.0 ns/op +1.9% (stable)
Labels cache-bypass memory 400.0 B/op 399.0 B/op -0.2% (stable)
Labels cache-bypass allocations 14.0 allocs/op 14.0 allocs/op 0.0% (stable)

State

  • Coverage, compatibility, and sampled performance are reported here from the same PR workflow.
  • This is a delta report, not a release gate by itself. Required checks still decide merge safety.
  • Performance is a smoke comparison, not a full benchmark lab run.
  • Delta states use the same noise guards as the quality gate (percent + absolute + low-baseline checks), so report labels match merge-gate behavior.

szibis added a commit that referenced this pull request Oct 12, 2026
@szibis
szibis force-pushed the fix/aggregation-over-binary branch from 0257129 to 6df85f0 Compare October 12, 2026 11:45
@github-actions github-actions Bot added size/XL Extra large change scope/translator LogQL translator and removed size/XL Extra large change labels Oct 12, 2026
szibis added a commit that referenced this pull request Oct 12, 2026
@szibis
szibis force-pushed the fix/aggregation-over-binary branch from 6df85f0 to be827a4 Compare October 12, 2026 11:57
@github-actions github-actions Bot added size/XL Extra large change and removed size/XL Extra large change labels Oct 12, 2026
szibis added a commit that referenced this pull request Oct 12, 2026
@szibis
szibis force-pushed the fix/aggregation-over-binary branch from be827a4 to 5158a0a Compare October 12, 2026 11:59
@github-actions github-actions Bot added size/XL Extra large change and removed size/XL Extra large change labels Oct 12, 2026
@github-actions github-actions Bot added size/XL Extra large change and removed size/XL Extra large change labels Oct 12, 2026
szibis and others added 28 commits October 29, 2026 12:03
… differential pins start+k*step

Under a label_replace or label_join the request is cloned to the expression
inside it from the request's own query, not from the handler's rewritten
one, which may have lost the parser stage an operand needs (a query naming
both `| json` and `| logfmt`). The aggregation differential's reference
evaluates at start+k*step, as a default Loki does; a join of two vectors off
the step grid answers on the aligned grid instead and is registered apart as
semantics/binary-step-grid (open). A transform's warnings and statistics are
pinned.
… and pin the ignoring shape under an outer sum

The edge, range and differential tables ran on v1.52.0 and v1.40.0; v1.40.0 is
below the supported floor, so they now run on v1.52.0 and v1.49.0, and the
bucket-edge shift that only v1.40.0 needed is gone.

A new edge case pins sum by (job) (A + ignoring(machine) B) with the ignored
label on no series, the shape of semantics/binary-ignoring-matching: Loki joins
both sides on job and the outer sum adds them ({job="a"} 8, {job="b"} 4 on the
fixture); main answers one unlabeled series valued 6. Generated wiring and
parity reports regenerated on the rebased branch.
B1 the visual page's sum by over a rate divided by a literal, B2 a sum by over a ratio of two per-stream metrics, B3 the instant sum(2 * ...) that answered 400, B4 the outer sum over + ignoring(machine) of semantics/binary-ignoring-matching. Generated performance evidence regenerated.
…de-ignored

Main opened semantics/without-comparison-inside-ignored while this change was
open and pinned the proxy's old answer in TestWithoutShapesStillDifferFromLoki:
sum without (pod) (rate(... | unwrap amount [5m]) > 0.33) summed every series,
the comparison dropped. A comparison is a binary, so the rebased change sends
the aggregation to the binary evaluator, which filters the series before the
fold (pkg/logql/evaluator.go:451-466 over vectorBinop). All three pinned cases
now equal Loki's answer (range and instant for the rate, instant for
max_over_time > 35), and the table failed with "the proxy now answers Loki's".

The three cases move to TestWithoutComparisonInsideMatchesLoki, which asserts
Loki's answer; TestCompat_AggregationOverBinary gains the sum without over a
comparison shape; the row is marked fixed by #741 with a visual fix-proof page.
Every case also asserts that VictoriaLogs was asked no /select/logsql/query: each operand is a stats query and the proxy folds the aggregated series. Checked as well with a probe over sum without (pod) (rate(... | unwrap amount [5m]) > 0.33), sum by over a ratio, the ignoring shape and sum(2 * ...), on both endpoints: one or two stats calls, no raw-row call.
The aggregation-over-binary A/B set (B1-B4) covers
semantics/aggregation-over-binary-dropped, and main's conformance gate now
requires a perf: block on every covered item. Written by
`bench/ab/over2x.py locks seed`: 2.0x Loki max, 1.0x target, cold, warm and
refresh, not locked (the daily results of 2026-10-28 have no rows for B1-B4).
…its operands

TestCompat_AggregationOverBinaryMixedParsers failed in e2e-compat (core)
with a 400 "log queries are not supported as an instant query type" from
the proxy for

  sum by (route) (sum by (route) (sum_over_time({...js...} | json | unwrap amount [5m]))
    / sum by (route) (count_over_time({...lf...} | logfmt [5m])))

A query naming both | json and | logfmt goes through preferWorkingParser,
which probes the lines and drops the parser that does not fit from the
whole query (removeParserStage). For a metric query that is a text rewrite
that takes a stage's parameters to be everything up to the next `|`; when
the dropped stage was the last one before an operand's range, that was the
rest of the query: the range, the closing parentheses, the operator's other
side. The cut query (`... count_over_time({...lf...}`) was no aggregation
over a binary any more, so /query answered the 400 and /query_range log
streams; a top-level binary of the same operands answered its left operand
alone with a 200 (sum by job 10 and 12 on the unit fixture where Loki
answers 2.5 and 6). Dropping a parser from a binary is wrong in any case:
Loki evaluates each side with its own pipeline (pkg/logql/evaluator.go:385).

preferWorkingParser now leaves a binary, and a vector aggregation over one,
under any label_replace, to its operands (operandsKeepTheirParsers): each
operand is its own handler's query, which is probed on its own, and the
binary reads its operands from the request. A query of one operand naming
both parsers is probed as before (its rewrite is cut the same way; not
covered here). The probe parses the query only when it names both parsers.

TestAggregationOverBinaryMixedParsersAfterTheParserProbe pins Loki's values
for the aggregation, a max over it, a label_replace around it and the
top-level binary, instant and range, on JSON lines (the probe prefers
json); it fails without this. TestParserProbeLeavesBinariesToTheirOperands
pins which queries the probe leaves alone.
This change orders the series of every fold it does by labels.Compare,
as Loki sorts an answer (pkg/logql/engine.go:569 sort.Slice of an instant
vector, :604 sort.Sort of a matrix): the generic without fold
(applyWithoutVector, applyWithoutMatrix) and, through it, the aggregation
over a binary. Main has since put the typed fold (typed_fold.go, foldLevels)
in front of the generic one, and it ordered its groups by their
length-prefixed key text, which is not that order: {x="a"} came before
{job="a",x="a"} (Loki: the other way round, job < x), and a shorter value
before a longer one ({pod="b"} before {pod="ab"}, {pod="api-2"} before
{pod="api-10"}).

The typed fold now sorts its groups with labels.Compare read straight
from the group key, which already holds the kept labels sorted by name as
length-prefixed text, so the sort allocates nothing; the key is unchanged. TestWithoutFoldLabelOrderAndKeys gains
both cases and fails without this.
…pass in the fuzz smoke

FuzzFoldBinaryAggregation folds random vectors (labels whose values hold
`,`, `=` and quotes; sum, avg, min, max, count; by, without, no grouping, a
grouping by an absent label) with foldBinaryAggregation and checks every
group's value against a reference of Loki's VectorAggEvaluator.Next and the
series order against labels.Compare. It found the typed fold's order (fixed
in the previous commit). Values are finite: NaN under min and max is
semantics/binary-division-by-zero-nan.

FuzzParserProbeLeavesBinaries fuzzes the parameters of the json and logfmt
stages (Loki's grammar: names, name="value", commas; a value may hold `|`,
`[` and parentheses) of a bare binary, an aggregation over one, a nested
aggregation and a label_replace around one, all left to their operands, and
of one operand naming both parsers, still probed; any text is read without
a panic.

Both run in the fuzz-smoke job (12s each).
…ouping label

TestAggregationOverBinaryEdgeCases gains, on VictoriaLogs v1.52.0 and
v1.49.0 and without reading raw rows:
- an operand with no series (its window an hour back holds no line) on the
  right, on the left, and under count: the join has no series, so the
  aggregation answers an empty vector (vectorBinop matches nothing,
  VectorAggEvaluator.Next folds an empty vector to an empty one);
- a grouping by a label no series has: one group without labels;
- count without every label: one group counting the three series;
- max without a label no series has: every series kept.
…label_replace instead of label_join

TestCompat_AggregationOverBinaryUnderLabelTransforms failed in e2e-compat
(core) on every label_join case with "unhealthy response 400 parse error at
line 1, col 1: syntax error: unexpected IDENTIFIER" from Loki
(localhost:13101): Loki's grammar has no label_join (pkg/logql/syntax/lex.go
maps only label_replace), so there is no Loki answer to compare with. The
case is now a label_replace over another label_replace that reads its
destination, which Loki answers on every window shape and at an instant.
The proxy's own label_join stays covered by the unit tests.
… over a binary

The registry row semantics/aggregation-over-binary-dropped and the
CHANGELOG entry now describe the parser probe's rewrite of a mixed-parser
metric query (it cut the query after the stage it dropped), the typed
fold's series order, the label_join e2e cases Loki cannot answer (its
grammar has no label_join), the new unit, edge and fuzz tests, and cite
Loki 3.7.9 (the same lines as 3.7.7). Wiring regenerated.
…proxies shut down

TestAggregationOverBinaryMultiTenant sends `sum(2 * ...)` and
`sum by (job) (A / B)` to /query and /query_range with X-Scope-OrgID
"0|1" on VictoriaLogs v1.49.0: each tenant is answered as a single tenant
is (12; 6 and 6) and the merge labels each series with its __tenant_id__,
as for any other metric query (multitenant.go); it is not refused as a log
query (multitenant.go has its own instant log-query rejection).

The helper behind every aggregation-over-binary handler test now shuts its
proxy down.
TestCompat_AggregationOverBinaryMixedParsers now also sends the binary
under the aggregation on its own:

  sum by (route) (sum_over_time({...js...} | json | unwrap amount [5m]))
    / sum by (route) (count_over_time({...lf...} | logfmt [5m]))

The parser probe rewrote it like the aggregation (it cut the query after
the json operand) and the proxy answered the left operand alone with a 200.
@szibis

szibis commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator Author

Verification for head 166837e8b7317e339642740e2a10729b4021c2a0 (rebased through #918):

  • CI: all 67 checks pass, including race/coverage unit tests, E2E/UI compatibility, stress, fuzz, lint, build and benchmarks. GitHub reports no merge conflicts.
  • Visual: smoke passed. Its sticky comment shows explore-sum-by-over-binary and explore-sum-without-comparison-inside match Loki at 1h; the 6h sum without panel no longer returns a byte-budget error.
  • Performance: A/B passed, 126 shape×range checks, 0 new Loki answer differences, VictoriaLogs restarts 0→0. B2 is 72 ms on the PR versus 8 ms on base and 465 ms on Loki; this is below the gate's 100 ms absolute regression threshold and remains visible in the A/B comment.
  • Local: GOWORK=off go test -count=1 ./internal/proxy passed (371.7 s); targeted -race tests and go vet ./internal/proxy passed. Conformance and changelog gates passed. The new regression forces a small manual-response budget and compares every step and label with the unrestricted answer.
  • Documentation: README one-glance, performance guide, Known Issues and historical benchmark links now distinguish the 2026-10-29 saved daily A/B from the older June measurement; docs-site build passed.

Required review is the remaining merge gate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Bug fix scope/ci CI/CD scope/docs Documentation scope/proxy Proxy core scope/tests Tests scope/translator LogQL translator size/XL Extra large change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants