diff --git a/.changeset/v2-alerts-list-filters.md b/.changeset/v2-alerts-list-filters.md new file mode 100644 index 0000000000..98743b9b01 --- /dev/null +++ b/.changeset/v2-alerts-list-filters.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/api': minor +--- + +feat: filter the v2 list alerts endpoint by tag, search, source and state diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 83203b68af..6c2fd20b0f 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -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" @@ -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": { @@ -6463,6 +6511,16 @@ } } }, + "400": { + "description": "Invalid query parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "401": { "description": "Unauthorized", "content": { diff --git a/packages/api/src/controllers/alerts.ts b/packages/api/src/controllers/alerts.ts index 9e040bacf2..f1c69d0ee5 100644 --- a/packages/api/src/controllers/alerts.ts +++ b/packages/api/src/controllers/alerts.ts @@ -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 = {}, +) => { + return Alert.countDocuments({ ...filter, team: teamId }); }; 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 = {}, ) => { - return Alert.find({ team: teamId }) + return Alert.find({ ...filter, team: teamId }) .sort({ _id: 1 }) .skip(offset) .limit(limit) diff --git a/packages/api/src/controllers/alertsPage.ts b/packages/api/src/controllers/alertsPage.ts index 87410fb536..fa21f6985f 100644 --- a/packages/api/src/controllers/alertsPage.ts +++ b/packages/api/src/controllers/alertsPage.ts @@ -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; +/** 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(), + /** 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)]), + ) + .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)) + .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>; + /** 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; -/** - * 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 { const filter: FilterQuery = { 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 { + const filter = buildAlertsFilter(teamId, params); + + if (params.createdBy != null) { + filter.createdBy = params.createdBy; + } + const cursor = params.cursor ? decodeCursor(params.cursor, alertsPageCursorSchema) : null; diff --git a/packages/api/src/routers/external-api/__tests__/alerts.int.test.ts b/packages/api/src/routers/external-api/__tests__/alerts.int.test.ts index 0d79f5ef43..7ddf6796b2 100644 --- a/packages/api/src/routers/external-api/__tests__/alerts.int.test.ts +++ b/packages/api/src/routers/external-api/__tests__/alerts.int.test.ts @@ -9,7 +9,7 @@ import { RAW_SQL_ALERT_TEMPLATE, RAW_SQL_NUMBER_ALERT_TEMPLATE, } from '@/fixtures'; -import { AlertSource, AlertThresholdType } from '@/models/alert'; +import { AlertSource, AlertState, AlertThresholdType } from '@/models/alert'; import Alert from '@/models/alert'; import Connection from '@/models/connection'; import Dashboard from '@/models/dashboard'; @@ -827,6 +827,187 @@ describe('External API Alerts', () => { await authRequest('get', `${ALERTS_BASE_URL}?limit=abc`).expect(400); await authRequest('get', `${ALERTS_BASE_URL}?limit=1.5`).expect(400); }); + + describe('filters', () => { + beforeEach(async () => { + await createTestAlertDirectly({ + displayName: 'Checkout errors', + tags: ['checkout', 'p1'], + }); + await createTestAlertDirectly({ + displayName: 'Checkout latency', + tags: ['checkout'], + state: AlertState.ALERT, + }); + await createTestAlertDirectly({ + displayName: 'Login failures', + tags: ['auth'], + source: AlertSource.INLINE, + }); + }); + + const listNames = async (query: string) => { + const response = await authRequest( + 'get', + `${ALERTS_BASE_URL}?${query}`, + ).expect(200); + return { + names: response.body.data.map(a => a.displayName), + meta: response.body.meta, + header: response.headers['x-total-count'], + }; + }; + + it('should return every alert when no filter is given', async () => { + const { names, meta } = await listNames(''); + expect(names).toHaveLength(3); + expect(meta.total).toBe(3); + }); + + it('should filter by a single tag', async () => { + const { names, meta } = await listNames('tag=p1'); + expect(names).toEqual(['Checkout errors']); + expect(meta.total).toBe(1); + }); + + it('should match any of several tags', async () => { + const { names } = await listNames('tag=p1&tag=auth'); + expect(names.sort()).toEqual(['Checkout errors', 'Login failures']); + }); + + it('should match tags exactly, not by prefix', async () => { + const { names } = await listNames('tag=check'); + expect(names).toEqual([]); + }); + + it('should search display names case-insensitively', async () => { + const { names } = await listNames('search=CHECKOUT'); + expect(names.sort()).toEqual(['Checkout errors', 'Checkout latency']); + }); + + it('should treat the search text literally', async () => { + const { names } = await listNames('search=.*'); + expect(names).toEqual([]); + }); + + it('should filter by state and source', async () => { + expect((await listNames('state=ALERT')).names).toEqual([ + 'Checkout latency', + ]); + expect((await listNames('source=inline')).names).toEqual([ + 'Login failures', + ]); + }); + + it('should combine filters with AND', async () => { + const { names } = await listNames('tag=checkout&search=latency'); + expect(names).toEqual(['Checkout latency']); + }); + + it('should report the filtered total and page within the filtered set', async () => { + const page1 = await listNames('tag=checkout&limit=1&offset=0'); + expect(page1.names).toHaveLength(1); + expect(page1.meta).toEqual({ total: 2, limit: 1, offset: 0 }); + expect(page1.header).toBe('2'); + + const page2 = await listNames('tag=checkout&limit=1&offset=1'); + expect(page2.names).toHaveLength(1); + expect(page2.names[0]).not.toBe(page1.names[0]); + expect(page2.meta.total).toBe(2); + }); + + it('should reject an unknown state or source', async () => { + await authRequest('get', `${ALERTS_BASE_URL}?state=BOGUS`).expect(400); + await authRequest('get', `${ALERTS_BASE_URL}?source=BOGUS`).expect(400); + }); + + it('should match any of several sources or states', async () => { + expect( + (await listNames('source=tile&source=inline')).names.sort(), + ).toEqual(['Checkout errors', 'Checkout latency', 'Login failures']); + expect((await listNames('state=ALERT&state=OK')).names.sort()).toEqual([ + 'Checkout errors', + 'Checkout latency', + 'Login failures', + ]); + expect((await listNames('state=ALERT&state=DISABLED')).names).toEqual([ + 'Checkout latency', + ]); + }); + + it('should treat a legacy alert without a source as saved_search', async () => { + await Alert.collection.insertOne({ + team: team._id, + displayName: 'Legacy alert', + threshold: 100, + interval: '1h', + thresholdType: AlertThresholdType.ABOVE, + channel: { + type: 'webhook', + webhookId: new ObjectId().toString(), + }, + }); + + expect((await listNames('source=saved_search')).names).toEqual([ + 'Legacy alert', + ]); + expect((await listNames('source=tile')).names.sort()).toEqual([ + 'Checkout errors', + 'Checkout latency', + ]); + }); + + it('should reject the ERROR state, which alerts never hold', async () => { + await authRequest('get', `${ALERTS_BASE_URL}?state=ERROR`).expect(400); + }); + + it('should accept a repeated filter value', async () => { + const { names } = await listNames(`${'state=OK&'.repeat(6)}limit=10`); + expect(names.sort()).toEqual(['Checkout errors', 'Login failures']); + }); + + it('should reject oversized filter values', async () => { + await authRequest( + 'get', + `${ALERTS_BASE_URL}?search=${'a'.repeat(513)}`, + ).expect(400); + await authRequest( + 'get', + `${ALERTS_BASE_URL}?tag=${'a'.repeat(33)}`, + ).expect(400); + const tooManyTags = Array.from({ length: 51 }, (_, i) => `tag=t${i}`); + await authRequest( + 'get', + `${ALERTS_BASE_URL}?${tooManyTags.join('&')}`, + ).expect(400); + }); + + it('should treat an empty tag or search as absent', async () => { + expect((await listNames('tag=')).names).toHaveLength(3); + expect((await listNames('search=')).names).toHaveLength(3); + }); + + it('should treat a legacy alert without state or tags as OK and untagged', async () => { + await Alert.collection.insertOne({ + team: team._id, + displayName: 'Legacy alert', + threshold: 100, + interval: '1h', + thresholdType: AlertThresholdType.ABOVE, + channel: { + type: 'webhook', + webhookId: new ObjectId().toString(), + }, + }); + + const ok = await listNames('state=OK'); + expect(ok.names).toContain('Legacy alert'); + const alerting = await listNames('state=ALERT'); + expect(alerting.names).not.toContain('Legacy alert'); + const tagged = await listNames('tag=checkout'); + expect(tagged.names).not.toContain('Legacy alert'); + }); + }); }); describe('Updating alerts', () => { diff --git a/packages/api/src/routers/external-api/v2/alerts.ts b/packages/api/src/routers/external-api/v2/alerts.ts index 7a23df9d1a..cb01c12cf0 100644 --- a/packages/api/src/routers/external-api/v2/alerts.ts +++ b/packages/api/src/routers/external-api/v2/alerts.ts @@ -11,6 +11,7 @@ import { updateAlert, validateAlertInput, } from '@/controllers/alerts'; +import { alertsFilterShape, buildAlertsFilter } from '@/controllers/alertsPage'; import { AlertSource } from '@/models/alert'; import { convertExternalAlertChartConfigToInternal, @@ -21,11 +22,7 @@ import { validateRequestWithEnhancedErrors as validateRequest, } from '@/utils/enhancedErrors'; import { translateAlertDocumentToExternalAlert } from '@/utils/externalApi'; -import { - getPagination, - paginationMeta, - paginationQuerySchema, -} from '@/utils/pagination'; +import { paginationMeta, paginationQuerySchema } from '@/utils/pagination'; import { alertSchema, ExternalAlertInput, @@ -550,6 +547,8 @@ router.get( * 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] * parameters: @@ -570,6 +569,50 @@ router.get( * minimum: 0 * 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: * '200': * description: Successfully retrieved alerts @@ -602,6 +645,12 @@ router.get( * total: 1 * limit: 1000 * offset: 0 + * '400': + * description: Invalid query parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * '401': * description: Unauthorized * content: @@ -617,9 +666,11 @@ router.get( * schema: * $ref: '#/components/schemas/Error' */ +const listAlertsQuerySchema = paginationQuerySchema.extend(alertsFilterShape); + router.get( '/', - processRequest({ query: paginationQuerySchema }), + processRequest({ query: listAlertsQuerySchema }), async (req, res, next) => { try { const teamId = req.user?.team; @@ -627,10 +678,13 @@ router.get( return res.status(403).json({ message: 'Forbidden' }); } - const { limit, offset } = getPagination(req.query); + const { limit, offset, ...filterParams } = listAlertsQuerySchema.parse( + req.query ?? {}, + ); + const filter = buildAlertsFilter(teamId, filterParams); const [alerts, total] = await Promise.all([ - getAlertsWithDisplayRefs(teamId, { limit, offset }), - countAlerts(teamId), + getAlertsWithDisplayRefs(teamId, { limit, offset }, filter), + countAlerts(teamId, filter), ]); // Surface the full count at the HTTP layer too, so a client that reads