Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/optimize-alert-history-queries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/api": patch
---

perf: optimize AlertHistory aggregation queries with time-window filters and compound index
33 changes: 21 additions & 12 deletions packages/api/src/controllers/__tests__/alertHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe('alertHistory controller', () => {
const alertId = new ObjectId();
const histories = await getRecentAlertHistories({
alertId,
interval: '5m',
limit: 10,
});

Expand All @@ -39,8 +40,8 @@ describe('alertHistory controller', () => {
channel: { type: null },
});

const now = new Date('2024-01-15T12:00:00Z');
const earlier = new Date('2024-01-15T11:00:00Z');
const now = new Date(Date.now() - 60000);
const earlier = new Date(Date.now() - 120000);

await AlertHistory.create({
alert: alert._id,
Expand All @@ -60,6 +61,7 @@ describe('alertHistory controller', () => {

const histories = await getRecentAlertHistories({
alertId: new ObjectId(alert._id),
interval: '5m',
limit: 10,
});

Expand Down Expand Up @@ -96,6 +98,7 @@ describe('alertHistory controller', () => {

const histories = await getRecentAlertHistories({
alertId: new ObjectId(alert._id),
interval: '5m',
limit: 3,
});

Expand All @@ -111,7 +114,7 @@ describe('alertHistory controller', () => {
channel: { type: null },
});

const timestamp = new Date('2024-01-15T12:00:00Z');
const timestamp = new Date(Date.now() - 60000);

// Create multiple histories with the same timestamp
await AlertHistory.create({
Expand All @@ -132,6 +135,7 @@ describe('alertHistory controller', () => {

const histories = await getRecentAlertHistories({
alertId: new ObjectId(alert._id),
interval: '5m',
limit: 10,
});

Expand All @@ -150,7 +154,7 @@ describe('alertHistory controller', () => {
channel: { type: null },
});

const timestamp = new Date('2024-01-15T12:00:00Z');
const timestamp = new Date(Date.now() - 60000);

// Create histories with mixed states at the same timestamp
await AlertHistory.create({
Expand Down Expand Up @@ -179,6 +183,7 @@ describe('alertHistory controller', () => {

const histories = await getRecentAlertHistories({
alertId: new ObjectId(alert._id),
interval: '5m',
limit: 10,
});

Expand All @@ -196,7 +201,7 @@ describe('alertHistory controller', () => {
channel: { type: null },
});

const timestamp = new Date('2024-01-15T12:00:00Z');
const timestamp = new Date(Date.now() - 60000);

await AlertHistory.create({
alert: alert._id,
Expand All @@ -216,6 +221,7 @@ describe('alertHistory controller', () => {

const histories = await getRecentAlertHistories({
alertId: new ObjectId(alert._id),
interval: '5m',
limit: 10,
});

Expand All @@ -232,9 +238,9 @@ describe('alertHistory controller', () => {
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');
const oldest = new Date(Date.now() - 180000);
const middle = new Date(Date.now() - 120000);
const newest = new Date(Date.now() - 60000);

// Create in random order
await AlertHistory.create({
Expand Down Expand Up @@ -263,6 +269,7 @@ describe('alertHistory controller', () => {

const histories = await getRecentAlertHistories({
alertId: new ObjectId(alert._id),
interval: '5m',
limit: 10,
});

Expand All @@ -281,9 +288,9 @@ describe('alertHistory controller', () => {
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');
const timestamp = new Date(Date.now() - 60000);
const older = new Date(Date.now() - 120000);
const newer = new Date(Date.now() - 30000);

await AlertHistory.create({
alert: alert._id,
Expand All @@ -303,6 +310,7 @@ describe('alertHistory controller', () => {

const histories = await getRecentAlertHistories({
alertId: new ObjectId(alert._id),
interval: '5m',
limit: 10,
});

Expand All @@ -328,7 +336,7 @@ describe('alertHistory controller', () => {
channel: { type: null },
});

const timestamp = new Date('2024-01-15T12:00:00Z');
const timestamp = new Date(Date.now() - 60000);

await AlertHistory.create({
alert: alert1._id,
Expand All @@ -348,6 +356,7 @@ describe('alertHistory controller', () => {

const histories = await getRecentAlertHistories({
alertId: new ObjectId(alert1._id),
interval: '5m',
limit: 10,
});

Expand Down
13 changes: 10 additions & 3 deletions packages/api/src/controllers/alertHistory.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
ALERT_INTERVAL_TO_MINUTES,
AlertInterval,
} from '@hyperdx/common-utils/dist/types';
import { ObjectId } from 'mongodb';

import { AlertState } from '@/models/alert';
Expand All @@ -16,19 +20,23 @@ type GroupedAlertHistory = {
*/
export async function getRecentAlertHistories({
alertId,
interval,
limit,
}: {
alertId: ObjectId;
interval: AlertInterval;
limit: number;
}): Promise<Omit<IAlertHistory, 'alert'>[]> {
const lookbackMs = limit * ALERT_INTERVAL_TO_MINUTES[interval] * 60 * 1000;

const groupedHistories = await AlertHistory.aggregate<GroupedAlertHistory>([
// Filter for the specific alert
{
$match: {
alert: new ObjectId(alertId),
createdAt: { $gte: new Date(Date.now() - lookbackMs) },
},
},
// Group documents by createdAt
{ $sort: { createdAt: -1 } },
{
$group: {
_id: '$createdAt',
Expand All @@ -43,7 +51,6 @@ export async function getRecentAlertHistories({
},
},
},
// Take the `createdAtLimit` most recent groups
{
$sort: {
_id: -1,
Expand Down
7 changes: 4 additions & 3 deletions packages/api/src/models/alertHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,12 @@ AlertHistorySchema.index(
{ expireAfterSeconds: ms('30d') / 1000 },
);

// Compound index for querying alert histories by alert and time
// Used by getPreviousAlertHistories and alerts router
// Supports queries like: { alert: id, createdAt: { $lte: date } } with sort { createdAt: -1 }
// Used by getRecentAlertHistories (matches on alert, sorts by createdAt)
AlertHistorySchema.index({ alert: 1, createdAt: -1 });

// Used by getPreviousAlertHistories (groups by {alert, group}, sorts by createdAt)
AlertHistorySchema.index({ alert: 1, group: 1, createdAt: -1 });

export default mongoose.model<IAlertHistory>(
'AlertHistory',
AlertHistorySchema,
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/routers/api/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ router.get('/', async (req, res: AlertsExpRes, next) => {
alerts.map(async alert => {
const history = await getRecentAlertHistories({
alertId: new ObjectId(alert._id),
interval: alert.interval,
limit: 20,
});

Expand Down
10 changes: 4 additions & 6 deletions packages/api/src/tasks/checkAlerts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -966,21 +966,19 @@ export const getPreviousAlertHistories = async (
50,
);

const lookbackDate = new Date(now.getTime() - ms('7d'));

const resultChunks = await Promise.all(
chunkedIds.map(async ids =>
AlertHistory.aggregate<AggregatedAlertHistory>([
// Filter for the given alerts, and only entries created before "now"
// This uses the compound index { alert: 1, createdAt: -1 }
{
$match: {
alert: { $in: ids },
createdAt: { $lte: now },
createdAt: { $lte: now, $gte: lookbackDate },
},
},
// Sort by alert and createdAt to leverage the index
// This ensures we can use the compound index efficiently
{
$sort: { alert: 1, createdAt: -1 },
$sort: { alert: 1, group: 1, createdAt: -1 },
},
// Group by alert ID AND group (if present), taking the first (latest) document for each combination
{
Expand Down
Loading