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>
This commit is contained in:
mlsalcedo 2026-03-05 20:47:38 -05:00 committed by GitHub
parent 32f1189a7d
commit 902b8ebdd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1499 additions and 85 deletions

View file

@ -0,0 +1,7 @@
---
"@hyperdx/api": minor
"@hyperdx/app": minor
"@hyperdx/common-utils": minor
---
feat(alerts): add anchored alert scheduling with `scheduleStartAt` and `scheduleOffsetMinutes`

View file

@ -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"

View file

@ -24,6 +24,8 @@ export type AlertInput = {
source?: AlertSource;
channel: AlertChannel;
interval: AlertInterval;
scheduleOffsetMinutes?: number;
scheduleStartAt?: string | null;
thresholdType: AlertThresholdType;
threshold: number;
@ -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,

View file

@ -1,3 +1,4 @@
import { ALERT_INTERVAL_TO_MINUTES } from '@hyperdx/common-utils/dist/types';
import mongoose, { Schema } from 'mongoose';
import type { ObjectId } from '.';
@ -44,6 +45,8 @@ export interface IAlert {
id: string;
channel: AlertChannel;
interval: AlertInterval;
scheduleOffsetMinutes?: number;
scheduleStartAt?: Date | null;
source?: AlertSource;
state: AlertState;
team: ObjectId;
@ -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: {
type: Date,
required: false,
},
channel: Schema.Types.Mixed, // slack, email, etc
state: {
type: String,

View file

@ -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()];
@ -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')

View file

@ -2,7 +2,7 @@ import express from 'express';
import _ from 'lodash';
import { ObjectId } from 'mongodb';
import { z } from 'zod';
import { validateRequest } from 'zod-express-middleware';
import { processRequest, validateRequest } from 'zod-express-middleware';
import { getRecentAlertHistories } from '@/controllers/alertHistory';
import {
@ -68,6 +68,8 @@ router.get('/', async (req, res, next) => {
..._.pick(alert, [
'_id',
'interval',
'scheduleOffsetMinutes',
'scheduleStartAt',
'threshold',
'thresholdType',
'state',
@ -89,7 +91,7 @@ router.get('/', async (req, res, next) => {
router.post(
'/',
validateRequest({ body: alertSchema }),
processRequest({ body: alertSchema }),
async (req, res, next) => {
const teamId = req.user?.team;
const userId = req.user?._id;
@ -110,7 +112,7 @@ router.post(
router.put(
'/:id',
validateRequest({
processRequest({
body: alertSchema,
params: z.object({
id: objectIdSchema,
@ -124,6 +126,7 @@ router.put(
}
const { id } = req.params;
const alertInput = req.body;
await validateAlertInput(teamId, alertInput);
res.json({
data: await updateAlert(id, teamId, alertInput),
});

View file

@ -286,6 +286,53 @@ describe('External API Alerts', () => {
consoleErrorSpy.mockRestore();
});
it('should reject scheduleOffsetMinutes when scheduleStartAt is provided', async () => {
const dashboard = await createTestDashboard();
const response = await authRequest('post', ALERTS_BASE_URL)
.send({
dashboardId: dashboard._id.toString(),
tileId: dashboard.tiles[0].id,
threshold: 100,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: new ObjectId().toString(),
},
scheduleOffsetMinutes: 2,
scheduleStartAt: new Date().toISOString(),
})
.expect(400);
expect(response.body).toHaveProperty('message');
});
it('should reject scheduleStartAt values more than 1 year in the future', async () => {
const dashboard = await createTestDashboard();
const response = await authRequest('post', ALERTS_BASE_URL)
.send({
dashboardId: dashboard._id.toString(),
tileId: dashboard.tiles[0].id,
threshold: 100,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: new ObjectId().toString(),
},
scheduleStartAt: new Date(
Date.now() + 366 * 24 * 60 * 60 * 1000,
).toISOString(),
})
.expect(400);
expect(response.body).toHaveProperty('message');
});
it('should create multiple alerts for different tiles', async () => {
// Create a dashboard with multiple tiles
const dashboard = await createTestDashboard({ numTiles: 3 });
@ -457,6 +504,31 @@ describe('External API Alerts', () => {
expect(retrievedAlert.interval).toBe('1h');
expect(retrievedAlert.message).toBe('Updated message');
});
it('should reject scheduleOffsetMinutes when scheduleStartAt is provided', async () => {
const { alert } = await createTestAlert({
interval: '1h',
});
const originalAlert = await authRequest(
'get',
`${ALERTS_BASE_URL}/${alert.id}`,
).expect(200);
await authRequest('put', `${ALERTS_BASE_URL}/${alert.id}`)
.send({
threshold: originalAlert.body.data.threshold,
interval: originalAlert.body.data.interval,
thresholdType: originalAlert.body.data.thresholdType,
source: originalAlert.body.data.source,
dashboardId: originalAlert.body.data.dashboardId,
tileId: originalAlert.body.data.tileId,
channel: originalAlert.body.data.channel,
scheduleOffsetMinutes: 2,
scheduleStartAt: new Date().toISOString(),
})
.expect(400);
});
});
describe('Deleting alerts', () => {

View file

@ -10,7 +10,10 @@ import {
updateAlert,
validateAlertInput,
} from '@/controllers/alerts';
import { validateRequestWithEnhancedErrors as validateRequest } from '@/utils/enhancedErrors';
import {
processRequestWithEnhancedErrors as processRequest,
validateRequestWithEnhancedErrors as validateRequest,
} from '@/utils/enhancedErrors';
import { translateAlertDocumentToExternalAlert } from '@/utils/externalApi';
import { alertSchema, objectIdSchema } from '@/utils/zod';
@ -106,6 +109,18 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
* interval:
* $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"
@ -395,7 +410,7 @@ router.get('/', async (req, res, next) => {
*/
router.post(
'/',
validateRequest({
processRequest({
body: alertSchema,
}),
async (req, res, next) => {
@ -405,7 +420,7 @@ router.post(
return res.sendStatus(403);
}
try {
const alertInput = alertSchema.parse(req.body);
const alertInput = req.body;
await validateAlertInput(teamId, alertInput);
const createdAlert = await createAlert(teamId, alertInput, userId);
@ -484,7 +499,7 @@ router.post(
*/
router.put(
'/:id',
validateRequest({
processRequest({
body: alertSchema,
params: z.object({
id: objectIdSchema,
@ -499,7 +514,7 @@ router.put(
}
const { id } = req.params;
const alertInput = alertSchema.parse(req.body);
const alertInput = req.body;
await validateAlertInput(teamId, alertInput);
const alert = await updateAlert(id, teamId, alertInput);

View file

@ -33,6 +33,7 @@ import {
alertHasGroupBy,
doesExceedThreshold,
getPreviousAlertHistories,
getScheduledWindowStart,
processAlert,
} from '@/tasks/checkAlerts';
import {
@ -123,6 +124,45 @@ describe('checkAlerts', () => {
});
});
describe('getScheduledWindowStart', () => {
it('should align to the default interval boundary when offset is 0', () => {
const now = new Date('2024-01-01T12:13:45.000Z');
const windowStart = getScheduledWindowStart(now, 5, 0);
expect(windowStart).toEqual(new Date('2024-01-01T12:10:00.000Z'));
});
it('should align to an offset boundary when schedule offset is provided', () => {
const now = new Date('2024-01-01T12:13:45.000Z');
const windowStart = getScheduledWindowStart(now, 5, 2);
expect(windowStart).toEqual(new Date('2024-01-01T12:12:00.000Z'));
});
it('should keep previous offset window until the next offset boundary', () => {
const now = new Date('2024-01-01T12:11:59.000Z');
const windowStart = getScheduledWindowStart(now, 5, 2);
expect(windowStart).toEqual(new Date('2024-01-01T12:07:00.000Z'));
});
it('should align windows using scheduleStartAt as an absolute anchor', () => {
const now = new Date('2024-01-01T12:13:45.000Z');
const scheduleStartAt = new Date('2024-01-01T12:02:30.000Z');
const windowStart = getScheduledWindowStart(now, 5, 0, scheduleStartAt);
expect(windowStart).toEqual(new Date('2024-01-01T12:12:30.000Z'));
});
it('should prioritize scheduleStartAt over offset alignment', () => {
const now = new Date('2024-01-01T12:13:45.000Z');
const scheduleStartAt = new Date('2024-01-01T12:02:30.000Z');
const windowStart = getScheduledWindowStart(now, 5, 2, scheduleStartAt);
expect(windowStart).toEqual(new Date('2024-01-01T12:12:30.000Z'));
});
});
describe('alertHasGroupBy', () => {
const makeDetails = (
overrides: Partial<{
@ -1152,6 +1192,116 @@ describe('checkAlerts', () => {
);
};
it('should skip processing before scheduleStartAt', async () => {
const {
team,
webhook,
connection,
source,
savedSearch,
teamWebhooksById,
clickhouseClient,
} = await setupSavedSearchAlertTest();
const details = await createAlertDetails(
team,
source,
{
source: AlertSource.SAVED_SEARCH,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
savedSearchId: savedSearch.id,
scheduleStartAt: '2023-11-16T22:15:00.000Z',
},
{
taskType: AlertTaskType.SAVED_SEARCH,
savedSearch,
},
);
const querySpy = jest.spyOn(clickhouseClient, 'queryChartConfig');
await processAlertAtTime(
new Date('2023-11-16T22:12:00.000Z'),
details,
clickhouseClient,
connection.id,
alertProvider,
teamWebhooksById,
);
expect(querySpy).not.toHaveBeenCalled();
expect(
await AlertHistory.countDocuments({ alert: details.alert.id }),
).toBe(0);
});
it('should skip processing until the first anchored window fully elapses', async () => {
const {
team,
webhook,
connection,
source,
savedSearch,
teamWebhooksById,
clickhouseClient,
} = await setupSavedSearchAlertTest();
const details = await createAlertDetails(
team,
source,
{
source: AlertSource.SAVED_SEARCH,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
savedSearchId: savedSearch.id,
scheduleStartAt: '2023-11-16T22:13:30.000Z',
},
{
taskType: AlertTaskType.SAVED_SEARCH,
savedSearch,
},
);
const querySpy = jest.spyOn(clickhouseClient, 'queryChartConfig');
await processAlertAtTime(
new Date('2023-11-16T22:13:45.000Z'),
details,
clickhouseClient,
connection.id,
alertProvider,
teamWebhooksById,
);
expect(querySpy).not.toHaveBeenCalled();
expect(
await AlertHistory.countDocuments({ alert: details.alert.id }),
).toBe(0);
await processAlertAtTime(
new Date('2023-11-16T22:18:31.000Z'),
details,
clickhouseClient,
connection.id,
alertProvider,
teamWebhooksById,
);
expect(querySpy).toHaveBeenCalledTimes(1);
expect(
await AlertHistory.countDocuments({ alert: details.alert.id }),
).toBe(1);
});
it('SAVED_SEARCH alert - slack webhook', async () => {
const {
team,

View file

@ -121,6 +121,90 @@ export const doesExceedThreshold = (
return false;
};
const normalizeScheduleOffsetMinutes = ({
alertId,
scheduleOffsetMinutes,
windowSizeInMins,
}: {
alertId: string;
scheduleOffsetMinutes: number | undefined;
windowSizeInMins: number;
}) => {
if (scheduleOffsetMinutes == null) {
return 0;
}
if (!Number.isFinite(scheduleOffsetMinutes)) {
return 0;
}
const normalized = Math.max(0, Math.floor(scheduleOffsetMinutes));
if (normalized < windowSizeInMins) {
return normalized;
}
const scheduleOffsetInMins = normalized % windowSizeInMins;
logger.warn(
{
alertId,
scheduleOffsetMinutes,
normalizedScheduleOffsetMinutes: scheduleOffsetInMins,
windowSizeInMins,
},
'scheduleOffsetMinutes is greater than or equal to the interval and was normalized',
);
return scheduleOffsetInMins;
};
const normalizeScheduleStartAt = ({
alertId,
scheduleStartAt,
}: {
alertId: string;
scheduleStartAt: IAlert['scheduleStartAt'];
}) => {
if (scheduleStartAt == null) {
return undefined;
}
if (fns.isValid(scheduleStartAt)) {
return scheduleStartAt;
}
logger.warn(
{
alertId,
scheduleStartAt,
},
'Invalid scheduleStartAt value detected, ignoring start time schedule',
);
return undefined;
};
export const getScheduledWindowStart = (
now: Date,
windowSizeInMins: number,
scheduleOffsetMinutes = 0,
scheduleStartAt?: Date,
) => {
if (scheduleStartAt != null) {
const windowSizeMs = windowSizeInMins * 60 * 1000;
const elapsedMs = Math.max(0, now.getTime() - scheduleStartAt.getTime());
const windowCountSinceStart = Math.floor(elapsedMs / windowSizeMs);
return new Date(
scheduleStartAt.getTime() + windowCountSinceStart * windowSizeMs,
);
}
if (scheduleOffsetMinutes <= 0) {
return roundDownToXMinutes(windowSizeInMins)(now);
}
const shiftedNow = fns.subMinutes(now, scheduleOffsetMinutes);
const roundedShiftedNow = roundDownToXMinutes(windowSizeInMins)(shiftedNow);
return fns.addMinutes(roundedShiftedNow, scheduleOffsetMinutes);
};
const fireChannelEvent = async ({
alert,
alertProvider,
@ -185,6 +269,12 @@ const fireChannelEvent = async ({
dashboardId: dashboard?.id,
groupBy: alert.groupBy,
interval: alert.interval,
...(alert.scheduleOffsetMinutes != null && {
scheduleOffsetMinutes: alert.scheduleOffsetMinutes,
}),
...(alert.scheduleStartAt != null && {
scheduleStartAt: alert.scheduleStartAt.toISOString(),
}),
message: alert.message,
name: alert.name,
savedSearchId: savedSearch?.id,
@ -287,6 +377,7 @@ const getAlertEvaluationDateRange = (
hasGroupBy: boolean,
nowInMinsRoundDown: Date,
windowSizeInMins: number,
scheduleStartAt?: Date,
) => {
// Calculate date range for the query
// Find the latest createdAt among all histories for this alert
@ -308,10 +399,16 @@ const getAlertEvaluationDateRange = (
previousCreatedAt = previous?.createdAt;
}
const rawStartTime = previousCreatedAt
? previousCreatedAt.getTime()
: fns.subMinutes(nowInMinsRoundDown, windowSizeInMins).getTime();
const clampedStartTime =
scheduleStartAt == null
? rawStartTime
: Math.max(rawStartTime, scheduleStartAt.getTime());
return calcAlertDateRange(
previousCreatedAt
? previousCreatedAt.getTime()
: fns.subMinutes(nowInMinsRoundDown, windowSizeInMins).getTime(),
clampedStartTime,
nowInMinsRoundDown.getTime(),
windowSizeInMins,
);
@ -454,7 +551,43 @@ export const processAlert = async (
const { alert, source, previousMap } = details;
try {
const windowSizeInMins = ms(alert.interval) / 60000;
const nowInMinsRoundDown = roundDownToXMinutes(windowSizeInMins)(now);
const scheduleStartAt = normalizeScheduleStartAt({
alertId: alert.id,
scheduleStartAt: alert.scheduleStartAt,
});
if (scheduleStartAt != null && now < scheduleStartAt) {
logger.info(
{
alertId: alert.id,
now,
scheduleStartAt,
},
'Skipped alert check because scheduleStartAt is in the future',
);
return;
}
const scheduleOffsetMinutes = normalizeScheduleOffsetMinutes({
alertId: alert.id,
scheduleOffsetMinutes: alert.scheduleOffsetMinutes,
windowSizeInMins,
});
if (scheduleStartAt != null && scheduleOffsetMinutes > 0) {
logger.info(
{
alertId: alert.id,
scheduleStartAt,
scheduleOffsetMinutes,
},
'scheduleStartAt is set; scheduleOffsetMinutes is ignored for window alignment',
);
}
const nowInMinsRoundDown = getScheduledWindowStart(
now,
windowSizeInMins,
scheduleOffsetMinutes,
scheduleStartAt,
);
const hasGroupBy = alertHasGroupBy(details);
// Check if we should skip this alert check based on last evaluation time
@ -466,6 +599,8 @@ export const processAlert = async (
now,
alertId: alert.id,
hasGroupBy,
scheduleOffsetMinutes,
scheduleStartAt,
},
`Skipped to check alert since the time diff is still less than 1 window size`,
);
@ -477,7 +612,20 @@ export const processAlert = async (
hasGroupBy,
nowInMinsRoundDown,
windowSizeInMins,
scheduleStartAt,
);
if (dateRange[0].getTime() >= dateRange[1].getTime()) {
logger.info(
{
alertId: alert.id,
dateRange,
nowInMinsRoundDown,
scheduleStartAt,
},
'Skipped alert check because the anchored window has not fully elapsed yet',
);
return;
}
const chartConfig = getChartConfigFromAlert(
details,

View file

@ -0,0 +1,48 @@
import { Types } from 'mongoose';
import {
type AlertDocument,
AlertSource,
AlertState,
AlertThresholdType,
} from '@/models/alert';
import { translateAlertDocumentToExternalAlert } from '@/utils/externalApi';
const createAlertDocument = (
overrides: Partial<Record<string, unknown>> = {},
): AlertDocument =>
({
_id: new Types.ObjectId(),
team: new Types.ObjectId(),
threshold: 5,
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
source: AlertSource.SAVED_SEARCH,
state: AlertState.OK,
channel: { type: null },
...overrides,
}) as unknown as AlertDocument;
describe('utils/externalApi', () => {
describe('translateAlertDocumentToExternalAlert', () => {
it('returns scheduleStartAt as null when explicitly cleared', () => {
const alert = createAlertDocument({
scheduleStartAt: null,
});
const translated = translateAlertDocumentToExternalAlert(alert);
expect(translated.scheduleStartAt).toBeNull();
});
it('returns scheduleStartAt as undefined when the value is missing', () => {
const alert = createAlertDocument({
scheduleStartAt: undefined,
});
const translated = translateAlertDocumentToExternalAlert(alert);
expect(translated.scheduleStartAt).toBeUndefined();
});
});
});

View file

@ -61,3 +61,55 @@ export function validateRequestWithEnhancedErrors(schemas: {
next();
};
}
/**
* Custom validation middleware that validates and assigns parsed request data.
* This preserves Zod transforms/refinements and strips unknown fields while
* keeping the same concatenated external API error format.
*/
export function processRequestWithEnhancedErrors(schemas: {
body?: z.ZodSchema;
params?: z.ZodSchema;
query?: z.ZodSchema;
}) {
return (req: Request, res: Response, next: NextFunction) => {
const errors: string[] = [];
if (schemas.body) {
const result = schemas.body.safeParse(req.body);
if (!result.success) {
errors.push(`Body validation failed: ${formatZodError(result.error)}`);
} else {
req.body = result.data;
}
}
if (schemas.params) {
const result = schemas.params.safeParse(req.params);
if (!result.success) {
errors.push(
`Params validation failed: ${formatZodError(result.error)}`,
);
} else {
req.params = result.data;
}
}
if (schemas.query) {
const result = schemas.query.safeParse(req.query);
if (!result.success) {
errors.push(`Query validation failed: ${formatZodError(result.error)}`);
} else {
req.query = result.data;
}
}
if (errors.length > 0) {
return res.status(400).json({
message: errors.join('; '),
});
}
next();
};
}

View file

@ -229,6 +229,8 @@ export type ExternalAlert = {
message?: string | null;
threshold: number;
interval: AlertInterval;
scheduleOffsetMinutes?: number;
scheduleStartAt?: string | null;
thresholdType: AlertThresholdType;
source?: string;
state: AlertState;
@ -263,6 +265,24 @@ function hasUpdatedAt(
return 'updatedAt' in alert && alert.updatedAt instanceof Date;
}
function transformScheduleStartAt(
scheduleStartAt: unknown,
): ExternalAlert['scheduleStartAt'] {
if (scheduleStartAt === null) {
return null;
}
if (scheduleStartAt === undefined) {
return undefined;
}
if (scheduleStartAt instanceof Date) {
return scheduleStartAt.toISOString();
}
return typeof scheduleStartAt === 'string' ? scheduleStartAt : undefined;
}
function transformSilencedToExternalSilenced(
silenced: AlertDocumentObject['silenced'],
): ExternalAlert['silenced'] {
@ -290,6 +310,10 @@ export function translateAlertDocumentToExternalAlert(
message: alertObj.message,
threshold: alertObj.threshold,
interval: alertObj.interval,
...(alertObj.scheduleOffsetMinutes != null && {
scheduleOffsetMinutes: alertObj.scheduleOffsetMinutes,
}),
scheduleStartAt: transformScheduleStartAt(alertObj.scheduleStartAt),
thresholdType: alertObj.thresholdType,
source: alertObj.source,
state: alertObj.state,

View file

@ -3,7 +3,9 @@ import {
DashboardFilterSchema,
MetricsDataType,
NumberFormatSchema,
scheduleStartAtSchema,
SearchConditionLanguageSchema as whereLanguageSchema,
validateAlertScheduleOffsetMinutes,
WebhookService,
} from '@hyperdx/common-utils/dist/types';
import { Types } from 'mongoose';
@ -408,13 +410,16 @@ export const alertSchema = z
.object({
channel: zChannel,
interval: z.enum(['1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d']),
scheduleOffsetMinutes: z.number().int().min(0).max(1439).optional(),
scheduleStartAt: scheduleStartAtSchema,
threshold: z.number().min(0),
thresholdType: z.nativeEnum(AlertThresholdType),
source: z.nativeEnum(AlertSource).default(AlertSource.SAVED_SEARCH),
name: z.string().min(1).max(512).nullish(),
message: z.string().min(1).max(4096).nullish(),
})
.and(zSavedSearchAlert.or(zTileAlert));
.and(zSavedSearchAlert.or(zTileAlert))
.superRefine(validateAlertScheduleOffsetMinutes);
// ==============================
// Webhooks

View file

@ -10,8 +10,10 @@ import {
AlertIntervalSchema,
AlertSource,
AlertThresholdType,
scheduleStartAtSchema,
SearchCondition,
SearchConditionLanguage,
validateAlertScheduleOffsetMinutes,
zAlertChannel,
} from '@hyperdx/common-utils/dist/types';
import { Alert as MantineAlert, TextInput } from '@mantine/core';
@ -29,7 +31,6 @@ import {
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconBrandSlack,
IconChartLine,
IconInfoCircleFilled,
IconPlus,
@ -44,10 +45,13 @@ import {
ALERT_CHANNEL_OPTIONS,
ALERT_INTERVAL_OPTIONS,
ALERT_THRESHOLD_TYPE_OPTIONS,
intervalToMinutes,
normalizeNoOpAlertScheduleFields,
} from '@/utils/alerts';
import { AlertPreviewChart } from './components/AlertPreviewChart';
import { AlertChannelForm } from './components/Alerts';
import { AlertScheduleFields } from './components/AlertScheduleFields';
import { getStoredLanguage } from './components/SearchInput/SearchWhereInput';
import { SQLInlineEditorControlled } from './components/SearchInput/SQLInlineEditor';
import { getWebhookChannelIcon } from './utils/webhookIcons';
@ -59,10 +63,13 @@ const SavedSearchAlertFormSchema = z
.object({
interval: AlertIntervalSchema,
threshold: z.number().int().min(1),
scheduleOffsetMinutes: z.number().int().min(0).default(0),
scheduleStartAt: scheduleStartAtSchema,
thresholdType: z.nativeEnum(AlertThresholdType),
channel: zAlertChannel,
})
.passthrough();
.passthrough()
.superRefine(validateAlertScheduleOffsetMinutes);
const AlertForm = ({
sourceId,
@ -91,17 +98,30 @@ const AlertForm = ({
}) => {
const { data: source } = useSource({ id: sourceId });
const { control, handleSubmit } = useForm<Alert>({
defaultValues: defaultValues || {
interval: '5m',
threshold: 1,
thresholdType: AlertThresholdType.ABOVE,
source: AlertSource.SAVED_SEARCH,
channel: {
type: 'webhook',
webhookId: '',
},
},
const {
control,
handleSubmit,
setValue,
formState: { dirtyFields },
} = useForm<Alert>({
defaultValues: defaultValues
? {
...defaultValues,
scheduleOffsetMinutes: defaultValues.scheduleOffsetMinutes ?? 0,
scheduleStartAt: defaultValues.scheduleStartAt ?? null,
}
: {
interval: '5m',
threshold: 1,
scheduleOffsetMinutes: 0,
scheduleStartAt: null,
thresholdType: AlertThresholdType.ABOVE,
source: AlertSource.SAVED_SEARCH,
channel: {
type: 'webhook',
webhookId: '',
},
},
resolver: zodResolver(SavedSearchAlertFormSchema),
});
@ -109,11 +129,31 @@ const AlertForm = ({
const thresholdType = useWatch({ control, name: 'thresholdType' });
const channelType = useWatch({ control, name: 'channel.type' });
const interval = useWatch({ control, name: 'interval' });
const scheduleOffsetMinutes = useWatch({
control,
name: 'scheduleOffsetMinutes',
});
const groupByValue = useWatch({ control, name: 'groupBy' });
const threshold = useWatch({ control, name: 'threshold' });
const maxScheduleOffsetMinutes = Math.max(
intervalToMinutes(interval ?? '5m') - 1,
0,
);
const intervalLabel = ALERT_INTERVAL_OPTIONS[interval ?? '5m'];
return (
<form onSubmit={handleSubmit(onSubmit)}>
<form
onSubmit={handleSubmit(data =>
onSubmit(
normalizeNoOpAlertScheduleFields(data, defaultValues, {
preserveExplicitScheduleOffsetMinutes:
dirtyFields.scheduleOffsetMinutes === true,
preserveExplicitScheduleStartAt:
dirtyFields.scheduleStartAt === true,
}),
),
)}
>
<Stack gap="xs">
<Paper px="md" py="sm" radius="xs">
<Text size="xxs" opacity={0.5}>
@ -155,6 +195,15 @@ const AlertForm = ({
control={control}
/>
</Group>
<AlertScheduleFields
control={control}
setValue={setValue}
scheduleOffsetName="scheduleOffsetMinutes"
scheduleStartAtName="scheduleStartAt"
scheduleOffsetMinutes={scheduleOffsetMinutes}
maxScheduleOffsetMinutes={maxScheduleOffsetMinutes}
offsetWindowLabel={`from each ${intervalLabel} window`}
/>
<Text size="xxs" opacity={0.5} mb={4} mt="xs">
grouped by
</Text>

View file

@ -0,0 +1,191 @@
import { useEffect, useState } from 'react';
import {
Control,
Controller,
FieldPath,
FieldValues,
PathValue,
UseFormSetValue,
useWatch,
} from 'react-hook-form';
import { NumberInput } from 'react-hook-form-mantine';
import {
Box,
Collapse,
Group,
Text,
Tooltip,
UnstyledButton,
} from '@mantine/core';
import { DateTimePicker } from '@mantine/dates';
import {
IconChevronDown,
IconChevronRight,
IconInfoCircle,
} from '@tabler/icons-react';
import { parseScheduleStartAtValue } from '@/utils/alerts';
const DATE_TIME_INPUT_FORMAT = 'YYYY-MM-DD HH:mm:ss';
type AlertScheduleFieldsProps<T extends FieldValues> = {
control: Control<T>;
setValue: UseFormSetValue<T>;
scheduleOffsetName: FieldPath<T>;
scheduleStartAtName: FieldPath<T>;
scheduleOffsetMinutes: number | null | undefined;
maxScheduleOffsetMinutes: number;
offsetWindowLabel: string;
};
export function AlertScheduleFields<T extends FieldValues>({
control,
setValue,
scheduleOffsetName,
scheduleStartAtName,
scheduleOffsetMinutes,
maxScheduleOffsetMinutes,
offsetWindowLabel,
}: AlertScheduleFieldsProps<T>) {
const showScheduleOffsetInput = maxScheduleOffsetMinutes > 0;
const scheduleStartAtValue = useWatch({
control,
name: scheduleStartAtName,
}) as string | null | undefined;
const hasScheduleStartAtAnchor = scheduleStartAtValue != null;
const hasAdvancedScheduleValues =
(scheduleOffsetMinutes ?? 0) > 0 || hasScheduleStartAtAnchor;
const [opened, setOpened] = useState(hasAdvancedScheduleValues);
useEffect(() => {
const normalizedOffset = scheduleOffsetMinutes ?? 0;
if (!showScheduleOffsetInput && normalizedOffset !== 0) {
setValue(scheduleOffsetName, 0 as PathValue<T, FieldPath<T>>, {
shouldValidate: true,
});
return;
}
if (hasScheduleStartAtAnchor && normalizedOffset > 0) {
setValue(scheduleOffsetName, 0 as PathValue<T, FieldPath<T>>, {
shouldValidate: true,
});
}
}, [
hasScheduleStartAtAnchor,
scheduleOffsetMinutes,
scheduleOffsetName,
setValue,
showScheduleOffsetInput,
]);
return (
<>
<UnstyledButton
onClick={() => setOpened(current => !current)}
mt="xs"
data-testid="alert-advanced-settings-toggle"
>
<Group gap={4}>
{opened ? (
<IconChevronDown size={14} opacity={0.5} />
) : (
<IconChevronRight size={14} opacity={0.5} />
)}
<Text size="xs" c="dimmed">
Advanced Settings
</Text>
</Group>
</UnstyledButton>
<Collapse in={opened}>
<Box data-testid="alert-advanced-settings-panel">
<Text size="xs" c="dimmed" mt="xs">
Optional schedule controls for aligning alert windows.
</Text>
{showScheduleOffsetInput && (
<>
<Group gap="xs" mt="xs">
<Group gap={4}>
<Text size="sm" opacity={0.7}>
Start offset (min)
</Text>
<Tooltip
label="Delays the start of each evaluation window by this many minutes. Useful when data is ingested with a lag."
multiline
w={260}
withArrow
zIndex={10050}
>
<Box style={{ lineHeight: 1, cursor: 'help' }}>
<IconInfoCircle size={14} opacity={0.4} />
</Box>
</Tooltip>
</Group>
<NumberInput
min={0}
max={maxScheduleOffsetMinutes}
step={1}
size="xs"
w={100}
control={control}
name={scheduleOffsetName}
disabled={hasScheduleStartAtAnchor}
/>
<Text size="sm" opacity={0.7}>
{offsetWindowLabel}
</Text>
</Group>
{hasScheduleStartAtAnchor && (
<Text size="xs" opacity={0.6} mt={4}>
Start offset is ignored while an anchor start time is set.
</Text>
)}
</>
)}
<Group gap="xs" mt="xs" align="start">
<Group gap={4} mt={6}>
<Text size="sm" opacity={0.7}>
Anchor start time
</Text>
<Tooltip
label="Pins alert windows to a fixed starting point instead of the default rolling schedule. Windows repeat at the configured interval from this time."
multiline
w={260}
withArrow
zIndex={10050}
>
<Box style={{ lineHeight: 1, cursor: 'help' }}>
<IconInfoCircle size={14} opacity={0.4} />
</Box>
</Tooltip>
</Group>
<Controller
control={control}
name={scheduleStartAtName}
render={({ field, fieldState: { error } }) => (
<DateTimePicker
size="xs"
valueFormat={DATE_TIME_INPUT_FORMAT}
w={260}
placeholder={DATE_TIME_INPUT_FORMAT}
clearable
dropdownType="popover"
popoverProps={{ withinPortal: true, zIndex: 10050 }}
value={parseScheduleStartAtValue(
field.value as string | null | undefined,
)}
onChange={value =>
field.onChange(value?.toISOString() ?? null)
}
error={error?.message}
/>
)}
/>
<Text size="xs" opacity={0.6} mt={6}>
Displayed in local time, stored as UTC
</Text>
</Group>
</Box>
</Collapse>
</>
);
}

View file

@ -3,6 +3,8 @@ import {
RawSqlSavedChartConfig,
} from '@hyperdx/common-utils/dist/types';
import { AlertWithCreatedBy } from '@/types';
export type SavedChartConfigWithSelectArray = Omit<
BuilderSavedChartConfig,
'select'
@ -24,6 +26,9 @@ export type SavedChartConfigWithSelectArray = Omit<
**/
export type ChartEditorFormState = Partial<BuilderSavedChartConfig> &
Partial<Omit<RawSqlSavedChartConfig, 'configType'>> & {
alert?: BuilderSavedChartConfig['alert'] & {
createdBy?: AlertWithCreatedBy['createdBy'];
};
series: SavedChartConfigWithSelectArray['select'];
configType?: 'sql' | 'builder';
};

View file

@ -31,6 +31,7 @@ import {
SelectList,
SourceKind,
TSource,
validateAlertScheduleOffsetMinutes,
} from '@hyperdx/common-utils/dist/types';
import {
Accordion,
@ -107,6 +108,8 @@ import {
DEFAULT_TILE_ALERT,
extendDateRangeToInterval,
intervalToGranularity,
intervalToMinutes,
normalizeNoOpAlertScheduleFields,
TILE_ALERT_INTERVAL_OPTIONS,
TILE_ALERT_THRESHOLD_TYPE_OPTIONS,
} from '@/utils/alerts';
@ -126,6 +129,7 @@ import {
import { ErrorBoundary } from './Error/ErrorBoundary';
import MVOptimizationIndicator from './MaterializedViews/MVOptimizationIndicator';
import { AggFnSelectControlled } from './AggFnSelect';
import { AlertScheduleFields } from './AlertScheduleFields';
import ChartDisplaySettingsDrawer, {
ChartConfigDisplaySettings,
} from './ChartDisplaySettingsDrawer';
@ -385,7 +389,7 @@ function ChartSeriesEditorComponent({
<AggFnSelectControlled
aggFnName={`${namePrefix}aggFn`}
quantileLevelName={`${namePrefix}level`}
defaultValue={AGG_FNS[0].value}
defaultValue={AGG_FNS[0]?.value ?? 'avg'}
control={control}
/>
</div>
@ -533,7 +537,9 @@ const ChartSeriesEditor = ChartSeriesEditorComponent;
const zSavedChartConfig = z
.object({
// TODO: Chart
alert: ChartAlertBaseSchema.optional(),
alert: ChartAlertBaseSchema.superRefine(
validateAlertScheduleOffsetMinutes,
).optional(),
})
.passthrough();
@ -580,7 +586,7 @@ export default function EditTimeChartForm({
register,
setError,
clearErrors,
formState: { errors, isDirty },
formState: { errors, isDirty, dirtyFields },
} = useForm<ChartEditorFormState>({
defaultValues: formValue,
values: formValue,
@ -612,9 +618,23 @@ export default function EditTimeChartForm({
useWatch({ control, name: 'displayType' }) ?? DisplayType.Line;
const markdown = useWatch({ control, name: 'markdown' });
const alertChannelType = useWatch({ control, name: 'alert.channel.type' });
const alertScheduleOffsetMinutes = useWatch({
control,
name: 'alert.scheduleOffsetMinutes',
});
const granularity = useWatch({ control, name: 'granularity' });
const maxAlertScheduleOffsetMinutes = alert?.interval
? Math.max(intervalToMinutes(alert.interval) - 1, 0)
: 0;
const alertIntervalLabel = alert?.interval
? TILE_ALERT_INTERVAL_OPTIONS[alert.interval]
: undefined;
const configType = useWatch({ control, name: 'configType' });
const chartConfigAlert = !isRawSqlSavedChartConfig(chartConfig)
? chartConfig.alert
: undefined;
const isRawSqlInput =
configType === 'sql' && displayType === DisplayType.Table;
@ -747,7 +767,22 @@ export default function EditTimeChartForm({
);
if (savedConfig && queriedConfig) {
setChartConfig?.(savedConfig);
const normalizedSavedConfig = isRawSqlSavedChartConfig(savedConfig)
? savedConfig
: {
...savedConfig,
alert: normalizeNoOpAlertScheduleFields(
savedConfig.alert,
chartConfigAlert,
{
preserveExplicitScheduleOffsetMinutes:
dirtyFields.alert?.scheduleOffsetMinutes === true,
preserveExplicitScheduleStartAt:
dirtyFields.alert?.scheduleStartAt === true,
},
),
};
setChartConfig?.(normalizedSavedConfig);
setQueriedConfigAndSource(
queriedConfig,
isRawSqlChart ? undefined : tableSource,
@ -755,6 +790,9 @@ export default function EditTimeChartForm({
}
})();
}, [
chartConfigAlert,
dirtyFields.alert?.scheduleOffsetMinutes,
dirtyFields.alert?.scheduleStartAt,
handleSubmit,
setChartConfig,
setQueriedConfigAndSource,
@ -806,9 +844,34 @@ export default function EditTimeChartForm({
tableSource,
);
if (savedChartConfig) onSave?.(savedChartConfig);
if (savedChartConfig) {
const normalizedSavedConfig = isRawSqlSavedChartConfig(savedChartConfig)
? savedChartConfig
: {
...savedChartConfig,
alert: normalizeNoOpAlertScheduleFields(
savedChartConfig.alert,
chartConfigAlert,
{
preserveExplicitScheduleOffsetMinutes:
dirtyFields.alert?.scheduleOffsetMinutes === true,
preserveExplicitScheduleStartAt:
dirtyFields.alert?.scheduleStartAt === true,
},
),
};
onSave?.(normalizedSavedConfig);
}
},
[onSave, tableSource, setError],
[
onSave,
tableSource,
setError,
chartConfigAlert,
dirtyFields.alert?.scheduleOffsetMinutes,
dirtyFields.alert?.scheduleStartAt,
],
);
// Track previous values for detecting changes
@ -1317,54 +1380,68 @@ export default function EditTimeChartForm({
)}
{alert && (
<Paper my="sm">
<Stack gap="xs">
<Paper px="md" py="sm" radius="xs" data-testid="alert-details">
<Group gap="xs" justify="space-between">
<Group gap="xs">
<Text size="sm" opacity={0.7}>
Alert when the value
</Text>
<NativeSelect
data={optionsToSelectData(
TILE_ALERT_THRESHOLD_TYPE_OPTIONS,
)}
size="xs"
name={`alert.thresholdType`}
control={control}
/>
<NumberInput
min={MINIMUM_THRESHOLD_VALUE}
size="xs"
w={80}
control={control}
name={`alert.threshold`}
/>
over
<NativeSelect
data={optionsToSelectData(TILE_ALERT_INTERVAL_OPTIONS)}
size="xs"
name={`alert.interval`}
control={control}
/>
<Text size="sm" opacity={0.7}>
window via
</Text>
<NativeSelect
data={optionsToSelectData(ALERT_CHANNEL_OPTIONS)}
size="xs"
name={`alert.channel.type`}
control={control}
/>
</Group>
{(alert as any)?.createdBy && (
<Text size="xs" opacity={0.6}>
Created by{' '}
{(alert as any).createdBy?.name ||
(alert as any).createdBy?.email}
</Text>
)}
<Stack gap="xs" data-testid="alert-details">
<Paper px="md" py="sm" radius="xs">
<Text size="xxs" opacity={0.5} mb={4}>
Trigger
</Text>
<Group gap="xs">
<Text size="sm" opacity={0.7}>
Alert when the value
</Text>
<NativeSelect
data={optionsToSelectData(
TILE_ALERT_THRESHOLD_TYPE_OPTIONS,
)}
size="xs"
name={`alert.thresholdType`}
control={control}
/>
<NumberInput
min={MINIMUM_THRESHOLD_VALUE}
size="xs"
w={80}
control={control}
name={`alert.threshold`}
/>
over
<NativeSelect
data={optionsToSelectData(TILE_ALERT_INTERVAL_OPTIONS)}
size="xs"
name={`alert.interval`}
control={control}
/>
<Text size="sm" opacity={0.7}>
window via
</Text>
<NativeSelect
data={optionsToSelectData(ALERT_CHANNEL_OPTIONS)}
size="xs"
name={`alert.channel.type`}
control={control}
/>
</Group>
<Text size="xxs" opacity={0.5} mb={4} mt="xs">
{alert?.createdBy && (
<Text size="xs" opacity={0.6} mt="xs">
Created by {alert.createdBy.name || alert.createdBy.email}
</Text>
)}
<AlertScheduleFields
control={control}
setValue={setValue}
scheduleOffsetName="alert.scheduleOffsetMinutes"
scheduleStartAtName="alert.scheduleStartAt"
scheduleOffsetMinutes={alertScheduleOffsetMinutes}
maxScheduleOffsetMinutes={maxAlertScheduleOffsetMinutes}
offsetWindowLabel={
alertIntervalLabel
? `from each ${alertIntervalLabel} window`
: 'from each alert window'
}
/>
</Paper>
<Paper px="md" py="sm" radius="xs">
<Text size="xxs" opacity={0.5} mb={4}>
Send to
</Text>
<AlertChannelForm

View file

@ -473,4 +473,22 @@ describe('DBEditTimeChartForm - Add/delete alerts for display type Number', () =
// Verify that onSave was not called
expect(onSave).not.toHaveBeenCalled();
});
it('shows alert scheduling fields inside advanced settings', async () => {
renderComponent();
await userEvent.click(screen.getByTestId('alert-button'));
expect(
screen.getByTestId('alert-advanced-settings-panel'),
).not.toBeVisible();
await userEvent.click(screen.getByTestId('alert-advanced-settings-toggle'));
expect(screen.getByTestId('alert-advanced-settings-panel')).toBeVisible();
expect(screen.getByText('Anchor start time')).toBeInTheDocument();
expect(
screen.getByTestId('alert-advanced-settings-toggle'),
).toHaveTextContent('Advanced Settings');
});
});

View file

@ -0,0 +1,95 @@
import { normalizeNoOpAlertScheduleFields } from '../alerts';
describe('normalizeNoOpAlertScheduleFields', () => {
it('drops no-op schedule fields for pre-migration alerts', () => {
const normalized = normalizeNoOpAlertScheduleFields(
{
scheduleOffsetMinutes: 0,
scheduleStartAt: null,
},
{},
);
expect(normalized).toEqual({});
});
it('treats undefined previous values as absent fields', () => {
const normalized = normalizeNoOpAlertScheduleFields(
{
scheduleOffsetMinutes: 0,
scheduleStartAt: null,
},
{
scheduleOffsetMinutes: undefined,
scheduleStartAt: undefined,
},
);
expect(normalized).toEqual({});
});
it('keeps no-op fields when they were already persisted', () => {
const normalized = normalizeNoOpAlertScheduleFields(
{
scheduleOffsetMinutes: 0,
scheduleStartAt: null,
},
{
scheduleOffsetMinutes: 0,
scheduleStartAt: null,
},
);
expect(normalized).toEqual({
scheduleOffsetMinutes: 0,
scheduleStartAt: null,
});
});
it('keeps non-default schedule fields', () => {
const normalized = normalizeNoOpAlertScheduleFields(
{
scheduleOffsetMinutes: 3,
scheduleStartAt: '2024-01-01T00:00:00.000Z',
},
{},
);
expect(normalized).toEqual({
scheduleOffsetMinutes: 3,
scheduleStartAt: '2024-01-01T00:00:00.000Z',
});
});
it('keeps an explicit offset reset when requested', () => {
const normalized = normalizeNoOpAlertScheduleFields(
{
scheduleOffsetMinutes: 0,
},
undefined,
{
preserveExplicitScheduleOffsetMinutes: true,
},
);
expect(normalized).toEqual({
scheduleOffsetMinutes: 0,
});
});
it('keeps an explicit start-at clear when requested', () => {
const normalized = normalizeNoOpAlertScheduleFields(
{
scheduleStartAt: null,
},
undefined,
{
preserveExplicitScheduleStartAt: true,
},
);
expect(normalized).toEqual({
scheduleStartAt: null,
});
});
});

View file

@ -8,6 +8,7 @@ import _ from 'lodash';
import { z } from 'zod';
import { Granularity } from '@hyperdx/common-utils/dist/core/utils';
import {
ALERT_INTERVAL_TO_MINUTES,
AlertChannelType,
AlertInterval,
AlertThresholdType,
@ -28,6 +29,10 @@ export function intervalToGranularity(interval: AlertInterval) {
return Granularity.OneDay;
}
export function intervalToMinutes(interval: AlertInterval): number {
return ALERT_INTERVAL_TO_MINUTES[interval];
}
export function intervalToDateRange(interval: AlertInterval): [Date, Date] {
const now = new Date();
if (interval === '1m') return [sub(now, { minutes: 15 }), now];
@ -114,6 +119,8 @@ export const DEFAULT_TILE_ALERT: z.infer<typeof ChartAlertBaseSchema> = {
threshold: 1,
thresholdType: AlertThresholdType.ABOVE,
interval: '5m',
scheduleOffsetMinutes: 0,
scheduleStartAt: null,
channel: {
type: 'webhook',
webhookId: '',
@ -130,3 +137,66 @@ export function isAlertSilenceExpired(silenced?: {
}): boolean {
return silenced ? new Date() > new Date(silenced.until) : false;
}
export function parseScheduleStartAtValue(
value: string | null | undefined,
): Date | null {
if (value == null) {
return null;
}
const parsedDate = new Date(value);
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
}
type AlertScheduleFields = {
scheduleOffsetMinutes?: number;
scheduleStartAt?: string | null;
};
type NormalizeAlertScheduleOptions = {
preserveExplicitScheduleOffsetMinutes?: boolean;
preserveExplicitScheduleStartAt?: boolean;
};
/**
* Keep alert documents backward-compatible by avoiding no-op writes for
* scheduling fields on pre-migration alerts that never had these keys.
*/
export function normalizeNoOpAlertScheduleFields<
T extends AlertScheduleFields | undefined,
>(
alert: T,
previousAlert?: AlertScheduleFields | null,
options: NormalizeAlertScheduleOptions = {},
): T {
if (alert == null) {
return alert;
}
const normalizedAlert = { ...alert };
// Treat undefined as "field absent" so we don't depend on object key
// preservation/stripping behavior from any parsing layer.
const previousHadOffset =
previousAlert != null && previousAlert.scheduleOffsetMinutes !== undefined;
const previousHadStartAt =
previousAlert != null && previousAlert.scheduleStartAt !== undefined;
if (
(normalizedAlert.scheduleOffsetMinutes ?? 0) === 0 &&
!previousHadOffset &&
!options.preserveExplicitScheduleOffsetMinutes
) {
delete normalizedAlert.scheduleOffsetMinutes;
}
if (
normalizedAlert.scheduleStartAt == null &&
!previousHadStartAt &&
!options.preserveExplicitScheduleStartAt
) {
delete normalizedAlert.scheduleStartAt;
}
return normalizedAlert as T;
}

View file

@ -301,6 +301,17 @@ export const AlertIntervalSchema = z.union([
export type AlertInterval = z.infer<typeof AlertIntervalSchema>;
export const ALERT_INTERVAL_TO_MINUTES: Record<AlertInterval, number> = {
'1m': 1,
'5m': 5,
'15m': 15,
'30m': 30,
'1h': 60,
'6h': 360,
'12h': 720,
'1d': 1440,
};
export const zAlertChannelType = z.literal('webhook');
export type AlertChannelType = z.infer<typeof zAlertChannelType>;
@ -322,9 +333,69 @@ export const zTileAlert = z.object({
dashboardId: z.string().min(1),
});
export const AlertBaseSchema = z.object({
export const validateAlertScheduleOffsetMinutes = (
alert: {
interval: AlertInterval;
scheduleOffsetMinutes?: number;
scheduleStartAt?: string | Date | null;
},
ctx: z.RefinementCtx,
) => {
const scheduleOffsetMinutes = alert.scheduleOffsetMinutes ?? 0;
const intervalMinutes = ALERT_INTERVAL_TO_MINUTES[alert.interval];
if (alert.scheduleStartAt != null && scheduleOffsetMinutes > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'scheduleOffsetMinutes must be 0 when scheduleStartAt is provided',
path: ['scheduleOffsetMinutes'],
});
}
if (scheduleOffsetMinutes >= intervalMinutes) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `scheduleOffsetMinutes must be less than ${intervalMinutes} minute${intervalMinutes === 1 ? '' : 's'}`,
path: ['scheduleOffsetMinutes'],
});
}
};
const MAX_SCHEDULE_START_AT_FUTURE_MS = 1000 * 60 * 60 * 24 * 365;
const MAX_SCHEDULE_START_AT_PAST_MS = 1000 * 60 * 60 * 24 * 365 * 10;
const MAX_SCHEDULE_OFFSET_MINUTES = 1439;
export const scheduleStartAtSchema = z
.union([z.string().datetime(), z.null()])
.optional()
.refine(
value =>
value == null ||
new Date(value).getTime() <= Date.now() + MAX_SCHEDULE_START_AT_FUTURE_MS,
{
message: 'scheduleStartAt must be within 1 year from now',
},
)
.refine(
value =>
value == null ||
new Date(value).getTime() >= Date.now() - MAX_SCHEDULE_START_AT_PAST_MS,
{
message: 'scheduleStartAt must be within 10 years in the past',
},
);
export const AlertBaseObjectSchema = z.object({
id: z.string().optional(),
interval: AlertIntervalSchema,
scheduleOffsetMinutes: z
.number()
.int()
.min(0)
.max(MAX_SCHEDULE_OFFSET_MINUTES)
.optional(),
scheduleStartAt: scheduleStartAtSchema,
threshold: z.number().int().min(1),
thresholdType: z.nativeEnum(AlertThresholdType),
channel: zAlertChannel,
@ -340,13 +411,25 @@ export const AlertBaseSchema = z.object({
.optional(),
});
export const ChartAlertBaseSchema = AlertBaseSchema.extend({
// Keep AlertBaseSchema as a ZodObject for backwards compatibility with
// external consumers that call object helpers like .extend()/.pick()/.omit().
export const AlertBaseSchema = AlertBaseObjectSchema;
const AlertBaseValidatedSchema = AlertBaseObjectSchema.superRefine(
validateAlertScheduleOffsetMinutes,
);
export const ChartAlertBaseSchema = AlertBaseObjectSchema.extend({
threshold: z.number().positive(),
});
const ChartAlertBaseValidatedSchema = ChartAlertBaseSchema.superRefine(
validateAlertScheduleOffsetMinutes,
);
export const AlertSchema = z.union([
z.intersection(AlertBaseSchema, zSavedSearchAlert),
z.intersection(ChartAlertBaseSchema, zTileAlert),
z.intersection(AlertBaseValidatedSchema, zSavedSearchAlert),
z.intersection(ChartAlertBaseValidatedSchema, zTileAlert),
]);
export type Alert = z.infer<typeof AlertSchema>;