Skip to content

Commit a798d1f

Browse files
committed
feat: add rate of change as alert trigger condition
Add a new "Rate of Change" condition type for alerts alongside the existing threshold rules. This compares the current evaluation window's value to the immediately preceding window and fires when the absolute or percentage change exceeds the configured threshold -- similar to Datadog's Change Alert, Grafana's diff/percent_diff reducers, and Splunk's Sudden Change detector. - New enums: AlertConditionType (threshold | rate_of_change), AlertChangeType (absolute | percentage) - Zod validation requiring changeType when conditionType is rate_of_change - Evaluation engine: extended date range for 2-window lookback, computeRateOfChange function, baseline bucket tracking in processAlert - Frontend: condition type / change type selectors in saved search and dashboard tile alert forms, improved alert card summary on /alerts page - Notification templates updated for rate-of-change context - Unit tests (schema validation, computeRateOfChange, external API) - Integration tests (API CRUD, ClickHouse evaluation with 4 scenarios) - E2E Playwright tests for saved search and dashboard tile flows Made-with: Cursor
1 parent 72d4642 commit a798d1f

File tree

20 files changed

+1363
-46
lines changed

20 files changed

+1363
-46
lines changed

packages/api/src/controllers/alerts.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { z } from 'zod';
66

77
import type { ObjectId } from '@/models';
88
import Alert, {
9+
AlertChangeType,
910
AlertChannel,
11+
AlertConditionType,
1012
AlertInterval,
1113
AlertSource,
1214
AlertThresholdType,
@@ -24,6 +26,8 @@ export type AlertInput = {
2426
id?: string;
2527
source?: AlertSource;
2628
channel: AlertChannel;
29+
conditionType?: AlertConditionType;
30+
changeType?: AlertChangeType;
2731
interval: AlertInterval;
2832
scheduleOffsetMinutes?: number;
2933
scheduleStartAt?: string | null;
@@ -141,6 +145,8 @@ const makeAlert = (alert: AlertInput, userId?: ObjectId): Partial<IAlert> => {
141145
source: alert.source,
142146
threshold: alert.threshold,
143147
thresholdType: alert.thresholdType,
148+
conditionType: alert.conditionType,
149+
changeType: alert.changeType,
144150
...(userId && { createdBy: userId }),
145151

146152
// Message template

packages/api/src/models/alert.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ export enum AlertThresholdType {
99
BELOW = 'below',
1010
}
1111

12+
export enum AlertConditionType {
13+
THRESHOLD = 'threshold',
14+
RATE_OF_CHANGE = 'rate_of_change',
15+
}
16+
17+
export enum AlertChangeType {
18+
ABSOLUTE = 'absolute',
19+
PERCENTAGE = 'percentage',
20+
}
21+
1222
export enum AlertState {
1323
ALERT = 'ALERT',
1424
DISABLED = 'DISABLED',
@@ -44,6 +54,8 @@ export enum AlertSource {
4454
export interface IAlert {
4555
id: string;
4656
channel: AlertChannel;
57+
conditionType?: AlertConditionType;
58+
changeType?: AlertChangeType;
4759
interval: AlertInterval;
4860
scheduleOffsetMinutes?: number;
4961
scheduleStartAt?: Date | null;
@@ -87,6 +99,17 @@ const AlertSchema = new Schema<IAlert>(
8799
enum: AlertThresholdType,
88100
required: false,
89101
},
102+
conditionType: {
103+
type: String,
104+
enum: AlertConditionType,
105+
default: AlertConditionType.THRESHOLD,
106+
required: false,
107+
},
108+
changeType: {
109+
type: String,
110+
enum: AlertChangeType,
111+
required: false,
112+
},
90113
interval: {
91114
type: String,
92115
required: true,

packages/api/src/routers/api/__tests__/alerts.test.ts

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,18 @@ import {
33
getServer,
44
makeAlertInput,
55
makeRawSqlTile,
6+
makeSavedSearchAlertInput,
67
makeTile,
78
randomMongoId,
89
} from '@/fixtures';
9-
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
10+
import Alert, {
11+
AlertChangeType,
12+
AlertConditionType,
13+
AlertSource,
14+
AlertThresholdType,
15+
} from '@/models/alert';
16+
import { SavedSearch } from '@/models/savedSearch';
17+
import { Source } from '@/models/source';
1018
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';
1119

1220
const MOCK_TILES = [makeTile(), makeTile(), makeTile(), makeTile(), makeTile()];
@@ -607,4 +615,153 @@ describe('alerts router', () => {
607615
})
608616
.expect(400);
609617
});
618+
619+
describe('rate of change alerts', () => {
620+
it('creates a rate-of-change alert with absolute change type', async () => {
621+
const dashboard = await agent
622+
.post('/dashboards')
623+
.send({
624+
name: 'RoC Dashboard',
625+
tiles: MOCK_TILES,
626+
tags: [],
627+
})
628+
.expect(200);
629+
630+
const resp = await agent
631+
.post('/alerts')
632+
.send({
633+
...makeAlertInput({
634+
dashboardId: dashboard.body.id,
635+
tileId: MOCK_TILES[0].id,
636+
webhookId: webhook._id.toString(),
637+
}),
638+
conditionType: AlertConditionType.RATE_OF_CHANGE,
639+
changeType: AlertChangeType.ABSOLUTE,
640+
})
641+
.expect(200);
642+
643+
expect(resp.body.data.conditionType).toBe(
644+
AlertConditionType.RATE_OF_CHANGE,
645+
);
646+
expect(resp.body.data.changeType).toBe(AlertChangeType.ABSOLUTE);
647+
});
648+
649+
it('creates a rate-of-change alert with percentage change type', async () => {
650+
const dashboard = await agent
651+
.post('/dashboards')
652+
.send({
653+
name: 'RoC Dashboard',
654+
tiles: MOCK_TILES,
655+
tags: [],
656+
})
657+
.expect(200);
658+
659+
const resp = await agent
660+
.post('/alerts')
661+
.send({
662+
...makeAlertInput({
663+
dashboardId: dashboard.body.id,
664+
tileId: MOCK_TILES[0].id,
665+
webhookId: webhook._id.toString(),
666+
}),
667+
conditionType: AlertConditionType.RATE_OF_CHANGE,
668+
changeType: AlertChangeType.PERCENTAGE,
669+
})
670+
.expect(200);
671+
672+
expect(resp.body.data.conditionType).toBe(
673+
AlertConditionType.RATE_OF_CHANGE,
674+
);
675+
expect(resp.body.data.changeType).toBe(AlertChangeType.PERCENTAGE);
676+
});
677+
678+
it('rejects rate-of-change alert without changeType', async () => {
679+
const dashboard = await agent
680+
.post('/dashboards')
681+
.send({
682+
name: 'RoC Dashboard',
683+
tiles: MOCK_TILES,
684+
tags: [],
685+
})
686+
.expect(200);
687+
688+
await agent
689+
.post('/alerts')
690+
.send({
691+
...makeAlertInput({
692+
dashboardId: dashboard.body.id,
693+
tileId: MOCK_TILES[0].id,
694+
webhookId: webhook._id.toString(),
695+
}),
696+
conditionType: AlertConditionType.RATE_OF_CHANGE,
697+
})
698+
.expect(400);
699+
});
700+
701+
it('creates a threshold alert without conditionType (backward compat)', async () => {
702+
const dashboard = await agent
703+
.post('/dashboards')
704+
.send({
705+
name: 'Backward Compat Dashboard',
706+
tiles: MOCK_TILES,
707+
tags: [],
708+
})
709+
.expect(200);
710+
711+
const resp = await agent
712+
.post('/alerts')
713+
.send(
714+
makeAlertInput({
715+
dashboardId: dashboard.body.id,
716+
tileId: MOCK_TILES[0].id,
717+
webhookId: webhook._id.toString(),
718+
}),
719+
)
720+
.expect(200);
721+
722+
expect(resp.body.data.conditionType).toBeUndefined();
723+
});
724+
725+
it('updates an existing alert to rate-of-change', async () => {
726+
const dashboard = await agent
727+
.post('/dashboards')
728+
.send({
729+
name: 'Update RoC Dashboard',
730+
tiles: MOCK_TILES,
731+
tags: [],
732+
})
733+
.expect(200);
734+
735+
const createResp = await agent
736+
.post('/alerts')
737+
.send(
738+
makeAlertInput({
739+
dashboardId: dashboard.body.id,
740+
tileId: MOCK_TILES[0].id,
741+
webhookId: webhook._id.toString(),
742+
}),
743+
)
744+
.expect(200);
745+
746+
const alertId = createResp.body.data._id;
747+
748+
const updateResp = await agent
749+
.put(`/alerts/${alertId}`)
750+
.send({
751+
...makeAlertInput({
752+
dashboardId: dashboard.body.id,
753+
tileId: MOCK_TILES[0].id,
754+
webhookId: webhook._id.toString(),
755+
}),
756+
conditionType: AlertConditionType.RATE_OF_CHANGE,
757+
changeType: AlertChangeType.PERCENTAGE,
758+
})
759+
.expect(200);
760+
761+
expect(updateResp.body.data.conditionType).toBe(
762+
AlertConditionType.RATE_OF_CHANGE,
763+
);
764+
expect(updateResp.body.data.changeType).toBe(AlertChangeType.PERCENTAGE);
765+
});
766+
});
610767
});

packages/api/src/routers/api/alerts.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ router.get('/', async (req, res: AlertsExpRes, next) => {
4242
scheduleStartAt: alert.scheduleStartAt?.toISOString() ?? undefined,
4343
threshold: alert.threshold,
4444
thresholdType: alert.thresholdType,
45+
conditionType: alert.conditionType,
46+
changeType: alert.changeType,
4547
channel: { type: alert.channel.type ?? undefined },
4648
state: alert.state,
4749
source: alert.source,

packages/api/src/routers/external-api/__tests__/alerts.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import { ObjectId } from 'mongodb';
33
import request from 'supertest';
44

55
import { getLoggedInAgent, getServer } from '../../../fixtures';
6-
import { AlertSource, AlertThresholdType } from '../../../models/alert';
6+
import {
7+
AlertChangeType,
8+
AlertConditionType,
9+
AlertSource,
10+
AlertThresholdType,
11+
} from '../../../models/alert';
712
import Alert from '../../../models/alert';
813
import Dashboard from '../../../models/dashboard';
914
import { SavedSearch } from '../../../models/savedSearch';
@@ -883,4 +888,47 @@ describe('External API Alerts', () => {
883888
.expect(401);
884889
});
885890
});
891+
892+
describe('Rate of Change Alerts', () => {
893+
it('should create and retrieve a rate-of-change alert', async () => {
894+
const { alert } = await createTestAlert({
895+
conditionType: AlertConditionType.RATE_OF_CHANGE,
896+
changeType: AlertChangeType.PERCENTAGE,
897+
});
898+
899+
expect(alert.conditionType).toBe(AlertConditionType.RATE_OF_CHANGE);
900+
expect(alert.changeType).toBe(AlertChangeType.PERCENTAGE);
901+
902+
const getResp = await authRequest(
903+
'get',
904+
`${ALERTS_BASE_URL}/${alert.id}`,
905+
).expect(200);
906+
907+
expect(getResp.body.data.conditionType).toBe(
908+
AlertConditionType.RATE_OF_CHANGE,
909+
);
910+
expect(getResp.body.data.changeType).toBe(AlertChangeType.PERCENTAGE);
911+
});
912+
913+
it('should reject rate-of-change without changeType', async () => {
914+
const dashboard = await createTestDashboard();
915+
const webhook = await createTestWebhook();
916+
917+
await authRequest('post', ALERTS_BASE_URL)
918+
.send({
919+
dashboardId: dashboard._id.toString(),
920+
tileId: dashboard.tiles[0].id,
921+
threshold: 50,
922+
interval: '5m',
923+
source: AlertSource.TILE,
924+
thresholdType: AlertThresholdType.ABOVE,
925+
conditionType: AlertConditionType.RATE_OF_CHANGE,
926+
channel: {
927+
type: 'webhook',
928+
webhookId: webhook._id.toString(),
929+
},
930+
})
931+
.expect(400);
932+
});
933+
});
886934
});

0 commit comments

Comments
 (0)