Skip to content
Merged
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-alerts-list-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/api': minor
---

feat: filter the v2 list alerts endpoint by tag, search, source and state
60 changes: 59 additions & 1 deletion packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6386,7 +6386,7 @@
"/api/v2/alerts": {
"get": {
"summary": "List Alerts",
"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.",
"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.",
"operationId": "listAlerts",
"tags": [
"Alerts"
Expand Down Expand Up @@ -6414,6 +6414,54 @@
"default": 0
},
"description": "Number of alerts to skip before returning results."
},
{
"name": "tag",
"in": "query",
"required": false,
"schema": {
"type": "array",
"items": {
"type": "string",
"maxLength": 32
},
"maxItems": 50
},
"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."
},
{
"name": "search",
"in": "query",
"required": false,
"schema": {
"type": "string",
"maxLength": 512
},
"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."
},
{
"name": "source",
"in": "query",
"required": false,
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AlertSource"
}
},
"description": "Only return alerts of any of the given sources. Repeat the parameter to pass several sources."
},
{
"name": "state",
"in": "query",
"required": false,
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AlertState"
}
},
"description": "Only return alerts in any of the given states. Repeat the parameter to pass several states."
}
],
"responses": {
Expand Down Expand Up @@ -6463,6 +6511,16 @@
}
}
},
"400": {
"description": "Invalid query parameters",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
Expand Down
12 changes: 8 additions & 4 deletions packages/api/src/controllers/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '@hyperdx/common-utils/dist/guards';
import { isRangeThresholdType } from '@hyperdx/common-utils/dist/types';
import { groupBy } from 'lodash';
import { Types } from 'mongoose';
import { type FilterQuery, Types } from 'mongoose';
import { z } from 'zod';

import { recordOnboardingTaskCompletion } from '@/controllers/user';
Expand Down Expand Up @@ -389,8 +389,11 @@ export const updateAlert = async (
return alert;
};

export const countAlerts = async (teamId: ObjectId) => {
return Alert.countDocuments({ team: teamId });
export const countAlerts = async (
teamId: ObjectId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Team scoping now has two sources of truth: buildAlertsFilter sets team, and countAlerts/getAlertsWithDisplayRefs override it

buildAlertsFilter(teamId, …) already returns { team: teamId, … }. Both controller functions then spread the filter and set team: teamId again, so the team key from the builder is always overwritten and never used. Keep the override as the single guard, since it stops a caller from widening the scope, and stop passing team through the filter. One way is to build the v2 filter without the team key. The other is to drop the separate teamId argument and require a filter that comes from buildAlertsFilter.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

filter: FilterQuery<IAlert> = {},
) => {
return Alert.countDocuments({ ...filter, team: teamId });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Team scope is now applied in two places; the copy in the controller does nothing

buildAlertsFilter already sets team: teamId. countAlerts and getAlertsWithDisplayRefs then spread that filter and set team again. Keep one owner: either the controllers take only the extra predicates (have buildAlertsFilter return the filter without team), or the route passes the full filter and the controllers stop overriding team.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping both on purpose. Round one asked the controllers to scope by team whatever filter they are given, and buildAlertsFilter also builds the internal alerts page query, which needs its team predicate. The overlap sets the same value twice.

};

export const getAlertById = async (
Expand Down Expand Up @@ -506,8 +509,9 @@ const DISPLAY_REF_POPULATE = [
export const getAlertsWithDisplayRefs = async (
teamId: ObjectId,
{ limit, offset }: { limit: number; offset: number },
filter: FilterQuery<IAlert> = {},
) => {
return Alert.find({ team: teamId })
return Alert.find({ ...filter, team: teamId })
.sort({ _id: 1 })
.skip(offset)
.limit(limit)
Expand Down
78 changes: 57 additions & 21 deletions packages/api/src/controllers/alertsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ import { objectIdSchema, stringListQueryParam, tagsSchema } from '@/utils/zod';

const MAX_PAGE_LIMIT = 500;

/** States an alert document can hold; ERROR only appears on history records. */
const ALERT_FILTER_STATES = [
AlertState.ALERT,
AlertState.DISABLED,
AlertState.INSUFFICIENT_DATA,
AlertState.OK,
AlertState.PENDING,
] as const;

/** The page cursor's payload: the sort key of the last item on the page. */
const alertsPageCursorSchema = z.object({
n: z.string().nullable(),
Expand All @@ -19,6 +28,34 @@ const alertsPageCursorSchema = z.object({

type AlertsPageCursor = z.infer<typeof alertsPageCursorSchema>;

/** Filter parameters shared by the internal alerts page and the v2 list endpoint. */
export const alertsFilterShape = {
/** A list of tags to filter alerts by. Returns alerts that have any of the specified tags */
tag: stringListQueryParam.pipe(tagsSchema).optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — tag is not deduplicated, unlike source and state, so repeating one tag can trip the 50-item cap

source and state now pass through [...new Set(v)], but tag goes straight into tagsSchema (max(MAX_TAGS)). As a result ?tag=prod repeated 51 times returns 400, while ?state=OK repeated any number of times is accepted. That contradicts the new 'should accept a repeated filter value' contract. Dedupe tags before the length check (for example stringListQueryParam.transform(v => v && [...new Set(v)]).pipe(tagsSchema)), and add a repeated-tag case to the test.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Repeated values are deduplicated for source and state but not for tag, and the test doesn't check that deduplication happens

Repeated state and source values are deduplicated, but repeated tag values are not. So tag=prod repeated 51 times returns 400 (tagsSchema allows at most 50), while state=OK repeated any number of times is accepted. Also, Mongo's $in gives the same result with or without duplicates, so the 'should accept a repeated filter value' test passes even if the Set transforms are removed. Fix: add the same deduplication to tag before it reaches tagsSchema, add a test that repeats one tag more than 50 times, and either assert the parsed value directly (e.g. alertsFilterShape.state.parse([...])) or drop the transform.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

/** A list of alert sources to filter by. Returns alerts that match any of the specified sources */
source: stringListQueryParam
.pipe(
z
.array(z.nativeEnum(AlertSource))
.optional()
.transform(v => v && [...new Set(v)]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Set-dedup transforms on source/state make no difference, and the test for them passes without them

Drop both .transform(v => v && [...new Set(v)]) calls. Mongo $in ignores duplicate values, and these arrays have no .max(), so dedup changes neither results nor validation. tag gets no dedup, so a repeated tag=x x51 still returns 400 while a repeated state is accepted. If repeated tags should be accepted too, dedup before tagsSchema and add a test that sends the same tag more than MAX_TAGS times. As written, the integration test 'should accept a repeated filter value' passes with or without the transform.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

)
.optional(),
/** A list of alert states to filter by. Returns alerts that match any of the specified states */
state: stringListQueryParam
.pipe(
z
.array(z.enum(ALERT_FILTER_STATES))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Narrowing the shared state enum also changes the internal /alerts route, and that route has no test for it

Removing ERROR from alertsFilterShape means the internal endpoint (routers/api/alerts.ts:129, via alertsPageQuerySchema) now returns 400 for state=ERROR where it used to return 200 with an empty list. The PR description and tests only cover the v2 route. Add a state=ERROR → 400 case to the existing invalid-query table in routers/api/__tests__/alertsPagination.int.test.ts (near line 400) so the internal contract is pinned too.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

.optional()
.transform(v => v && [...new Set(v)]),
)
.optional(),
/** A search string to filter alerts by their display name. Substring, case-insensitive match */
search: z.string().trim().max(MAX_ALERT_DISPLAY_NAME_LENGTH).optional(),
};

export type AlertsFilterParams = z.infer<z.ZodObject<typeof alertsFilterShape>>;

/** Query parameters schema for the list alerts endpoint. */
export const alertsPageQuerySchema = z
.object({
Expand All @@ -28,18 +65,7 @@ export const alertsPageQuerySchema = z
cursor: z.string().optional(),
/** A user ID, to filter for alerts created by a specific user */
createdBy: objectIdSchema.optional(),
/** A list of tags to filter alerts by. Returns alerts that have any of the specified tags */
tag: stringListQueryParam.pipe(tagsSchema).optional(),
/** A list of alert sources to filter by. Returns alerts that match any of the specified sources */
source: stringListQueryParam
.pipe(z.array(z.nativeEnum(AlertSource)).optional())
.optional(),
/** A list of alert states to filter by. Returns alerts that match any of the specified states */
state: stringListQueryParam
.pipe(z.array(z.nativeEnum(AlertState)).optional())
.optional(),
/** A search string to filter alerts by their display name. Substring, case-insensitive match */
search: z.string().trim().max(MAX_ALERT_DISPLAY_NAME_LENGTH).optional(),
...alertsFilterShape,
})
.superRefine((query, ctx) => {
if (query.cursor == null) {
Expand All @@ -64,19 +90,13 @@ export const alertsPageQuerySchema = z

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

/**
* Mongo filters for a single page.
*/
export function buildAlertsPageFilter(
/** Mongo filter for the shared filter parameters, scoped to the team. */
export function buildAlertsFilter(
teamId: ObjectId | string,
params: AlertsPageParams,
params: AlertsFilterParams,
): FilterQuery<IAlert> {
const filter: FilterQuery<IAlert> = { team: teamId };

if (params.createdBy != null) {
filter.createdBy = params.createdBy;
}

if (params.tag != null) {
filter.tags = { $in: params.tag };
}
Expand All @@ -101,6 +121,22 @@ export function buildAlertsPageFilter(
};
}

return filter;
}

/**
* Mongo filters for a single page.
*/
export function buildAlertsPageFilter(
teamId: ObjectId | string,
params: AlertsPageParams,
): FilterQuery<IAlert> {
const filter = buildAlertsFilter(teamId, params);

if (params.createdBy != null) {
filter.createdBy = params.createdBy;
}

const cursor = params.cursor
? decodeCursor(params.cursor, alertsPageCursorSchema)
: null;
Expand Down
Loading
Loading