fix(grafana): type-safe multi-select control guardrails, disclose dropped variable references - #363
Merged
giorgi-imerlishvili-elastic merged 3 commits intoAug 19, 2026
Conversation
…pped variable references Two related gaps in variable/control translation: - Multi-select guardrail type mismatch (issue elastic#353): Kibana infers a bound ES|QL control parameter's type from the selected option *values*, not from the control's own keyword-typed option-list query. A Grafana variable whose label values happen to look numeric (CPU/core indices, ports, PIDs, status codes) can bind `?var` as an integer array, and MV_CONTAINS requires both arguments to share a type -- so comparing that integer-typed `?var` against the keyword ".*" sentinel (or a keyword field) fails ES|QL's compile-time type verification, breaking the whole query rather than degrading gracefully. - Dropped-but-referenced variables go unnoticed (issue elastic#356): a Grafana variable used by a panel's PromQL (most sharply, an `interval` variable used as a rate/range-vector window, e.g. rate(x[$RateInterval])) but intentionally skipped by its variable rule (which assumes "handled by Kibana's time picker") silently disappears with no control and no warning, handing control of the window to the migrated query's bucket-width heuristic instead. Fixes: - `_mv_contains_filter` now wraps the bound parameter in TO_STRING(...) unconditionally (a no-op on an already-keyword value), so the multi-select guardrail type-checks regardless of how Kibana infers the parameter. Field-binding detection regexes in panels.py and parity_oracle.py were updated to recognize both the bare and wrapped shapes. - New `_disclose_dropped_referenced_variables`, run after every control-synthesis pass in translate_dashboard, appends a control_warnings entry naming any variable referenced by a panel's original PromQL but never bound to a control -- a specific message for `interval` variables, a generic one for other types. Skips variables that are genuinely unused, already bound (checking both the variable_name and classic-control _source_variable_name ownership keys), or already covered by a more specific existing warning. Also wraps the same MV_CONTAINS(?instance, ...) guardrail shape in the Redis exporter curated packs (763, 11835), and updates scripts/dashboard_qa.py's own multi-value parameter detection to recognize the wrapped form -- both gaps found via an independent model review before merge, along with the classic-control false-disclosure fix above.
The elastic#356 RateInterval warning only mentioned the ES|QL TBUCKET heuristic, but dashboard 9852's native PROMQL panels inline a fixed [5m] range. Name both substitutes, document classic-control ownership keys, and note that TO_STRING is a no-op when Kibana binds keyword strings.
shmsr
approved these changes
Aug 19, 2026
Merged
4 tasks
… incidental sample data A same-day commit (4cb7726) reserved the literal axis-label text "percentage" as an opaque Grafana-unit-id alias whose title is intentionally suppressed (so unit-inferred titles like "%" can take over instead) and added a correct, dedicated test for that behavior. It didn't touch this unrelated, pre-existing test, which happened to reuse the same literal string purely as incidental sample text for testing something else entirely (that bar charts keep axis config while omitting line/area-only appearance keys) -- breaking it on main and therefore on every open PR whose CI merges against main. Swap the incidental fixture text for an ordinary, non-reserved label so the test again exercises its own actual intent without colliding with the new opaque-alias behavior.
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.
Issues fixed
MV_CONTAINSmulti-select control guardrail fails ES|QL type verification when Kibana infers a numeric-looking bound parameter as an integer arrayintervaltemplate variables are dropped with no control and no warning #356 — a Grafana variable referenced by panel queries but dropped during migration (no control, no?varbinding) disappears silently, with no disclosureValidity
Both issues are valid.
FROM metrics-generic-default | LIMIT 1 | EVAL matched = MV_CONTAINS(?cpu, ".*")withparams: [{"cpu": [0, 1]}](simulating Kibana binding a numeric-looking multi-select control, e.g. CPU/core indices fromlabel_values(node_cpu_seconds_total, cpu)) returns a realverification_exception: "second argument of[MV_CONTAINS(?cpu, ".*")]must be[integer], found value[".*"]type[keyword]". This is a compile-time ES|QL error, so it fails the whole panel query, not just a subset of rows.intervaltemplate variables are dropped with no control and no warning #356:interval_variable_ruleintentionally emits no control forintervalvariables, on the assumption that Kibana's time picker covers their purpose. That assumption is false when the variable is used as a rate/range-vector window (rate(x[$RateInterval])) rather than the displayed time range — dropping it silently hands control of the rate window to the migrated query'sTBUCKETbucket-width heuristic, which is not equivalent and can differ from the source value in either direction.Root cause
_mv_contains_filterinpromql.pyemitsMV_CONTAINS(?var, ".*")/MV_CONTAINS(?var, field)unconditionally. Kibana's ES|QL control binding infers?var's type from the selected option values, not from the control's own (keyword) option-list query text. A variable whose values look numeric binds?varas an integer array;MV_CONTAINSrequires both arguments to share a type, so the keyword".*"sentinel and keyword label field both fail to type-check against it.intervaltemplate variables are dropped with no control and no warning #356: no pass ever compared "variables referenced by panel queries" against "variables that ended up with a working control or?varbinding" — a variable that fell through every control-synthesis rule with no warning (interval variables by design, but also e.g. a hiddenqueryvariable, or any variable type whose rule didn't fire) just vanished.Code paths checked
observability_migration/adapters/source/grafana/promql.py:_mv_contains_filter.observability_migration/adapters/source/grafana/panels.py:_ESQL_VALUE_PARAM_FIELD_PATTERNS(field-binding detection used by curated-pack control retargeting),translate_variables/query_variable_rule(both the ES|QL param-control path and the classic non-ESQLoptionscontrol path), and the new_variable_names_referenced_in_panels/_disclose_dropped_referenced_variables, wired intotranslate_dashboardafter_ensure_param_controls, late-bound group controls, and?varretargeting.observability_migration/core/verification/parity_oracle.py:_MV_CONTAINS_PARAM_RE(used by the parity oracle to recognize which variables a query binds).MV_CONTAINS(?var, ...)guardrails, bypassing_mv_contains_filterentirely:grafana_763_redis_exporter,grafana_11835_redis_exporter_helm.scripts/dashboard_qa.py: its own independentMV_CONTAINS(?nametext scan, used to decide whether to bind a QA probe parameter as a list or a scalar.Fix
_mv_contains_filternow wraps the parameter inTO_STRING(...)unconditionally:MV_CONTAINS(TO_STRING(?var), ".*")/MV_CONTAINS(TO_STRING(?var), field).TO_STRINGon an already-keyword value is a no-op, so this is safe regardless of how Kibana ends up inferring the type.MV_COUNT(?var) == 0is left unwrapped sinceMV_COUNTdoesn't care about element type._ESQL_VALUE_PARAM_FIELD_PATTERNS(panels.py) and_MV_CONTAINS_PARAM_RE(parity_oracle.py) to match both the bare andTO_STRING-wrapped shapes, so existing control-retargeting and parity-oracle logic keeps recognizing the parameter binding._disclose_dropped_referenced_variables(variables, controls, panels, control_warnings), called once after every control-synthesis pass. For each templating-list variable: skip if never referenced by any panel's original PromQL, skip if already bound to a control (checking both thevariable_namekey used by ES|QL param controls and the_source_variable_namekey used by classic non-ESQL controls), skip if some earlier pass already warned about it by name. Otherwise append acontrol_warningsentry — a specific "rate/range window... bucket-width heuristic" message forintervalvariables, a generic "referenced but dropped" message for every other type.MV_CONTAINS(?instance, ...)shape in the two Redis exporter curated packs' hand-written query overrides (6 occurrences acrossgrafana_763_redis_exporterandgrafana_11835_redis_exporter_helm), for consistency with the now-universal type-safety guarantee.scripts/dashboard_qa.py's multi-value parameter detection (new shared_is_multi_value_paramhelper) to recognize theTO_STRING-wrapped form, so it keeps binding multi-select QA probe parameters as lists instead of silently falling back to scalars.interval/custom variable disclosure behavior indocs/sources/grafana.md.Side effects considered (found via independent model review before merge)
_disclose_dropped_referenced_variablesonly checkedcontrol.get("variable_name")for "is this variable bound". A classicoptions/rangecontrol (emitted when the target doesn't support ES|QL named-parameter binding) never sets that key — only_source_variable_name, attached later intranslate_variables. Reproduced directly: a workingoptionscontrol was falsely flagged as "dropped". Fixed by checking both keys, mirroring the existing_covered_control_variable_refshelper's lookup pattern. Addedtest_variable_bound_to_a_classic_options_control_is_not_disclosed._mv_contains_filterfix doesn't touch curated packs that hand-author their ownMV_CONTAINS(?var, ...)text. Found and wrapped the two Redis exporter packs above; updated the one test locking in the old bare shape.scripts/dashboard_qa.py. Its own multi-value detection only matched the bareMV_CONTAINS(?namesubstring; after this fix, that check would always miss the real (wrapped) shape and silently bind multi-select QA parameters as scalars. Fixed with a shared detection helper covering both shapes; added 3 new unit tests.observability_migration/core/coverage/interaction_canary.py's hand-authoredMV_CONTAINS(?services, service.name)literal unchanged — its docstring states it is intentionally "self-contained... unaffected by what a real run emits", andcheck_network_contract(the interaction-audit consumer) does not do any text-basedMV_CONTAINSparsing, only structuredparams/param_kinds, so there is no behavioral risk in leaving it as-is.query_result()'s "no populate-query equivalent") gets exactly one warning, not two.Tests
TestMvContainsFilterTypeSafetyand a numeric-looking-values regression test intests/test_grafana_issues_316_319.py;DroppedReferencedVariableDisclosureTests(7 tests, including the classic-control regression) intests/test_migrate.py;test_esql_param_control_retargets_when_panel_binds_via_to_string_wrapped_mv_containsand an updated curated-override assertion intests/test_curated_packs.py; a wrapped-form recognition test intests/core/test_parity_oracle.py; 3 new_is_multi_value_paramtests intests/test_dashboard_qa.py; updated assertions intests/test_k8s_views_global_interaction_scenario.py.make lintandmake typecheckclean.Kibana / Elasticsearch verification
#353's failure mode is a compile-time ES|QL type-check at the Elasticsearch engine level, not a Kibana rendering concern, so it was verified directly against a live local Elasticsearch cluster rather than through the Kibana UI (see Validity above): reproduced the exact
verification_exceptionwith the pre-fix bare shape, then confirmed theTO_STRING-wrapped shape executes successfully with the same integer-bound parameter. #356 only produces text incontrol_warnings(CLI/report output), with no Kibana-rendered element to check either way.Review
Independent review performed via a subagent on a different model (GPT 5.6 Sol xhigh) per the team's cross-model review workflow. First pass found the three issues described above (classic-control false disclosure, unwrapped curated-pack guardrails, and the
dashboard_qa.pydetection gap) — all reproduced independently, fixed, and covered by new regression tests. Second pass confirmed APPROVE, no remaining blocking issues.