From c9cbfc19b4c594de594d6eff008280ff225cfc5d Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Mon, 10 Nov 2025 11:28:48 -0500 Subject: [PATCH 1/2] fix: Group alert histories by evaluation time --- .changeset/fair-berries-occur.md | 6 + .../__tests__/alertHistory.test.ts | 359 ++++++++++++++++++ packages/api/src/controllers/alertHistory.ts | 67 ++++ packages/api/src/routers/api/alerts.ts | 19 +- packages/app/src/AlertsPage.tsx | 7 +- 5 files changed, 439 insertions(+), 19 deletions(-) create mode 100644 .changeset/fair-berries-occur.md create mode 100644 packages/api/src/controllers/__tests__/alertHistory.test.ts create mode 100644 packages/api/src/controllers/alertHistory.ts diff --git a/.changeset/fair-berries-occur.md b/.changeset/fair-berries-occur.md new file mode 100644 index 000000000..8fb9e33db --- /dev/null +++ b/.changeset/fair-berries-occur.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/api": patch +"@hyperdx/app": patch +--- + +fix: Group alert histories by evaluation time diff --git a/packages/api/src/controllers/__tests__/alertHistory.test.ts b/packages/api/src/controllers/__tests__/alertHistory.test.ts new file mode 100644 index 000000000..8d0922a00 --- /dev/null +++ b/packages/api/src/controllers/__tests__/alertHistory.test.ts @@ -0,0 +1,359 @@ +import { ObjectId } from 'mongodb'; + +import { getRecentAlertHistories } from '@/controllers/alertHistory'; +import { clearDBCollections, closeDB, connectDB } from '@/fixtures'; +import Alert, { AlertState } from '@/models/alert'; +import AlertHistory from '@/models/alertHistory'; +import Team from '@/models/team'; + +describe('alertHistory controller', () => { + beforeAll(async () => { + await connectDB(); + }); + + afterEach(async () => { + await clearDBCollections(); + }); + + afterAll(async () => { + await closeDB(); + }); + + describe('getRecentAlertHistories', () => { + it('should return empty array when no histories exist', async () => { + const alertId = new ObjectId(); + const histories = await getRecentAlertHistories({ + alertId, + limit: 10, + }); + + expect(histories).toEqual([]); + }); + + it('should return recent alert histories for a given alert', async () => { + const team = await Team.create({ name: 'Test Team' }); + const alert = await Alert.create({ + team: team._id, + threshold: 100, + interval: '5m', + channel: { type: null }, + }); + + const now = new Date('2024-01-15T12:00:00Z'); + const earlier = new Date('2024-01-15T11:00:00Z'); + + await AlertHistory.create({ + alert: alert._id, + createdAt: now, + state: AlertState.ALERT, + counts: 5, + lastValues: [{ startTime: now, count: 5 }], + }); + + await AlertHistory.create({ + alert: alert._id, + createdAt: earlier, + state: AlertState.OK, + counts: 0, + lastValues: [{ startTime: earlier, count: 0 }], + }); + + const histories = await getRecentAlertHistories({ + alertId: new ObjectId(alert._id), + limit: 10, + }); + + expect(histories).toHaveLength(2); + expect(histories[0].createdAt).toEqual(now); + expect(histories[0].state).toBe(AlertState.ALERT); + expect(histories[0].counts).toBe(5); + expect(histories[1].createdAt).toEqual(earlier); + expect(histories[1].state).toBe(AlertState.OK); + expect(histories[1].counts).toBe(0); + }); + + it('should respect the limit parameter', async () => { + const team = await Team.create({ name: 'Test Team' }); + const alert = await Alert.create({ + team: team._id, + threshold: 100, + interval: '5m', + channel: { type: null }, + }); + + // Create 5 histories + for (let i = 0; i < 5; i++) { + await AlertHistory.create({ + alert: alert._id, + createdAt: new Date(Date.now() - i * 60000), + state: AlertState.OK, + counts: 0, + lastValues: [ + { startTime: new Date(Date.now() - i * 60000), count: 0 }, + ], + }); + } + + const histories = await getRecentAlertHistories({ + alertId: new ObjectId(alert._id), + limit: 3, + }); + + expect(histories).toHaveLength(3); + }); + + it('should group histories by createdAt timestamp', async () => { + const team = await Team.create({ name: 'Test Team' }); + const alert = await Alert.create({ + team: team._id, + threshold: 100, + interval: '5m', + channel: { type: null }, + }); + + const timestamp = new Date('2024-01-15T12:00:00Z'); + + // Create multiple histories with the same timestamp + await AlertHistory.create({ + alert: alert._id, + createdAt: timestamp, + state: AlertState.OK, + counts: 0, + lastValues: [{ startTime: timestamp, count: 0 }], + }); + + await AlertHistory.create({ + alert: alert._id, + createdAt: timestamp, + state: AlertState.OK, + counts: 0, + lastValues: [{ startTime: timestamp, count: 0 }], + }); + + const histories = await getRecentAlertHistories({ + alertId: new ObjectId(alert._id), + limit: 10, + }); + + expect(histories).toHaveLength(1); + expect(histories[0].createdAt).toEqual(timestamp); + expect(histories[0].counts).toBe(0); // 0 + 0 + expect(histories[0].lastValues).toHaveLength(2); + }); + + it('should set state to ALERT if any grouped history has ALERT state', async () => { + const team = await Team.create({ name: 'Test Team' }); + const alert = await Alert.create({ + team: team._id, + threshold: 100, + interval: '5m', + channel: { type: null }, + }); + + const timestamp = new Date('2024-01-15T12:00:00Z'); + + // Create histories with mixed states at the same timestamp + await AlertHistory.create({ + alert: alert._id, + createdAt: timestamp, + state: AlertState.OK, + counts: 0, + lastValues: [{ startTime: timestamp, count: 0 }], + }); + + await AlertHistory.create({ + alert: alert._id, + createdAt: timestamp, + state: AlertState.ALERT, + counts: 3, + lastValues: [{ startTime: timestamp, count: 3 }], + }); + + await AlertHistory.create({ + alert: alert._id, + createdAt: timestamp, + state: AlertState.OK, + counts: 0, + lastValues: [{ startTime: timestamp, count: 0 }], + }); + + const histories = await getRecentAlertHistories({ + alertId: new ObjectId(alert._id), + limit: 10, + }); + + expect(histories).toHaveLength(1); + expect(histories[0].state).toBe(AlertState.ALERT); + expect(histories[0].counts).toBe(3); // 0 + 3 + 0 + }); + + it('should set state to OK when all grouped histories are OK', async () => { + const team = await Team.create({ name: 'Test Team' }); + const alert = await Alert.create({ + team: team._id, + threshold: 100, + interval: '5m', + channel: { type: null }, + }); + + const timestamp = new Date('2024-01-15T12:00:00Z'); + + await AlertHistory.create({ + alert: alert._id, + createdAt: timestamp, + state: AlertState.OK, + counts: 0, + lastValues: [{ startTime: timestamp, count: 0 }], + }); + + await AlertHistory.create({ + alert: alert._id, + createdAt: timestamp, + state: AlertState.OK, + counts: 0, + lastValues: [{ startTime: timestamp, count: 0 }], + }); + + const histories = await getRecentAlertHistories({ + alertId: new ObjectId(alert._id), + limit: 10, + }); + + expect(histories).toHaveLength(1); + expect(histories[0].state).toBe(AlertState.OK); + }); + + it('should sort histories by createdAt in descending order', async () => { + const team = await Team.create({ name: 'Test Team' }); + const alert = await Alert.create({ + team: team._id, + threshold: 100, + interval: '5m', + channel: { type: null }, + }); + + const oldest = new Date('2024-01-15T10:00:00Z'); + const middle = new Date('2024-01-15T11:00:00Z'); + const newest = new Date('2024-01-15T12:00:00Z'); + + // Create in random order + await AlertHistory.create({ + alert: alert._id, + createdAt: middle, + state: AlertState.OK, + counts: 0, + lastValues: [{ startTime: middle, count: 0 }], + }); + + await AlertHistory.create({ + alert: alert._id, + createdAt: newest, + state: AlertState.ALERT, + counts: 3, + lastValues: [{ startTime: newest, count: 3 }], + }); + + await AlertHistory.create({ + alert: alert._id, + createdAt: oldest, + state: AlertState.OK, + counts: 0, + lastValues: [{ startTime: oldest, count: 0 }], + }); + + const histories = await getRecentAlertHistories({ + alertId: new ObjectId(alert._id), + limit: 10, + }); + + expect(histories).toHaveLength(3); + expect(histories[0].createdAt).toEqual(newest); + expect(histories[1].createdAt).toEqual(middle); + expect(histories[2].createdAt).toEqual(oldest); + }); + + it('should sort lastValues by startTime in ascending order', async () => { + const team = await Team.create({ name: 'Test Team' }); + const alert = await Alert.create({ + team: team._id, + threshold: 100, + interval: '5m', + channel: { type: null }, + }); + + const timestamp = new Date('2024-01-15T12:00:00Z'); + const older = new Date('2024-01-15T11:00:00Z'); + const newer = new Date('2024-01-15T13:00:00Z'); + + await AlertHistory.create({ + alert: alert._id, + createdAt: timestamp, + state: AlertState.OK, + counts: 0, + lastValues: [{ startTime: older, count: 0 }], + }); + + await AlertHistory.create({ + alert: alert._id, + createdAt: timestamp, + state: AlertState.OK, + counts: 0, + lastValues: [{ startTime: newer, count: 0 }], + }); + + const histories = await getRecentAlertHistories({ + alertId: new ObjectId(alert._id), + limit: 10, + }); + + expect(histories).toHaveLength(1); + expect(histories[0].lastValues).toHaveLength(2); + expect(histories[0].lastValues[0].startTime).toEqual(older); + expect(histories[0].lastValues[1].startTime).toEqual(newer); + }); + + it('should only return histories for the specified alert', async () => { + const team = await Team.create({ name: 'Test Team' }); + const alert1 = await Alert.create({ + team: team._id, + threshold: 100, + interval: '5m', + channel: { type: null }, + }); + + const alert2 = await Alert.create({ + team: team._id, + threshold: 200, + interval: '5m', + channel: { type: null }, + }); + + const timestamp = new Date('2024-01-15T12:00:00Z'); + + await AlertHistory.create({ + alert: alert1._id, + createdAt: timestamp, + state: AlertState.ALERT, + counts: 5, + lastValues: [{ startTime: timestamp, count: 5 }], + }); + + await AlertHistory.create({ + alert: alert2._id, + createdAt: timestamp, + state: AlertState.OK, + counts: 0, + lastValues: [{ startTime: timestamp, count: 0 }], + }); + + const histories = await getRecentAlertHistories({ + alertId: new ObjectId(alert1._id), + limit: 10, + }); + + expect(histories).toHaveLength(1); + expect(histories[0].state).toBe(AlertState.ALERT); + expect(histories[0].counts).toBe(5); + }); + }); +}); diff --git a/packages/api/src/controllers/alertHistory.ts b/packages/api/src/controllers/alertHistory.ts new file mode 100644 index 000000000..9b06ca262 --- /dev/null +++ b/packages/api/src/controllers/alertHistory.ts @@ -0,0 +1,67 @@ +import { ObjectId } from 'mongodb'; + +import { AlertState } from '@/models/alert'; +import AlertHistory, { IAlertHistory } from '@/models/alertHistory'; + +type GroupedAlertHistory = { + _id: Date; + states: string[]; + counts: number; + lastValues: IAlertHistory['lastValues'][]; +}; + +/** + * Gets the most recent alert histories for a given alert ID, + * limiting to the given number of entries. + */ +export async function getRecentAlertHistories({ + alertId, + limit, +}: { + alertId: ObjectId; + limit: number; +}): Promise[]> { + const groupedHistories = await AlertHistory.aggregate([ + // Filter for the specific alert + { + $match: { + alert: new ObjectId(alertId), + }, + }, + // Group documents by createdAt + { + $group: { + _id: '$createdAt', + states: { + $push: '$state', + }, + counts: { + $sum: '$counts', + }, + lastValues: { + $push: '$lastValues', + }, + }, + }, + // Take the `createdAtLimit` most recent groups + { + $sort: { + _id: -1, + }, + }, + { + $limit: limit, + }, + ]); + + return groupedHistories.map(group => ({ + createdAt: group._id, + state: group.states.includes(AlertState.ALERT) + ? AlertState.ALERT + : AlertState.OK, + counts: group.counts, + lastValues: group.lastValues + .flat() + .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()), + })); +} diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index c3d8981ca..9c14fbbf8 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -1,8 +1,10 @@ import express from 'express'; import _ from 'lodash'; +import { ObjectId } from 'mongodb'; import { z } from 'zod'; import { validateRequest } from 'zod-express-middleware'; +import { getRecentAlertHistories } from '@/controllers/alertHistory'; import { createAlert, deleteAlert, @@ -10,7 +12,6 @@ import { getAlertsEnhanced, updateAlert, } from '@/controllers/alerts'; -import AlertHistory from '@/models/alertHistory'; import { alertSchema, objectIdSchema } from '@/utils/zod'; const router = express.Router(); @@ -26,18 +27,10 @@ router.get('/', async (req, res, next) => { const data = await Promise.all( alerts.map(async alert => { - const history = await AlertHistory.find( - { - alert: alert._id, - }, - { - __v: 0, - _id: 0, - alert: 0, - }, - ) - .sort({ createdAt: -1 }) - .limit(20); + const history = await getRecentAlertHistories({ + alertId: new ObjectId(alert._id), + limit: 20, + }); return { history, diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx index 808f88567..f6c197f11 100644 --- a/packages/app/src/AlertsPage.tsx +++ b/packages/app/src/AlertsPage.tsx @@ -18,18 +18,13 @@ import type { AlertsPageItem } from './types'; import styles from '../styles/AlertsPage.module.scss'; -// TODO: exceptions latestHighestValue needs to be different condition (total count of exceptions not highest value within an exception) - function AlertHistoryCard({ history }: { history: AlertHistory }) { const start = new Date(history.createdAt.toString()); const today = React.useMemo(() => new Date(), []); - const latestHighestValue = history.lastValues.length - ? Math.max(...history.lastValues.map(({ count }) => count)) - : 0; return ( From 68a9fa3cb5f9c212765bbedecefe90e777a80a82 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Mon, 10 Nov 2025 12:16:19 -0500 Subject: [PATCH 2/2] style: Remove extra 'at' in AlertCard --- packages/app/src/AlertsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx index f6c197f11..cf1054904 100644 --- a/packages/app/src/AlertsPage.tsx +++ b/packages/app/src/AlertsPage.tsx @@ -24,7 +24,7 @@ function AlertHistoryCard({ history }: { history: AlertHistory }) { return (