Skip to content
Open
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/v2-alert-silence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/api': minor
---

feat: Silence and unsilence alerts through the external API with POST and DELETE /api/v2/alerts/{id}/silenced
167 changes: 167 additions & 0 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6600,6 +6600,173 @@
}
}
},
"/api/v2/alerts/{id}/silenced": {
"post": {
"summary": "Silence Alert",
"description": "Silences an alert until the given time. Notifications are suppressed while the alert is silenced.",
"operationId": "silenceAlert",
"tags": [
"Alerts"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Alert ID",
"example": "65f5e4a3b9e77c001a123456"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"mutedUntil"
],
"properties": {
"mutedUntil": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 timestamp, in the future, until which the alert is silenced.",
"example": "2026-01-01T00:00:00.000Z"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Successfully silenced alert",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlertResponseEnvelope"
}
}
}
},
"400": {
"description": "Invalid request, such as a mutedUntil in the past",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Alert not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
},
"delete": {
"summary": "Unsilence Alert",
"description": "Clears the silence on an alert so notifications resume",
"operationId": "unsilenceAlert",
"tags": [
"Alerts"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Alert ID",
"example": "65f5e4a3b9e77c001a123456"
}
],
"responses": {
"200": {
"description": "Successfully unsilenced alert",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AlertResponseEnvelope"
}
}
}
},
"400": {
"description": "Invalid request, such as a malformed alert ID",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Alert not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v2/charts/series": {
"post": {
"summary": "Query Chart Series Data",
Expand Down
33 changes: 33 additions & 0 deletions packages/api/src/controllers/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,34 @@ export const getAlertById = async (
});
};

export const silenceAlert = async (
alertId: ObjectId | string,
teamId: ObjectId | string,
userId: ObjectId,
until: Date,
) => {
const alert = await getAlertById(alertId, teamId);
if (!alert) {
return null;
}
alert.silenced = { by: userId, at: new Date(), until };
await alert.save();
return alert;
};

export const unsilenceAlert = async (
alertId: ObjectId | string,
teamId: ObjectId | string,
) => {
const alert = await getAlertById(alertId, teamId);
if (!alert) {
return null;
}
alert.silenced = undefined;
await alert.save();
return alert;
};

export const getTeamDashboardAlertsByDashboardAndTile = async (
teamId: ObjectId,
) => {
Expand Down Expand Up @@ -491,6 +519,11 @@ const DISPLAY_REF_POPULATE = [
{ path: 'dashboard', select: 'name tags tiles.id tiles.config.name' },
];

/** Populate (and project) the search or dashboard reference of an alert document in place */
export const populateAlertDisplayRefs = (
alert: NonNullable<Awaited<ReturnType<typeof getAlertById>>>,
) => alert.populate<AlertWithDisplayRefs>(DISPLAY_REF_POPULATE);

/** Get alerts, with a populated (and projected) search or dashboard reference */
export const getAlertsWithDisplayRefs = async (
teamId: ObjectId,
Expand Down
28 changes: 28 additions & 0 deletions packages/api/src/routers/api/__tests__/alerts.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,34 @@ describe('alerts router', () => {
);
});

it('accepts a silence time with a UTC offset', 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 until = new Date(Date.now() + 3 * 3600000);
await agent
.post(`/alerts/${alert.body.data._id}/silenced`)
.send({ mutedUntil: until.toISOString().replace('Z', '+02:00') })
.expect(200);

const alertFromDb = await Alert.findById(alert.body.data._id);
expect(alertFromDb!.silenced!.until.getTime()).toBe(
until.getTime() - 2 * 3600000,
);
});

it('can unsilence an alert', async () => {
const dashboard = await agent
.post('/dashboards')
Expand Down
32 changes: 15 additions & 17 deletions packages/api/src/routers/api/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
deleteAlert,
getAlertById,
getAlertEnhanced,
silenceAlert,
unsilenceAlert,
updateAlert,
validateAlertInput,
} from '@/controllers/alerts';
Expand All @@ -32,7 +34,11 @@ import { IAlertHistory } from '@/models/alertHistory';
import { resolveAlertDisplayFields } from '@/utils/alerts';
import { setBusinessContext } from '@/utils/instrumentation';
import { PreSerialized, sendJson } from '@/utils/serialization';
import { internalAlertSchema, objectIdSchema } from '@/utils/zod';
import {
internalAlertSchema,
mutedUntilSchema,
objectIdSchema,
} from '@/utils/zod';

const router = express.Router();

Expand Down Expand Up @@ -398,12 +404,7 @@ router.post(
'/:id/silenced',
validateRequest({
body: z.object({
mutedUntil: z
.string()
.datetime()
.refine(val => new Date(val) > new Date(), {
message: 'mutedUntil must be in the future',
}),
mutedUntil: mutedUntilSchema,
}),
params: z.object({
id: objectIdSchema,
Expand All @@ -416,16 +417,15 @@ router.post(
return res.sendStatus(403);
}

const alert = await getAlertById(req.params.id, teamId);
const alert = await silenceAlert(
req.params.id,
teamId,
req.user._id,
new Date(req.body.mutedUntil),
);
if (!alert) {
return res.status(404).json({ error: 'Alert not found' });
}
alert.silenced = {
by: req.user._id,
at: new Date(),
until: new Date(req.body.mutedUntil),
};
await alert.save();

res.sendStatus(200);
} catch (e) {
Expand All @@ -448,12 +448,10 @@ router.delete(
return res.sendStatus(403);
}

const alert = await getAlertById(req.params.id, teamId);
const alert = await unsilenceAlert(req.params.id, teamId);
if (!alert) {
return res.status(404).json({ error: 'Alert not found' });
}
alert.silenced = undefined;
await alert.save();

res.sendStatus(200);
} catch (e) {
Expand Down
Loading
Loading