From 7e17c4f420c2b314ffd487ed60eb27b6c3e04794 Mon Sep 17 00:00:00 2001 From: Andrew Dunn Date: Sat, 24 Oct 2026 22:09:30 -0400 Subject: [PATCH 1/4] feat(api): filter v2 list alerts by tag, search, source and state Add optional tag, search (display name), source and state query parameters to GET /api/v2/alerts. The filter fields and Mongo filter builder are now shared with the internal alerts page, and meta.total and X-Total-Count count only the matching alerts. Sort order and offset paging are unchanged. --- .changeset/v2-alerts-list-filters.md | 5 + packages/api/openapi.json | 63 +++++++++- packages/api/src/controllers/alerts.ts | 12 +- packages/api/src/controllers/alertsPage.ts | 59 +++++---- .../external-api/__tests__/alerts.int.test.ts | 117 +++++++++++++++++- .../api/src/routers/external-api/v2/alerts.ts | 61 ++++++++- 6 files changed, 287 insertions(+), 30 deletions(-) create mode 100644 .changeset/v2-alerts-list-filters.md 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 f6ea5912f6..295459aca0 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -6366,7 +6366,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" @@ -6394,6 +6394,67 @@ "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": { + "type": "string", + "enum": [ + "saved_search", + "tile", + "inline" + ] + } + }, + "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": { + "type": "string", + "enum": [ + "ALERT", + "DISABLED", + "ERROR", + "INSUFFICIENT_DATA", + "OK", + "PENDING" + ] + } + }, + "description": "Only return alerts in any of the given states. Repeat the parameter to pass several states." } ], "responses": { diff --git a/packages/api/src/controllers/alerts.ts b/packages/api/src/controllers/alerts.ts index 0e8762e48c..c83482de1a 100644 --- a/packages/api/src/controllers/alerts.ts +++ b/packages/api/src/controllers/alerts.ts @@ -7,7 +7,7 @@ import { import { isRawSqlSavedChartConfig } 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'; @@ -378,8 +378,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 = { team: teamId }, +) => { + return Alert.countDocuments(filter); }; export const getAlertById = async ( @@ -495,8 +498,9 @@ const DISPLAY_REF_POPULATE = [ export const getAlertsWithDisplayRefs = async ( teamId: ObjectId, { limit, offset }: { limit: number; offset: number }, + filter: FilterQuery = { team: teamId }, ) => { - return Alert.find({ team: teamId }) + return Alert.find(filter) .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..1f0d44dd18 100644 --- a/packages/api/src/controllers/alertsPage.ts +++ b/packages/api/src/controllers/alertsPage.ts @@ -19,6 +19,24 @@ 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()) + .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(), +}; + +export type AlertsFilterParams = z.infer>; + /** Query parameters schema for the list alerts endpoint. */ export const alertsPageQuerySchema = z .object({ @@ -28,18 +46,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 +71,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 +102,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..6b18ac38a7 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,121 @@ 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 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..490948f8cb 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, @@ -550,6 +551,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 +573,52 @@ 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: + * type: string + * enum: [saved_search, tile, inline] + * 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: + * type: string + * enum: [ALERT, DISABLED, ERROR, INSUFFICIENT_DATA, OK, PENDING] + * description: >- + * Only return alerts in any of the given states. Repeat the + * parameter to pass several states. * responses: * '200': * description: Successfully retrieved alerts @@ -619,7 +668,9 @@ router.get( */ router.get( '/', - processRequest({ query: paginationQuerySchema }), + processRequest({ + query: paginationQuerySchema.extend(alertsFilterShape), + }), async (req, res, next) => { try { const teamId = req.user?.team; @@ -628,9 +679,13 @@ router.get( } const { limit, offset } = getPagination(req.query); + const filter = buildAlertsFilter( + teamId, + z.object(alertsFilterShape).parse(req.query ?? {}), + ); 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 From ed8f1b32adffbe6a0ac824e14fd9124e551b2918 Mon Sep 17 00:00:00 2001 From: Andrew Dunn Date: Mon, 2 Nov 2026 05:17:04 -0500 Subject: [PATCH 2/4] fix(api): scope list filters to the team, drop ERROR state, add filter tests --- packages/api/openapi.json | 11 +++- packages/api/src/controllers/alerts.ts | 8 +-- packages/api/src/controllers/alertsPage.ts | 23 ++++++- .../external-api/__tests__/alerts.int.test.ts | 61 +++++++++++++++++++ .../api/src/routers/external-api/v2/alerts.ts | 8 ++- 5 files changed, 103 insertions(+), 8 deletions(-) diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 295459aca0..dcec0ea0d7 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -6447,7 +6447,6 @@ "enum": [ "ALERT", "DISABLED", - "ERROR", "INSUFFICIENT_DATA", "OK", "PENDING" @@ -6504,6 +6503,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 c83482de1a..df38d4033d 100644 --- a/packages/api/src/controllers/alerts.ts +++ b/packages/api/src/controllers/alerts.ts @@ -380,9 +380,9 @@ export const updateAlert = async ( export const countAlerts = async ( teamId: ObjectId, - filter: FilterQuery = { team: teamId }, + filter: FilterQuery = {}, ) => { - return Alert.countDocuments(filter); + return Alert.countDocuments({ ...filter, team: teamId }); }; export const getAlertById = async ( @@ -498,9 +498,9 @@ const DISPLAY_REF_POPULATE = [ export const getAlertsWithDisplayRefs = async ( teamId: ObjectId, { limit, offset }: { limit: number; offset: number }, - filter: FilterQuery = { team: teamId }, + filter: FilterQuery = {}, ) => { - return Alert.find(filter) + 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 1f0d44dd18..82e8a7e25f 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(), @@ -25,11 +34,21 @@ export const alertsFilterShape = { 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()) + .pipe( + z + .array(z.nativeEnum(AlertSource)) + .max(Object.values(AlertSource).length) + .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()) + .pipe( + z + .array(z.enum(ALERT_FILTER_STATES)) + .max(ALERT_FILTER_STATES.length) + .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(), 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 6b18ac38a7..5ad793526d 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 @@ -921,6 +921,67 @@ describe('External API Alerts', () => { 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 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, diff --git a/packages/api/src/routers/external-api/v2/alerts.ts b/packages/api/src/routers/external-api/v2/alerts.ts index 490948f8cb..25f095ef5b 100644 --- a/packages/api/src/routers/external-api/v2/alerts.ts +++ b/packages/api/src/routers/external-api/v2/alerts.ts @@ -615,7 +615,7 @@ router.get( * type: array * items: * type: string - * enum: [ALERT, DISABLED, ERROR, INSUFFICIENT_DATA, OK, PENDING] + * enum: [ALERT, DISABLED, INSUFFICIENT_DATA, OK, PENDING] * description: >- * Only return alerts in any of the given states. Repeat the * parameter to pass several states. @@ -651,6 +651,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: From 1a8a95ccc04bd477c781d5984e3880ca813a4bc4 Mon Sep 17 00:00:00 2001 From: Andrew Dunn Date: Mon, 2 Nov 2026 05:44:21 -0500 Subject: [PATCH 3/4] fix(api): dedupe repeated list filter values and parse the v2 list query once --- packages/api/src/controllers/alertsPage.ts | 8 ++++---- .../external-api/__tests__/alerts.int.test.ts | 5 +++++ .../api/src/routers/external-api/v2/alerts.ts | 19 +++++++------------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/api/src/controllers/alertsPage.ts b/packages/api/src/controllers/alertsPage.ts index 82e8a7e25f..fa21f6985f 100644 --- a/packages/api/src/controllers/alertsPage.ts +++ b/packages/api/src/controllers/alertsPage.ts @@ -37,8 +37,8 @@ export const alertsFilterShape = { .pipe( z .array(z.nativeEnum(AlertSource)) - .max(Object.values(AlertSource).length) - .optional(), + .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 */ @@ -46,8 +46,8 @@ export const alertsFilterShape = { .pipe( z .array(z.enum(ALERT_FILTER_STATES)) - .max(ALERT_FILTER_STATES.length) - .optional(), + .optional() + .transform(v => v && [...new Set(v)]), ) .optional(), /** A search string to filter alerts by their display name. Substring, case-insensitive match */ 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 5ad793526d..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 @@ -961,6 +961,11 @@ describe('External API Alerts', () => { 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', diff --git a/packages/api/src/routers/external-api/v2/alerts.ts b/packages/api/src/routers/external-api/v2/alerts.ts index 25f095ef5b..f8654ef4f3 100644 --- a/packages/api/src/routers/external-api/v2/alerts.ts +++ b/packages/api/src/routers/external-api/v2/alerts.ts @@ -22,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, @@ -672,11 +668,11 @@ router.get( * schema: * $ref: '#/components/schemas/Error' */ +const listAlertsQuerySchema = paginationQuerySchema.extend(alertsFilterShape); + router.get( '/', - processRequest({ - query: paginationQuerySchema.extend(alertsFilterShape), - }), + processRequest({ query: listAlertsQuerySchema }), async (req, res, next) => { try { const teamId = req.user?.team; @@ -684,11 +680,10 @@ router.get( return res.status(403).json({ message: 'Forbidden' }); } - const { limit, offset } = getPagination(req.query); - const filter = buildAlertsFilter( - teamId, - z.object(alertsFilterShape).parse(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 }, filter), countAlerts(teamId, filter), From 2ffe0733578afcf58da9ce2f7cf31a3d619b5549 Mon Sep 17 00:00:00 2001 From: Andrew Dunn Date: Tue, 3 Nov 2026 06:09:26 -0800 Subject: [PATCH 4/4] docs(api): reference AlertSource and AlertState in the list filter params --- packages/api/openapi.json | 16 ++-------------- .../api/src/routers/external-api/v2/alerts.ts | 6 ++---- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/packages/api/openapi.json b/packages/api/openapi.json index dcec0ea0d7..21e622e5ff 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -6426,12 +6426,7 @@ "schema": { "type": "array", "items": { - "type": "string", - "enum": [ - "saved_search", - "tile", - "inline" - ] + "$ref": "#/components/schemas/AlertSource" } }, "description": "Only return alerts of any of the given sources. Repeat the parameter to pass several sources." @@ -6443,14 +6438,7 @@ "schema": { "type": "array", "items": { - "type": "string", - "enum": [ - "ALERT", - "DISABLED", - "INSUFFICIENT_DATA", - "OK", - "PENDING" - ] + "$ref": "#/components/schemas/AlertState" } }, "description": "Only return alerts in any of the given states. Repeat the parameter to pass several states." diff --git a/packages/api/src/routers/external-api/v2/alerts.ts b/packages/api/src/routers/external-api/v2/alerts.ts index f8654ef4f3..cb01c12cf0 100644 --- a/packages/api/src/routers/external-api/v2/alerts.ts +++ b/packages/api/src/routers/external-api/v2/alerts.ts @@ -599,8 +599,7 @@ router.get( * schema: * type: array * items: - * type: string - * enum: [saved_search, tile, inline] + * $ref: '#/components/schemas/AlertSource' * description: >- * Only return alerts of any of the given sources. Repeat the * parameter to pass several sources. @@ -610,8 +609,7 @@ router.get( * schema: * type: array * items: - * type: string - * enum: [ALERT, DISABLED, INSUFFICIENT_DATA, OK, PENDING] + * $ref: '#/components/schemas/AlertState' * description: >- * Only return alerts in any of the given states. Repeat the * parameter to pass several states.