mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
840d73076c
commit
78aff3365d
5 changed files with 439 additions and 19 deletions
6
.changeset/fair-berries-occur.md
Normal file
6
.changeset/fair-berries-occur.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
fix: Group alert histories by evaluation time
|
||||
359
packages/api/src/controllers/__tests__/alertHistory.test.ts
Normal file
359
packages/api/src/controllers/__tests__/alertHistory.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
67
packages/api/src/controllers/alertHistory.ts
Normal file
67
packages/api/src/controllers/alertHistory.ts
Normal 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()),
|
||||
}));
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in a new issue