Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
c1b8292
feat: add support for `scheduleStartAt` and `scheduleOffsetMinutes` i…
melsalcedo Feb 9, 2026
ffa5bf2
Update packages/api/src/controllers/alerts.ts
mlsalcedo Feb 17, 2026
7f1506c
Update packages/api/src/controllers/alerts.ts
mlsalcedo Feb 17, 2026
8408688
refactor: simplify `scheduleStartAt` validation using `fns.isValid` f…
melsalcedo Feb 17, 2026
bf4b847
feat: improve alert scheduling by updating `scheduleStartAt` validati…
melsalcedo Feb 17, 2026
799c84c
Merge branch 'main' into startTime-offset
teeohhem Feb 19, 2026
03c07cb
refactor(alerts): dedupe interval and offset validation
melsalcedo Feb 19, 2026
f8b379e
feat(app): use DateTimePicker for scheduleStartAt input
melsalcedo Feb 20, 2026
ea4ab24
feat(app): add datetime picker to dashboard alert start time
melsalcedo Feb 20, 2026
dfadc7e
refactor(alerts): share scheduleStartAt schema and parser
melsalcedo Feb 20, 2026
b903e61
chore(alerts): clarify anchor start label in forms
melsalcedo Feb 20, 2026
d1a028d
fix(alerts): handle null scheduleStartAt and add scheduling guards
melsalcedo Feb 20, 2026
29af34b
chore(alerts): address review feedback for schedule start
melsalcedo Feb 20, 2026
30c4865
chore(alerts): normalize schedule defaults and 1m offset UX
melsalcedo Feb 20, 2026
32a7267
refactor(alerts): centralize schedule fields and harden API validation
melsalcedo Feb 20, 2026
539b531
fix(alerts): avoid no-op schedule writes for existing alerts
melsalcedo Feb 20, 2026
41e0c26
refactor(alerts): share no-op schedule normalization helper
melsalcedo Feb 20, 2026
6e31044
fix(alerts): tighten scheduleStartAt validation and defaults
melsalcedo Feb 20, 2026
d1498fc
fix(alerts): guard new-alert schedule normalization
melsalcedo Feb 20, 2026
fe52938
chore(alerts): tighten copy and offset bounds
melsalcedo Feb 20, 2026
f7cceef
chore(alerts): clarify offset behavior with anchored start
melsalcedo Feb 20, 2026
6a4c407
chore(alerts): guard anchor bounds and document zod coupling
melsalcedo Feb 20, 2026
be098f9
feat(alerts): enforce anchored offset semantics across layers
melsalcedo Feb 20, 2026
7d502a2
fix(alerts): clear stale offset when scheduleStartAt is set
melsalcedo Feb 20, 2026
e718023
fix(alerts): preserve schema compatibility and harden no-op normaliza…
melsalcedo Feb 20, 2026
6c96d66
fix(common-utils): keep validated alert schemas internal
melsalcedo Feb 20, 2026
2d29bb5
fix(alerts): tighten external validation and clear stale offsets
melsalcedo Feb 20, 2026
3780acd
Merge remote-tracking branch 'upstream/main' into codex/issue-1715-st…
melsalcedo Feb 20, 2026
64052ab
fix(app): preserve explicit alert schedule resets
melsalcedo Feb 28, 2026
da9293d
Merge remote-tracking branch 'upstream/main' into codex/issue-1715-st…
melsalcedo Feb 28, 2026
2f3c582
test(api): cover omitted alert schedule updates
melsalcedo Feb 28, 2026
12d483d
feat(app): collapse alert schedule fields under Advanced Options
teeohhem Mar 2, 2026
2505234
Merge remote-tracking branch 'upstream/main' into codex/issue-1715-st…
melsalcedo Mar 3, 2026
4d199db
feat(app): tuck alert scheduling under advanced settings
melsalcedo Mar 3, 2026
a9c3a0a
Merge remote-tracking branch 'mlsalcedo/startTime-offset' into codex/…
melsalcedo Mar 3, 2026
436adb8
refactor(app): align alert schedule layouts
melsalcedo Mar 3, 2026
3acb031
refactor(api): parse alert requests in middleware
melsalcedo Mar 3, 2026
b4928d7
Merge remote-tracking branch 'upstream/main' into codex/issue-1715-st…
melsalcedo Mar 3, 2026
d1c329a
Merge remote-tracking branch 'upstream/main' into codex/issue-1715-st…
melsalcedo Mar 4, 2026
04efb18
Merge branch 'main' into startTime-offset
mlsalcedo Mar 4, 2026
b1faed3
Merge remote-tracking branch 'upstream/main' into codex/issue-1715-st…
melsalcedo Mar 5, 2026
d302304
Merge branch 'startTime-offset' of https://github.com/mlsalcedo/hyper…
melsalcedo Mar 5, 2026
05b8f25
fix: linitng
teeohhem Mar 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/silent-zebras-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hyperdx/api": minor
"@hyperdx/app": minor
"@hyperdx/common-utils": minor
---

feat(alerts): add anchored alert scheduling with `scheduleStartAt` and `scheduleOffsetMinutes`
14 changes: 14 additions & 0 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,20 @@
"$ref": "#/components/schemas/AlertInterval",
"example": "1h"
},
"scheduleOffsetMinutes": {
"type": "integer",
"minimum": 0,
"description": "Offset from the interval boundary in minutes. For example, 2 with a 5m interval evaluates windows at :02, :07, :12, etc. (UTC).",
"nullable": true,
"example": 2
},
"scheduleStartAt": {
"type": "string",
"format": "date-time",
"description": "Absolute UTC start time anchor. Alert windows start from this timestamp and repeat every interval.",
"nullable": true,
"example": "2026-02-08T10:00:00.000Z"
},
"source": {
"$ref": "#/components/schemas/AlertSource",
"example": "tile"
Expand Down
23 changes: 23 additions & 0 deletions packages/api/src/controllers/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type AlertInput = {
source?: AlertSource;
channel: AlertChannel;
interval: AlertInterval;
scheduleOffsetMinutes?: number;
scheduleStartAt?: string | null;
thresholdType: AlertThresholdType;
threshold: number;

Expand Down Expand Up @@ -105,9 +107,30 @@ export const validateAlertInput = async (
};

const makeAlert = (alert: AlertInput, userId?: ObjectId): Partial<IAlert> => {
// Preserve existing DB value when scheduleStartAt is omitted from updates
// (undefined), while still allowing explicit clears via null.
const hasScheduleStartAt = alert.scheduleStartAt !== undefined;
// If scheduleStartAt is explicitly provided, offset-based alignment is ignored.
// Force persisted offset to 0 so updates can't leave stale non-zero offsets.
// If scheduleStartAt is explicitly cleared and offset is omitted, also reset
// to 0 to avoid preserving stale values from older documents.
const normalizedScheduleOffsetMinutes =
hasScheduleStartAt && alert.scheduleStartAt != null
? 0
: hasScheduleStartAt && alert.scheduleOffsetMinutes == null
? 0
: alert.scheduleOffsetMinutes;

return {
channel: alert.channel,
interval: alert.interval,
...(normalizedScheduleOffsetMinutes != null && {
scheduleOffsetMinutes: normalizedScheduleOffsetMinutes,
}),
...(hasScheduleStartAt && {
scheduleStartAt:
alert.scheduleStartAt == null ? null : new Date(alert.scheduleStartAt),
}),
source: alert.source,
threshold: alert.threshold,
thresholdType: alert.thresholdType,
Expand Down
26 changes: 26 additions & 0 deletions packages/api/src/models/alert.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ALERT_INTERVAL_TO_MINUTES } from '@hyperdx/common-utils/dist/types';
import mongoose, { Schema } from 'mongoose';

import type { ObjectId } from '.';
Expand Down Expand Up @@ -44,6 +45,8 @@ export interface IAlert {
id: string;
channel: AlertChannel;
interval: AlertInterval;
scheduleOffsetMinutes?: number;
scheduleStartAt?: Date | null;
source?: AlertSource;
state: AlertState;
team: ObjectId;
Expand Down Expand Up @@ -88,6 +91,29 @@ const AlertSchema = new Schema<IAlert>(
type: String,
required: true,
},
scheduleOffsetMinutes: {
type: Number,
min: 0,
// Maximum offset for daily windows (24h - 1 minute).
max: 1439,
validate: {
validator: function (this: IAlert, value: number | undefined) {
if (value == null) {
return true;
}

const intervalMinutes = ALERT_INTERVAL_TO_MINUTES[this.interval];
return intervalMinutes == null || value < intervalMinutes;
},
message:
'scheduleOffsetMinutes must be less than the alert interval in minutes',
},
required: false,
},
scheduleStartAt: {
Comment thread
mlsalcedo marked this conversation as resolved.
type: Date,
required: false,
},
channel: Schema.Types.Mixed, // slack, email, etc
state: {
type: String,
Expand Down
241 changes: 240 additions & 1 deletion packages/api/src/routers/api/__tests__/alerts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
makeTile,
randomMongoId,
} from '@/fixtures';
import Alert from '@/models/alert';
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';

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

it('preserves scheduleStartAt when omitted in updates and clears when null', async () => {
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);

const scheduleStartAt = '2024-01-01T00:00:00.000Z';
const createdAlert = await agent
.post('/alerts')
.send({
...makeAlertInput({
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
webhookId: webhook._id.toString(),
}),
scheduleStartAt,
})
.expect(200);

const updatePayload = {
channel: createdAlert.body.data.channel,
interval: createdAlert.body.data.interval,
threshold: 10,
thresholdType: createdAlert.body.data.thresholdType,
source: createdAlert.body.data.source,
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
};

await agent
.put(`/alerts/${createdAlert.body.data._id}`)
.send(updatePayload)
.expect(200);

const alertAfterOmittedScheduleStartAt = await Alert.findById(
createdAlert.body.data._id,
);
expect(
alertAfterOmittedScheduleStartAt?.scheduleStartAt?.toISOString(),
).toBe(scheduleStartAt);

await agent
.put(`/alerts/${createdAlert.body.data._id}`)
.send({
...updatePayload,
scheduleStartAt: null,
})
.expect(200);

const alertAfterNullScheduleStartAt = await Alert.findById(
createdAlert.body.data._id,
);
expect(alertAfterNullScheduleStartAt?.scheduleStartAt).toBeNull();
});

it('preserves scheduleOffsetMinutes when schedule fields are omitted in updates', async () => {
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);

const createdAlert = await agent
.post('/alerts')
.send({
...makeAlertInput({
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
interval: '15m',
webhookId: webhook._id.toString(),
}),
scheduleOffsetMinutes: 2,
})
.expect(200);

await agent
.put(`/alerts/${createdAlert.body.data._id}`)
.send({
channel: createdAlert.body.data.channel,
interval: createdAlert.body.data.interval,
threshold: 10,
thresholdType: createdAlert.body.data.thresholdType,
source: createdAlert.body.data.source,
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
})
.expect(200);

const updatedAlert = await Alert.findById(createdAlert.body.data._id);
expect(updatedAlert?.scheduleOffsetMinutes).toBe(2);
expect(updatedAlert?.scheduleStartAt).toBeUndefined();
});

it('resets scheduleOffsetMinutes to 0 when scheduleStartAt is set without offset', async () => {
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);

const createdAlert = await agent
.post('/alerts')
.send({
...makeAlertInput({
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
webhookId: webhook._id.toString(),
}),
scheduleOffsetMinutes: 2,
})
.expect(200);

expect(createdAlert.body.data.scheduleOffsetMinutes).toBe(2);

const scheduleStartAt = '2024-01-01T00:00:00.000Z';

await agent
.put(`/alerts/${createdAlert.body.data._id}`)
.send({
channel: createdAlert.body.data.channel,
interval: createdAlert.body.data.interval,
threshold: createdAlert.body.data.threshold,
thresholdType: createdAlert.body.data.thresholdType,
source: createdAlert.body.data.source,
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
scheduleStartAt,
})
.expect(200);

const updatedAlert = await Alert.findById(createdAlert.body.data._id);
expect(updatedAlert?.scheduleOffsetMinutes).toBe(0);
expect(updatedAlert?.scheduleStartAt?.toISOString()).toBe(scheduleStartAt);
});

it('resets stale scheduleOffsetMinutes when scheduleStartAt is cleared without offset', async () => {
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);

const staleAlert = await Alert.create({
team: team._id,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '15m',
threshold: 8,
thresholdType: AlertThresholdType.ABOVE,
source: AlertSource.TILE,
dashboard: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
scheduleOffsetMinutes: 2,
scheduleStartAt: new Date('2024-01-01T00:00:00.000Z'),
});

await agent
.put(`/alerts/${staleAlert._id.toString()}`)
.send({
...makeAlertInput({
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
interval: '15m',
webhookId: webhook._id.toString(),
}),
scheduleStartAt: null,
})
.expect(200);

const updatedAlert = await Alert.findById(staleAlert._id);
expect(updatedAlert?.scheduleOffsetMinutes).toBe(0);
expect(updatedAlert?.scheduleStartAt).toBeNull();
});

it('rejects scheduleStartAt values more than 1 year in the future', async () => {
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);

const farFutureScheduleStartAt = new Date(
Date.now() + 366 * 24 * 60 * 60 * 1000,
).toISOString();

await agent
.post('/alerts')
.send({
...makeAlertInput({
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
webhookId: webhook._id.toString(),
}),
scheduleStartAt: farFutureScheduleStartAt,
})
.expect(400);
});

it('rejects scheduleStartAt values older than 10 years in the past', async () => {
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);

const tooOldScheduleStartAt = new Date(
Date.now() - 11 * 365 * 24 * 60 * 60 * 1000,
).toISOString();

await agent
.post('/alerts')
.send({
...makeAlertInput({
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
webhookId: webhook._id.toString(),
}),
scheduleStartAt: tooOldScheduleStartAt,
})
.expect(400);
});

it('rejects scheduleOffsetMinutes when scheduleStartAt is provided', async () => {
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);

await agent
.post('/alerts')
.send({
...makeAlertInput({
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
webhookId: webhook._id.toString(),
}),
scheduleOffsetMinutes: 2,
scheduleStartAt: new Date().toISOString(),
})
.expect(400);
});

it('preserves createdBy field during updates', async () => {
const dashboard = await agent
.post('/dashboards')
Expand Down
Loading
Loading