fix: Group alert histories by evaluation time (#1338)

Closes HDX-2728

# Summary

This PR groups AlertHistory records by `createdAt` time to avoid showing multiple alert histories for the same time on the alerts page. There can be multiple AlertHistory records for the same `createdAt` time for grouped alerts

## Testing

To test this, setup a Saved Search alert with a group by configured, then navigate to the alerts page to see one history per time:

<img width="1466" height="154" alt="Screenshot 2025-11-07 at 4 46 40 PM" src="https://github.com/user-attachments/assets/ccc48ba0-07b2-48b1-ad25-de8c88467611" />
<img width="791" height="773" alt="Screenshot 2025-11-07 at 4 46 30 PM" src="https://github.com/user-attachments/assets/2ab0f0c6-1d46-4c65-9fbb-cf4c5d62580e" />
This commit is contained in:
Drew Davis 2025-11-10 14:21:20 -05:00 committed by GitHub
parent 840d73076c
commit 78aff3365d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 439 additions and 19 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/api": patch
"@hyperdx/app": patch
---
fix: Group alert histories by evaluation time

View file

@ -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);
});
});
});

View file

@ -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<Omit<IAlertHistory, 'alert'>[]> {
const groupedHistories = await AlertHistory.aggregate<GroupedAlertHistory>([
// 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()),
}));
}

View file

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

View file

@ -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 (
<Tooltip
label={latestHighestValue + ' ' + formatRelative(start, today)}
label={`${history.counts ?? 0} alerts ${formatRelative(start, today)}`}
color="dark"
withArrow
>