fix(grafana): refuse agg(A op B) shapes with no honest ES|QL rendering - #433
Merged
shmsr merged 1 commit intoSep 2, 2026
Conversation
`count(A and B >= threshold)` translated to `COUNT(A)`: the set operator and the threshold were both discarded, and the panel shipped as `migrated_with_warnings`. On a real k8s volumes dashboard that rendered as 13 PVCs over their warning threshold when only 8 PVCs existed at all and the correct answer was 2. A plausible wrong number is the worst outcome a migration can produce, because nobody re-derives it by hand. `_ast_aggregate_fragment` rewrote three shapes of `agg(A op B)` -- the linear `sum(A +/- B)` push-down, scalar hoisting, and the histogram mean idiom -- and let every other shape fall through. The generic `fragment_extract`/`stats_expression` fallback then rebuilt `agg(<first metric leaf>)` from the fragment's summary fields, silently dropping the operator and the remaining operands. Add a closing guard that refuses whatever no rewrite could express, so these degrade to a "Migration Required" placeholder carrying the original PromQL and a reason naming the operator and the aggregation, instead of a fabricated value. The guard is an allowlist of operators it hands on rather than a list of operators it refuses: a deny-list omitted `atan2` and reproduced the exact mistranslation it exists to stop, so an unenumerated operator now fails closed. `or` is the one deferred operator. It carries established Grafana idioms -- the same-metric range-window fallback and the live-absent operand drop -- that need a resolver parse time does not have. Refs elastic#377
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #377.
Is the issue valid?
Yes for two of its three claims; the third is not a bug.
count(A and B >= threshold)translated toCOUNT(A)and shipped asmigrated_with_warnings. On the reporter's k8s volumes dashboard the "Running PVCs Above % Used Warning Threshold" tile rendered 13 when the index holds only 8 PVCs and the correct answer is 2.andand the>= 80/100threshold were both discarded with no warning naming them.WHERE @timestamp. The report inferred that the absent time predicate makes the panel scan the whole index. Against the raw_queryAPI that reproduces (a Sept-1 window returned Aug-20 rows, sinceBUCKET()only sizes buckets). But Kibana does not send the query bare: theinternal/search/esql_asyncbody carries a sibling"filter": {"range": {"@timestamp": {gte, lte}}}built from the time picker, and the panel correctly showsN/Aon an empty window. This is the deliberate design from 117fd4e and is unchanged here.Root cause
_ast_aggregate_fragmentrewrites three shapes ofagg(A op B):sum(A ± B)push-down,max(A * 8)),Every other shape fell through and returned a bare
unknownfragment. The genericfragment_extract/stats_expressionfallback then rebuiltagg(<first metric leaf>)from the fragment's summary fields — which discards the operator and every operand but the first, producing a well-formed query with no relation to the source semantics.The underlying reason no generic rewrite exists: PromQL evaluates
A op Bper matching series pair, matching on the operands' full label set, before the aggregation reduces the survivors. ES|QL has no equivalent stage.Code paths checked
observability_migration/adapters/source/grafana/promql.py—_ast_aggregate_fragment,_push_outer_agg,_make_binary_fragment,_append_not_feasible_reasoncolocated_binary_agg_plan/colocated_binary_agg_unblock— the per-document renderer that legitimately handlescount(A + B)fragment_guardrails_rule— turns accumulatednot_feasible_reasonsintofeasibility = "not_feasible"fragment_extract/stats_expression— the fallback that produced the wrong queryQUERY_PREPROCESSORS→CLASSIFIERS→TRANSLATORS→POSTPROCESSORS→VALIDATORSordering, to confirm the refusal lands before the fallback and can still be cleared by the co-located rendererThe fix
A closing guard in
_ast_aggregate_fragmentrefuses anyagg(A op B)that no rewrite above could express, so it degrades to a "Migration Required" placeholder carrying the original PromQL and a reason naming both the operator and the aggregation.The guard is an allowlist of operators it hands on, not a list of operators it refuses. The first revision was a deny-list and it omitted
atan2, which reproduced the exact mistranslation the guard exists to prevent. An unenumerated operator now fails closed.oris the single deferred operator: it carries established Grafana idioms — the same-metric range-window fallback (max_over_time(M[$interval]) or max_over_time(M[5m])) and the live-absent operand drop — that need a resolver parse time does not have.Side effects considered
A 107-case matrix of
agg(binary_expr)shapes was run against the unmodified base and this branch:feasible→not_feasibleEvery existing safe rewrite still applies:
sum(A ± B)push-down, scalar hoisting, the histogram mean idiom, and the co-locatedcount(A + B)renderer. A full artifact diff of a real migration (base vs branch) shows exactly one panel changed.Refusing
orwas tried and reverted — it broketest_clamp_wrapper_uses_real_output_field_when_panel_drops_unmigrated_target, which is how the boundary was located.Tests
make lint— clean.make typecheck— clean (10 source files).tests/test_issue377_agg_over_binary_operator.py, including a sweep asserting no operator silently drops an operand.agg_over_and_operator_not_feasible.txt,agg_over_series_comparison_not_feasible.txt..git/hookshas only samples), so all five configured checks were run manually: gitleaks v8.30.1 via Docker (no leaks, 894 commits), source headers (492 files), ruff, pytest smoke, no-local-paths. Nothing was bypassed;--no-verifywas not used.Kibana visual verification
Chrome DevTools MCP against
https://127.0.0.1:5601, dashboardobs-migrate-k8s-storage-volumes-cluster, view mode, hard-reloaded to clear stale edit state.esql_asyncrequest body showed Kibana's injected@timestamprange filter. This is what refutes the issue's third claim.Pre-existing failures
None attributable to this branch. One transient local failure —
test_grafana_create_alert_rules_without_api_key_fails_the_run— was caused by an exportedKIBANA_API_KEYin the verifying shell; that test asserts behavior without a key. Clearing the env restored a full green run. Not a code issue.Known gap, not fixed here
count(A or B)still drops the right operand (COUNT(node_a)). It is older and wider than this fix, needs a resolver at a later pipeline stage, and is tracked in #434. It is documented in the new test's docstring so it stays visible rather than silently sitting inside the deferred branch.