Skip to content

fix: unwrap sum, avg, max and min over time answer Loki's window for every range and step - #885

Merged
szibis merged 11 commits into
mainfrom
perf/unwrap-conversion-check
Oct 28, 2026
Merged

szibis merged 11 commits into
mainfrom
perf/unwrap-conversion-check

Conversation

@szibis

@szibis szibis commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes semantics/unwrap-over-time-range-windows: sum_over_time, avg_over_time, max_over_time and min_over_time with unwrap answer Loki's window (t-range, t] on query_range for every relation of range and step: shorter, equal, longer and not a multiple, sliding, json and logfmt, grouped and ungrouped. Instant queries were already exact and stay so.

This supersedes #740 (fix/unwrap-over-time-range-windows). Its first four commits duplicate #727, which is already on main, and its VictoriaLogs v1.40 parts are below the v1.49.0 floor. Both are dropped here; its tests are brought over and adapted.

It also includes #570 by @clain23, kept as a cherry-picked commit with the contributor as author: the raw-row evaluator reads (t-range, t] for every function.

What was wrong (range longer than the step on main)

Loki reads each step's sample from (t-range, t]:

  • pkg/logql/range_vector.go:102-189 (batchRangeVectorIterator.Next and load) defines the window: the lower bound is exclusive and the evaluation time is inclusive.
  • range_vector.go:371-420 defines sumOverTime, avgOverTime, maxOverTime and minOverTime.

With a range longer than the step, the windows overlap.

Root cause on main:

  • statsRateRangeEqualsStepShift (internal/proxy/metric_binary.go:796-820) treats every range of these functions as the step (spec.Window = step).
  • shouldUseManualRangeMetricCompat (range_metric_compat.go) kept them off the window evaluator.

So every point was one VictoriaLogs step bucket (t-step, t]:

  • sum(sum_over_time({app="payment-service"} | logfmt | unwrap amount [5m])) at step 60: proxy 2.00257e+06, Loki 9.84307e+06 (stack measurement, Loki 3.7.7 and VictoriaLogs v1.52.0). The proxy sums one step of the five in the window.
  • sum_over_time({app="uw"} | unwrap amount [150s]) at step 2m: proxy 725.875, Loki 942.125 (emulated VictoriaLogs).
  • A line every 4.5 minutes under [5m] at step 60 gave 13 samples in an hour; Loki has 61.

Fix

commit author what
2c9beedd perf szibis (prepared) a range shorter than the step parses only the lines inside a window: the window phase filter goes right after the query's filters, ahead of every pipe
51a8ee34 fix szibis (prepared) range shorter than the step: the step bucket carries the window phase filter, so it holds exactly (t-range, t] (semantics/unwrap-over-time-short-range-windows)
944decea fix Igor Kudrin (#570) the raw-row evaluator reads (t-range, t] for every function: start exclusive, fetch end +1ns, range and instant (semantics/lower-bound-exclusive)
5e2fca17 fix szibis range longer than the step: window merge from anchored gcd(range, step) buckets of partial aggregates; registry, e2e, CHANGELOG
0fa20eed perf szibis the window evaluator parses its stats buckets with a pooled JSON parser
18e48088 fix szibis the route records the fold budget only for a query that folds; NaN and rate_counter handler tests
67382665 perf szibis max by/min by over the same function grouped upstream (unwrapGroupPushdown); sum, max and min buckets without a count; buckets read in place; points written as raw JSON; one-pass flag strip
bd821caf perf szibis window bounds as forward-only cursors; direct pair scanner
5ac625a5 fix szibis the push-down names each grouped value by its own label; detected_level keeps the fold; e2e for max by, min by and max
93af511d perf szibis the window matrix reserves each series' points from its buckets, not from every step of the range (below)

A memory regression found by the stack A/B, fixed in the last commit. sum without (pod) (count_over_time({env="production", instrumentation!="otel", app!="otel-collector"}[1m])) over 6h at step 10s (W8 of unwrap-sum-without, not an unwrap query) was 1.7 to 2× slower than main in the first A/B of this branch (main 9.5 s, branch 17.6 s warm p50). The proxy time was in the collector, not in a computation: a heap profile showed buildHitsRangeMetricMatrix holding 1.9 GB live for a 1 MB answer, because it reserves room for every step of the range for every series that holds a point, and this query builds a series per stream before the outer fold. The raw-JSON points of 67382665 reserved 32 bytes a step where main's point slices reserved 24, so the branch held a quarter more and ran at the process memory limit. The last commit reserves the bound a series can reach (its buckets times window/step+2, never more than the steps of the range). Same six windows, one stack, one proxy per build:

6h, step 10s, W8 wall time p50 peak RSS
main 2.6 s 3.3 GB
this branch before the last commit 5.3 s 4.1 GB
this branch with it 1.4 s 0.38 GB

The answers are byte for byte the same; the A/B below, taken with the fix, has W8 6h at 3.05 s on main and 1.60 s on the branch.

Design of range longer than the step: VictoriaLogs first, a bounded fold in the proxy.

  • Routing. handleStatsCompatRange sends these functions to the existing window evaluator (proxyManualRangeMetricRange), the route rate with unwrap already takes. The route is used only when unwrapWindowBuckets holds: a range longer than the step, a gated unwrap, and series that are a stats grouping.

  • What VictoriaLogs computes. VictoriaLogs answers one stats_query_range on buckets of gcd(range, step), anchored at the request with the offset argument (slidingStatsBucket, setSlidingStatsRangeParams). Each bucket holds the partial aggregate of the converted value (sum, max or min; unwrapBucketAgg). VictoriaLogs answers only a bucket that holds a row, so the aggregate's point is the presence; avg adds count() as __sample_count to divide by.

  • Group push-down. max by (L) (max_over_time(...)) and min by (L) (min_over_time(...)), L possibly empty, are the extreme of the group's lines in the window whatever the series are (range_vector.go:405-420, VectorAggEvaluator.Next, a NaN giving way in both). unwrapGroupPushdown has VictoriaLogs group the buckets by L instead of per stream (or per stream and line for JSON) and skips the JSON fold. Loki's series limit counts the groups (pkg/logql/engine.go:530-590 JoinSampleVector), as the read does. avg by over avg_over_time is not decomposable and keeps the per-series fold.

    • Each grouped field is named after the label whose name or VictoriaLogs name it is.
    • A detected_level grouping keeps the fold: the per-stream read labels a stream with no level unknown.
    • service_name is pushed down: the translation derives it ahead of the stats (the coalesce over the stored names), as for sum by (service_name).
  • What the proxy computes. mergeUnwrapBuckets folds the buckets of every window:

    • sum: the sum of the buckets;
    • avg: the sum divided by the window's count (the collector keeps the counts for avg only);
    • max and min: the extreme, with Loki's NaN rule.

    A step gets a sample only where the window holds a line.

  • Why VictoriaLogs cannot do it alone. A stats_query_range bucket is one tumbling interval, and VictoriaLogs has no sliding-window stats. Overlapping windows cannot be one bucket each. The proxy reads only aggregated buckets (series × buckets, bounded by the 64 MiB stats response limit), never raw rows.

  • Aggregations around the range. unwrapWindowForm takes only the ones the evaluator applies after the window:

    • a sum by labels or none;
    • topk and bottomk;
    • label_replace;
    • binary operands;
    • count, min, max and avg by labels (foldVectorAggregations);
    • a labelled without as the outermost aggregation (withWithoutFold).

    A query that folds afterwards (a series-level chain, an ungrouped sum of a function VictoriaLogs does not sum) reads under the raw-row budget (recordFoldBudget, shared with withWithoutFold), and Loki's series limit applies to the folded answer. A grouped sum over the limit still answers Loki's 400.

  • Per-stream series of this route carry detected_level="unknown" for a stream without a level, as Loki's do (semantics/without-instant-detected-level-unknown) and as rate's already did.

  • Unchanged. Ranges up to the step keep the native step bucket path. The 7-day E1, E4, E8 and E9 shapes ([1m] and [5m] at step 10m) still send one phase-filtered stats_query_range (TestUnwrapOverTimeShortRangeStaysOnStepBuckets). Instant queries are unchanged.

  • Extent cache. The window phase filter now names the window start modulo the step. A window moved by whole steps sends the same query text, so the stats extent cache reuses its buckets: one miss and five partial answers, where main had six misses (TestWindowPhaseFilterKeepsExtentCacheAcrossSteps).

    • The unwrap functions never used the extent cache, on main or here. Every query that holds unwrap gets an unwrap check: startUnwrapConversionCheck (unwrap_conversion_error.go:1342-1360) attaches a nan-counting check even with | __error__="". statsExtentPost (stats_extent_cache.go:116-119) sends every call of a checked request to VictoriaLogs.
    • So the warm and refresh half of the 7-day E1, E4, E8 and E9 gap cannot close while unwrap bypasses the cache. Only the cold half moves, through the phase filter of the prepared perf commit.

VictoriaLogs support: the evaluator needs the stats_query_range offset argument and count(), sum, max and min, all present from v1.49.0 to v1.53.0. No new capability gate.

Tests

All of these run against the real handler over the emulated VictoriaLogs (vlEmulator) and compare with a reference evaluator of Loki's windows written apart from the proxy (lokiOverTimeGroups).

test subtests on main (dfa8d041)
TestUnwrapOverTimeWindowsDifferential: generated rows with edges at ±1ns, offsets, 1 in 6 cases instant, 11 query forms, json/logfmt/none, VictoriaLogs v1.49.0, v1.53.0 and unknown 234 210 fail
TestUnwrapOverTimeRangeWindowEdgeCases: answers spelled out for lines on every window edge 28 28 fail
TestUnwrapOverTimeRawWindowBounds: the more than 11,000-bucket raw route, edges ±1ns 4 4 fail
TestUnwrapOverTimeSparseSamplePresence 12 12 fail
TestUnwrapOverTimeUngroupedSumIgnoresSeriesLimit (plus the grouped 400) 7 6 fail
TestUnwrapOverTimeShortRangeStaysOnStepBuckets (7d routing) 4 4 fail
TestWindowPhaseFilterKeepsExtentCacheAcrossSteps 2 1 fails
TestUnwrapOverTimeResidualShapesKeepStatsBuckets (pins the open forms on step buckets, no raw rows) 7 pass
TestLowerBoundIsExclusiveForEveryRangeFunction (#570) 5 cases fails
TestUnwrapGroupPushdownMatchesPerStreamFold: push-down vs the per-stream fold (unwrapGroupPushdownOff), every series' values byte for byte under the same labels and the same 400s, series limits 0 to 3, three versions, two labels in either order, detected_level 39 new path
TestUnwrapGroupPushdownDerivesServiceName 2 new
TestDropKeptFlagMatchesReplacements (one-pass strip = the three replacements, 5000 generated answers) 1 new
TestRateCounterReadsLokiWindow: rate_counter through the handler, range and instant 2 fails (0.333333, want 0.666667)
TestUnwrapOverTimeRangeWindowsNaN: the counting gate's nan group through the merged buckets (sum and avg NaN in the 5 windows that hold the line; max and min keep the clean value), with and without | __error__="" 8 fails (one NaN step, not five)

Conversion errors on the new route are covered by the existing TestUnwrapConversionErrorAnswersLokiText. Its sum by (service_name) (sum_over_time(... | logfmt | unwrap v [5m])) and max_over_time ... duration(t) cases at step 60 now take the window route and still answer Loki's 400 text. The intercepted form sends only the nan counter.

Pinned answers that read the step bucket move to the window:

  • five sliding goldens of TestExtractionListOutsideWithoutKeepsItsGrouping;
  • two max without (pod) answers of TestWithoutFoldIsLimitedToTheOperatorsItProves, now Loki's 36,36,36 and 40,40,40 by hand from the fixture, with detected_level.

TestQueryRange_AvgOverTimeByEmptyReturnsSingleSeries answers buckets of the sum and the count. The test harnesses now shut down every proxy and close every cache they build (runOverTimeCase, unwrapRangeResult).

e2e: TestCompat_UnwrapOverTimeRangeWindows (group core, compiled with -tags e2e). It now also asks max by (app), min by (route) and max(max_over_time) over json and logfmt:

Proofs (local, no Docker)

  • Unit tests capped (GOMEMLIMIT=2GiB GOMAXPROCS=4 go test -short -count=1 -p 1): pass.

  • -race on the touched tests (the window, push-down, ungrouped-sum, phase-filter, NaN, rate_counter and avg tests the bucket reader reaches, rerun on the last commit): pass.

  • Full capped run, GOMEMLIMIT=3GiB GOMAXPROCS=4 /usr/bin/time -l go test -short -count=1 -p 1 ./cmd/... ./internal/...: every package ok.

    internal/proxy wall peak RSS
    main 250c5d3c 113.0 s 154 s 2,514,403,328 B
    branch c538f410 (the stack-proof head, before the rebase onto 507915a5) 120.0 s 148 s 2,698,952,704 B
    branch 93af511d (after the rebase, idle host) 121.8 s n/a 2,648,932,352 B

    All three: every package ok, run on an otherwise idle host. The peak RSS of this work's six earlier runs ranged from 1.735 to 1.906 GB on the same code family, so the peak depends on the order the tests are scheduled in; the branch is +5% over main here. Full runs taken while the host was loaded failed on dial tcp ...: connect: can't assign requested address (macOS ephemeral ports in TIME_WAIT) and on the ten-minute go test timeout, never on an assertion; they passed when repeated on an idle host.

    • go vet ./... and go vet -tags e2e ./test/e2e-compat/: clean. gofmt -l: clean.
  • golangci-lint run ./...: 0 issues.

  • python3 scripts/ci/check_conformance.py: conformance gate ok.

  • python3 scripts/ci/check_changelog_pr.py --base origin/main --head HEAD: ok. Released sections are byte-identical to main.

Proxy-side cost (benchuw: a canned 10-series VictoriaLogs answer served from memory, so only the proxy is measured; GOMAXPROCS=2; 10 interleaved rounds of main, the branch before its last two perf commits (18e48088) and now (bd821caf); benchstat; load average 9.2 at the start, 9.0 at the end). VictoriaLogs time is not included; the stack A/B measures it.

shape sec/op main before after B/op main before after allocs main after
E1 7d [1m] step 600 (range shorter than the step) 2.25 ms 2.29 ms 2.01 ms 4.0 MiB 4.0 MiB 3.0 MiB 2.8k 2.1k
E8 7d [5m] step 600 (range shorter than the step) 2.16 ms 2.18 ms 1.98 ms 3.9 MiB 3.9 MiB 2.9 MiB 2.8k 2.1k
E8 24h [5m] step 60 3.09 ms 5.90 ms 2.83 ms 6.9 MiB 11.7 MiB 4.2 MiB 4.8k 1.6k
E8 1h [5m] step 5 1.59 ms 3.43 ms 2.15 ms 2.6 MiB 5.9 MiB 2.1 MiB 1.9k 1.6k
E4 6h max by [1m] step 15 3.14 ms 18.66 ms 2.85 ms 7.0 MiB 42.3 MiB 4.2 MiB 4.8k 1.5k
E4-like 6h min by 3.15 ms 18.81 ms 2.93 ms 6.9 MiB 43.0 MiB 4.1 MiB 4.8k 1.5k
max(max_over_time) 24h, logfmt 3.12 ms 3.11 ms 2.85 ms 6.1 MiB 6.6 MiB 4.1 MiB 3.6k 1.5k
avg 24h [5m] step 60 3.02 ms 7.00 ms 5.34 ms 6.3 MiB 12.6 MiB 6.7 MiB 4.4k 1.8k
  • Every shape's memory is below main's except avg (1.06×). Avg keeps a count series, so VictoriaLogs' answer is twice the size.
  • Time is at or below main's except two shapes:
    • E8 1h at step 5 (+35%): 60 buckets merge per window;
    • avg (+77%): two series per group to read, and long decimals to format.
  • max(max_over_time(... | logfmt ...)) answered the step bucket before; it is now exact through the push-down.
  • Harness (not committed): scratchpad/benchuw/zz_bench_uw_test.go. Its fake keeps every answer in memory, and the limit argument varies per iteration to miss the proxy's response cache. Results are in benchuw/benchstat.txt.

Registry

Changed:

id: semantics/unwrap-over-time-range-windows
title: sum, avg, max and min over time with unwrap answer Loki's window on query_range for a range longer than the step (fixed)
proves:
  - window-bounds-and-step-alignment
  - loki_api_v1_query_range
consumer: explore
why: |
  Loki evaluates `sum_over_time`, `avg_over_time`, `max_over_time` and
  `min_over_time` over unwrap on the window (t-range, t] for every step
  (pkg/logql/range_vector.go:102-189 batchRangeVectorIterator.Next, load: the
  lower bound is exclusive, the evaluation time inclusive;
  range_vector.go:371-420 the functions).

  Range == step is fixed with semantics/unwrap-range-last-point (anchored step
  buckets relabelled onto the evaluation time each closes), range < step with
  semantics/unwrap-over-time-short-range-windows (the window phase filter keeps
  only the lines of (t-range, t] in the step bucket).

  Range > step was answered from one VictoriaLogs step bucket per point
  (statsRateRangeEqualsStepShift treats every range of these functions as the
  step, and shouldUseManualRangeMetricCompat kept them off the window
  evaluator): the value was the step (t-step, t], not the window. Measured on an
  isolated stack (Loki 3.7.7, VictoriaLogs v1.52.0, generator data),
  `sum(sum_over_time({app="payment-service"} | logfmt | unwrap amount [5m]))` at
  step 60 and t=1791665280: Loki 9.84307e+06, proxy 2.00257e+06 (one step of
  the five in the window); with an emulated VictoriaLogs,
  `sum_over_time({app="uw"} | unwrap amount [150s])` at step 2m gave 725.875
  where Loki has 942.125. Whether a step had a sample followed the step bucket
  too: a line every 4.5 minutes under [5m] at step 60 gave 13 samples where Loki
  has 61.

  Fixed: a range longer than the step goes to the window evaluator
  (unwrapWindowBuckets). VictoriaLogs answers, per series, buckets of the
  greatest common divisor of range and step anchored at the request
  (stats_query_range with the offset argument), each holding the partial
  aggregate of its lines (the sum, the maximum or the minimum of the converted
  value; VictoriaLogs answers only a bucket that holds a line, so the point is
  the presence) and, for avg, their count; the proxy merges the buckets of every
  window (mergeUnwrapBuckets): the sum, the sum over the window's count for avg,
  the extreme for max and min, and a sample only where the window holds a line.
  `max by (L) (max_over_time(...))` and `min by (L) (min_over_time(...))`, L
  possibly empty, are the extreme of the group's lines in the window whatever
  the series are (pkg/logql/range_vector.go:405-420, VectorAggEvaluator.Next):
  VictoriaLogs groups those buckets by L (unwrapGroupPushdown), with no series
  per stream or line read, also over a JSON parser, and Loki's series limit
  counts the groups (pkg/logql/engine.go:530-590 JoinSampleVector).
  Windows overlap when the range is longer than the step, so one VictoriaLogs
  bucket per point cannot hold a window, and VictoriaLogs has no sliding-window
  stats: the merge in the proxy reads aggregated buckets only (series times
  buckets, bounded by the stats response limit), never raw rows. The outer
  aggregations are the ones the evaluator already applies after the window: a
  sum by labels or none, topk and bottomk, label_replace, a binary operation, a
  count, min, max or avg by labels (foldVectorAggregations), and a labelled
  `without` as the outermost aggregation (withWithoutFold); the reads before an
  outer fold are bounded by the raw-row budget, as for a `without`, and Loki's
  series limit applies to the folded answer. A grid of more than 11,000 buckets
  (a range that the step does not divide, say [5m] at step 86.4s: 2.4s buckets)
  is answered by the raw-row evaluator, bounded by
  -manual-range-metric-row-limit, which reads the same window
  (semantics/lower-bound-exclusive). Instant queries were already exact.

  This supersedes #740 (fix/unwrap-over-time-range-windows), whose duplicated
  range == step commits are on main, and includes #570
  (semantics/lower-bound-exclusive).

  Not covered: the forms whose series VictoriaLogs groups per stream and line
  under an outer aggregation, nested aggregations the evaluator does not apply
  and an empty `without ()` keep the native step buckets
  (semantics/unwrap-over-time-range-windows-residual-forms).
request:
  method: GET
  path: /loki/api/v1/query_range
  params:
    query: 'sum(sum_over_time({app="payment-service"} | logfmt | unwrap amount [5m]))'
    start: 'now-1h'
    end: 'now'
    step: '60'
  headers:
    X-Scope-OrgID: "0"
compare:
  mode: observed
  fields: [series, values]
  expect: |
    One series, Loki's window sum at every step.
gap:
  status: fixed
  fixed_by: TBD-PR
  impact: explore-visible
  effort: M
  area: 'internal/proxy unwrap_samples.go unwrapWindowBuckets, unwrapBucketAgg, mergeUnwrapBuckets; range_metric_compat.go handleStatsCompatRange, buildHitsRangeMetricMatrix'
  loki: 'pkg/logql/range_vector.go:102-189 batchRangeVectorIterator (window (t-range, t]); range_vector.go:371-420 sumOverTime, avgOverTime, maxOverTime, minOverTime'
  proxy: 'anchored gcd(range, step) buckets of the partial aggregate and the sample count, merged per window'
  planned_test: 'TestUnwrapOverTimeWindowsDifferential, TestUnwrapOverTimeRangeWindowEdgeCases, TestUnwrapOverTimeRawWindowBounds (internal/proxy); TestCompat_UnwrapOverTimeRangeWindows (test/e2e-compat, group core)'
  note: 'Range == step fixed with semantics/unwrap-range-last-point, range < step with semantics/unwrap-over-time-short-range-windows. Supersedes #740; includes #570.'
  clusters: []

New (pending owner review):

id: semantics/lower-bound-exclusive
title: the raw-row range evaluator reads the window (t-range, t] for every range function (fixed)
proves:
  - window-bounds-and-step-alignment
  - loki_api_v1_query_range
  - loki_api_v1_query
consumer: explore
why: |
  Loki's batchRangeVectorIterator.load skips a sample with `Timestamp <= start`
  ("the lower bound of the range is not inclusive") and keeps samples up to and
  including the evaluation time, for every range aggregation, unwrapped ones
  included (pkg/logql/range_vector.go:148-189, Loki v3.7.7).

  The raw-row evaluator (aggregateManualWindow) excluded the lower bound only for
  rate, count_over_time, bytes_over_time and bytes_rate, and extended its
  VictoriaLogs fetch by 1ns past the evaluation time (the fetch end is
  exclusive) only for those and quantile_over_time. Every other function read
  the window [t-range, t): over samples {0:1, 10:2, 20:3} and the window
  (0, 20], sum 3 where Loki has 5, avg 1.5 (2.5), min 1 (2), and a sample at t
  was never read. It answers rate_counter on query_range, the instant queries
  over a JSON parser of sum, avg, max, min, stddev, stdvar, first and last over
  time, and any range grid of more than 11,000 buckets.

  Fixed (contributed in #570): the window is (t-range, t] for every function,
  on query_range and instant queries. Only samples exactly on a window edge
  move, which second-precision timestamps make common.

  Not covered: rate_counter's value itself is this evaluator's counter
  increase over the window; Loki extrapolates it to the window edges
  (pkg/logql/range_vector.go:275-346 rateCounter, extrapolatedRate), which the
  evaluator does not.
request:
  method: GET
  path: /loki/api/v1/query
  params:
    query: 'sum by (app) (sum_over_time({app="payment-service"} | json | unwrap amount [2m]))'
    time: 'now'
  headers:
    X-Scope-OrgID: "0"
compare:
  mode: observed
  fields: [series, values]
  expect: |
    The sum of the lines in (time-2m, time]: a line at time-2m is out, a line at time is in.
gap:
  status: fixed
  fixed_by: TBD-PR
  impact: explore-visible
  visual: none
  visual_reason: 'it moves only a sample that sits exactly on a window edge, one line per window at most, which no panel at Explore''s steps tells apart; proven by the window comparison over lines on every edge against Loki in the e2e'
  effort: S
  area: 'internal/proxy range_metric_compat.go aggregateManualWindow, proxyManualRangeMetricRange and proxyManualRangeMetricInstant (fetch end)'
  loki: 'pkg/logql/range_vector.go:148-189 batchRangeVectorIterator.load'
  proxy: '(t-range, t] for every function of the raw-row evaluator'
  planned_test: 'TestLowerBoundIsExclusiveForEveryRangeFunction, TestCompatHelpers_AggregateManualWindow, TestUnwrapOverTimeRawWindowBounds (internal/proxy); TestCompat_UnwrapOverTimeRangeWindows (test/e2e-compat, group core: the JSON instant over lines on the window edges)'
  note: 'Contributed in #570. rate_counter extrapolation is not covered.'
  clusters: []
id: semantics/unwrap-over-time-range-windows-residual-forms
title: some outer aggregations of sum, avg, max and min over time with unwrap answer the step bucket, not the window, when the range is longer than the step (open)
proves:
  - window-bounds-and-step-alignment
  - metric-series-identity
  - loki_api_v1_query_range
consumer: explore
why: |
  Loki takes these functions per series over the window (t-range, t]
  (pkg/logql/range_vector.go:102-189, range_vector.go:371-420), a series being
  the stream plus the labels a parser extracted less the unwrapped one
  (pkg/logql/log/metrics_extraction.go), and applies the outer aggregations per
  step (pkg/logql/evaluator.go VectorAggEvaluator.Next).

  The window merge of semantics/unwrap-over-time-range-windows reads per-series
  buckets of partial aggregates and applies the outer aggregation after the
  window. Three kinds of query keep VictoriaLogs' native step buckets with a
  range longer than the step, so every point is the step (t-step, t]:
  - a JSON parser query whose series the translation groups by stream and line
    (`stats by (_stream, _msg)`) under an outer aggregation, such as
    `sum(avg_over_time({..} | json | unwrap x [5m]))` or
    `max by (pod) (sum_over_time({..} | json | unwrap x [5m]))`, and a `without`
    over a parser query grouped by line: buckets per distinct line are not a
    bounded grouping, and (_stream, _msg) is not Loki's series either. A max by
    over max_over_time and a min by over min_over_time are not among them: their
    buckets group by the outer labels (unwrapGroupPushdown);
  - nested aggregations the evaluator does not apply after the window (a sum
    over a sum, a `without` under another aggregation, a ranking `without`);
  - an empty `without ()` (semantics/without-empty-grouping-range-collapses).
  Exact windows need Loki's parsed series identity as a stats grouping first;
  then the partial aggregates merge per window like the other forms. Found with
  an emulated VictoriaLogs while fixing semantics/unwrap-over-time-range-windows
  (not measured on a stack).
request:
  method: GET
  path: /loki/api/v1/query_range
  params:
    query: 'sum(avg_over_time({service_name="api-gateway"} | json | unwrap duration_ms | __error__="" [5m]))'
    start: 'now-1h'
    end: 'now'
    step: '60'
  headers:
    X-Scope-OrgID: "0"
compare:
  mode: observed
  fields: [series, values]
  expect: |
    One series, the per-series window averages of Loki added up at every step.
gap:
  status: open
  impact: explore-visible
  effort: L
  area: 'internal/proxy unwrap_samples.go unwrapWindowBuckets and unwrapWindowForm (these forms keep native step buckets); the translator''s per-series grouping of parser queries'
  loki: 'pkg/logql/range_vector.go:102-189 batchRangeVectorIterator; range_vector.go:371-420 sumOverTime, avgOverTime, maxOverTime, minOverTime; pkg/logql/evaluator.go VectorAggEvaluator.Next'
  proxy: 'native step buckets: the step-sized bucket, not the window'
  planned_test: 'TestUnwrapOverTimeResidualShapesKeepStatsBuckets pins the route today (internal/proxy); the forms join TestUnwrapOverTimeWindowsDifferential and TestCompat_UnwrapOverTimeRangeWindows once fixed'
  note: 'Residual of semantics/unwrap-over-time-range-windows; depends on the parsed series identity rows.'
  clusters: []

New with the prepared commit (pending owner review):

id: semantics/unwrap-over-time-short-range-windows
title: sum, avg, max and min over time with unwrap and a range shorter than the step answer Loki's window on query_range (fixed)
proves:
  - window-bounds-and-step-alignment
  - loki_api_v1_query_range
consumer: explore
why: |
  Loki evaluates `sum_over_time`, `avg_over_time`, `max_over_time` and
  `min_over_time` over unwrap on the window (t-range, t] for every step
  (pkg/logql/range_vector.go:102-189 batchRangeVectorIterator.Next and load: the
  lower bound is exclusive, and a line between two windows is consumed without
  joining any sample). With a range shorter than the step the windows are
  disjoint and a step whose window holds no line has no sample.

  The proxy answered these functions from VictoriaLogs' anchored step buckets
  (edge, edge+step] relabelled onto the evaluation time each closes
  (statsRateRangeEqualsStepShift, semantics/unwrap-range-last-point), so a step's
  value was the whole step: `sum by (service_name) (sum_over_time({service_name="api-gateway"}
  | json | unwrap duration_ms [1m]))` over 7 days (step 10m, the daily
  unwrap-conversion-error E1 shape) summed ten minutes of lines per point where
  Loki sums one, and a step with lines only outside its window had a sample Loki
  does not.

  Fixed: for a range shorter than the step the request's VictoriaLogs query
  carries the window filter of the window evaluator (withWindowPhaseFilter: the
  phase of a line's stored _time modulo the step, anchored at start-range), right
  after the query's filters, so the bucket (t-step, t] holds exactly the lines of
  (t-range, t] and VictoriaLogs parses only those. Values and sample presence are
  Loki's, as an operand of a binary operation too, and with the conversion-error
  check, which counts only the lines inside a window as Loki fails only on those.

  A range longer than the step is fixed with semantics/unwrap-over-time-range-windows.
  Not covered: the series identity of avg, max and min under an outer aggregation
  (semantics/unwrap-sum-by-per-series-function-extraction-list). VictoriaLogs
  evaluates the phase in float64, so a line within a few hundred nanoseconds of a
  window edge may fall on either side, as for the window evaluator.
request:
  method: GET
  path: /loki/api/v1/query_range
  params:
    query: 'sum by (service_name) (sum_over_time({service_name="api-gateway"} | json | unwrap duration_ms [1m]))'
    start: 'now-6h'
    end: 'now'
    step: '600'
  headers:
    X-Scope-OrgID: "0"
compare:
  mode: observed
  fields: [series, values]
  expect: |
    One series per service_name, Loki's one-minute window sum at every 10-minute step.
gap:
  status: fixed
  fixed_by: TBD-PR
  impact: explore-visible
  visual: none
  visual_reason: 'a fix proof is captured at the ranges Loki holds in the visual stack (up to 1.5h), where Explore''s auto step is a few seconds and any range a panel can show is longer than the step (semantics/unwrap-over-time-range-windows); range < step needs a step of minutes (7d), proven by the window comparison against Loki in the e2e'
  effort: S
  area: 'internal/proxy metric_binary.go proxyStatsQueryRange (statsRateRangeEqualsStepShift branch) and range_metric_compat.go withWindowPhaseFilter'
  loki: 'pkg/logql/range_vector.go:102-189 batchRangeVectorIterator (window (t-range, t]); range_vector.go:371-420 sumOverTime, avgOverTime, maxOverTime, minOverTime'
  proxy: 'anchored step buckets restricted to the lines of (t-range, t] by the window filter'
  planned_test: 'TestUnwrapOverTimeShortRangeMatchesLoki (internal/proxy); TestCompat_UnwrapOverTimeShortRange (test/e2e-compat, group core)'
  clusters: []

Visual fix-proof entry: explore-unwrap-sum-over-time-sliding-window (bench/visual/spec.json), sum by (service_name) (sum_over_time({service_name="api-gateway"} | json | unwrap duration_ms | __error__="" [5m])) at Explore's step.

Not covered (follow-ups)

  1. semantics/unwrap-over-time-range-windows-residual-forms stays open. These keep the step bucket:

    • a JSON parser query grouped per stream and line under an outer aggregation;
    • nested aggregations (a sum over a sum, a nested or ranking without);
    • an empty without ().

    All need Loki's parsed series identity as a stats grouping first.

  2. rate_counter's value has no Loki extrapolation (range_vector.go:275-346); only its window membership is fixed.

  3. foldVectorAggregations re-encodes JSON for the outer folds that are not decomposable (count by, avg by, max by over another function). Their proxy cost stays at the old level; the decomposable max by/min by forms are pushed down.

  4. A range the step does not divide can pass 11,000 gcd buckets and fall back to raw rows (bounded). A phase-split alternative stays on step-wide buckets:

    • one query of full step buckets for the whole steps of the range;
    • one phase-filtered query for the remainder, range mod step.
  5. The outer avg(...) over a logfmt query and outer count/min/max/avg over json differ from Loki for any range relation (series identity, also on main).

  6. detected_level="unknown" now appears on per-stream series when the range is longer than the step, as Loki puts it, but not on the native step path of a range up to the step. Zooming a panel can change the label set. The native path's per-stream series should carry it too (semantics/without-instant-detected-level-unknown is the instant side).

  7. An instant topk(5, max by (pod) (max_over_time(... | json ...))) misses samples, on main too: the instant path does not apply a series-level chain under a ranking. The differential keeps that form to query_range (rangeOnly).

  8. E2 at 7d (quantile_over_time at [1m], step 10m) answering 502 was not reproduced here, because it needs the stack. Unverified hypothesis: fetchQuantileCounts reads value counts per bucket for 1008 buckets and passes -backend-max-buffered-response-bytes; badRequestStatusOr maps that error to 502.

  9. The without folds over an unwrap function (W1, W2, W3, W4, W7 of unwrap-sum-without, 6h, step 10s) stay at 7 to 40x Loki's warm and refresh time (113 to 180 ms against 3 to 18 ms), 2x main's because main answered the wrong window; W8 (sum without (pod) (count_over_time(...)), not an unwrap query) is 1.3 to 1.6 s against 10 to 87 ms on main and here alike. Both go through the window route, which builds a per-stream matrix and folds it; reading the buckets through the stats extent cache and folding from stored buckets is the next item (the stacked branch does not cover this route).

  10. E2 (quantile_over_time over unwrap, 6h) and U9 (rate_counter folded per series) are above 2x Loki warm and refresh on main and here; they use the quantile and per-series fold routes.

Open questions for the owner

  • Proxy-side cost of the corrected route. (Measured before the last commit, which changes only the reservation of each series' points.) After the two perf commits before it, every benchuw shape is at or below main's memory, except avg at 1.06×. Proxy time stays above main's for E8 1h at step 5 (+35%, 0.6 ms) and for avg (+77%, 2.3 ms). Sign-off is needed on those two, or a follow-up: a running window for sum, or a phase-split for avg.
  • The 2.55 GB RSS note could not be located earlier. The full capped runs of this work peaked between 1.7 and 2.7 GB for main and the branches alike (the idle-host runs above: main 2.51 GB, branch 2.65 GB), so the peak depends on test scheduling more than on the change; TestUnwrapOverTime* alone peaks at 92 MB.

Proofs on a stack

Everything below ran on one Colima VM (8 CPU, 14 GB), Loki 3.7.9, VictoriaLogs v1.49.0 (floor) and v1.53.0. Stack-proof heads are c538f410 (this branch) and bfaedadd (the stacked extent-cache branch), cut from 250c5d3c. The branches were then rebased onto 507915a5 (heads 93af511d and de6fadb7); the only change on main in between is #883 (a transport retry), the gates, the unit run and the e2e regex groups were repeated on the rebased heads and pass, and the A/B, 24h/7d, load and visual numbers carry over. VictoriaLogs and Loki restart counts were read before and after the e2e, A/B, 24h, 7d (VictoriaLogs) and load runs and were 0 every time; the visual runs did not record them.

Status against the 2x Loki bar. It is correct (e2e, differentials, visual: its answers equal Loki's where main's differ) and it removes the memory regression it introduced. The shapes on the window route stay above 2x Loki: W1, W2, W3, W4, W7 (without folds over an unwrap function, 6h) at 7 to 40x Loki warm and refresh, and U9 (rate_counter, 1h and 6h) at 7 to 60x; main is above 2x on the same rows, answering a wrong window faster. These rows are kept on the above-2x baseline and tracked as follow-ups (follow-up 9 below, the stats extent cache for the window route, and step-aligned refresh), and this change lands now so the answers are Loki's.

e2e against Loki (group core and the unwrap regex)

go test -tags e2e -count=1 -run '^(TestSetup_IngestLogs|TestCompat_UnwrapOverTimeRangeWindows|TestCompat_UnwrapOverTimeShortRange|TestCompat_UnwrapRangeEndSample|TestCompat_UnwrapUngroupedSum.*|TestCompat_UnwrapConversionErrorParity|TestCompat_UnwrapSumWithout.*|TestCompat_UnwrapFirstLast.*)$' ./test/e2e-compat/ on an isolated compose project (every port +10000), plus the whole core group (TestSetup_IngestLogs, TestCompat_*, TestExtended_*, TestChaining_*, ...). Subtests of TestCompat_UnwrapConversionErrorParity/open and TestCompat_UnwrapValueFormsParity skip by design (forms whose Loki error the proxy does not detect yet, registered open).

VictoriaLogs group tests passed (incl. subtests) failed skipped (documented open forms) seconds VL / Loki restarts before-after uptime before
v1.49.0 core (all TestCompat_* and friends) 3613 0 87 351 0/0 - 0/0 21:48 load 6.77 6.31 7.40
v1.49.0 regex group 858 0 3 12 0/0 - 0/0 21:44 load 5.10 5.72 7.71
v1.53.0 core (all TestCompat_* and friends) 3613 0 87 204 0/0 - 0/0 21:55 load 13.20 8.55 7.80
v1.53.0 regex group 858 0 3 10 0/0 - 0/0 21:46 load 9.47 6.40 7.63

Named tests: TestChaining_MetricQueryUnwrapChainWorks PASS, TestCompat_UnwrapConversionErrorParity PASS, TestCompat_UnwrapDurationSeconds PASS, TestCompat_UnwrapFirstLastRangeLevelBy PASS, TestCompat_UnwrapFirstLastTopkBottomk PASS, TestCompat_UnwrapFirstLastUnderAggregation PASS, TestCompat_UnwrapMissingAndNonNumeric PASS, TestCompat_UnwrapOverTimeRangeWindows PASS, TestCompat_UnwrapOverTimeShortRange PASS, TestCompat_UnwrapOverflowParity PASS, TestCompat_UnwrapQuantile PASS, TestCompat_UnwrapRangeEndSample PASS, TestCompat_UnwrapRangeSamplesAreNumbers PASS, TestCompat_UnwrapSumWithoutExtractionList PASS, TestCompat_UnwrapSumWithoutKeepsSeries PASS, TestCompat_UnwrapSumWithoutUnderAnotherAggregation PASS, TestCompat_UnwrapUngroupedSum PASS, TestCompat_UnwrapUngroupedSumPerSeriesFunctions PASS, TestCompat_UnwrapValueFormsParity PASS, TestSetup_IngestLogs PASS

The rebased heads (93af511d) repeat the regex group: 858 passed, 0 failed, 3 skipped on v1.49.0 and on v1.53.0.

A/B: main, this branch and Loki (bench/ab/pr_smoke.py)

pr_smoke.py --base origin/main --head perf/unwrap-conversion-check --ranges 1h,6h --refresh-s 60 --refresh-runs 5 --loki-mem 6g --vl-mem 4g with the sets unwrap-conversion-error, unwrap-gate, unwrap-validity, unwrap-ungrouped-sum, unwrap-sum-without and control: 132 shape x range rows, 4 runs (the first cold), warm p50, refresh p50 over windows moving 60 s, targets interleaved per request, 26.9 minutes, load average 5.8 at the start. Base 250c5d3c, head c538f410.

Verdict: faster, no regression. 1 faster, 131 within noise; results against Loki: 120 same, 0 new differences, 12 pre-existing (all differs (as base)). Above 2x Loki: 0 new, 0 worsened by the tool's rule, 67 on the committed list, 16 cleared.

Proxy RSS is in the load test below; proxy CPU was not sampled in these runs. VictoriaLogs CPU, sampled during every interleaved row (one number per row, shared by the three targets): median row maximum 58%, highest 528% of one core (8 cores). VictoriaLogs restarts 0 to 0 in every set.

The one verdict-level mover is W8 6h (3.05 s to 1.60 s, the memory fix above). The tool's verdict misses smaller moves, so every cold, warm and refresh cell was compared with main's by hand: 396 cells, 6 faster than main by more than 50 ms and 25%, and 13 slower by more than 50 ms and 25%. Those 13 are the 6h cold, warm and refresh cells of W1, W2, W4 and W7 and the 1h refresh of W4 (all in unwrap-sum-without; sum without (pod) or max without (pod) over an unwrap function with a 5m range at a 10s step):

row main this branch Loki
W1 sum without (pod) (max_over_time(... unwrap amount [5m])) 6h cold / warm / refresh 71 / 77 / 77 ms 155 / 153 / 158 ms 111 / 17 / 4 ms
W2 sum without (pod) (sum_over_time(...)) 6h 71 / 74 / 76 ms 155 / 163 / 165 ms 63 / 13 / 4 ms
W4 max without (pod) (max_over_time(...)) 6h 97 / 74 / 80 ms 166 / 163 / 178 ms 82 / 11 / 4 ms
W7 topk(5, sum without (pod) (...)) 6h 71 / 71 / 94 ms 151 / 152 / 181 ms 68 / 11 / 5 ms

In each of those rows main answers the step bucket and its answer differs from Loki (baseline_parity_vs_reference: differs), and this branch's answer is the same as Loki's. The extra 80 to 90 ms is the cost of the correct answer (merging the sliding windows of the buckets instead of reading one), not a regression of a correct path. These rows are still 7 to 40x Loki's warm and refresh time, so they are listed as above 2x below and are not fixed here (the stacked branch does not cache this route either; see its description).

Rows above 2x Loki (PR ÷ Loki above 2 and more than 50 ms over, cold, warm or refresh)

71 cells. Main is above 2x on almost all of them too; the base column shows it. The first-pass pr_smoke list at the start of this work (before the memory fix) had 4 new and 14 worsened rows, all W1, W2, W3, W4, W7, W8 and U3, U12; with the fix none is new or worse than the committed list.

set shape range mode base PR Loki PR÷Loki base÷Loki
unwrap-conversion-error E1 json duration_ms sum by service 6h warm 83 ms 82 ms 19 ms 4.3x 4.37x
unwrap-conversion-error E1 json duration_ms sum by service 6h refresh 98 ms 83 ms 4 ms 20.8x 24.50x
unwrap-conversion-error E2 json duration_ms p95 by service 1h warm 90 ms 84 ms 16 ms 5.2x 5.62x
unwrap-conversion-error E2 json duration_ms p95 by service 1h refresh 82 ms 90 ms 3 ms 30.0x 27.33x
unwrap-conversion-error E2 json duration_ms p95 by service 6h cold 467 ms 449 ms 165 ms 2.7x 2.83x
unwrap-conversion-error E2 json duration_ms p95 by service 6h warm 447 ms 507 ms 14 ms 36.2x 31.93x
unwrap-conversion-error E2 json duration_ms p95 by service 6h refresh 396 ms 394 ms 2 ms 197.0x 198.00x
unwrap-conversion-error E3 logfmt latency_ms rate, production 1h refresh 84 ms 82 ms 2 ms 41.0x 42.00x
unwrap-conversion-error E3 logfmt latency_ms rate, production 6h warm 469 ms 490 ms 91 ms 5.4x 5.15x
unwrap-conversion-error E3 logfmt latency_ms rate, production 6h refresh 445 ms 439 ms 4 ms 109.8x 111.25x
unwrap-conversion-error E6 logfmt ttl unit string (Loki 400) 6h cold 61 ms 73 ms 22 ms 3.3x 2.77x
unwrap-conversion-error E8 json duration_ms sum by service, namespace prod 6h warm 189 ms 188 ms 64 ms 2.9x 2.95x
unwrap-conversion-error E8 json duration_ms sum by service, namespace prod 6h refresh 186 ms 186 ms 4 ms 46.5x 46.50x
unwrap-conversion-error E9 logfmt latency_ms sum by service, namespace prod 6h warm 282 ms 276 ms 55 ms 5.0x 5.13x
unwrap-conversion-error E9 logfmt latency_ms sum by service, namespace prod 6h refresh 301 ms 276 ms 3 ms 92.0x 100.33x
unwrap-gate G2 json duration_ms sum_over_time, no filter 6h warm 80 ms 81 ms 26 ms 3.1x 3.08x
unwrap-gate G2 json duration_ms sum_over_time, no filter 6h refresh 80 ms 81 ms 4 ms 20.2x 20.00x
unwrap-gate G4 json duration_ms sum_over_time, error filter 6h warm 89 ms 87 ms 21 ms 4.1x 4.24x
unwrap-gate G4 json duration_ms sum_over_time, error filter 6h refresh 83 ms 86 ms 3 ms 28.7x 27.67x
unwrap-gate G6 json duration_ms sum by rate 6h warm 86 ms 82 ms 19 ms 4.3x 4.53x
unwrap-gate G6 json duration_ms sum by rate 6h refresh 92 ms 102 ms 3 ms 34.0x 30.67x
unwrap-gate G7 duration() of a unit string 6h refresh 55 ms 55 ms 4 ms 13.8x 13.75x
unwrap-gate G8 bytes() of a number 6h refresh 60 ms 57 ms 3 ms 19.0x 20.00x
unwrap-gate G9 json duration_ms max_over_time 6h refresh 101 ms 81 ms 3 ms 27.0x 33.67x
unwrap-gate G10 logfmt latency_ms sum_over_time, production 1h refresh 77 ms 76 ms 2 ms 38.0x 38.50x
unwrap-gate G10 logfmt latency_ms sum_over_time, production 6h warm 444 ms 460 ms 132 ms 3.5x 3.36x
unwrap-gate G10 logfmt latency_ms sum_over_time, production 6h refresh 410 ms 433 ms 3 ms 144.3x 136.67x
unwrap-validity U1 json duration_ms sum by service 6h warm 80 ms 74 ms 19 ms 3.9x 4.21x
unwrap-validity U1 json duration_ms sum by service 6h refresh 77 ms 76 ms 3 ms 25.3x 25.67x
unwrap-validity U2 json duration_ms max by service 6h refresh 105 ms 83 ms 2 ms 41.5x 52.50x
unwrap-validity U3 json duration_ms sliding sum 6h warm 86 ms 79 ms 26 ms 3.0x 3.31x
unwrap-validity U3 json duration_ms sliding sum 6h refresh 78 ms 77 ms 2 ms 38.5x 39.00x
unwrap-validity U8 rate of an unwrapped label 6h warm 82 ms 86 ms 18 ms 4.8x 4.56x
unwrap-validity U8 rate of an unwrapped label 6h refresh 85 ms 83 ms 4 ms 20.8x 21.25x
unwrap-validity U12 json duration_ms over every service 6h warm 225 ms 228 ms 35 ms 6.5x 6.43x
unwrap-validity U12 json duration_ms over every service 6h refresh 247 ms 240 ms 5 ms 48.0x 49.40x
unwrap-validity U13 logfmt latency_ms over every service 1h refresh 99 ms 77 ms 3 ms 25.7x 33.00x
unwrap-validity U13 logfmt latency_ms over every service 6h warm 529 ms 412 ms 56 ms 7.4x 9.45x
unwrap-validity U13 logfmt latency_ms over every service 6h refresh 571 ms 455 ms 2 ms 227.5x 285.50x
unwrap-ungrouped-sum U2 json duration_ms rate 6h warm 81 ms 79 ms 23 ms 3.4x 3.52x
unwrap-ungrouped-sum U2 json duration_ms rate 6h refresh 76 ms 74 ms 3 ms 24.7x 25.33x
unwrap-ungrouped-sum U3 json duration_ms rate, error filter 6h warm 74 ms 75 ms 18 ms 4.2x 4.11x
unwrap-ungrouped-sum U3 json duration_ms rate, error filter 6h refresh 81 ms 76 ms 4 ms 19.0x 20.25x
unwrap-ungrouped-sum U4 logfmt latency_ms rate, production 1h refresh 65 ms 58 ms 2 ms 29.0x 32.50x
unwrap-ungrouped-sum U4 logfmt latency_ms rate, production 6h warm 314 ms 333 ms 135 ms 2.5x 2.33x
unwrap-ungrouped-sum U4 logfmt latency_ms rate, production 6h refresh 306 ms 307 ms 2 ms 153.5x 153.00x
unwrap-ungrouped-sum U9 logfmt amount rate_counter (folded per series) 1h warm 165 ms 155 ms 23 ms 6.7x 7.17x
unwrap-ungrouped-sum U9 logfmt amount rate_counter (folded per series) 1h refresh 158 ms 160 ms 2 ms 80.0x 79.00x
unwrap-ungrouped-sum U9 logfmt amount rate_counter (folded per series) 6h warm 180 ms 187 ms 22 ms 8.5x 8.18x
unwrap-ungrouped-sum U9 logfmt amount rate_counter (folded per series) 6h refresh 196 ms 193 ms 2 ms 96.5x 98.00x
unwrap-sum-without W1 sum without pod, max_over_time 6h warm 77 ms 153 ms 17 ms 9.0x 4.53x
unwrap-sum-without W1 sum without pod, max_over_time 6h refresh 77 ms 158 ms 4 ms 39.5x 19.25x
unwrap-sum-without W2 sum without pod, sum_over_time 6h cold 71 ms 155 ms 63 ms 2.5x 1.13x
unwrap-sum-without W2 sum without pod, sum_over_time 6h warm 74 ms 163 ms 13 ms 12.5x 5.69x
unwrap-sum-without W2 sum without pod, sum_over_time 6h refresh 76 ms 165 ms 4 ms 41.2x 19.00x
unwrap-sum-without W3 sum without pod, rate 6h cold 219 ms 188 ms 67 ms 2.8x 3.27x
unwrap-sum-without W3 sum without pod, rate 6h warm 223 ms 188 ms 11 ms 17.1x 20.27x
unwrap-sum-without W3 sum without pod, rate 6h refresh 221 ms 226 ms 4 ms 56.5x 55.25x
unwrap-sum-without W4 max without pod, max_over_time 1h refresh 15 ms 76 ms 2 ms 38.0x 7.50x
unwrap-sum-without W4 max without pod, max_over_time 6h cold 97 ms 166 ms 82 ms 2.0x 1.18x
unwrap-sum-without W4 max without pod, max_over_time 6h warm 74 ms 163 ms 11 ms 14.8x 6.73x
unwrap-sum-without W4 max without pod, max_over_time 6h refresh 80 ms 178 ms 4 ms 44.5x 20.00x
unwrap-sum-without W7 topk 5 of sum without pod 6h cold 71 ms 151 ms 68 ms 2.2x 1.04x
unwrap-sum-without W7 topk 5 of sum without pod 6h warm 71 ms 152 ms 11 ms 13.8x 6.45x
unwrap-sum-without W7 topk 5 of sum without pod 6h refresh 94 ms 181 ms 5 ms 36.2x 18.80x
unwrap-sum-without W8 sum without pod, count_over_time (production) 1h warm 146 ms 132 ms 49 ms 2.7x 2.98x
unwrap-sum-without W8 sum without pod, count_over_time (production) 1h refresh 131 ms 139 ms 6 ms 23.2x 21.83x
unwrap-sum-without W8 sum without pod, count_over_time (production) 6h cold 3.44 s 1.49 s 10 ms 149.3x 344.30x
unwrap-sum-without W8 sum without pod, count_over_time (production) 6h warm 3.05 s 1.60 s 26 ms 61.6x 117.19x
unwrap-sum-without W8 sum without pod, count_over_time (production) 6h refresh 3.05 s 1.77 s 18 ms 98.6x 169.61x
control topk rate by app 6h refresh 82 ms 80 ms 5 ms 16.0x 16.40x

71 mode-rows above 2x of 132 shape x range rows.

The full 132-row table (cold, warm, refresh for main, this branch and Loki, with every ratio) is generated from the run's summaries; it is posted with the A/B comment.

Long ranges: 24h against Loki, 7d without Loki

sum, rate, max over unwrap, E1 E3 E4 E8 E9 of unwrap-conversion-error, one stack, main 250c5d3c, this branch (c538f410), the stacked branch (bfaedadd) and Loki side by side. Cold is the first request of the window, warm the median of three repeats, refresh the median of five windows moving 60 s forward. 24h: seed of 26h of generator data (Loki and VictoriaLogs), step 60 s, window ending an hour before the seed end. Answers against Loki: E1, E4, E8, E9 all 1441 (E8 7205) points equal on this branch and the stacked one; main has 0 of 7205 (E8) and 0 of 1441 (E9) points equal (the sliding-window bug); E3 has one series more than Loki in all three builds (the pre-existing series-identity difference of the differs (as base) rows).

shape mode main branch 1 branch 2 Loki branch 1 ÷ Loki branch 2 ÷ Loki main ÷ Loki
E1 json duration_ms sum by service cold 2.33 s 1.10 s 1.12 s 2.84 s 0.39x 0.39x 0.82x
E1 json duration_ms sum by service warm 1 ms 1 ms 1 ms 23 ms 0.04x 0.04x 0.04x
E1 json duration_ms sum by service refresh 632 ms 763 ms 10 ms 110 ms 6.94x 0.09x 5.75x
E3 logfmt latency_ms rate, production cold 2.85 s 2.94 s 2.93 s 12.48 s 0.24x 0.23x 0.23x
E3 logfmt latency_ms rate, production warm 1 ms 1 ms 1 ms 4 ms 0.25x 0.25x 0.25x
E3 logfmt latency_ms rate, production refresh 2.83 s 2.94 s 18 ms 307 ms 9.57x 0.06x 9.23x
E4 logfmt size_bytes max by service cold 254 ms 275 ms 247 ms 1.11 s 0.25x 0.22x 0.23x
E4 logfmt size_bytes max by service warm 1 ms 1 ms 1 ms 4 ms 0.25x 0.25x 0.25x
E4 logfmt size_bytes max by service refresh 279 ms 288 ms 22 ms 34 ms 8.47x 0.65x 8.21x
E8 json duration_ms sum by service, na cold 1.53 s 1.37 s 1.18 s 5.13 s 0.27x 0.23x 0.30x
E8 json duration_ms sum by service, na warm 1 ms 1 ms 1 ms 5 ms 0.20x 0.20x 0.20x
E8 json duration_ms sum by service, na refresh 1.20 s 1.27 s 39 ms 310 ms 4.09x 0.13x 3.86x
E9 logfmt latency_ms sum by service, n cold 1.95 s 1.83 s 1.71 s 5.62 s 0.33x 0.30x 0.35x
E9 logfmt latency_ms sum by service, n warm 1 ms 1 ms 1 ms 4 ms 0.25x 0.25x 0.25x
E9 logfmt latency_ms sum by service, n refresh 1.65 s 1.68 s 9 ms 194 ms 8.66x 0.05x 8.51x

7d: a seed of 170 hours into VictoriaLogs (retention raised to 9d with a scratch compose overlay). Against Loki it could not be run on this stack: with the same overlay for Loki (-store.retention and -validation.reject-old-samples.max-age at 216h) its push endpoint closed the connection about a day and a half into the backfill (the log generator died with RemoteDisconnected; Loki used 2.2 GiB of its 6 GiB limit at the start; the container went down with the stack, so the cause was not captured), so no 7d Loki reference exists here. The VictoriaLogs-only run compares the three builds with each other (step 600 s, refresh moving 60 s per run, which is inside one step):

shape mode main branch 1 branch 2
E1 json duration_ms sum by service cold 3.92 s 1.69 s 1.42 s
E1 json duration_ms sum by service warm 0 ms 0 ms 0 ms
E1 json duration_ms sum by service refresh 3.15 s 1.32 s 1.00 s
E3 logfmt latency_ms rate, production cold 14.34 s 8.24 s 8.83 s
E3 logfmt latency_ms rate, production warm 1 ms 0 ms 0 ms
E3 logfmt latency_ms rate, production refresh 17.24 s 9.22 s 9.27 s
E4 logfmt size_bytes max by service cold 2.20 s 853 ms 800 ms
E4 logfmt size_bytes max by service warm 0 ms 0 ms 0 ms
E4 logfmt size_bytes max by service refresh 1.50 s 739 ms 695 ms
E8 json duration_ms sum by service, na cold 7.88 s 4.57 s 4.19 s
E8 json duration_ms sum by service, na warm 1 ms 0 ms 0 ms
E8 json duration_ms sum by service, na refresh 6.57 s 3.95 s 3.75 s
E9 logfmt latency_ms sum by service, n cold 10.25 s 5.78 s 5.60 s
E9 logfmt latency_ms sum by service, n warm 1 ms 1 ms 1 ms
E9 logfmt latency_ms sum by service, n refresh 11.34 s 5.73 s 5.90 s

Cold: E1 3.9 s to 1.7 s, E3 14.3 s to 8.2 s, E4 2.2 s to 0.85 s, E8 7.9 s to 4.6 s, E9 10.3 s to 5.8 s (all five have a range shorter than the 600 s step, so the gain is the window phase filter of the first commits). The 7d warm answers come from the response cache.

Load: the backend-safety test

load_safety.py shape: 32 concurrent clients loop over 80 range metric queries at 6h for 180 s on randomly shifted windows, builds in the order main, stacked branch, stacked branch, main on one stack (Loki and VictoriaLogs) seeded with 7h; a sampler records proxy RSS every 2 s and a /labels probe every 2 s. "branch 2" is the stacked extent-cache branch (it contains this one). Load average 5.7 at the start.

slot build requests 200 400 429 502 p50 p95 peak RSS RSS at end /labels probe (n, non-200, max) VL / Loki restarts before-after
0 main 958 896 50 2 10 5.419 s 17.181 s 3136.3 MB 3136.3 MB 93, 0, 1.408 s 0/0 - 0/0
1 branch 2 937 874 49 5 9 5.586 s 19.117 s 2412.0 MB 1770.5 MB 91, 0, 0.729 s 0/0 - 0/0
2 branch 2 969 902 52 5 10 5.227 s 17.558 s 2578.7 MB 2578.7 MB 94, 0, 1.628 s 0/0 - 0/0
3 main 682 639 30 5 8 6.651 s 25.213 s 3073.3 MB 3071.2 MB 88, 0, 2.291 s 0/0 - 0/0

400, 429 and 502 answers occur in the same numbers on main and on the branch, so they are not caused by it: the 400s are Loki-style pipeline errors (SampleExtractionErr, JSONParserErr) and parse errors of generated shapes, the 429s are the heavy-query guard (-backend-max-concurrent-heavy-queries=2, wait -backend-heavy-query-queue-wait=20s), and the 502s are manual range metric row limit exceeded (1000000) on the sum by (detected_level) (count_over_time(... | json ...)) shapes (a status Loki does not give; it is on main as well and untouched here). /labels answered 200 on every probe in every slot, and neither backend restarted.

Visual

bench/visual/ci_run.py --base origin/main (isolated stack, main and this branch as host proxies, Loki direct, Grafana with the pinned plugins), 29 captures: the new explore-unwrap-sum-over-time-sliding-window page (fix proof), explore-unwrap-max-over-time-end-sample, explore-unwrap-sum, explore-unwrap-quantile-over-time, explore-unwrap-rate-sum, explore-unwrap-conversion-error, the core Explore pages and the Drilldown landing, service logs and fields pages, at 15m, 1h and 6h (Loki holds 1.5h, so 6h compares main with the branch only), plus Live tail. Base 507915a5, head c538f410.

  • Fix proof, explore-unwrap-sum-over-time-sliding-window: main and the branch differ, as the fix intends: at 15m the series has 90 points on main and 901 on the branch, at 1h 360 against 721, at 6h the values differ (55,640 against 987,603 at one step). The branch's panel data is identical to Loki's at 15m and 1h; only the index/stats bytes/chunks differ, which the tool lists as explained (VictoriaLogs has no chunk accounting).
  • Every other page: main and the branch are identical (main = PR) on every request, including the Drilldown pages and the Live tail (1905 entries on the branch, Loki 434 of them in common over its 1.5h window).
  • Against Loki the differences are the tool's explained list (the _extracted suffix, sampled detected_labels cardinality, index/stats and index/volume bytes, the drilldown-limits probe) and the history-dependent patterns. One capture needs a note: explore-unwrap-conversion-error at 15m and 6h shows a request on one side only; Grafana sent the failing query (Loki's 400) twice on the branch's panel and once on main's, both answers are the same 400. It is a Grafana retry in that capture, not a different answer.
  • Montage images (main, branch and Loki side by side) are to be embedded here once hosted; the compare tables are in the visual comment.

Uptime and restarts

run load average at start VictoriaLogs / Loki restarts before to after
e2e (4 runs, each its own stack) 5.1 to 13.2 0 / 0 to 0 / 0
A/B 5.8 0 / 0 to 0 / 0 (each set)
24h 13.8 0 / 0 to 0 / 0
7d (VictoriaLogs only) 6.2 0 to 0
load, four slots 5.7 0 / 0 to 0 / 0 in every slot
visual 8.3 not restarted (stack removed after the run)

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

PR Quality Report

Compared against base branch main.

Coverage and tests

Signal Base PR Delta
Test count 15086 15614 528
Coverage 88.6% 88.6% 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 3406.0 ns/op 3403.0 ns/op -0.1% (stable)
QueryRange cache-hit memory 1240.0 B/op 1240.0 B/op 0.0% (stable)
QueryRange cache-hit allocations 27.0 allocs/op 27.0 allocs/op 0.0% (stable)
QueryRange cache-bypass CPU cost 4065.0 ns/op 4097.0 ns/op +0.8% (stable)
QueryRange cache-bypass memory 1303.0 B/op 1303.0 B/op 0.0% (stable)
QueryRange cache-bypass allocations 27.0 allocs/op 27.0 allocs/op 0.0% (stable)
Labels cache-hit CPU cost 876.1 ns/op 878.7 ns/op +0.3% (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 1455.0 ns/op 1489.0 ns/op +2.3% (stable)
Labels cache-bypass memory 399.0 B/op 400.0 B/op +0.3% (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.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

✅ Visual smoke: passed with warnings

Base 7a2f97a27a vs PR 44ca788501, 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-unwrap-sum-over-time-sliding-window 15m, explore-unwrap-sum-over-time-sliding-window 1h, explore-unwrap-sum-over-time-sliding-window 6h.
Core set: 5 capture(s) at 1h. Detailed set: 29 entries (explore-unwrap-sum-over-time-sliding-window, 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-logs-json-level, 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-B, explore-C, explore-E, explore-F, explore-logs-json, explore-logfmt-metric, explore-logs-extracted, explore-extracted-metric, explore-extracted-filter-metric, explore-extracted-group-metric, explore-logs-plain-level, explore-plain-level-metric, explore-logs-line-filter-or, explore-metric-line-filter-or, explore-unwrap-sum, explore-unwrap-unit-string, explore-unwrap-missing-label, explore-unwrap-conversion-error, explore-unwrap-rate-sum, explore-empty-pattern-filter, explore-pattern-filter-anchored, explore-unwrap-quantile-over-time, explore-json-error-filter-metric, explore-unwrap-max-over-time-end-sample, 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, dd-field-service_version, dd-field-pipeline, explore-sum-without-rate, explore-topk-sum-without, explore-sum-without-count.
Not run (capture budget): explore-sum-without-count, explore-topk-sum-without, explore-sum-without-rate, dd-field-pipeline, dd-field-service_version, explore-without-nested-under-aggregation, explore-without-over-inner-aggregation, explore-nested-aggregation-outer-by, explore-max-by-count-over-time-sliding, explore-unwrap-first-last-under-sum, explore-unwrap-max-over-time-end-sample, explore-json-error-filter-metric, explore-unwrap-quantile-over-time, explore-pattern-filter-anchored, explore-empty-pattern-filter, explore-unwrap-rate-sum, explore-unwrap-conversion-error, explore-unwrap-missing-label, explore-unwrap-unit-string, explore-unwrap-sum, explore-metric-line-filter-or, explore-logs-line-filter-or, explore-plain-level-metric, explore-logs-plain-level, explore-extracted-group-metric, explore-extracted-filter-metric, explore-extracted-metric, explore-logs-extracted, explore-logfmt-metric, explore-logs-json, explore-F, explore-E.

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/lower-bound-exclusive: no visual proof, exempt: it moves only a sample that sits exactly on a window edge, one line per window at most, which no panel at Explore's steps tells apart; proven by the window comparison over lines on every edge against
    semantics/unwrap-over-time-range-windows: 🟡 improved
page range base = PR (data) vs Loki (PR) verdict
explore-unwrap-sum-over-time-sliding-window 15m differs (1) 1 difference(s), 1 on base too 🟡 improved
explore-unwrap-sum-over-time-sliding-window 1h differs (1) 1 difference(s), 1 on base too 🟡 improved
explore-unwrap-sum-over-time-sliding-window 6h differs (1) n/a (beyond the Loki window) ⚪ undecided
explore-unwrap-sum-over-time-sliding-window-15m: improved

explore-unwrap-sum-over-time-sliding-window-15m

explore-unwrap-sum-over-time-sliding-window-1h: improved

explore-unwrap-sum-over-time-sliding-window-1h

explore-unwrap-sum-over-time-sliding-window-6h: undecided

explore-unwrap-sum-over-time-sliding-window-6h

  • semantics/unwrap-over-time-short-range-windows: no visual proof, exempt: a fix proof is captured at the ranges Loki holds in the visual stack (up to 1.5h), where Explore's auto step is a few seconds and any range a panel can show is longer than the step (semantics/unwrap-o

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) 2.16% ✅
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 1 difference(s), 1 on base too 0.04% ✅
explore-B 1h detailed ✅ identical 1 difference(s), 1 on base too 0.04% ✅
explore-C 1h detailed ✅ identical 1 difference(s), 1 on base too 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-unwrap-sum-over-time-sliding-window 15m detailed differs (1): query ('sum by (service_name) (sum_over_time({service_name=" 1 difference(s), 1 on base too 0.48% ✅ improved (closer to Loki)
explore-unwrap-sum-over-time-sliding-window 1h detailed differs (1): query ('sum by (service_name) (sum_over_time({service_name=" 1 difference(s), 1 on base too 1.20% ✅ improved (closer to Loki)
explore-unwrap-sum-over-time-sliding-window 6h detailed differs (1): query ('sum by (service_name) (sum_over_time({service_name=" n/a (beyond the Loki window) 3.65% ✅ improved (closer to Loki)
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-logs-drop-dl 1h detailed ✅ identical 2 difference(s), 2 on base too; 1 explained 0.07% ✅
lvl-explore-logs-drop-level 1h detailed ✅ identical 1 difference(s), 1 on base too; 1 explained 0.07% ✅
lvl-explore-logs-json-level 1h detailed ✅ identical identical (1 explained) 0.07% ✅
lvl-explore-logs-json-level-extracted-stored 1h detailed ✅ identical identical (1 explained) 0.07% ✅
lvl-explore-logs-keep-dl 1h detailed ✅ identical 1 difference(s), 1 on base too; 1 explained 0.07% ✅
lvl-explore-logs-level-label-stored 1h detailed ✅ identical identical (1 explained) 0.07% ✅
lvl-explore-logs-level-selector-stored 1h detailed ✅ identical identical (1 explained) 0.07% ✅
lvl-explore-logs-logfmt-level-extracted-stored 1h detailed ✅ identical identical (1 explained) 0.07% ✅
lvl-explore-logs-logfmt-level-regexp 1h detailed ✅ identical identical (1 explained) 0.07% ✅
lvl-explore-rate-errors-by-service 1h detailed ✅ identical 1 difference(s), 1 on base too; 1 explained 0.04% ✅
lvl-explore-topk-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-drop-level 1h detailed ✅ identical identical (1 explained) 0.04% ✅
lvl-explore-volume-json-level-filter 1h detailed ✅ identical identical (1 explained) 0.04% ✅
lvl-explore-volume-keep-dl 1h detailed ✅ identical identical (1 explained) 0.04% ✅
lvl-explore-volume-level-extracted-stored 1h detailed ✅ identical 1 difference(s), 1 on base too 0.04% ✅
lvl-explore-volume-level-label-stored 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 1752, Loki 582, in both 582 7.63% ✅

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 difference that did not reproduce on the recapture. Pixel differences only warn.

dd-fields-1h: history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-28T09%3A54%3
  • history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-28T09%3A54%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-28T09%3A54%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-28T09%3A54%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-28T09%3A54%3
  • history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-28T09%3A54%3A00.000Z&query=%7Bservic: body: content differs (rows 4 vs 2)
  • vs Loki, explained: resource /api/datasources/uid/*/resources/detected_fields?end=2026-10-28T09%3A54%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-28T09%3A54%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-unwrap-sum-over-time-sliding-window-6h: no Loki data at this range; the same differences match Loki at a shorter range (1): query ('sum by (service_name) (sum_o
  • no Loki data at this range; the same differences match Loki at a shorter range (1): query ('sum by (service_name) (sum_over_time({service_name="api-gateway"} | json | unwrap durati: {"service_name": "api-gateway"}: value at 1793159640000: 58719
  • pixel diff 3.6%

explore-unwrap-sum-over-time-sliding-window-6h

lvl-dd-field-pipeline-pill-1h: history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-28T09%3A54%3
  • history-dependent difference, not gated (1): resource /api/datasources/uid/*/resources/patterns?end=2026-10-28T09%3A54%3A00.000Z&query=%7Bservic: body: content differs (rows 4 vs 2)
  • vs Loki: resource /api/datasources/uid/*/resources/detected_fields?end=2026-10-28T09%3A54%3A00.000Z&query=%7: body: content differs (rows 36 vs 15)
  • vs Loki, explained: resource /api/datasources/uid/*/resources/detected_labels?end=2026-10-28T09%3A54%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-885.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

⚪ Performance A/B: no change beyond noise

171 shape×range — 171 ⚪ same · results vs Loki: 161 same, 0 new difference(s), 10 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: 171 shape×range; proxy flag base off, pr off

Every shape is within noise of the base build (±30% and 100 ms).

171 unchanged shape×range (within noise)
shape range Loki profile base p50 PR p50 change Loki p50 PR ÷ Loki result vs Loki
⚪ bytes_over_time by app 1h defaults 17 ms 10 ms ⚪ -41% 94 ms 0.11× ✅ same
⚪ drilldown service landing 1h defaults 8 ms 7 ms ⚪ -12% 44 ms 0.16× ✅ same
⚪ instant json filtered instant defaults 8 ms 7 ms ⚪ -12% 72 ms 0.10× ✅ same
⚪ instant json volume instant defaults 23 ms 23 ms ⚪ +0% 95 ms 0.24× ✅ same
⚪ instant ungrouped rate instant defaults 6 ms 5 ms ⚪ -17% 74 ms 0.07× ✅ same
⚪ json rate by level (no filter, no drop) 1h defaults 400 400 ⚪ -33% 400 — ✅ same
⚪ json volume no filters 1h defaults 32 ms 31 ms ⚪ -3% 82 ms 0.38× ✅ same
⚪ logfmt count by level, filter, drop 1h defaults 11 ms 10 ms ⚪ -9% 64 ms 0.16× ✅ same
⚪ logfmt volume 1h defaults 69 ms 69 ms ⚪ +0% 71 ms 0.97× ✅ same
⚪ logs plain 1h defaults 60 ms 59 ms ⚪ -2% 436 ms 0.14× ✅ same
⚪ logs | json 1h defaults 114 ms 109 ms ⚪ -4% 2.01 s 0.05× ✅ same
⚪ logs | logfmt 1h defaults 122 ms 147 ms ⚪ +20% 78 ms 1.88× ✅ same
⚪ stream-label count by app 1h defaults 8 ms 7 ms ⚪ -12% 54 ms 0.13× ✅ same
⚪ topk rate by app 1h defaults 36 ms 39 ms ⚪ +8% 93 ms 0.42× ➖ differs (as base)
⚪ ungrouped rate 1h defaults 5 ms 4 ms ⚪ -20% 104 ms 0.04× ✅ same
⚪ DB3 json field breakdown user_id 1h defaults 7 ms 7 ms ⚪ +0% 63 ms 0.11× ✅ same
⚪ DB4 json field breakdown batch_id 1h defaults 43 ms 38 ms ⚪ -12% 66 ms 0.58× ✅ same
⚪ X1 json filter level_extracted, by level 1h defaults 97 ms 87 ms ⚪ -10% 51 ms 1.71× ✅ same
⚪ X2 json group by level_extracted 1h defaults 10 ms 9 ms ⚪ -10% 50 ms 0.18× ✅ same
⚪ X3 json rate, level_extracted equality 1h defaults 8 ms 8 ms ⚪ +0% 46 ms 0.17× ✅ same
⚪ X4 json bytes, level_extracted regexp 1h defaults 90 ms 92 ms ⚪ +2% 90 ms 1.02× ✅ same
⚪ X5 logfmt filter level_extracted, by level 1h defaults 16 ms 15 ms ⚪ -6% 57 ms 0.26× ✅ same
⚪ E1 logs, json parser over stream labels 1h defaults 93 ms 77 ms ⚪ -17% 52 ms 1.48× ✅ same
⚪ E2 logs, logfmt parser over stream labels 1h defaults 68 ms 51 ms ⚪ -25% 41 ms 1.24× ✅ same
⚪ E3 logs, json, existence filter on level_extracted 1h defaults 119 ms 110 ms ⚪ -8% 67 ms 1.64× ✅ same
⚪ E4 logs, logfmt, existence filter on level_extracted 1h defaults 70 ms 71 ms ⚪ +1% 48 ms 1.48× ✅ same
⚪ E5 logs, json, drop level_extracted 1h defaults 89 ms 93 ms ⚪ +4% 53 ms 1.75× ✅ same
⚪ E6 logfmt count by level_extracted 1h defaults 5 ms 5 ms ⚪ +0% 7 ms 0.71× ✅ same
⚪ E7 json count by level_extracted 1h defaults 62 ms 60 ms ⚪ -3% 16 ms 3.75× ✅ same
⚪ E8 instant logfmt by level_extracted instant defaults 4 ms 4 ms ⚪ +0% 7 ms 0.57× ✅ same
⚪ J1 json no-error count by service_name, one service 1h defaults 18 ms 16 ms ⚪ -11% 27 ms 0.59× ✅ same
⚪ J2 json no-error count by app, namespace 1h defaults 41 ms 38 ms ⚪ -7% 91 ms 0.42× ✅ same
⚪ J3 json no-error count by parsed method 1h defaults 32 ms 32 ms ⚪ +0% 25 ms 1.28× ✅ same
⚪ J4 json no-error count by service_name, all services 1h defaults 23 ms 22 ms ⚪ -4% 113 ms 0.19× ✅ same
⚪ J5 json no-error bytes_rate by app 1h defaults 12 ms 11 ms ⚪ -8% 69 ms 0.16× ✅ same
⚪ A explore volume, 2 filters, drop 1h defaults 82 ms 77 ms ⚪ -6% 95 ms 0.81× ✅ same
⚪ B grouped sum, filter, no drop 1h defaults 60 ms 57 ms ⚪ -5% 77 ms 0.74× ✅ same
⚪ C ungrouped sum, filter, no drop 1h defaults 59 ms 58 ms ⚪ -2% 75 ms 0.77× ✅ same
⚪ D volume, no filters 1h defaults 69 ms 65 ms ⚪ -6% 109 ms 0.60× ✅ same
⚪ E field breakdown pipeline (explore) 1h defaults 57 ms 57 ms ⚪ +0% 77 ms 0.74× ✅ same
⚪ F field breakdown service_version (explore) 1h defaults 84 ms 76 ms ⚪ -10% 89 ms 0.85× ✅ same
⚪ G field breakdown pipeline (drilldown) 1h defaults 7 ms 8 ms ⚪ +14% 72 ms 0.11× ✅ same
⚪ H field breakdown service_version (drilldown) 1h defaults 42 ms 43 ms ⚪ +2% 76 ms 0.57× ✅ same
⚪ I grafana volume of B 1h defaults 58 ms 58 ms ⚪ +0% 81 ms 0.72× ✅ same
⚪ LF13 explore volume, level filter before json 1h defaults 30 ms 31 ms ⚪ +3% 52 ms 0.60× ✅ same
⚪ LF15 drilldown field breakdown, level filter 1h defaults 6 ms 6 ms ⚪ +0% 9 ms 0.67× ✅ same
⚪ LF16 drilldown label breakdown, level filter 1h defaults 34 ms 33 ms ⚪ -3% 7 ms 4.71× ✅ same
⚪ LF20 logfmt count by detected_level 1h defaults 10 ms 9 ms ⚪ -10% 9 ms 1.00× ✅ same
⚪ LF21 logfmt count by detected_level, level filter 1h defaults 6 ms 5 ms ⚪ -17% 7 ms 0.71× ✅ same
⚪ LF23 json count by level, level filter 1h defaults 30 ms 31 ms ⚪ +3% 13 ms 2.38× ✅ same
⚪ LF24 rate of errors by service 1h defaults 14 ms 11 ms ⚪ -21% 87 ms 0.13× ➖ differs (as base)
⚪ LF25 count of warnings, error label dropped 1h defaults 13 ms 12 ms ⚪ -8% 43 ms 0.28× ➖ differs (as base)
⚪ LF26 topk services by errors 1h defaults 826 ms 646 ms ⚪ -22% 67 ms 9.64× ➖ differs (as base)
⚪ LF28 logs, keep detected_level 1h defaults 39 ms 41 ms ⚪ +5% 14 ms 2.93× ✅ same
⚪ LF29 logs, drop detected_level 1h defaults 62 ms 65 ms ⚪ +5% 15 ms 4.33× ✅ same
⚪ LF30 logs, drop level 1h defaults 58 ms 63 ms ⚪ +9% 15 ms 4.20× ✅ same
⚪ LF31 count by detected_level, level dropped 1h defaults 10 ms 10 ms ⚪ +0% 13 ms 0.77× ✅ same
⚪ LF32 count by detected_level, detected_level kept 1h defaults 7 ms 6 ms ⚪ -14% 12 ms 0.50× ✅ same
⚪ LF7 logs, json then level equals 1h defaults 122 ms 116 ms ⚪ -5% 149 ms 0.78× ✅ same
⚪ LF8 logs, logfmt then level regexp 1h defaults 49 ms 56 ms ⚪ +14% 61 ms 0.92× ✅ same
⚪ LS1 logs, level stream label 1h defaults 41 ms 42 ms ⚪ +2% 58 ms 0.72× ✅ same
⚪ LS2 logs, level stream label regexp, all services 1h defaults 69 ms 70 ms ⚪ +1% 55 ms 1.27× ✅ same
⚪ LS3 logs, level in the selector 1h defaults 35 ms 34 ms ⚪ -3% 27 ms 1.26× ✅ same
⚪ LS4 logs, json then level_extracted 1h defaults 142 ms 141 ms ⚪ -1% 137 ms 1.03× ✅ same
⚪ LS5 logs, logfmt then level_extracted regexp 1h defaults 59 ms 58 ms ⚪ -2% 50 ms 1.16× ✅ same
⚪ LS6 level stream label count 1h defaults 5 ms 4 ms ⚪ -20% 42 ms 0.10× ✅ same
⚪ LS7 rate of level stream label by service 1h defaults 7 ms 6 ms ⚪ -14% 86 ms 0.07× ✅ same
⚪ LS8 json count by level_extracted, collision 1h defaults 59 ms 60 ms ⚪ +2% 13 ms 4.62× ✅ same
⚪ O2 explore log volume (Grafana form), underscore structured-metadata filter 1h defaults 33 ms 33 ms ⚪ +0% 64 ms 0.52× ✅ same
⚪ DL1 drilldown field breakdown json-expr, level filter 1h defaults 13 ms 12 ms ⚪ -8% 40 ms 0.30× ✅ same
⚪ DL2 drilldown field breakdown, level + stream-label filter before json 1h defaults 31 ms 30 ms ⚪ -3% 7 ms 4.29× ✅ same
⚪ DL3 drilldown logfmt field breakdown, level filter 1h defaults 114 ms 125 ms ⚪ +10% 98 ms 1.28× ➖ differs (as base)
⚪ DL4 instant count, level filter, json+logfmt, 1h instant defaults 389 ms 381 ms ⚪ -2% 978 ms 0.39× ✅ sum ±0.25%
⚪ DL6 explore volume, level filter before json 1h defaults 37 ms 32 ms ⚪ -14% 70 ms 0.46× ✅ same
⚪ N1 sum by app of max by app,pod, count 1h defaults 6 ms 6 ms ⚪ +0% 55 ms 0.11× ✅ same
⚪ N10 instant min of avg, rate instant defaults 6 ms 6 ms ⚪ +0% 67 ms 0.09× ✅ same
⚪ N11 instant three levels instant defaults 5 ms 5 ms ⚪ +0% 54 ms 0.09× ✅ same
⚪ N12 instant json inner, count of sum instant defaults 12 ms 13 ms ⚪ +8% 64 ms 0.20× ✅ same
⚪ N13 instant without under by instant defaults 14 ms 14 ms ⚪ +0% 67 ms 0.21× ✅ same
⚪ N14 sliding sum of max 1h defaults 481 ms 385 ms ⚪ -20% 99 ms 3.89× ✅ same
⚪ N15 sliding sums only 1h defaults 8 ms 8 ms ⚪ +0% 62 ms 0.13× ✅ same
⚪ N16 innermost without under by, tumbling 1h defaults 7 ms 6 ms ⚪ -14% 37 ms 0.16× ✅ same
⚪ N2 max by namespace of sum by namespace,app, rate 1h defaults 4 ms 4 ms ⚪ +0% 28 ms 0.14× ✅ same
⚪ N3 count by namespace of sum by namespace,app 1h defaults 3 ms 3 ms ⚪ +0% 29 ms 0.10× ✅ same
⚪ N4 avg by app of min by app,pod 1h defaults 6 ms 6 ms ⚪ +0% 33 ms 0.18× ✅ same
⚪ N5 three levels, max min sum 1h defaults 3 ms 3 ms ⚪ +0% 29 ms 0.10× ✅ same
⚪ N6 json inner, sum of max 1h defaults 9 ms 6 ms ⚪ -33% 33 ms 0.18× ✅ same
⚪ N7 logfmt inner, max of sum 1h defaults 3 ms 3 ms ⚪ +0% 9 ms 0.33× ✅ same
⚪ N8 without over a named by level, tumbling 1h defaults 6 ms 5 ms ⚪ -17% 34 ms 0.15× ✅ same
⚪ N9 instant sum of max instant defaults 9 ms 6 ms ⚪ -33% 118 ms 0.05× ✅ same
⚪ OR1 logs, or alternatives 1h defaults 71 ms 63 ms ⚪ -11% 64 ms 0.98× ✅ same
⚪ OR2 logs, negated or alternatives 1h defaults 88 ms 80 ms ⚪ -9% 50 ms 1.60× ✅ same
⚪ OR3 logs, regexp or alternatives 1h defaults 55 ms 53 ms ⚪ -4% 65 ms 0.82× ✅ same
⚪ OR4 count by level, or alternatives with parser 1h defaults 32 ms 32 ms ⚪ +0% 45 ms 0.71× ✅ same
⚪ OR5 rate, negated regexp or alternatives 1h defaults 5 ms 5 ms ⚪ +0% 88 ms 0.06× ✅ same
⚪ OR6 instant, or alternatives instant defaults 6 ms 6 ms ⚪ +0% 61 ms 0.10× ✅ same
⚪ P1 api-gateway empty pattern 1h defaults 25 ms 25 ms ⚪ +0% 15 ms 1.67× ✅ same
⚪ P2 api-gateway not empty pattern, line filter 1h defaults 103 ms 87 ms ⚪ -16% 23 ms 3.78× ✅ same
⚪ P3 nginx count_over_time, pattern with spaces 1h defaults 5 ms 4 ms ⚪ -20% 5 ms 0.80× ✅ same
⚪ P4 nginx count_over_time, GET request pattern 1h defaults 5 ms 5 ms ⚪ +0% 4 ms 1.25× ✅ same
⚪ A1 nginx include GET, log query 1h defaults 59 ms 53 ms ⚪ -10% 9 ms 5.89× ✅ same
⚪ A2 nginx exclude GET, log query 1h defaults 30 ms 24 ms ⚪ -20% 9 ms 2.67× ✅ same
⚪ A3 nginx include GET, count_over_time 1h defaults 5 ms 4 ms ⚪ -20% 4 ms 1.00× ✅ same
⚪ A4 nginx exclude GET, count_over_time 1h defaults 5 ms 4 ms ⚪ -20% 4 ms 1.00× ✅ same
⚪ A5 drilldown exclude, volume by detected_level 1h defaults 9 ms 9 ms ⚪ +0% 4 ms 2.25× ✅ same
⚪ A6 nginx include ends on a literal, log query 1h defaults 13 ms 12 ms ⚪ -8% 4 ms 3.00× ✅ same
⚪ A7 nginx exclude ends on a literal, log query 1h defaults 23 ms 22 ms ⚪ -4% 10 ms 2.20× ✅ same
⚪ A8 nginx include two literals, count_over_time 1h defaults 5 ms 5 ms ⚪ +0% 4 ms 1.25× ✅ same
⚪ A9 nginx include two literals, ends on a literal, count_over_time 1h defaults 4 ms 4 ms ⚪ +0% 3 ms 1.33× ✅ same
⚪ P1 logs, json, level equals 1h defaults 150 ms 138 ms ⚪ -8% 73 ms 1.89× ✅ same
⚪ P2 logs, logfmt, level not equal 1h defaults 31 ms 32 ms ⚪ +3% 6 ms 5.33× ✅ same
⚪ P3 logs, json, level existence 1h defaults 104 ms 102 ms ⚪ -2% 53 ms 1.92× ✅ same
⚪ P4 logfmt count by level 1h defaults 5 ms 5 ms ⚪ +0% 6 ms 0.83× ✅ same
⚪ P5 json count by level 1h defaults 31 ms 31 ms ⚪ +0% 16 ms 1.94× ✅ same
⚪ P6 json count, level filter 1h defaults 4 ms 4 ms ⚪ +0% 12 ms 0.33× ✅ same
⚪ P7 json rate by level, level regexp 1h defaults 5 ms 4 ms ⚪ -20% 14 ms 0.29× ✅ same
⚪ P8 json count by a name that is no stream label 1h defaults 34 ms 31 ms ⚪ -9% 15 ms 2.07× ✅ same
⚪ P9 instant logfmt by level instant defaults 5 ms 5 ms ⚪ +0% 7 ms 0.71× ✅ same
⚪ E1 json duration_ms sum by service 1h defaults 77 ms 67 ms ⚪ -13% 20 ms 3.35× ✅ same
⚪ E2 json duration_ms p95 by service 1h defaults 210 ms 203 ms ⚪ -3% 18 ms 11× ✅ same
⚪ E3 logfmt latency_ms rate, production 1h defaults 391 ms 384 ms ⚪ -2% 116 ms 3.31× ➖ differs (as base)
⚪ E4 logfmt size_bytes max by service 1h defaults 39 ms 36 ms ⚪ -8% 14 ms 2.57× ✅ same
⚪ E5 json duration_ms sum, instant instant defaults 24 ms 24 ms ⚪ +0% 25 ms 0.96× ✅ same
⚪ E6 logfmt ttl unit string (Loki 400) 1h defaults 400 400 ⚪ -4% 400 — ✅ same
⚪ E7 json hexadecimal trace_id (Loki 400) 1h defaults 400 400 ⚪ -2% 400 — ✅ same
⚪ E8 json duration_ms sum by service, namespace prod 1h defaults 143 ms 155 ms ⚪ +8% 88 ms 1.76× ✅ same
⚪ E9 logfmt latency_ms sum by service, namespace prod 1h defaults 237 ms 253 ms ⚪ +7% 85 ms 2.98× ✅ same
⚪ G1 logfmt amount sum_over_time, no filter 1h defaults 27 ms 30 ms ⚪ +11% 13 ms 2.31× ✅ same
⚪ G10 logfmt latency_ms sum_over_time, production 1h defaults 378 ms 381 ms ⚪ +1% 139 ms 2.74× ➖ differs (as base)
⚪ G11 logfmt amount sum_over_time, instant instant defaults 6 ms 6 ms ⚪ +0% 10 ms 0.60× ✅ same
⚪ G12 json duration_ms sum_over_time, instant instant defaults 23 ms 22 ms ⚪ -4% 23 ms 0.96× ✅ same
⚪ G2 json duration_ms sum_over_time, no filter 1h defaults 63 ms 67 ms ⚪ +6% 29 ms 2.31× ✅ same
⚪ G3 logfmt amount sum_over_time, error filter 1h defaults 28 ms 30 ms ⚪ +7% 11 ms 2.73× ✅ same
⚪ G4 json duration_ms sum_over_time, error filter 1h defaults 61 ms 67 ms ⚪ +10% 26 ms 2.58× ✅ same
⚪ G5 logfmt amount sum by rate 1h defaults 32 ms 30 ms ⚪ -6% 11 ms 2.73× ✅ same
⚪ G6 json duration_ms sum by rate 1h defaults 74 ms 109 ms ⚪ +47% 27 ms 4.04× ✅ same
⚪ G7 duration() of a unit string 1h defaults 46 ms 50 ms ⚪ +9% 9 ms 5.56× ✅ same
⚪ G8 bytes() of a number 1h defaults 48 ms 48 ms ⚪ +0% 8 ms 6.00× ✅ same
⚪ G9 json duration_ms max_over_time 1h defaults 69 ms 69 ms ⚪ +0% 105 ms 0.66× ✅ same
⚪ W1 sum without pod, max_over_time 1h defaults 34 ms 54 ms ⚪ +59% 16 ms 3.38× ✅ same
⚪ W2 sum without pod, sum_over_time 1h defaults 32 ms 54 ms ⚪ +69% 14 ms 3.86× ✅ same
⚪ W3 sum without pod, rate 1h defaults 67 ms 63 ms ⚪ -6% 14 ms 4.50× ✅ same
⚪ W4 max without pod, max_over_time 1h defaults 34 ms 57 ms ⚪ +68% 15 ms 3.80× ✅ same
⚪ W5 sum without pod, max_over_time, instant instant defaults 9 ms 8 ms ⚪ -11% 12 ms 0.67× ✅ same
⚪ W6 sum without pod, rate, instant instant defaults 8 ms 9 ms ⚪ +12% 12 ms 0.75× ✅ same
⚪ W7 topk 5 of sum without pod 1h defaults 33 ms 55 ms ⚪ +67% 16 ms 3.44× ✅ same
⚪ W8 sum without pod, count_over_time (production) 1h defaults 275 ms 234 ms ⚪ -15% 59 ms 3.97× ✅ same
⚪ W9 label_replace control, instant instant defaults 7 ms 6 ms ⚪ -14% 70 ms 0.09× ✅ same
⚪ U1 logfmt amount rate 1h defaults 29 ms 28 ms ⚪ -3% 13 ms 2.15× ✅ same
⚪ U2 json duration_ms rate 1h defaults 64 ms 63 ms ⚪ -2% 26 ms 2.42× ✅ same
⚪ U3 json duration_ms rate, error filter 1h defaults 65 ms 64 ms ⚪ -2% 24 ms 2.67× ✅ same
⚪ U4 logfmt latency_ms rate, production 1h defaults 269 ms 287 ms ⚪ +7% 110 ms 2.61× ➖ differs (as base)
⚪ U5 logfmt amount rate, instant instant defaults 6 ms 6 ms ⚪ +0% 10 ms 0.60× ✅ same
⚪ U6 json duration_ms rate, instant instant defaults 9 ms 8 ms ⚪ -11% 20 ms 0.40× ✅ same
⚪ U7 json duration_ms sum_over_time, instant instant defaults 17 ms 10 ms ⚪ -41% 20 ms 0.50× ✅ same
⚪ U8 logfmt amount rate by service (grouped) 1h defaults 34 ms 32 ms ⚪ -6% 12 ms 2.67× ✅ same
⚪ U9 logfmt amount rate_counter (folded per series) 1h defaults 176 ms 185 ms ⚪ +5% 33 ms 5.61× ➖ differs (as base)
⚪ U1 json duration_ms sum by service 1h defaults 83 ms 97 ms ⚪ +17% 19 ms 5.11× ✅ same
⚪ U10 instant sum_over_time unwrap instant defaults 24 ms 25 ms ⚪ +4% 25 ms 1.00× ✅ same
⚪ U11 instant max_over_time missing label instant defaults 5 ms 5 ms ⚪ +0% 12 ms 0.42× ✅ same
⚪ U12 json duration_ms over every service 1h defaults 193 ms 199 ms ⚪ +3% 52 ms 3.83× ✅ same
⚪ U13 logfmt latency_ms over every service 1h defaults 404 ms 376 ms ⚪ -7% 62 ms 6.06× ➖ differs (as base)
⚪ U14 duration() of a unit string 1h defaults 43 ms 44 ms ⚪ +2% 6 ms 7.33× ✅ same
⚪ U2 json duration_ms max by service 1h defaults 74 ms 63 ms ⚪ -15% 37 ms 1.70× ✅ same
⚪ U3 json duration_ms sliding sum 1h defaults 61 ms 68 ms ⚪ +11% 29 ms 2.34× ✅ same
⚪ U4 logfmt size_bytes sum by service 1h defaults 34 ms 34 ms ⚪ +0% 7 ms 4.86× ✅ same
⚪ U5 logfmt ttl unit string (no sample) 1h defaults 33 ms 32 ms ⚪ -3% 6 ms 5.33× ✅ same
⚪ U6 missing label, no parser 1h defaults 16 ms 12 ms ⚪ -25% 11 ms 1.09× ✅ same
⚪ U7 missing label, ungrouped sum 1h defaults 14 ms 10 ms ⚪ -29% 11 ms 0.91× ✅ same
⚪ U8 rate of an unwrapped label 1h defaults 66 ms 63 ms ⚪ -5% 15 ms 4.20× ✅ same
⚪ U9 bytes() conversion 1h defaults 43 ms 45 ms ⚪ +5% 11 ms 4.09× ✅ 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
bytes_over_time by app 1h defaults 50 ms 46 ms 632 ms 0.07×
drilldown service landing 1h defaults 24 ms 28 ms 494 ms 0.06×
instant json filtered instant defaults 7 ms 7 ms 71 ms 0.10×
instant json volume instant defaults 23 ms 22 ms 92 ms 0.24×
instant ungrouped rate instant defaults 6 ms 5 ms 74 ms 0.07×
json rate by level (no filter, no drop) 1h defaults 400 400 400 —
json volume no filters 1h defaults 42 ms 39 ms 661 ms ↺ 0.06×
logfmt count by level, filter, drop 1h defaults 350 ms 340 ms 811 ms 0.42×
logfmt volume 1h defaults 163 ms 179 ms 1.09 s 0.16×
logs plain 1h defaults 69 ms 66 ms 460 ms 0.14×
logs | json 1h defaults 79 ms 86 ms 1.66 s 0.05×
logs | logfmt 1h defaults 119 ms 139 ms 85 ms 1.64×
stream-label count by app 1h defaults 16 ms 15 ms 513 ms 0.03×
topk rate by app 1h defaults 36 ms 33 ms 570 ms 0.06×
ungrouped rate 1h defaults 10 ms 10 ms 515 ms 0.02×
DB3 json field breakdown user_id 1h defaults 46 ms 35 ms 479 ms 0.07×
DB4 json field breakdown batch_id 1h defaults 76 ms 76 ms 580 ms 0.13×
X1 json filter level_extracted, by level 1h defaults 2.48 s 2.45 s 597 ms 4.10×
X2 json group by level_extracted 1h defaults 225 ms 221 ms 816 ms 0.27×
X3 json rate, level_extracted equality 1h defaults 203 ms 201 ms 450 ms 0.45×
X4 json bytes, level_extracted regexp 1h defaults 319 ms 303 ms 519 ms 0.58×
X5 logfmt filter level_extracted, by level 1h defaults 505 ms 501 ms 748 ms 0.67×
E1 logs, json parser over stream labels 1h defaults 99 ms 99 ms 58 ms † —
E2 logs, logfmt parser over stream labels 1h defaults 61 ms 50 ms 43 ms † —
E3 logs, json, existence filter on level_extracted 1h defaults 106 ms 118 ms 57 ms † —
E4 logs, logfmt, existence filter on level_extracted 1h defaults 61 ms 78 ms 42 ms † —
E5 logs, json, drop level_extracted 1h defaults 90 ms 85 ms 50 ms † —
E6 logfmt count by level_extracted 1h defaults 34 ms 33 ms 42 ms 0.79×
E7 json count by level_extracted 1h defaults 972 ms 985 ms 172 ms 5.73×
E8 instant logfmt by level_extracted instant defaults 4 ms 5 ms 7 ms 0.71×
J1 json no-error count by service_name, one service 1h defaults 139 ms 119 ms 183 ms 0.65×
J2 json no-error count by app, namespace 1h defaults 350 ms 314 ms 720 ms 0.44×
J3 json no-error count by parsed method 1h defaults 109 ms 118 ms 186 ms 0.63×
J4 json no-error count by service_name, all services 1h defaults 374 ms 374 ms 738 ms 0.51×
J5 json no-error bytes_rate by app 1h defaults 221 ms 212 ms 534 ms 0.40×
A explore volume, 2 filters, drop 1h defaults 1.89 s 1.14 s 392 ms 2.90×
B grouped sum, filter, no drop 1h defaults 101 ms 89 ms 358 ms 0.25×
C ungrouped sum, filter, no drop 1h defaults 90 ms 95 ms 373 ms 0.25×
D volume, no filters 1h defaults 151 ms 153 ms 661 ms 0.23×
E field breakdown pipeline (explore) 1h defaults 79 ms 78 ms 374 ms 0.21×
F field breakdown service_version (explore) 1h defaults 1.01 s 1.01 s 700 ms 1.44×
G field breakdown pipeline (drilldown) 1h defaults 36 ms 34 ms 368 ms 0.09×
H field breakdown service_version (drilldown) 1h defaults 72 ms 67 ms 442 ms 0.15×
I grafana volume of B 1h defaults 82 ms 82 ms 366 ms 0.22×
LF13 explore volume, level filter before json 1h defaults 88 ms 78 ms 286 ms 0.27×
LF15 drilldown field breakdown, level filter 1h defaults 72 ms 76 ms 55 ms 1.38×
LF16 drilldown label breakdown, level filter 1h defaults 40 ms 44 ms 26 ms 1.69×
LF20 logfmt count by detected_level 1h defaults 53 ms 38 ms 32 ms 1.19×
LF21 logfmt count by detected_level, level filter 1h defaults 33 ms 25 ms 32 ms 0.78×
LF23 json count by level, level filter 1h defaults 40 ms 39 ms 81 ms 0.48×
LF24 rate of errors by service 1h defaults 335 ms 332 ms 321 ms 1.03×
LF25 count of warnings, error label dropped 1h defaults 516 ms 335 ms 293 ms 1.14×
LF26 topk services by errors 1h defaults 638 ms 659 ms 298 ms 2.21×
LF28 logs, keep detected_level 1h defaults 34 ms 42 ms 15 ms † —
LF29 logs, drop detected_level 1h defaults 60 ms 61 ms 15 ms 4.07×
LF30 logs, drop level 1h defaults 68 ms 65 ms 15 ms 4.33×
LF31 count by detected_level, level dropped 1h defaults 33 ms 32 ms 65 ms 0.49×
LF32 count by detected_level, detected_level kept 1h defaults 20 ms 19 ms 82 ms 0.23×
LF7 logs, json then level equals 1h defaults 157 ms 115 ms 145 ms † —
LF8 logs, logfmt then level regexp 1h defaults 54 ms 58 ms 66 ms † —
LS1 logs, level stream label 1h defaults 58 ms 40 ms 56 ms 0.71×
LS2 logs, level stream label regexp, all services 1h defaults 71 ms 69 ms 59 ms 1.17×
LS3 logs, level in the selector 1h defaults 35 ms 32 ms 28 ms 1.14×
LS4 logs, json then level_extracted 1h defaults 143 ms 166 ms 145 ms † —
LS5 logs, logfmt then level_extracted regexp 1h defaults 72 ms 71 ms 54 ms † —
LS6 level stream label count 1h defaults 7 ms 7 ms 324 ms 0.02×
LS7 rate of level stream label by service 1h defaults 14 ms 13 ms 386 ms 0.03×
LS8 json count by level_extracted, collision 1h defaults 992 ms 968 ms 112 ms 8.64×
O2 explore log volume (Grafana form), underscore structured-metadata filter 1h defaults 122 ms 121 ms 549 ms 0.22×
DL1 drilldown field breakdown json-expr, level filter 1h defaults 373 ms 383 ms 336 ms 1.14×
DL2 drilldown field breakdown, level + stream-label filter before json 1h defaults 41 ms 40 ms 20 ms 2.00×
DL3 drilldown logfmt field breakdown, level filter 1h defaults 520 ms 503 ms 575 ms 0.87×
DL4 instant count, level filter, json+logfmt, 1h instant defaults 382 ms 399 ms 1.02 s 0.39×
DL6 explore volume, level filter before json 1h defaults 151 ms 145 ms 631 ms 0.23×
N1 sum by app of max by app,pod, count 1h defaults 57 ms 35 ms 542 ms 0.06×
N10 instant min of avg, rate instant defaults 7 ms 7 ms 77 ms 0.09×
N11 instant three levels instant defaults 10 ms 5 ms 54 ms 0.09×
N12 instant json inner, count of sum instant defaults 14 ms 19 ms 64 ms † —
N13 instant without under by instant defaults 14 ms 19 ms 75 ms 0.25×
N14 sliding sum of max 1h defaults 492 ms 447 ms 1.27 s 0.35×
N15 sliding sums only 1h defaults 18 ms 18 ms 406 ms 0.04×
N16 innermost without under by, tumbling 1h defaults 44 ms 40 ms 700 ms 0.06×
N2 max by namespace of sum by namespace,app, rate 1h defaults 30 ms 24 ms 454 ms 0.05×
N3 count by namespace of sum by namespace,app 1h defaults 13 ms 16 ms 366 ms † —
N4 avg by app of min by app,pod 1h defaults 48 ms 42 ms 598 ms 0.07×
N5 three levels, max min sum 1h defaults 14 ms 13 ms 380 ms 0.03×
N6 json inner, sum of max 1h defaults 119 ms 103 ms 523 ms 0.20×
N7 logfmt inner, max of sum 1h defaults 16 ms 17 ms 59 ms 0.29×
N8 without over a named by level, tumbling 1h defaults 46 ms 35 ms 633 ms 0.06×
N9 instant sum of max instant defaults 6 ms 6 ms 64 ms 0.09×
OR1 logs, or alternatives 1h defaults 66 ms 68 ms 83 ms 0.82×
OR2 logs, negated or alternatives 1h defaults 75 ms 76 ms 52 ms 1.46×
OR3 logs, regexp or alternatives 1h defaults 58 ms 47 ms 63 ms 0.75×
OR4 count by level, or alternatives with parser 1h defaults 93 ms 92 ms 373 ms 0.25×
OR5 rate, negated regexp or alternatives 1h defaults 41 ms 42 ms 898 ms 0.05×
OR6 instant, or alternatives instant defaults 7 ms 6 ms 63 ms 0.10×
P1 api-gateway empty pattern 1h defaults 32 ms 25 ms 59 ms † —
P2 api-gateway not empty pattern, line filter 1h defaults 85 ms 91 ms 25 ms 3.64×
P3 nginx count_over_time, pattern with spaces 1h defaults 15 ms 14 ms 19 ms † —
P4 nginx count_over_time, GET request pattern 1h defaults 16 ms 14 ms 19 ms 0.74×
A1 nginx include GET, log query 1h defaults 38 ms 30 ms 10 ms 3.00×
A2 nginx exclude GET, log query 1h defaults 19 ms 19 ms 10 ms † —
A3 nginx include GET, count_over_time 1h defaults 7 ms 6 ms 19 ms ↺ 0.32×
A4 nginx exclude GET, count_over_time 1h defaults 14 ms 14 ms 25 ms 0.56×
A5 drilldown exclude, volume by detected_level 1h defaults 33 ms 30 ms 21 ms 1.43×
A6 nginx include ends on a literal, log query 1h defaults 13 ms 13 ms 16 ms † —
A7 nginx exclude ends on a literal, log query 1h defaults 24 ms 20 ms 10 ms † —
A8 nginx include two literals, count_over_time 1h defaults 18 ms 17 ms 20 ms 0.85×
A9 nginx include two literals, ends on a literal, count_over_time 1h defaults 16 ms 17 ms 20 ms † —
P1 logs, json, level equals 1h defaults 154 ms 153 ms 83 ms † —
P2 logs, logfmt, level not equal 1h defaults 31 ms 30 ms 30 ms † —
P3 logs, json, level existence 1h defaults 97 ms 97 ms 64 ms † —
P4 logfmt count by level 1h defaults 22 ms 23 ms 26 ms 0.88×
P5 json count by level 1h defaults 84 ms 67 ms 106 ms 0.63×
P6 json count, level filter 1h defaults 11 ms 11 ms 92 ms 0.12×
P7 json rate by level, level regexp 1h defaults 24 ms 12 ms 111 ms 0.11×
P8 json count by a name that is no stream label 1h defaults 66 ms 68 ms 90 ms 0.76×
P9 instant logfmt by level instant defaults 5 ms 5 ms 7 ms 0.71×
E1 json duration_ms sum by service 1h defaults 69 ms 64 ms 160 ms 0.40×
E2 json duration_ms p95 by service 1h defaults 201 ms 193 ms 136 ms 1.42×
E3 logfmt latency_ms rate, production 1h defaults 387 ms 417 ms 622 ms 0.67×
E4 logfmt size_bytes max by service 1h defaults 37 ms 34 ms 184 ms 0.18×
E5 json duration_ms sum, instant instant defaults 24 ms 31 ms 25 ms 1.24×
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 138 ms 149 ms 577 ms 0.26×
E9 logfmt latency_ms sum by service, namespace prod 1h defaults 232 ms 253 ms 469 ms 0.54×
G1 logfmt amount sum_over_time, no filter 1h defaults 36 ms 31 ms 52 ms 0.60×
G10 logfmt latency_ms sum_over_time, production 1h defaults 354 ms 398 ms 776 ms 0.51×
G11 logfmt amount sum_over_time, instant instant defaults 6 ms 6 ms 10 ms 0.60×
G12 json duration_ms sum_over_time, instant instant defaults 28 ms 22 ms 23 ms 0.96×
G2 json duration_ms sum_over_time, no filter 1h defaults 62 ms 66 ms 151 ms 0.44×
G3 logfmt amount sum_over_time, error filter 1h defaults 29 ms 30 ms 51 ms 0.59×
G4 json duration_ms sum_over_time, error filter 1h defaults 61 ms 66 ms 151 ms 0.44×
G5 logfmt amount sum by rate 1h defaults 30 ms 31 ms 41 ms ↺ 0.76×
G6 json duration_ms sum by rate 1h defaults 68 ms 72 ms 175 ms 0.41×
G7 duration() of a unit string 1h defaults 47 ms 49 ms 40 ms 1.23×
G8 bytes() of a number 1h defaults 46 ms 50 ms 34 ms 1.47×
G9 json duration_ms max_over_time 1h defaults 73 ms 70 ms 1.29 s 0.05×
W1 sum without pod, max_over_time 1h defaults 40 ms 67 ms 94 ms 0.71×
W2 sum without pod, sum_over_time 1h defaults 32 ms 54 ms 79 ms 0.68×
W3 sum without pod, rate 1h defaults 72 ms 76 ms 78 ms 0.97×
W4 max without pod, max_over_time 1h defaults 42 ms 54 ms 91 ms 0.59×
W5 sum without pod, max_over_time, instant instant defaults 6 ms 12 ms 12 ms 1.00×
W6 sum without pod, rate, instant instant defaults 11 ms 7 ms 14 ms 0.50×
W7 topk 5 of sum without pod 1h defaults 35 ms 55 ms 92 ms 0.60×
W8 sum without pod, count_over_time (production) 1h defaults 307 ms 276 ms 508 ms 0.54×
W9 label_replace control, instant instant defaults 4 ms 4 ms 70 ms 0.06×
U1 logfmt amount rate 1h defaults 36 ms 28 ms 34 ms 0.82×
U2 json duration_ms rate 1h defaults 65 ms 67 ms 88 ms 0.76×
U3 json duration_ms rate, error filter 1h defaults 67 ms 67 ms 100 ms 0.67×
U4 logfmt latency_ms rate, production 1h defaults 276 ms 273 ms 520 ms 0.53×
U5 logfmt amount rate, instant instant defaults 7 ms 9 ms 11 ms 0.82×
U6 json duration_ms rate, instant instant defaults 12 ms 13 ms 20 ms 0.65×
U7 json duration_ms sum_over_time, instant instant defaults 10 ms 10 ms 20 ms 0.50×
U8 logfmt amount rate by service (grouped) 1h defaults 32 ms 31 ms 41 ms 0.76×
U9 logfmt amount rate_counter (folded per series) 1h defaults 183 ms 176 ms 538 ms † —
U1 json duration_ms sum by service 1h defaults 83 ms 68 ms 217 ms 0.31×
U10 instant sum_over_time unwrap instant defaults 24 ms 24 ms 25 ms 0.96×
U11 instant max_over_time missing label instant defaults 6 ms 5 ms 14 ms † —
U12 json duration_ms over every service 1h defaults 175 ms 182 ms 583 ms 0.31×
U13 logfmt latency_ms over every service 1h defaults 398 ms 371 ms 762 ms 0.49×
U14 duration() of a unit string 1h defaults 43 ms 43 ms 38 ms 1.13×
U2 json duration_ms max by service 1h defaults 70 ms 65 ms 505 ms 0.13×
U3 json duration_ms sliding sum 1h defaults 62 ms 66 ms 163 ms 0.40×
U4 logfmt size_bytes sum by service 1h defaults 36 ms 33 ms 36 ms 0.92×
U5 logfmt ttl unit string (no sample) 1h defaults 33 ms 34 ms 30 ms † —
U6 missing label, no parser 1h defaults 16 ms 12 ms 63 ms † —
U7 missing label, ungrouped sum 1h defaults 14 ms 11 ms 62 ms † —
U8 rate of an unwrapped label 1h defaults 63 ms 81 ms 154 ms 0.53×
U9 bytes() conversion 1h defaults 42 ms 45 ms 37 ms 1.22×

† 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.

20 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 1.14 s 392 ms 2.90× 4.82× — as slow on base
known defaults cold E7 json count by level_extracted 1h 985 ms 172 ms 5.73× 5.65× — as slow on base
known defaults warm U1 json duration_ms sum by service 1h 97 ms 19 ms 5.11× 4.37× — as slow on base
known defaults warm U12 json duration_ms over every service 1h 199 ms 52 ms 3.83× 3.71× — as slow on base
known defaults warm U13 logfmt latency_ms over every service 1h 376 ms 62 ms 6.06× 6.52× — as slow on base
known defaults cold X1 json filter level_extracted, by level 1h 2.45 s 597 ms 4.10× 4.16× — as slow on base
known defaults warm E2 json duration_ms p95 by service 1h 203 ms 18 ms 11× 12× — as slow on base
known defaults warm E3 logfmt latency_ms rate, production 1h 384 ms 116 ms 3.31× 3.37× — as slow on base
known defaults warm E9 logfmt latency_ms sum by service, namespace prod 1h 253 ms 85 ms 2.98× 2.79× — as slow on base
known defaults warm U4 logfmt latency_ms rate, production 1h 287 ms 110 ms 2.61× 2.45× — as slow on base
known defaults warm U9 logfmt amount rate_counter (folded per series) 1h 185 ms 33 ms 5.61× 5.33× — as slow on base
known defaults cold P2 api-gateway not empty pattern, line filter 1h 91 ms 25 ms 3.64× 3.40× — as slow on base
known defaults warm P2 api-gateway not empty pattern, line filter 1h 87 ms 23 ms 3.78× 4.48× — as slow on base
known defaults warm W8 sum without pod, count_over_time (production) 1h 234 ms 59 ms 3.97× 4.66× — as slow on base
known defaults warm G6 json duration_ms sum by rate 1h 109 ms 27 ms 4.04× 2.74× — within noise of base
known defaults warm G10 logfmt latency_ms sum_over_time, production 1h 381 ms 139 ms 2.74× 2.72× — as slow on base
known defaults cold LF26 topk services by errors 1h 659 ms 298 ms 2.21× 2.14× — as slow on base
known defaults warm LF26 topk services by errors 1h 646 ms 67 ms 9.64× 12× — as slow on base
known defaults cold LS8 json count by level_extracted, collision 1h 968 ms 112 ms 8.64× 8.86× — as slow on base
known defaults warm N14 sliding sum of max 1h 385 ms 99 ms 3.89× 4.86× — as slow on base
Why these shapes
  • internal/translator/translator.go → 168: control/bytes_over_time by app, control/instant json filtered, 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 …
  • internal/proxy/unwrap_conversion_error.go → 86: control/instant json filtered, control/instant json volume, control/json rate by level (no filter, no drop), control/json volume no filters, extracted-pushdown/X1 json filter level_extracted, by level, extracted-pushdown/X2 json group by level_extracted, extracted-pushdown/X3 json rate, level_extracted equality, extracted-pushdown/X4 json bytes, level_extracted regexp …
  • internal/proxy/vector_matching.go → 47: control/stream-label count by app, 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 …
  • internal/proxy/range_metric_compat.go → 25: control/bytes_over_time by app, control/instant json volume, control/instant ungrouped rate, control/json volume no filters, control/stream-label count by app, control/topk rate by app, control/ungrouped rate, json-filter-pushdown/A explore volume, 2 filters, drop …
  • 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

base 7a2f97a27a → PR 04ec132833 · 4 runs (first is cold), warm p50 · ranges 1h · noise ±30% and 100 ms · VictoriaLogs restarts 0→0 · 6.3 min · phases stack_up 40s, seed_and_build 85s, loki-defaults:loki_profile 0s, loki-defaults:proxies 2s, loki-defaults:matrix 238s. Same runner, one fresh stack, targets interleaved per request. How to read and reproduce: bench/ab/README.md.

@szibis
szibis force-pushed the perf/unwrap-conversion-check branch from 93af511 to 4efb03c Compare October 28, 2026 09:29
@github-actions github-actions Bot added size/XL Extra large change and removed size/XL Extra large change labels Oct 28, 2026
@szibis
szibis force-pushed the perf/unwrap-conversion-check branch from 4efb03c to da1b25e Compare October 28, 2026 09:51
szibis and others added 11 commits October 28, 2026 10:51
…ndow

Loki reads a step's sample from the window (t-range, t] only
(pkg/logql/range_vector.go:102-189, batchRangeVectorIterator.Next and load):
a line between two windows belongs to no sample. The window evaluator already
kept only those lines with a filter on the stored _time, but it appended the
filter after the whole log pipeline, so VictoriaLogs derived service_name,
unpacked and ran the unwrap gate on every line of the range first.

The filter now follows the query's filters, ahead of every pipe. For
sum by (service_name) (rate({env="production"} | logfmt | unwrap latency_ms [5m]))
over 7 days (step 10m, the daily E3 shape at 13 s cold and warm) half the
lines are parsed; a [1m] range skips nine lines in ten. The pipes the filter
moves ahead of are row-local and never write _time, so the stats see the
same rows and the answer is unchanged. A query holding the derived
detected_level chain keeps the filter after the chain, whose base keys its
remembered presence probe. The unwrap conversion-error lookup carries the
filter once, ahead of the parser, instead of appending a second one.

The conversion-error counter itself is not the cost: the 2026-10-08 A/B ran
E3 at 7d in 13.37 s on main without it and 13.44 s with it, and the metric
sends one stats_query_range and no lookup on clean data.
…than the step answer Loki's window

Loki reads each step's sample from (t-range, t] only
(pkg/logql/range_vector.go:102-189, batchRangeVectorIterator.Next and load).
With a range shorter than the step the windows are disjoint: most lines belong
to no sample and a step whose window is empty has none. The proxy answered
these functions from VictoriaLogs' anchored step buckets (t-step, t]
(statsRateRangeEqualsStepShift), so
sum by (service_name) (sum_over_time({service_name="api-gateway"} | json | unwrap duration_ms [1m]))
over 7 days at step 10m (the daily E1 shape; E4, E8 and E9 at 7d likewise)
summed ten minutes of lines per point where Loki sums one, and a step with
lines only outside its window had a sample Loki does not.

The stats query now carries the window filter the other range metrics use,
right after the query's filters, so each bucket holds exactly Loki's window and
VictoriaLogs parses only those lines. The unwrap conversion-error check rides
on the same query and counts only the lines inside a window, the only ones Loki
fails on.

TestUnwrapOverTimeShortRangeMatchesLoki compares the four functions with a
reference Loki evaluator over generated rows (edges, empty windows) on three
VictoriaLogs lines, with the error filter, with the conversion check, with a
scalar operand and between two vector operands: all 96 subtests fail before
this change and pass after. No visual fix proof: the visual stack holds Loki
for 1.5h, where Explore's step is seconds and every panel range is longer than
the step. A range longer than the step stays open
(semantics/unwrap-over-time-range-windows).
…for every function

Loki's batchRangeVectorIterator.load skips `sample.Timestamp <= start` (the
comment in that loop reads "the lower bound of the range is not inclusive")
and keeps samples up to and including the evaluation time, and unwrapped
ranges share that iterator (pkg/logql/range_vector.go:148-189, v3.7.7).
aggregateManualWindow excluded the lower bound only for rate,
count_over_time, bytes_over_time and bytes_rate, so every unwrapped range
function read the sample on windowStart that Loki drops, and answered one
sample high.

Correcting only the lower bound would leave the window open at both ends:
the 1ns extension of the VictoriaLogs fetch, which is exclusive at its end
where Loki is inclusive, was applied to quantile and the log-range functions
alone, so a sample exactly on the evaluation timestamp was never fetched. The
extension is now unconditional, on query_range and on instant queries.

TestLowerBoundIsExclusiveForEveryRangeFunction fails without the change and
passes with it (its quantile subcase uses q=0, which tells {1,2,3} from
{2,3}); TestCompatHelpers_AggregateManualWindow encoded the old bound and is
updated to Loki's.
…han the step answer Loki's window

Loki reads each step's sample from (t-range, t]
(pkg/logql/range_vector.go:102-189 batchRangeVectorIterator.Next and load;
the functions at range_vector.go:371-420), and the windows overlap when the
range is longer than the step. statsRateRangeEqualsStepShift read every range
of these functions as the step and shouldUseManualRangeMetricCompat kept them
off the window evaluator, so each point was one VictoriaLogs step bucket
(t-step, t]: sum(sum_over_time({app="payment-service"} | logfmt | unwrap
amount [5m])) at step 60 was 2.00257e+06 where Loki has 9.84307e+06 on the
stack, sum_over_time({app="uw"} | unwrap amount [150s]) at step 2m 725.875
where Loki has 942.125 on the emulated backend, and a line every 4.5 minutes
gave 13 samples in an hour where Loki has 61.

A range longer than the step now goes to the window evaluator that already
answers rate with unwrap (unwrapWindowBuckets). VictoriaLogs returns, per
series, buckets of gcd(range, step) anchored at the request with the partial
aggregate of the converted value (sum, max or min) and the line count
(unwrapBucketAgg; avg keeps the counts to divide by), and the proxy merges the
buckets of each window (mergeUnwrapBuckets) with a sample only where the
window holds a line. VictoriaLogs has no sliding-window stats and one step
bucket cannot hold an overlapping window, so the fold is done in the proxy,
over aggregated buckets only; a grid over 11,000 buckets goes to the raw-row
evaluator bounded by -manual-range-metric-row-limit, which reads the same
window since the previous commit. The route takes only the aggregations the
evaluator applies after the window (unwrapWindowForm): sums by labels or none,
topk and bottomk, label_replace, binary operands, count/min/max/avg by labels
and an outermost labelled without; an outer fold reads under the raw-row
budget (recordFoldBudget, shared with withWithoutFold) and Loki's series
limit applies to its answer. JSON parser queries grouped per stream and line
under an outer aggregation, nested aggregations and an empty without () keep
the step buckets (registered open:
semantics/unwrap-over-time-range-windows-residual-forms). Per-stream series of the route carry detected_level="unknown" for a
stream without a level, as Loki's and rate's do. Ranges up to the step and
instant queries are unchanged; the 7-day E1/E4/E8/E9 shapes ([1m] and [5m] at
step 10m) keep their single phase-filtered step bucket query.

The window phase filter now names the window start modulo the step instead
of the request's start: the same lines, but the query text, and so the stats
extent cache key, stays the same when a window moves by whole steps (one miss
and five partial answers where main had six misses).

Tests: TestUnwrapOverTimeWindowsDifferential (generated rows, edges at +-1ns,
offsets, instant queries, ten query forms, VictoriaLogs v1.49.0, v1.53.0 and
unknown) against a reference evaluator of Loki's windows,
TestUnwrapOverTimeRangeWindowEdgeCases (answers spelled out),
TestUnwrapOverTimeRawWindowBounds, TestUnwrapOverTimeSparseSamplePresence,
TestUnwrapOverTimeUngroupedSumIgnoresSeriesLimit,
TestUnwrapOverTimeResidualShapesKeepStatsBuckets,
TestUnwrapOverTimeShortRangeStaysOnStepBuckets,
TestWindowPhaseFilterKeepsExtentCacheAcrossSteps and the e2e
TestCompat_UnwrapOverTimeRangeWindows (shared fixtures plus lines on every
window edge, range and instant). The differential, edge and presence tests
fail on main for range > step. Pinned answers that read the step bucket move
to the window: five sliding extraction-list goldens and two max without
answers. Brought over from #740 (its tests, adapted to the supported
VictoriaLogs lines), which this supersedes.
… parser

collectRangeMetricHitsAux parsed every stats_query_range answer with
fj.ParseBytes, a new parser per call whose value cache is allocated again for
every [timestamp, value] pair. A range longer than the step now sends sum,
avg, max and min over time with unwrap through this read with a value and a
count series per group, so the allocation grew with the answer: a canned
10-series answer of a 24h query at step 60 with a [5m] range allocated
50 MiB per request (main's step-bucket answer 10 MiB). It now takes a parser
from statsQRFJPool, the pool the step-bucket trims use: 18 MiB per request,
1h at step 5s 28 MiB to 9 MiB. Nothing parsed outlives the call (labels are
string copies, values floats).
… that folds

The window route of sum, avg, max and min over time with unwrap recorded the
raw-row budget in the request's series limit scope for every query it took,
also a grouped sum that folds nothing; a folded read later in the same
request (the other operand of a binary expression) would then read under that
budget and its error text. It is now recorded only for the outer folds the
route applies afterwards: a series-level aggregation chain
(foldVectorAggregations) and an ungrouped sum of a function VictoriaLogs does
not sum (foldUngroupedSum). A labelled without records it in withWithoutFold.

Two handler tests cover what the differential's emulator cannot:
TestUnwrapOverTimeRangeWindowsNaN sends the counting gate's nan group through
the merged buckets (sum and avg are NaN in the five windows that hold the nan
line, max and min keep the clean value; on main one step is NaN), and
TestRateCounterReadsLokiWindow checks rate_counter's raw window on query_range
and at an instant (0.666667 over (t-1m, t]; main answers 0.333333 with the
old bounds).
…eads its buckets in place

The corrected range-longer-than-step route cost the proxy 2 to 5 times the
memory of main's (wrong) step-bucket answer. Measured with a canned
10-series VictoriaLogs answer served from memory (the proxy's own cost):
E8 24h 6.8 MiB on main against 11.4 MiB, E4 6h 7.4 against 44.9 MiB.

- max by (L) (max_over_time(...)) and min by (L) (min_over_time(...)), L
  possibly empty: Loki takes the extreme per series over the window and then
  per group (pkg/logql/range_vector.go:405-420; evaluator.go
  VectorAggEvaluator.Next, a NaN giving way in both), which is the extreme of
  the group's lines in the window. unwrapGroupPushdown has VictoriaLogs group
  the buckets by L instead of by stream (or stream and line for a JSON query),
  and the evaluator answers without the JSON fold. Loki's series limit counts
  the groups (pkg/logql/engine.go:530-590 JoinSampleVector), as the read does.
  The translated grouping must be L plus the series identity (_stream, _msg,
  level); anything else keeps the fold. JSON max by and min by of the same
  function leave the residual forms.
- sum, max and min over time read no sample count: VictoriaLogs answers a
  bucket only when it holds a row, so the aggregate's point is the presence
  (the counting gate's nan group still poisons a sum's point); avg keeps it.
- collectRangeMetricHitsAux reads the [ts, value] pairs in place with
  jsonparser and parses only each series' labels (pooled fastjson parser);
  buildHitsRangeMetricMatrix writes the points into a raw JSON array instead
  of an interface pair per point (byte for byte what encoding/json writes);
  the unwrap counter's kept flag leaves the answer in one in-place pass
  instead of three copies (dropKeptFlag, also on the step-bucket route).

Same canned answer, after: E8 24h 4.5 MiB in 1.6k allocations (main 6.8 MiB,
4.2k), E8 1h 2.3 (3.0), E4 6h 4.5 (7.4), avg 24h 7.2 (6.3), the 7-day [1m]
and [5m] shapes 2.9 (3.5, 4.7).

TestUnwrapGroupPushdownMatchesPerStreamFold compares the pushed-down answer
with the per-stream fold's (unwrapGroupPushdownOff) over generated rows,
three VictoriaLogs versions, grouped, ungrouped and under topk, series limits
0 to 3: the same series byte for byte and the same 400s. The differential
gains max by, min by, max and topk of max by over JSON;
TestUnwrapOverTimeRangeWindowsNaN the max by and min by forms;
TestDropKeptFlagMatchesReplacements holds the one-pass removal to the three
replacements. Five sliding extraction-list goldens send the new bucket query;
max(max_over_time(... | logfmt amount, route ...)) now answers Loki's window.
…t pairs directly

buildHitsRangeMetricMatrix searched the window bounds of every step (two
binary searches over the samples, one over the presence, two over the counts
of avg). The windows only move forward with t, so the bounds are now cursors
that only move forward; the prefix sums are built only for the additive
functions that read them. eachBucketPoint reads VictoriaLogs' pairs
`[number,"number"]` with a direct scan instead of two nested jsonparser
walks per pair.

Interleaved benchstat (n=10, canned 10-series answer served from memory,
GOMAXPROCS=2, load average 7 to 9), main / before this branch's perf commits /
now: E8 24h [5m] step 60 3.09 / 5.90 / 2.83 ms, 6.9 / 11.7 / 4.2 MiB; E4 6h
max by 3.14 / 18.66 / 2.85 ms, 7.0 / 42.3 / 4.2 MiB; E8 1h step 5 1.59 /
3.43 / 2.15 ms; avg 24h 3.02 / 7.00 / 5.34 ms, 6.3 / 12.6 / 6.7 MiB; the
7-day range-shorter-than-step shapes 2.25 / 2.29 / 2.01 ms.
…nd keeps the fold for detected_level

unwrapGroupPushdown checked that the translation's grouping held the outer
labels as a set and then paired them by position with the user's by-list,
while the collector names each grouped field after the label at its index
(collectRangeMetricHitsAux vlToLoki): a by-list the translation writes in
another order would put a value under the wrong name. Each grouped field is
now named after the label whose name or VictoriaLogs name it is.

A detected_level (or level) grouping keeps the per-stream fold: a stream
without a level is labelled detected_level="unknown" by the per-stream read
(buildMetricSeriesEntry), which a grouping by the stored field does not give.
service_name is pushed down: the translation derives it ahead of the stats
(the coalesce over the stored names), as for sum by (service_name).

TestUnwrapGroupPushdownMatchesPerStreamFold gains max by (app, pod), min by
(pod, app) and max by (detected_level, pod); TestUnwrapGroupPushdownDerivesServiceName
checks the derivation ahead of the grouping. The e2e
TestCompat_UnwrapOverTimeRangeWindows asks max by (app), min by (route) and
max over max_over_time over json and logfmt, on the shared and the edge
fixtures, range and instant.
…ot from the range

buildHitsRangeMetricMatrix reserved 32 bytes for every step of the range for
every series that held a point. A query that builds a series per stream before
an outer fold reserved that for thousands of sparse series: the 6h
`sum without (pod) (count_over_time({env="production", ...}[1m]))` shape (W8 of
the unwrap-sum-without set, step 10s, 2161 steps) held 1.9 GB live and
allocated 22 GB per six queries on the stack, a quarter more than main, whose
point slices reserved 24 bytes a step. The process ran at its memory limit and
spent the difference in the collector.

A point needs a bucket in its window, and a bucket lies in the window of at
most window/step+2 evaluation times, so a series cannot hold more points than
its buckets (its presence buckets for count and rate) times that, and never
more than the steps of the range. The reservation is now that bound; a series
that fills every step reserves what it did.

Measured on the A/B stack (6h, step 10s, the same windows, six queries each):
main 2.6 s and 3.3 GB peak RSS, before this commit 5.3 s and 4.1 GB, with it
1.4 s and 0.38 GB. The answers are identical (the same bytes per window).
@szibis
szibis force-pushed the perf/unwrap-conversion-check branch from da1b25e to 44ca788 Compare October 28, 2026 09:51
@github-actions github-actions Bot added size/XL Extra large change and removed size/XL Extra large change labels Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Bug fix 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