Skip to content

Compare a canvas KPI against a target measure - #9820

Open
dfliess wants to merge 2 commits into
rilldata:mainfrom
dfliess:kpi-measure-comparison
Open

dfliess wants to merge 2 commits into
rilldata:mainfrom
dfliess:kpi-measure-comparison

Conversation

@dfliess

@dfliess dfliess commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

We wanted to show a measure next to its target in a canvas KPI, and we could not find a way to do it. If there is one and we missed it please let us know, feel free to close this.

What we found: a canvas KPI can only compare a measure with itself, over an earlier time range. But a budget, a target or a forecast is a second measure, so today there is no way to render "revenue vs target". We drew those cards by hand instead, as Vega custom charts, about 35 lines of positioned text per card. This PR is what we use now, in case it is useful to you too.

kpi_grid:
  metrics_view: sales
  measures: [revenue, gross_margin_pct]
  measure_comparisons:
    - measure: revenue
      compare_to: target_revenue
    - measure: gross_margin_pct
      compare_to: target_margin_pct
  comparison: [previous, percent_change]

How it works: the comparison query asks for the other measure over the same time range. It does not ask for the same measure over an earlier range. The result is then stored under the name of the first measure, so KPI.svelte renders it as before and we did not have to touch it.

Things we were not sure about

  • The name. We called it measure_comparisons. We avoided comparison_measures because that name already means something else in the codebase. Rename it if you prefer.
  • A list, not a map. We wrote each pair as a list item, because other canvas widgets already store per-measure settings that way. A map would work too.
  • An entry for a measure that the grid no longer shows is ignored, and not an error. If you remove a measure in the inspector, its entry stays in the YAML. We did not want that to break the whole KPI, because the inspector does not show this option, so nobody could fix it from the UI.
  • Percentage measures need delta, not percent_change. Rill already hides percent_change for percentage measures. delta then gives the difference in points. We wrote this in the docs instead of adding a special case.
  • It also works without a time dimension, which the time comparison does not.

Some questions. We do not know Rill well enough to answer these ourselves.

  1. We run two queries, one per measure, over the same range and the same filter. Maybe one query is enough, but that is a bigger change to the component and we did not want to make it here.
  2. There is no UI for this, it is YAML only. We did not know where the option should go in the inspector.
  3. We only did this in one place, the KPI widget. If you want the same thing in the leaderboard, the pivot or Explore, then the target probably belongs in the metrics view instead, and this should be built there. We did the small version because we did not know.

Happy to rename things or rework it, and just as happy to keep it on our side if you do not want it.

@nishantmonu51 nishantmonu51 added Type:Feature New feature request Area:Dashboard Size:M Medium change: 100-499 lines labels Sep 1, 2026

@nishantmonu51 nishantmonu51 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The branch conflicts with main in KPIProvider.svelte and kpi-grid/index.ts, and the rebase needs to carry more than a marker resolution. On main, totalQuery and comparisonTotalQuery are gated on supportsTotal (#9814), because a measure with required_dimensions has no single total and KPI.svelte hides its comparison block, and every request goes through mapEphemeralMeasuresForRequest / splitTimeSeriesMeasures (#9855, #9864) so adhoc_measures resolve. The new measureComparison branches check only showComparison && isValid && visible and send a raw [{ name: comparisonMeasureKey }], so once merged a kpi_grid measure with required_dimensions fires a comparison totals query the card never renders, and a compare_to pointing at an adhoc_measures entry is sent as a plain measure name and rejected by the runtime as unknown. The backend carries the mirror decision: the measures loop in validateKPIGrid on main accepts ephemeral names while the new compare_to check uses metricsViewHasMeasure only, so both sides need to agree on whether compare_to may name an ad-hoc measure.

One detail to preserve in the resolution: this PR moves .toLowerCase() out of KPI.svelte into the provider's time-comparison branch so a target's display name keeps its casing, while main still lowercases in KPI.svelte and that hunk auto-merges. The provider-side .toLowerCase() has to survive, or time-comparison labels change from "previous period" to "Previous period".

Comment thread runtime/canvas/component.go Outdated
if !ok {
return errors.New("each entry in 'measure_comparisons' must be an object with 'measure' and 'compare_to'")
}
if _, ok := pathutil.GetPathString(entry, "measure"); !ok {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

measure is only checked for being a string, so a typo such as measure: revenu reconciles cleanly, KPIGrid.svelte finds no matching entry, and the card silently keeps the time comparison with no error anywhere. Validating it against the metrics view the way compare_to is validated a few lines below would catch that while preserving the intended behaviour that an entry for a measure removed from the grid is inert, since a removed measure is still a measure of the metrics view.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a5fa389. measure is now checked against the metrics view and the ad-hoc measures, with a test for the typo case. A measure removed from the grid is still valid, as you said.

comparison?: ComponentComparisonOptions[];
// Measure to compare against over the primary time range (e.g. a target),
// instead of the time comparison. Takes precedence over it when set.
comparison_measure?: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comparison_measure lands on KPISpec, which is also the shape of the standalone kpi component, and validateKPI does not know the key, so the two component types now validate the same concept differently. Since KPIProvider is only instantiated from KPIGrid, either keep this out of the public kpi spec or mark it as internal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a5fa389. I kept it out of the spec: KPIGrid passes the target to KPIProvider as a prop, and KPISpec is the same as on main.

A canvas KPI can only compare a measure with itself, over an earlier time
range. But a budget, a target or a forecast is a second measure, so there is
no way to render "revenue vs target" today, and those cards end up drawn by
hand as Vega custom charts.

`measure_comparisons` says which measure a KPI is compared against:

```yaml
kpi_grid:
  metrics_view: sales
  measures: [revenue]
  measure_comparisons:
    - measure: revenue
      compare_to: target_revenue
  comparison: [previous, percent_change]
```

The comparison query then asks for that measure over the same time range,
instead of the same measure over an earlier range. The result is stored under
the name of the first measure, so the existing rendering path does not change.
Each pair is a list item, because other canvas widgets already store
per-measure settings that way.

Both measures must belong to the same metrics view, and the reconciler checks
`compare_to`. An entry for a measure the grid no longer shows is ignored, and
not an error: removing a measure in the inspector leaves its entry behind, and
breaking the resource for that would leave a state nobody can fix from the UI,
since the inspector does not show this option.

It also works without a time dimension, which the time comparison does not.
For a percentage measure use `delta`, since Rill already hides
`percent_change` there, and `delta` gives the difference in points.
- Gate the target's totals query on supportsTotal, for the KPI's own
  measure and for the target. A target without a single total shows no
  comparison.
- Send the target through mapEphemeralMeasuresForRequest and
  splitTimeSeriesMeasures, so compare_to can name an adhoc_measures
  entry. The backend now accepts that too.
- Validate the measure of each measure_comparisons entry against the
  metrics view and the ad-hoc measures.
- Deleting an ad-hoc measure also drops the entries that compare
  against it, so the grid does not break on a leftover compare_to.
- Pass the target to KPIProvider as a prop instead of adding
  comparison_measure to the public KPISpec.
@dfliess
dfliess force-pushed the kpi-measure-comparison branch from 187a2f4 to a5fa389 Compare September 21, 2026 15:28
@dfliess

dfliess commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review. I rebased on main and put the changes in a second commit (a5fa389), so they are easy to see.

  • The target's totals query now waits for supportsTotal, like the time comparison. I also check the target itself: if it has required_dimensions, the card shows no comparison.
  • The target goes through mapEphemeralMeasuresForRequest and splitTimeSeriesMeasures, so compare_to can name an adhoc_measures entry. validateKPIGrid accepts it too. Every other measure field in canvas accepts ad-hoc measures, so this seemed the consistent choice.
  • When you delete an ad-hoc measure in the editor, removeMeasureFromComponentSpec now also drops the entries that compare against it. Otherwise a leftover compare_to would break the whole grid, and the editor cannot fix it.
  • The .toLowerCase() stays in the provider, and the time comparison label is still lowercase.

Happy to move the target to the metrics view later if you prefer.

This branch has not been deployed

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

Labels

Area:Dashboard Size:M Medium change: 100-499 lines Type:Feature New feature request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants