Skip to main content
RunBook Academy

← All break/fix scenarios in Observability

intermediategrafana-dashboard~30 min

Break/Fix: Broken Dashboard Query

Reported symptoms

  • ●During a live incident the "Checkout 5xx ratio" panel read "No data" for eleven minutes, and the incident timeline records "error ratio flat, errors are zero" at 14:41
  • ●The alert that fires on the same signal had been in `firing` state since 14:34, so Prometheus plainly had the data the panel could not draw
  • ●The panel immediately to its right, on the same data source and the same time range, renders normally throughout
  • ●Pasting the panel expression into Explore returns data instantly, which everyone read as proof that the query is fine
  • ●The dashboard author cannot reproduce it - the panel renders for them, in their browser, right now
  • ●It fails for anyone who opens the dashboard from the link in the alert annotation and works for anyone who opens it from their own bookmark
  • ●Three panels across two dashboards behave this way; they share nothing except that all three were copied from the same source dashboard
  • ●The panel has been fixed twice in the Grafana UI, each fix confirmed working by the person who made it, and each time the fault came back within the hour

Evidence

  • · Panel Inspect, Query tab: the expression Grafana actually sent contains `namespace="checkout-eu|checkout-us"` - one matcher, one literal value, a pipe inside it
  • · The working panel beside it sends `namespace=~"checkout-eu|checkout-us"`; the two expressions differ by one character in the saved JSON
  • · The `namespace` variable is defined with `multi: true`, `includeAll: true` and `allValue: ".*"`
  • · The alert annotation link carries `var-namespace=checkout-eu&var-namespace=checkout-us`; the author browser had a single value persisted from last week
  • · Running the substituted expression against `/api/v1/query` returns HTTP 200 with `"result":[]` - success, empty, no error text anywhere
  • · `/api/v1/series` for the same metric returns the series, with a `namespace` label whose distinct values are `checkout-eu` and `checkout-us`
  • · The dashboard provider file declares `allowUiUpdates: false` with `updateIntervalSeconds: 30`, and the Grafana log records the dashboard being written from file shortly after each UI fix
  • · Git history on the source dashboard shows the `=` matcher was written when `namespace` was a single-value variable and has not been touched since
Diagnosis and resolutionclick to reveal

Root cause

Two defects met, and the second one is the reason the first survived three attempts to fix it. The panel selects `namespace="$namespace"` with `=`, an exact label matcher, while the `namespace` variable has since been made multi-value. When more than one value is selected, Grafana substitutes the selected values as a single string - the Prometheus data source joins them with `|`, and a data source without its own interpolation rule joins them with a comma - so the matcher becomes an exact comparison against the literal text `checkout-eu|checkout-us`. No series carries a namespace label with that value, so the selector matches nothing. The result is an empty vector, which Prometheus returns as HTTP 200 with an empty result array, and which Grafana draws as "No data". Nothing in the path reports an error, because nothing in the path went wrong: the query was well formed and was answered truthfully. The panel worked for eighteen months because the variable was single-valued for eighteen months; making it multi-value was a breaking change to every panel that interpolated it with `=`, and it was made without touching a single panel. The `All` option fails the same way for the same reason - `allValue: ".*"` substituted into an exact matcher asks for a namespace literally named `.*`. The second defect is the provisioning model. The dashboard is file-provisioned with `allowUiUpdates: false`, so both UI fixes were reverted at the next thirty-second reconcile, while the two engineers who made them had already watched the panel come back to life and closed the ticket. The repository copy, which is the only copy that matters, still said `=`.

Remediation

Fix the matcher in the repository, not in the UI, because a UI edit on this dashboard has a lifetime of thirty seconds. Change `=` to `=~` on the three affected panels, then audit the source dashboard they were copied from and every other panel that interpolates a multi-value variable, since the same edit was copied wherever the dashboard was. Know what `=~` costs before reaching for it: it is a regex match, so the values being substituted must be ones you control. Keep `namespace` a query variable whose values come from the data source rather than a textbox, and where a value can contain regex metacharacters use the explicit `${namespace:regex}` form so Grafana emits a properly escaped alternation instead of a string you have to trust. Decide deliberately about `includeAll`: with `=~` and `allValue: ".*"` the panel will happily aggregate namespaces it was never designed to cover, which is a different wrong answer rather than no answer. Then fix the reading that actually caused the harm. "No data" and "zero" are different statements and the panel drew them identically; make the ratio panel show its denominator, or pair it with a panel on the raw request rate, so an empty result is visibly an unanswered question rather than a flat green line. Finally, close the provisioning trap: keep the file as the source of truth, but put the repository path in the dashboard description so the next engineer does not spend an incident applying a change that will be discarded. If the change window is closed and the fix cannot ship today, the honest stopgap is to pin the alert annotation link to a single namespace so the panel resolves - with an owner, an expiry, and a note in the channel saying the panel is filtered, never an open-ended workaround that outlives the memory of why it exists.

Verification

Verify from the path that failed, not from the path that worked. Open the dashboard through the alert annotation link, with both namespaces pinned, in a private window that carries no persisted variable state - that is the exact client that saw "No data", and the author browser is the least representative client on the estate. Then read Inspect, Query and confirm the substituted expression contains `namespace=~"checkout-eu|checkout-us"`, because the pre-substitution text in the panel editor is not what the data source received and never was. Take that substituted expression and run it against `/api/v1/query` directly; a non-empty result array is the proof, and a rendered panel is not, since a panel can be rendering a cached frame. Wait out one full provisioning interval and reload: a fix that has not survived a reconcile has not been shown to be a fix at all, which is precisely what went wrong twice already. Prove the empty-result case now reads as empty by selecting a namespace with no traffic and confirming the panel says something a reader cannot mistake for zero. Close the audit by grepping the dashboard repository for an exact matcher fed by a variable and requiring no matches outside the panels you have deliberately reviewed.

Prevention

Treat a variable becoming multi-value as a breaking change to every panel that reads it. The edit is one checkbox in the variable editor, it touches no panel, and it silently changes the type of the string every panel is interpolating; nothing in Grafana warns and nothing in the JSON diff makes it look dangerous. Default to `=~` for any matcher fed by a variable and reserve `=` for values that are structurally incapable of being a list, and add the grep to CI so a copied panel cannot reintroduce it. Review the substituted query, not the authored one - Inspect, Query is the only view in Grafana that shows what the data source received, and every diagnosis that skips it is reasoning about a string that was never sent. Test dashboards from a clean session before declaring them working, because persisted variable state makes the author the one person who cannot see the fault. Make the file the unambiguous source of truth and say so where an operator will see it at 03:00, so a UI fix on a provisioned dashboard is understood as a note rather than a change. And write into the incident checklist that a "No data" panel is an unanswered question: the failure that cost eleven minutes here was not the missing series, it was an experienced engineer reading absence as evidence of health and recording it in the timeline.

Reported symptoms

At 14:34 CheckoutErrorRatioHigh fires. The on-call engineer follows the runbook link in the alert annotation, which opens the checkout service dashboard with the affected namespaces already pinned.

The panel named “Checkout 5xx ratio” says No data.

At 14:41 the incident commander writes into the timeline: error ratio flat, errors are zero, looking elsewhere. The team spends the next eleven minutes on the load balancer. The error ratio was 4.8% the whole time.

By the time anyone questions the panel, the shape of the problem looks like four unrelated things:

  • The panel directly to its right - request rate, same data source, same time range, same dashboard - renders perfectly.
  • Copying the panel’s expression into Explore returns data immediately. Two engineers take this as proof that the query is correct, which it is, and therefore that the panel must be a rendering bug, which it is not.
  • The engineer who built the dashboard cannot reproduce it. The panel renders for them, in their browser, while they are being asked to look at it.
  • The same three panels - two on this dashboard, one on the payments dashboard - have been reported before. Each was fixed in the Grafana UI by a different person, each fix was confirmed working, and each time the fault returned within the hour. The ticket has been reopened twice and is currently labelled “intermittent Grafana bug”.

Three of those four observations are true and none of them is about a bug in Grafana.

Evidence provided

The first useful move is to stop looking at the query in the panel editor and look at the query the data source received. Panel menu, Inspect, Query tab:

Expr:  sum(rate(http_requests_total{job="checkout",namespace="checkout-eu|checkout-us",code=~"5.."}[5m]))
       /
       sum(rate(http_requests_total{job="checkout",namespace="checkout-eu|checkout-us"}[5m]))
Step:  15s

The same view on the panel next to it, which works:

Expr:  sum by (namespace) (rate(http_requests_total{job="checkout",namespace=~"checkout-eu|checkout-us"}[5m]))
Step:  15s

Run the failing expression against Prometheus by hand. Note what comes back.

Read-only / SafeHTTP 200. success. empty.
$ curl -s -G http://prometheus:9090/api/v1/query \
--data-urlencode 'query=http_requests_total{job="checkout",namespace="checkout-eu|checkout-us"}'
{"status":"success","data":{"resultType":"vector","result":[]}}

Illustrative output

Read-only / Safethe label values that do exist
$ curl -s -G http://prometheus:9090/api/v1/series \
--data-urlencode 'match[]=http_requests_total{job="checkout"}' \
| jq -r '[.data[].namespace] | unique[]'
checkout-eu
checkout-us

Illustrative output

The variable definition, from the dashboard JSON:

{
  "name":       "namespace",
  "type":       "query",
  "datasource": { "type": "prometheus", "uid": "prom-prod" },
  "query":      "label_values(http_requests_total{job=\"checkout\"}, namespace)",
  "refresh":    1,
  "multi":      true,
  "includeAll": true,
  "allValue":   ".*"
}

The URL the alert annotation opens, and the URL the author’s bookmark opens:

alert link:  /d/checkout-svc/checkout?var-namespace=checkout-eu&var-namespace=checkout-us&from=now-3h&to=now
bookmark:    /d/checkout-svc/checkout?var-namespace=checkout-eu

And the provider file that owns the dashboard:

# /etc/grafana/provisioning/dashboards/prod.yaml
apiVersion: 1
providers:
  - name:                  prod-sre
    orgId:                 1
    folderUid:             sre
    type:                  file
    disableDeletion:       false
    updateIntervalSeconds: 30
    allowUiUpdates:        false
    options:
      path: /etc/grafana/provisioning/dashboards/prod-sre

Work the evidence before reading on

Everything above is a fact about something. The work is deciding what each one is a fact about.

  1. Put the two Inspect expressions side by side. They differ by one character. Which character, and what does that character mean to the PromQL parser?
  2. The substituted value is checkout-eu|checkout-us. Prometheus was asked whether a series has a namespace label equal to that. What is the honest answer, and is the answer an error?
  3. The author’s URL pins one namespace and the alert’s URL pins two. What is different about the string Grafana builds in each case?
  4. The variable also offers All, expanding to .*. Trace what that produces in the failing panel. Does it help?
  5. Two people fixed this in the UI and watched it work. Read the provider file and say how long their fix was alive.
  6. The panel was correct for eighteen months and nobody edited it. What changed in the dashboard that could break a panel without touching the panel?

Before continuing: the query returned HTTP 200 with an empty result array. Which of the two words “No data” is the misleading one?

Root cause

The matcher could not match, and nothing was wrong

= in PromQL is an exact label matcher. It compares the label value byte for byte against the string you gave it. =~ is a regex matcher, and | is alternation inside a regex.

When a Grafana variable is single-valued, both forms behave the same way for a plain value, which is why namespace="$namespace" looked correct for eighteen months and passed every review. When the variable is multi-valued, Grafana has to render a set of values into one string. The Prometheus data source joins them with |, escaping regex metacharacters as it goes, precisely so that the result drops into a =~ matcher and works. A data source with no interpolation rule of its own joins them with a comma instead. Either way the panel is now holding a string that only means anything to a regex matcher.

Substituted into =, the string is not alternation. It is a namespace name. Prometheus is being asked for series whose namespace label is literally checkout-eu|checkout-us, and no such series exists. The answer is an empty vector: HTTP 200, "status":"success", "result":[]. There is no error to find because no error occurred, and every layer downstream is behaving correctly - the data source plugin returns an empty frame, and Grafana draws an empty frame as “No data”.

All fails the same way and for the same reason. allValue: ".*" is written for a regex matcher; through = it asks for a namespace literally named .*.

This also explains the two observations that looked like contradictions. Explore returned data because the engineer pasted the pre-substitution expression and Explore resolved the variable against their own single-value selection. The author could not reproduce it because their browser had one namespace persisted from last week, and one selected value produces a string that an exact matcher can still match.

The change that broke it did not touch the panel

Nobody edited a query. Someone made namespace multi-value so that a second region could be viewed alongside the first - a small, obviously useful change that touched one field in the variable editor.

That edit changes the type of the string every panel interpolates, from “a value” to “a set rendered as a regex”. Every panel using =~ absorbed it without comment. Every panel using = became silently wrong. Grafana does not warn, no panel is modified, and the JSON diff is one boolean.

The fix kept being reverted

The dashboard is file-provisioned with allowUiUpdates: false. Grafana reconciles the provider every thirty seconds and rewrites the database row from the file on disk whenever they differ.

Both UI fixes were therefore genuine and correct and lived for under a minute. Both engineers watched the panel come back to life, believed the ticket, and closed it. The file in the repository never changed, so the reconcile put = back, and the fault reappeared as an “intermittent Grafana bug” - which is the only description that fits if you do not know the reconcile is happening.

Resolution

  1. Change the matcher in the repository, not in the UI. The panel expression becomes namespace=~"$namespace". A UI edit on this dashboard has a lifetime of one reconcile interval, which is how this ticket was closed twice without being fixed.
  2. Apply the same change to all three affected panels, then open the source dashboard they were copied from and fix it there too. A copied defect propagates on the next copy, and this one has already propagated twice.
  3. Audit the rest of the dashboard repository for an exact matcher fed by a variable. Every one of them is either correct because the variable can never hold a set, or a latent instance of this fault waiting for someone to tick multi.
  4. Decide about All rather than inheriting it. With =~ and allValue: ".*" the panel now aggregates every namespace the metric has, including ones this dashboard was never meant to cover. That is a different wrong answer, not a fix.
  5. Where a variable value can contain regex metacharacters, use the explicit ${namespace:regex} form so Grafana emits a properly escaped alternation instead of a string whose safety depends on the values happening to be simple.
  6. Give the ratio panel its denominator. Put the raw request rate beside it or under it, so an empty ratio is visibly an unanswered question while traffic is flowing.
  7. Put the repository path in the dashboard description, where an operator will read it at 03:00. The provisioning model is correct; it being invisible is what cost two engineers their afternoon.
  8. If the change cannot ship today, hold deliberately: pin the alert annotation link to a single namespace so the panel resolves, and record an owner, an expiry date and a note in the incident channel that the panel is filtered. An undated workaround outlives the memory of why it exists.
  9. Correct the incident timeline. The line that says "errors are zero" is now known to be false, and a postmortem that leaves it standing teaches the wrong lesson to everyone who reads it later.

Verification

  1. Reproduce through the path that failed. Open the alert annotation link, with both namespaces pinned, in a private window carrying no persisted variable state. The author browser is the least representative client on the estate and is the reason this went three rounds.
  2. Read Inspect, Query and confirm the substituted expression contains namespace=~"checkout-eu|checkout-us". The panel editor text is not evidence; it is the string before the substitution that matters.
  3. Run that substituted expression against /api/v1/query and require a non-empty result array. A rendered panel can be a cached frame; the API answer cannot.
  4. Select All and confirm the panel still resolves, then confirm the number it shows is the number you intended it to show now that it spans every namespace.
  5. Wait out one full provisioning interval and reload. A fix that has not survived a reconcile has not been shown to be a fix - this is the exact check that was skipped twice.
  6. Prove the empty case now reads as empty. Select a namespace with no traffic and confirm the panel shows something a reader under pressure cannot mistake for zero.
  7. Grep the dashboard repository for an exact matcher against an interpolated variable and require no matches outside the panels you deliberately reviewed. Wire the same grep into CI so the next copy cannot reintroduce it.
  8. Confirm the alert and the panel now agree. Fire the alert in a test rule against the same window and check that the panel is non-empty for the period the alert covers; a page and a panel that disagree is the condition this incident was made of.

Prevention

  • Treat making a variable multi-value as a breaking change to every panel that interpolates it. It is one checkbox, it modifies no panel, and it changes the type of the string every panel receives. Nothing warns, so the discipline has to come from the reviewer.
  • Default to =~ for any matcher fed by a variable. Reserve = for values that are structurally incapable of being a list, and say so in a comment when you use it.
  • Grep for the pattern in CI. An exact matcher against $var is cheap to detect in dashboard JSON and impossible to spot by eye in a panel editor.
  • Review the substituted query, not the authored one. Inspect, Query is the only view that shows what the data source received, and it is the first click in any “the panel is empty” investigation.
  • Test dashboards from a clean session. Persisted variable state means the author is the one person who cannot see the fault, and the author is usually the person asked to confirm it.
  • Make the source of truth visible. A file-provisioned dashboard with allowUiUpdates: false is a good default and a trap for anyone who does not know it applies; the dashboard description is where they will find out.
  • Never let a ratio panel stand alone. Without its denominator in view it cannot distinguish no errors from no traffic from no answer.
  • Write “No data is not zero” into the incident checklist, and mean it: the expensive part of this incident was a true observation recorded as a false conclusion.