Skip to content

Commit 483540a

Browse files
Merge branch 'main' into agent/legacy-span-error-status
2 parents da311cd + 0453aa1 commit 483540a

11 files changed

Lines changed: 644 additions & 41 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/api': patch
3+
---
4+
5+
fix: A grouped alert whose group vanishes from the data now resolves with the group's values in `{{attributes.*}}` (alert title and message) and `{{var.*}}` (webhook body)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/api': minor
3+
---
4+
5+
feat: filter the v2 list alerts endpoint by tag, search, source and state

‎docs/alert-webhook-template-variables.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ a HyperDX alert without parsing the human-readable message body.
2121
| `{{thresholdMax}}` | number | Upper bound of a `between`/`outside` condition. Always unset for every other comparator, even if the alert was once a range, so guard it (see below). |
2222
| `{{value}}` | number | The value that triggered or resolved the alert. |
2323
| `{{groupKey}}` | string | The breaching group, for a grouped alert. |
24-
| `{{var}}` | object | The breaching group's values by group-by column or SQL alias. Use one value at a time, e.g. `{{var.customer_id}}`, or `{{var.[service.name]}}` for a name with dots. Empty when not grouped, and on the resolve of a group that has no rows left. |
24+
| `{{var}}` | object | The breaching group's values by group-by column or SQL alias. Use one value at a time, e.g. `{{var.customer_id}}`, or `{{var.[service.name]}}` for a name with dots. Empty when not grouped. |
2525
| `{{sourceQuery}}` | string | The condition behind the alert — search expression or SQL (see below). |
2626
| `{{teamId}}` | string | Team the alert belongs to. |
2727
| `{{note}}` | string | The alert's freeform note — commonly a runbook link. |

‎packages/api/openapi.json‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6386,7 +6386,7 @@
63866386
"/api/v2/alerts": {
63876387
"get": {
63886388
"summary": "List Alerts",
6389-
"description": "Retrieves alerts for the authenticated team (paginated). Results are capped at `limit` (default and maximum 1000). When more records exist than are returned, `meta.total` exceeds `data.length`; clients with large collections must page with `limit`/`offset` to retrieve them all.",
6389+
"description": "Retrieves alerts for the authenticated team (paginated). Results are capped at `limit` (default and maximum 1000). When more records exist than are returned, `meta.total` exceeds `data.length`; clients with large collections must page with `limit`/`offset` to retrieve them all. Optional filters narrow the result set, and `meta.total` counts only the matching alerts.",
63906390
"operationId": "listAlerts",
63916391
"tags": [
63926392
"Alerts"
@@ -6414,6 +6414,54 @@
64146414
"default": 0
64156415
},
64166416
"description": "Number of alerts to skip before returning results."
6417+
},
6418+
{
6419+
"name": "tag",
6420+
"in": "query",
6421+
"required": false,
6422+
"schema": {
6423+
"type": "array",
6424+
"items": {
6425+
"type": "string",
6426+
"maxLength": 32
6427+
},
6428+
"maxItems": 50
6429+
},
6430+
"description": "Only return alerts that have any of the given tags. Repeat the parameter to pass several tags. Matches the tags stored on the alert, not tags inherited from the linked dashboard or saved search."
6431+
},
6432+
{
6433+
"name": "search",
6434+
"in": "query",
6435+
"required": false,
6436+
"schema": {
6437+
"type": "string",
6438+
"maxLength": 512
6439+
},
6440+
"description": "Only return alerts whose display name contains this text (case-insensitive). Matches the display name stored on the alert, not a name derived from the linked dashboard or saved search."
6441+
},
6442+
{
6443+
"name": "source",
6444+
"in": "query",
6445+
"required": false,
6446+
"schema": {
6447+
"type": "array",
6448+
"items": {
6449+
"$ref": "#/components/schemas/AlertSource"
6450+
}
6451+
},
6452+
"description": "Only return alerts of any of the given sources. Repeat the parameter to pass several sources."
6453+
},
6454+
{
6455+
"name": "state",
6456+
"in": "query",
6457+
"required": false,
6458+
"schema": {
6459+
"type": "array",
6460+
"items": {
6461+
"$ref": "#/components/schemas/AlertState"
6462+
}
6463+
},
6464+
"description": "Only return alerts in any of the given states. Repeat the parameter to pass several states."
64176465
}
64186466
],
64196467
"responses": {
@@ -6463,6 +6511,16 @@
64636511
}
64646512
}
64656513
},
6514+
"400": {
6515+
"description": "Invalid query parameters",
6516+
"content": {
6517+
"application/json": {
6518+
"schema": {
6519+
"$ref": "#/components/schemas/Error"
6520+
}
6521+
}
6522+
}
6523+
},
64666524
"401": {
64676525
"description": "Unauthorized",
64686526
"content": {

‎packages/api/src/controllers/alerts.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from '@hyperdx/common-utils/dist/guards';
1212
import { isRangeThresholdType } from '@hyperdx/common-utils/dist/types';
1313
import { groupBy } from 'lodash';
14-
import { Types } from 'mongoose';
14+
import { type FilterQuery, Types } from 'mongoose';
1515
import { z } from 'zod';
1616

1717
import { recordOnboardingTaskCompletion } from '@/controllers/user';
@@ -389,8 +389,11 @@ export const updateAlert = async (
389389
return alert;
390390
};
391391

392-
export const countAlerts = async (teamId: ObjectId) => {
393-
return Alert.countDocuments({ team: teamId });
392+
export const countAlerts = async (
393+
teamId: ObjectId,
394+
filter: FilterQuery<IAlert> = {},
395+
) => {
396+
return Alert.countDocuments({ ...filter, team: teamId });
394397
};
395398

396399
export const getAlertById = async (
@@ -506,8 +509,9 @@ const DISPLAY_REF_POPULATE = [
506509
export const getAlertsWithDisplayRefs = async (
507510
teamId: ObjectId,
508511
{ limit, offset }: { limit: number; offset: number },
512+
filter: FilterQuery<IAlert> = {},
509513
) => {
510-
return Alert.find({ team: teamId })
514+
return Alert.find({ ...filter, team: teamId })
511515
.sort({ _id: 1 })
512516
.skip(offset)
513517
.limit(limit)

‎packages/api/src/controllers/alertsPage.ts‎

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ import { objectIdSchema, stringListQueryParam, tagsSchema } from '@/utils/zod';
1111

1212
const MAX_PAGE_LIMIT = 500;
1313

14+
/** States an alert document can hold; ERROR only appears on history records. */
15+
const ALERT_FILTER_STATES = [
16+
AlertState.ALERT,
17+
AlertState.DISABLED,
18+
AlertState.INSUFFICIENT_DATA,
19+
AlertState.OK,
20+
AlertState.PENDING,
21+
] as const;
22+
1423
/** The page cursor's payload: the sort key of the last item on the page. */
1524
const alertsPageCursorSchema = z.object({
1625
n: z.string().nullable(),
@@ -19,6 +28,34 @@ const alertsPageCursorSchema = z.object({
1928

2029
type AlertsPageCursor = z.infer<typeof alertsPageCursorSchema>;
2130

31+
/** Filter parameters shared by the internal alerts page and the v2 list endpoint. */
32+
export const alertsFilterShape = {
33+
/** A list of tags to filter alerts by. Returns alerts that have any of the specified tags */
34+
tag: stringListQueryParam.pipe(tagsSchema).optional(),
35+
/** A list of alert sources to filter by. Returns alerts that match any of the specified sources */
36+
source: stringListQueryParam
37+
.pipe(
38+
z
39+
.array(z.nativeEnum(AlertSource))
40+
.optional()
41+
.transform(v => v && [...new Set(v)]),
42+
)
43+
.optional(),
44+
/** A list of alert states to filter by. Returns alerts that match any of the specified states */
45+
state: stringListQueryParam
46+
.pipe(
47+
z
48+
.array(z.enum(ALERT_FILTER_STATES))
49+
.optional()
50+
.transform(v => v && [...new Set(v)]),
51+
)
52+
.optional(),
53+
/** A search string to filter alerts by their display name. Substring, case-insensitive match */
54+
search: z.string().trim().max(MAX_ALERT_DISPLAY_NAME_LENGTH).optional(),
55+
};
56+
57+
export type AlertsFilterParams = z.infer<z.ZodObject<typeof alertsFilterShape>>;
58+
2259
/** Query parameters schema for the list alerts endpoint. */
2360
export const alertsPageQuerySchema = z
2461
.object({
@@ -28,18 +65,7 @@ export const alertsPageQuerySchema = z
2865
cursor: z.string().optional(),
2966
/** A user ID, to filter for alerts created by a specific user */
3067
createdBy: objectIdSchema.optional(),
31-
/** A list of tags to filter alerts by. Returns alerts that have any of the specified tags */
32-
tag: stringListQueryParam.pipe(tagsSchema).optional(),
33-
/** A list of alert sources to filter by. Returns alerts that match any of the specified sources */
34-
source: stringListQueryParam
35-
.pipe(z.array(z.nativeEnum(AlertSource)).optional())
36-
.optional(),
37-
/** A list of alert states to filter by. Returns alerts that match any of the specified states */
38-
state: stringListQueryParam
39-
.pipe(z.array(z.nativeEnum(AlertState)).optional())
40-
.optional(),
41-
/** A search string to filter alerts by their display name. Substring, case-insensitive match */
42-
search: z.string().trim().max(MAX_ALERT_DISPLAY_NAME_LENGTH).optional(),
68+
...alertsFilterShape,
4369
})
4470
.superRefine((query, ctx) => {
4571
if (query.cursor == null) {
@@ -64,19 +90,13 @@ export const alertsPageQuerySchema = z
6490

6591
export type AlertsPageParams = z.infer<typeof alertsPageQuerySchema>;
6692

67-
/**
68-
* Mongo filters for a single page.
69-
*/
70-
export function buildAlertsPageFilter(
93+
/** Mongo filter for the shared filter parameters, scoped to the team. */
94+
export function buildAlertsFilter(
7195
teamId: ObjectId | string,
72-
params: AlertsPageParams,
96+
params: AlertsFilterParams,
7397
): FilterQuery<IAlert> {
7498
const filter: FilterQuery<IAlert> = { team: teamId };
7599

76-
if (params.createdBy != null) {
77-
filter.createdBy = params.createdBy;
78-
}
79-
80100
if (params.tag != null) {
81101
filter.tags = { $in: params.tag };
82102
}
@@ -101,6 +121,22 @@ export function buildAlertsPageFilter(
101121
};
102122
}
103123

124+
return filter;
125+
}
126+
127+
/**
128+
* Mongo filters for a single page.
129+
*/
130+
export function buildAlertsPageFilter(
131+
teamId: ObjectId | string,
132+
params: AlertsPageParams,
133+
): FilterQuery<IAlert> {
134+
const filter = buildAlertsFilter(teamId, params);
135+
136+
if (params.createdBy != null) {
137+
filter.createdBy = params.createdBy;
138+
}
139+
104140
const cursor = params.cursor
105141
? decodeCursor(params.cursor, alertsPageCursorSchema)
106142
: null;

‎packages/api/src/models/alertHistory.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,25 @@ export interface IAlertHistoryAnalytics {
3232
notificationTargets?: AlertNotificationTargetTiming[];
3333
}
3434

35+
/** One group-by column and its value, stored as a pair. */
36+
export type GroupValue = { key: string; value: string };
37+
38+
export const attributesToGroupValues = (
39+
attributes: Record<string, string>,
40+
): GroupValue[] | undefined => {
41+
const entries = Object.entries(attributes);
42+
return entries.length > 0
43+
? entries.map(([key, value]) => ({ key, value }))
44+
: undefined;
45+
};
46+
47+
export const groupValuesToAttributes = (
48+
groupValues: GroupValue[] | null | undefined,
49+
): Record<string, string> | undefined =>
50+
groupValues?.length
51+
? Object.fromEntries(groupValues.map(v => [v.key, v.value]))
52+
: undefined;
53+
3554
export interface IAlertHistory {
3655
alert: ObjectId;
3756
counts: number;
@@ -40,6 +59,13 @@ export interface IAlertHistory {
4059
lastValues: { startTime: Date; count: number }[];
4160
group?: string; // For group-by alerts, stores the group identifier
4261
fired?: boolean;
62+
/**
63+
* Group-by values of a group-by alert's group. Kept so a group that later
64+
* vanishes from the data still resolves with its values, which the joined
65+
* `group` string cannot be parsed back into. Stored as pairs because column
66+
* names may contain dots, which Mongo would read as nested paths in a key.
67+
*/
68+
groupValues?: GroupValue[];
4369
/**
4470
* Errors recorded for this evaluation window. Present on ERROR-state rows
4571
* (query/processing failures where no normal history is written) and on
@@ -85,6 +111,17 @@ const AlertHistorySchema = new Schema<IAlertHistory>({
85111
type: Boolean,
86112
required: false,
87113
},
114+
groupValues: {
115+
type: [
116+
{
117+
_id: false,
118+
key: { type: String, required: true },
119+
value: { type: String },
120+
},
121+
],
122+
required: false,
123+
default: undefined,
124+
},
88125
errors: {
89126
type: [
90127
{

0 commit comments

Comments
 (0)