Skip to content

feat: ephemeral measures UI for explore and canvas dashboards - #9864

Open
nishantmonu51 wants to merge 10 commits into
mainfrom
ephemeral_measures
Open

feat: ephemeral measures UI for explore and canvas dashboards#9864
nishantmonu51 wants to merge 10 commits into
mainfrom
ephemeral_measures

Conversation

@nishantmonu51

@nishantmonu51 nishantmonu51 commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Frontend for ephemeral measures (labelled "Adhoc measures" in the UI) on top of the expression measure compute added in #9855: users define ad-hoc measures like profit = revenue - cost in the UI, without editing the metrics view YAML.

  • Explore: create and edit ephemeral measures from the measures selector and the pivot sidebar (EphemeralMeasureDialog). Definitions live on the explore state and are honored by every view: big numbers, time series, leaderboards, dimension table, time dimension detail, pivot (including comparison chips, conditional formatting and exports).
  • URL state and presets: definitions are encoded in the ephemeral URL param (name, display name, expression, optional format preset) and in ExplorePreset.ephemeral_measures, so shared links, bookmarks and the most-recent-state loader reproduce them. Invalid or unknown definitions are dropped with a user-visible error, along with any columns or sorts that reference them.
  • Canvas: components with measure fields (KPI, KPI grid, table/pivot, leaderboard, all charts) accept a calculated_measures renderer prop, editable from the inspector (EphemeralMeasureEditor) and offered inline from the field selectors. Requests attach the expression compute and labels/formatters resolve like for any spec measure.
  • Validation: a client-side parser (expression-parser.ts) mirrors the runtime allowlist (arithmetic, numeric literals, parentheses, unary minus, abs/round/floor/ceil/sqrt/ln/exp/power/coalesce/nullif/greatest/least) and gives inline errors. Names must not collide with metrics view fields or comparison accessor suffixes, and expressions can only reference plain measures (no window, required-dimension or time-comparison measures).
  • Sharing surfaces: public URLs keep only ephemeral measures whose referenced measures are visible to the recipient; alerts and scheduled reports never prefill an ephemeral measure since the saved query has no definition to resolve it against.
  • Adds EphemeralMeasure to the DashboardState proto for the legacy proto-state path.

Part of PLAT-135

Checklist:

  • Covered by tests
  • Ran it and it works as intended
  • Reviewed the diff before requesting a review
  • Checked for unhandled edge cases
  • Linked the issues it closes
  • Checked if the docs need to be updated. If so, create a separate Linear DOCS issue
  • Intend to cherry-pick into the release branch
  • I'm proud of this work!

Frontend for ephemeral (calculated) measures on top of the `expression`
measure compute: users define ad-hoc measures such as
`profit = revenue - cost` from the explore measure selector, the pivot
sidebar and the canvas inspector. Definitions live on the explore state
(encoded in the `ephemeral` URL param and explore preset) or on a canvas
component's `ephemeral_measures` prop, and every view attaches the
expression compute to matching request measures.

Claude-Session: https://claude.ai/code/session_01NW6ETnqzwL5jdoVCKQPHNf
The calc -> ephemeral rename also rewrote five CSS `calc(` tokens in
MeasureBigNumber.svelte. The i18n guard flagged hardcoded strings in the
ephemeral measure editors and in two files that now import messages, so
those strings move to the catalogs and the format preset list is shared.

Claude-Session: https://claude.ai/code/session_01NW6ETnqzwL5jdoVCKQPHNf
The YAML property on canvas components keeps the user-facing name
`calculated_measures`; the runtime validator and tests follow. Code
identifiers stay ephemeral-named.

Claude-Session: https://claude.ai/code/session_01NW6ETnqzwL5jdoVCKQPHNf
User-facing labels, titles and URL-state errors now say "adhoc measure"
instead of "calculated measure". Code identifiers and the canvas
`calculated_measures` property are unchanged.

Claude-Session: https://claude.ai/code/session_01NW6ETnqzwL5jdoVCKQPHNf
- Canvas chart validation accepts the chart's own ephemeral measures.
- The active (sort) measure resolves ephemeral measures so single-measure
  leaderboards format values instead of rendering blanks.
- The client expression parser uses the server's depth limit (32).
- "All measures visible" counts ephemeral measures, so a hidden spec measure
  is never reported as visible to public URLs, chat context or the editor.
- Public URLs keep pivot chips of ephemeral measures that survive sanitizing.
- Editing a scheduled report recovers ephemeral definitions from the saved
  request instead of a non-existent explore store.
- Removing the last visible measure re-seeds spec defaults.
- Aliases derived from display names always pass name validation (shared
  slugify), and URL-state validation reserves every metrics view field.

Claude-Session: https://claude.ai/code/session_01NW6ETnqzwL5jdoVCKQPHNf
Resolves conflicts with the unified expression filter refactor (#9746):
adopts the new `where` construction and runes-mode components while
keeping the ephemeral measure request mapping, and points the URL-state
spec at the moved test helpers.

Claude-Session: https://claude.ai/code/session_01NW6ETnqzwL5jdoVCKQPHNf

@AdityaHegde AdityaHegde 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.

Measure used for QA issues in openrtb project,

name: spending_per_million_impressions
expr: overall_spend/ impressions * 1000
  1. Getting effect_update_depth_exceeded when sorting by computed measure in dimension display. Not a 100% sure what is triggering this, doesnt always happen.
  2. Seeing invalid measure \"spending_per_million_impressions\": measure \"spending_per_million_impressions\" not found for this measure's time series query error when comparing by dimension.
  3. Report dialog's Columns section doesnt show displayName, shows the raw name instead.
  4. Computed measure is not carried over to Open in browser in report email. It is present in the csv download.
  5. Alert seems to not recognise an ephemeral measure.

UX feedback,

  1. Do we need the measure in url even if it hidden? When user has a lot of such measures the url can unnecessarily get bloated. How about saving these in localStorage by metrics view? Any place this metrics view is referenced, user can add ephemeral measures to visible measure, including canvas. One big advantage is the measures are not lost if the user has not saved a bookmark.
  2. Should we show a warning when creating public url? If a dependant measure is hidden the computed measure gets hidden and the user wont know exactly why.
  3. Context columns like delta are not displayed for these measures. Feels a bit weird when timeseries has a line for comparison time range but no context column. I am guessing this is because we have oneof for MeasureCompute? Is it planned to support both?
  4. Do we need nulls last when sorting these? Or is it intended to show null at the top when sorting in desc order?
  5. In pivot you can add a measure from sidebar but only edit it if it is added to columns. Maybe we should add an edit in sidebar as well?

Comment thread runtime/canvas/component.go Outdated
Comment thread web-common/src/components/searchable-filter-menu/SearchableFilterChip.svelte Outdated
class="truncate min-w-0 flex-1 text-left pointer-events-none text-fg-primary"
>
{displayName}
{#if ephemeralNames.has(item.id)}

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.

Probably out of scope of this PR, but there seems to be a lot of duplication here if we need to add 5 instances for ephemeral editing.

Subcomponent is one option for shown vs hidden items. For tooltip maybe we can add a MaybeShowTooltip helper component.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Extracted DashboardMetricsItemLabel, which owns the label, the ƒx marker and the edit button. The five call sites are now one component each and the file drops 51 lines.

Comment thread web-common/src/components/searchable-filter-menu/SearchableMenuContent.svelte Outdated
</div>

{#if ephemeralEditorOpen && component}
<EphemeralMeasureEditor

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.

This also feels like a generic component like MultiFieldInput is responsible for a measure editor.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The editor is mounted once at the inspector root (ComponentsEditor) and driven by canvasEphemeralMeasureEditor. The field inputs only set that store, so they no longer own an editor.

Comment thread web-common/src/features/canvas/inspector/fields/FieldSelectorDropdown.svelte Outdated
@@ -0,0 +1,613 @@
/**

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.

How about reusing the filters parser? We use nearly and not hand write tokeniser and grammer. We could maintain the allow list only to match measure_expression.

Another option is to add an API to compile and get errors from backend.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Replaced the hand-written tokeniser and recursive-descent parser with a nearley grammar (measure-expression.ne), built by build-measure-expression-grammar alongside the filter and rill-time grammars. The allow-list check against the metrics view stays in the parser wrapper.

- Rename the canvas YAML property to `adhoc_measures` so the user-facing
  name matches the "Adhoc measure" UI label.
- Add an `additional-label` slot to SearchableFilterChip instead of the
  usage-specific `fx` prop.
- Use `onCheckedChange` in SearchableMenuContent, replacing the deferred
  onclick workaround for the checkbox self-toggle race.
- Render the menu's action slot in its own full-width bordered row rather
  than the select-all footer, fixing the hover in canvas field selectors,
  and share CreateEphemeralMeasureButton across explore and canvas.
- Report dialog columns show ephemeral measures by display name.
- Recover ephemeral definitions from a report's saved query so "Open in
  browser" restores the measure.
- Alerts accept ephemeral measures; comparison and percent-of-total
  criteria stay disabled since those computes resolve against the spec.

Claude-Session: https://claude.ai/code/session_011wsNGLshPEUqH6PePMnBkv
Comparing by dimension switches the default TDD chart to a Vega stacked
bar, which builds its own query through the component chart provider.
`createTDDCartesianSpec` never set `adhoc_measures`, so the ephemeral
measure went out as a bare name and the runtime rejected it with
`invalid measure "...": measure "..." not found`.

Also adds a regression test asserting that every explore query naming an
ephemeral measure carries its definition, across the time series, totals
and dimension-comparison paths in both the explore and TDD views.

Claude-Session: https://claude.ai/code/session_011wsNGLshPEUqH6PePMnBkv
@nishantmonu51

nishantmonu51 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

On the reported bugs:

2. measure not found when comparing by dimension — fixed in c62e986. Comparing by dimension flips the TDD chart to a Vega stacked bar (chart-series.ts:95), which builds its own query through the component chart provider. createTDDCartesianSpec never set adhoc_measures, so the measure went out as a bare name and hit the no-compute branch of ResolveMeasure. Definitions now thread MeasureChartTDDChartcreateTDDCartesianSpec. That is also why it looked intermittent: it only affects the Vega chart types, which is exactly what comparison-by-dimension switches into.

3. Report dialog columns now resolve ephemeral measures to their display name, and they can be re-added as columns.

4. Open in browser recovers the definitions from the report's saved query, so it no longer depends on the stored dashboard state.

5. Alerts accept ephemeral measures now — the saved query embeds the expression, so the name resolves without dashboard state. Comparison and percent-of-total criteria stay disabled for them, since LookupMeasure resolves those computes against the metrics view.

1. effect_update_depth_exceeded — reproduced and fixed in d4cfd07. See the follow-up comment below; this paragraph originally said it was not reproduced.

UX points 1-5 are unaddressed and still open for discussion.

`timeoutID` was `$state`, and the effect that suppresses cell tooltips while
scrolling both reads it (to clear the pending timeout) and writes it. Each
write re-ran the effect, which wrote again, until Svelte aborted with
`effect_update_depth_exceeded`. Nothing renders `timeoutID`, so a plain
variable is enough.

Reproduced in the browser by sorting a dimension table by an ephemeral
measure: dropping its context columns resizes the table, which fires the
scroll handler that sets `scrolling`.

Claude-Session: https://claude.ai/code/session_011wsNGLshPEUqH6PePMnBkv
@nishantmonu51

Copy link
Copy Markdown
Collaborator Author

1. effect_update_depth_exceeded — reproduced in the browser and fixed in d4cfd07.

timeoutID in DimensionTable.svelte was $state, and the effect that suppresses cell tooltips while scrolling both reads it (to clear the pending timeout) and writes it. Each write re-ran the effect, which wrote again, until Svelte aborted. Nothing renders timeoutID, so it is now a plain variable.

Sorting by an ephemeral measure is what surfaced it: the measure has no context columns, so switching the sort to it resizes the table, which fires the scroll handler that sets scrolling. That is also why it looked intermittent — the loop needs a scroll event, not the sort itself. Verified against the local dev project: before the change the first click on the column header threw; after it, sorting plus vertical and horizontal scrolling produce no errors.

Worth noting the loop was never specific to ephemeral measures — any scroll of the dimension table could hit it.

UX feedback:
- Persist ad-hoc measure definitions in a per-metrics-view localStorage
  library, so they survive without a bookmark and are shared by every
  explore on the same metrics view. The URL now carries only the
  definitions the explore state references, keeping shared links short.
- Warn when creating a public URL that drops an ad-hoc measure because it
  references a measure the recipient cannot see.
- Support comparison context columns (delta, delta percent, percent of
  total) for ad-hoc measures: a referenced measure may now resolve to an
  expression measure defined in the same query.
- Sort ad-hoc measures with `NULLS LAST` in both dialects.
- Allow editing and deleting an ad-hoc measure from the pivot sidebar,
  not just from the columns list.

Code review:
- Replace the hand-written tokeniser and recursive descent parser with a
  nearley grammar, matching the filter and rill-time parsers.
- Move the canvas ad-hoc measure editor into a store mounted once at the
  inspector root, so the generic field inputs only open it.
- Extract `DashboardMetricsItemLabel` to remove the duplicated label,
  marker and edit button markup in `DashboardMetricsDraggableList`.

Claude-Session: https://claude.ai/code/session_01E6mxYCo1KqJpcxr1NMKPRA
@nishantmonu51

Copy link
Copy Markdown
Collaborator Author

On the UX feedback, addressed in f7576ee:

1. Definitions now live in a per-metrics-view localStorage library (rill:app:adhoc-measures:<metricsView>), so they survive without a bookmark and are shared by every explore on that metrics view. The URL carries only the definitions the explore state references, so hidden ones no longer bloat it. Canvas still keeps definitions on the component; consuming the shared library there is a follow-up.

2. Creating a public URL now lists the ad-hoc measures that will be dropped, with the hidden measures each one references.

3. Supported now, not a oneof limitation. ResolveMeasure resolved comparison and percent-of-total base measures via LookupMeasure, which only sees metrics view measures; resolveReferencedMeasure also resolves expression measures defined in the same query. Delta, delta percent and percent of total all work for ad-hoc measures, and the frontend gates are gone.

4. Not intended. Both dialects now emit NULLS LAST regardless of direction, and safe divide returns NULL via NULLIF(den, 0) instead of infinity or NaN.

5. Added edit and delete to the pivot sidebar.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants