diff --git a/.changeset/formula-api-surfaces.md b/.changeset/formula-api-surfaces.md new file mode 100644 index 0000000000..5094a9b34a --- /dev/null +++ b/.changeset/formula-api-surfaces.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/common-utils': minor +'@hyperdx/api': minor +'@hyperdx/cli': patch +--- + +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 b2f93f5c52..32e7653e7d 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, 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", + "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, log, and trace 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, log, and trace 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, log, and trace 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, log, and trace 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/patchDashboard.int.test.ts b/packages/api/src/mcp/__tests__/dashboards/patchDashboard.int.test.ts index f8f9d8eeef..48b22acd7b 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 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. + describe('metric formula validation', () => { + 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/__tests__/dashboards/queryTile.int.test.ts b/packages/api/src/mcp/__tests__/dashboards/queryTile.int.test.ts index 6ed26ddb40..ea5162d0cd 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,257 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => { expect(result.content).toHaveLength(1); }); + it('should save and query a metric formula tile', 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 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, + 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', 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 sessions', + config: { + displayType: 'line', + sourceId: sessionSource._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, Log, or Trace source', + ); + }); + + it('should reject a formula referencing an unknown series', 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..9f994c46fb 100644 --- a/packages/api/src/mcp/prompts/dashboards/content.ts +++ b/packages/api/src/mcp/prompts/dashboards/content.ts @@ -1063,6 +1063,42 @@ to plot the first as a ratio of the second. Useful for error rates: ], asRatio: true +== 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" }, + { aggFn: "sum", metricType: "sum", metricName: "requests.fsi", alias: "FSI" } + ], + formulas: [ + { 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, 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"). + - 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/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/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index f3a59f84c5..eaff0ae129 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, ); +// ─── Chart formulas ────────────────────────────────────────────────────────── + +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, 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) + .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..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 @@ -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,290 @@ describe('External API v2 Dashboards - new format', () => { }); }); + 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. + 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('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(), + 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, Log, or Trace source', + ); + }); + + it('does not block updates over an unchanged formula tile whose source kind changed', async () => { + // 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({ + 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.Session } }, + ); + + // 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, Log, or Trace 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..a61485c8fc 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -572,6 +572,37 @@ 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, 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). + * 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 +668,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, + * log, and trace 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 +738,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, + * log, and trace 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 +812,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, + * log, and trace 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 +848,25 @@ 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, log, and trace 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..63b149e18d 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, @@ -299,6 +300,18 @@ const convertToExternalTileChartConfig = ( return typeof value === 'string' ? value : defaultValue; }; + // 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 } + : {}; + 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', + // Formulas round-trip as-is; the input schema has already + // validated the expressions against `select`. + 'formulas', + 'showOperandSeries', ]), displayType: externalConfig.displayType === 'stacked_bar' @@ -745,6 +774,10 @@ export function convertToInternalTileConfig( 'orderBy', 'groupByColumnsOnLeft', 'onClick', + // Formulas round-trip as-is; the input schema has already + // 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,88 @@ function getHeatmapTilesWithIncompatibleSources( }); } +/** + * Returns source IDs referenced by formula tiles that exist but are not + * 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[], + 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 && !isFormulaSourceKind(source.kind); + }); +} + +/** + * For a PUT (update) request, return only the formula tiles that need to + * be re-validated against the source-kind gate. Mirrors + * `filterChangedHeatmapTiles` below: a tile that already carried the same + * 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[], + 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. + 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 @@ -1331,6 +1459,21 @@ 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. 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 formulaIncompatibleSources = getFormulaTilesWithIncompatibleSources( + sources, + formulaTilesToCheck, + ); + 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) { return `Could not find the following onClick dashboard IDs: ${missingOnClickDashboards.join(', ')}`; } 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..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,6 +7542,152 @@ describe('checkAlerts', () => { expect(slack.postMessageToWebhook).not.toHaveBeenCalled(); }); + // ── 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 { + 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/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..1806cdf05e 100644 --- a/packages/app/src/components/ChartEditor/utils.ts +++ b/packages/app/src/components/ChartEditor/utils.ts @@ -1,7 +1,11 @@ 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, + isFormulaSourceKind, + validateRawSqlForAlert, +} from '@hyperdx/common-utils/dist/core/utils'; import { isBuilderSavedChartConfig, isPromqlSavedChartConfig, @@ -173,35 +177,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; - -/** - * 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; +// 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, isFormulaSourceKind }; export function convertFormStateToSavedChartConfig( form: ChartEditorFormState, 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..b117dd2661 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, @@ -1391,6 +1392,38 @@ export function displayTypeSupportsBuilderAlerts( ); } +/** + * 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, +): displayType is + | DisplayType.Line + | DisplayType.StackedBar + | DisplayType.Table + | DisplayType.Number => + displayType === DisplayType.Line || + displayType === DisplayType.StackedBar || + displayType === DisplayType.Table || + displayType === DisplayType.Number; + +/** + * 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, +): kind is SourceKind.Metric | SourceKind.Log | SourceKind.Trace => + kind === SourceKind.Metric || + kind === SourceKind.Log || + kind === SourceKind.Trace; + 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..4ba26dcf2b 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,75 @@ 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 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`). + * - Number charts display a single value, so they support at most one + * formula. + * + * 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`). + * - `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'], + }); + } +}