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
23 changes: 23 additions & 0 deletions .changeset/inline-alerts-external-api-mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@hyperdx/api': minor
---

Support inline chart alerts (source `inline` + `chartConfig`) in the external
API v2 and the MCP `clickstack_save_alert` / `clickstack_get_alert` tools.
Inline alerts can now be created, updated, listed, and deleted through
`/api/v2/alerts` using the same tile-config dialect as v2 dashboards, with the
same validation rules as the internal API: display-type allowlist, metric
formula validation, the formula source-kind gate, raw SQL template validation,
and team-scoped source/connection ownership. Passing a `chartConfig` to a
`tile` or `saved_search` alert is now rejected instead of silently dropped,
and reading an inline alert whose config carries internal-only fields omits
`chartConfig` rather than returning a lossy approximation.

Also fixes three pre-existing issues on the external v2 dashboards path: a
gauge tile saved through `clickstack_save_dashboard` with `isDelta: true` was
persisted as a non-delta (the flag was dropped converting MCP tiles to the
internal shape); a tile config carrying an unrecognized `configType` (e.g.
`promql`) was silently stored as a builder config and skipped the formula and
number-select rules, and is now rejected; and validation errors on alert
bodies no longer collapse to `Invalid input` — the schema reports the failing
branch's own message.
99 changes: 97 additions & 2 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,75 @@
"type": "string",
"enum": [
"saved_search",
"tile"
"tile",
"inline"
],
"description": "Alert source type."
"description": "Alert source type. \"saved_search\" alerts monitor a saved search, \"tile\" alerts monitor a dashboard tile, and \"inline\" alerts carry their own chart configuration (see AlertChartConfig) without requiring a saved search or dashboard.\n"
},
"AlertChartConfigOverlay": {
"type": "object",
"description": "Fields an alert's chart config carries on top of the dashboard tile config dialect. Unlike a tile (whose name lives on the tile, not its config), an inline alert's config is standalone.\n",
"properties": {
"name": {
"type": "string",
"description": "Display name for the alert query. Used in notification titles and as an alert-name fallback when the alert itself has no name.\n",
"example": "Error Rate Query"
},
"where": {
"type": "string",
"maxLength": 10000,
"description": "Chart-level filter applied on top of every select item's own \"where\" (combined via AND). Builder variants only; rejected on Raw SQL variants (filter inside the sqlTemplate instead).\n",
"example": "ServiceName:api"
},
"whereLanguage": {
"$ref": "#/components/schemas/QueryLanguage",
"description": "Language of the chart-level \"where\" filter."
}
}
},
"AlertLineChartConfig": {
"allOf": [
{
"$ref": "#/components/schemas/LineChartConfig"
},
{
"$ref": "#/components/schemas/AlertChartConfigOverlay"
}
]
},
"AlertBarChartConfig": {
"allOf": [
{
"$ref": "#/components/schemas/BarChartConfig"
},
{
"$ref": "#/components/schemas/AlertChartConfigOverlay"
}
]
},
"AlertNumberChartConfig": {
"allOf": [
{
"$ref": "#/components/schemas/NumberChartConfig"
},
{
"$ref": "#/components/schemas/AlertChartConfigOverlay"
}
]
},
"AlertChartConfig": {
"description": "The chart configuration an inline alert evaluates, in the same dialect as dashboard tile configs plus the alert-only fields in AlertChartConfigOverlay. Only the display types the alert evaluator supports are accepted: line, stacked_bar, and number, in both builder and Raw SQL (configType \"sql\") variants. Raw SQL templates must reference the evaluation window via the time-filter and interval macros (e.g. $__timeFilter and $__timeInterval), and the referenced source/connection must belong to the team.\n",
"oneOf": [
{
"$ref": "#/components/schemas/AlertLineChartConfig"
},
{
"$ref": "#/components/schemas/AlertBarChartConfig"
},
{
"$ref": "#/components/schemas/AlertNumberChartConfig"
}
]
},
"AlertState": {
"type": "string",
Expand Down Expand Up @@ -240,6 +306,10 @@
"nullable": true,
"example": "ServiceName"
},
"chartConfig": {
"$ref": "#/components/schemas/AlertChartConfig",
"description": "Chart configuration for inline alerts. Required when source is \"inline\" and rejected otherwise. Returned on single-alert responses (GET by ID, POST, PUT); the list endpoint omits it.\n"
},
"threshold": {
"type": "number",
"description": "Threshold value for triggering the alert. For between and not_between threshold types, this is the lower bound.",
Expand Down Expand Up @@ -5523,6 +5593,31 @@
],
"name": "Error Spike Alert"
}
},
"inlineAlert": {
"summary": "Create an inline alert that carries its own chart config",
"value": {
"source": "inline",
"chartConfig": {
"displayType": "line",
"sourceId": "65f5e4a3b9e77c001a123456",
"select": [
{
"aggFn": "count",
"where": "level:error",
"whereLanguage": "lucene"
}
]
},
"threshold": 100,
"interval": "5m",
"thresholdType": "above",
"channel": {
"type": "webhook",
"webhookId": "65f5e4a3b9e77c001a789012"
},
"name": "Error log spike"
}
}
}
}
Expand Down
14 changes: 14 additions & 0 deletions packages/api/src/controllers/alerts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
displayTypeSupportsBuilderAlerts,
displayTypeSupportsRawSqlAlerts,
isFormulaSourceKind,
validateRawSqlForAlert,
} from '@hyperdx/common-utils/dist/core/utils';
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
Expand Down Expand Up @@ -189,6 +190,19 @@ export const validateAlertInput = async (
if (source == null) {
throw new Api400Error('Source not found');
}

// Same formula source-kind gate as dashboard tiles ("Add Formula" is
// disabled in the editor for these kinds), so the API cannot persist a
// config the editor refuses.
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — New formula source-kind gate has no "unchanged config" grandfathering, unlike the parallel dashboard-tile gate

filterChangedFormulaTiles (packages/api/src/routers/external-api/v2/utils/dashboards.ts:1121) deliberately skips this check for a tile that already carried the same formulas on the same source, "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." validateAlertInput applies the gate unconditionally on every create and update, so an existing inline alert whose source kind later changes becomes permanently un-editable — even to adjust its threshold or channels. Skip the check on update when chartConfig.formulas and chartConfig.source are unchanged from the stored alert.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Formula source-kind gate is unscoped on update, so a later source-kind change permanently blocks editing an existing alert

validateAlertInput runs on both create and update (external PUT /api/v2/alerts/:id, internal PUT /alerts/:id, and clickstack_save_alert with an id). The dashboard equivalent deliberately scopes the same gate to tiles whose formulas/sourceId changed — see filterChangedFormulaTiles and its comment in packages/api/src/routers/external-api/v2/utils/dashboards.ts:1121-1124 ("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"). Here, an inline alert created with formulas on a Metric source whose kind is later changed to Session can no longer be updated at all — not even to change its threshold. Skip the check on update when the incoming chartConfig.formulas and chartConfig.source are unchanged from the persisted alert.

'formulas' in chartConfig &&
(chartConfig.formulas?.length ?? 0) > 0 &&
!isFormulaSourceKind(source.kind)
) {
throw new Api400Error(
'Alerts with formulas require a Metric, Log, or Trace source',
);
}
}
}

Expand Down
Loading
Loading