Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions .changeset/silly-toes-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---

feat: Add alert history + ack to alert editor
14 changes: 14 additions & 0 deletions packages/api/src/controllers/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,20 @@ export const getAlertsEnhanced = async (teamId: ObjectId) => {
}>(['savedSearch', 'dashboard', 'createdBy', 'silenced.by']);
};

export const getAlertEnhanced = async (
alertId: ObjectId | string,
teamId: ObjectId,
) => {
return Alert.findOne({ _id: alertId, team: teamId }).populate<{
savedSearch: ISavedSearch;
dashboard: IDashboard;
createdBy?: IUser;
silenced?: IAlert['silenced'] & {
by: IUser;
};
}>(['savedSearch', 'dashboard', 'createdBy', 'silenced.by']);
};

export const deleteAlert = async (id: string, teamId: ObjectId) => {
return Alert.deleteOne({
_id: id,
Expand Down
87 changes: 86 additions & 1 deletion packages/api/src/routers/api/__tests__/alerts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import {
randomMongoId,
RAW_SQL_ALERT_TEMPLATE,
} from '@/fixtures';
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
import Alert, {
AlertSource,
AlertState,
AlertThresholdType,
} from '@/models/alert';
import AlertHistory from '@/models/alertHistory';
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';

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

describe('GET /alerts/:id', () => {
it('returns 404 for non-existent alert', async () => {
const fakeId = randomMongoId();
await agent.get(`/alerts/${fakeId}`).expect(404);
});

it('returns alert with empty history when no history exists', async () => {
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);

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

const res = await agent.get(`/alerts/${alert.body.data._id}`).expect(200);

expect(res.body.data._id).toBe(alert.body.data._id);
expect(res.body.data.history).toEqual([]);
expect(res.body.data.threshold).toBe(alert.body.data.threshold);
expect(res.body.data.interval).toBe(alert.body.data.interval);
expect(res.body.data.dashboard).toBeDefined();
expect(res.body.data.tileId).toBe(dashboard.body.tiles[0].id);
});

it('returns alert with history entries', async () => {
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);

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

const now = new Date(Date.now() - 60000);
const earlier = new Date(Date.now() - 120000);

await AlertHistory.create({
alert: alert.body.data._id,
createdAt: now,
state: AlertState.ALERT,
counts: 5,
lastValues: [{ startTime: now, count: 5 }],
});

await AlertHistory.create({
alert: alert.body.data._id,
createdAt: earlier,
state: AlertState.OK,
counts: 0,
lastValues: [{ startTime: earlier, count: 0 }],
});

const res = await agent.get(`/alerts/${alert.body.data._id}`).expect(200);

expect(res.body.data._id).toBe(alert.body.data._id);
expect(res.body.data.history).toHaveLength(2);
expect(res.body.data.history[0].state).toBe('ALERT');
expect(res.body.data.history[0].counts).toBe(5);
expect(res.body.data.history[1].state).toBe('OK');
expect(res.body.data.history[1].counts).toBe(0);
});
});
});
159 changes: 106 additions & 53 deletions packages/api/src/routers/api/alerts.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,90 @@
import type { AlertsApiResponse } from '@hyperdx/common-utils/dist/types';
import type {
AlertApiResponse,
AlertsApiResponse,
AlertsPageItem,
} from '@hyperdx/common-utils/dist/types';
import express from 'express';
import { pick } from 'lodash';
import { ObjectId } from 'mongodb';
import { z } from 'zod';
import { processRequest, validateRequest } from 'zod-express-middleware';

import { getRecentAlertHistoriesBatch } from '@/controllers/alertHistory';
import {
getRecentAlertHistories,
getRecentAlertHistoriesBatch,
} from '@/controllers/alertHistory';
import {
createAlert,
deleteAlert,
getAlertById,
getAlertEnhanced,
getAlertsEnhanced,
updateAlert,
validateAlertInput,
} from '@/controllers/alerts';
import { sendJson } from '@/utils/serialization';
import { IAlertHistory } from '@/models/alertHistory';
import { PreSerialized, sendJson } from '@/utils/serialization';
import { alertSchema, objectIdSchema } from '@/utils/zod';

const router = express.Router();

type EnhancedAlert = NonNullable<Awaited<ReturnType<typeof getAlertEnhanced>>>;

const formatAlertResponse = (
alert: EnhancedAlert,
history: Omit<IAlertHistory, 'alert'>[],
): PreSerialized<AlertsPageItem> => {
return {
history,
silenced: alert.silenced
? {
by: alert.silenced.by?.email,
at: alert.silenced.at,
until: alert.silenced.until,
}
: undefined,
createdBy: alert.createdBy
? pick(alert.createdBy, ['email', 'name'])
: undefined,
channel: pick(alert.channel, ['type']),
...(alert.dashboard && {
dashboardId: alert.dashboard._id,
dashboard: {
tiles: alert.dashboard.tiles
.filter(tile => tile.id === alert.tileId)
.map(tile => ({
id: tile.id,
config: { name: tile.config.name },
})),
...pick(alert.dashboard, ['_id', 'updatedAt', 'name', 'tags']),
},
}),
...(alert.savedSearch && {
savedSearchId: alert.savedSearch._id,
savedSearch: pick(alert.savedSearch, [
'_id',
'createdAt',
'name',
'updatedAt',
'tags',
]),
}),
...pick(alert, [
'_id',
'interval',
'scheduleOffsetMinutes',
'scheduleStartAt',
'threshold',
'thresholdType',
'state',
'source',
'tileId',
'createdAt',
'updatedAt',
]),
};
};

type AlertsExpRes = express.Response<AlertsApiResponse>;
router.get('/', async (req, res: AlertsExpRes, next) => {
try {
Expand All @@ -39,63 +105,50 @@ router.get('/', async (req, res: AlertsExpRes, next) => {

const data = alerts.map(alert => {
const history = historyMap.get(alert._id.toString()) ?? [];

return {
history,
silenced: alert.silenced
? {
by: alert.silenced.by?.email,
at: alert.silenced.at,
until: alert.silenced.until,
}
: undefined,
createdBy: alert.createdBy
? pick(alert.createdBy, ['email', 'name'])
: undefined,
channel: pick(alert.channel, ['type']),
...(alert.dashboard && {
dashboardId: alert.dashboard._id,
dashboard: {
tiles: alert.dashboard.tiles
.filter(tile => tile.id === alert.tileId)
.map(tile => ({
id: tile.id,
config: { name: tile.config.name },
})),
...pick(alert.dashboard, ['_id', 'updatedAt', 'name', 'tags']),
},
}),
...(alert.savedSearch && {
savedSearchId: alert.savedSearch._id,
savedSearch: pick(alert.savedSearch, [
'_id',
'createdAt',
'name',
'updatedAt',
'tags',
]),
}),
...pick(alert, [
'_id',
'interval',
'scheduleOffsetMinutes',
'scheduleStartAt',
'threshold',
'thresholdType',
'state',
'source',
'tileId',
'createdAt',
'updatedAt',
]),
};
return formatAlertResponse(alert, history);
});

sendJson(res, { data });
} catch (e) {
next(e);
}
});

type AlertExpRes = express.Response<AlertApiResponse>;
router.get(
'/:id',
validateRequest({
params: z.object({
id: objectIdSchema,
}),
}),
async (req, res: AlertExpRes, next) => {
try {
const teamId = req.user?.team;
if (teamId == null) {
return res.sendStatus(403);
}

const alert = await getAlertEnhanced(req.params.id, teamId);
if (!alert) {
return res.sendStatus(404);
}

const history = await getRecentAlertHistories({
alertId: new ObjectId(alert._id),
interval: alert.interval,
limit: 20,
});

const data = formatAlertResponse(alert, history);

sendJson(res, { data });
} catch (e) {
next(e);
}
},
);

router.post(
'/',
processRequest({ body: alertSchema }),
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/utils/serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ type JsonStringifiable = { toJSON(): string };
* toJSON(): string). This allows passing raw Mongoose data to sendJson()
* while keeping type inference from the typed Express response.
*/
type PreSerialized<T> = T extends string
export type PreSerialized<T> = T extends string
? string | JsonStringifiable
: T extends (infer U)[]
? PreSerialized<U>[]
Expand Down
Loading
Loading