Skip to content

feat: Adding remaining API v2 endpoints - saved searches & webhooks#2586

Open
jordan-simonovski wants to merge 16 commits into
mainfrom
jordansimonovski/crud-saved-search-webhook
Open

feat: Adding remaining API v2 endpoints - saved searches & webhooks#2586
jordan-simonovski wants to merge 16 commits into
mainfrom
jordansimonovski/crud-saved-search-webhook

Conversation

@jordan-simonovski

@jordan-simonovski jordan-simonovski commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds bearer-auth CRUD to the External API v2 for two resources so a provider (clickstack) can manage them programmatically:

  • Saved searches — new /api/v2/saved-searches router (list / get / create / update / delete), team-scoped.
  • Webhooks — upgrades /api/v2/webhooks from list-only to full CRUD (adds POST / PUT / DELETE).

Why

The v2 external API previously had no way to manage saved searches at all (/api/v2/search only executes a query), and webhooks were read-only. OSS parity only needs the read-only webhook data source, but the provider integration needs to manage both as first-class resources. The webhook CRUD is specifically what unblocks the clickstack_webhook resource (HDX-4490).

Alerts v2 already had full CRUD, so it's untouched.

What changed

  • routers/external-api/v2/savedSearches.ts (new) — team-scoped CRUD. Request/response use sourceId (not the internal source) for a consistent external contract; a requireValidSourceId middleware rejects a sourceId that isn't a source owned by the caller's team (prevents cross-team references). Reuses the existing savedSearch controllers and the model's toExternalJSON().
  • routers/external-api/v2/webhooks.ts — adds POST / PUT / DELETE. PUT is a full replace ($set present fields, $unset omitted/empty ones). Duplicate service+name → 400.
  • utils/zod.ts — adds externalWebhookCreateSchema + HTTP header validators for the request body.
  • controllers/savedSearch.ts — deleteSavedSearch now returns the deleted doc so the router can distinguish 404 from success (the only other caller ignored the return).
  • index.ts — mounts the new router behind validateUserAccessKey + rate limiter.
  • Integration tests for both routers.

Security / sensitive data

Webhook headers and queryParams are write-only: accepted on create/update but never returned by any read endpoint (externalWebhookSchema omits them), so auth tokens and other secrets don't leak. All operations are team-scoped (create/read/update/delete all filter by team); request bodies can't spoof team/_id (Zod strips unknown keys, team is set server-side).

Testing

  • make dev-int FILE=savedSearches-crud → 12/12 pass
  • make dev-int FILE=external-api/tests/webhooks → 21/21 pass
  • tsc --noEmit clean; yarn lint:fix clean (warnings pre-existing)

Coverage includes team-scoping/404 isolation, sourceId ownership validation, duplicate rejection, and assertions that webhook secrets are never returned on create.

Follow-up (not in this PR)

MCP agent tools don't yet cover the new write paths (webhook create/update/delete, saved-search delete) — tracked in HDX-4700.

Changeset

Included (@hyperdx/api minor).

resolves hdx-4673 hdx-4674

Saved searches and webhooks are the last API endpoints remaining. This
frees us up to finalise the tf provider build
@changeset-bot

changeset-bot Bot commented Jul 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 79628ef

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@hyperdx/api Minor
@hyperdx/common-utils Patch
@hyperdx/app Minor
@hyperdx/otel-collector Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview, Comment Jul 8, 2026 6:37am
hyperdx-storybook Ready Ready Preview, Comment Jul 8, 2026 6:37am

Request Review

@github-actions github-actions Bot added the review/tier-4 Critical — deep review + domain expert sign-off label Jul 3, 2026
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches auth, data models, config, tasks, OTel pipeline, ClickHouse, or CI/CD.

Why this tier:

  • Critical-path files (4):
    • packages/api/src/routers/external-api/v2/alerts.ts
    • packages/api/src/routers/external-api/v2/index.ts
    • packages/api/src/routers/external-api/v2/savedSearches.ts
    • packages/api/src/routers/external-api/v2/webhooks.ts
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api) + shared utils (packages/common-utils)

Review process: Deep review from a domain expert. Synchronous walkthrough may be required.
SLA: Schedule synchronous review within 2 business days.

Stats
  • Production files changed: 21
  • Production lines changed: 3959 (+ 1490 in test files, excluded from tier calculation)
  • Branch: jordansimonovski/crud-saved-search-webhook
  • Author: jordan-simonovski

To override this classification, remove the review/tier-4 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds full CRUD to the External API v2 for two previously read-limited (or absent) resources: a new /api/v2/saved-searches router and extended /api/v2/webhooks routes (POST / PUT / DELETE). Both are team-scoped, bearer-auth protected, and paginated.

  • Saved searches – new router with GET list (paginated), GET by ID, POST, PUT (full replace with filter-renderability enforcement and grandfathering for pre-existing legacy filters), and DELETE (cascades to dependent alerts via the refactored controller).
  • Webhooks – GET gains pagination + meta; POST uses direct Webhook.create() with duplicate-key catch; PUT implements a secure full-replace with write-only secret preservation / destination-change clearing and an optimistic-concurrency 409; DELETE mirrors the internal guard that blocks removal when alerts still reference the webhook.
  • Shared utilitiesfilterKey / isRenderablePinnedFilter / parseQuery in common-utils, paginationMeta helper, isDuplicateKeyError, and consolidated webhook header/query-param Zod schemas shared between the internal and external routers.

Confidence Score: 3/5

The webhook CRUD and pagination additions are solid, but the saved-search PUT grandfathering logic has a key-normalisation bug that will cause a 400 on any GET → PUT round-trip where the saved search was originally created by the UI without an explicit filter type field — a realistic and common case that would break provider tooling on existing data.

The filterKey function produces [null, condition] for stored filters whose type was never written, while incoming filters always carry type:'sql' from the schema default, yielding ["sql", condition]. The keys never match, so grandfathering is silently bypassed for those filters and the PUT rejects with 400 on real saved-search data. The fix is a one-line normalisation (f.type ?? 'sql'), but until it lands, any provider that reads a saved search and writes it back unchanged will hit a wall if the search contains a legacy typeless filter.

packages/api/src/routers/external-api/v2/savedSearches.ts — specifically the filterKey helper and how it is called in the PUT handler with .lean() results from the database.

Important Files Changed

Filename Overview
packages/api/src/routers/external-api/v2/savedSearches.ts New 724-line CRUD router. Core logic is sound; team-scoped, paginated, with filter-renderability enforcement. The filterKey grandfathering helper still mismatches legacy stored filters whose type was never written against incoming filters the schema defaults to type:'sql', making the PUT silently reject unchanged legacy filters instead of grandfathering them.
packages/api/src/routers/external-api/v2/webhooks.ts Extends list endpoint with pagination and adds POST/PUT/DELETE. PUT has well-considered optimistic-concurrency 409, destination-change secret clearing, and readable vs write-only field semantics. DELETE correctly mirrors the internal alert-reference guard. YAML example truncation and redundant pre-flight findOne from prior review are both fixed.
packages/api/src/controllers/savedSearch.ts Refactored deleteSavedSearch to a two-phase delete (alerts first, then parent) with a best-effort post-delete re-sweep; now returns the found document so the router can distinguish 404 from success.
packages/api/src/utils/zod.ts Adds externalWebhookCreateSchema with CRLF/control-char hardening on headers and query params; consolidates the duplicate internal validators into the shared schema. tagsSchema re-exported from common-utils.
packages/common-utils/src/filters.ts Adds parseQuery and isRenderablePinnedFilter with quote-aware SQL parsing. Logic is careful with escaping, lazy regex, and AND-keyword detection; well-covered by 109 new unit tests.
packages/api/src/utils/pagination.ts New shared pagination utility with Zod schema (limit/offset, max 1000), paginationMeta helper with truncation logging and counter metric.
packages/api/src/routers/external-api/tests/savedSearches-crud.test.ts 607-line integration test suite covering team isolation, sourceId ownership, filter renderability/grandfathering, pagination, and cascading delete.
packages/api/src/routers/external-api/tests/webhooks.test.ts Expanded to 582 lines covering create, update, delete, duplicate rejection, pagination, team isolation, referencing-alert guard, and secret non-disclosure on read.
packages/api/src/routers/api/webhooks.ts Internal webhook router now shares header/query-param validators from zod.ts; adds length caps that weren't present before. Consistent with the external schema.
packages/api/src/models/savedSearch.ts Adds compound index {team:1, _id:1} for efficient team-scoped list + sort queries.
packages/api/src/models/webhook.ts Adds compound index {team:1, _id:1} alongside the existing unique index to keep the _id sort covered for paginated list queries.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as Client (Clickstack)
    participant MW as Auth + Rate Limiter
    participant SS as /api/v2/saved-searches
    participant WH as /api/v2/webhooks
    participant SC as SavedSearch Controller
    participant DB as MongoDB

    Note over C,DB: Saved Search CRUD
    C->>MW: Bearer token
    MW->>SS: GET / (list)
    SS->>DB: "find({team}) + countDocuments (parallel)"
    DB-->>SS: [docs], total
    SS-->>C: "{data, meta:{total,limit,offset}}"

    C->>SS: POST / (create)
    SS->>SS: validateRequest (Zod body)
    SS->>SS: requireValidSourceId (team-scoped)
    SS->>SS: firstNonRenderableFilter check
    SS->>SC: createSavedSearch(teamId, input)
    SC->>DB: SavedSearch.create()
    DB-->>SC: doc
    SC-->>SS: doc
    SS-->>C: "{data: toExternalJSON()}"

    C->>SS: PUT /:id (update)
    SS->>DB: "findOne({_id,team},{filters:1}).lean()"
    DB-->>SS: existing filters
    SS->>SS: build grandfathered Set via filterKey()
    SS->>SS: firstNonRenderableFilter(req.body.filters, grandfathered)
    SS->>SC: updateSavedSearch(teamId, id, input)
    SC->>DB: findOneAndUpdate
    DB-->>SC: updated doc
    SS-->>C: "{data: toExternalJSON()}"

    C->>SS: DELETE /:id
    SS->>SC: deleteSavedSearch(teamId, id)
    SC->>DB: findOne (existence check)
    SC->>DB: deleteSavedSearchAlerts (pre-delete)
    SC->>DB: deleteOne (parent)
    SC->>DB: deleteSavedSearchAlerts (post-delete re-sweep)
    SS-->>C: "{}"

    Note over C,DB: Webhook CRUD
    C->>WH: POST / (create)
    WH->>WH: validateRequest (externalWebhookCreateSchema)
    WH->>DB: "Webhook.create({team,...})"
    DB-->>WH: doc (or 11000 duplicate key error)
    WH-->>C: "{data} or 400 duplicate"

    C->>WH: PUT /:id (update)
    WH->>DB: "findOne({_id,team}) snapshot url+service"
    WH->>WH: compute $set/$unset (destination-change secret clearing)
    WH->>DB: "findOneAndUpdate({_id,team,url,service}, op, {new:true})"
    DB-->>WH: updated doc or null (concurrent mod)
    WH-->>C: "{data} or 409 conflict or 404"

    C->>WH: DELETE /:id
    WH->>DB: "Alert.countDocuments({channel.webhookId,team})"
    DB-->>WH: count
    WH->>DB: "Webhook.findOneAndDelete (if count=0)"
    WH-->>C: "{} or 409 (alerts still reference)"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C as Client (Clickstack)
    participant MW as Auth + Rate Limiter
    participant SS as /api/v2/saved-searches
    participant WH as /api/v2/webhooks
    participant SC as SavedSearch Controller
    participant DB as MongoDB

    Note over C,DB: Saved Search CRUD
    C->>MW: Bearer token
    MW->>SS: GET / (list)
    SS->>DB: "find({team}) + countDocuments (parallel)"
    DB-->>SS: [docs], total
    SS-->>C: "{data, meta:{total,limit,offset}}"

    C->>SS: POST / (create)
    SS->>SS: validateRequest (Zod body)
    SS->>SS: requireValidSourceId (team-scoped)
    SS->>SS: firstNonRenderableFilter check
    SS->>SC: createSavedSearch(teamId, input)
    SC->>DB: SavedSearch.create()
    DB-->>SC: doc
    SC-->>SS: doc
    SS-->>C: "{data: toExternalJSON()}"

    C->>SS: PUT /:id (update)
    SS->>DB: "findOne({_id,team},{filters:1}).lean()"
    DB-->>SS: existing filters
    SS->>SS: build grandfathered Set via filterKey()
    SS->>SS: firstNonRenderableFilter(req.body.filters, grandfathered)
    SS->>SC: updateSavedSearch(teamId, id, input)
    SC->>DB: findOneAndUpdate
    DB-->>SC: updated doc
    SS-->>C: "{data: toExternalJSON()}"

    C->>SS: DELETE /:id
    SS->>SC: deleteSavedSearch(teamId, id)
    SC->>DB: findOne (existence check)
    SC->>DB: deleteSavedSearchAlerts (pre-delete)
    SC->>DB: deleteOne (parent)
    SC->>DB: deleteSavedSearchAlerts (post-delete re-sweep)
    SS-->>C: "{}"

    Note over C,DB: Webhook CRUD
    C->>WH: POST / (create)
    WH->>WH: validateRequest (externalWebhookCreateSchema)
    WH->>DB: "Webhook.create({team,...})"
    DB-->>WH: doc (or 11000 duplicate key error)
    WH-->>C: "{data} or 400 duplicate"

    C->>WH: PUT /:id (update)
    WH->>DB: "findOne({_id,team}) snapshot url+service"
    WH->>WH: compute $set/$unset (destination-change secret clearing)
    WH->>DB: "findOneAndUpdate({_id,team,url,service}, op, {new:true})"
    DB-->>WH: updated doc or null (concurrent mod)
    WH-->>C: "{data} or 409 conflict or 404"

    C->>WH: DELETE /:id
    WH->>DB: "Alert.countDocuments({channel.webhookId,team})"
    DB-->>WH: count
    WH->>DB: "Webhook.findOneAndDelete (if count=0)"
    WH-->>C: "{} or 409 (alerts still reference)"
Loading

Reviews (13): Last reviewed commit: "fix(review): address code-review finding..." | Re-trigger Greptile

Comment thread packages/api/src/routers/external-api/v2/webhooks.ts Outdated
Comment thread packages/api/src/routers/external-api/v2/webhooks.ts Outdated
Comment thread packages/api/src/routers/external-api/v2/savedSearches.ts Outdated
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

review content below

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 227 passed • 3 skipped • 1495s

Status Count
✅ Passed 227
❌ Failed 0
⚠️ Flaky 1
⏭️ Skipped 3

Tests ran across 4 shards in parallel.

View full report →

@jordan-simonovski jordan-simonovski changed the title feat: Adding remaining API v2 endpoints feat: Adding remaining API v2 endpoints - saved searches & webhooks Jul 3, 2026
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Deep Review

No critical issues found. No P0/P1 defects survived verification — team-scoping, the write-only secret handling for webhook headers/queryParams, the destination-change secret-clearing guard, and the optimistic-concurrency 409 path all check out. The findings below are correctness/robustness gaps and consistency issues worth addressing.

🟡 P2 -- recommended

  • packages/common-utils/src/filters.ts:484 -- isRenderablePinnedFilter only recognizes the literal ' AND ' (space-delimited), so a condition joining conjuncts with a tab or newline (e.g. col IN ('a')\nAND foo IN ('b')) passes the renderability check yet executes a second-column predicate the sidebar never shows, defeating the guard's displayed-equals-executed contract.
    • Fix: Collapse all Unicode whitespace runs to a single space before scanning for AND/IN/BETWEEN, or require the stored condition to be byte-equal to filtersToQuery's re-emitted form.
    • adversarial
  • packages/api/src/routers/external-api/v2/savedSearches.ts:631 -- the grandfather check builds its key set from existing.filters via an unchecked as Filter[] cast on a Mixed-typed column, so a stored filter whose shape does not match the re-defaulted request shape (e.g. a legacy entry missing an explicit type) keys differently and makes a benign read-modify-write PUT return 400 on data the API itself served.
    • Fix: Safe-parse existing.filters through the filter schema (dropping non-conforming entries) and normalize the type default inside filterKey so stored and echoed-back filters key identically.
    • correctness, kieran-typescript
  • packages/api/src/utils/pagination.ts:27 -- the default limit equals the max (1000), so previously-unbounded /api/v2/alerts and /api/v2/webhooks list responses now silently return only the first 1000 records for any team exceeding that, and a client that does not inspect meta.total/X-Total-Count processes a truncated set.
    • Fix: Confirm no live team exceeds the cap, or gate the truncation behind an explicit opt-in and keep returning the full set for existing non-paginating callers.
    • api-contract
  • packages/api/src/routers/external-api/v2/webhooks.ts:75 -- the GET-list handler drops rows that fail externalWebhookSchema via .filter(s => s !== undefined) and only logs, whereas the POST/PUT path emits webhookSerializationErrorCounter, so silently-dropped list rows have no metric and are undetectable by operators.
    • Fix: Increment webhookSerializationErrorCounter in formatExternalWebhook's failure branch so every serialization failure is counted regardless of path.
    • project-standards
  • packages/api/src/mcp/tools/savedSearches/index.ts:6 -- the new external-API write paths (saved-search delete, webhook create/update/delete) have no corresponding MCP tools, so an agent cannot perform actions an API client can; this parity gap is acknowledged as tracked follow-up but leaves the agent surface behind the API surface.
    • Fix: Add MCP tools mirroring the new write routes (or confirm the tracked follow-up covers each), handling the write-only headers/queryParams contract explicitly.
    • agent-native
🔵 P3 nitpicks (11)
  • packages/api/src/routers/external-api/v2/alerts.ts:730 -- DELETE /api/v2/alerts/:id now returns 404 for a missing alert instead of 200, making the endpoint non-idempotent so a retried delete of an already-removed alert now errors.
    • Fix: Confirm no client relies on idempotent delete-retry semantics, or keep returning 200 for an already-absent alert.
  • packages/api/src/controllers/savedSearch.ts:99 -- the post-delete alert re-sweep failure calls logger.warn with no paired counter, unlike the metric-plus-log pattern used elsewhere in this same change.
    • Fix: Add and increment a counter alongside the warn so the orphan window is alertable, not just log-scrapable.
  • packages/api/src/utils/pagination.ts:33 -- offset has a lower bound but no .max(), so an oversized integer offset flows into Mongo .skip() and can surface as a 500 rather than a 400.
    • Fix: Add a sane .max() to offset so oversized paging is rejected as a client error.
  • packages/api/src/routers/external-api/v2/webhooks.ts:337 -- the GET list excludes unserializable rows from data[] while meta.total/X-Total-Count still count them, so a client paging by comparing accumulated data.length to total never converges.
    • Fix: Exclude undecodable rows from the count too, or document that data.length is not a completeness signal.
  • packages/api/src/routers/external-api/v2/webhooks.ts:598 -- $set/$unset are typed Record<string, unknown>, so a future rename of a webhook field would silently build a no-op update key instead of failing to compile.
    • Fix: Type the update objects as a Partial<Pick<...>> / UpdateQuery slice of the webhook document fields.
  • packages/api/src/routers/api/webhooks.ts:174 -- the internal duplicate-detection pre-flight was changed from (team, service, url) to (team, service, name), altering the accept/reject boundary for edge-case inputs with no changeset entry.
    • Fix: Add a changeset note documenting the observable behavior change, even though it aligns the check with the unique index.
  • packages/api/src/mcp/tools/savedSearches/schemas.ts:14 -- the MCP save path shares createSavedSearch/updateSavedSearch with the external API but lacks its filter length cap and renderability check, so an MCP caller can persist filters the API would reject.
    • Fix: Share one filter-body schema between the MCP tool and the external router, or explicitly document/test the intentional exemption.
  • packages/api/src/mcp/tools/dashboards/schemas.ts:991 -- MCP tag schemas were narrowed to maxItems: 50 / maxLength: 32, so a round-trip of a dashboard/saved-search carrying legacy over-limit tags now fails validation where it previously succeeded.
    • Fix: Verify no stored tags exceed the new caps, or warn instead of reject on legacy over-limit values.
  • packages/api/src/routers/external-api/v2/savedSearches.ts:89 -- select/where use raw 4 * 1024/8 * 1024 literals while adjacent fields on the same schema use named constants.
    • Fix: Promote the caps to named constants so the shared 8KB size with MAX_FILTER_EXPR_LENGTH reads as intentional.
  • packages/common-utils/src/filters.ts:498 -- the \b(?:NOT|AND|OR)\b key guard also matches a legitimate column expression whose quoted map key equals one of those tokens (e.g. LogAttributes['OR'] IN ('x')), wrongly rejecting it as non-renderable.
    • Fix: Exclude quoted/bracketed segments from the keyword scan before testing the parsed key.
  • packages/api/src/routers/api/webhooks.ts:142 -- the internal router still inlines the same 7-field webhook body schema across three handlers, so future field changes must be duplicated by hand.
    • Fix: Extract one shared webhook body schema as was done for the external externalWebhookCreateSchema.

Reviewers (11): correctness, security, adversarial, testing, maintainability, api-contract, reliability, kieran-typescript, performance, project-standards, agent-native.

Testing gaps:

  • No test asserts webhook headers/queryParams are absent from create/update/list responses (the write-only guarantee).
  • No test for the destinationChanged secret-clearing on PUT or the 409 concurrent-modification path.
  • No test drives a concurrent alert-create into the saved-search delete window to prove the re-sweep prevents an orphan, nor the swallow-and-log failure branch.
  • No test for the respondPersistedButUnserializable 500 path or the isDuplicateKeyError backstop on the internal router.
  • No test for the grandfather read-modify-write of a legacy non-renderable filter, nor for renderability rejection of compound / non-space-AND / subquery conditions.
  • No test asserts the pagination truncation log/metric fires, or that the new compound indexes are used by the paginated queries.

Pre-existing / residual (not counted toward verdict): webhook url accepts internal/link-local hosts (blind SSRF) with no egress allowlist — shared with the internal router and now reachable by API-key callers; the non-atomic multi-document deleteSavedSearch leaves a narrow, log-only orphan window absent transactions; new list endpoints use the {data, meta} envelope while sibling v2 dashboards/sources lists still return {data} only.

Comment thread packages/api/src/routers/external-api/v2/webhooks.ts
Comment thread packages/api/src/routers/external-api/v2/savedSearches.ts Outdated
Comment thread packages/api/src/routers/external-api/__tests__/webhooks.test.ts
@pulpdrew pulpdrew mentioned this pull request Jul 6, 2026
// expression into filters[].condition/left/right.
const MAX_FILTER_EXPR_LENGTH = 8 * 1024;

const boundedFilterSchema = FilterSchema.superRefine((filter, ctx) => {

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.

We probably want a separate FilterSchema here because saved searches expect a very particular format for filters (type: 'sql', condition: "<col> IN ('<value1>', ...)" - otherwise the filters will not render in the sidebar. The existing savedFilterValues on the dashboards API has the same format, I believe (though there is probably better validation we could do there).

Image

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.

Good call. Resolved

@pulpdrew

pulpdrew commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Closes #2544

…ilter schema to ensure it is renderable on UI. Moved check into utils
- savedSearches: remove NUL bytes from filterKey (made the file binary to git);
  use a lean {filters:1} query on PUT instead of the fully-populated
  getSavedSearch; document that reads may return legacy lucene/sql_ast filters
- filters: reject pinned filters whose parsed key absorbed a NOT/AND/OR operator
  (e.g. `col NOT BETWEEN 1 AND 2`), which executed the inverse of the rendered facet
- webhooks: block external DELETE with 409 while an alert still references the
  webhook (mirrors the internal router; prevents orphaned alerts); emit a metric
  for the persisted-but-unserializable path and extract the duplicated block
- savedSearch controller: make the post-delete alert re-sweep best-effort so its
  failure logs a warning instead of turning a successful delete into a 500
- tests: NOT-operator filter rejection; external webhook DELETE 409 guard

@pulpdrew pulpdrew left a comment

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.

LGTM

Comment on lines +96 to +103
try {
await deleteSavedSearchAlerts(savedSearchId, teamId);
} catch (e) {
logger.warn(
{ err: e, savedSearchId, team: teamId },
'Post-delete alert re-sweep failed; a concurrently-created alert may be orphaned for this deleted saved search',
);
}

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.

If only we had transactions 😢

Comment on lines +64 to +66
tags: tagsSchema.describe(
`Dashboard tags. Up to ${MAX_TAGS} tags, each at most ${MAX_TAG_LENGTH} characters.`,
),

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.

Curious - are the constraints not already automatically communicated to the agent through the tagsSchema?

Comment on lines +617 to +622
// Grandfather filters that are unchanged from the stored search: a
// read-modify-write may echo back a legacy non-renderable filter the API
// itself served on GET. Only new/changed filters must render.
// Read only the stored filters for the grandfather check — a lean,
// projected query, not the fully-populated getSavedSearch (which also
// populates createdBy/updatedBy we don't need here).

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.

FWIW, I don't think that we should have any saved searches with the sql_ast form, since filters were not saved in saved searches at all until relatively recently, and I don't believe that we've made any changes since then related to sql_ast filters in search.

'channel.webhookId': req.params.id,
team: teamId,
});
if (referencingAlertCount > 0) {

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.

Thanks for updating this


// MongoDB duplicate-key error (unique-index violation). Used to translate a
// racing or colliding write into a 400 instead of a generic 500.
export const isDuplicateKeyError = (e: unknown): boolean =>

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/tier-4 Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants