Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/formula-api-surfaces.md
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 73 additions & 2 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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
}
}
},
Expand Down Expand Up @@ -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
}
}
},
Expand Down Expand Up @@ -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
}
}
},
Expand Down Expand Up @@ -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."
Expand Down
179 changes: 179 additions & 0 deletions packages/api/src/mcp/__tests__/dashboards/patchDashboard.int.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
});
});
Loading
Loading