Skip to content

Commit 0558f77

Browse files
feat: attribute alert notification time to each target (#3003)
1 parent 057a684 commit 0558f77

9 files changed

Lines changed: 392 additions & 7 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@hyperdx/api': minor
3+
'@hyperdx/app': minor
4+
'@hyperdx/common-utils': minor
5+
---
6+
7+
Record and show which notification target an evaluation's delivery time went to. `webhookDurationMs` was a single number covering the whole delivery, and because targets are dispatched concurrently the slowest one sets it — so a multi-target alert reported a figure with no way to tell which webhook was responsible, or that the other targets were fine.
8+
9+
Each dispatch is now timed individually and aggregated per target across the evaluation, since a grouped alert notifies the same target once per firing group and again on resolve. One entry per distinct target carries its webhook id, display name, summed duration, how many dispatches it took, and how many failed. The evaluation history's "Notification duration" cell expands in place to show the breakdown.
10+
11+
Stored per evaluation rather than per dispatch: a 50-group alert notifying 10 targets would otherwise write 500 entries onto every history row. The array is capped at `ALERT_NOTIFICATION_TARGETS_LIMIT` and sorted slowest-first, so the cap drops the least interesting rows. Records written before this change keep rendering their total with nothing to expand.

packages/api/src/models/alertHistory.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { AlertErrorType } from '@hyperdx/common-utils/dist/types';
1+
import {
2+
AlertErrorType,
3+
AlertNotificationTargetTiming,
4+
} from '@hyperdx/common-utils/dist/types';
25
import mongoose, { Schema } from 'mongoose';
36
import ms from 'ms';
47

@@ -28,6 +31,12 @@ export interface IAlertHistoryAnalytics {
2831
* (expected buckets − 1). 0 in steady state.
2932
*/
3033
backfilledBuckets?: number;
34+
/**
35+
* Per-target breakdown of `webhookDurationMs`, one entry per distinct
36+
* target, slowest first. Targets dispatch concurrently, so these do not sum
37+
* to `webhookDurationMs`. Absent when the evaluation sent nothing.
38+
*/
39+
notificationTargets?: AlertNotificationTargetTiming[];
3140
}
3241

3342
export interface IAlertHistory {
@@ -105,6 +114,20 @@ const AlertHistorySchema = new Schema<IAlertHistory>({
105114
queryDurationMs: { type: Number, required: false },
106115
webhookDurationMs: { type: Number, required: false },
107116
backfilledBuckets: { type: Number, required: false },
117+
notificationTargets: {
118+
type: [
119+
{
120+
_id: false,
121+
targetId: { type: String, required: true },
122+
target: { type: String, required: true },
123+
durationMs: { type: Number, required: true },
124+
dispatches: { type: Number, required: true },
125+
failures: { type: Number, required: true },
126+
},
127+
],
128+
required: false,
129+
default: undefined,
130+
},
108131
},
109132
required: false,
110133
default: undefined,

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3345,6 +3345,17 @@ describe('checkAlerts', () => {
33453345
expect(normalHistories[0].analytics!.webhookDurationMs).toEqual(
33463346
expect.any(Number),
33473347
);
3348+
// Per-target breakdown of that total: one entry for the alert's single
3349+
// configured webhook, named so the UI can attribute the time. Read
3350+
// field by field — these come back as Mongoose subdocuments, which
3351+
// don't deep-equal a plain object literal.
3352+
const targets = normalHistories[0].analytics!.notificationTargets;
3353+
expect(targets).toHaveLength(1);
3354+
expect(targets![0].targetId).toBe(webhook._id.toString());
3355+
expect(targets![0].target).toBe(webhook.name);
3356+
expect(targets![0].durationMs).toEqual(expect.any(Number));
3357+
expect(targets![0].dispatches).toBe(1);
3358+
expect(targets![0].failures).toBe(0);
33483359
});
33493360

33503361
it('keeps ERROR rows from older windows when a later window succeeds', async () => {

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

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ import {
3333
isRawSqlSavedChartConfig,
3434
} from '@hyperdx/common-utils/dist/guards';
3535
import {
36+
ALERT_NOTIFICATION_TARGETS_LIMIT,
3637
AlertErrorType,
38+
AlertNotificationTargetTiming,
3739
AlertThresholdType,
3840
BuilderChartConfigWithOptDateRange,
3941
ChartConfigWithOptDateRange,
@@ -80,7 +82,9 @@ import {
8082
AlertMessageTemplateDefaultView,
8183
buildAlertMessageTemplateTitle,
8284
NotificationFailure,
85+
NotificationTiming,
8386
renderAlertTemplate,
87+
RenderedAlert,
8488
} from '@/tasks/checkAlerts/template';
8589
import { handleSendGenericWebhook } from '@/tasks/checkAlerts/transports';
8690
import { tasksTracer } from '@/tasks/tracer';
@@ -494,7 +498,7 @@ const fireChannelEvent = async ({
494498
totalCount: number;
495499
windowSizeInMins: number;
496500
teamWebhooksById: Map<string, IWebhook>;
497-
}): Promise<NotificationFailure[]> => {
501+
}): Promise<Pick<RenderedAlert, 'failures' | 'timings'>> => {
498502
const team = alert.team;
499503
if (team == null) {
500504
throw new Error('Team not found');
@@ -546,7 +550,7 @@ const fireChannelEvent = async ({
546550
value: totalCount,
547551
};
548552

549-
const { failures } = await renderAlertTemplate({
553+
const { failures, timings } = await renderAlertTemplate({
550554
alertProvider,
551555
clickhouseClient,
552556
metadata,
@@ -561,7 +565,7 @@ const fireChannelEvent = async ({
561565
teamId,
562566
teamWebhooksById,
563567
});
564-
return failures;
568+
return { failures, timings };
565569
};
566570

567571
// Use a delimiter that's unlikely to appear in alert IDs or group names
@@ -925,6 +929,45 @@ export const processAlert = async (
925929
// (query duration, webhook delivery time, backfilled buckets). Populated
926930
// progressively; hoisted so the catch blocks can attach what was measured.
927931
const evaluationAnalytics: IAlertHistoryAnalytics = {};
932+
// Per-target notification timings, keyed by webhook id so the same target
933+
// notified for several groups (and again on resolve) aggregates into one
934+
// entry rather than one per dispatch.
935+
const notificationTimings = new Map<string, AlertNotificationTargetTiming>();
936+
const recordNotificationTimings = (timings: NotificationTiming[]) => {
937+
for (const timing of timings) {
938+
const existing = notificationTimings.get(timing.key);
939+
if (existing == null) {
940+
notificationTimings.set(timing.key, {
941+
targetId: timing.key,
942+
target: timing.target,
943+
durationMs: timing.durationMs,
944+
dispatches: 1,
945+
failures: timing.ok ? 0 : 1,
946+
});
947+
continue;
948+
}
949+
existing.durationMs += timing.durationMs;
950+
existing.dispatches += 1;
951+
existing.failures += timing.ok ? 0 : 1;
952+
}
953+
};
954+
/**
955+
* Fold the aggregated timings onto the analytics object. Called before the
956+
* records are written, from both the success and the error path, so a
957+
* failed evaluation still reports what it managed to deliver.
958+
*/
959+
const flushNotificationTimings = () => {
960+
if (notificationTimings.size === 0) {
961+
return;
962+
}
963+
evaluationAnalytics.notificationTargets = Array.from(
964+
notificationTimings.values(),
965+
)
966+
// Slowest first: the point of the breakdown is finding what dominated
967+
// the total, and the cap below should drop the least interesting rows.
968+
.sort((a, b) => b.durationMs - a.durationMs)
969+
.slice(0, ALERT_NOTIFICATION_TARGETS_LIMIT);
970+
};
928971
try {
929972
const windowSizeInMins = ms(alert.interval) / 60000;
930973
const scheduleStartAt = normalizeScheduleStartAt({
@@ -1232,7 +1275,7 @@ export const processAlert = async (
12321275
// alert logic requiring large, nested objects. We should look at
12331276
// cleaning this up next. fireChannelEvent guards against null values
12341277
// for these properties.
1235-
const failures = await fireChannelEvent({
1278+
const { failures, timings } = await fireChannelEvent({
12361279
alert,
12371280
alertProvider,
12381281
attributes,
@@ -1250,6 +1293,7 @@ export const processAlert = async (
12501293
windowSizeInMins,
12511294
teamWebhooksById,
12521295
});
1296+
recordNotificationTimings(timings);
12531297
// Each entry is a target that didn't end up delivered: unresolvable,
12541298
// capped, or (for the inline dispatcher) an actual send rejection —
12551299
// see renderAlertTemplate.
@@ -1373,6 +1417,7 @@ export const processAlert = async (
13731417

13741418
// Single-value evaluations always cover exactly the current window.
13751419
evaluationAnalytics.backfilledBuckets = 0;
1420+
flushNotificationTimings();
13761421
const historyRecords = Array.from(histories.values());
13771422
for (const record of historyRecords) {
13781423
record.analytics = evaluationAnalytics;
@@ -1595,6 +1640,7 @@ export const processAlert = async (
15951640
}
15961641

15971642
// Save all history records and update alert state
1643+
flushNotificationTimings();
15981644
const historyRecords = Array.from(histories.values());
15991645
for (const record of historyRecords) {
16001646
record.analytics = evaluationAnalytics;
@@ -1623,6 +1669,9 @@ export const processAlert = async (
16231669
e instanceof InvalidAlertError
16241670
? AlertErrorType.INVALID_ALERT
16251671
: AlertErrorType.UNKNOWN;
1672+
// An evaluation can notify some targets and then fail; report what it
1673+
// managed to deliver rather than dropping the timings with the error.
1674+
flushNotificationTimings();
16261675
try {
16271676
await alertProvider.recordAlertErrors(
16281677
alert.id,

packages/api/src/tasks/checkAlerts/template.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,11 +341,30 @@ const channelKey = (c: PopulatedAlertChannel) =>
341341
const channelLabel = (c: PopulatedAlertChannel) =>
342342
c.type === 'webhook' ? c.channel.name : c.type;
343343

344+
/**
345+
* One dispatch's wall time. Emitted per target per event, so a grouped alert
346+
* produces one of these per (group, target); the caller aggregates.
347+
*/
348+
export type NotificationTiming = {
349+
/** Stable identity for aggregation across events — the webhook id. */
350+
key: string;
351+
/** Display label: the webhook's name. */
352+
target: string;
353+
durationMs: number;
354+
ok: boolean;
355+
};
356+
344357
export type RenderedAlert = {
345358
/** The rendered message body, as delivered to every target. */
346359
body: string;
347360
/** One entry per target that did not end up delivered — see NotificationFailure. */
348361
failures: NotificationFailure[];
362+
/**
363+
* One entry per target that reached the dispatcher, delivered or not.
364+
* Targets that failed before dispatch have no timing — there was nothing to
365+
* time — so this is not the complement of `failures`.
366+
*/
367+
timings: NotificationTiming[];
349368
};
350369

351370
// this method will build the body of the alert message and will be used to send the alert to the channel
@@ -733,11 +752,17 @@ ${targetTemplate}`;
733752
// queued dispatcher resolves after enqueue and never rejects here; it
734753
// reports delivery outcomes through its own logs/metrics instead (see
735754
// agent_docs/observability.md).
755+
const timings: NotificationTiming[] = [];
736756
await Promise.all(
737757
jobs.map(async job => {
758+
// Per-job, not around the Promise.all: the whole point is attributing
759+
// the total to a target, and the dispatches overlap.
760+
const startedAt = performance.now();
761+
let ok = true;
738762
try {
739763
await dispatcher.dispatch(job);
740764
} catch (e) {
765+
ok = false;
741766
logger.error(
742767
{
743768
alertId: alert.id,
@@ -751,11 +776,18 @@ ${targetTemplate}`;
751776
type: job.populatedChannel.type,
752777
error: e,
753778
});
779+
} finally {
780+
timings.push({
781+
key: channelKey(job.populatedChannel),
782+
target: channelLabel(job.populatedChannel),
783+
durationMs: Math.round(performance.now() - startedAt),
784+
ok,
785+
});
754786
}
755787
}),
756788
);
757789

758-
return { body, failures };
790+
return { body, failures, timings };
759791
}
760792

761793
throw new Error(`Unsupported alert source: ${alert.source}`);

packages/app/src/components/alerts/AlertEvaluationRow.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
AlertErrorsContent,
1616
} from '@/components/alerts/AlertHistoryCards';
1717
import { AlertStateBadge } from '@/components/alerts/AlertStateBadge';
18+
import { NotificationDurationCell } from '@/components/alerts/NotificationDurationCell';
1819
import { FormatTime } from '@/useFormatTime';
1920
import { formatDurationMs } from '@/utils';
2021

@@ -184,7 +185,7 @@ export function AlertEvaluationRow({
184185
</Table.Td>
185186
<Table.Td>{durationCell(history.analytics?.queryDurationMs)}</Table.Td>
186187
<Table.Td>
187-
{durationCell(history.analytics?.webhookDurationMs)}
188+
<NotificationDurationCell analytics={history.analytics} />
188189
</Table.Td>
189190
<Table.Td>
190191
{hasErrors ? (
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import * as React from 'react';
2+
import type { AlertHistoryAnalytics } from '@hyperdx/common-utils/dist/types';
3+
import { Collapse, Group, Stack, Text, UnstyledButton } from '@mantine/core';
4+
import { IconChevronDown, IconChevronRight } from '@tabler/icons-react';
5+
6+
import { formatDurationMs } from '@/utils';
7+
8+
/**
9+
* The evaluation's notification wall time, expandable in place into a
10+
* per-target breakdown.
11+
*
12+
* It expands *within* the cell rather than adding child rows: the parent row
13+
* already owns a chevron for groups and errors, and a second row-level
14+
* expander competing with it would be ambiguous to click.
15+
*/
16+
export function NotificationDurationCell({
17+
analytics,
18+
}: {
19+
analytics?: AlertHistoryAnalytics;
20+
}) {
21+
const [expanded, setExpanded] = React.useState(false);
22+
const total = analytics?.webhookDurationMs;
23+
const targets = analytics?.notificationTargets ?? [];
24+
25+
if (total == null) {
26+
return <></>;
27+
}
28+
29+
// Records written before per-target timing existed have the total but no
30+
// breakdown, so there is nothing to expand into.
31+
if (targets.length === 0) {
32+
return <Text size="sm">{formatDurationMs(total)}</Text>;
33+
}
34+
35+
return (
36+
<Stack gap={2} align="flex-start">
37+
<UnstyledButton
38+
// The parent row toggles its own expansion on click; without this the
39+
// cell's expander would fire both.
40+
onClick={event => {
41+
event.stopPropagation();
42+
setExpanded(value => !value);
43+
}}
44+
aria-expanded={expanded}
45+
data-testid="notification-duration-toggle"
46+
>
47+
<Group gap={2} wrap="nowrap">
48+
<Text size="sm">{formatDurationMs(total)}</Text>
49+
{expanded ? (
50+
<IconChevronDown size={12} />
51+
) : (
52+
<IconChevronRight size={12} />
53+
)}
54+
</Group>
55+
</UnstyledButton>
56+
<Collapse expanded={expanded}>
57+
<Stack gap={2} pt={2} data-testid="notification-duration-breakdown">
58+
{targets.map(target => (
59+
// Keyed on the id, not the label: two webhooks can share a name.
60+
<Group key={target.targetId} gap="xs" wrap="nowrap">
61+
<Text size="xs" c="dimmed">
62+
{target.target}
63+
</Text>
64+
<Text size="xs">{formatDurationMs(target.durationMs)}</Text>
65+
{target.dispatches > 1 && (
66+
<Text size="xs" c="dimmed">
67+
×{target.dispatches}
68+
</Text>
69+
)}
70+
{target.failures > 0 && (
71+
<Text size="xs" c="var(--color-text-danger)">
72+
{target.failures} failed
73+
</Text>
74+
)}
75+
</Group>
76+
))}
77+
</Stack>
78+
</Collapse>
79+
</Stack>
80+
);
81+
}

0 commit comments

Comments
 (0)