Skip to content

Commit 0453aa1

Browse files
fix(api): resolve a vanished alert group with its values (#3507)
## Summary Closes #3470 Stores a grouped alert's group-by values on its history row and reads them back at resolve time, so `{{attributes.*}}` and `{{var.*}}` are filled in when a group resolves after it has disappeared from the data. Until now they rendered empty on that resolve: the group had no rows left to read values from, and the joined `group` string on the history row cannot be split back into values. - `groupValues` is a list of `{ key, value }` pairs rather than an object, since group-by column names can contain dots and Mongo would read them as nested paths. - Live values still win. The history fallback applies only when the group has no current rows. - Empty values are stored too. History rows written before this change have no `groupValues`; they resolve as they do today, and the next evaluation of a live group stores the values. - The docs no longer say `{{var}}` is empty on the resolve of a vanished group. ### How to reproduce **Starting point:** `yarn dev`, a webhook destination whose body template uses `{{var.service}}`, and a group-by alert on `service`. **On `main`:** 1. Let the alert fire for one `service` value, then stop sending data for that value and wait out the alert window. 2. The next evaluation resolves the alert, and the webhook body has an empty `{{var.service}}`. **On this branch:** 1. Repeat the steps above. 2. The resolve body carries the group's `service` value. `make dev-int FILE=checkAlerts` runs the new cases: plain and dotted aliases, a generic webhook body, an empty value, and a legacy row. ### How to test on Vercel preview N/A, API-only change ### References - Linear Issue: - Related PRs:
1 parent c792850 commit 0453aa1

5 files changed

Lines changed: 270 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/api': patch
3+
---
4+
5+
fix: A grouped alert whose group vanishes from the data now resolves with the group's values in `{{attributes.*}}` (alert title and message) and `{{var.*}}` (webhook body)

‎docs/alert-webhook-template-variables.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ a HyperDX alert without parsing the human-readable message body.
2121
| `{{thresholdMax}}` | number | Upper bound of a `between`/`outside` condition. Always unset for every other comparator, even if the alert was once a range, so guard it (see below). |
2222
| `{{value}}` | number | The value that triggered or resolved the alert. |
2323
| `{{groupKey}}` | string | The breaching group, for a grouped alert. |
24-
| `{{var}}` | object | The breaching group's values by group-by column or SQL alias. Use one value at a time, e.g. `{{var.customer_id}}`, or `{{var.[service.name]}}` for a name with dots. Empty when not grouped, and on the resolve of a group that has no rows left. |
24+
| `{{var}}` | object | The breaching group's values by group-by column or SQL alias. Use one value at a time, e.g. `{{var.customer_id}}`, or `{{var.[service.name]}}` for a name with dots. Empty when not grouped. |
2525
| `{{sourceQuery}}` | string | The condition behind the alert — search expression or SQL (see below). |
2626
| `{{teamId}}` | string | Team the alert belongs to. |
2727
| `{{note}}` | string | The alert's freeform note — commonly a runbook link. |

‎packages/api/src/models/alertHistory.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,25 @@ export interface IAlertHistoryAnalytics {
3232
notificationTargets?: AlertNotificationTargetTiming[];
3333
}
3434

35+
/** One group-by column and its value, stored as a pair. */
36+
export type GroupValue = { key: string; value: string };
37+
38+
export const attributesToGroupValues = (
39+
attributes: Record<string, string>,
40+
): GroupValue[] | undefined => {
41+
const entries = Object.entries(attributes);
42+
return entries.length > 0
43+
? entries.map(([key, value]) => ({ key, value }))
44+
: undefined;
45+
};
46+
47+
export const groupValuesToAttributes = (
48+
groupValues: GroupValue[] | null | undefined,
49+
): Record<string, string> | undefined =>
50+
groupValues?.length
51+
? Object.fromEntries(groupValues.map(v => [v.key, v.value]))
52+
: undefined;
53+
3554
export interface IAlertHistory {
3655
alert: ObjectId;
3756
counts: number;
@@ -40,6 +59,13 @@ export interface IAlertHistory {
4059
lastValues: { startTime: Date; count: number }[];
4160
group?: string; // For group-by alerts, stores the group identifier
4261
fired?: boolean;
62+
/**
63+
* Group-by values of a group-by alert's group. Kept so a group that later
64+
* vanishes from the data still resolves with its values, which the joined
65+
* `group` string cannot be parsed back into. Stored as pairs because column
66+
* names may contain dots, which Mongo would read as nested paths in a key.
67+
*/
68+
groupValues?: GroupValue[];
4369
/**
4470
* Errors recorded for this evaluation window. Present on ERROR-state rows
4571
* (query/processing failures where no normal history is written) and on
@@ -85,6 +111,17 @@ const AlertHistorySchema = new Schema<IAlertHistory>({
85111
type: Boolean,
86112
required: false,
87113
},
114+
groupValues: {
115+
type: [
116+
{
117+
_id: false,
118+
key: { type: String, required: true },
119+
value: { type: String },
120+
},
121+
],
122+
required: false,
123+
default: undefined,
124+
},
88125
errors: {
89126
type: [
90127
{

‎packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts‎

Lines changed: 203 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2036,7 +2036,7 @@ describe('checkAlerts', () => {
20362036
const setupSavedSearchAlertTest = async ({
20372037
webhookSettings,
20382038
}: Partial<{
2039-
webhookSettings: IWebhook;
2039+
webhookSettings: Partial<IWebhook>;
20402040
}> = {}) => {
20412041
const team = await createTeam({ name: 'My Team' });
20422042

@@ -5807,6 +5807,208 @@ describe('checkAlerts', () => {
58075807
expect(renderedBody).toContain('Group: "ServiceName:web"');
58085808
});
58095809

5810+
// Fires a grouped raw SQL alert for one service, then evaluates again once
5811+
// the only event has left the window so the group has no rows.
5812+
const fireThenVanish = async ({
5813+
alias,
5814+
serviceName = 'web',
5815+
setup,
5816+
name,
5817+
message,
5818+
}: {
5819+
alias: string;
5820+
serviceName?: string;
5821+
setup?: Parameters<typeof setupSavedSearchAlertTest>[0];
5822+
name?: string;
5823+
message?: string;
5824+
}) => {
5825+
const { team, webhook, connection, teamWebhooksById, clickhouseClient } =
5826+
await setupSavedSearchAlertTest(setup);
5827+
5828+
const firedAt = new Date('2023-11-16T22:12:00.000Z');
5829+
const eventMs = firedAt.getTime() - ms('5m');
5830+
5831+
await bulkInsertLogs(
5832+
[1, 2].map(i => ({
5833+
ServiceName: serviceName,
5834+
Timestamp: new Date(eventMs),
5835+
SeverityText: 'error',
5836+
Body: `error ${i}`,
5837+
})),
5838+
);
5839+
5840+
const dashboard = await new Dashboard({
5841+
name: 'Raw SQL Vanished Group Dashboard',
5842+
team: team._id,
5843+
tiles: [
5844+
{
5845+
id: 'rawsql-vanished',
5846+
x: 0,
5847+
y: 0,
5848+
w: 6,
5849+
h: 4,
5850+
config: {
5851+
configType: 'sql',
5852+
displayType: 'line',
5853+
sqlTemplate: `
5854+
SELECT
5855+
toStartOfInterval(Timestamp, INTERVAL {intervalSeconds:Int64} second) AS ts,
5856+
ServiceName AS "${alias}",
5857+
count() AS cnt
5858+
FROM default.otel_logs
5859+
WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64})
5860+
AND Timestamp < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})
5861+
GROUP BY ts, "${alias}"
5862+
ORDER BY ts`,
5863+
connection: connection.id,
5864+
},
5865+
},
5866+
],
5867+
}).save();
5868+
5869+
const tile = dashboard.tiles?.find(t => t.id === 'rawsql-vanished');
5870+
if (!tile) throw new Error('tile not found');
5871+
5872+
const details = await createAlertDetails(
5873+
team,
5874+
undefined,
5875+
{
5876+
source: AlertSource.TILE,
5877+
channel: {
5878+
type: 'webhook',
5879+
webhookId: webhook._id.toString(),
5880+
},
5881+
interval: '5m',
5882+
thresholdType: AlertThresholdType.ABOVE,
5883+
threshold: 2,
5884+
dashboardId: dashboard.id,
5885+
tileId: 'rawsql-vanished',
5886+
name,
5887+
message,
5888+
},
5889+
{
5890+
taskType: AlertTaskType.TILE,
5891+
tile,
5892+
dashboard,
5893+
},
5894+
);
5895+
5896+
const evaluateAt = (at: Date) =>
5897+
processAlertAtTime(
5898+
at,
5899+
details,
5900+
clickhouseClient,
5901+
connection,
5902+
alertProvider,
5903+
teamWebhooksById,
5904+
);
5905+
5906+
await evaluateAt(firedAt);
5907+
expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
5908+
5909+
const firedRow = await AlertHistory.findOne({
5910+
alert: details.alert.id,
5911+
group: `${alias}:${serviceName}`,
5912+
}).lean();
5913+
5914+
return {
5915+
details,
5916+
firedRow,
5917+
resolve: async () => {
5918+
await evaluateAt(new Date(firedAt.getTime() + ms('10m')));
5919+
expect((await Alert.findById(details.alert.id))!.state).toBe('OK');
5920+
},
5921+
};
5922+
};
5923+
5924+
// Alert titles and messages read the nested `attributes` view, so a dotted
5925+
// alias is addressed by its path; `var` exists only in webhook bodies.
5926+
it.each([
5927+
['ServiceName', 'attributes.ServiceName'],
5928+
['service.name', 'attributes.service.name'],
5929+
])(
5930+
'TILE alert (raw SQL) - a vanished group keyed by %s persists and resolves with its values',
5931+
async (alias, ref) => {
5932+
const { firedRow, resolve } = await fireThenVanish({
5933+
alias,
5934+
name: `Errors for {{${ref}}}`,
5935+
message: `Affected service: {{${ref}}}`,
5936+
});
5937+
expect(firedRow?.groupValues).toEqual([{ key: alias, value: 'web' }]);
5938+
5939+
await resolve();
5940+
5941+
expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(2);
5942+
const [, resolvePayload] = jest.mocked(slack.postMessageToWebhook).mock
5943+
.calls[1];
5944+
expect(resolvePayload.text).toContain('Errors for web');
5945+
expect(JSON.stringify(resolvePayload.blocks)).toContain(
5946+
'Affected service: web',
5947+
);
5948+
},
5949+
);
5950+
5951+
it('TILE alert (raw SQL) - a vanished group resolves with its values in a generic webhook body', async () => {
5952+
const fetchMock = jest.fn().mockResolvedValue({
5953+
ok: true,
5954+
text: jest.fn().mockResolvedValue(''),
5955+
});
5956+
global.fetch = fetchMock;
5957+
5958+
const { resolve } = await fireThenVanish({
5959+
alias: 'service.name',
5960+
setup: {
5961+
webhookSettings: {
5962+
service: WebhookService.Generic,
5963+
url: 'https://webhook.site/123',
5964+
name: 'Generic Webhook',
5965+
body: JSON.stringify({
5966+
state: '{{state}}',
5967+
service: '{{var.[service.name]}}',
5968+
}),
5969+
},
5970+
},
5971+
});
5972+
await resolve();
5973+
5974+
expect(fetchMock).toHaveBeenCalledTimes(2);
5975+
const resolveBody = JSON.parse(fetchMock.mock.calls[1][1].body);
5976+
expect(resolveBody).toEqual({ state: 'OK', service: 'web' });
5977+
});
5978+
5979+
it('TILE alert (raw SQL) - a vanished group with an empty value persists and resolves', async () => {
5980+
const { firedRow, resolve } = await fireThenVanish({
5981+
alias: 'ServiceName',
5982+
serviceName: '',
5983+
});
5984+
expect(firedRow?.groupValues).toEqual([
5985+
{ key: 'ServiceName', value: '' },
5986+
]);
5987+
5988+
await resolve();
5989+
5990+
expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(2);
5991+
});
5992+
5993+
it('TILE alert (raw SQL) - a previous ALERT row without groupValues still resolves with empty attributes', async () => {
5994+
const { details, resolve } = await fireThenVanish({
5995+
alias: 'ServiceName',
5996+
name: 'Errors for {{attributes.ServiceName}}',
5997+
});
5998+
await AlertHistory.updateMany(
5999+
{ alert: details.alert.id },
6000+
{ $unset: { groupValues: 1 } },
6001+
);
6002+
6003+
await resolve();
6004+
6005+
expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(2);
6006+
const [, resolvePayload] = jest.mocked(slack.postMessageToWebhook).mock
6007+
.calls[1];
6008+
expect(resolvePayload.text).toContain('Errors for');
6009+
expect(resolvePayload.text).not.toContain('web');
6010+
});
6011+
58106012
it('TILE alert (raw SQL) - alert is evaluated using the last numeric column', async () => {
58116013
const { team, webhook, connection, teamWebhooksById, clickhouseClient } =
58126014
await setupSavedSearchAlertTest();

‎packages/api/src/tasks/checkAlerts/index.ts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ import { serializeError } from 'serialize-error';
6060
import { ALERT_HISTORY_QUERY_CONCURRENCY } from '@/controllers/alertHistory';
6161
import { AlertState, IAlert, IAlertError } from '@/models/alert';
6262
import AlertHistory, {
63+
attributesToGroupValues,
64+
GroupValue,
65+
groupValuesToAttributes,
6366
IAlertHistory,
6467
IAlertHistoryAnalytics,
6568
} from '@/models/alertHistory';
@@ -1284,7 +1287,6 @@ export const processAlert = async (
12841287
string,
12851288
{ value: number; attributes: Record<string, string>; startTime: Date }
12861289
>();
1287-
// ponytail: a group that vanished from the data resolves without its values.
12881290
const groupAttributes = new Map<string, Record<string, string>>();
12891291

12901292
// Helper to get or create history for a group
@@ -1471,7 +1473,12 @@ export const processAlert = async (
14711473
group: groupKey,
14721474
totalCount: lastValue?.count || 0,
14731475
startTime: lastValue?.startTime || nowInMinsRoundDown,
1474-
attributes: groupAttributes.get(groupKey),
1476+
// A group with no rows left has no current values; use the ones
1477+
// recorded when it last reported. The aggregation yields null for
1478+
// rows that never stored any, which would skip the `{}` default.
1479+
attributes:
1480+
groupAttributes.get(groupKey) ??
1481+
groupValuesToAttributes(previousHistory?.groupValues),
14751482
});
14761483
}
14771484
};
@@ -1648,7 +1655,6 @@ export const processAlert = async (
16481655

16491656
for (const [groupKey, evaluation] of bucketEvaluations.entries()) {
16501657
const history = getOrCreateHistory(groupKey);
1651-
16521658
if (evaluation.exceeds) {
16531659
history.counts += 1;
16541660
if (shouldFireBasedOnConsecutiveWindows(groupKey)) {
@@ -1712,6 +1718,18 @@ export const processAlert = async (
17121718
getOrCreateHistory('');
17131719
}
17141720

1721+
// A resolve reads values only from a previous ALERT or PENDING row.
1722+
for (const [groupKey, history] of histories.entries()) {
1723+
if (
1724+
history.state === AlertState.ALERT ||
1725+
history.state === AlertState.PENDING
1726+
) {
1727+
history.groupValues = attributesToGroupValues(
1728+
groupAttributes.get(groupKey) ?? {},
1729+
);
1730+
}
1731+
}
1732+
17151733
// Check for state transitions and send notifications
17161734
for (const [groupKey, history] of histories.entries()) {
17171735
const previousKey = computeHistoryMapKey(alert.id, groupKey);
@@ -1819,6 +1837,7 @@ export interface AggregatedAlertHistory {
18191837
state: AlertState;
18201838
group?: string;
18211839
fired?: boolean;
1840+
groupValues?: GroupValue[] | null;
18221841
}
18231842

18241843
/**
@@ -1880,6 +1899,7 @@ export const getPreviousAlertHistories = async (
18801899
createdAt: { $first: '$createdAt' },
18811900
state: { $first: '$state' },
18821901
fired: { $first: '$fired' },
1902+
groupValues: { $first: '$groupValues' },
18831903
},
18841904
},
18851905
{
@@ -1889,6 +1909,7 @@ export const getPreviousAlertHistories = async (
18891909
state: 1,
18901910
group: '$_id.group',
18911911
fired: 1,
1912+
groupValues: 1,
18921913
},
18931914
},
18941915
]);

0 commit comments

Comments
 (0)