diff --git a/.changeset/v2-alert-silence.md b/.changeset/v2-alert-silence.md new file mode 100644 index 0000000000..e0504abd6b --- /dev/null +++ b/.changeset/v2-alert-silence.md @@ -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 diff --git a/packages/api/openapi.json b/packages/api/openapi.json index f6ea5912f6..e284e82e09 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -6600,6 +6600,163 @@ } } }, + "/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" + } + } + } + }, + "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", diff --git a/packages/api/src/controllers/alerts.ts b/packages/api/src/controllers/alerts.ts index 0e8762e48c..6f97547ccd 100644 --- a/packages/api/src/controllers/alerts.ts +++ b/packages/api/src/controllers/alerts.ts @@ -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, ) => { diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index 3085e3d764..4ae47106a1 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -23,6 +23,8 @@ import { deleteAlert, getAlertById, getAlertEnhanced, + silenceAlert, + unsilenceAlert, updateAlert, validateAlertInput, } from '@/controllers/alerts'; @@ -416,16 +418,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) { @@ -448,12 +449,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) { diff --git a/packages/api/src/routers/external-api/__tests__/alerts.int.test.ts b/packages/api/src/routers/external-api/__tests__/alerts.int.test.ts index 0d79f5ef43..24c295c879 100644 --- a/packages/api/src/routers/external-api/__tests__/alerts.int.test.ts +++ b/packages/api/src/routers/external-api/__tests__/alerts.int.test.ts @@ -935,6 +935,201 @@ describe('External API Alerts', () => { }); }); + describe('Silencing alerts', () => { + const futureDate = () => new Date(Date.now() + 60 * 60 * 1000); + + it('should silence an alert and return it with the silenced state', async () => { + const { alert } = await createTestAlert({ name: 'Silence Test Alert' }); + const mutedUntil = futureDate().toISOString(); + + const response = await authRequest( + 'post', + `${ALERTS_BASE_URL}/${alert.id}/silenced`, + ) + .send({ mutedUntil }) + .expect(200); + + expect(response.body.data.id).toBe(alert.id); + expect(response.body.data.silenced).toMatchObject({ + by: user._id.toString(), + until: mutedUntil, + }); + + const stored = await Alert.findById(alert.id); + expect(stored?.silenced?.until.toISOString()).toBe(mutedUntil); + expect(stored?.silenced?.by?.toString()).toBe(user._id.toString()); + }); + + it('should unsilence a silenced alert', async () => { + const { alert } = await createTestAlert({ name: 'Unsilence Test Alert' }); + await authRequest('post', `${ALERTS_BASE_URL}/${alert.id}/silenced`) + .send({ mutedUntil: futureDate().toISOString() }) + .expect(200); + + const response = await authRequest( + 'delete', + `${ALERTS_BASE_URL}/${alert.id}/silenced`, + ).expect(200); + + expect(response.body.data.id).toBe(alert.id); + expect(response.body.data.silenced).toBeUndefined(); + const stored = await Alert.findById(alert.id); + expect(stored?.silenced).toBeUndefined(); + }); + + it('should return inherited displayName and tags from the silence routes', async () => { + const dashboard = await createTestDashboard({ name: 'Inherited' }); + await Dashboard.updateOne( + { _id: dashboard._id }, + { tags: ['inherited-tag'] }, + ); + const alert = await createTestAlertDirectly({ + dashboard: dashboard._id, + tileId: dashboard.tiles[0].id, + }); + const url = `${ALERTS_BASE_URL}/${alert.id}`; + + const fetched = await authRequest('get', url).expect(200); + expect(fetched.body.data).toMatchObject({ + displayName: 'Inherited - Tile', + tags: ['inherited-tag'], + }); + + const silenced = await authRequest('post', `${url}/silenced`) + .send({ mutedUntil: futureDate().toISOString() }) + .expect(200); + expect(silenced.body.data).toMatchObject({ + displayName: fetched.body.data.displayName, + tags: fetched.body.data.tags, + }); + + const unsilenced = await authRequest('delete', `${url}/silenced`).expect( + 200, + ); + expect(unsilenced.body.data).toMatchObject({ + displayName: fetched.body.data.displayName, + tags: fetched.body.data.tags, + }); + }); + + it('should return 404 with a JSON body when silencing a non-existent alert', async () => { + const response = await authRequest( + 'post', + `${ALERTS_BASE_URL}/${new ObjectId().toString()}/silenced`, + ) + .send({ mutedUntil: futureDate().toISOString() }) + .expect(404); + expect(response.body).toEqual({ message: 'Alert not found' }); + }); + + it('should return 404 with a JSON body when unsilencing a non-existent alert', async () => { + const response = await authRequest( + 'delete', + `${ALERTS_BASE_URL}/${new ObjectId().toString()}/silenced`, + ).expect(404); + expect(response.body).toEqual({ message: 'Alert not found' }); + }); + + it('should reject a mutedUntil in the past', async () => { + const { alert } = await createTestAlert({ name: 'Past Silence Alert' }); + + await authRequest('post', `${ALERTS_BASE_URL}/${alert.id}/silenced`) + .send({ mutedUntil: new Date(Date.now() - 60 * 1000).toISOString() }) + .expect(400); + + const stored = await Alert.findById(alert.id); + expect(stored?.silenced).toBeUndefined(); + }); + + it('should accept an ISO 8601 timestamp with a UTC offset', async () => { + const { alert } = await createTestAlert({ name: 'Offset Silence Alert' }); + const until = new Date(Date.now() + 2 * 60 * 60 * 1000); + const offsetTs = new Date(until.getTime() + 2 * 60 * 60 * 1000) + .toISOString() + .replace(/\.\d{3}Z$/, '+02:00'); + + await authRequest('post', `${ALERTS_BASE_URL}/${alert.id}/silenced`) + .send({ mutedUntil: offsetTs }) + .expect(200); + const stored = await Alert.findById(alert.id); + expect(stored?.silenced?.until.getTime()).toBe( + Math.floor(until.getTime() / 1000) * 1000, + ); + }); + + it('should expose the silenced state on GET and list reads', async () => { + const { alert } = await createTestAlert({ name: 'Read Silence Alert' }); + const mutedUntil = futureDate().toISOString(); + await authRequest('post', `${ALERTS_BASE_URL}/${alert.id}/silenced`) + .send({ mutedUntil }) + .expect(200); + + const expected = { + by: user._id.toString(), + until: mutedUntil, + at: expect.any(String), + }; + const single = await authRequest( + 'get', + `${ALERTS_BASE_URL}/${alert.id}`, + ).expect(200); + expect(single.body.data.silenced).toMatchObject(expected); + + const list = await authRequest('get', ALERTS_BASE_URL).expect(200); + const listed = list.body.data.find( + (a: { id: string }) => a.id === alert.id, + ); + expect(listed.silenced).toMatchObject(expected); + }); + + it('should keep an existing silence when the alert is updated', async () => { + const { alert } = await createTestAlert({ name: 'Update Silence Alert' }); + const mutedUntil = futureDate().toISOString(); + await authRequest('post', `${ALERTS_BASE_URL}/${alert.id}/silenced`) + .send({ mutedUntil }) + .expect(200); + const current = await authRequest( + 'get', + `${ALERTS_BASE_URL}/${alert.id}`, + ).expect(200); + + await authRequest('put', `${ALERTS_BASE_URL}/${alert.id}`) + .send({ + threshold: 500, + interval: '1h', + thresholdType: AlertThresholdType.ABOVE, + source: AlertSource.TILE, + dashboardId: current.body.data.dashboardId, + tileId: current.body.data.tileId, + channel: current.body.data.channel, + }) + .expect(200); + + const after = await authRequest( + 'get', + `${ALERTS_BASE_URL}/${alert.id}`, + ).expect(200); + expect(after.body.data.threshold).toBe(500); + expect(after.body.data.silenced).toMatchObject({ until: mutedUntil }); + }); + + it('should reject a missing or malformed mutedUntil', async () => { + const { alert } = await createTestAlert({ name: 'Bad Silence Alert' }); + const url = `${ALERTS_BASE_URL}/${alert.id}/silenced`; + + await authRequest('post', url).send({}).expect(400); + await authRequest('post', url) + .send({ mutedUntil: 'tomorrow' }) + .expect(400); + }); + + it('should reject an invalid alert id', async () => { + await authRequest('post', `${ALERTS_BASE_URL}/not-an-id/silenced`) + .send({ mutedUntil: futureDate().toISOString() }) + .expect(400); + }); + }); + describe('Inline alerts', () => { // Team-scoped connection + source the inline chart configs reference. const makeSource = async () => { diff --git a/packages/api/src/routers/external-api/v2/alerts.ts b/packages/api/src/routers/external-api/v2/alerts.ts index 7a23df9d1a..4a4adc1987 100644 --- a/packages/api/src/routers/external-api/v2/alerts.ts +++ b/packages/api/src/routers/external-api/v2/alerts.ts @@ -8,6 +8,8 @@ import { deleteAlert, getAlertsWithDisplayRefs, getAlertWithDisplayRefs, + silenceAlert, + unsilenceAlert, updateAlert, validateAlertInput, } from '@/controllers/alerts'; @@ -945,4 +947,182 @@ router.delete( }, ); +/** + * @openapi + * /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' + */ +router.post( + '/:id/silenced', + processRequest({ + body: z.object({ + mutedUntil: z + .string() + .datetime({ offset: true }) + .refine(val => new Date(val) > new Date(), { + message: 'mutedUntil must be in the future', + }), + }), + params: z.object({ + id: objectIdSchema, + }), + }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + const userId = req.user?._id; + if (teamId == null || userId == null) { + return res.status(403).json({ message: 'Forbidden' }); + } + + const silenced = await silenceAlert( + req.params.id, + teamId, + userId, + new Date(req.body.mutedUntil), + ); + const alert = + silenced && (await getAlertWithDisplayRefs(req.params.id, teamId)); + if (alert == null) { + return res.status(404).json({ message: 'Alert not found' }); + } + + res.json({ + data: translateAlertDocumentToExternalAlertWithChartConfig(alert), + }); + } catch (e) { + next(e); + } + }, +); + +/** + * @openapi + * /api/v2/alerts/{id}/silenced: + * 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' + * '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' + */ +router.delete( + '/:id/silenced', + validateRequest({ + params: z.object({ + id: objectIdSchema, + }), + }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) { + return res.status(403).json({ message: 'Forbidden' }); + } + + const unsilenced = await unsilenceAlert(req.params.id, teamId); + const alert = + unsilenced && (await getAlertWithDisplayRefs(req.params.id, teamId)); + if (alert == null) { + return res.status(404).json({ message: 'Alert not found' }); + } + + res.json({ + data: translateAlertDocumentToExternalAlertWithChartConfig(alert), + }); + } catch (e) { + next(e); + } + }, +); + export default router;