Skip to content

Commit 78aff33

Browse files
authored
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" />
1 parent 840d730 commit 78aff33

File tree

5 files changed

+439
-19
lines changed

5 files changed

+439
-19
lines changed

.changeset/fair-berries-occur.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@hyperdx/api": patch
3+
"@hyperdx/app": patch
4+
---
5+
6+
fix: Group alert histories by evaluation time
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
import { ObjectId } from 'mongodb';
2+
3+
import { getRecentAlertHistories } from '@/controllers/alertHistory';
4+
import { clearDBCollections, closeDB, connectDB } from '@/fixtures';
5+
import Alert, { AlertState } from '@/models/alert';
6+
import AlertHistory from '@/models/alertHistory';
7+
import Team from '@/models/team';
8+
9+
describe('alertHistory controller', () => {
10+
beforeAll(async () => {
11+
await connectDB();
12+
});
13+
14+
afterEach(async () => {
15+
await clearDBCollections();
16+
});
17+
18+
afterAll(async () => {
19+
await closeDB();
20+
});
21+
22+
describe('getRecentAlertHistories', () => {
23+
it('should return empty array when no histories exist', async () => {
24+
const alertId = new ObjectId();
25+
const histories = await getRecentAlertHistories({
26+
alertId,
27+
limit: 10,
28+
});
29+
30+
expect(histories).toEqual([]);
31+
});
32+
33+
it('should return recent alert histories for a given alert', async () => {
34+
const team = await Team.create({ name: 'Test Team' });
35+
const alert = await Alert.create({
36+
team: team._id,
37+
threshold: 100,
38+
interval: '5m',
39+
channel: { type: null },
40+
});
41+
42+
const now = new Date('2024-01-15T12:00:00Z');
43+
const earlier = new Date('2024-01-15T11:00:00Z');
44+
45+
await AlertHistory.create({
46+
alert: alert._id,
47+
createdAt: now,
48+
state: AlertState.ALERT,
49+
counts: 5,
50+
lastValues: [{ startTime: now, count: 5 }],
51+
});
52+
53+
await AlertHistory.create({
54+
alert: alert._id,
55+
createdAt: earlier,
56+
state: AlertState.OK,
57+
counts: 0,
58+
lastValues: [{ startTime: earlier, count: 0 }],
59+
});
60+
61+
const histories = await getRecentAlertHistories({
62+
alertId: new ObjectId(alert._id),
63+
limit: 10,
64+
});
65+
66+
expect(histories).toHaveLength(2);
67+
expect(histories[0].createdAt).toEqual(now);
68+
expect(histories[0].state).toBe(AlertState.ALERT);
69+
expect(histories[0].counts).toBe(5);
70+
expect(histories[1].createdAt).toEqual(earlier);
71+
expect(histories[1].state).toBe(AlertState.OK);
72+
expect(histories[1].counts).toBe(0);
73+
});
74+
75+
it('should respect the limit parameter', async () => {
76+
const team = await Team.create({ name: 'Test Team' });
77+
const alert = await Alert.create({
78+
team: team._id,
79+
threshold: 100,
80+
interval: '5m',
81+
channel: { type: null },
82+
});
83+
84+
// Create 5 histories
85+
for (let i = 0; i < 5; i++) {
86+
await AlertHistory.create({
87+
alert: alert._id,
88+
createdAt: new Date(Date.now() - i * 60000),
89+
state: AlertState.OK,
90+
counts: 0,
91+
lastValues: [
92+
{ startTime: new Date(Date.now() - i * 60000), count: 0 },
93+
],
94+
});
95+
}
96+
97+
const histories = await getRecentAlertHistories({
98+
alertId: new ObjectId(alert._id),
99+
limit: 3,
100+
});
101+
102+
expect(histories).toHaveLength(3);
103+
});
104+
105+
it('should group histories by createdAt timestamp', async () => {
106+
const team = await Team.create({ name: 'Test Team' });
107+
const alert = await Alert.create({
108+
team: team._id,
109+
threshold: 100,
110+
interval: '5m',
111+
channel: { type: null },
112+
});
113+
114+
const timestamp = new Date('2024-01-15T12:00:00Z');
115+
116+
// Create multiple histories with the same timestamp
117+
await AlertHistory.create({
118+
alert: alert._id,
119+
createdAt: timestamp,
120+
state: AlertState.OK,
121+
counts: 0,
122+
lastValues: [{ startTime: timestamp, count: 0 }],
123+
});
124+
125+
await AlertHistory.create({
126+
alert: alert._id,
127+
createdAt: timestamp,
128+
state: AlertState.OK,
129+
counts: 0,
130+
lastValues: [{ startTime: timestamp, count: 0 }],
131+
});
132+
133+
const histories = await getRecentAlertHistories({
134+
alertId: new ObjectId(alert._id),
135+
limit: 10,
136+
});
137+
138+
expect(histories).toHaveLength(1);
139+
expect(histories[0].createdAt).toEqual(timestamp);
140+
expect(histories[0].counts).toBe(0); // 0 + 0
141+
expect(histories[0].lastValues).toHaveLength(2);
142+
});
143+
144+
it('should set state to ALERT if any grouped history has ALERT state', async () => {
145+
const team = await Team.create({ name: 'Test Team' });
146+
const alert = await Alert.create({
147+
team: team._id,
148+
threshold: 100,
149+
interval: '5m',
150+
channel: { type: null },
151+
});
152+
153+
const timestamp = new Date('2024-01-15T12:00:00Z');
154+
155+
// Create histories with mixed states at the same timestamp
156+
await AlertHistory.create({
157+
alert: alert._id,
158+
createdAt: timestamp,
159+
state: AlertState.OK,
160+
counts: 0,
161+
lastValues: [{ startTime: timestamp, count: 0 }],
162+
});
163+
164+
await AlertHistory.create({
165+
alert: alert._id,
166+
createdAt: timestamp,
167+
state: AlertState.ALERT,
168+
counts: 3,
169+
lastValues: [{ startTime: timestamp, count: 3 }],
170+
});
171+
172+
await AlertHistory.create({
173+
alert: alert._id,
174+
createdAt: timestamp,
175+
state: AlertState.OK,
176+
counts: 0,
177+
lastValues: [{ startTime: timestamp, count: 0 }],
178+
});
179+
180+
const histories = await getRecentAlertHistories({
181+
alertId: new ObjectId(alert._id),
182+
limit: 10,
183+
});
184+
185+
expect(histories).toHaveLength(1);
186+
expect(histories[0].state).toBe(AlertState.ALERT);
187+
expect(histories[0].counts).toBe(3); // 0 + 3 + 0
188+
});
189+
190+
it('should set state to OK when all grouped histories are OK', async () => {
191+
const team = await Team.create({ name: 'Test Team' });
192+
const alert = await Alert.create({
193+
team: team._id,
194+
threshold: 100,
195+
interval: '5m',
196+
channel: { type: null },
197+
});
198+
199+
const timestamp = new Date('2024-01-15T12:00:00Z');
200+
201+
await AlertHistory.create({
202+
alert: alert._id,
203+
createdAt: timestamp,
204+
state: AlertState.OK,
205+
counts: 0,
206+
lastValues: [{ startTime: timestamp, count: 0 }],
207+
});
208+
209+
await AlertHistory.create({
210+
alert: alert._id,
211+
createdAt: timestamp,
212+
state: AlertState.OK,
213+
counts: 0,
214+
lastValues: [{ startTime: timestamp, count: 0 }],
215+
});
216+
217+
const histories = await getRecentAlertHistories({
218+
alertId: new ObjectId(alert._id),
219+
limit: 10,
220+
});
221+
222+
expect(histories).toHaveLength(1);
223+
expect(histories[0].state).toBe(AlertState.OK);
224+
});
225+
226+
it('should sort histories by createdAt in descending order', async () => {
227+
const team = await Team.create({ name: 'Test Team' });
228+
const alert = await Alert.create({
229+
team: team._id,
230+
threshold: 100,
231+
interval: '5m',
232+
channel: { type: null },
233+
});
234+
235+
const oldest = new Date('2024-01-15T10:00:00Z');
236+
const middle = new Date('2024-01-15T11:00:00Z');
237+
const newest = new Date('2024-01-15T12:00:00Z');
238+
239+
// Create in random order
240+
await AlertHistory.create({
241+
alert: alert._id,
242+
createdAt: middle,
243+
state: AlertState.OK,
244+
counts: 0,
245+
lastValues: [{ startTime: middle, count: 0 }],
246+
});
247+
248+
await AlertHistory.create({
249+
alert: alert._id,
250+
createdAt: newest,
251+
state: AlertState.ALERT,
252+
counts: 3,
253+
lastValues: [{ startTime: newest, count: 3 }],
254+
});
255+
256+
await AlertHistory.create({
257+
alert: alert._id,
258+
createdAt: oldest,
259+
state: AlertState.OK,
260+
counts: 0,
261+
lastValues: [{ startTime: oldest, count: 0 }],
262+
});
263+
264+
const histories = await getRecentAlertHistories({
265+
alertId: new ObjectId(alert._id),
266+
limit: 10,
267+
});
268+
269+
expect(histories).toHaveLength(3);
270+
expect(histories[0].createdAt).toEqual(newest);
271+
expect(histories[1].createdAt).toEqual(middle);
272+
expect(histories[2].createdAt).toEqual(oldest);
273+
});
274+
275+
it('should sort lastValues by startTime in ascending order', async () => {
276+
const team = await Team.create({ name: 'Test Team' });
277+
const alert = await Alert.create({
278+
team: team._id,
279+
threshold: 100,
280+
interval: '5m',
281+
channel: { type: null },
282+
});
283+
284+
const timestamp = new Date('2024-01-15T12:00:00Z');
285+
const older = new Date('2024-01-15T11:00:00Z');
286+
const newer = new Date('2024-01-15T13:00:00Z');
287+
288+
await AlertHistory.create({
289+
alert: alert._id,
290+
createdAt: timestamp,
291+
state: AlertState.OK,
292+
counts: 0,
293+
lastValues: [{ startTime: older, count: 0 }],
294+
});
295+
296+
await AlertHistory.create({
297+
alert: alert._id,
298+
createdAt: timestamp,
299+
state: AlertState.OK,
300+
counts: 0,
301+
lastValues: [{ startTime: newer, count: 0 }],
302+
});
303+
304+
const histories = await getRecentAlertHistories({
305+
alertId: new ObjectId(alert._id),
306+
limit: 10,
307+
});
308+
309+
expect(histories).toHaveLength(1);
310+
expect(histories[0].lastValues).toHaveLength(2);
311+
expect(histories[0].lastValues[0].startTime).toEqual(older);
312+
expect(histories[0].lastValues[1].startTime).toEqual(newer);
313+
});
314+
315+
it('should only return histories for the specified alert', async () => {
316+
const team = await Team.create({ name: 'Test Team' });
317+
const alert1 = await Alert.create({
318+
team: team._id,
319+
threshold: 100,
320+
interval: '5m',
321+
channel: { type: null },
322+
});
323+
324+
const alert2 = await Alert.create({
325+
team: team._id,
326+
threshold: 200,
327+
interval: '5m',
328+
channel: { type: null },
329+
});
330+
331+
const timestamp = new Date('2024-01-15T12:00:00Z');
332+
333+
await AlertHistory.create({
334+
alert: alert1._id,
335+
createdAt: timestamp,
336+
state: AlertState.ALERT,
337+
counts: 5,
338+
lastValues: [{ startTime: timestamp, count: 5 }],
339+
});
340+
341+
await AlertHistory.create({
342+
alert: alert2._id,
343+
createdAt: timestamp,
344+
state: AlertState.OK,
345+
counts: 0,
346+
lastValues: [{ startTime: timestamp, count: 0 }],
347+
});
348+
349+
const histories = await getRecentAlertHistories({
350+
alertId: new ObjectId(alert1._id),
351+
limit: 10,
352+
});
353+
354+
expect(histories).toHaveLength(1);
355+
expect(histories[0].state).toBe(AlertState.ALERT);
356+
expect(histories[0].counts).toBe(5);
357+
});
358+
});
359+
});

0 commit comments

Comments
 (0)