Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a798d1f
feat: add rate of change as alert trigger condition
dhable Mar 18, 2026
5bd6e92
chore: add changeset for rate of change alerts
dhable Mar 18, 2026
c8ad541
fix: address PR feedback from CI and code quality bot
dhable Mar 19, 2026
89d0617
Merge branch 'main' into feature/rate-of-change-alerts
dhable Mar 19, 2026
e26dac3
Merge remote-tracking branch 'origin/main' into feature/rate-of-chang…
dhable Apr 1, 2026
e5bab0a
Merge branch 'feature/rate-of-change-alerts' of github.com:hyperdxio/…
dhable Apr 1, 2026
cc774c1
fix: skip alert when rate-of-change produces Infinity/-Infinity (#2034)
dhable Apr 2, 2026
edeb42b
Merge branch 'main' into feature/rate-of-change-alerts
dhable Apr 2, 2026
debd8b7
Merge branch 'main' into feature/rate-of-change-alerts
dhable Apr 2, 2026
e6101e4
fix: handle grouped rate-of-change alerts when bucket has no data
dhable Apr 2, 2026
c93c376
fix: address PR review feedback on rate-of-change alerts
dhable Apr 2, 2026
a499b1c
Merge branch 'main' into feature/rate-of-change-alerts
dhable Apr 2, 2026
4953f5c
refactor: split rate-of-change tests into separate file
dhable Apr 3, 2026
52837f3
Merge branch 'main' into feature/rate-of-change-alerts
dhable Apr 3, 2026
6551425
fix: remove unreachable changeType fallback in rate-of-change evaluation
dhable Apr 3, 2026
29117af
docs: clarify why Math.min is used for RoC date range lookback
dhable Apr 3, 2026
092721c
Merge branch 'main' into feature/rate-of-change-alerts
dhable Apr 3, 2026
d843d5c
Merge branch 'main' into feature/rate-of-change-alerts
dhable Apr 3, 2026
df23a99
Merge branch 'main' into feature/rate-of-change-alerts
dhable Apr 6, 2026
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
7 changes: 7 additions & 0 deletions .changeset/rate-of-change-alerts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@hyperdx/common-utils': minor
'@hyperdx/api': minor
'@hyperdx/app': minor
---

feat: add rate of change as alert trigger condition alongside existing threshold rules
6 changes: 6 additions & 0 deletions packages/api/src/controllers/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { z } from 'zod';

import type { ObjectId } from '@/models';
import Alert, {
AlertChangeType,
AlertChannel,
AlertConditionType,
AlertInterval,
AlertSource,
AlertThresholdType,
Expand All @@ -24,6 +26,8 @@ export type AlertInput = {
id?: string;
source?: AlertSource;
channel: AlertChannel;
conditionType?: AlertConditionType;
changeType?: AlertChangeType;
interval: AlertInterval;
scheduleOffsetMinutes?: number;
scheduleStartAt?: string | null;
Expand Down Expand Up @@ -141,6 +145,8 @@ const makeAlert = (alert: AlertInput, userId?: ObjectId): Partial<IAlert> => {
source: alert.source,
threshold: alert.threshold,
thresholdType: alert.thresholdType,
conditionType: alert.conditionType,
changeType: alert.changeType,
...(userId && { createdBy: userId }),

// Message template
Expand Down
23 changes: 23 additions & 0 deletions packages/api/src/models/alert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ export enum AlertThresholdType {
BELOW = 'below',
}

export enum AlertConditionType {
THRESHOLD = 'threshold',
RATE_OF_CHANGE = 'rate_of_change',
}

export enum AlertChangeType {
ABSOLUTE = 'absolute',
PERCENTAGE = 'percentage',
}

export enum AlertState {
ALERT = 'ALERT',
DISABLED = 'DISABLED',
Expand Down Expand Up @@ -44,6 +54,8 @@ export enum AlertSource {
export interface IAlert {
id: string;
channel: AlertChannel;
conditionType?: AlertConditionType;
changeType?: AlertChangeType;
interval: AlertInterval;
scheduleOffsetMinutes?: number;
scheduleStartAt?: Date | null;
Expand Down Expand Up @@ -87,6 +99,17 @@ const AlertSchema = new Schema<IAlert>(
enum: AlertThresholdType,
required: false,
},
conditionType: {
type: String,
enum: AlertConditionType,
default: AlertConditionType.THRESHOLD,
required: false,
},
changeType: {
type: String,
enum: AlertChangeType,
required: false,
},
interval: {
type: String,
required: true,
Expand Down
159 changes: 158 additions & 1 deletion packages/api/src/routers/api/__tests__/alerts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@ import {
getServer,
makeAlertInput,
makeRawSqlTile,
makeSavedSearchAlertInput,
makeTile,
randomMongoId,
} from '@/fixtures';
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
import Alert, {
AlertChangeType,
AlertConditionType,
AlertSource,
AlertThresholdType,
} from '@/models/alert';
import { SavedSearch } from '@/models/savedSearch';
Comment thread
dhable marked this conversation as resolved.
Outdated
import { Source } from '@/models/source';
Comment thread
dhable marked this conversation as resolved.
Outdated
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';

const MOCK_TILES = [makeTile(), makeTile(), makeTile(), makeTile(), makeTile()];
Expand Down Expand Up @@ -607,4 +615,153 @@ describe('alerts router', () => {
})
.expect(400);
});

describe('rate of change alerts', () => {
it('creates a rate-of-change alert with absolute change type', async () => {
const dashboard = await agent
.post('/dashboards')
.send({
name: 'RoC Dashboard',
tiles: MOCK_TILES,
tags: [],
})
.expect(200);

const resp = await agent
.post('/alerts')
.send({
...makeAlertInput({
dashboardId: dashboard.body.id,
tileId: MOCK_TILES[0].id,
webhookId: webhook._id.toString(),
}),
conditionType: AlertConditionType.RATE_OF_CHANGE,
changeType: AlertChangeType.ABSOLUTE,
})
.expect(200);

expect(resp.body.data.conditionType).toBe(
AlertConditionType.RATE_OF_CHANGE,
);
expect(resp.body.data.changeType).toBe(AlertChangeType.ABSOLUTE);
});

it('creates a rate-of-change alert with percentage change type', async () => {
const dashboard = await agent
.post('/dashboards')
.send({
name: 'RoC Dashboard',
tiles: MOCK_TILES,
tags: [],
})
.expect(200);

const resp = await agent
.post('/alerts')
.send({
...makeAlertInput({
dashboardId: dashboard.body.id,
tileId: MOCK_TILES[0].id,
webhookId: webhook._id.toString(),
}),
conditionType: AlertConditionType.RATE_OF_CHANGE,
changeType: AlertChangeType.PERCENTAGE,
})
.expect(200);

expect(resp.body.data.conditionType).toBe(
AlertConditionType.RATE_OF_CHANGE,
);
expect(resp.body.data.changeType).toBe(AlertChangeType.PERCENTAGE);
});

it('rejects rate-of-change alert without changeType', async () => {
const dashboard = await agent
.post('/dashboards')
.send({
name: 'RoC Dashboard',
tiles: MOCK_TILES,
tags: [],
})
.expect(200);

await agent
.post('/alerts')
.send({
...makeAlertInput({
dashboardId: dashboard.body.id,
tileId: MOCK_TILES[0].id,
webhookId: webhook._id.toString(),
}),
conditionType: AlertConditionType.RATE_OF_CHANGE,
})
.expect(400);
});

it('creates a threshold alert without conditionType (backward compat)', async () => {
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Backward Compat Dashboard',
tiles: MOCK_TILES,
tags: [],
})
.expect(200);

const resp = await agent
.post('/alerts')
.send(
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: MOCK_TILES[0].id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);

expect(resp.body.data.conditionType).toBeUndefined();
});

it('updates an existing alert to rate-of-change', async () => {
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Update RoC Dashboard',
tiles: MOCK_TILES,
tags: [],
})
.expect(200);

const createResp = await agent
.post('/alerts')
.send(
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: MOCK_TILES[0].id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);

const alertId = createResp.body.data._id;

const updateResp = await agent
.put(`/alerts/${alertId}`)
.send({
...makeAlertInput({
dashboardId: dashboard.body.id,
tileId: MOCK_TILES[0].id,
webhookId: webhook._id.toString(),
}),
conditionType: AlertConditionType.RATE_OF_CHANGE,
changeType: AlertChangeType.PERCENTAGE,
})
.expect(200);

expect(updateResp.body.data.conditionType).toBe(
AlertConditionType.RATE_OF_CHANGE,
);
expect(updateResp.body.data.changeType).toBe(AlertChangeType.PERCENTAGE);
});
});
});
2 changes: 2 additions & 0 deletions packages/api/src/routers/api/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ router.get('/', async (req, res: AlertsExpRes, next) => {
scheduleStartAt: alert.scheduleStartAt?.toISOString() ?? undefined,
threshold: alert.threshold,
thresholdType: alert.thresholdType,
conditionType: alert.conditionType,
changeType: alert.changeType,
channel: { type: alert.channel.type ?? undefined },
state: alert.state,
source: alert.source,
Expand Down
50 changes: 49 additions & 1 deletion packages/api/src/routers/external-api/__tests__/alerts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { ObjectId } from 'mongodb';
import request from 'supertest';

import { getLoggedInAgent, getServer } from '../../../fixtures';
import { AlertSource, AlertThresholdType } from '../../../models/alert';
import {
AlertChangeType,
AlertConditionType,
AlertSource,
AlertThresholdType,
} from '../../../models/alert';
import Alert from '../../../models/alert';
import Dashboard from '../../../models/dashboard';
import { SavedSearch } from '../../../models/savedSearch';
Expand Down Expand Up @@ -883,4 +888,47 @@ describe('External API Alerts', () => {
.expect(401);
});
});

describe('Rate of Change Alerts', () => {
it('should create and retrieve a rate-of-change alert', async () => {
const { alert } = await createTestAlert({
conditionType: AlertConditionType.RATE_OF_CHANGE,
changeType: AlertChangeType.PERCENTAGE,
});

expect(alert.conditionType).toBe(AlertConditionType.RATE_OF_CHANGE);
expect(alert.changeType).toBe(AlertChangeType.PERCENTAGE);

const getResp = await authRequest(
'get',
`${ALERTS_BASE_URL}/${alert.id}`,
).expect(200);

expect(getResp.body.data.conditionType).toBe(
AlertConditionType.RATE_OF_CHANGE,
);
expect(getResp.body.data.changeType).toBe(AlertChangeType.PERCENTAGE);
});

it('should reject rate-of-change without changeType', async () => {
const dashboard = await createTestDashboard();
const webhook = await createTestWebhook();

await authRequest('post', ALERTS_BASE_URL)
.send({
dashboardId: dashboard._id.toString(),
tileId: dashboard.tiles[0].id,
threshold: 50,
interval: '5m',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.ABOVE,
conditionType: AlertConditionType.RATE_OF_CHANGE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
})
.expect(400);
});
});
});
Loading
Loading