mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
## Summary This PR adds BETWEEN and NOT BETWEEN alert threshold types. ### Screenshots or video <img width="2064" height="678" alt="Screenshot 2026-04-16 at 2 44 10 PM" src="https://github.com/user-attachments/assets/ac74ae00-65f8-44f8-80fb-689157d9adff" /> <img width="2062" height="673" alt="Screenshot 2026-04-16 at 2 44 17 PM" src="https://github.com/user-attachments/assets/9853d3a4-90a0-464b-97b2-1ff659e15688" /> ### How to test locally or on Vercel This must be tested locally, since alerts are not supported in the preview environment. To see the notification content, run an echo server locally and create a webhook that targets it (http://localhost:3000): ```bash npx http-echo-server ``` ### References - Linear Issue: Closes HDX-3988 - Related PRs:
612 lines
18 KiB
TypeScript
612 lines
18 KiB
TypeScript
import express from 'express';
|
|
import _ from 'lodash';
|
|
import { z } from 'zod';
|
|
|
|
import {
|
|
createAlert,
|
|
deleteAlert,
|
|
getAlertById,
|
|
getAlerts,
|
|
updateAlert,
|
|
validateAlertInput,
|
|
} from '@/controllers/alerts';
|
|
import {
|
|
processRequestWithEnhancedErrors as processRequest,
|
|
validateRequestWithEnhancedErrors as validateRequest,
|
|
} from '@/utils/enhancedErrors';
|
|
import { translateAlertDocumentToExternalAlert } from '@/utils/externalApi';
|
|
import { alertSchema, objectIdSchema } from '@/utils/zod';
|
|
|
|
/**
|
|
* @openapi
|
|
* components:
|
|
* schemas:
|
|
* Error:
|
|
* type: object
|
|
* properties:
|
|
* message:
|
|
* type: string
|
|
* description: Human-readable error message.
|
|
* example: "NOT_FOUND: Alert not found"
|
|
* AlertInterval:
|
|
* type: string
|
|
* enum: [1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d]
|
|
* description: Evaluation interval.
|
|
* AlertThresholdType:
|
|
* type: string
|
|
* enum: [above, below, above_exclusive, below_or_equal, equal, not_equal, between, not_between]
|
|
* description: Threshold comparison direction.
|
|
* AlertSource:
|
|
* type: string
|
|
* enum: [saved_search, tile]
|
|
* description: Alert source type.
|
|
* AlertState:
|
|
* type: string
|
|
* enum: [ALERT, OK, INSUFFICIENT_DATA, DISABLED]
|
|
* description: Current alert state.
|
|
* AlertChannelType:
|
|
* type: string
|
|
* enum: [webhook]
|
|
* description: Channel type.
|
|
* AlertSilenced:
|
|
* type: object
|
|
* description: Silencing metadata.
|
|
* properties:
|
|
* by:
|
|
* type: string
|
|
* description: User ID who silenced the alert.
|
|
* nullable: true
|
|
* example: "65f5e4a3b9e77c001a234567"
|
|
* at:
|
|
* type: string
|
|
* description: Silence start timestamp.
|
|
* format: date-time
|
|
* example: "2026-03-19T08:00:00.000Z"
|
|
* until:
|
|
* type: string
|
|
* description: Silence end timestamp.
|
|
* format: date-time
|
|
* example: "2026-03-20T08:00:00.000Z"
|
|
* AlertChannelWebhook:
|
|
* type: object
|
|
* required:
|
|
* - type
|
|
* - webhookId
|
|
* properties:
|
|
* type:
|
|
* $ref: '#/components/schemas/AlertChannelType'
|
|
* description: Channel type. Must be "webhook" for webhook alerts.
|
|
* webhookId:
|
|
* type: string
|
|
* description: Webhook destination ID.
|
|
* example: "65f5e4a3b9e77c001a789012"
|
|
* AlertChannel:
|
|
* oneOf:
|
|
* - $ref: '#/components/schemas/AlertChannelWebhook'
|
|
* discriminator:
|
|
* propertyName: type
|
|
* Alert:
|
|
* type: object
|
|
* properties:
|
|
* dashboardId:
|
|
* type: string
|
|
* description: Dashboard ID for tile-based alerts.
|
|
* nullable: true
|
|
* example: "65f5e4a3b9e77c001a567890"
|
|
* tileId:
|
|
* type: string
|
|
* description: Tile ID for tile-based alerts. Must be a line, stacked bar, or number type tile.
|
|
* nullable: true
|
|
* example: "65f5e4a3b9e77c001a901234"
|
|
* savedSearchId:
|
|
* type: string
|
|
* description: Saved search ID for saved_search alerts.
|
|
* nullable: true
|
|
* example: "65f5e4a3b9e77c001a345678"
|
|
* groupBy:
|
|
* type: string
|
|
* description: Group-by key for saved search alerts.
|
|
* nullable: true
|
|
* example: "ServiceName"
|
|
* threshold:
|
|
* type: number
|
|
* description: Threshold value for triggering the alert. For between and not_between threshold types, this is the lower bound.
|
|
* example: 100
|
|
* thresholdMax:
|
|
* type: number
|
|
* nullable: true
|
|
* description: Upper bound for between and not_between threshold types. Required when thresholdType is between or not_between, must be >= threshold.
|
|
* example: 500
|
|
* interval:
|
|
* $ref: '#/components/schemas/AlertInterval'
|
|
* description: Evaluation interval for the alert.
|
|
* example: "1h"
|
|
* scheduleOffsetMinutes:
|
|
* type: integer
|
|
* minimum: 0
|
|
* description: Offset from the interval boundary in minutes. For example, 2 with a 5m interval evaluates windows at :02, :07, :12, etc. (UTC).
|
|
* nullable: true
|
|
* example: 2
|
|
* scheduleStartAt:
|
|
* type: string
|
|
* format: date-time
|
|
* description: Absolute UTC start time anchor. Alert windows start from this timestamp and repeat every interval.
|
|
* nullable: true
|
|
* example: "2026-02-08T10:00:00.000Z"
|
|
* source:
|
|
* $ref: '#/components/schemas/AlertSource'
|
|
* description: Alert source type (tile-based or saved search).
|
|
* example: "tile"
|
|
* thresholdType:
|
|
* $ref: '#/components/schemas/AlertThresholdType'
|
|
* description: Threshold comparison direction.
|
|
* example: "above"
|
|
* channel:
|
|
* $ref: '#/components/schemas/AlertChannel'
|
|
* description: Alert notification channel configuration.
|
|
* name:
|
|
* type: string
|
|
* description: Human-friendly alert name.
|
|
* nullable: true
|
|
* example: "Test Alert"
|
|
* message:
|
|
* type: string
|
|
* description: Alert message template.
|
|
* nullable: true
|
|
* example: "Test Alert Message"
|
|
*
|
|
* AlertResponse:
|
|
* allOf:
|
|
* - $ref: '#/components/schemas/Alert'
|
|
* - type: object
|
|
* properties:
|
|
* id:
|
|
* type: string
|
|
* description: Unique alert identifier.
|
|
* example: "65f5e4a3b9e77c001a123456"
|
|
* state:
|
|
* $ref: '#/components/schemas/AlertState'
|
|
* description: Current alert state.
|
|
* example: "ALERT"
|
|
* teamId:
|
|
* type: string
|
|
* description: Team identifier.
|
|
* example: "65f5e4a3b9e77c001a345678"
|
|
* silenced:
|
|
* $ref: '#/components/schemas/AlertSilenced'
|
|
* description: Silencing metadata.
|
|
* nullable: true
|
|
* createdAt:
|
|
* type: string
|
|
* nullable: true
|
|
* format: date-time
|
|
* description: Creation timestamp.
|
|
* example: "2023-01-01T00:00:00.000Z"
|
|
* updatedAt:
|
|
* type: string
|
|
* nullable: true
|
|
* format: date-time
|
|
* description: Last update timestamp.
|
|
* example: "2023-01-01T00:00:00.000Z"
|
|
*
|
|
* CreateAlertRequest:
|
|
* allOf:
|
|
* - $ref: '#/components/schemas/Alert'
|
|
* - type: object
|
|
* required:
|
|
* - threshold
|
|
* - interval
|
|
* - thresholdType
|
|
* - channel
|
|
*
|
|
* UpdateAlertRequest:
|
|
* allOf:
|
|
* - $ref: '#/components/schemas/Alert'
|
|
* - type: object
|
|
* required:
|
|
* - threshold
|
|
* - interval
|
|
* - thresholdType
|
|
* - channel
|
|
*
|
|
* AlertResponseEnvelope:
|
|
* type: object
|
|
* properties:
|
|
* data:
|
|
* $ref: '#/components/schemas/AlertResponse'
|
|
* description: The alert object.
|
|
*
|
|
* AlertsListResponse:
|
|
* type: object
|
|
* properties:
|
|
* data:
|
|
* type: array
|
|
* description: List of alert objects.
|
|
* items:
|
|
* $ref: '#/components/schemas/AlertResponse'
|
|
*
|
|
* EmptyResponse:
|
|
* type: object
|
|
* properties: {}
|
|
*/
|
|
|
|
const router = express.Router();
|
|
|
|
/**
|
|
* @openapi
|
|
* /api/v2/alerts/{id}:
|
|
* get:
|
|
* summary: Get Alert
|
|
* description: Retrieves a specific alert by ID
|
|
* operationId: getAlert
|
|
* tags: [Alerts]
|
|
* parameters:
|
|
* - name: id
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* description: Alert ID
|
|
* example: "65f5e4a3b9e77c001a123456"
|
|
* responses:
|
|
* '200':
|
|
* description: Successfully retrieved alert
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/AlertResponseEnvelope'
|
|
* examples:
|
|
* alertResponse:
|
|
* summary: Single alert response
|
|
* value:
|
|
* data:
|
|
* id: "65f5e4a3b9e77c001a123456"
|
|
* threshold: 80
|
|
* interval: "5m"
|
|
* thresholdType: "above"
|
|
* source: "tile"
|
|
* state: "ALERT"
|
|
* channel:
|
|
* type: "webhook"
|
|
* webhookId: "65f5e4a3b9e77c001a789012"
|
|
* teamId: "65f5e4a3b9e77c001a345678"
|
|
* tileId: "65f5e4a3b9e77c001a901234"
|
|
* dashboardId: "65f5e4a3b9e77c001a567890"
|
|
* createdAt: "2023-03-15T10:20:30.000Z"
|
|
* updatedAt: "2023-03-15T14:25:10.000Z"
|
|
* '401':
|
|
* description: Unauthorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* '404':
|
|
* description: Alert not found
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
router.get(
|
|
'/:id',
|
|
validateRequest({
|
|
params: z.object({
|
|
id: objectIdSchema,
|
|
}),
|
|
}),
|
|
async (req, res, next) => {
|
|
try {
|
|
const teamId = req.user?.team;
|
|
if (teamId == null) {
|
|
return res.sendStatus(403);
|
|
}
|
|
|
|
const alert = await getAlertById(req.params.id, teamId);
|
|
|
|
if (alert == null) {
|
|
return res.sendStatus(404);
|
|
}
|
|
|
|
return res.json({
|
|
data: translateAlertDocumentToExternalAlert(alert),
|
|
});
|
|
} catch (e) {
|
|
next(e);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /api/v2/alerts:
|
|
* get:
|
|
* summary: List Alerts
|
|
* description: Retrieves a list of all alerts for the authenticated team
|
|
* operationId: listAlerts
|
|
* tags: [Alerts]
|
|
* responses:
|
|
* '200':
|
|
* description: Successfully retrieved alerts
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/AlertsListResponse'
|
|
* examples:
|
|
* alertsList:
|
|
* summary: List of alerts
|
|
* value:
|
|
* data:
|
|
* - id: "65f5e4a3b9e77c001a123456"
|
|
* threshold: 100
|
|
* interval: "15m"
|
|
* thresholdType: "above"
|
|
* source: "tile"
|
|
* state: "OK"
|
|
* channel:
|
|
* type: "webhook"
|
|
* webhookId: "65f5e4a3b9e77c001a789012"
|
|
* teamId: "65f5e4a3b9e77c001a345678"
|
|
* tileId: "65f5e4a3b9e77c001a901234"
|
|
* dashboardId: "65f5e4a3b9e77c001a567890"
|
|
* createdAt: "2023-01-01T00:00:00.000Z"
|
|
* updatedAt: "2023-01-01T00:00:00.000Z"
|
|
* '401':
|
|
* description: Unauthorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* example:
|
|
* message: "Unauthorized access. API key is missing or invalid."
|
|
*/
|
|
router.get('/', async (req, res, next) => {
|
|
try {
|
|
const teamId = req.user?.team;
|
|
if (teamId == null) {
|
|
return res.sendStatus(403);
|
|
}
|
|
|
|
const alerts = await getAlerts(teamId);
|
|
|
|
return res.json({
|
|
data: alerts.map(alert => translateAlertDocumentToExternalAlert(alert)),
|
|
});
|
|
} catch (e) {
|
|
next(e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /api/v2/alerts:
|
|
* post:
|
|
* summary: Create Alert
|
|
* description: Creates a new alert
|
|
* operationId: createAlert
|
|
* tags: [Alerts]
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/CreateAlertRequest'
|
|
* examples:
|
|
* tileAlert:
|
|
* summary: Create a tile-based alert
|
|
* value:
|
|
* dashboardId: "65f5e4a3b9e77c001a567890"
|
|
* tileId: "65f5e4a3b9e77c001a901234"
|
|
* threshold: 100
|
|
* interval: "1h"
|
|
* source: "tile"
|
|
* thresholdType: "above"
|
|
* channel:
|
|
* type: "webhook"
|
|
* webhookId: "65f5e4a3b9e77c001a789012"
|
|
* name: "Error Spike Alert"
|
|
* message: "Error rate has exceeded 100 in the last hour"
|
|
* responses:
|
|
* '200':
|
|
* description: Successfully created alert
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/AlertResponseEnvelope'
|
|
* '401':
|
|
* description: Unauthorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* '500':
|
|
* description: Server error or validation failure
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
router.post(
|
|
'/',
|
|
processRequest({
|
|
body: alertSchema,
|
|
}),
|
|
async (req, res, next) => {
|
|
const teamId = req.user?.team;
|
|
const userId = req.user?._id;
|
|
if (teamId == null || userId == null) {
|
|
return res.sendStatus(403);
|
|
}
|
|
try {
|
|
const alertInput = req.body;
|
|
await validateAlertInput(teamId, alertInput);
|
|
|
|
const createdAlert = await createAlert(teamId, alertInput, userId);
|
|
|
|
return res.json({
|
|
data: translateAlertDocumentToExternalAlert(createdAlert),
|
|
});
|
|
} catch (e) {
|
|
next(e);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /api/v2/alerts/{id}:
|
|
* put:
|
|
* summary: Update Alert
|
|
* description: Updates an existing alert
|
|
* operationId: updateAlert
|
|
* tags: [Alerts]
|
|
* parameters:
|
|
* - name: id
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* description: Alert ID
|
|
* example: "65f5e4a3b9e77c001a123456"
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/UpdateAlertRequest'
|
|
* examples:
|
|
* updateAlert:
|
|
* summary: Update alert properties
|
|
* value:
|
|
* threshold: 500
|
|
* interval: "1h"
|
|
* thresholdType: "above"
|
|
* source: "tile"
|
|
* dashboardId: "65f5e4a3b9e77c001a567890"
|
|
* tileId: "65f5e4a3b9e77c001a901234"
|
|
* channel:
|
|
* type: "webhook"
|
|
* webhookId: "65f5e4a3b9e77c001a789012"
|
|
* name: "Updated Alert Name"
|
|
* message: "Updated threshold and interval"
|
|
* responses:
|
|
* '200':
|
|
* description: Successfully updated alert
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/AlertResponseEnvelope'
|
|
* '401':
|
|
* description: Unauthorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* '404':
|
|
* description: Alert not found
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* '500':
|
|
* description: Server error or validation failure
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
router.put(
|
|
'/:id',
|
|
processRequest({
|
|
body: alertSchema,
|
|
params: z.object({
|
|
id: objectIdSchema,
|
|
}),
|
|
}),
|
|
async (req, res, next) => {
|
|
try {
|
|
const teamId = req.user?.team;
|
|
|
|
if (teamId == null) {
|
|
return res.sendStatus(403);
|
|
}
|
|
const { id } = req.params;
|
|
|
|
const alertInput = req.body;
|
|
await validateAlertInput(teamId, alertInput);
|
|
|
|
const alert = await updateAlert(id, teamId, alertInput);
|
|
|
|
if (alert == null) {
|
|
return res.sendStatus(404);
|
|
}
|
|
|
|
res.json({
|
|
data: translateAlertDocumentToExternalAlert(alert),
|
|
});
|
|
} catch (e) {
|
|
next(e);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /api/v2/alerts/{id}:
|
|
* delete:
|
|
* summary: Delete Alert
|
|
* description: Deletes an alert
|
|
* operationId: deleteAlert
|
|
* tags: [Alerts]
|
|
* parameters:
|
|
* - name: id
|
|
* in: path
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* description: Alert ID
|
|
* example: "65f5e4a3b9e77c001a123456"
|
|
* responses:
|
|
* '200':
|
|
* description: Successfully deleted alert
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/EmptyResponse'
|
|
* example: {}
|
|
* '401':
|
|
* description: Unauthorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* '404':
|
|
* description: Alert not found
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
router.delete(
|
|
'/:id',
|
|
validateRequest({
|
|
params: z.object({
|
|
id: objectIdSchema,
|
|
}),
|
|
}),
|
|
async (req, res, next) => {
|
|
try {
|
|
const teamId = req.user?.team;
|
|
const { id: alertId } = req.params;
|
|
if (teamId == null) {
|
|
return res.sendStatus(403);
|
|
}
|
|
|
|
await deleteAlert(alertId, teamId);
|
|
res.sendStatus(200);
|
|
} catch (e) {
|
|
next(e);
|
|
}
|
|
},
|
|
);
|
|
|
|
export default router;
|