From 6ee4cc3f3eef467d9e2cbe6342fdd5f853406d49 Mon Sep 17 00:00:00 2001 From: Warren Lee <5959690+wrn14897@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:46:20 -0700 Subject: [PATCH 1/4] feat: formula support across API surfaces (HDX-5081) Plumb metric formula configs through every surface that persists or accepts chart configs: - External dashboards API v2: formulas/showOperandSeries on line, stacked_bar, table and number builder tile schemas, round-tripped through both converters, with write-time expression validation and a metric-source-only gate. OpenAPI spec regenerated with the new Formula component. - Shared validation: validateChartConfigFormulas in common-utils dashboardValidation mirrors the chart editor's save-time rules; isFormulaDisplayType moves to common-utils core/utils so the editor and server gates cannot drift. - MCP: save_dashboard / patch_dashboard tile schemas accept formulas, query_tile computes formula columns, and the dashboards query-guide prompt documents the feature. - CLI: number/table tile config transforms delegate to the shared formula-aware common-utils implementations; AGENTS.md pipeline notes updated. Alert-on-formula-tile support and its checkAlerts integration tests landed with HDX-5080 (#2909); verified green on this branch. --- .changeset/formula-api-surfaces.md | 7 + packages/api/openapi.json | 75 ++++++- .../dashboards/queryTile.int.test.ts | 153 ++++++++++++++ .../api/src/mcp/prompts/dashboards/content.ts | 24 +++ .../api/src/mcp/tools/dashboards/schemas.ts | 71 ++++++- .../__tests__/dashboards.int.test.ts | 200 ++++++++++++++++++ .../src/routers/external-api/v2/dashboards.ts | 93 +++++++- .../external-api/v2/utils/dashboards.ts | 95 ++++++++- packages/api/src/utils/zod.ts | 61 +++++- .../app/src/components/ChartEditor/utils.ts | 25 +-- packages/cli/AGENTS.md | 13 +- packages/cli/src/shared/tileConfig.ts | 41 ++-- .../src/__tests__/dashboardValidation.test.ts | 141 ++++++++++++ packages/common-utils/src/core/utils.ts | 19 ++ .../common-utils/src/dashboardValidation.ts | 77 ++++++- 15 files changed, 1036 insertions(+), 59 deletions(-) create mode 100644 .changeset/formula-api-surfaces.md diff --git a/.changeset/formula-api-surfaces.md b/.changeset/formula-api-surfaces.md new file mode 100644 index 0000000000..3a3599ae72 --- /dev/null +++ b/.changeset/formula-api-surfaces.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/common-utils': minor +'@hyperdx/api': minor +'@hyperdx/cli': patch +--- + +Metric formulas are now supported across every API surface that persists or accepts chart configs. The external dashboards API v2 and the MCP `save_dashboard` / `patch_dashboard` tools accept `formulas` (letter-ref arithmetic over the tile's select items, e.g. `A / (A + B) * 100`) and `showOperandSeries` on line, stacked bar, table and number builder tiles, round-trip them through GET/PUT, and validate the expressions on write — unknown series refs, malformed syntax, combining formulas with `asRatio`, multiple formulas on a number tile, and formulas on non-metric sources are all rejected with actionable errors. MCP `query_tile` computes formula columns, the query-guide prompt documents the feature, and the OpenAPI spec includes the new `Formula` schema. The CLI's dashboard tile pipeline now delegates its number/table config transforms to the shared common-utils implementations, so formula tiles render with operand-hiding behavior identical to the web. diff --git a/packages/api/openapi.json b/packages/api/openapi.json index b2f93f5c52..318845dfdf 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -1591,6 +1591,30 @@ } } }, + "Formula": { + "type": "object", + "required": [ + "expression" + ], + "description": "A derived series computed from the chart's select items via a letter-ref arithmetic expression (metric sources only). \"A\" refers to select[0], \"B\" to select[1], and so on. The grammar supports + - * /, parentheses, and numeric constants; expressions are parsed and validated, never passed through as raw SQL. Division by zero or a missing operand yields NULL (rendered as a gap).\n", + "properties": { + "expression": { + "type": "string", + "maxLength": 1024, + "description": "Arithmetic expression over the select items by position, e.g. \"A / (A + B) * 100\" for a success-rate percentage.\n", + "example": "A / (A + B) * 100" + }, + "alias": { + "type": "string", + "description": "Display label for the formula series in chart legends and column headers. Falls back to the raw expression text when unset.\n", + "example": "Success rate %" + }, + "numberFormat": { + "$ref": "#/components/schemas/NumberFormat", + "description": "Per-series number formatting options for the formula series.\n" + } + } + }, "LineBuilderChartConfig": { "type": "object", "required": [ @@ -1662,6 +1686,19 @@ "minimum": 0, "description": "Maximum number of series rendered (top-N by value). Omit to use the default render cap, set 0 for unlimited, or a positive N to keep the top N series.\n", "example": 5 + }, + "formulas": { + "type": "array", + "maxItems": 10, + "description": "Derived series computed from the select items via letter-ref arithmetic (\"A\" = select[0], \"B\" = select[1], ...). Metric sources only. Cannot be combined with asRatio.\n", + "items": { + "$ref": "#/components/schemas/Formula" + } + }, + "showOperandSeries": { + "type": "boolean", + "description": "Only meaningful with formulas. When false, only the formula series are returned; the raw operand series are hidden.\n", + "default": true } } }, @@ -1726,6 +1763,19 @@ "minimum": 0, "description": "Maximum number of series rendered (top-N by value). Omit to use the default render cap, set 0 for unlimited, or a positive N to keep the top N series.", "example": 5 + }, + "formulas": { + "type": "array", + "maxItems": 10, + "description": "Derived series computed from the select items via letter-ref arithmetic (\"A\" = select[0], \"B\" = select[1], ...). Metric sources only. Cannot be combined with asRatio.\n", + "items": { + "$ref": "#/components/schemas/Formula" + } + }, + "showOperandSeries": { + "type": "boolean", + "description": "Only meaningful with formulas. When false, only the formula series are returned; the raw operand series are hidden.\n", + "default": true } } }, @@ -1796,6 +1846,19 @@ "onClick": { "$ref": "#/components/schemas/OnClick", "description": "Optional link-out configuration applied when a user clicks a row." + }, + "formulas": { + "type": "array", + "maxItems": 10, + "description": "Derived columns computed from the select items via letter-ref arithmetic (\"A\" = select[0], \"B\" = select[1], ...). Metric sources only. Cannot be combined with asRatio.\n", + "items": { + "$ref": "#/components/schemas/Formula" + } + }, + "showOperandSeries": { + "type": "boolean", + "description": "Only meaningful with formulas. When false, only the formula columns are returned; the raw operand columns are hidden.\n", + "default": true } } }, @@ -1824,12 +1887,20 @@ "select": { "type": "array", "minItems": 1, - "maxItems": 1, - "description": "Exactly one aggregated value to display as a single number.", + "maxItems": 20, + "description": "Exactly one aggregated value to display as a single number — unless \"formulas\" is set, in which case the select items are the formula's operands and the (single) formula value is displayed instead.\n", "items": { "$ref": "#/components/schemas/SelectItem" } }, + "formulas": { + "type": "array", + "maxItems": 1, + "description": "A single derived value computed from the select items via letter-ref arithmetic (\"A\" = select[0], \"B\" = select[1], ...). Metric sources only. Number tiles display the formula value and always hide the operand series.\n", + "items": { + "$ref": "#/components/schemas/Formula" + } + }, "numberFormat": { "$ref": "#/components/schemas/NumberFormat", "description": "Number formatting options for displayed values." diff --git a/packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts b/packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts index 6ed26ddb40..daceec4222 100644 --- a/packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts @@ -3,6 +3,7 @@ import { MetricsDataType, SourceKind } from '@hyperdx/common-utils/dist/types'; import { bucketExponentialHistogramObservations, bulkInsertLogs, + bulkInsertMetricsGauge, bulkInsertMetricsHistogram, DEFAULT_DATABASE, DEFAULT_LOGS_TABLE, @@ -121,6 +122,158 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => { expect(result.content).toHaveLength(1); }); + it('should save and query a metric formula tile (HDX-5081)', async () => { + const metricSource = await Source.create({ + kind: SourceKind.Metric, + team: ctx.team._id, + from: { databaseName: DEFAULT_DATABASE, tableName: '' }, + metricTables: { + [MetricsDataType.Gauge.toLowerCase()]: DEFAULT_METRICS_TABLE.GAUGE, + }, + timestampValueExpression: 'TimeUnix', + connection: ctx.connection._id, + name: 'Gauge Metrics', + }); + const now = new Date(); + await bulkInsertMetricsGauge([ + { + MetricName: 'formula.errors', + ResourceAttributes: {}, + ServiceName: 'api', + TimeUnix: now, + Value: 10, + }, + { + MetricName: 'formula.requests', + ResourceAttributes: {}, + ServiceName: 'api', + TimeUnix: now, + Value: 200, + }, + ]); + + const formulas = [{ expression: 'A / B * 100', alias: 'Error rate %' }]; + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Formula Dashboard', + tiles: [ + { + name: 'Error rate', + config: { + displayType: 'line', + sourceId: metricSource._id.toString(), + select: [ + { + aggFn: 'max', + metricType: 'gauge', + metricName: 'formula.errors', + alias: 'Errors', + }, + { + aggFn: 'max', + metricType: 'gauge', + metricName: 'formula.requests', + alias: 'Requests', + }, + ], + formulas, + showOperandSeries: false, + }, + }, + ], + }, + ); + if (createResult.isError) { + throw new Error(getFirstText(createResult)); + } + const dashboard = JSON.parse(getFirstText(createResult)); + // The formula config round-trips through the save response. + expect(dashboard.tiles[0].config.formulas).toEqual(formulas); + expect(dashboard.tiles[0].config.showOperandSeries).toBe(false); + + const result = await callTool(ctx.client!, 'clickstack_query_tile', { + dashboardId: dashboard.id, + tileId: dashboard.tiles[0].id, + startTime: new Date(now.getTime() - 60_000).toISOString(), + endTime: new Date(now.getTime() + 60_000).toISOString(), + }); + + expect(result.isError).toBeFalsy(); + const parsed: { + result: { data: Array> }; + } = JSON.parse(getFirstText(result)); + const rows = parsed.result.data; + expect(rows.length).toBeGreaterThan(0); + // The formula column is computed in ClickHouse (10 / 200 * 100 = 5) and, + // with showOperandSeries: false, the raw operand columns are hidden. + expect(Number(rows[0]['Error rate %'])).toBe(5); + expect(rows[0]).not.toHaveProperty('Errors'); + expect(rows[0]).not.toHaveProperty('Requests'); + }); + + it('should reject a formula tile on a non-metric source (HDX-5081)', async () => { + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Bad Formula Dashboard', + tiles: [ + { + name: 'Formula on traces', + config: { + displayType: 'line', + sourceId: ctx.traceSource._id.toString(), + select: [ + { aggFn: 'count', alias: 'A' }, + { aggFn: 'count', alias: 'B' }, + ], + formulas: [{ expression: 'A / B' }], + }, + }, + ], + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('require a Metric source'); + }); + + it('should reject a formula referencing an unknown series (HDX-5081)', async () => { + const metricSource = await Source.create({ + kind: SourceKind.Metric, + team: ctx.team._id, + from: { databaseName: DEFAULT_DATABASE, tableName: '' }, + metricTables: { + [MetricsDataType.Gauge.toLowerCase()]: DEFAULT_METRICS_TABLE.GAUGE, + }, + timestampValueExpression: 'TimeUnix', + connection: ctx.connection._id, + name: 'Gauge Metrics Invalid Formula', + }); + + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { + name: 'Invalid Formula Dashboard', + tiles: [ + { + name: 'Unknown ref', + config: { + displayType: 'line', + sourceId: metricSource._id.toString(), + select: [ + { + aggFn: 'max', + metricType: 'gauge', + metricName: 'formula.errors', + }, + ], + formulas: [{ expression: 'A / B' }], + }, + }, + ], + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Unknown series'); + }); + it('should save and query an exponential histogram tile', async () => { const metricSource = await Source.create({ kind: SourceKind.Metric, diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index 307c87a2f9..492e901776 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -1063,6 +1063,30 @@ to plot the first as a ratio of the second. Useful for error rates: ], asRatio: true +== FORMULAS (metric sources only) == + +line / stacked_bar / table / number tiles on a METRIC source can add derived +series computed from the select items via letter-ref arithmetic: "A" is +select[0], "B" is select[1], and so on. The grammar is + - * /, parentheses, +and numeric constants; division by zero or a missing operand renders as a gap +(NULL). Example: a success-rate percentage over three counters: + select: [ + { aggFn: "sum", metricType: "sum", metricName: "requests.success", alias: "Success" }, + { aggFn: "sum", metricType: "sum", metricName: "requests.error", alias: "Error" }, + { aggFn: "sum", metricType: "sum", metricName: "requests.fsi", alias: "FSI" } + ], + formulas: [ + { expression: "A / (A + B + C) * 100", alias: "Success rate %", numberFormat: { output: "percent", mantissa: 1 } } + ] + +Rules: + - Metric sources only; the server rejects formulas on log/trace sources. + - Each formula adds one series after the operand series. Set + showOperandSeries: false to return only the formula series. + - Cannot be combined with asRatio: express the ratio as a formula ("A / B"). + - number tiles support a single formula, display its value, and always hide + the operand series; their select items are the formula's operands. + == DASHBOARD FILTERS == Optional dashboard-level filter declarations. Each entry adds a dropdown to the dashboard header that scopes tiles against the filter's expression. Use this for focused per-dimension dashboards (per-service, per-tenant, per-endpoint) instead of hardcoding the dimension into every tile's where clause. diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index f3a59f84c5..aa84f6fe74 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -268,6 +268,54 @@ const mcpTileSelectItemSchema = z : data, ); +// ─── Metric formulas (HDX-5081) ────────────────────────────────────────────── + +const mcpFormulaSchema = z.object({ + expression: z + .string() + .max(1024) + .describe( + 'Arithmetic expression over the tile\'s select items by position: "A" is ' + + 'select[0], "B" is select[1], etc. Supports + - * /, parentheses, and ' + + 'numeric constants — e.g. "A / (A + B) * 100" for a success rate. ' + + 'Division by zero or a missing operand renders as a gap (NULL), not an error.', + ), + alias: z + .string() + .optional() + .describe( + 'Display label for the formula series (chart legend / column header). ' + + 'Falls back to the raw expression text when unset. Always set a short, ' + + 'human-readable alias (e.g. "Error rate %").', + ), + numberFormat: mcpNumberFormatSchema + .optional() + .describe(seriesLevelNumberFormatDescription), +}); + +const tileFormulasDescription = + 'METRIC SOURCES ONLY. Derived series computed from the select items via ' + + 'letter-ref arithmetic ("A" = select[0], "B" = select[1], ...). Example: ' + + '[{ expression: "A / B * 100", alias: "Error rate %" }] with select = ' + + '[errors count, total count]. Each formula adds one series computed in ' + + 'ClickHouse. Cannot be combined with asRatio (express the ratio as a ' + + 'formula instead). Rejected on non-metric sources.'; + +const mcpTileFormulasSchema = z + .array(mcpFormulaSchema) + .max(10) + .optional() + .describe(tileFormulasDescription); + +const mcpShowOperandSeriesSchema = z + .boolean() + .optional() + .describe( + 'Only meaningful with `formulas`: when false, only the formula series ' + + 'are returned and the raw operand series (the select items) are hidden. ' + + 'Defaults to true (operands shown alongside the formula).', + ); + // ─── OnClick (link-out) schemas for table tiles ────────────────────────────── const mcpOnClickFilterTemplateSchema = z .object({ @@ -579,6 +627,8 @@ const mcpLineTileSchema = mcpTileLayoutSchema.extend({ 'Scale the y-axis to the data range instead of starting at zero.', ), seriesLimit: seriesLimitSchema.describe(timeChartSeriesLimitDescription), + formulas: mcpTileFormulasSchema, + showOperandSeries: mcpShowOperandSeriesSchema, }), }); @@ -598,6 +648,8 @@ const mcpBarTileSchema = mcpTileLayoutSchema.extend({ .optional() .describe(tileLevelNumberFormatDescription), seriesLimit: seriesLimitSchema.describe(timeChartSeriesLimitDescription), + formulas: mcpTileFormulasSchema, + showOperandSeries: mcpShowOperandSeriesSchema, }), }); @@ -644,6 +696,8 @@ const mcpTableTileSchema = mcpTileLayoutSchema.extend({ .optional() .describe(tileLevelNumberFormatDescription), onClick: mcpOnClickSchema.optional(), + formulas: mcpTileFormulasSchema, + showOperandSeries: mcpShowOperandSeriesSchema, }), }); @@ -654,11 +708,24 @@ const mcpNumberTileSchema = mcpTileLayoutSchema.extend({ sourceId: z.string().describe('Source ID – call clickstack_list_sources'), select: z .array(mcpTileSelectItemSchema) - .length(1) - .describe('Exactly one metric to display'), + .min(1) + .max(20) + .describe( + 'Exactly one metric to display — unless `formulas` is set, in which ' + + 'case the select items are the formula operands and the (single) ' + + 'formula value is displayed instead.', + ), numberFormat: mcpNumberFormatSchema .optional() .describe(tileLevelNumberFormatDescription), + formulas: z + .array(mcpFormulaSchema) + .max(1) + .optional() + .describe( + `${tileFormulasDescription} Number tiles support a single formula ` + + 'and always display the formula value (operand series are hidden).', + ), color: ChartPaletteTokenSchema.optional().describe( numberTileColorDescription, ), diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts index d9cca5fa35..a319bc3897 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts @@ -1,3 +1,4 @@ +import { isBuilderSavedChartConfig } from '@hyperdx/common-utils/dist/guards'; import { MetricsDataType, SourceKind } from '@hyperdx/common-utils/dist/types'; import { omit } from 'lodash'; import { ObjectId } from 'mongodb'; @@ -6055,6 +6056,205 @@ describe('External API v2 Dashboards - new format', () => { }); }); + describe('Metric formulas (HDX-5081)', () => { + // Two metric operand series (A, B) — the shape the formula grammar + // references by position. Payloads go through `.send()` (untyped) so + // negative tests can post intentionally invalid values. + const metricSelect = () => [ + { + aggFn: 'max', + valueExpression: 'Value', + metricType: 'gauge', + metricName: 'test.errors', + alias: 'Errors', + where: '', + }, + { + aggFn: 'max', + valueExpression: 'Value', + metricType: 'gauge', + metricName: 'test.requests', + alias: 'Requests', + where: '', + }, + ]; + + const formulaTile = (config: Record) => ({ + name: 'Formula tile', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId: metricSource._id.toString(), + select: metricSelect(), + ...config, + }, + }); + + const postTile = (config: Record) => + authRequest('post', BASE_URL).send({ + name: 'Formula dashboard', + tiles: [formulaTile(config)], + tags: [], + }); + + // ── Positive: round-trips per display type ────────────────────────── + + it('round-trips a line tile with formulas and showOperandSeries', async () => { + const formulas = [ + { + expression: 'A / B * 100', + alias: 'Error rate %', + numberFormat: { output: 'percent', mantissa: 1 }, + }, + ]; + const create = await postTile({ + formulas, + showOperandSeries: false, + }).expect(200); + expect(create.body.data.tiles[0].config.formulas).toEqual(formulas); + expect(create.body.data.tiles[0].config.showOperandSeries).toBe(false); + + const get = await authRequest( + 'get', + `${BASE_URL}/${create.body.data.id}`, + ).expect(200); + expect(get.body.data.tiles[0].config.formulas).toEqual(formulas); + expect(get.body.data.tiles[0].config.showOperandSeries).toBe(false); + }); + + it('round-trips a table tile with formulas', async () => { + const formulas = [{ expression: 'A + B', alias: 'Combined' }]; + const create = await postTile({ + displayType: 'table', + formulas, + }).expect(200); + expect(create.body.data.tiles[0].config.formulas).toEqual(formulas); + + const get = await authRequest( + 'get', + `${BASE_URL}/${create.body.data.id}`, + ).expect(200); + expect(get.body.data.tiles[0].config.formulas).toEqual(formulas); + }); + + it('round-trips a number tile with multiple operands and one formula', async () => { + const formulas = [{ expression: 'A / B * 100', alias: 'Error rate %' }]; + const create = await postTile({ + displayType: 'number', + formulas, + }).expect(200); + const config = create.body.data.tiles[0].config; + expect(config.formulas).toEqual(formulas); + // Every operand select item survives (formulas reference them by + // position); operands are always hidden on number tiles, persisted + // internally rather than exposed on the external surface. + expect(config.select).toHaveLength(2); + expect(config.showOperandSeries).toBeUndefined(); + + const get = await authRequest( + 'get', + `${BASE_URL}/${create.body.data.id}`, + ).expect(200); + expect(get.body.data.tiles[0].config.formulas).toEqual(formulas); + expect(get.body.data.tiles[0].config.select).toHaveLength(2); + + // The internal saved config is self-describing about hidden operands. + const saved = await Dashboard.findById(create.body.data.id).lean(); + const savedConfig = saved!.tiles[0].config; + expect( + isBuilderSavedChartConfig(savedConfig) && savedConfig.showOperandSeries, + ).toBe(false); + }); + + it('round-trips formulas through an update (PUT)', async () => { + const created = await postTile({}).expect(200); + const dashboardId = created.body.data.id; + const tile = created.body.data.tiles[0]; + + const formulas = [{ expression: 'B / A', alias: 'Inverse' }]; + const update = await authRequest('put', `${BASE_URL}/${dashboardId}`) + .send({ + name: 'Formula dashboard', + tiles: [{ ...tile, config: { ...tile.config, formulas } }], + tags: [], + }) + .expect(200); + expect(update.body.data.tiles[0].config.formulas).toEqual(formulas); + + const get = await authRequest('get', `${BASE_URL}/${dashboardId}`).expect( + 200, + ); + expect(get.body.data.tiles[0].config.formulas).toEqual(formulas); + }); + + // ── Negative: one per validation rule ─────────────────────────────── + + it('rejects a formula referencing a series the tile does not have', async () => { + const res = await postTile({ + formulas: [{ expression: 'A / C' }], + }).expect(400); + expect(res.body.message).toContain('tiles.0.config.formulas.0'); + expect(res.body.message).toContain('Unknown series "C"'); + }); + + it('rejects a malformed formula expression', async () => { + const res = await postTile({ + formulas: [{ expression: 'A +' }], + }).expect(400); + expect(res.body.message).toContain('tiles.0.config.formulas.0'); + }); + + it('rejects formulas combined with asRatio', async () => { + const res = await postTile({ + formulas: [{ expression: 'A / B' }], + asRatio: true, + }).expect(400); + expect(res.body.message).toContain('asRatio'); + }); + + it('rejects multiple formulas on a number tile', async () => { + const res = await postTile({ + displayType: 'number', + formulas: [{ expression: 'A / B' }, { expression: 'A + B' }], + }).expect(400); + expect(res.body.message).toContain( + 'Number charts support a single formula', + ); + }); + + it('rejects multiple select items on a number tile without formulas', async () => { + const res = await postTile({ displayType: 'number' }).expect(400); + expect(res.body.message).toContain( + 'Number tiles support a single select item', + ); + }); + + it('rejects formulas on a non-metric source', async () => { + const res = await postTile({ + sourceId: traceSource._id.toString(), + select: [ + { aggFn: 'count', where: '', alias: 'A' }, + { aggFn: 'count', where: '', alias: 'B' }, + ], + formulas: [{ expression: 'A / B' }], + }).expect(400); + expect(res.body.message).toContain('require a Metric source'); + }); + + // ── Backward compatibility: formulas are opt-in ───────────────────── + + it('round-trips a multi-series metric tile with no formulas', async () => { + const create = await postTile({}).expect(200); + expect(create.body.data.tiles[0].config.formulas).toBeUndefined(); + expect( + create.body.data.tiles[0].config.showOperandSeries, + ).toBeUndefined(); + }); + }); + describe('Containers and tabs', () => { const buildTile = ( sourceId: string, diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index 01536b45ff..548ef78105 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -572,6 +572,36 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * Per-series number formatting options. When set, takes precedence * over the chart-level numberFormat for this select item only. * + * Formula: + * type: object + * required: + * - expression + * description: > + * A derived series computed from the chart's select items via a + * letter-ref arithmetic expression (metric sources only). "A" refers + * to select[0], "B" to select[1], and so on. The grammar supports + * + - * /, parentheses, and numeric constants; expressions are parsed + * and validated, never passed through as raw SQL. Division by zero or + * a missing operand yields NULL (rendered as a gap). + * properties: + * expression: + * type: string + * maxLength: 1024 + * description: > + * Arithmetic expression over the select items by position, e.g. + * "A / (A + B) * 100" for a success-rate percentage. + * example: "A / (A + B) * 100" + * alias: + * type: string + * description: > + * Display label for the formula series in chart legends and column + * headers. Falls back to the raw expression text when unset. + * example: "Success rate %" + * numberFormat: + * $ref: '#/components/schemas/NumberFormat' + * description: > + * Per-series number formatting options for the formula series. + * * LineBuilderChartConfig: * type: object * required: @@ -637,6 +667,21 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * the default render cap, set 0 for unlimited, or a positive N to * keep the top N series. * example: 5 + * formulas: + * type: array + * maxItems: 10 + * description: > + * Derived series computed from the select items via letter-ref + * arithmetic ("A" = select[0], "B" = select[1], ...). Metric + * sources only. Cannot be combined with asRatio. + * items: + * $ref: '#/components/schemas/Formula' + * showOperandSeries: + * type: boolean + * description: > + * Only meaningful with formulas. When false, only the formula + * series are returned; the raw operand series are hidden. + * default: true * * BarBuilderChartConfig: * type: object @@ -692,6 +737,21 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * the default render cap, set 0 for unlimited, or a positive N to * keep the top N series. * example: 5 + * formulas: + * type: array + * maxItems: 10 + * description: > + * Derived series computed from the select items via letter-ref + * arithmetic ("A" = select[0], "B" = select[1], ...). Metric + * sources only. Cannot be combined with asRatio. + * items: + * $ref: '#/components/schemas/Formula' + * showOperandSeries: + * type: boolean + * description: > + * Only meaningful with formulas. When false, only the formula + * series are returned; the raw operand series are hidden. + * default: true * * TableBuilderChartConfig: * type: object @@ -751,6 +811,21 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * onClick: * $ref: '#/components/schemas/OnClick' * description: Optional link-out configuration applied when a user clicks a row. + * formulas: + * type: array + * maxItems: 10 + * description: > + * Derived columns computed from the select items via letter-ref + * arithmetic ("A" = select[0], "B" = select[1], ...). Metric + * sources only. Cannot be combined with asRatio. + * items: + * $ref: '#/components/schemas/Formula' + * showOperandSeries: + * type: boolean + * description: > + * Only meaningful with formulas. When false, only the formula + * columns are returned; the raw operand columns are hidden. + * default: true * * NumberBuilderChartConfig: * type: object @@ -772,10 +847,24 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * select: * type: array * minItems: 1 - * maxItems: 1 - * description: Exactly one aggregated value to display as a single number. + * maxItems: 20 + * description: > + * Exactly one aggregated value to display as a single number — + * unless "formulas" is set, in which case the select items are + * the formula's operands and the (single) formula value is + * displayed instead. * items: * $ref: '#/components/schemas/SelectItem' + * formulas: + * type: array + * maxItems: 1 + * description: > + * A single derived value computed from the select items via + * letter-ref arithmetic ("A" = select[0], "B" = select[1], ...). + * Metric sources only. Number tiles display the formula value and + * always hide the operand series. + * items: + * $ref: '#/components/schemas/Formula' * numberFormat: * $ref: '#/components/schemas/NumberFormat' * description: Number formatting options for displayed values. diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index 88b1c19404..2ab82cdac2 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -32,6 +32,7 @@ import { RawSqlSavedChartConfig, resolveChartPaletteToken, SavedChartConfig, + SourceKind, } from '@hyperdx/common-utils/dist/types'; import { SearchConditionLanguageSchema as whereLanguageSchema } from '@hyperdx/common-utils/dist/types'; import { pick } from 'lodash'; @@ -299,6 +300,18 @@ const convertToExternalTileChartConfig = ( return typeof value === 'string' ? value : defaultValue; }; + // Round-trip metric formulas (HDX-5081): emitted only when present so + // formula-less tiles keep their pre-formula response shape. Number tiles + // never emit `showOperandSeries` — operands are always hidden there, and + // the internal converter re-persists the explicit `false` on the way in. + const externalFormulaFields = config.formulas?.length + ? { formulas: config.formulas } + : {}; + const externalShowOperandSeriesField = + config.formulas?.length && config.showOperandSeries !== undefined + ? { showOperandSeries: config.showOperandSeries } + : {}; + switch (config.displayType) { case DisplayType.Line: return { @@ -320,6 +333,8 @@ const convertToExternalTileChartConfig = ( // Three-state passthrough: 0 (unlimited) and positive N round-trip; // null/undefined map to absent (the default-cap state). seriesLimit: config.seriesLimit ?? undefined, + ...externalFormulaFields, + ...externalShowOperandSeriesField, }; case DisplayType.StackedBar: return { @@ -339,15 +354,23 @@ const convertToExternalTileChartConfig = ( // Three-state passthrough: 0 (unlimited) and positive N round-trip; // null/undefined map to absent (the default-cap state). seriesLimit: config.seriesLimit ?? undefined, + ...externalFormulaFields, + ...externalShowOperandSeriesField, }; case DisplayType.Number: return { displayType: config.displayType, sourceId, + // A formula number tile carries every operand select item (the + // formula references them by position), so emit them all; a + // formula-less tile keeps its single-item response shape. select: Array.isArray(config.select) - ? [convertToExternalSelectItem(config.select[0])] + ? config.formulas?.length + ? config.select.map(convertToExternalSelectItem) + : [convertToExternalSelectItem(config.select[0])] : [DEFAULT_SELECT_ITEM], numberFormat: config.numberFormat, + ...externalFormulaFields, // Normalize stored palette tokens on the way out. A static `color` // saved before the hue rename holds a legacy `chart-1`..`chart-10` // token in Mongo (the `tiles` field is `Mixed`), so map it to the @@ -412,6 +435,8 @@ const convertToExternalTileChartConfig = ( ? config.select.map(convertToExternalSelectItem) : [DEFAULT_SELECT_ITEM], orderBy: stringValueOrDefault(config.orderBy, undefined), + ...externalFormulaFields, + ...externalShowOperandSeriesField, }; case DisplayType.Search: return { @@ -722,6 +747,10 @@ export function convertToInternalTileConfig( 'alignDateRangeToGranularity', 'compareToPreviousPeriod', 'fitYAxisToData', + // Metric formulas (HDX-5081) round-trip as-is; the input schema + // has already parsed/validated the expressions against `select`. + 'formulas', + 'showOperandSeries', ]), displayType: externalConfig.displayType === 'stacked_bar' @@ -745,6 +774,10 @@ export function convertToInternalTileConfig( 'orderBy', 'groupByColumnsOnLeft', 'onClick', + // Metric formulas (HDX-5081) round-trip as-is; the input schema + // has already parsed/validated the expressions against `select`. + 'formulas', + 'showOperandSeries', ]), displayType: DisplayType.Table, select: externalConfig.select.map(convertToInternalSelectItem), @@ -757,10 +790,23 @@ export function convertToInternalTileConfig( case 'number': internalConfig = { displayType: DisplayType.Number, - select: [convertToInternalSelectItem(externalConfig.select[0])], + // A formula number tile keeps every operand select item (formulas + // reference them by position); a formula-less tile persists its + // single item, preserving the pre-formula shape. + select: externalConfig.formulas?.length + ? externalConfig.select.map(convertToInternalSelectItem) + : [convertToInternalSelectItem(externalConfig.select[0])], source: externalConfig.sourceId, where: '', numberFormat: externalConfig.numberFormat, + // Number charts display the first value column, so operand series + // are always hidden — persist the explicit `false` so the saved + // config is self-describing, mirroring the chart editor's + // `convertFormStateToSavedChartConfig`. + formulas: externalConfig.formulas, + showOperandSeries: externalConfig.formulas?.length + ? false + : undefined, // The input schema validates these as hue-only palette tokens, // so pass them through directly; `_.omitBy(_.isNil)` below drops // them when absent. @@ -1019,6 +1065,41 @@ function getHeatmapTilesWithIncompatibleSources( }); } +/** + * Returns source IDs referenced by formula tiles that exist but are not + * metric sources. Metric formulas (HDX-5081) only render through the + * composed multi-series metric query — on any other source kind the + * renderer silently ignores them, so accepting the config would persist + * a tile that quietly doesn't do what the caller asked. Mirrors the + * editor's normalization, which strips formulas on non-metric sources + * (`convertFormStateToSavedChartConfig`). + */ +function getFormulaTilesWithNonMetricSources( + sources: SourceForValidation[], + tiles: ExternalDashboardTileWithId[], +): string[] { + const formulaSourceIds = new Set(); + for (const tile of tiles) { + if ( + isConfigTile(tile) && + !isRawSqlExternalTileConfig(tile.config) && + 'formulas' in tile.config && + (tile.config.formulas?.length ?? 0) > 0 && + 'sourceId' in tile.config && + tile.config.sourceId + ) { + formulaSourceIds.add(tile.config.sourceId); + } + } + if (formulaSourceIds.size === 0) return []; + + const sourceById = new Map(sources.map(s => [s._id.toString(), s])); + return [...formulaSourceIds].filter(id => { + const source = sourceById.get(id); + return source !== undefined && source.kind !== SourceKind.Metric; + }); +} + /** * For a PUT (update) request, return only the heatmap tiles that need * to be re-validated against the source-kind gate. A heatmap tile that @@ -1331,6 +1412,16 @@ export async function validateDashboardTiles( return `Heatmap tiles require a Trace source. The following source IDs are not Trace sources: ${heatmapNonTraceSources.join(', ')}`; } + // Metric-formula source-kind gate: the renderer only applies formulas on + // metric sources, so reject rather than persist a silently-inert config. + const formulaNonMetricSources = getFormulaTilesWithNonMetricSources( + sources, + tiles, + ); + if (formulaNonMetricSources.length > 0) { + return `Tiles with formulas require a Metric source. The following source IDs are not Metric sources: ${formulaNonMetricSources.join(', ')}`; + } + if (missingOnClickDashboards.length > 0) { return `Could not find the following onClick dashboard IDs: ${missingOnClickDashboards.join(', ')}`; } diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index eea00d4ae7..de776bf03b 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -1,3 +1,4 @@ +import { validateChartConfigFormulas } from '@hyperdx/common-utils/dist/dashboardValidation'; import { addDuplicateTileIdIssues, AggregateFunctionSchema, @@ -10,6 +11,7 @@ import { DashboardFilterSchema, MAX_TAG_LENGTH, MAX_TAGS, + MetricFormulaSchema, MetricsDataType, NumberFormatSchema, NumberTileColorConditionSchema, @@ -248,6 +250,24 @@ const externalDashboardRawSqlChartConfigBaseSchema = z.object({ numberFormat: NumberFormatSchema.optional(), }); +// Derived series computed from the `select` entries via letter-ref arithmetic +// expressions (`A` = select[0], ...), e.g. "A / (A + B) * 100". Metric sources +// only (enforced by `validateDashboardTiles`, which can see the tile's +// source); expressions are parsed and validated against the tile's select +// list by `validateChartConfigFormulas` in the tile-config refinement below. +// `MetricFormulaSchema` is imported from common-utils so the external surface +// cannot drift from what the UI persists. The array cap mirrors what the +// editor would ever produce (one formula per "Add Formula" click; select +// itself caps at 20). +const externalFormulasSchema = z.array(MetricFormulaSchema).max(10).optional(); + +// Whether the raw operand series referenced by `formulas` are returned +// alongside the formula column(s) (true / unset) or only the formula +// column(s) are returned (false). Only meaningful when `formulas` is +// non-empty; ignored otherwise. Number tiles always hide operands +// (persisted internally as false), so they do not expose this field. +const externalShowOperandSeriesSchema = z.boolean().optional(); + const externalDashboardTimeChartConfigSchema = z.object({ sourceId: objectIdSchema, select: z.array(externalDashboardSelectItemSchema).min(1).max(20), @@ -256,6 +276,8 @@ const externalDashboardTimeChartConfigSchema = z.object({ alignDateRangeToGranularity: z.boolean().optional(), fillNulls: z.boolean().optional(), numberFormat: NumberFormatSchema.optional(), + formulas: externalFormulasSchema, + showOperandSeries: externalShowOperandSeriesSchema, }); const externalDashboardLineChartConfigSchema = @@ -313,6 +335,8 @@ const externalDashboardTableChartConfigSchema = z.object({ numberFormat: NumberFormatSchema.optional(), groupByColumnsOnLeft: z.boolean().optional(), onClick: externalOnClickSchema.optional(), + formulas: externalFormulasSchema, + showOperandSeries: externalShowOperandSeriesSchema, }); const externalDashboardTableRawSqlChartConfigSchema = @@ -349,8 +373,17 @@ const externalDashboardCategoricalBarRawSqlChartConfigSchema = const externalDashboardNumberChartConfigSchema = z.object({ displayType: z.literal('number'), sourceId: objectIdSchema, - select: z.array(externalDashboardSelectItemSchema).length(1), + // Exactly one select item unless `formulas` is set: a formula number tile + // displays the (single) formula value and its select entries are the + // formula's operands (e.g. A / (A + B)). The one-item rule for + // formula-less tiles is enforced in the tile-config refinement below, + // where `formulas` is visible. + select: z.array(externalDashboardSelectItemSchema).min(1).max(20), numberFormat: NumberFormatSchema.optional(), + // Number tiles display a single value, so operand series are always + // hidden — persisted internally as `showOperandSeries: false` (see + // `convertToInternalTileConfig`); the field is not exposed here. + formulas: externalFormulasSchema, // Number-tile color authoring. Mirrors the internal // `SharedChartSettingsSchema` fields (common-utils types.ts), which the // editor gates to number tiles (`ChartDisplaySettingsDrawer`: @@ -539,6 +572,32 @@ const externalDashboardTileConfigSchema = z message: 'asRatio can only be used with exactly two select items', }); } + + if (!('configType' in data)) { + // Metric formulas (builder tiles only): expression parse + series-ref + // range checks, asRatio mutual exclusion, and the number-tile + // single-formula cap — shared with the chart editor's save-time rules + // via common-utils. + validateChartConfigFormulas(data, ctx); + + // A number tile without formulas displays its single select item; the + // relaxed `.min(1).max(20)` on the number schema exists only so + // formula operands fit (the editor enforces the same in + // `validateChartForm`). + if ( + data.displayType === 'number' && + !('formulas' in data && (data.formulas?.length ?? 0) > 0) && + Array.isArray(data.select) && + data.select.length !== 1 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Number tiles support a single select item unless formulas are used (extra items are formula operands)', + path: ['select'], + }); + } + } }) .transform(data => { // Re-parse through the appropriate sub-schema to strip unknown fields. diff --git a/packages/app/src/components/ChartEditor/utils.ts b/packages/app/src/components/ChartEditor/utils.ts index ae155c8a2e..26c948ee5c 100644 --- a/packages/app/src/components/ChartEditor/utils.ts +++ b/packages/app/src/components/ChartEditor/utils.ts @@ -1,7 +1,10 @@ import { omit, pick } from 'lodash'; import { Path, UseFormSetError } from 'react-hook-form'; import { validateFormula } from '@hyperdx/common-utils/dist/core/formula'; -import { validateRawSqlForAlert } from '@hyperdx/common-utils/dist/core/utils'; +import { + isFormulaDisplayType, + validateRawSqlForAlert, +} from '@hyperdx/common-utils/dist/core/utils'; import { isBuilderSavedChartConfig, isPromqlSavedChartConfig, @@ -173,22 +176,10 @@ const isCustomOrderByDisplayType = ( displayType === DisplayType.Bar || displayType === DisplayType.Pie; -/** - * Display types that can carry formulas (HDX-5080) — the shapes the formula - * query paths render (composed multi-series for metrics, inline single-scan - * for events). Mirrors the "Add Formula" gating in ChartEditorControls. - */ -export const isFormulaDisplayType = ( - displayType: DisplayType | undefined, -): displayType is - | DisplayType.Line - | DisplayType.StackedBar - | DisplayType.Table - | DisplayType.Number => - displayType === DisplayType.Line || - displayType === DisplayType.StackedBar || - displayType === DisplayType.Table || - displayType === DisplayType.Number; +// Re-exported from common-utils (single source of truth shared with the +// external API / MCP tile validation) so the "Add Formula" gating in +// ChartEditorControls and the server-side surfaces cannot drift. +export { isFormulaDisplayType }; /** * Source kinds that can carry formulas: metric sources (rendered via the diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index c11cfa773d..a609bf38db 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -144,13 +144,16 @@ dashboard**: 1. `shared/tileConfig.ts` resolves a tile's `SavedChartConfig` against its source (port of the web Tile's `queriedConfig` effect in `DBDashboardPage.tsx`) and applies the per-displayType config transform - (`convertToTimeChartConfig` / `convertToNumberChartConfig` / - `convertToTableChartConfig` / `convertToCategoricalChartConfig`). + (`convertToTimeChartConfig` locally; `convertToNumberChartConfig` / + `convertToTableChartConfig` / `convertToCategoricalChartConfig` are the + shared common-utils transforms the web imports too — the number transform + is formula-aware, hiding operand series on number tiles with `formulas`). 2. `shared/tileQuery.ts` executes it via `clickhouseClient.queryChartConfig()` from common-utils — internally `setChartSelectsAlias → renderChartConfig` - (one composed statement, even for multi-series metric charts), identical to - the web's `useQueriedChartConfig` core (minus chunking / MV optimization / - PromQL). + (one composed statement, even for multi-series metric charts and metric + formula tiles, whose derived `formulas` columns are computed in ClickHouse), + identical to the web's `useQueriedChartConfig` core (minus chunking / MV + optimization / PromQL). 3. `shared/chartData.ts` shapes the response (ports of `formatResponseForTimeChart`, `formatResponseForCategoricalChart`, etc.). 4. `termchart/` renders pure ANSI strings, consumed by both the Ink `TileChart` diff --git a/packages/cli/src/shared/tileConfig.ts b/packages/cli/src/shared/tileConfig.ts index fc23d8b853..d94fbefa70 100644 --- a/packages/cli/src/shared/tileConfig.ts +++ b/packages/cli/src/shared/tileConfig.ts @@ -16,6 +16,8 @@ import { convertDateRangeToGranularityString, convertToCategoricalChartConfig, + convertToNumberChartConfig as convertToNumberChartConfigShared, + convertToTableChartConfig as convertToTableChartConfigShared, getAlignedDateRange, } from '@hyperdx/common-utils/dist/core/utils'; import { @@ -26,7 +28,6 @@ import { isRawSqlSavedChartConfig, } from '@hyperdx/common-utils/dist/guards'; import type { - BuilderChartConfigWithDateRange, ChartConfigWithDateRange, Filter, MetricsDataType, @@ -328,45 +329,31 @@ export function convertToTimeChartConfig( } /** - * @source packages/app/src/ChartUtils.tsx (convertToNumberChartConfig) + * Delegates to the shared common-utils transform (which the web imports too, + * see packages/app/src/ChartUtils.tsx) so formula-aware behavior — number + * tiles with `formulas` hide their operand series — cannot drift. The CLI + * keeps only the raw-SQL passthrough guard the web applies at the call site. */ export function convertToNumberChartConfig( config: ChartConfigWithDateRange, ): ChartConfigWithDateRange { if (!isBuilderChartConfig(config)) return config; - const { granularity: _g, groupBy: _gb, ...rest } = config; - return rest as ChartConfigWithDateRange; + // The shared transform widens timestampValueExpression to optional; the + // date range and every other field pass through, so the narrowing is safe. + return convertToNumberChartConfigShared(config) as ChartConfigWithDateRange; } /** - * @source packages/app/src/ChartUtils.tsx (convertToTableChartConfig) + * Delegates to the shared common-utils transform (which the web imports too, + * see packages/app/src/ChartUtils.tsx): drops granularity, defaults the row + * limit, and stabilizes ordering for grouped tables. */ export function convertToTableChartConfig( config: ChartConfigWithDateRange, ): ChartConfigWithDateRange { if (!isBuilderChartConfig(config)) return config; - - const { granularity: _g, ...rest } = config; - const convertedConfig = structuredClone( - rest, - ) as BuilderChartConfigWithDateRange; - - // Set a default limit if not already set - if (!convertedConfig.limit) { - convertedConfig.limit = { limit: 200 }; - } - - // Set a default orderBy if groupBy is set but orderBy is not, - // so that the set of rows within the limit is stable. - if ( - convertedConfig.groupBy && - typeof convertedConfig.groupBy === 'string' && - !convertedConfig.orderBy - ) { - convertedConfig.orderBy = convertedConfig.groupBy; - } - - return convertedConfig; + // Same safe narrowing as convertToNumberChartConfig above. + return convertToTableChartConfigShared(config) as ChartConfigWithDateRange; } /** diff --git a/packages/common-utils/src/__tests__/dashboardValidation.test.ts b/packages/common-utils/src/__tests__/dashboardValidation.test.ts index c559e637fe..0906fa0e9a 100644 --- a/packages/common-utils/src/__tests__/dashboardValidation.test.ts +++ b/packages/common-utils/src/__tests__/dashboardValidation.test.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { + validateChartConfigFormulas, validateDashboardFilterFieldGating, validateDashboardFilterModes, validateDashboardFilterVariableNames, @@ -419,3 +420,143 @@ describe('validateDashboardFilterFieldGating', () => { expect(issues[0].path).toEqual(['body', 'filters', 0, 'variableName']); }); }); + +describe('validateChartConfigFormulas', () => { + type TestConfig = Parameters[0]; + + const collectFormulaIssues = ( + config: TestConfig, + paths?: { configPath?: (string | number)[] }, + ) => { + const schema = z + .object({}) + .superRefine((_, ctx) => validateChartConfigFormulas(config, ctx, paths)); + const result = schema.safeParse({}); + return result.success ? [] : result.error.issues; + }; + + const twoSeries = [{ aggFn: 'max' }, { aggFn: 'max' }]; + + it('accepts a config without formulas', () => { + expect(collectFormulaIssues({ select: twoSeries })).toEqual([]); + expect(collectFormulaIssues({ select: twoSeries, formulas: [] })).toEqual( + [], + ); + }); + + it('accepts a valid formula over existing series', () => { + expect( + collectFormulaIssues({ + displayType: 'line', + select: twoSeries, + formulas: [{ expression: 'A / (A + B) * 100' }], + }), + ).toEqual([]); + }); + + it('reports an unknown series ref at the formula expression path', () => { + const issues = collectFormulaIssues({ + displayType: 'line', + select: twoSeries, + formulas: [{ expression: 'A / C' }], + }); + + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(['formulas', 0, 'expression']); + expect(issues[0].message).toContain('Unknown series "C"'); + }); + + it('reports a malformed expression', () => { + const issues = collectFormulaIssues({ + displayType: 'line', + select: twoSeries, + formulas: [{ expression: 'A +' }], + }); + + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(['formulas', 0, 'expression']); + }); + + it('reports every invalid formula, not just the first', () => { + const issues = collectFormulaIssues({ + displayType: 'line', + select: twoSeries, + formulas: [ + { expression: 'A / Z' }, + { expression: 'A + B' }, + { expression: '' }, + ], + }); + + expect(issues.map(i => i.path)).toEqual([ + ['formulas', 0, 'expression'], + ['formulas', 2, 'expression'], + ]); + }); + + it('treats a string select as zero referenceable series', () => { + const issues = collectFormulaIssues({ + displayType: 'line', + select: 'Body', + formulas: [{ expression: 'A' }], + }); + + expect(issues).toHaveLength(1); + expect(issues[0].message).toContain('no series'); + }); + + it('rejects formulas combined with asRatio', () => { + const issues = collectFormulaIssues({ + displayType: 'line', + select: twoSeries, + formulas: [{ expression: 'A / B' }], + asRatio: true, + }); + + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(['formulas']); + expect(issues[0].message).toContain('asRatio'); + }); + + it('rejects multiple formulas on a number chart', () => { + const issues = collectFormulaIssues({ + displayType: 'number', + select: twoSeries, + formulas: [{ expression: 'A / B' }, { expression: 'A + B' }], + }); + + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(['formulas']); + expect(issues[0].message).toBe('Number charts support a single formula'); + }); + + it('allows multiple formulas on a line chart', () => { + expect( + collectFormulaIssues({ + displayType: 'line', + select: twoSeries, + formulas: [{ expression: 'A / B' }, { expression: 'A + B' }], + }), + ).toEqual([]); + }); + + it('honors a custom config path', () => { + const issues = collectFormulaIssues( + { + displayType: 'line', + select: twoSeries, + formulas: [{ expression: 'A / C' }], + }, + { configPath: ['tiles', 3, 'config'] }, + ); + + expect(issues[0].path).toEqual([ + 'tiles', + 3, + 'config', + 'formulas', + 0, + 'expression', + ]); + }); +}); diff --git a/packages/common-utils/src/core/utils.ts b/packages/common-utils/src/core/utils.ts index 5816bb5543..e3fff4033e 100644 --- a/packages/common-utils/src/core/utils.ts +++ b/packages/common-utils/src/core/utils.ts @@ -1391,6 +1391,25 @@ export function displayTypeSupportsBuilderAlerts( ); } +/** + * Display types that can carry formulas (HDX-5080) — the shapes the formula + * query paths render (composed multi-series for metrics, inline single-scan + * for events). Shared by the chart editor's "Add Formula" gating + * (`packages/app/src/components/ChartEditor/utils.ts`) and the external API / + * MCP tile validation, so the surfaces cannot drift. + */ +export const isFormulaDisplayType = ( + displayType: DisplayType | undefined, +): displayType is + | DisplayType.Line + | DisplayType.StackedBar + | DisplayType.Table + | DisplayType.Number => + displayType === DisplayType.Line || + displayType === DisplayType.StackedBar || + displayType === DisplayType.Table || + displayType === DisplayType.Number; + export function displayTypeSupportsPromQLAlerts( displayType: DisplayType | undefined, ): boolean { diff --git a/packages/common-utils/src/dashboardValidation.ts b/packages/common-utils/src/dashboardValidation.ts index 5b10880b1f..ccd65e6e58 100644 --- a/packages/common-utils/src/dashboardValidation.ts +++ b/packages/common-utils/src/dashboardValidation.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; +import { validateFormula } from './core/formula'; import { getFilterVariableName, hasFilterEffect, isFilterBroadcastEnabled, isFilterVariableEnabled, } from './filters'; -import { DashboardContainerSchema } from './types'; +import { DashboardContainerSchema, DisplayType } from './types'; // Inputs shared by the internal `DashboardSchema` refinement and the // external API body schema: the validation only depends on the @@ -269,3 +270,77 @@ export function validateDashboardFilterFieldGating< } }); } + +// Structural because the internal and external chart-config shapes differ +// (internal `seriesReturnType: 'ratio'` vs external `asRatio: true`); only +// these fields decide formula validity. `select` stays loosely typed — a +// string select (search tiles) simply has zero letter-referenceable series. +type ChartConfigForFormulaValidation = { + displayType?: DisplayType | string; + select?: unknown; + formulas?: { expression: string }[]; + asRatio?: boolean; +}; + +/** + * Structural validation for a chart config's metric formulas (HDX-5081), + * mirroring the chart editor's save-time rules (`validateChartForm` in + * `packages/app/src/components/ChartEditor/utils.ts`) so the API cannot + * persist a config the editor refuses: + * + * - Every formula expression must parse and only reference existing series + * (`A` = select[0], ...), via the same `validateFormula` the editor and + * the query renderer use. + * - Formulas are mutually exclusive with the ratio toggle (`asRatio`); the + * renderer resolves the combination deterministically (formulas win) but + * persisting both would be a lie in the saved config. + * - Number charts display a single value, so they support at most one + * formula. + * + * The metric-source-only gate deliberately lives with the callers that can + * see the tile's source (`validateDashboardTiles` in the external API) — + * this helper only sees the config. + * + * Issues raised: + * - Invalid expression (path `.formulas[i].expression`). + * - `asRatio` combined with formulas (path `.formulas`). + * - More than one formula on a number chart (path `.formulas`). + */ +export function validateChartConfigFormulas( + config: ChartConfigForFormulaValidation, + ctx: z.RefinementCtx, + paths?: { configPath?: (string | number)[] }, +): void { + const formulas = config.formulas; + if (!formulas || formulas.length === 0) return; + const configPath = paths?.configPath ?? []; + + const seriesCount = Array.isArray(config.select) ? config.select.length : 0; + formulas.forEach((formula, formulaIdx) => { + const result = validateFormula(formula.expression ?? '', { seriesCount }); + if (!result.ok) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: result.errors.map(e => e.message).join('; '), + path: [...configPath, 'formulas', formulaIdx, 'expression'], + }); + } + }); + + if (config.asRatio) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'formulas cannot be combined with asRatio; express the ratio as a formula instead (e.g. "A / B")', + path: [...configPath, 'formulas'], + }); + } + + if (config.displayType === DisplayType.Number && formulas.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Number charts support a single formula', + path: [...configPath, 'formulas'], + }); + } +} From 18bf35ebc5628ca4eab1516c7b89c07b2983e0b5 Mon Sep 17 00:00:00 2001 From: Warren Lee <5959690+wrn14897@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:55:09 -0700 Subject: [PATCH 2/4] fix: close formula validation gaps in MCP patch and update-path source gate Address two review findings on HDX-5081: - clickstack_patch_dashboard now re-validates the merged tile through externalDashboardTileSchemaWithId, the same schema the save_dashboard body schemas route every tile through. Previously a patch could persist configs an equivalent create/full-update rejects: malformed or unknown-ref formula expressions, asRatio combined with formulas (also a pre-existing gap for the asRatio/two-select rule), multiple formulas on a number tile, and multi-select number tiles without formulas. - The metric-source formula gate in validateDashboardTiles is now scoped on updates via filterChangedFormulaTiles, mirroring the heatmap gate: a tile that already carried the same formulas on the same source no longer blocks unrelated dashboard edits when the source's kind was changed after acceptance. New formula tiles, edited formulas, and sourceId changes still flow through the check. --- .../dashboards/patchDashboard.int.test.ts | 179 ++++++++++++++++++ .../mcp/tools/dashboards/patchDashboard.ts | 17 ++ .../__tests__/dashboards.int.test.ts | 44 +++++ .../external-api/v2/utils/dashboards.ts | 59 +++++- 4 files changed, 298 insertions(+), 1 deletion(-) diff --git a/packages/api/src/mcp/__tests__/dashboards/patchDashboard.int.test.ts b/packages/api/src/mcp/__tests__/dashboards/patchDashboard.int.test.ts index f8f9d8eeef..0a64625190 100644 --- a/packages/api/src/mcp/__tests__/dashboards/patchDashboard.int.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/patchDashboard.int.test.ts @@ -1,5 +1,9 @@ +import { MetricsDataType, SourceKind } from '@hyperdx/common-utils/dist/types'; + +import { DEFAULT_DATABASE, DEFAULT_METRICS_TABLE } from '@/fixtures'; import { callTool, getFirstText } from '@/mcp/__tests__/mcpTestUtils'; import Dashboard from '@/models/dashboard'; +import { Source } from '@/models/source'; import type { ExternalDashboardTileWithId } from '@/utils/zod'; import { setupDashboardTests } from './setup'; @@ -870,4 +874,179 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { expect(output.warnings).toBeUndefined(); }); }); + + // Patches replace a single tile without going through the save_dashboard + // body schemas, so the handler must re-run the external tile schema's + // cross-field refinements itself — otherwise a patch could persist a + // config that an equivalent create or full update rejects (HDX-5081). + describe('metric formula validation (HDX-5081)', () => { + const metricSelect = () => [ + { + aggFn: 'max', + metricType: 'gauge', + metricName: 'patch.errors', + alias: 'Errors', + }, + { + aggFn: 'max', + metricType: 'gauge', + metricName: 'patch.requests', + alias: 'Requests', + }, + ]; + + const createMetricSource = () => + Source.create({ + kind: SourceKind.Metric, + team: ctx.team._id, + from: { databaseName: DEFAULT_DATABASE, tableName: '' }, + metricTables: { + [MetricsDataType.Gauge.toLowerCase()]: DEFAULT_METRICS_TABLE.GAUGE, + }, + timestampValueExpression: 'TimeUnix', + connection: ctx.connection._id, + name: 'Gauge Metrics (patch)', + }); + + const createFormulaDashboard = async (sourceId: string) => { + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Patch Formula Dashboard', + tiles: [ + { + name: 'Error rate', + config: { + displayType: 'line', + sourceId, + select: metricSelect(), + formulas: [{ expression: 'A / B * 100', alias: 'Rate %' }], + }, + }, + ], + }, + ); + if (createResult.isError) { + throw new Error(getFirstText(createResult)); + } + return JSON.parse(getFirstText(createResult)); + }; + + it('rejects a patch whose formula references an unknown series', async () => { + const metricSource = await createMetricSource(); + const sourceId = metricSource._id.toString(); + const dashboard = await createFormulaDashboard(sourceId); + + const patchResult = await callTool( + ctx.client!, + 'clickstack_patch_dashboard', + { + dashboardId: dashboard.id, + tileId: dashboard.tiles[0].id, + tile: { + config: { + displayType: 'line', + sourceId, + select: metricSelect(), + formulas: [{ expression: 'A / C' }], + }, + }, + }, + ); + + expect(patchResult.isError).toBe(true); + expect(getFirstText(patchResult)).toContain('Unknown series'); + }); + + it('rejects a patch combining formulas with asRatio', async () => { + const metricSource = await createMetricSource(); + const sourceId = metricSource._id.toString(); + const dashboard = await createFormulaDashboard(sourceId); + + const patchResult = await callTool( + ctx.client!, + 'clickstack_patch_dashboard', + { + dashboardId: dashboard.id, + tileId: dashboard.tiles[0].id, + tile: { + config: { + displayType: 'line', + sourceId, + select: metricSelect(), + formulas: [{ expression: 'A / B' }], + asRatio: true, + }, + }, + }, + ); + + expect(patchResult.isError).toBe(true); + expect(getFirstText(patchResult)).toContain('asRatio'); + }); + + it('rejects a patch making a number tile multi-select without formulas', async () => { + const metricSource = await createMetricSource(); + const sourceId = metricSource._id.toString(); + const dashboard = await createFormulaDashboard(sourceId); + + const patchResult = await callTool( + ctx.client!, + 'clickstack_patch_dashboard', + { + dashboardId: dashboard.id, + tileId: dashboard.tiles[0].id, + tile: { + config: { + displayType: 'number', + sourceId, + select: metricSelect(), + }, + }, + }, + ); + + expect(patchResult.isError).toBe(true); + expect(getFirstText(patchResult)).toContain( + 'Number tiles support a single select item', + ); + }); + + it('round-trips a valid formula patch', async () => { + const metricSource = await createMetricSource(); + const sourceId = metricSource._id.toString(); + const dashboard = await createFormulaDashboard(sourceId); + + const formulas = [{ expression: 'B / A', alias: 'Inverse' }]; + const patchResult = await callTool( + ctx.client!, + 'clickstack_patch_dashboard', + { + dashboardId: dashboard.id, + tileId: dashboard.tiles[0].id, + tile: { + config: { + displayType: 'line', + sourceId, + select: metricSelect(), + formulas, + showOperandSeries: false, + }, + }, + }, + ); + + expect(patchResult.isError).toBeFalsy(); + + const getResult = await callTool( + ctx.client!, + 'clickstack_get_dashboard_tile', + { dashboardId: dashboard.id, tileId: dashboard.tiles[0].id }, + ); + const tile = JSON.parse(getFirstText(getResult)); + expect(tile.config.formulas).toEqual(formulas); + expect(tile.config.showOperandSeries).toBe(false); + }); + }); }); diff --git a/packages/api/src/mcp/tools/dashboards/patchDashboard.ts b/packages/api/src/mcp/tools/dashboards/patchDashboard.ts index cd4c1a9c9d..06eeafcc0c 100644 --- a/packages/api/src/mcp/tools/dashboards/patchDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/patchDashboard.ts @@ -12,6 +12,7 @@ import { validateDashboardTiles, } from '@/routers/external-api/v2/utils/dashboards'; import type { ExternalDashboardTileWithId } from '@/utils/zod'; +import { externalDashboardTileSchemaWithId } from '@/utils/zod'; import { mcpPatchDashboardSchema } from './schemas'; import { @@ -143,6 +144,22 @@ export function registerPatchDashboard({ config: incoming.config, } as ExternalDashboardTileWithId; + // Re-validate the merged tile through the external tile schema — + // the same one the save_dashboard body schemas route every tile + // through. The MCP input schema only checks field shapes; the + // cross-field refinements (formula expression validation, the + // asRatio/two-select rule, the number-tile single-select rule) + // live on the external schema's superRefine, so skipping this + // parse would let a patch persist a config that an equivalent + // create or full update rejects. + const tileParse = + externalDashboardTileSchemaWithId.safeParse(mergedTile); + if (!tileParse.success) { + return mcpUserError( + `Validation error: ${JSON.stringify(tileParse.error.errors)}`, + ); + } + // Error on raw SQL tiles that have no source defined but which use // macros which require a source to be set const sqlFilterSourceError = getRawSqlMissingSourceError([mergedTile]); diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts index a319bc3897..3c4fa3c81a 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts @@ -6244,6 +6244,50 @@ describe('External API v2 Dashboards - new format', () => { expect(res.body.message).toContain('require a Metric source'); }); + it('does not block updates over an unchanged formula tile whose source kind changed', async () => { + // Accept a formula tile while the source is a metric source, then + // flip the source's kind out from under it (a full-replace source + // update can do this). Mirroring the heatmap gate, an update that + // leaves the formula tile untouched must not be rejected — only + // changes to the tile's formulas/source re-enter the gate. + const created = await postTile({ + formulas: [{ expression: 'A / B', alias: 'Rate' }], + }).expect(200); + const dashboardId = created.body.data.id; + const tile = created.body.data.tiles[0]; + + // `kind` is the Mongoose discriminator key, which updateOne silently + // strips from $set — write through the raw collection instead, the + // same way a source-replace API call would leave the doc. + await Source.collection.updateOne( + { _id: metricSource._id }, + { $set: { kind: SourceKind.Log } }, + ); + + // Unrelated edit (rename) resubmitting the unchanged tile: accepted. + await authRequest('put', `${BASE_URL}/${dashboardId}`) + .send({ name: 'Renamed formula dashboard', tiles: [tile], tags: [] }) + .expect(200); + + // Changing the tile's formulas re-enters the gate: rejected. + const res = await authRequest('put', `${BASE_URL}/${dashboardId}`) + .send({ + name: 'Renamed formula dashboard', + tiles: [ + { + ...tile, + config: { + ...tile.config, + formulas: [{ expression: 'B / A', alias: 'Inverse' }], + }, + }, + ], + tags: [], + }) + .expect(400); + expect(res.body.message).toContain('require a Metric source'); + }); + // ── Backward compatibility: formulas are opt-in ───────────────────── it('round-trips a multi-series metric tile with no formulas', async () => { diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index 2ab82cdac2..fd1023cf7b 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -1100,6 +1100,56 @@ function getFormulaTilesWithNonMetricSources( }); } +/** + * For a PUT (update) request, return only the formula tiles that need to + * be re-validated against the metric-source gate. Mirrors + * `filterChangedHeatmapTiles` below: a tile that already carried the same + * formulas on the same source in the existing dashboard is kept as + * "unchanged" so the user can edit other parts of the dashboard without + * being blocked when the underlying source's `kind` was changed after the + * formulas were originally accepted. New formula tiles, tiles that newly + * gained or edited formulas, and tiles whose `sourceId` changed all flow + * through the check. + */ +function filterChangedFormulaTiles( + requestTiles: ExternalDashboardTileWithId[], + existingTiles: DashboardDocument['tiles'], +): ExternalDashboardTileWithId[] { + const existingTilesById = new Map( + existingTiles.map(t => [t.id, t]), + ); + return requestTiles.filter(tile => { + if ( + !isConfigTile(tile) || + isRawSqlExternalTileConfig(tile.config) || + !('formulas' in tile.config) || + (tile.config.formulas?.length ?? 0) === 0 + ) { + return false; + } + const existing = tile.id ? existingTilesById.get(tile.id) : undefined; + if (existing === undefined) { + // New formula tile: validate. + return true; + } + const existingConfig = existing.config; + if (!isBuilderSavedChartConfig(existingConfig)) { + // Existing tile was raw-SQL/PromQL; user is converting to a builder + // tile with formulas. + return true; + } + if (!_.isEqual(existingConfig.formulas, tile.config.formulas)) { + // Formulas newly added or edited: the user is actively touching the + // gated feature, so surface the source-kind error rather than + // persisting another silently-inert formula. + return true; + } + // Existing tile already carried these exact formulas. Re-check only + // when the source changed. + return existingConfig.source?.toString() !== tile.config.sourceId; + }); +} + /** * For a PUT (update) request, return only the heatmap tiles that need * to be re-validated against the source-kind gate. A heatmap tile that @@ -1414,9 +1464,16 @@ export async function validateDashboardTiles( // Metric-formula source-kind gate: the renderer only applies formulas on // metric sources, so reject rather than persist a silently-inert config. + // On create (no existingTiles), validate all tiles. On update, scope to + // tiles whose formulas/sourceId changed — mirroring the heatmap gate — + // so a source whose kind changed after acceptance doesn't block + // unrelated dashboard edits. + const formulaTilesToCheck = existingTiles + ? filterChangedFormulaTiles(tiles, existingTiles) + : tiles; const formulaNonMetricSources = getFormulaTilesWithNonMetricSources( sources, - tiles, + formulaTilesToCheck, ); if (formulaNonMetricSources.length > 0) { return `Tiles with formulas require a Metric source. The following source IDs are not Metric sources: ${formulaNonMetricSources.join(', ')}`; From 5178b96ac50f3d75e3006cf6fe61a6f3b39a6b99 Mon Sep 17 00:00:00 2001 From: Warren Lee <5959690+wrn14897@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:56:59 -0700 Subject: [PATCH 3/4] feat: extend formula API support to log/trace event sources (HDX-5132) Rebased onto #2953 (event-source formulas) and widened the API gate to match its editor gating: - isFormulaSourceKind (Metric | Log | Trace) moves from the app chart editor into common-utils core/utils (the app re-exports it), so the editor's Add Formula gating and the server-side surfaces share one predicate. - The external/MCP formula source gate now rejects only formula-incapable kinds (e.g. session) instead of everything non-metric, with an updated error message; MCP tool descriptions, the dashboards query-guide prompt, and the OpenAPI spec document the widened support (including a log error-rate example). - Tests: external round-trip of a trace-source formula tile plus a session-source rejection (kind-flip test now flips to session); MCP save + query_tile of a log-source formula tile asserting the inline-computed value with hidden operands; checkAlerts event-formula tile alert proving the threshold evaluates the formula value. --- .changeset/formula-api-surfaces.md | 2 +- packages/api/openapi.json | 10 +- .../dashboards/queryTile.int.test.ts | 108 ++++++++++++- .../api/src/mcp/prompts/dashboards/content.ts | 28 +++- .../api/src/mcp/tools/dashboards/schemas.ts | 12 +- .../__tests__/dashboards.int.test.ts | 61 ++++++-- .../src/routers/external-api/v2/dashboards.ts | 18 ++- .../external-api/v2/utils/dashboards.ts | 37 ++--- .../__tests__/checkAlerts.int.test.ts | 146 ++++++++++++++++++ .../app/src/components/ChartEditor/utils.ts | 16 +- packages/common-utils/src/core/utils.ts | 16 ++ 11 files changed, 381 insertions(+), 73 deletions(-) diff --git a/.changeset/formula-api-surfaces.md b/.changeset/formula-api-surfaces.md index 3a3599ae72..5094a9b34a 100644 --- a/.changeset/formula-api-surfaces.md +++ b/.changeset/formula-api-surfaces.md @@ -4,4 +4,4 @@ '@hyperdx/cli': patch --- -Metric formulas are now supported across every API surface that persists or accepts chart configs. The external dashboards API v2 and the MCP `save_dashboard` / `patch_dashboard` tools accept `formulas` (letter-ref arithmetic over the tile's select items, e.g. `A / (A + B) * 100`) and `showOperandSeries` on line, stacked bar, table and number builder tiles, round-trip them through GET/PUT, and validate the expressions on write — unknown series refs, malformed syntax, combining formulas with `asRatio`, multiple formulas on a number tile, and formulas on non-metric sources are all rejected with actionable errors. MCP `query_tile` computes formula columns, the query-guide prompt documents the feature, and the OpenAPI spec includes the new `Formula` schema. The CLI's dashboard tile pipeline now delegates its number/table config transforms to the shared common-utils implementations, so formula tiles render with operand-hiding behavior identical to the web. +Chart formulas are now supported across every API surface that persists or accepts chart configs. The external dashboards API v2 and the MCP `save_dashboard` / `patch_dashboard` tools accept `formulas` (letter-ref arithmetic over the tile's select items, e.g. `A / (A + B) * 100`) and `showOperandSeries` on line, stacked bar, table and number builder tiles, round-trip them through GET/PUT, and validate the expressions on write — unknown series refs, malformed syntax, combining formulas with `asRatio`, multiple formulas on a number tile, and formulas on formula-incapable source kinds (anything other than metric, log, or trace) are all rejected with actionable errors. MCP `query_tile` computes formula columns for both metric and log/trace event tiles, the query-guide prompt documents the feature, and the OpenAPI spec includes the new `Formula` schema. The CLI's dashboard tile pipeline now delegates its number/table config transforms to the shared common-utils implementations, so formula tiles render with operand-hiding behavior identical to the web. diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 318845dfdf..32e7653e7d 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -1596,7 +1596,7 @@ "required": [ "expression" ], - "description": "A derived series computed from the chart's select items via a letter-ref arithmetic expression (metric sources only). \"A\" refers to select[0], \"B\" to select[1], and so on. The grammar supports + - * /, parentheses, and numeric constants; expressions are parsed and validated, never passed through as raw SQL. Division by zero or a missing operand yields NULL (rendered as a gap).\n", + "description": "A derived series computed from the chart's select items via a letter-ref arithmetic expression (metric, log, and trace sources only). \"A\" refers to select[0], \"B\" to select[1], and so on. The grammar supports + - * /, parentheses, and numeric constants; expressions are parsed and validated, never passed through as raw SQL. Division by zero or a missing operand yields NULL (rendered as a gap).\n", "properties": { "expression": { "type": "string", @@ -1690,7 +1690,7 @@ "formulas": { "type": "array", "maxItems": 10, - "description": "Derived series computed from the select items via letter-ref arithmetic (\"A\" = select[0], \"B\" = select[1], ...). Metric sources only. Cannot be combined with asRatio.\n", + "description": "Derived series computed from the select items via letter-ref arithmetic (\"A\" = select[0], \"B\" = select[1], ...). Metric, log, and trace sources only. Cannot be combined with asRatio.\n", "items": { "$ref": "#/components/schemas/Formula" } @@ -1767,7 +1767,7 @@ "formulas": { "type": "array", "maxItems": 10, - "description": "Derived series computed from the select items via letter-ref arithmetic (\"A\" = select[0], \"B\" = select[1], ...). Metric sources only. Cannot be combined with asRatio.\n", + "description": "Derived series computed from the select items via letter-ref arithmetic (\"A\" = select[0], \"B\" = select[1], ...). Metric, log, and trace sources only. Cannot be combined with asRatio.\n", "items": { "$ref": "#/components/schemas/Formula" } @@ -1850,7 +1850,7 @@ "formulas": { "type": "array", "maxItems": 10, - "description": "Derived columns computed from the select items via letter-ref arithmetic (\"A\" = select[0], \"B\" = select[1], ...). Metric sources only. Cannot be combined with asRatio.\n", + "description": "Derived columns computed from the select items via letter-ref arithmetic (\"A\" = select[0], \"B\" = select[1], ...). Metric, log, and trace sources only. Cannot be combined with asRatio.\n", "items": { "$ref": "#/components/schemas/Formula" } @@ -1896,7 +1896,7 @@ "formulas": { "type": "array", "maxItems": 1, - "description": "A single derived value computed from the select items via letter-ref arithmetic (\"A\" = select[0], \"B\" = select[1], ...). Metric sources only. Number tiles display the formula value and always hide the operand series.\n", + "description": "A single derived value computed from the select items via letter-ref arithmetic (\"A\" = select[0], \"B\" = select[1], ...). Metric, log, and trace sources only. Number tiles display the formula value and always hide the operand series.\n", "items": { "$ref": "#/components/schemas/Formula" } diff --git a/packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts b/packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts index daceec4222..b2f857987c 100644 --- a/packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts @@ -213,15 +213,113 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => { expect(rows[0]).not.toHaveProperty('Requests'); }); - it('should reject a formula tile on a non-metric source (HDX-5081)', async () => { + it('should save and query a log-source formula tile (HDX-5132)', async () => { + // Event (log/trace) formulas compile inline in the single-scan SELECT + // (renderSelectListWithFormulas) rather than the composed metric path — + // prove the whole surface end-to-end: MCP save → internal persistence → + // external round-trip → query_tile computes the derived column. + const logSource = await Source.create({ + kind: SourceKind.Log, + team: ctx.team._id, + from: { + databaseName: DEFAULT_DATABASE, + tableName: DEFAULT_LOGS_TABLE, + }, + timestampValueExpression: 'Timestamp', + connection: ctx.connection._id, + name: 'Formula Logs', + bodyExpression: 'Body', + severityTextExpression: 'SeverityText', + }); + const now = new Date(); + await bulkInsertLogs([ + ...Array.from({ length: 3 }, (_, i) => ({ + Body: `formula error log ${i}`, + ServiceName: 'api', + SeverityText: 'error', + Timestamp: now, + })), + ...Array.from({ length: 9 }, (_, i) => ({ + Body: `formula info log ${i}`, + ServiceName: 'api', + SeverityText: 'info', + Timestamp: now, + })), + ]); + + const formulas = [{ expression: 'A / B * 100', alias: 'Error rate %' }]; + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Log Formula Dashboard', + tiles: [ + { + name: 'Log error rate', + config: { + displayType: 'number', + sourceId: logSource._id.toString(), + select: [ + { + aggFn: 'count', + where: 'SeverityText:error', + alias: 'Errors', + }, + { aggFn: 'count', alias: 'Total' }, + ], + formulas, + }, + }, + ], + }, + ); + if (createResult.isError) { + throw new Error(getFirstText(createResult)); + } + const dashboard = JSON.parse(getFirstText(createResult)); + expect(dashboard.tiles[0].config.formulas).toEqual(formulas); + + const result = await callTool(ctx.client!, 'clickstack_query_tile', { + dashboardId: dashboard.id, + tileId: dashboard.tiles[0].id, + startTime: new Date(now.getTime() - 60_000).toISOString(), + endTime: new Date(now.getTime() + 60_000).toISOString(), + }); + + expect(result.isError).toBeFalsy(); + const parsed: { + result: { data: Array> }; + } = JSON.parse(getFirstText(result)); + const rows = parsed.result.data; + expect(rows.length).toBeGreaterThan(0); + // 3 errors / 12 total * 100 = 25; number tiles always hide operands. + expect(Number(rows[0]['Error rate %'])).toBe(25); + expect(rows[0]).not.toHaveProperty('Errors'); + expect(rows[0]).not.toHaveProperty('Total'); + }); + + it('should reject a formula tile on a formula-incapable source kind (HDX-5081)', async () => { + const sessionSource = await Source.create({ + kind: SourceKind.Session, + team: ctx.team._id, + from: { + databaseName: DEFAULT_DATABASE, + tableName: 'rrweb_events', + }, + timestampValueExpression: 'Timestamp', + traceSourceId: ctx.traceSource._id.toString(), + connection: ctx.connection._id, + name: 'Sessions', + }); + const result = await callTool(ctx.client!, 'clickstack_save_dashboard', { name: 'Bad Formula Dashboard', tiles: [ { - name: 'Formula on traces', + name: 'Formula on sessions', config: { displayType: 'line', - sourceId: ctx.traceSource._id.toString(), + sourceId: sessionSource._id.toString(), select: [ { aggFn: 'count', alias: 'A' }, { aggFn: 'count', alias: 'B' }, @@ -233,7 +331,9 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => { }); expect(result.isError).toBe(true); - expect(getFirstText(result)).toContain('require a Metric source'); + expect(getFirstText(result)).toContain( + 'require a Metric, Log, or Trace source', + ); }); it('should reject a formula referencing an unknown series (HDX-5081)', async () => { diff --git a/packages/api/src/mcp/prompts/dashboards/content.ts b/packages/api/src/mcp/prompts/dashboards/content.ts index 492e901776..9f994c46fb 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -1063,13 +1063,14 @@ to plot the first as a ratio of the second. Useful for error rates: ], asRatio: true -== FORMULAS (metric sources only) == - -line / stacked_bar / table / number tiles on a METRIC source can add derived -series computed from the select items via letter-ref arithmetic: "A" is -select[0], "B" is select[1], and so on. The grammar is + - * /, parentheses, -and numeric constants; division by zero or a missing operand renders as a gap -(NULL). Example: a success-rate percentage over three counters: +== FORMULAS (metric + log/trace sources) == + +line / stacked_bar / table / number tiles on a METRIC, LOG, or TRACE source +can add derived series computed from the select items via letter-ref +arithmetic: "A" is select[0], "B" is select[1], and so on. The grammar is ++ - * /, parentheses, and numeric constants; division by zero or a missing +operand renders as a gap. Example: a success-rate percentage over three +metric counters: select: [ { aggFn: "sum", metricType: "sum", metricName: "requests.success", alias: "Success" }, { aggFn: "sum", metricType: "sum", metricName: "requests.error", alias: "Error" }, @@ -1079,8 +1080,19 @@ and numeric constants; division by zero or a missing operand renders as a gap { expression: "A / (A + B + C) * 100", alias: "Success rate %", numberFormat: { output: "percent", mantissa: 1 } } ] +Example: an error-rate percentage on a log/trace source, from two filtered +counts over the same rows: + select: [ + { aggFn: "count", where: "SeverityText:error", alias: "Errors" }, + { aggFn: "count", alias: "Total" } + ], + formulas: [ + { expression: "A / B * 100", alias: "Error rate %" } + ] + Rules: - - Metric sources only; the server rejects formulas on log/trace sources. + - Metric, log, and trace sources only; the server rejects formulas on other + source kinds (e.g. session). - Each formula adds one series after the operand series. Set showOperandSeries: false to return only the formula series. - Cannot be combined with asRatio: express the ratio as a formula ("A / B"). diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index aa84f6fe74..c30b0b587e 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -294,12 +294,12 @@ const mcpFormulaSchema = z.object({ }); const tileFormulasDescription = - 'METRIC SOURCES ONLY. Derived series computed from the select items via ' + - 'letter-ref arithmetic ("A" = select[0], "B" = select[1], ...). Example: ' + - '[{ expression: "A / B * 100", alias: "Error rate %" }] with select = ' + - '[errors count, total count]. Each formula adds one series computed in ' + - 'ClickHouse. Cannot be combined with asRatio (express the ratio as a ' + - 'formula instead). Rejected on non-metric sources.'; + 'METRIC, LOG, AND TRACE SOURCES. Derived series computed from the select ' + + 'items via letter-ref arithmetic ("A" = select[0], "B" = select[1], ...). ' + + 'Example: [{ expression: "A / B * 100", alias: "Error rate %" }] with ' + + 'select = [errors count, total count]. Each formula adds one series ' + + 'computed in ClickHouse. Cannot be combined with asRatio (express the ' + + 'ratio as a formula instead). Rejected on other source kinds (e.g. session).'; const mcpTileFormulasSchema = z .array(mcpFormulaSchema) diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts index 3c4fa3c81a..3dfd2c0eb6 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts @@ -6232,24 +6232,65 @@ describe('External API v2 Dashboards - new format', () => { ); }); - it('rejects formulas on a non-metric source', async () => { - const res = await postTile({ + it('round-trips formulas on a log/trace event source (HDX-5132)', async () => { + // Event sources compile formulas inline in the single-scan SELECT + // (renderSelectListWithFormulas) — the API accepts and round-trips + // them just like metric formulas. + const formulas = [{ expression: 'A / B * 100', alias: 'Error rate %' }]; + const create = await postTile({ sourceId: traceSource._id.toString(), + select: [ + { aggFn: 'count', where: 'StatusCode:Error', alias: 'Errors' }, + { aggFn: 'count', where: '', alias: 'Total' }, + ], + formulas, + showOperandSeries: false, + }).expect(200); + expect(create.body.data.tiles[0].config.formulas).toEqual(formulas); + expect(create.body.data.tiles[0].config.showOperandSeries).toBe(false); + + const get = await authRequest( + 'get', + `${BASE_URL}/${create.body.data.id}`, + ).expect(200); + expect(get.body.data.tiles[0].config.formulas).toEqual(formulas); + expect(get.body.data.tiles[0].config.showOperandSeries).toBe(false); + }); + + it('rejects formulas on a formula-incapable source kind (session)', async () => { + const sessionSource = await Source.create({ + kind: SourceKind.Session, + team: team._id, + from: { + databaseName: DEFAULT_DATABASE, + tableName: 'rrweb_events', + }, + timestampValueExpression: 'Timestamp', + traceSourceId: traceSource._id.toString(), + connection: connection._id, + name: 'Sessions', + }); + + const res = await postTile({ + sourceId: sessionSource._id.toString(), select: [ { aggFn: 'count', where: '', alias: 'A' }, { aggFn: 'count', where: '', alias: 'B' }, ], formulas: [{ expression: 'A / B' }], }).expect(400); - expect(res.body.message).toContain('require a Metric source'); + expect(res.body.message).toContain( + 'require a Metric, Log, or Trace source', + ); }); it('does not block updates over an unchanged formula tile whose source kind changed', async () => { // Accept a formula tile while the source is a metric source, then - // flip the source's kind out from under it (a full-replace source - // update can do this). Mirroring the heatmap gate, an update that - // leaves the formula tile untouched must not be rejected — only - // changes to the tile's formulas/source re-enter the gate. + // flip the source's kind to a formula-incapable one out from under it + // (a full-replace source update can do this). Mirroring the heatmap + // gate, an update that leaves the formula tile untouched must not be + // rejected — only changes to the tile's formulas/source re-enter the + // gate. const created = await postTile({ formulas: [{ expression: 'A / B', alias: 'Rate' }], }).expect(200); @@ -6261,7 +6302,7 @@ describe('External API v2 Dashboards - new format', () => { // same way a source-replace API call would leave the doc. await Source.collection.updateOne( { _id: metricSource._id }, - { $set: { kind: SourceKind.Log } }, + { $set: { kind: SourceKind.Session } }, ); // Unrelated edit (rename) resubmitting the unchanged tile: accepted. @@ -6285,7 +6326,9 @@ describe('External API v2 Dashboards - new format', () => { tags: [], }) .expect(400); - expect(res.body.message).toContain('require a Metric source'); + expect(res.body.message).toContain( + 'require a Metric, Log, or Trace source', + ); }); // ── Backward compatibility: formulas are opt-in ───────────────────── diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index 548ef78105..a61485c8fc 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -578,7 +578,8 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * - expression * description: > * A derived series computed from the chart's select items via a - * letter-ref arithmetic expression (metric sources only). "A" refers + * letter-ref arithmetic expression (metric, log, and trace sources + * only). "A" refers * to select[0], "B" to select[1], and so on. The grammar supports * + - * /, parentheses, and numeric constants; expressions are parsed * and validated, never passed through as raw SQL. Division by zero or @@ -672,8 +673,8 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * maxItems: 10 * description: > * Derived series computed from the select items via letter-ref - * arithmetic ("A" = select[0], "B" = select[1], ...). Metric - * sources only. Cannot be combined with asRatio. + * arithmetic ("A" = select[0], "B" = select[1], ...). Metric, + * log, and trace sources only. Cannot be combined with asRatio. * items: * $ref: '#/components/schemas/Formula' * showOperandSeries: @@ -742,8 +743,8 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * maxItems: 10 * description: > * Derived series computed from the select items via letter-ref - * arithmetic ("A" = select[0], "B" = select[1], ...). Metric - * sources only. Cannot be combined with asRatio. + * arithmetic ("A" = select[0], "B" = select[1], ...). Metric, + * log, and trace sources only. Cannot be combined with asRatio. * items: * $ref: '#/components/schemas/Formula' * showOperandSeries: @@ -816,8 +817,8 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * maxItems: 10 * description: > * Derived columns computed from the select items via letter-ref - * arithmetic ("A" = select[0], "B" = select[1], ...). Metric - * sources only. Cannot be combined with asRatio. + * arithmetic ("A" = select[0], "B" = select[1], ...). Metric, + * log, and trace sources only. Cannot be combined with asRatio. * items: * $ref: '#/components/schemas/Formula' * showOperandSeries: @@ -861,7 +862,8 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * description: > * A single derived value computed from the select items via * letter-ref arithmetic ("A" = select[0], "B" = select[1], ...). - * Metric sources only. Number tiles display the formula value and + * Metric, log, and trace sources only. Number tiles display the + * formula value and * always hide the operand series. * items: * $ref: '#/components/schemas/Formula' diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index fd1023cf7b..5d6b46c08d 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -1,6 +1,7 @@ import { displayTypeSupportsBuilderAlerts, displayTypeSupportsRawSqlAlerts, + isFormulaSourceKind, } from '@hyperdx/common-utils/dist/core/utils'; import { validateDashboardContainersStructure, @@ -32,7 +33,6 @@ import { RawSqlSavedChartConfig, resolveChartPaletteToken, SavedChartConfig, - SourceKind, } from '@hyperdx/common-utils/dist/types'; import { SearchConditionLanguageSchema as whereLanguageSchema } from '@hyperdx/common-utils/dist/types'; import { pick } from 'lodash'; @@ -1067,14 +1067,14 @@ function getHeatmapTilesWithIncompatibleSources( /** * Returns source IDs referenced by formula tiles that exist but are not - * metric sources. Metric formulas (HDX-5081) only render through the - * composed multi-series metric query — on any other source kind the - * renderer silently ignores them, so accepting the config would persist - * a tile that quietly doesn't do what the caller asked. Mirrors the - * editor's normalization, which strips formulas on non-metric sources - * (`convertFormStateToSavedChartConfig`). + * formula-capable. Formulas render on metric sources (via the composed + * multi-series metric query, HDX-5081) and log/trace event sources (inline + * in the single-scan SELECT, HDX-5132); other kinds (e.g. session) are + * deliberately gated off, mirroring the editor's "Add Formula" gating and + * save-time normalization via the shared `isFormulaSourceKind` predicate, + * so the API cannot persist a config the editor refuses. */ -function getFormulaTilesWithNonMetricSources( +function getFormulaTilesWithIncompatibleSources( sources: SourceForValidation[], tiles: ExternalDashboardTileWithId[], ): string[] { @@ -1096,7 +1096,7 @@ function getFormulaTilesWithNonMetricSources( const sourceById = new Map(sources.map(s => [s._id.toString(), s])); return [...formulaSourceIds].filter(id => { const source = sourceById.get(id); - return source !== undefined && source.kind !== SourceKind.Metric; + return source !== undefined && !isFormulaSourceKind(source.kind); }); } @@ -1462,21 +1462,22 @@ export async function validateDashboardTiles( return `Heatmap tiles require a Trace source. The following source IDs are not Trace sources: ${heatmapNonTraceSources.join(', ')}`; } - // Metric-formula source-kind gate: the renderer only applies formulas on - // metric sources, so reject rather than persist a silently-inert config. - // On create (no existingTiles), validate all tiles. On update, scope to - // tiles whose formulas/sourceId changed — mirroring the heatmap gate — - // so a source whose kind changed after acceptance doesn't block - // unrelated dashboard edits. + // Formula source-kind gate: formulas are supported on metric and + // log/trace sources (see isFormulaSourceKind); other kinds are rejected + // rather than persisting a config the editor refuses. On create (no + // existingTiles), validate all tiles. On update, scope to tiles whose + // formulas/sourceId changed — mirroring the heatmap gate — so a source + // whose kind changed after acceptance doesn't block unrelated dashboard + // edits. const formulaTilesToCheck = existingTiles ? filterChangedFormulaTiles(tiles, existingTiles) : tiles; - const formulaNonMetricSources = getFormulaTilesWithNonMetricSources( + const formulaIncompatibleSources = getFormulaTilesWithIncompatibleSources( sources, formulaTilesToCheck, ); - if (formulaNonMetricSources.length > 0) { - return `Tiles with formulas require a Metric source. The following source IDs are not Metric sources: ${formulaNonMetricSources.join(', ')}`; + if (formulaIncompatibleSources.length > 0) { + return `Tiles with formulas require a Metric, Log, or Trace source. The following source IDs are not formula-capable: ${formulaIncompatibleSources.join(', ')}`; } if (missingOnClickDashboards.length > 0) { diff --git a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts index f488d82df7..4c10e07b17 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts @@ -7542,6 +7542,152 @@ describe('checkAlerts', () => { expect(slack.postMessageToWebhook).not.toHaveBeenCalled(); }); + // ── Event (log/trace) formula tiles (HDX-5132) ── + // Event formulas compile inline in the single-scan SELECT + // (renderSelectListWithFormulas) rather than the composed metric path. + // The alert path is source-kind agnostic — it passes `formulas` through + // and forces showOperandSeries: false — so the formula value drives the + // threshold here too. + + it('TILE alert (events, formula) - threshold compares the formula value, not an operand', async () => { + const { + team, + webhook, + connection, + source, + teamWebhooksById, + clickhouseClient, + } = await setupSavedSearchAlertTest(); + + // Persistent mock: the beforeEach mockResolvedValueOnce only covers the + // first call, and resolve notifications can trigger a second one. + jest + .spyOn(slack, 'postMessageToWebhook') + .mockResolvedValue({ text: 'ok' }); + + const now = new Date('2023-11-16T22:12:00.000Z'); + // Alert window is [22:05, 22:10) + const eventMs = now.getTime() - ms('7m'); + + // 1 error / 4 total * 100 = 25. Both operands (1 and 4) are far from + // the formula result, so a regression back to evaluating either + // operand fails loudly against the threshold below. + await bulkInsertLogs([ + { + ServiceName: 'api', + Timestamp: new Date(eventMs), + SeverityText: 'error', + Body: 'Oh no! Something went wrong!', + }, + ...Array.from({ length: 3 }, (_, i) => ({ + ServiceName: 'api', + Timestamp: new Date(eventMs), + SeverityText: 'info', + Body: `All good ${i}`, + })), + ]); + + const dashboard = await new Dashboard({ + name: 'My Dashboard', + team: team._id, + tiles: [ + { + id: 'evtformula1', + x: 0, + y: 0, + w: 6, + h: 4, + config: { + name: 'Error rate', + select: [ + { + aggFn: 'count', + aggCondition: 'SeverityText:error', + valueExpression: '', + aggConditionLanguage: 'lucene', + alias: 'Errors', + }, + { + aggFn: 'count', + aggCondition: '', + valueExpression: '', + aggConditionLanguage: 'lucene', + alias: 'Total', + }, + ], + formulas: [{ expression: 'A / B * 100', alias: 'Error rate' }], + showOperandSeries: false, + where: '', + displayType: 'line', + source: source.id, + groupBy: '', + }, + }, + ], + }).save(); + + const tile = dashboard.tiles?.find((t: any) => t.id === 'evtformula1'); + if (!tile) throw new Error('tile not found for event formula test'); + + const details = await createAlertDetails( + team, + source, + { + source: AlertSource.TILE, + channel: { + type: 'webhook', + webhookId: webhook._id.toString(), + }, + interval: '5m', + thresholdType: AlertThresholdType.ABOVE, + threshold: 20, + dashboardId: dashboard.id, + tileId: 'evtformula1', + }, + { + taskType: AlertTaskType.TILE, + tile, + dashboard, + }, + ); + + await processAlertAtTime( + now, + details, + clickhouseClient, + connection.id, + alertProvider, + teamWebhooksById, + ); + + expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT'); + + const [history] = await AlertHistory.find({ + alert: details.alert.id, + }).sort({ createdAt: 1 }); + expect(history.state).toBe('ALERT'); + expect(history.lastValues.length).toBe(1); + // The formula value (25) — not operand A (1) or operand B (4). + expect(history.lastValues[0].count).toBe(25); + + expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1); + expect( + jest.mocked(slack.postMessageToWebhook).mock.calls[0][1].text, + ).toContain('25 meets or exceeds 20'); + + // Next window has no data -> resolves to OK. + const nextWindow = new Date('2023-11-16T22:16:00.000Z'); + await processAlertAtTime( + nextWindow, + details, + clickhouseClient, + connection.id, + alertProvider, + teamWebhooksById, + ); + expect((await Alert.findById(details.alert.id))!.state).toBe('OK'); + }); + it('TILE alert (metrics, grouped ratio) - honors ratioMode share_of_total', async () => { const now = new Date('2023-11-16T22:12:00.000Z'); // Alert window is [22:05, 22:10) diff --git a/packages/app/src/components/ChartEditor/utils.ts b/packages/app/src/components/ChartEditor/utils.ts index 26c948ee5c..1806cdf05e 100644 --- a/packages/app/src/components/ChartEditor/utils.ts +++ b/packages/app/src/components/ChartEditor/utils.ts @@ -3,6 +3,7 @@ import { Path, UseFormSetError } from 'react-hook-form'; import { validateFormula } from '@hyperdx/common-utils/dist/core/formula'; import { isFormulaDisplayType, + isFormulaSourceKind, validateRawSqlForAlert, } from '@hyperdx/common-utils/dist/core/utils'; import { @@ -179,20 +180,7 @@ const isCustomOrderByDisplayType = ( // Re-exported from common-utils (single source of truth shared with the // external API / MCP tile validation) so the "Add Formula" gating in // ChartEditorControls and the server-side surfaces cannot drift. -export { isFormulaDisplayType }; - -/** - * Source kinds that can carry formulas: metric sources (rendered via the - * composed multi-series metric query) and event sources (log/trace, compiled - * inline in the single-scan SELECT — see renderSelectListWithFormulas in - * common-utils). - */ -export const isFormulaSourceKind = ( - kind: SourceKind | undefined, -): kind is SourceKind.Metric | SourceKind.Log | SourceKind.Trace => - kind === SourceKind.Metric || - kind === SourceKind.Log || - kind === SourceKind.Trace; +export { isFormulaDisplayType, isFormulaSourceKind }; export function convertFormStateToSavedChartConfig( form: ChartEditorFormState, diff --git a/packages/common-utils/src/core/utils.ts b/packages/common-utils/src/core/utils.ts index e3fff4033e..880805c562 100644 --- a/packages/common-utils/src/core/utils.ts +++ b/packages/common-utils/src/core/utils.ts @@ -32,6 +32,7 @@ import { RawSqlChartConfig, SavedChartConfig, SortSpecificationList, + SourceKind, SQLInterval, TileTemplateSchema, TSource, @@ -1410,6 +1411,21 @@ export const isFormulaDisplayType = ( displayType === DisplayType.Table || displayType === DisplayType.Number; +/** + * Source kinds that can carry formulas (HDX-5132): metric sources (rendered + * via the composed multi-series metric query) and event sources (log/trace, + * compiled inline in the single-scan SELECT — see + * renderSelectListWithFormulas in renderChartConfig). Shared by the chart + * editor's "Add Formula" gating and the external API / MCP tile validation, + * so the surfaces cannot drift. Session (and other) sources stay gated off. + */ +export const isFormulaSourceKind = ( + kind: SourceKind | undefined, +): kind is SourceKind.Metric | SourceKind.Log | SourceKind.Trace => + kind === SourceKind.Metric || + kind === SourceKind.Log || + kind === SourceKind.Trace; + export function displayTypeSupportsPromQLAlerts( displayType: DisplayType | undefined, ): boolean { From ddfd42d939db9a4f6c6bf36f8373db941d6277ab Mon Sep 17 00:00:00 2001 From: Warren Lee <5959690+wrn14897@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:00:09 -0700 Subject: [PATCH 4/4] chore: simplify comments and drop ticket refs from tests and comments --- .../dashboards/patchDashboard.int.test.ts | 6 +- .../dashboards/queryTile.int.test.ts | 15 +++-- .../api/src/mcp/tools/dashboards/schemas.ts | 2 +- .../__tests__/dashboards.int.test.ts | 16 +++--- .../external-api/v2/utils/dashboards.ts | 56 +++++++++---------- .../__tests__/checkAlerts.int.test.ts | 12 ++-- packages/common-utils/src/core/utils.ts | 20 +++---- .../common-utils/src/dashboardValidation.ts | 14 ++--- 8 files changed, 64 insertions(+), 77 deletions(-) diff --git a/packages/api/src/mcp/__tests__/dashboards/patchDashboard.int.test.ts b/packages/api/src/mcp/__tests__/dashboards/patchDashboard.int.test.ts index 0a64625190..48b22acd7b 100644 --- a/packages/api/src/mcp/__tests__/dashboards/patchDashboard.int.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/patchDashboard.int.test.ts @@ -876,10 +876,10 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { }); // Patches replace a single tile without going through the save_dashboard - // body schemas, so the handler must re-run the external tile schema's + // body schemas, so the handler re-runs the external tile schema's // cross-field refinements itself — otherwise a patch could persist a - // config that an equivalent create or full update rejects (HDX-5081). - describe('metric formula validation (HDX-5081)', () => { + // config that an equivalent create or full update rejects. + describe('metric formula validation', () => { const metricSelect = () => [ { aggFn: 'max', diff --git a/packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts b/packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts index b2f857987c..ea5162d0cd 100644 --- a/packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts @@ -122,7 +122,7 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => { expect(result.content).toHaveLength(1); }); - it('should save and query a metric formula tile (HDX-5081)', async () => { + it('should save and query a metric formula tile', async () => { const metricSource = await Source.create({ kind: SourceKind.Metric, team: ctx.team._id, @@ -213,11 +213,10 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => { expect(rows[0]).not.toHaveProperty('Requests'); }); - it('should save and query a log-source formula tile (HDX-5132)', async () => { - // Event (log/trace) formulas compile inline in the single-scan SELECT - // (renderSelectListWithFormulas) rather than the composed metric path — - // prove the whole surface end-to-end: MCP save → internal persistence → - // external round-trip → query_tile computes the derived column. + it('should save and query a log-source formula tile', async () => { + // Event (log/trace) formulas render through a different path than + // metric formulas (inline single-scan SELECT vs the composed query), + // so cover them end-to-end: save → round-trip → query_tile. const logSource = await Source.create({ kind: SourceKind.Log, team: ctx.team._id, @@ -298,7 +297,7 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => { expect(rows[0]).not.toHaveProperty('Total'); }); - it('should reject a formula tile on a formula-incapable source kind (HDX-5081)', async () => { + it('should reject a formula tile on a formula-incapable source kind', async () => { const sessionSource = await Source.create({ kind: SourceKind.Session, team: ctx.team._id, @@ -336,7 +335,7 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => { ); }); - it('should reject a formula referencing an unknown series (HDX-5081)', async () => { + it('should reject a formula referencing an unknown series', async () => { const metricSource = await Source.create({ kind: SourceKind.Metric, team: ctx.team._id, diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index c30b0b587e..eaff0ae129 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -268,7 +268,7 @@ const mcpTileSelectItemSchema = z : data, ); -// ─── Metric formulas (HDX-5081) ────────────────────────────────────────────── +// ─── Chart formulas ────────────────────────────────────────────────────────── const mcpFormulaSchema = z.object({ expression: z diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts index 3dfd2c0eb6..35e5c8a662 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts @@ -6056,7 +6056,7 @@ describe('External API v2 Dashboards - new format', () => { }); }); - describe('Metric formulas (HDX-5081)', () => { + describe('Chart formulas', () => { // Two metric operand series (A, B) — the shape the formula grammar // references by position. Payloads go through `.send()` (untyped) so // negative tests can post intentionally invalid values. @@ -6232,10 +6232,9 @@ describe('External API v2 Dashboards - new format', () => { ); }); - it('round-trips formulas on a log/trace event source (HDX-5132)', async () => { - // Event sources compile formulas inline in the single-scan SELECT - // (renderSelectListWithFormulas) — the API accepts and round-trips - // them just like metric formulas. + it('round-trips formulas on a log/trace event source', async () => { + // Event-source formulas are accepted and round-trip just like + // metric formulas. const formulas = [{ expression: 'A / B * 100', alias: 'Error rate %' }]; const create = await postTile({ sourceId: traceSource._id.toString(), @@ -6285,10 +6284,9 @@ describe('External API v2 Dashboards - new format', () => { }); it('does not block updates over an unchanged formula tile whose source kind changed', async () => { - // Accept a formula tile while the source is a metric source, then - // flip the source's kind to a formula-incapable one out from under it - // (a full-replace source update can do this). Mirroring the heatmap - // gate, an update that leaves the formula tile untouched must not be + // Accept a formula tile on a metric source, then flip the source's + // kind to a formula-incapable one out from under it. Mirroring the + // heatmap gate, an update that leaves the tile untouched must not be // rejected — only changes to the tile's formulas/source re-enter the // gate. const created = await postTile({ diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index 5d6b46c08d..63b149e18d 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -300,10 +300,10 @@ const convertToExternalTileChartConfig = ( return typeof value === 'string' ? value : defaultValue; }; - // Round-trip metric formulas (HDX-5081): emitted only when present so - // formula-less tiles keep their pre-formula response shape. Number tiles - // never emit `showOperandSeries` — operands are always hidden there, and - // the internal converter re-persists the explicit `false` on the way in. + // Formulas are emitted only when present so formula-less tiles keep + // their pre-formula response shape. Number tiles never emit + // `showOperandSeries` — operands are always hidden there, and the + // internal converter re-persists the explicit `false` on the way in. const externalFormulaFields = config.formulas?.length ? { formulas: config.formulas } : {}; @@ -747,8 +747,8 @@ export function convertToInternalTileConfig( 'alignDateRangeToGranularity', 'compareToPreviousPeriod', 'fitYAxisToData', - // Metric formulas (HDX-5081) round-trip as-is; the input schema - // has already parsed/validated the expressions against `select`. + // Formulas round-trip as-is; the input schema has already + // validated the expressions against `select`. 'formulas', 'showOperandSeries', ]), @@ -774,8 +774,8 @@ export function convertToInternalTileConfig( 'orderBy', 'groupByColumnsOnLeft', 'onClick', - // Metric formulas (HDX-5081) round-trip as-is; the input schema - // has already parsed/validated the expressions against `select`. + // Formulas round-trip as-is; the input schema has already + // validated the expressions against `select`. 'formulas', 'showOperandSeries', ]), @@ -1067,12 +1067,11 @@ function getHeatmapTilesWithIncompatibleSources( /** * Returns source IDs referenced by formula tiles that exist but are not - * formula-capable. Formulas render on metric sources (via the composed - * multi-series metric query, HDX-5081) and log/trace event sources (inline - * in the single-scan SELECT, HDX-5132); other kinds (e.g. session) are - * deliberately gated off, mirroring the editor's "Add Formula" gating and - * save-time normalization via the shared `isFormulaSourceKind` predicate, - * so the API cannot persist a config the editor refuses. + * formula-capable. Formulas render on metric and log/trace sources; other + * kinds (e.g. session) are deliberately gated off via the shared + * `isFormulaSourceKind` predicate — the same gate as the editor's + * "Add Formula" button — so the API cannot persist a config the editor + * refuses. */ function getFormulaTilesWithIncompatibleSources( sources: SourceForValidation[], @@ -1102,14 +1101,13 @@ function getFormulaTilesWithIncompatibleSources( /** * For a PUT (update) request, return only the formula tiles that need to - * be re-validated against the metric-source gate. Mirrors + * be re-validated against the source-kind gate. Mirrors * `filterChangedHeatmapTiles` below: a tile that already carried the same - * formulas on the same source in the existing dashboard is kept as - * "unchanged" so the user can edit other parts of the dashboard without - * being blocked when the underlying source's `kind` was changed after the - * formulas were originally accepted. New formula tiles, tiles that newly - * gained or edited formulas, and tiles whose `sourceId` changed all flow - * through the check. + * formulas on the same source is kept as "unchanged" so the user can edit + * other parts of the dashboard without being blocked when the source's + * `kind` was changed after the formulas were originally accepted. New + * formula tiles, tiles with new or edited formulas, and tiles whose + * `sourceId` changed all flow through the check. */ function filterChangedFormulaTiles( requestTiles: ExternalDashboardTileWithId[], @@ -1139,9 +1137,8 @@ function filterChangedFormulaTiles( return true; } if (!_.isEqual(existingConfig.formulas, tile.config.formulas)) { - // Formulas newly added or edited: the user is actively touching the - // gated feature, so surface the source-kind error rather than - // persisting another silently-inert formula. + // Formulas newly added or edited: the user is actively touching + // the gated feature, so surface the source-kind error. return true; } // Existing tile already carried these exact formulas. Re-check only @@ -1462,13 +1459,10 @@ export async function validateDashboardTiles( return `Heatmap tiles require a Trace source. The following source IDs are not Trace sources: ${heatmapNonTraceSources.join(', ')}`; } - // Formula source-kind gate: formulas are supported on metric and - // log/trace sources (see isFormulaSourceKind); other kinds are rejected - // rather than persisting a config the editor refuses. On create (no - // existingTiles), validate all tiles. On update, scope to tiles whose - // formulas/sourceId changed — mirroring the heatmap gate — so a source - // whose kind changed after acceptance doesn't block unrelated dashboard - // edits. + // Formula source-kind gate. On create (no existingTiles), validate all + // tiles. On update, scope to tiles whose formulas/sourceId changed — + // mirroring the heatmap gate — so a source whose kind changed after + // acceptance doesn't block unrelated dashboard edits. const formulaTilesToCheck = existingTiles ? filterChangedFormulaTiles(tiles, existingTiles) : tiles; diff --git a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts index 4c10e07b17..8fb7da654e 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts @@ -7542,12 +7542,12 @@ describe('checkAlerts', () => { expect(slack.postMessageToWebhook).not.toHaveBeenCalled(); }); - // ── Event (log/trace) formula tiles (HDX-5132) ── - // Event formulas compile inline in the single-scan SELECT - // (renderSelectListWithFormulas) rather than the composed metric path. - // The alert path is source-kind agnostic — it passes `formulas` through - // and forces showOperandSeries: false — so the formula value drives the - // threshold here too. + // ── Event (log/trace) formula tiles ── + // Event formulas render through a different path than metric formulas + // (inline single-scan SELECT vs the composed query). The alert path is + // source-kind agnostic — it passes `formulas` through and forces + // showOperandSeries: false — so the formula value drives the threshold + // here too. it('TILE alert (events, formula) - threshold compares the formula value, not an operand', async () => { const { diff --git a/packages/common-utils/src/core/utils.ts b/packages/common-utils/src/core/utils.ts index 880805c562..b117dd2661 100644 --- a/packages/common-utils/src/core/utils.ts +++ b/packages/common-utils/src/core/utils.ts @@ -1393,11 +1393,10 @@ export function displayTypeSupportsBuilderAlerts( } /** - * Display types that can carry formulas (HDX-5080) — the shapes the formula - * query paths render (composed multi-series for metrics, inline single-scan - * for events). Shared by the chart editor's "Add Formula" gating - * (`packages/app/src/components/ChartEditor/utils.ts`) and the external API / - * MCP tile validation, so the surfaces cannot drift. + * Display types that can carry formulas — the shapes the formula query + * paths render (composed multi-series for metrics, inline single-scan for + * events). Shared by the chart editor's "Add Formula" gating and the + * external API / MCP tile validation, so the surfaces cannot drift. */ export const isFormulaDisplayType = ( displayType: DisplayType | undefined, @@ -1412,12 +1411,11 @@ export const isFormulaDisplayType = ( displayType === DisplayType.Number; /** - * Source kinds that can carry formulas (HDX-5132): metric sources (rendered - * via the composed multi-series metric query) and event sources (log/trace, - * compiled inline in the single-scan SELECT — see - * renderSelectListWithFormulas in renderChartConfig). Shared by the chart - * editor's "Add Formula" gating and the external API / MCP tile validation, - * so the surfaces cannot drift. Session (and other) sources stay gated off. + * Source kinds that can carry formulas: metric sources (rendered via the + * composed multi-series metric query) and log/trace event sources (compiled + * inline in the single-scan SELECT). Shared by the chart editor's + * "Add Formula" gating and the external API / MCP tile validation, so the + * surfaces cannot drift. Session (and other) sources stay gated off. */ export const isFormulaSourceKind = ( kind: SourceKind | undefined, diff --git a/packages/common-utils/src/dashboardValidation.ts b/packages/common-utils/src/dashboardValidation.ts index ccd65e6e58..4ba26dcf2b 100644 --- a/packages/common-utils/src/dashboardValidation.ts +++ b/packages/common-utils/src/dashboardValidation.ts @@ -283,23 +283,21 @@ type ChartConfigForFormulaValidation = { }; /** - * Structural validation for a chart config's metric formulas (HDX-5081), - * mirroring the chart editor's save-time rules (`validateChartForm` in + * Structural validation for a chart config's formulas, mirroring the chart + * editor's save-time rules (`validateChartForm` in * `packages/app/src/components/ChartEditor/utils.ts`) so the API cannot * persist a config the editor refuses: * * - Every formula expression must parse and only reference existing series * (`A` = select[0], ...), via the same `validateFormula` the editor and * the query renderer use. - * - Formulas are mutually exclusive with the ratio toggle (`asRatio`); the - * renderer resolves the combination deterministically (formulas win) but - * persisting both would be a lie in the saved config. + * - Formulas are mutually exclusive with the ratio toggle (`asRatio`). * - Number charts display a single value, so they support at most one * formula. * - * The metric-source-only gate deliberately lives with the callers that can - * see the tile's source (`validateDashboardTiles` in the external API) — - * this helper only sees the config. + * The source-kind gate deliberately lives with the callers that can see the + * tile's source (`validateDashboardTiles` in the external API) — this + * helper only sees the config. * * Issues raised: * - Invalid expression (path `.formulas[i].expression`).