Skip to content

[HDX-5079] Render formulas in the composed metric query - #2908

Merged
kodiakhq[bot] merged 5 commits into
mainfrom
warren/HDX-5079-render-formulas
Aug 17, 2026
Merged

[HDX-5079] Render formulas in the composed metric query#2908
kodiakhq[bot] merged 5 commits into
mainfrom
warren/HDX-5079-render-formulas

Conversation

@wrn14897

Copy link
Copy Markdown
Member

Summary

Renders metric formulas (HDX-5078's formulas config) in the composed multi-series metric query (HDX-5077), so a derived series like A / (A + B + C) * 100 is computed by ClickHouse as part of the single composed query instead of not rendering at all.

  • compileFormulaAst (core/formula.ts): compiles the validated letter-ref AST into a SQL expression over per-series value expressions. Never splices user text into SQL — only the parsed/validated AST is walked.
  • Missing-data semantics match the existing ratio projection: a series ref compiles to coalesce(<pivot>, 0) (a missing operand counts as 0, so a zero-error group reads 0%, not N/A), and every division denominator is wrapped in nullif(..., 0) (zero or missing denominator → NULL → rendered gap, never 0 or an error).
  • Projection order (renderMultiSeriesMetricChartConfig): operand value columns first in select order, then formula columns in formulas order, ahead of the group/bucket passthrough columns — preserving the useChartNumberFormats positional meta contract. showOperandSeries: false drops the operand columns so only the formula column(s) are returned.
  • Routing: a single-series metric chart with a formula (e.g. A * 100) now routes through the composed path (single-branch union pivot). Per-series branches strip formulas to avoid recursion.
  • Precedence: formulas supersede seriesReturnType: 'ratio' (the two are mutually exclusive in the editor; the renderer stays deterministic on a hand-built config carrying both).
  • builderToRawSql: formula configs are rejected from "convert to SQL" with a clear message (same limitation as multi-series metric charts).
  • Formulas are validated at render time with the structured validator; an invalid persisted expression throws a descriptive error rather than a ClickHouse error.

Alerts on formula tiles work with no changes since they query through queryChartConfigrenderChartConfig.

Testing

  • make ci-lint, make ci-unit pass.
  • New unit tests: compileFormulaAst (precedence, nested divisions, unary minus, HDX-4938 example) and SQL snapshot tests for the formula projection (grouped, hidden operands, single-series routing, formula-vs-ratio precedence, alias collision suffixing, invalid-formula error).
  • New integration tests in queryChartConfig.int.test.ts (all passing against the docker ClickHouse, with the HDX-5076/5077 regression baseline unchanged):
    • HDX-4938 motivating example A / (A + B + C) * 100
    • Division by zero / missing denominator → gap, not 0 or error (pinned against the same fixture as the seriesReturnType: 'ratio' test for drop-in parity)
    • Formula over mixed gauge + sum (increase) operands across tables
    • Grouped formula computed per (bucket, group) row
    • Meta contract with operand series shown vs hidden
    • Number- and table-shape formulas

How to test on Vercel preview

N/A — query-rendering change in common-utils; the chart editor UI for formulas lands in HDX-5080.

References

@wrn14897 wrn14897 added the ai-generated AI-generated content; review carefully before merging. label Aug 13, 2026
@changeset-bot

changeset-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d06dbba

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@hyperdx/common-utils Minor
@hyperdx/api Patch
@hyperdx/app Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview Aug 17, 2026 10:37pm
hyperdx-storybook Ready Ready Preview Aug 17, 2026 10:37pm

Request Review

@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds SQL compilation and composed-query rendering for metric formulas, including single-series routing and raw-SQL template conversion.

  • Compiles validated formula ASTs into ClickHouse expressions with explicit missing-value and zero-denominator semantics.
  • Projects formula columns alongside or instead of operand columns while preserving positional metadata ordering.
  • Escapes composed-query output aliases and adds unit, snapshot, and integration coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported malformed alias quoting is fixed across all composed projection paths.

Important Files Changed

Filename Overview
packages/common-utils/src/core/formula.ts Adds a validated-AST-to-SQL compiler with coalesced operands and guarded division.
packages/common-utils/src/core/renderChartConfig.ts Adds formula-aware composed projections and correctly fixes quoted aliases across formula, operand, and ratio columns.
packages/common-utils/src/core/builderToRawSql.ts Enables composed multi-series, ratio, and formula metric queries in generated raw-SQL templates.
packages/common-utils/src/tests/queryChartConfig.int.test.ts Covers formula execution, missing-data behavior, mixed metric types, grouping, metadata ordering, and supported display shapes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Config[Metric chart config] --> Validate[Validate formula AST]
  Validate --> Branches[Render one query branch per operand]
  Branches --> Union[UNION ALL]
  Union --> Pivot[Pivot values by series index]
  Pivot --> Compile[Compile formula over pivot expressions]
  Compile --> Project[Project operands and formula aliases]
  Project --> ClickHouse[Execute composed ClickHouse query]
Loading

Reviews (6): Last reviewed commit: "Merge branch 'main' into warren/HDX-5079..." | Re-trigger Greptile

Comment thread packages/common-utils/src/core/renderChartConfig.ts Outdated
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 299 passed • 1 skipped • 1081s

Status Count
✅ Passed 299
❌ Failed 0
⚠️ Flaky 1
⏭️ Skipped 1

Tests ran across 4 shards in parallel.

View full report →

@wrn14897
wrn14897 marked this pull request as ready for review August 14, 2026 04:22
@github-actions github-actions Bot added the review/tier-2 Low risk — AI review + quick human skim label Aug 14, 2026
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🔵 Tier 2 — Low Risk

Small, isolated change with no API route or data model modifications.

Why this tier:

  • Standard feature/fix — introduces new logic or modifies core functionality

Additional context: touches the query rendering engine lightly (142 lines, under the 150-line bar for Tier 4)

Review process: AI review + quick human skim (target: 5–15 min). Reviewer validates AI assessment and checks for domain-specific concerns.
SLA: Resolve within 4 business hours.

Stats
  • Production files changed: 3
  • Production lines changed: 188 (+ 981 in test files, excluded from tier calculation)
  • Branch: warren/HDX-5079-render-formulas
  • Author: wrn14897

To override this classification, remove the review/tier-2 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Deep Review

Rendering metric formulas (A / (A + B + C) * 100) into the composed multi-series ClickHouse metric query. I read every changed line in formula.ts, renderChartConfig.ts, and builderToRawSql.ts, plus the supporting parser/validator and config schema. The core is sound: compileFormulaAst walks only the validated AST (never splicing raw expression text), numeric literals are digits-only, validateFormula runs before compilation, and quotedColumnName now escapes embedded double quotes at every emission site inside renderMultiSeriesMetricChartConfig (formula, operand, ratio, and plain paths). Single-series charts without a formula are unaffected by the new routing, and the value-columns-first meta ordering that useChartNumberFormats depends on is preserved.

✅ No critical issues found. No P0/P1 defects; the diff is safe to merge. One test gap and a couple of small maintainability nits below.

🟡 P2 — recommended

  • packages/common-utils/src/core/builderToRawSql.ts:100 — The new branch that rejects formula-bearing metric configs from raw-SQL conversion (including single-series configs with formulas, which previously converted) and its distinct error message are untested; a refactor flipping the ternary or dropping the formulas clause passes CI silently.
    • Fix: Add a builderToRawSql test asserting a single-series metric config carrying formulas returns isError: true with the formula-specific message.
    • testing
🔵 P3 nitpicks (2)
  • packages/common-utils/src/core/renderChartConfig.ts:2473 — The per-series value-column projection (valueExprFor(splitIdx) AS quotedColumnName(...)) is emitted byte-identically in both the hasFormulas operand branch and the plain else branch, so a future change to value-column emission must be made twice or the two silently diverge.
    • Fix: Extract a shared closure that both branches call to push the ordered value columns.
    • maintainability
  • packages/common-utils/src/core/builderToRawSql.ts:102 — The formula-presence check (config.formulas?.length ?? 0) > 0 is re-implemented inline (twice) rather than reusing the exported hasMetricFormulas helper, duplicating the definition of "what counts as a formula chart" across modules.
    • Fix: Import and call hasMetricFormulas in place of the inline expression.
    • maintainability

Reviewers (8 dispatched): correctness, security, adversarial, testing, maintainability, kieran-typescript, api-contract, learnings-researcher. Graded findings reflect the testing and maintainability reviewers plus direct orchestrator analysis of the correctness, security, and API-contract dimensions (all clean); the learnings researcher found no prior docs/solutions/ guidance on ClickHouse SQL generation or the composed-metric query.

Testing gaps:

  • The number-chart (no passthrough columns) formula projection with implicit global aggregation is exercised only by the live-ClickHouse integration test, not by a fast unit-level SQL-shape assertion in renderChartConfig.test.ts.
  • Pre-existing / out of scope: the general renderSelect path (renderChartConfig.ts:817) still interpolates a user alias into an AS "..." identifier without escaping — the natural home for the new quotedColumnName helper. Not introduced by this diff; worth a follow-up.

pulpdrew
pulpdrew previously approved these changes Aug 17, 2026

@pulpdrew pulpdrew left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, with a comment that could be a followup if it makes sense.

Comment on lines +99 to +109
if (
isMetric &&
Array.isArray(config.select) &&
(config.select.length > 1 || (config.formulas?.length ?? 0) > 0)
) {
return {
isError: true,
error: 'Multi-series metric charts cannot be auto-converted to SQL.',
error:
(config.formulas?.length ?? 0) > 0
? 'Metric charts with formulas cannot be auto-converted to SQL.'
: 'Multi-series metric charts cannot be auto-converted to SQL.',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This was a limit originally because multi-series metrics couldn't be represented as a single query. Now they can, and so can formulas + ratios. Is there a reason to continue blocking this case?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good point. I believe we should be able to show the query now. Let me revisit the code here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No technical reason anymore — you're right that the composed shape lifted the original constraint. I checked what was left:

  • The per-series branches inherit isRenderingRawSqlTemplate, so each branch already renders with $__sourceTable(<metricType>) and the time/interval macros, same as the single-series conversion.
  • replaceMacros resolves multiple $__sourceTable(type) occurrences with per-occurrence args, $__filters lands once per branch source CTE (never in the outer pivot), the hoisted SETTINGS are literal, and the outer pivot/formula projection binds no params.

The only reason it was still blocked was scope — HDX-5077 listed builderToRawSql multi-series support as a follow-up, and this gate just extended that to formulas with a clearer message.

Lifted in d63af10: multi-series, ratio, and formula metric charts now convert to a macro-based template (with snapshot tests, $__filters-placement coverage, and a replaceMacros round-trip). Non-time-series metric charts keep the existing restriction.

Compile the validated formula AST (HDX-5078) into the final SELECT
projection over the pivoted per-series columns produced by the composed
multi-series metric query (HDX-5077).

- compileFormulaAst: letter refs resolve to the pivot expressions with
  ratio-consistent semantics (missing operand -> 0, division by zero or
  missing denominator -> NULL, rendered as a gap)
- Formula columns append after the operand value columns (select order),
  preserving the useChartNumberFormats positional meta contract;
  showOperandSeries: false emits only the formula column(s)
- Single-series metric charts with a formula route through the composed
  path; formulas take precedence over seriesReturnType: 'ratio'
- builderToRawSql rejects formula configs (same limitation as
  multi-series metric charts)
A formula or series alias containing a double quote terminated the
double-quoted identifier early (AS "bad"name") and failed the query
with a ClickHouse syntax error. Escape by doubling the quote at
SQL-emission time across all composed-projection sites (formula,
operand, and ratio columns); collision dedup and result meta keep the
raw name.

Addresses greptile P1 review comment.
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found.

This is a self-contained SQL-rendering change with heavy unit + integration test coverage. The two highest-risk surfaces were verified clean:

  • SQL injection: compileFormulaAst walks only the validated AST (parseFormula/validateFormula) and never splices raw expression text into SQL. All four composed-path identifier emission sites (operand, formula, ratio, else) route through the new quotedColumnName, which correctly doubles " for ClickHouse double-quoted identifiers. The formula column's raw-expression fallback name is escaped at every emission site.
  • Missing-data semantics: a series ref compiles to coalesce(<pivot>, 0) and every division denominator is wrapped in nullif(..., 0), matching the existing seriesReturnType: 'ratio' projection so a missing operand reads 0 and a zero/missing denominator renders a gap.
  • Routing & recursion: single-series-with-formula correctly diverts to the composed path; per-series branch configs strip formulas/showOperandSeries, preventing re-entry. Formula/ratio precedence is deterministic. Formula columns contain aggregates, so GROUP BY ALL excludes them exactly as the ratio path relies on.

🟡 P2 -- recommended

  • packages/common-utils/src/core/renderChartConfig.ts:818 -- the single-series alias emission (AS "${{ UNSAFE_RAW_SQL: select.alias }}") does not escape embedded double quotes, unlike the composed path this diff hardened; a select.alias containing " breaks the identifier. This is pre-existing and untouched by the diff, but now stands out as the one remaining unescaped alias site.
    • Fix: route this alias through the same quotedColumnName helper so single-series and composed paths escape identifiers consistently.
🔵 P3 nitpicks (2)
  • packages/common-utils/src/core/formula.ts (compileFormulaAst, number case) -- the comment claims JS stringification yields "plain digits / decimal notation," but a large or tiny literal stringifies to exponential form (e.g. 1e+21, 1e-7); these are still valid ClickHouse float literals so no query error results, but the comment overstates the guarantee.

    • Fix: reword the comment to acknowledge exponential notation for extreme magnitudes, or note that ClickHouse accepts scientific-notation float literals.
  • packages/common-utils/src/__tests__/formula.test.ts -- no compileFormulaAst case exercises a numeric literal that stringifies to exponential notation, leaving that (benign) emission branch unasserted.

    • Fix: add a compileFormulaAst assertion for a large/small numeric literal to pin the emitted SQL text.

Reviewers: synthesized from direct diff analysis across correctness, security/SQL-injection, testing, and maintainability lenses. (The dispatched persona sub-agents had not returned findings at synthesis time; findings above are verified against the diff and surrounding code.)

Testing gaps: formula numeric literals that render in exponential notation are not covered by an explicit assertion, though the resulting SQL is valid.

The multi-series gate in renderBuilderConfigAsSqlTemplate predated the
composed single-query renderer: each per-series branch now emits its own
$__sourceTable(<metricType>) and time macros, $__filters lands once per
branch source CTE, the hoisted SETTINGS are literal, and the outer pivot
binds no params — so multi-series, ratio, and formula metric charts
convert to a raw-SQL template like any other metric chart. Non-time-series
metric charts keep the existing restriction.

Addresses review feedback on the formula gate.
@kodiakhq
kodiakhq Bot merged commit 3ecf73c into main Aug 17, 2026
40 of 42 checks passed
kodiakhq Bot pushed a commit that referenced this pull request Aug 20, 2026
## Summary

Exposes metric formulas (HDX-5078's `formulas` config, rendered by HDX-5079) in the chart editor for metric sources, so a derived series like `A / (A + B + C) * 100` can be built, validated, saved, and reloaded from the UI.

Rebased onto main now that #2908 (HDX-5079 rendering) has merged.

### Editor

- **Formula rows** (`ChartFormulaEditor`) on metric-source builder charts (time series / table / number): "Add Formula" appends a row with a monospace letter-ref expression input, an alias, a per-formula number format (reuses the per-series format drawer), and "Remove Formula".
- **Inline validation** with the structured validator from HDX-5078 (`validateFormula`): malformed expressions, unknown series refs, constant-only expressions, etc. surface live under the input; `validateChartForm` blocks save/run with the same messages so an invalid expression can never reach ClickHouse.
- **Letter badges** (`A`, `B`, `C`, ...) on metric series rows so formula refs are discoverable.
- **"Show input series" toggle** drives `showOperandSeries` (formula + raw operand series vs formula column(s) only). Adding a formula on a Number tile defaults operands to hidden, since Number tiles render the first value column.
- **Mutual exclusion with ratio**: the "As Ratio" switch is hidden while a formula exists, and "Add Formula" is hidden while ratio mode is on (formulas supersede ratio in the renderer).
- The Number-tile series cap (1, or 2 for ratio) is lifted when formulas exist, so operand-only series like `A / (A + B + C)` can be built.
- `normalizeChartConfig` strips `formulas`/`showOperandSeries` on save for non-metric sources and for display types the composed metric query does not render (pie/bar/heatmap/search/patterns), mirroring the existing `metricName`/`having` stripping. The form state keeps them, so switching back restores the rows.

### Rendering consumers (positional value-column contract)

The composed metric query projects operand columns (unless hidden) then formula columns, ahead of group-by passthrough columns. Updated the consumers that map columns positionally:

- `useChartNumberFormats`: operand columns → `select[i].numberFormat`, formula columns → `formulas[j].numberFormat`, both falling back to the chart-wide format; chart-wide axis format prefers formula formats when operands are hidden.
- New `getBuilderValueColumnCount` helper (formula/ratio-aware) used by `DBTableChart` for group-by column inference; per-column color mapping skips hidden-operand formula configs.
- `DBTimeChart` drill-down skips the value-range filter when operands are hidden (formula columns don't map onto `select` expressions).
- Legend/tooltip naming needs no changes — formula columns arrive as named result columns (`alias || expression`).

Persistence needs no API changes: tiles validate against `SavedChartConfigSchema`, which already carries `formulas`/`showOperandSeries`, and `builderToRawSql` already rejects formula configs with a clear message on the Builder → SQL switch.

### Alerts on formula tiles (`packages/api`)

Contrary to #2908's "alerts work with no changes" claim, the alert task does **not** run the tile config as-is — `getChartConfigFromAlert` rebuilds it from an explicit field list that dropped `formulas`/`showOperandSeries`. An alert on a formula tile therefore queried only the raw operand series and compared the threshold against the **last operand's value** (e.g. `740442112.0 meets or exceeds 0.1` for a byte-valued operand), regardless of the tile's "Show input series" toggle. Fixed here:

- `formulas` is passed through, and operand columns are always dropped from the alert query (`showOperandSeries: false`) so the formula is the value column `parseAlertData` picks — the alert evaluates exactly what the tile displays.
- Drive-by with the same omission shape: `ratioMode` is now passed through, so grouped `share_of_total` ratio tile alerts no longer silently evaluate as `per_group`.
- New integration tests (`make dev-int FILE=checkAlerts`, 168 passing): formula value drives the alert (fixture chosen so the formula result differs from both operands), toggle-independence, NULL formula (zero denominator) skipped without NaN history, and `share_of_total` honored (asserting the exact share value a `per_group` fallback couldn't produce).

### Testing

- `make ci-lint`, `make ci-unit` pass.
- New unit tests:
  - `DBEditTimeChartForm.test.tsx`: Add/Remove Formula, inline validation (malformed / unknown ref / clears when fixed), save round-trip, save blocked on invalid expression, ratio mutual exclusion, `showOperandSeries` toggle, non-metric sources show no formula controls.
  - `ChartEditor/utils.test.ts`: `validateChartForm` formula rules (including the Number-tile cap lift) and normalization stripping/round-trip.
  - `source.test.ts`: `useChartNumberFormats` formula-column mapping (operands shown/hidden, ratio precedence, chart-format fallbacks) and `getBuilderValueColumnCount`.
- New dashboard E2E (`make dev-e2e FILE=dashboard GREP="Metric formulas"`, passing): creates a metric table tile with two gauge series + `A / (A + B) * 100`, asserts the inline error for an invalid ref, hides operands, saves, reloads the page, verifies the formula column renders with a finite value, and reopens the editor to verify the round-trip.

### How to test on Vercel preview

1. Open a dashboard → Add tile → select a metrics source.
2. Add two series (note the `A`/`B` badges), click **Add Formula**, enter `A / (A + B) * 100`.
3. Try `A / C` to see the inline error; toggle **Show input series**; save, reload, and confirm the tile renders the formula series.

### References

- Linear Issue: [HDX-5080](https://linear.app/clickhouse/issue/HDX-5080/chart-editor-ui-for-metric-formulas)
- Related PRs: #2908 (HDX-5079 formula rendering, base branch), #2872 (HDX-5078 schema + parser)

### Screenshots
<img width="1676" height="725" alt="image" src="https://github.com/user-attachments/assets/f16478f7-ac82-496d-90f2-056cc1260c70" />
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-generated AI-generated content; review carefully before merging. automerge review/tier-2 Low risk — AI review + quick human skim

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants