diff --git a/.changeset/silent-zebras-switch.md b/.changeset/silent-zebras-switch.md new file mode 100644 index 0000000000..583aba0924 --- /dev/null +++ b/.changeset/silent-zebras-switch.md @@ -0,0 +1,7 @@ +--- +"@hyperdx/api": minor +"@hyperdx/app": minor +"@hyperdx/common-utils": minor +--- + +feat(alerts): add anchored alert scheduling with `scheduleStartAt` and `scheduleOffsetMinutes` diff --git a/packages/api/openapi.json b/packages/api/openapi.json index ffd0b5a9a4..328dd60df9 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -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" diff --git a/packages/api/src/controllers/alerts.ts b/packages/api/src/controllers/alerts.ts index 5ff63113e5..f26697123b 100644 --- a/packages/api/src/controllers/alerts.ts +++ b/packages/api/src/controllers/alerts.ts @@ -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 => { + // 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, diff --git a/packages/api/src/models/alert.ts b/packages/api/src/models/alert.ts index fd1c8e2441..4497bd9cf3 100644 --- a/packages/api/src/models/alert.ts +++ b/packages/api/src/models/alert.ts @@ -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( 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, diff --git a/packages/api/src/routers/api/__tests__/alerts.test.ts b/packages/api/src/routers/api/__tests__/alerts.test.ts index 59f09f32b3..ff9d7a1e6d 100644 --- a/packages/api/src/routers/api/__tests__/alerts.test.ts +++ b/packages/api/src/routers/api/__tests__/alerts.test.ts @@ -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') diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index ac090e2092..10575b1e05 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -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), }); diff --git a/packages/api/src/routers/external-api/__tests__/alerts.test.ts b/packages/api/src/routers/external-api/__tests__/alerts.test.ts index d45dc7bb5c..5108074ea2 100644 --- a/packages/api/src/routers/external-api/__tests__/alerts.test.ts +++ b/packages/api/src/routers/external-api/__tests__/alerts.test.ts @@ -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', () => { diff --git a/packages/api/src/routers/external-api/v2/alerts.ts b/packages/api/src/routers/external-api/v2/alerts.ts index b16ffa4044..cf0a12a102 100644 --- a/packages/api/src/routers/external-api/v2/alerts.ts +++ b/packages/api/src/routers/external-api/v2/alerts.ts @@ -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); diff --git a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts index 9862fa2ed3..5fd1843c68 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts @@ -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, diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index cad316a36f..507e484d34 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -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, diff --git a/packages/api/src/utils/__tests__/externalApi.test.ts b/packages/api/src/utils/__tests__/externalApi.test.ts new file mode 100644 index 0000000000..a5d8575aa8 --- /dev/null +++ b/packages/api/src/utils/__tests__/externalApi.test.ts @@ -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> = {}, +): 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(); + }); + }); +}); diff --git a/packages/api/src/utils/enhancedErrors.ts b/packages/api/src/utils/enhancedErrors.ts index c1ba9ca174..512ade6a47 100644 --- a/packages/api/src/utils/enhancedErrors.ts +++ b/packages/api/src/utils/enhancedErrors.ts @@ -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(); + }; +} diff --git a/packages/api/src/utils/externalApi.ts b/packages/api/src/utils/externalApi.ts index 38f93598c1..721e4ad4f3 100644 --- a/packages/api/src/utils/externalApi.ts +++ b/packages/api/src/utils/externalApi.ts @@ -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, diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index eee9a58ac5..bccb10817d 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -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 diff --git a/packages/app/src/DBSearchPageAlertModal.tsx b/packages/app/src/DBSearchPageAlertModal.tsx index a06351a9c6..d1f0e8b7ed 100644 --- a/packages/app/src/DBSearchPageAlertModal.tsx +++ b/packages/app/src/DBSearchPageAlertModal.tsx @@ -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({ - defaultValues: defaultValues || { - interval: '5m', - threshold: 1, - thresholdType: AlertThresholdType.ABOVE, - source: AlertSource.SAVED_SEARCH, - channel: { - type: 'webhook', - webhookId: '', - }, - }, + const { + control, + handleSubmit, + setValue, + formState: { dirtyFields }, + } = useForm({ + 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 ( -
+ + onSubmit( + normalizeNoOpAlertScheduleFields(data, defaultValues, { + preserveExplicitScheduleOffsetMinutes: + dirtyFields.scheduleOffsetMinutes === true, + preserveExplicitScheduleStartAt: + dirtyFields.scheduleStartAt === true, + }), + ), + )} + > @@ -155,6 +195,15 @@ const AlertForm = ({ control={control} /> + grouped by diff --git a/packages/app/src/components/AlertScheduleFields.tsx b/packages/app/src/components/AlertScheduleFields.tsx new file mode 100644 index 0000000000..a5893f5a11 --- /dev/null +++ b/packages/app/src/components/AlertScheduleFields.tsx @@ -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 = { + control: Control; + setValue: UseFormSetValue; + scheduleOffsetName: FieldPath; + scheduleStartAtName: FieldPath; + scheduleOffsetMinutes: number | null | undefined; + maxScheduleOffsetMinutes: number; + offsetWindowLabel: string; +}; + +export function AlertScheduleFields({ + control, + setValue, + scheduleOffsetName, + scheduleStartAtName, + scheduleOffsetMinutes, + maxScheduleOffsetMinutes, + offsetWindowLabel, +}: AlertScheduleFieldsProps) { + 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>, { + shouldValidate: true, + }); + return; + } + if (hasScheduleStartAtAnchor && normalizedOffset > 0) { + setValue(scheduleOffsetName, 0 as PathValue>, { + shouldValidate: true, + }); + } + }, [ + hasScheduleStartAtAnchor, + scheduleOffsetMinutes, + scheduleOffsetName, + setValue, + showScheduleOffsetInput, + ]); + + return ( + <> + setOpened(current => !current)} + mt="xs" + data-testid="alert-advanced-settings-toggle" + > + + {opened ? ( + + ) : ( + + )} + + Advanced Settings + + + + + + + Optional schedule controls for aligning alert windows. + + {showScheduleOffsetInput && ( + <> + + + + Start offset (min) + + + + + + + + + + {offsetWindowLabel} + + + {hasScheduleStartAtAnchor && ( + + Start offset is ignored while an anchor start time is set. + + )} + + )} + + + + Anchor start time + + + + + + + + ( + + field.onChange(value?.toISOString() ?? null) + } + error={error?.message} + /> + )} + /> + + Displayed in local time, stored as UTC + + + + + + ); +} diff --git a/packages/app/src/components/ChartEditor/types.ts b/packages/app/src/components/ChartEditor/types.ts index 8031285697..3e0cabc0c7 100644 --- a/packages/app/src/components/ChartEditor/types.ts +++ b/packages/app/src/components/ChartEditor/types.ts @@ -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 & Partial> & { + alert?: BuilderSavedChartConfig['alert'] & { + createdBy?: AlertWithCreatedBy['createdBy']; + }; series: SavedChartConfigWithSelectArray['select']; configType?: 'sql' | 'builder'; }; diff --git a/packages/app/src/components/DBEditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm.tsx index a5955a1b3d..3f9cc6f024 100644 --- a/packages/app/src/components/DBEditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm.tsx @@ -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({ @@ -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({ 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 && ( - - - - - - Alert when the value - - - - over - - - window via - - - - {(alert as any)?.createdBy && ( - - Created by{' '} - {(alert as any).createdBy?.name || - (alert as any).createdBy?.email} - - )} + + + + Trigger + + + + Alert when the value + + + + over + + + window via + + - + {alert?.createdBy && ( + + Created by {alert.createdBy.name || alert.createdBy.email} + + )} + + + + Send to { + 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'); + }); }); diff --git a/packages/app/src/utils/__tests__/alerts.test.ts b/packages/app/src/utils/__tests__/alerts.test.ts new file mode 100644 index 0000000000..0e6c84319b --- /dev/null +++ b/packages/app/src/utils/__tests__/alerts.test.ts @@ -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, + }); + }); +}); diff --git a/packages/app/src/utils/alerts.ts b/packages/app/src/utils/alerts.ts index 252cf2f8a8..bee07ccdf1 100644 --- a/packages/app/src/utils/alerts.ts +++ b/packages/app/src/utils/alerts.ts @@ -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 = { 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; +} diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 662f465096..a97419d1db 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -301,6 +301,17 @@ export const AlertIntervalSchema = z.union([ export type AlertInterval = z.infer; +export const ALERT_INTERVAL_TO_MINUTES: Record = { + '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; @@ -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;