fix: Fix inaccurate openapi docs for external alerts API (#1676)

# Summary

This PR updates the external Alerts API specs so that the specs match the actual behavior of the API.

I've also added a validator to an API which was missing any validation.
This commit is contained in:
Drew Davis 2026-01-29 11:20:50 -05:00 committed by GitHub
parent b8ab312a4c
commit 941bc23ef4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 264 additions and 123 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/api": patch
---
fix: Fix inaccurate openapi docs for external alerts API

View file

@ -47,10 +47,12 @@
},
"name": {
"type": "string",
"nullable": true,
"example": "High Error Rate"
},
"message": {
"type": "string",
"nullable": true,
"example": "Error rate exceeds threshold"
},
"threshold": {
@ -59,6 +61,16 @@
},
"interval": {
"type": "string",
"enum": [
"1m",
"5m",
"15m",
"30m",
"1h",
"6h",
"12h",
"1d"
],
"example": "15m"
},
"thresholdType": {
@ -73,19 +85,28 @@
"type": "string",
"enum": [
"tile",
"search"
"saved_search"
],
"example": "tile"
},
"state": {
"type": "string",
"example": "inactive"
"enum": [
"OK",
"ALERT",
"INSUFFICIENT_DATA",
"DISABLED"
],
"example": "ALERT"
},
"channel": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"webhook"
],
"example": "webhook"
},
"webhookId": {
@ -100,10 +121,12 @@
},
"tileId": {
"type": "string",
"nullable": true,
"example": "65f5e4a3b9e77c001a901234"
},
"dashboard": {
"type": "string",
"nullable": true,
"example": "65f5e4a3b9e77c001a567890"
},
"savedSearch": {
@ -115,16 +138,32 @@
"nullable": true
},
"silenced": {
"type": "boolean",
"nullable": true
"type": "object",
"nullable": true,
"properties": {
"by": {
"type": "string",
"nullable": true
},
"at": {
"type": "string",
"format": "date-time"
},
"until": {
"type": "string",
"format": "date-time"
}
}
},
"createdAt": {
"type": "string",
"nullable": true,
"format": "date-time",
"example": "2023-01-01T00:00:00.000Z"
},
"updatedAt": {
"type": "string",
"nullable": true,
"format": "date-time",
"example": "2023-01-01T00:00:00.000Z"
}
@ -142,25 +181,47 @@
"properties": {
"dashboardId": {
"type": "string",
"nullable": true,
"example": "65f5e4a3b9e77c001a567890"
},
"tileId": {
"type": "string",
"nullable": true,
"example": "65f5e4a3b9e77c001a901234"
},
"savedSearchId": {
"type": "string",
"nullable": true,
"example": "65f5e4a3b9e77c001a345678"
},
"groupBy": {
"type": "string",
"nullable": true,
"example": "ServiceName"
},
"threshold": {
"type": "number",
"example": 100
},
"interval": {
"type": "string",
"enum": [
"1m",
"5m",
"15m",
"30m",
"1h",
"6h",
"12h",
"1d"
],
"example": "1h"
},
"source": {
"type": "string",
"enum": [
"tile",
"search"
"saved_search"
],
"example": "tile"
},
@ -177,6 +238,9 @@
"properties": {
"type": {
"type": "string",
"enum": [
"webhook"
],
"example": "webhook"
},
"webhookId": {
@ -187,10 +251,12 @@
},
"name": {
"type": "string",
"nullable": true,
"example": "Test Alert"
},
"message": {
"type": "string",
"nullable": true,
"example": "Test Alert Message"
}
}
@ -198,14 +264,52 @@
"UpdateAlertRequest": {
"type": "object",
"properties": {
"dashboardId": {
"type": "string",
"nullable": true,
"example": "65f5e4a3b9e77c001a567890"
},
"tileId": {
"type": "string",
"nullable": true,
"example": "65f5e4a3b9e77c001a901234"
},
"savedSearchId": {
"type": "string",
"nullable": true,
"example": "65f5e4a3b9e77c001a345678"
},
"groupBy": {
"type": "string",
"nullable": true,
"example": "ServiceName"
},
"threshold": {
"type": "number",
"example": 500
"example": 100
},
"interval": {
"type": "string",
"enum": [
"1m",
"5m",
"15m",
"30m",
"1h",
"6h",
"12h",
"1d"
],
"example": "1h"
},
"source": {
"type": "string",
"enum": [
"tile",
"saved_search"
],
"example": "tile"
},
"thresholdType": {
"type": "string",
"enum": [
@ -214,27 +318,14 @@
],
"example": "above"
},
"source": {
"type": "string",
"enum": [
"tile",
"search"
],
"example": "tile"
},
"dashboardId": {
"type": "string",
"example": "65f5e4a3b9e77c001a567890"
},
"tileId": {
"type": "string",
"example": "65f5e4a3b9e77c001a901234"
},
"channel": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"webhook"
],
"example": "webhook"
},
"webhookId": {
@ -245,11 +336,13 @@
},
"name": {
"type": "string",
"example": "Updated Alert Name"
"nullable": true,
"example": "Test Alert"
},
"message": {
"type": "string",
"example": "Updated message"
"nullable": true,
"example": "Test Alert Message"
}
}
},
@ -655,13 +748,11 @@
"value": {
"data": {
"id": "65f5e4a3b9e77c001a123456",
"name": "CPU Usage Alert",
"message": "CPU usage is above 80%",
"threshold": 80,
"interval": "5m",
"thresholdType": "above",
"source": "tile",
"state": "active",
"state": "ALERT",
"channel": {
"type": "webhook",
"webhookId": "65f5e4a3b9e77c001a789012"
@ -868,13 +959,11 @@
"data": [
{
"id": "65f5e4a3b9e77c001a123456",
"name": "High Error Rate",
"message": "Error rate exceeds threshold",
"threshold": 100,
"interval": "15m",
"thresholdType": "above",
"source": "tile",
"state": "inactive",
"state": "OK",
"channel": {
"type": "webhook",
"webhookId": "65f5e4a3b9e77c001a789012"

View file

@ -242,10 +242,10 @@ describe('External API Alerts', () => {
message: 'This should fail validation',
};
// API returns 500 for validation errors
// API returns 400 for validation errors
const response = await authRequest('post', ALERTS_BASE_URL)
.send(invalidInput)
.expect(500);
.expect(400);
expect(response.body).toHaveProperty('message');

View file

@ -30,15 +30,18 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
* example: "65f5e4a3b9e77c001a123456"
* name:
* type: string
* nullable: true
* example: "High Error Rate"
* message:
* type: string
* nullable: true
* example: "Error rate exceeds threshold"
* threshold:
* type: number
* example: 100
* interval:
* type: string
* enum: [1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d]
* example: "15m"
* thresholdType:
* type: string
@ -46,16 +49,18 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
* example: "above"
* source:
* type: string
* enum: [tile, search]
* enum: [tile, saved_search]
* example: "tile"
* state:
* type: string
* example: "inactive"
* enum: [OK, ALERT, INSUFFICIENT_DATA, DISABLED]
* example: "ALERT"
* channel:
* type: object
* properties:
* type:
* type: string
* enum: [webhook]
* example: "webhook"
* webhookId:
* type: string
@ -65,9 +70,11 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
* example: "65f5e4a3b9e77c001a345678"
* tileId:
* type: string
* nullable: true
* example: "65f5e4a3b9e77c001a901234"
* dashboard:
* type: string
* nullable: true
* example: "65f5e4a3b9e77c001a567890"
* savedSearch:
* type: string
@ -76,14 +83,26 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
* type: string
* nullable: true
* silenced:
* type: boolean
* type: object
* nullable: true
* properties:
* by:
* type: string
* nullable: true
* at:
* type: string
* format: date-time
* until:
* type: string
* format: date-time
* createdAt:
* type: string
* nullable: true
* format: date-time
* example: "2023-01-01T00:00:00.000Z"
* updatedAt:
* type: string
* nullable: true
* format: date-time
* example: "2023-01-01T00:00:00.000Z"
*
@ -98,19 +117,30 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
* properties:
* dashboardId:
* type: string
* nullable: true
* example: "65f5e4a3b9e77c001a567890"
* tileId:
* type: string
* nullable: true
* example: "65f5e4a3b9e77c001a901234"
* savedSearchId:
* type: string
* nullable: true
* example: "65f5e4a3b9e77c001a345678"
* groupBy:
* type: string
* nullable: true
* example: "ServiceName"
* threshold:
* type: number
* example: 100
* interval:
* type: string
* enum: [1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d]
* example: "1h"
* source:
* type: string
* enum: [tile, search]
* enum: [tile, saved_search]
* example: "tile"
* thresholdType:
* type: string
@ -121,55 +151,72 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
* properties:
* type:
* type: string
* enum: [webhook]
* example: "webhook"
* webhookId:
* type: string
* example: "65f5e4a3b9e77c001a789012"
* name:
* type: string
* nullable: true
* example: "Test Alert"
* message:
* type: string
* nullable: true
* example: "Test Alert Message"
*
* UpdateAlertRequest:
* type: object
* properties:
* dashboardId:
* type: string
* nullable: true
* example: "65f5e4a3b9e77c001a567890"
* tileId:
* type: string
* nullable: true
* example: "65f5e4a3b9e77c001a901234"
* savedSearchId:
* type: string
* nullable: true
* example: "65f5e4a3b9e77c001a345678"
* groupBy:
* type: string
* nullable: true
* example: "ServiceName"
* threshold:
* type: number
* example: 500
* example: 100
* interval:
* type: string
* enum: [1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d]
* example: "1h"
* source:
* type: string
* enum: [tile, saved_search]
* example: "tile"
* thresholdType:
* type: string
* enum: [above, below]
* example: "above"
* source:
* type: string
* enum: [tile, search]
* example: "tile"
* dashboardId:
* type: string
* example: "65f5e4a3b9e77c001a567890"
* tileId:
* type: string
* example: "65f5e4a3b9e77c001a901234"
* channel:
* type: object
* properties:
* type:
* type: string
* enum: [webhook]
* example: "webhook"
* webhookId:
* type: string
* example: "65f5e4a3b9e77c001a789012"
* name:
* type: string
* example: "Updated Alert Name"
* nullable: true
* example: "Test Alert"
* message:
* type: string
* example: "Updated message"
* nullable: true
* example: "Test Alert Message"
*
* AlertResponse:
* type: object
@ -191,6 +238,7 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
*/
const router = express.Router();
/**
* @openapi
* /api/v2/alerts/{id}:
@ -220,13 +268,11 @@ const router = express.Router();
* value:
* data:
* id: "65f5e4a3b9e77c001a123456"
* name: "CPU Usage Alert"
* message: "CPU usage is above 80%"
* threshold: 80
* interval: "5m"
* thresholdType: "above"
* source: "tile"
* state: "active"
* state: "ALERT"
* channel:
* type: "webhook"
* webhookId: "65f5e4a3b9e77c001a789012"
@ -298,13 +344,11 @@ router.get(
* value:
* data:
* - id: "65f5e4a3b9e77c001a123456"
* name: "High Error Rate"
* message: "Error rate exceeds threshold"
* threshold: 100
* interval: "15m"
* thresholdType: "above"
* source: "tile"
* state: "inactive"
* state: "OK"
* channel:
* type: "webhook"
* webhookId: "65f5e4a3b9e77c001a789012"
@ -388,23 +432,29 @@ router.get('/', async (req, res, next) => {
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post('/', 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;
const createdAlert = await createAlert(teamId, alertInput, userId);
router.post(
'/',
validateRequest({
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;
const createdAlert = await createAlert(teamId, alertInput, userId);
return res.json({
data: translateAlertDocumentToExternalAlert(createdAlert),
});
} catch (e) {
next(e);
}
});
return res.json({
data: translateAlertDocumentToExternalAlert(createdAlert),
});
} catch (e) {
next(e);
}
},
);
/**
* @openapi

View file

@ -1,4 +1,5 @@
// @ts-nocheck TODO: Fix When Restoring Alerts
import { FlattenMaps, LeanDocument } from 'mongoose';
import { z } from 'zod';
import { AlertDocument } from '@/models/alert';
@ -151,48 +152,6 @@ export const translateExternalChartToInternalChart = (
};
};
const translateChartDocumentToExternalChart = (
chart: z.infer<typeof chartSchema>,
): z.infer<typeof externalChartSchemaWithId> => {
const { id, x, name, y, w, h, series, seriesReturnType } = chart;
return {
id,
name,
x,
y,
w,
h,
asRatio: seriesReturnType === 'ratio',
series: series.map(s => {
const {
type,
table,
aggFn,
level,
field,
where,
groupBy,
sortOrder,
content,
numberFormat,
} = s;
return {
type,
dataSource: table === 'metrics' ? 'metrics' : 'events',
aggFn,
level,
field,
where,
groupBy,
sortOrder,
content,
numberFormat,
};
}),
};
};
export type ExternalDashboard = {
id: string;
name: string;
@ -220,12 +179,12 @@ export function translateDashboardDocumentToExternalDashboard(
// Alert related types and transformations
export type ExternalAlert = {
id: string;
name: string | null;
message: string | null;
name?: string | null;
message?: string | null;
threshold: number;
interval: string;
thresholdType: string;
source: string;
source?: string;
state: string;
channel: any;
team: string;
@ -233,16 +192,50 @@ export type ExternalAlert = {
dashboard?: string;
savedSearch?: string;
groupBy?: string;
silenced?: any;
createdAt: string;
updatedAt: string;
silenced?: {
by?: string;
at: string;
until: string;
};
createdAt?: string;
updatedAt?: string;
};
type AlertDocumentObject =
| AlertDocument
| FlattenMaps<LeanDocument<AlertDocument>>;
function hasCreatedAt(
alert: AlertDocumentObject,
): alert is AlertDocument & { createdAt: Date } {
return 'createdAt' in alert && alert.createdAt instanceof Date;
}
function hasUpdatedAt(
alert: AlertDocumentObject,
): alert is AlertDocument & { updatedAt: Date } {
return 'updatedAt' in alert && alert.updatedAt instanceof Date;
}
function transformSilencedToExternalSilenced(
silenced: AlertDocumentObject['silenced'],
): ExternalAlert['silenced'] {
return silenced
? {
by: silenced.by?.toString(),
at: silenced.at.toISOString(),
until: silenced.until.toISOString(),
}
: undefined;
}
export function translateAlertDocumentToExternalAlert(
alert: AlertDocument,
): ExternalAlert {
// Convert to plain object if it's a Mongoose document
const alertObj = alert.toJSON ? alert.toJSON() : { ...alert };
const alertObj: AlertDocumentObject = alert.toJSON
? alert.toJSON()
: { ...alert };
// Copy all fields, renaming _id to id, ensuring ObjectId's are strings
const result = {
@ -260,9 +253,13 @@ export function translateAlertDocumentToExternalAlert(
dashboard: alertObj.dashboard?.toString(),
savedSearch: alertObj.savedSearch?.toString(),
groupBy: alertObj.groupBy,
silenced: alertObj.silenced,
createdAt: alertObj.createdAt.toISOString(),
updatedAt: alertObj.updatedAt.toISOString(),
silenced: transformSilencedToExternalSilenced(alertObj.silenced),
createdAt: hasCreatedAt(alertObj)
? alertObj.createdAt.toISOString()
: undefined,
updatedAt: hasUpdatedAt(alertObj)
? alertObj.updatedAt.toISOString()
: undefined,
};
return result;