Skip to content

Commit 902b8eb

Browse files
mlsalcedomelsalcedoteeohhem
authored
feat(alerts): add scheduleStartAt + scheduleOffsetMinutes (#1745)
Closes #1715 ## Summary - Add scheduleStartAt and scheduleOffsetMinutes to alert schemas and API validation. - Update alert evaluation scheduling to anchor windows by scheduleStartAt when set. - Skip evaluations before scheduleStartAt. - Keep current behavior unchanged when scheduling fields are unset. - Add UI fields and API/OpenAPI/external API support for the new schedule options. - Add alert scheduler tests for anchored windows and pre-start skip behavior. ## Notes - This enables Splunk-style scheduled monitor migration where checks must run on isolated, periodic windows anchored to specific times. - scheduleStartAt is the primary anchor; scheduleOffsetMinutes remains optional for backward-compatible alignment. Co-authored-by: melsalcedo <128840984+melsalcedo@users.noreply.github.com> Co-authored-by: Tom Alexander <3245235+teeohhem@users.noreply.github.com>
1 parent 32f1189 commit 902b8eb

22 files changed

Lines changed: 1499 additions & 85 deletions

File tree

‎.changeset/silent-zebras-switch.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hyperdx/api": minor
3+
"@hyperdx/app": minor
4+
"@hyperdx/common-utils": minor
5+
---
6+
7+
feat(alerts): add anchored alert scheduling with `scheduleStartAt` and `scheduleOffsetMinutes`

‎packages/api/openapi.json‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,20 @@
169169
"$ref": "#/components/schemas/AlertInterval",
170170
"example": "1h"
171171
},
172+
"scheduleOffsetMinutes": {
173+
"type": "integer",
174+
"minimum": 0,
175+
"description": "Offset from the interval boundary in minutes. For example, 2 with a 5m interval evaluates windows at :02, :07, :12, etc. (UTC).",
176+
"nullable": true,
177+
"example": 2
178+
},
179+
"scheduleStartAt": {
180+
"type": "string",
181+
"format": "date-time",
182+
"description": "Absolute UTC start time anchor. Alert windows start from this timestamp and repeat every interval.",
183+
"nullable": true,
184+
"example": "2026-02-08T10:00:00.000Z"
185+
},
172186
"source": {
173187
"$ref": "#/components/schemas/AlertSource",
174188
"example": "tile"

‎packages/api/src/controllers/alerts.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export type AlertInput = {
2424
source?: AlertSource;
2525
channel: AlertChannel;
2626
interval: AlertInterval;
27+
scheduleOffsetMinutes?: number;
28+
scheduleStartAt?: string | null;
2729
thresholdType: AlertThresholdType;
2830
threshold: number;
2931

@@ -105,9 +107,30 @@ export const validateAlertInput = async (
105107
};
106108

107109
const makeAlert = (alert: AlertInput, userId?: ObjectId): Partial<IAlert> => {
110+
// Preserve existing DB value when scheduleStartAt is omitted from updates
111+
// (undefined), while still allowing explicit clears via null.
112+
const hasScheduleStartAt = alert.scheduleStartAt !== undefined;
113+
// If scheduleStartAt is explicitly provided, offset-based alignment is ignored.
114+
// Force persisted offset to 0 so updates can't leave stale non-zero offsets.
115+
// If scheduleStartAt is explicitly cleared and offset is omitted, also reset
116+
// to 0 to avoid preserving stale values from older documents.
117+
const normalizedScheduleOffsetMinutes =
118+
hasScheduleStartAt && alert.scheduleStartAt != null
119+
? 0
120+
: hasScheduleStartAt && alert.scheduleOffsetMinutes == null
121+
? 0
122+
: alert.scheduleOffsetMinutes;
123+
108124
return {
109125
channel: alert.channel,
110126
interval: alert.interval,
127+
...(normalizedScheduleOffsetMinutes != null && {
128+
scheduleOffsetMinutes: normalizedScheduleOffsetMinutes,
129+
}),
130+
...(hasScheduleStartAt && {
131+
scheduleStartAt:
132+
alert.scheduleStartAt == null ? null : new Date(alert.scheduleStartAt),
133+
}),
111134
source: alert.source,
112135
threshold: alert.threshold,
113136
thresholdType: alert.thresholdType,

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ALERT_INTERVAL_TO_MINUTES } from '@hyperdx/common-utils/dist/types';
12
import mongoose, { Schema } from 'mongoose';
23

34
import type { ObjectId } from '.';
@@ -44,6 +45,8 @@ export interface IAlert {
4445
id: string;
4546
channel: AlertChannel;
4647
interval: AlertInterval;
48+
scheduleOffsetMinutes?: number;
49+
scheduleStartAt?: Date | null;
4750
source?: AlertSource;
4851
state: AlertState;
4952
team: ObjectId;
@@ -88,6 +91,29 @@ const AlertSchema = new Schema<IAlert>(
8891
type: String,
8992
required: true,
9093
},
94+
scheduleOffsetMinutes: {
95+
type: Number,
96+
min: 0,
97+
// Maximum offset for daily windows (24h - 1 minute).
98+
max: 1439,
99+
validate: {
100+
validator: function (this: IAlert, value: number | undefined) {
101+
if (value == null) {
102+
return true;
103+
}
104+
105+
const intervalMinutes = ALERT_INTERVAL_TO_MINUTES[this.interval];
106+
return intervalMinutes == null || value < intervalMinutes;
107+
},
108+
message:
109+
'scheduleOffsetMinutes must be less than the alert interval in minutes',
110+
},
111+
required: false,
112+
},
113+
scheduleStartAt: {
114+
type: Date,
115+
required: false,
116+
},
91117
channel: Schema.Types.Mixed, // slack, email, etc
92118
state: {
93119
type: String,

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

Lines changed: 240 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
makeTile,
66
randomMongoId,
77
} from '@/fixtures';
8-
import Alert from '@/models/alert';
8+
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
99
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';
1010

1111
const MOCK_TILES = [makeTile(), makeTile(), makeTile(), makeTile(), makeTile()];
@@ -116,6 +116,245 @@ describe('alerts router', () => {
116116
expect(allAlerts.body.data[0].threshold).toBe(10);
117117
});
118118

119+
it('preserves scheduleStartAt when omitted in updates and clears when null', async () => {
120+
const dashboard = await agent
121+
.post('/dashboards')
122+
.send(MOCK_DASHBOARD)
123+
.expect(200);
124+
125+
const scheduleStartAt = '2024-01-01T00:00:00.000Z';
126+
const createdAlert = await agent
127+
.post('/alerts')
128+
.send({
129+
...makeAlertInput({
130+
dashboardId: dashboard.body.id,
131+
tileId: dashboard.body.tiles[0].id,
132+
webhookId: webhook._id.toString(),
133+
}),
134+
scheduleStartAt,
135+
})
136+
.expect(200);
137+
138+
const updatePayload = {
139+
channel: createdAlert.body.data.channel,
140+
interval: createdAlert.body.data.interval,
141+
threshold: 10,
142+
thresholdType: createdAlert.body.data.thresholdType,
143+
source: createdAlert.body.data.source,
144+
dashboardId: dashboard.body.id,
145+
tileId: dashboard.body.tiles[0].id,
146+
};
147+
148+
await agent
149+
.put(`/alerts/${createdAlert.body.data._id}`)
150+
.send(updatePayload)
151+
.expect(200);
152+
153+
const alertAfterOmittedScheduleStartAt = await Alert.findById(
154+
createdAlert.body.data._id,
155+
);
156+
expect(
157+
alertAfterOmittedScheduleStartAt?.scheduleStartAt?.toISOString(),
158+
).toBe(scheduleStartAt);
159+
160+
await agent
161+
.put(`/alerts/${createdAlert.body.data._id}`)
162+
.send({
163+
...updatePayload,
164+
scheduleStartAt: null,
165+
})
166+
.expect(200);
167+
168+
const alertAfterNullScheduleStartAt = await Alert.findById(
169+
createdAlert.body.data._id,
170+
);
171+
expect(alertAfterNullScheduleStartAt?.scheduleStartAt).toBeNull();
172+
});
173+
174+
it('preserves scheduleOffsetMinutes when schedule fields are omitted in updates', async () => {
175+
const dashboard = await agent
176+
.post('/dashboards')
177+
.send(MOCK_DASHBOARD)
178+
.expect(200);
179+
180+
const createdAlert = await agent
181+
.post('/alerts')
182+
.send({
183+
...makeAlertInput({
184+
dashboardId: dashboard.body.id,
185+
tileId: dashboard.body.tiles[0].id,
186+
interval: '15m',
187+
webhookId: webhook._id.toString(),
188+
}),
189+
scheduleOffsetMinutes: 2,
190+
})
191+
.expect(200);
192+
193+
await agent
194+
.put(`/alerts/${createdAlert.body.data._id}`)
195+
.send({
196+
channel: createdAlert.body.data.channel,
197+
interval: createdAlert.body.data.interval,
198+
threshold: 10,
199+
thresholdType: createdAlert.body.data.thresholdType,
200+
source: createdAlert.body.data.source,
201+
dashboardId: dashboard.body.id,
202+
tileId: dashboard.body.tiles[0].id,
203+
})
204+
.expect(200);
205+
206+
const updatedAlert = await Alert.findById(createdAlert.body.data._id);
207+
expect(updatedAlert?.scheduleOffsetMinutes).toBe(2);
208+
expect(updatedAlert?.scheduleStartAt).toBeUndefined();
209+
});
210+
211+
it('resets scheduleOffsetMinutes to 0 when scheduleStartAt is set without offset', async () => {
212+
const dashboard = await agent
213+
.post('/dashboards')
214+
.send(MOCK_DASHBOARD)
215+
.expect(200);
216+
217+
const createdAlert = await agent
218+
.post('/alerts')
219+
.send({
220+
...makeAlertInput({
221+
dashboardId: dashboard.body.id,
222+
tileId: dashboard.body.tiles[0].id,
223+
webhookId: webhook._id.toString(),
224+
}),
225+
scheduleOffsetMinutes: 2,
226+
})
227+
.expect(200);
228+
229+
expect(createdAlert.body.data.scheduleOffsetMinutes).toBe(2);
230+
231+
const scheduleStartAt = '2024-01-01T00:00:00.000Z';
232+
233+
await agent
234+
.put(`/alerts/${createdAlert.body.data._id}`)
235+
.send({
236+
channel: createdAlert.body.data.channel,
237+
interval: createdAlert.body.data.interval,
238+
threshold: createdAlert.body.data.threshold,
239+
thresholdType: createdAlert.body.data.thresholdType,
240+
source: createdAlert.body.data.source,
241+
dashboardId: dashboard.body.id,
242+
tileId: dashboard.body.tiles[0].id,
243+
scheduleStartAt,
244+
})
245+
.expect(200);
246+
247+
const updatedAlert = await Alert.findById(createdAlert.body.data._id);
248+
expect(updatedAlert?.scheduleOffsetMinutes).toBe(0);
249+
expect(updatedAlert?.scheduleStartAt?.toISOString()).toBe(scheduleStartAt);
250+
});
251+
252+
it('resets stale scheduleOffsetMinutes when scheduleStartAt is cleared without offset', async () => {
253+
const dashboard = await agent
254+
.post('/dashboards')
255+
.send(MOCK_DASHBOARD)
256+
.expect(200);
257+
258+
const staleAlert = await Alert.create({
259+
team: team._id,
260+
channel: {
261+
type: 'webhook',
262+
webhookId: webhook._id.toString(),
263+
},
264+
interval: '15m',
265+
threshold: 8,
266+
thresholdType: AlertThresholdType.ABOVE,
267+
source: AlertSource.TILE,
268+
dashboard: dashboard.body.id,
269+
tileId: dashboard.body.tiles[0].id,
270+
scheduleOffsetMinutes: 2,
271+
scheduleStartAt: new Date('2024-01-01T00:00:00.000Z'),
272+
});
273+
274+
await agent
275+
.put(`/alerts/${staleAlert._id.toString()}`)
276+
.send({
277+
...makeAlertInput({
278+
dashboardId: dashboard.body.id,
279+
tileId: dashboard.body.tiles[0].id,
280+
interval: '15m',
281+
webhookId: webhook._id.toString(),
282+
}),
283+
scheduleStartAt: null,
284+
})
285+
.expect(200);
286+
287+
const updatedAlert = await Alert.findById(staleAlert._id);
288+
expect(updatedAlert?.scheduleOffsetMinutes).toBe(0);
289+
expect(updatedAlert?.scheduleStartAt).toBeNull();
290+
});
291+
292+
it('rejects scheduleStartAt values more than 1 year in the future', async () => {
293+
const dashboard = await agent
294+
.post('/dashboards')
295+
.send(MOCK_DASHBOARD)
296+
.expect(200);
297+
298+
const farFutureScheduleStartAt = new Date(
299+
Date.now() + 366 * 24 * 60 * 60 * 1000,
300+
).toISOString();
301+
302+
await agent
303+
.post('/alerts')
304+
.send({
305+
...makeAlertInput({
306+
dashboardId: dashboard.body.id,
307+
tileId: dashboard.body.tiles[0].id,
308+
webhookId: webhook._id.toString(),
309+
}),
310+
scheduleStartAt: farFutureScheduleStartAt,
311+
})
312+
.expect(400);
313+
});
314+
315+
it('rejects scheduleStartAt values older than 10 years in the past', async () => {
316+
const dashboard = await agent
317+
.post('/dashboards')
318+
.send(MOCK_DASHBOARD)
319+
.expect(200);
320+
321+
const tooOldScheduleStartAt = new Date(
322+
Date.now() - 11 * 365 * 24 * 60 * 60 * 1000,
323+
).toISOString();
324+
325+
await agent
326+
.post('/alerts')
327+
.send({
328+
...makeAlertInput({
329+
dashboardId: dashboard.body.id,
330+
tileId: dashboard.body.tiles[0].id,
331+
webhookId: webhook._id.toString(),
332+
}),
333+
scheduleStartAt: tooOldScheduleStartAt,
334+
})
335+
.expect(400);
336+
});
337+
338+
it('rejects scheduleOffsetMinutes when scheduleStartAt is provided', async () => {
339+
const dashboard = await agent
340+
.post('/dashboards')
341+
.send(MOCK_DASHBOARD)
342+
.expect(200);
343+
344+
await agent
345+
.post('/alerts')
346+
.send({
347+
...makeAlertInput({
348+
dashboardId: dashboard.body.id,
349+
tileId: dashboard.body.tiles[0].id,
350+
webhookId: webhook._id.toString(),
351+
}),
352+
scheduleOffsetMinutes: 2,
353+
scheduleStartAt: new Date().toISOString(),
354+
})
355+
.expect(400);
356+
});
357+
119358
it('preserves createdBy field during updates', async () => {
120359
const dashboard = await agent
121360
.post('/dashboards')

0 commit comments

Comments
 (0)