Skip to content

feat(analytics): add useMetricView client experience - #488

Draft
atilafassina wants to merge 36 commits into
mv-metadata-pipelinefrom
mv-hook
Draft

feat(analytics): add useMetricView client experience#488
atilafassina wants to merge 36 commits into
mv-metadata-pipelinefrom
mv-hook

Conversation

@atilafassina

@atilafassina atilafassina commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Delivers the complete Metric Views client experience on top of #512: applications can query typed Metric Views, format results from catalog metadata, and build interactive cross-filtering charts without duplicating query or presentation logic.

Delivered

  • Add useMetricView with inferred selected-row types, metadata, loading/error state, cancellation, HMR refresh, and shared warehouse-readiness reporting.
  • Consolidate the Analytics SSE lifecycle used by useMetricView and useAnalyticsQuery.
  • Add framework-agnostic formatValue, formatLabel, toD3Format, and toMetricFilter utilities.
  • Add chart click and controlled-selection APIs (onDataClick and selected) for cross-filter interactions.
  • Add an end-to-end Metric Views playground covering metadata-driven labels, formatting, filters, charts, and table output.
  • Document hook usage, formatting, filters, and Plotly/ECharts integration.
  • Cover hook typing, SSE behavior, format/filter utilities, chart interactions, and the playground integration with focused tests.

Stacked on #512.

@github-actions

Copy link
Copy Markdown
Contributor

🔬  Run evals on this PR  ·  Go to Evals Monitor →

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle size report

Compared against bundle-size-baseline.json (main).

@databricks/appkit

npm tarball (packed): 816 KB (+6.5 KB) — gzipped download (dist + bin; excludes release-only docs/NOTICE).

dist raw gzip
JS (runtime) 837 KB (+5.1 KB) 292 KB (+2.1 KB)
Type declarations 308 KB (+2.2 KB) 106 KB (+1.0 KB)
Source maps 1.6 MB (+12 KB) 549 KB (+4.6 KB)
Other 11 KB 3.7 KB
Total 2.8 MB (+19 KB) 950 KB (+7.8 KB)
Per-entry composition (own code — deps external (as shipped))
Entry Initial (gz) Lazy (gz) Total (gz) node_modules (min) Own code (min)
. 87 KB (+510 B) 2.5 KB 90 KB (+510 B) external 285 KB (+1.3 KB)
./beta 45 KB 458 B 45 KB external 129 KB
./type-generator 20 KB (+353 B) 0 B 20 KB (+353 B) external 57 KB (+615 B)

Chunks:

Entry Chunk Load Size (gz)
. index.js initial 83 KB
. utils.js initial 4.0 KB
. remote-tunnel-manager.js lazy 2.5 KB
./beta beta.js initial 29 KB
./beta stream-manager.js initial 5.8 KB
./beta wide-event-emitter.js initial 3.2 KB
./beta databricks.js initial 3.0 KB
./beta configuration.js initial 2.1 KB
./beta service-context.js initial 1.3 KB
./beta client.js initial 431 B
./beta client-options.js initial 220 B
./beta supervisor-api.js lazy 193 B
./beta databricks.js lazy 142 B
./beta index.js lazy 123 B
./type-generator index.js initial 20 KB

@databricks/appkit-ui ⚠️ over budget

npm tarball (packed): 335 KB (+30 KB) — gzipped download (dist + bin; excludes release-only docs/NOTICE).

dist raw gzip
JS (runtime) 382 KB (+22 KB) 128 KB (+8.8 KB)
Type declarations 226 KB (+21 KB) 82 KB (+7.9 KB)
Source maps 732 KB (+46 KB) 242 KB (+17 KB)
CSS 16 KB 3.3 KB
Total 1.3 MB (+89 KB) 455 KB (+34 KB)
Per-entry composition (consumer bundle — deps bundled, peerDeps external)
Entry Initial (gz) Lazy (gz) Total (gz) node_modules (min) Own code (min)
./js 5.0 KB (+659 B) 49 KB 54 KB (+659 B) 208 KB 13 KB (+1.7 KB)
./js/beta 20 B 0 B 20 B 0 B 0 B
./react 431 KB (+1.4 KB) 49 KB 479 KB (+1.4 KB) 1.3 MB (+12 B) 172 KB (+4.2 KB)
./react/beta 20 B 0 B 20 B 0 B 0 B

Chunks:

Entry Chunk Load Size (gz)
./js index.js initial 4.8 KB
./js chunk initial 120 B
./js apache-arrow lazy 49 KB
./js/beta beta.js initial 20 B
./react index.js initial 429 KB
./react tslib initial 2.1 KB
./react apache-arrow lazy 49 KB
./react/beta beta.js initial 20 B

⚠️ Over budget: a package's shipped tarball, or a browser entry's consumer bundle (deps included), grew by more than 5% (and >10 KB). This check will fail — reduce the size, or acknowledge the increase by updating bundle-size-baseline.json.

@atilafassina atilafassina changed the title feat(analytics): metric-view runtime hook + format utilities + payload metadata (PR5) feat(analytics): useMetricViews Jul 23, 2026
@atilafassina
atilafassina force-pushed the mv-hook branch 2 times, most recently from 7456890 to 613d5cb Compare July 29, 2026 08:24
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

🤖 AppKit PR bot

🔬 Run evals

Start an eval for this PR from the evals-monitor app: Go to Evals Monitor →

📦 Try this PR's app template

Scaffolds a new app from this PR's SDK build. Run it in any folder (requires the GitHub CLI — gh auth login — and the Databricks CLI):

gh run download 30925696129 -R databricks/appkit -n appkit-template-0.53.0-pr.4bb2df1-mv-hook-488 -D appkit-pr-488 \
  && unzip -o "appkit-pr-488/appkit-template-0.53.0-pr.4bb2df1-mv-hook-488.zip" -d "appkit-pr-488" \
  && databricks apps init --template "appkit-pr-488"

The template pins @databricks/appkit and @databricks/appkit-ui to tarballs built from this branch, so the scaffolded app runs against this PR's code.

Freeze the three shared seams the metric-view hook/server/generator phases
compile against:
- S1: MetricColumnMeta + MetricViewsMetadata value types in packages/shared
- S2: optional per-column metadata on the SSE result message + makeResultMessage
- S3: base MetricRegistry, MetricKey, Infer* helpers, MetricFilter mirrors, and
  UseMetricViewOptions/UseMetricViewResult in appkit-ui hook types

Types only (plus a makeResultMessage passthrough); existing /query callers are
unchanged since metadata is optional.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
… metadata (PR5 1-4)

Implement the four parallel PR5 slices against the frozen phase-0 contracts:

- Generator (type-generator): emit metric-views.ts (was .d.ts) carrying both
  the erasable declare-module MetricRegistry augmentation and a runtime
  `export const metricViewsMetadata = {...} as const`. Header is a type-only
  import (no runtime side-effect import on the Node server). Rename propagated
  through METRIC_TYPES_FILE, mvOutFile, vite-plugin, CLI announce, and tests;
  generated .ts added to Biome ignore.
- Server (analytics plugin): accept an injected `metricViewsMetadata` config
  and stamp the responding metric's per-column slice (scoped to the requested
  measures/dimensions) into the SSE result payload. Metadata is response
  decoration — it never enters composeMetricCacheKey and never alters SQL.
- Hook (appkit-ui): `useMetricView(key, opts)` mirroring useAnalyticsQuery
  (SSE, abort-on-arg-change, autoStart), returning
  { data, loading, error, errorCode, metadata }.
- Formatters (appkit-ui js): pure, React-free, tree-shakeable formatValue /
  formatLabel / toD3Format taking the format spec / column metadata as args.

Also fix a pre-existing latent port collision: analytics.integration.test.ts
and server.integration.test.ts both hardcoded port 9879; under the added
metric-test weight they could bind concurrently in the shared vitest worker
pool, so an analytics request hit the server-plugin app and 404'd. Switch the
analytics integration test to an OS-assigned ephemeral port (port: 0), matching
the files plugin integration test.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
…se 5)

Integrate the metric-view runtime in dev-playground and document it:

- Regenerate the generated artifact as shared/appkit-types/metric-views.ts
  (delete the legacy .d.ts). Verified byte-for-byte identical to a live
  `generate-types` DESCRIBE against a real UC Metric View (warehouse
  dd43ee29fedd958d, dogfood): display_name/format/description genuinely flow
  from the UC YAML through typegen into the runtime metricViewsMetadata const.
- Inject the const server-side: analytics({ metricViewsMetadata }).
- Add a /metric-views demo route calling useMetricView("revenue", …) with
  timeGrain/timeDimension, rendering a chart + table whose labels and value
  formats come from the payload metadata (never hand-typed), degrading
  gracefully when metadata is absent.
- Docs: extend plugins/analytics.md with the useMetricView + format-utility +
  metricViewsMetadata injection story (Plotly + ECharts examples), and fix the
  stale metric-views.d.ts references in development/type-generation.md.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
… builder to /js

Move the twelve-operator MetricFilter grammar out of react/hooks/types.ts into a
canonical, framework-agnostic js/metric-filter/ module and add a toMetricFilter
builder that compiles a { dimension -> value(s) } shorthand into a MetricFilter
(scalar -> equals, array -> in, omit undefined/empty). react/hooks/types.ts now
re-exports the types so the /react public surface and UseMetricViewOptions.filter
are unchanged. Wire the dev-playground metric-views route's buildFilter onto
toMetricFilter, keeping only the app-specific cross-filter facet-exclusion local.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
Adds two public chart props, inherited by every chart type via the factory:
- onDataClick?(datum: ChartClickDatum): fire-and-forget click callback. base.tsx
  builds a memoized internal echarts onEvents={{ click }} only when the handler is
  set (no idle listener), mapping raw params via the pure mapToDatum. Pointer-only
  (canvas) — documented to require a keyboard-accessible equivalent.
- selected?: string | string[]: controlled, name-based visual emphasis. base.tsx
  runs the pure applySelectionEmphasis transform over the built option so matching
  bar/pie-donut categories stay prominent and the rest dim; no-op when unset.

ChartClickDatum is the only new public (barrel) symbol; mapToDatum,
applySelectionEmphasis and SelectionEmphasisOptions are internal. echarts types
stay out of the public API (datum.raw is unknown). Phases 1 and 2 are committed
together so the producer helpers have their consumer (satisfies knip).

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
…egment charts

Wire the new appkit-ui chart props into the metric-views demo: the region BarChart
and segment DonutChart get onDataClick={(d) => setDimension(dim, d.name)} — reusing
the same setDimension the table row-click uses — and selected={selection[dim]} so the
clicked category is emphasized. LineChart, Table, and the existing (keyboard-accessible)
table row-click are unchanged.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
… review cleanup

Address adversarial-review findings on the useMetricView / metric-route branch:

- Type soundness: infer rows from the selected measure/dimension tuples
  (PickMetricRow) and correlate timeDimension/timeGrain to temporal dims only.
- Formatting: preserve every currency symbol the generator emits end-to-end and
  keep bigint precision (no Number() rounding).
- Cache correctness: stamp fresh per-column metadata AFTER the cached execute()
  so a cache hit never serves stale labels/formats after a redeploy.
- Charts: guard selected="" as a no-op, split [x,y] click tuples into x/y, and
  memoize onEvents on handler presence (no listener thrash per SSE tick).
- Typegen: sweep a stale sibling metric-views.d.ts on upgrade and reject a
  .d.ts mvOutFile.
- Drop the unused public notify export and the fake "Write back" demo; remove
  the dead autoStart option; align AnalyticsStreamMessage; tighten the biome
  ignore; add tests + comment cleanup.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
Drop comments that restated adjacent JSDoc/functions:
- base.tsx: the inline-handler re-subscribe rationale lived in both the
  `interactive` and `onEvents` comments; keep it once at `onEvents` (where
  the subscription happens) and point the `interactive` note at it.
- use-metric-view.ts: result-branch comment re-explained metadata narrowing
  already documented on `asMetricMetadata`; defer to that doc.
- js/format/index.ts: call-site comment restated `currencyPrefix`'s own JSDoc.
- render-types.ts: convert `generateMetricTypeDeclarations`'s // block to
  /** */ so the exported function's rationale surfaces on IDE hover.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
useMetricView never implemented autoStart — the row was copied from
useAnalyticsQuery's options table. The hook's effect calls start()
unconditionally and UseMetricViewOptions has no such field.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
- Centralize analytics SSE parsing across React hooks
- Share metric filter types and runtime operator vocabulary
- Reuse metadata and label formatters; refresh size baseline

Signed-off-by: Atila Fassina <atila@fassina.eu>
- analytics.md: make the Plotly and ECharts chart-library references
  links to plotly.com/javascript and echarts.apache.org; minor wording
  tidy (hardcode).
- charts/types.ts: trim the ChartClickDatum doc comment.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
Phase 1 of typegen-ci-resilient-describe. In blocking (`--wait`) mode the
type generator now suppresses the `.d.ts` write on ANY degraded result
(query `result: unknown` or degraded metric), leaving the committed types
untouched as the CI fallback of record, then throws as before. The prior
path wrote degraded (`unknown`) declarations first and threw after, which
clobbered good committed types on a fresh CI checkout — including via the
auth/timeout/bad-id/DELETED fatal-degrade path.

Non-blocking mode is unchanged (still writes degraded types for the
detached worker to refresh). Tests inverted to assert no-write-on-degrade
while preserving throw + behavioral assertions; adds coverage for the
query-side fatal-degrade clobber-prevention case.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
Phase 2 of typegen-ci-resilient-describe. Adds a pure
classifyBlockingFailure(error) → "deterministic" | "environmental" to
type-generator/errors.ts, building on the existing getErrorStatus and
isConnectivityError helpers.

Deterministic (build must crash regardless of committed types): HTTP 404
(bad warehouse id) and 400 (malformed request), checked first and walked
through cause/AggregateError chains. Environmental (has-types gate applies
later): 401/403 auth, connectivity, DELETED/DELETING, wait-timeout, and any
unrecognized failure (the default). The auth status set is a one-line
change point for the auth-owning team. No behavior change to
isConnectivityError. Adds tests/errors.test.ts.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
…wait

Phase 3 of typegen-ci-resilient-describe (join point). Wires the
classifyBlockingFailure taxonomy into the reordered blocking write path so
`--wait` degrades gracefully on environmental failures instead of always
crashing:

- Deterministic failures (SQL syntax, HTTP 404/400) still crash the build.
- Environmental failures (401/403 auth, connectivity, DELETED/DELETING,
  wait-timeout, unrecognized) now flow through a has-types gate: if committed
  analytics/metric-views .d.ts exist, skip the (already-suppressed) write,
  emit one loud greppable stderr warning naming the coarse cause
  (auth blocked / warehouse unreachable / warehouse unavailable) + warehouse
  id, and exit 0 using the committed types as the fallback of record. If no
  committed types exist, crash with a generic 'run generate-types --wait
  locally' remedy.

Serving types are excluded from the gate (gitignored, degrade independently).
Non-blocking mode is unchanged. Threads deterministic-vs-environmental and a
coarse cause label out of the query + metric preflights. Adds gate-matrix
coverage: environmental+present (per cause) → warning+exit0, environmental+
absent → crash, deterministic (404/400/syntax) → crash regardless of types,
partial presence, serving-exclusion, and CI-safe (ANSI-free) warning output.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
Phase 4 of typegen-ci-resilient-describe. Updates the type-generation docs
to describe the committed-types fallback and two-bucket failure taxonomy for
blocking (`--wait`) builds: committed .d.ts as the fallback of record,
--wait never overwriting good types with degraded ones, deterministic
failures (SQL syntax / 404 / 400) crashing vs. environmental failures
(auth / connectivity / deleted / timeout) gating on committed-type presence,
the loud stderr warning, and the run-locally remedy for a first build with no
committed types. Notes the metric-views-only edge case (empty analytics.d.ts
satisfies the gate). Refreshes the metric-view section to reference the same
taxonomy instead of the old always-fail framing.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
…anges

Wash of the typegen-ci-resilient-describe branch: strips loop-process
"Phase N" labels from comments, test names, and describe titles (keeping
the semantic text), removes an unnecessary comment / empty else-block /
useless default parameter, rewrites two stale+duplicated write-suppression
comments to match the actual behavior, and converts errors.test.ts's
`(error as any)` casts to the sibling `Object.assign(new Error(...), { ... })`
idiom. Comments, names, and test-setup style only — no logic or assertion
changes (537 tests still pass).

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
Brings in main's revert of PR #502 (#509), which relocated the typegen
cache back to node_modules/.databricks. This branch was cut from the
#502 merge commit and inherited that surface without ever depending on
it, so the sync is a clean subtraction: cache-paths.ts, the committed
apps/dev-playground/.appkit/ fixtures, the cache-serialization tests,
and the warehouse-less-deploy docs all drop out, while the metric-view
runtime (useMetricView, formatters, metric-views.ts codegen) is
untouched.

bundle-size-baseline.json conflicted because both sides regenerated it;
resolved by taking main's, to be regenerated against the merged tree.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
The merge took main's baseline to resolve the conflict; this remeasures
against the post-merge build so the numbers reflect the actual tree.
`size:compare` now reports no change.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
…use labels

`classifyEnvironmentalCause` read only `err.status`/`err.statusCode` on the
top-level error, while `errors.ts` already resolved `response.status` and
walked `cause`/`AggregateError` chains. A 403 reported under `response` or
wrapped in a cause chain was therefore labeled "warehouse unavailable"
instead of "auth blocked", pointing CI at the wrong remedy.

Move the helper next to `classifyBlockingFailure` in errors.ts so both
classifiers share one status-extraction path, and reuse the existing
chain walk.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
…typegen

The blocking-mode preflight set `decision = "degradeAll"` for connectivity
failures but left `isEnvironmental` false, so `hadEnvironmentalFailure`
never flipped and the has-types gate never ran for an unreachable
warehouse. On a fresh CI checkout that meant: queries degrade to
`result: unknown`, write suppression skips `analytics.d.ts` entirely, no
fatal errors are recorded, and the run exits 0 having written no types —
the build then fails later somewhere less legible.

Flag connectivity failures (preflight and per-query DESCRIBE) as
environmental so the gate decides: warn and fall back when committed types
exist, crash with the run-locally remedy when they don't. This cannot turn
a passing build red — with committed types the outcome is unchanged apart
from the warning now being emitted.

Also return `environmentalCause` from `generateQueriesFromDescribe`, which
`QueryGenerationResult` already declared and the metric path already set.
Without it every query-path environmental failure fell back to the default
"warehouse unavailable" label, and the "warehouse unreachable" label was
unreachable in practice.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
…utation

Two comments claimed blocking mode suppresses writes only for pure
degradation and that degraded artifacts are still written before a throw.
`suppressDegradedWrite: mode === "blocking"` is unconditional, so any
degradation suppresses the write including on runs that then throw. The
behavior is what the PR intends; the comments described the old shape.

The ANSI-free warning test set `process.env.CI` and deleted it in `finally`,
clobbering a pre-existing value for later tests. Nothing under
`src/type-generator` reads `CI`, so the assignment never affected the
assertion — it only risked perturbing third-party color detection, which is
exactly what this test checks. Drop it rather than stub it.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
…uery path

The gate tests in index.test.ts mock `generateQueriesFromDescribe`, so they
supply the `hadEnvironmentalFailure: true` they assert on. They pass whether
or not the query path actually reports it — which is how the connectivity
bug survived review: the preflight returned false and no test joined the
two halves.

Mock only the SDK boundary so the real query path classifies the failure and
the real gate decides. Covers the fresh-checkout crash (previously exit 0
with no types written), the committed-types warn-and-preserve path with the
"warehouse unreachable" label, and non-blocking staying silent.

Verified these fail when the isEnvironmental assignment is reverted.

Co-authored-by: Isaac
…rehouse gate

The new gate test replaced `@databricks/sdk-experimental` with a bare factory
mock. After #475 the type-generator reaches the SDK through
`../../workspace-client`, whose `legacy.ts` destructures `ConfigError`,
`Context` and `TimeUnits` off that module at import time — so the factory-only
mock starved module init and the suite failed to collect.

Mock the wrapper instead, spreading `importOriginal` so the re-exported SDK
values survive. This matches the sibling type-generator tests and keeps the
test's intent: the wrapper is now the client boundary, so the real query path
still classifies the failure and the real gate still decides. Verified by
re-injecting the original bug (`isEnvironmental = false` on the connectivity
branch) and confirming both gate tests fail.

Co-authored-by: Isaac
Signed-off-by: Atila Fassina <atila@fassina.eu>
Signed-off-by: Atila Fassina <atila@fassina.eu>
Signed-off-by: Atila Fassina <atila@fassina.eu>

# Conflicts:
#	packages/appkit/src/type-generator/index.ts
#	packages/appkit/src/type-generator/tests/index.test.ts
Signed-off-by: Atila Fassina <atila@fassina.eu>
Signed-off-by: Atila Fassina <atila@fassina.eu>

# Conflicts:
#	bundle-size-baseline.json
Signed-off-by: Atila Fassina <atila@fassina.eu>
Signed-off-by: Atila Fassina <atila@fassina.eu>
@atilafassina
atilafassina changed the base branch from main to mv-metadata-pipeline August 4, 2026 15:44
@atilafassina atilafassina changed the title feat(analytics): useMetricViews feat(analytics): add useMetricView client experience Aug 4, 2026
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.

1 participant