Repository navigation
feat(api): filter v2 list alerts by tag, search, source and state #3506
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7e17c4f
ed8f1b3
1a8a95c
2ffe073
c647387
9e788e1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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, | ||
| filter: FilterQuery<IAlert> = {}, | ||
| ) => { | ||
| return Alert.countDocuments({ ...filter, team: teamId }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| }; | ||
|
|
||
| export const getAlertById = async ( | ||
|
|
@@ -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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(), | ||
|
|
@@ -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(), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor —
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Repeated values are deduplicated for Repeated 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)]), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Narrowing the shared Removing 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({ | ||
|
|
@@ -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) { | ||
|
|
@@ -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 }; | ||
| } | ||
|
|
@@ -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; | ||
|
|
||
There was a problem hiding this comment.
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:
buildAlertsFiltersetsteam, andcountAlerts/getAlertsWithDisplayRefsoverride itbuildAlertsFilter(teamId, …)already returns{ team: teamId, … }. Both controller functions then spread the filter and setteam: teamIdagain, so theteamkey 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 passingteamthrough the filter. One way is to build the v2 filter without the team key. The other is to drop the separateteamIdargument and require a filter that comes frombuildAlertsFilter.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