Repository navigation
feat(alerts): hand an alert's investigation to a Claude agent #3122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
58a13a5
5bbd438
0b8689d
74b9d4d
913faa6
d2fb340
c391c6e
9d64cd4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| --- | ||
| '@hyperdx/common-utils': minor | ||
| '@hyperdx/api': minor | ||
| --- | ||
|
|
||
| feat: hand an alert's investigation to a Claude agent | ||
|
|
||
| An alert's notification channel can now be an agent instead of a webhook. When the alert fires, HyperDX starts an Anthropic session with a structured payload — status, comparator, threshold and its upper bound, current value, group key, source query, time range, and the alert's note as a runbook — and the agent investigates from there through the ClickStack MCP server. The result lives in that session; nothing is delivered from here, so the agent's own configuration decides where findings go. | ||
|
|
||
| Channels are additive, so a webhook still pages you immediately while the agent works the same incident. Sessions are deduped per alert, per agent, within a cooldown window, and the key is claimed before the Anthropic calls — so a grouped alert breaching on a thousand group keys starts one investigation rather than a thousand, and two concurrent firings cannot each pay for a session. Only the firing edge dispatches: the prompt says an alert fired, so investigating a resolution would be wrong as well as wasteful. A dispatch that fails is recorded against the alert as a distinct error naming the agent, and cannot consume another channel's notification slot. | ||
|
|
||
| Existing alerts are unaffected — the channel shape gates only what can be written, and nothing that used to validate stops validating. Agent channels require `HDX_MANAGED_AGENTS_ENABLED`, enforced once in shared validation so the internal API, the external API v2 and the MCP `saveAlert` tool cannot drift on what they accept. | ||
|
|
||
| Also fixes `{{note}}` in webhook templates, which always rendered empty because nothing ever set it. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ import { groupBy } from 'lodash'; | |
| import { Types } from 'mongoose'; | ||
| import { z } from 'zod'; | ||
|
|
||
| import { IS_MANAGED_AGENTS_ENABLED } from '@/config'; | ||
| import type { ObjectId } from '@/models'; | ||
| import Alert, { | ||
| AlertChannel, | ||
|
|
@@ -19,6 +20,7 @@ import Alert, { | |
| } from '@/models/alert'; | ||
| import Connection from '@/models/connection'; | ||
| import Dashboard, { IDashboard } from '@/models/dashboard'; | ||
| import ManagedAgent from '@/models/managedAgent'; | ||
| import { ISavedSearch, SavedSearch } from '@/models/savedSearch'; | ||
| import { Source } from '@/models/source'; | ||
| import { IUser } from '@/models/user'; | ||
|
|
@@ -217,19 +219,46 @@ export const validateAlertInput = async ( | |
| throw new Api400Error('At least one notification channel is required'); | ||
| } | ||
|
|
||
| const webhookIds = channels | ||
| .filter(c => c.type === 'webhook') | ||
| .map(c => c.webhookId); | ||
| for (const webhookId of webhookIds) { | ||
| validateObjectId(webhookId, 'Invalid webhook ID'); | ||
| } | ||
| const uniqueIds = [...new Set(webhookIds)]; | ||
| const found = await Webhook.countDocuments({ | ||
| _id: { $in: uniqueIds }, | ||
| team: teamId, | ||
| }); | ||
| if (found !== uniqueIds.length) { | ||
| throw new Api400Error('Webhook not found'); | ||
| // Each channel kind names a row in a different collection; the check is the | ||
| // same either way — the ids parse, and every distinct one belongs to this | ||
| // team. Kept as one pass so a third channel kind cannot arrive with a | ||
| // subtly different rule. | ||
| const assertChannelRefsExist = async ( | ||
| ids: string[], | ||
| model: { countDocuments: (f: object) => PromiseLike<number> }, | ||
| label: string, | ||
| ) => { | ||
| for (const id of ids) { | ||
| validateObjectId(id, `Invalid ${label} ID`); | ||
| } | ||
| const unique = [...new Set(ids)]; | ||
| const found = await model.countDocuments({ | ||
| _id: { $in: unique }, | ||
| team: teamId, | ||
| }); | ||
| if (found !== unique.length) { | ||
| throw new Api400Error( | ||
| `${label[0].toUpperCase()}${label.slice(1)} not found`, | ||
| ); | ||
| } | ||
| }; | ||
|
|
||
| await assertChannelRefsExist( | ||
| channels.filter(c => c.type === 'webhook').map(c => c.webhookId), | ||
| Webhook, | ||
| 'webhook', | ||
| ); | ||
|
|
||
| const agentIds = channels.filter(c => c.type === 'agent').map(c => c.agentId); | ||
| if (agentIds.length > 0) { | ||
| // The alert editor is flag-gated too; this rejects an agent channel saved | ||
| // via the API on a deployment where dispatch would silently do nothing. | ||
| if (!IS_MANAGED_AGENTS_ENABLED) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Turning HDX_MANAGED_AGENTS_ENABLED off makes existing agent-channel alerts unsavable, not just uncreatable
|
||
| throw new Api400Error( | ||
| 'AI agent notification channels are not enabled on this deployment (set HDX_MANAGED_AGENTS_ENABLED)', | ||
| ); | ||
| } | ||
| await assertChannelRefsExist(agentIds, ManagedAgent, 'agent'); | ||
| } | ||
|
|
||
| return refs; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,7 +15,7 @@ import { | |
| mcpUserError, | ||
| validateObjectId, | ||
| } from '@/mcp/utils/errors'; | ||
| import { AlertSource } from '@/models/alert'; | ||
| import { AlertChannel, AlertSource } from '@/models/alert'; | ||
| import { | ||
| convertExternalAlertChartConfigToInternal, | ||
| translateAlertDocumentToExternalAlertWithChartConfig, | ||
|
|
@@ -48,11 +48,13 @@ export function registerSaveAlert({ | |
| 'Alerts monitor a saved search, a dashboard tile, or an inline chart ' + | ||
| 'config (source "inline" + chartConfig — no saved search or dashboard ' + | ||
| 'needed) and fire when the metric crosses a threshold. At least one ' + | ||
| 'webhook notification channel ' + | ||
| 'is required: pass "channels" for 1-10 targets, or the legacy singular ' + | ||
| '"channel" for one. Updates replace the alert configuration rather than ' + | ||
| 'merging it, so read the alert first and resend its full "channels" ' + | ||
| 'array to avoid dropping channels you did not mean to remove.', | ||
| 'notification channel is required — a webhook ' + | ||
| '({type:"webhook", webhookId}) or, on deployments with managed agents ' + | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — MCP tool advertises agent channels with no way to discover an agentId The tool description now tells the model it can pass |
||
| 'enabled, an AI agent investigation ({type:"agent", agentId}). Pass ' + | ||
| '"channels" for 1-10 targets, or the legacy singular "channel" for ' + | ||
| 'one. Updates replace the alert configuration rather than merging it, ' + | ||
| 'so read the alert first and resend its full "channels" array to ' + | ||
| 'avoid dropping channels you did not mean to remove.', | ||
| inputSchema: mcpSaveAlertSchema, | ||
| }, | ||
| async input => { | ||
|
|
@@ -95,10 +97,24 @@ export function registerSaveAlert({ | |
|
|
||
| // Build the alert input matching the shape expected by controllers. | ||
| const source = MCP_SOURCE_TO_ALERT_SOURCE[input.source]; | ||
| // The MCP schema keeps webhookId/agentId both optional on one object | ||
| // shape (the per-type pairing is enforced in validateSaveAlertInput), so | ||
| // narrow into the discriminated AlertChannel here. | ||
| const toAlertChannel = (c: { | ||
| type: 'webhook' | 'agent'; | ||
| webhookId?: string; | ||
| agentId?: string; | ||
| }): AlertChannel => | ||
| c.type === 'agent' | ||
| ? { type: 'agent', agentId: c.agentId ?? '' } | ||
| : { type: 'webhook', webhookId: c.webhookId ?? '' }; | ||
|
|
||
| const alertInput: AlertInput = { | ||
| source, | ||
| // `channel` is omitted; makeAlert mirrors it from channels[0]. | ||
| channels: input.channels ?? (input.channel ? [input.channel] : []), | ||
| channels: ( | ||
| input.channels ?? (input.channel ? [input.channel] : []) | ||
| ).map(toAlertChannel), | ||
| interval: input.interval, | ||
| threshold: input.threshold, | ||
| thresholdType: input.thresholdType as AlertThresholdType, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -106,11 +106,18 @@ const mcpAlertChartConfigSchema = z | |
| const mcpAlertChannelSchema = z | ||
| .object({ | ||
| type: z | ||
| .literal('webhook') | ||
| .enum(['webhook', 'agent']) | ||
| .describe('Channel type for alert notifications.'), | ||
| webhookId: z | ||
| .string() | ||
| .optional() | ||
| .describe('Webhook destination ID (required for webhook channel).'), | ||
| agentId: z | ||
| .string() | ||
| .optional() | ||
| .describe( | ||
| 'Managed agent ID (required for agent channel; a firing alert starts an AI investigation on this agent).', | ||
| ), | ||
| }) | ||
| .describe('Alert notification channel configuration.'); | ||
|
|
||
|
|
@@ -245,6 +252,18 @@ export function validateSaveAlertInput(data: McpSaveAlertInput): string | null { | |
| } | ||
| } | ||
|
|
||
| // Per-type required channel id (the Zod schema keeps both ids optional so | ||
| // one object shape serves both types; enforce the pairing here). | ||
| for (const channel of [data.channel, ...(data.channels ?? [])]) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — MCP channel schema accepts a channel carrying both ids and silently drops the one that does not match the type The flat MCP channel object keeps |
||
| if (!channel) continue; | ||
| if (channel.type === 'webhook' && !channel.webhookId) { | ||
| return 'webhookId is required for a webhook channel'; | ||
| } | ||
| if (channel.type === 'agent' && !channel.agentId) { | ||
| return 'agentId is required for an agent channel'; | ||
| } | ||
| } | ||
|
|
||
| // Source-specific required fields | ||
| if (data.source === 'tile') { | ||
| if (!data.dashboardId) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The changeset covers
@hyperdx/apiand@hyperdx/common-utils, but the new “AI agent error” label also changes user-facing behavior in the published@hyperdx/apppackage. This violates the repository directive requiring every affected published package to be included in the changeset. Add an@hyperdx/appentry before merging.Context Used: CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!