Skip to content

feat(api): add an alert evaluator extension point - #3576

Open
teeohhem wants to merge 3 commits into
mainfrom
claude/alert-evaluator
Open

teeohhem wants to merge 3 commits into
mainfrom
claude/alert-evaluator

Conversation

@teeohhem

@teeohhem teeohhem commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Summary

processAlert hard-codes threshold evaluation after its scheduling and chart-config setup. This adds an extension point for other detection strategies, and the threshold path is unchanged when none is registered.

  • AlertEvaluator (tasks/checkAlerts/evaluators.ts): appliesTo(alert), evaluate(ctx), and an optional evaluationNow(alert, now) clock shift. The first registered evaluator that applies takes over; otherwise the threshold query runs as before.
  • AlertEvaluationContext: what processAlert has already resolved (window, date range, unoptimized chart config, clients), plus the helpers the threshold path uses: getOrCreateHistory, trySendNotification, sendNotificationIfResolved, and a new persistHistories. Evaluators therefore get the same silencing, resolve and persistence behavior.
  • evaluationNow is applied in both processAlert and getAlertWindowStart, so the scheduled window and the history fetched up front stay aligned.
  • @/extensions: a registerExtensions() entry point, called once on the first evaluator lookup. It registers nothing in this repo.

Supporting changes:

  • In processAlert, the notification helpers now come before the threshold query (both paths use them); otherwise unchanged.
  • trySendNotification returns false when the send was suppressed (silenced) and true otherwise.
  • alerts.evaluate records an alert_evaluator attribute: threshold, or the evaluator's name.

How to test locally or on Vercel

  1. cd packages/api && yarn ci:unit src/tasks/checkAlerts: covers evaluator lookup, a registered evaluator bypassing the threshold query, the shifted clock, error recording, and the fallback to the threshold path.
  2. make dev-int FILE=checkAlerts: the threshold path is unchanged (335 tests).

🤖 Generated with Claude Code

processAlert hard-codes threshold evaluation after its scheduling and
config setup. Add an AlertEvaluator interface: the first registered
evaluator whose appliesTo matches evaluates the alert through an
AlertEvaluationContext (the same history, notification, and persistence
helpers the threshold path uses); otherwise the threshold path runs
unchanged. An evaluator can also shift the evaluation clock, applied in
processAlert and in getAlertWindowStart so window and history stay aligned.

Evaluators are registered from @/extensions, called once on first lookup.
It registers none here. The evaluator name is recorded on alerts.evaluate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@teeohhem
teeohhem requested a review from a team as a code owner November 5, 2026 21:24
@teeohhem
teeohhem requested a review from wrn14897 November 5, 2026 21:24
@vercel

vercel Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
hyperdx-oss Ready Ready Preview Nov 6, 2026 2:31pm UTC
hyperdx-storybook Ready Ready Preview Nov 6, 2026 2:31pm UTC

Request Review

@changeset-bot

changeset-bot Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: bf29ce8

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

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

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches authentication, tenancy data models, the public API or shipped database config — or substantially changes the query rendering engine, background tasks, the OTel pipeline, image build, or release CI.

Why this tier:

  • Background tasks or delivery pipeline substantially modified — 405 lines (bar: 30):
    • packages/api/src/tasks/checkAlerts/evaluators.ts
    • packages/api/src/tasks/checkAlerts/index.ts

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: 3
  • Production lines changed: 410 (+ 345 in test files, excluded from tier calculation)
  • Critical-path lines changed: 405
  • Branch: claude/alert-evaluator
  • Author: teeohhem

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

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Duplicate code analysis

No new duplicate code introduced by this PR.


jscpd compared this branch against origin/main
across app, api and common-utils. Only new clones are listed; the codebase
currently holds 110 in total (1.22%).
Run yarn dupes locally to reproduce, or yarn dupes:all for the full picture.

}
};

const persistHistories = async (

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 — persistHistories copies the threshold persist step, which still keeps its own two copies

The single-value branch (~1596-1606) and the time-series branch (~1830-1840) still do flush → attach analytics → updateAlertState by hand, and they already differ from persistHistories: they always attach analytics, while the helper skips analytics when the object is empty. Make both threshold exits call persistHistories(Array.from(histories.values())) so the 'same persistence behavior' the PR promises comes from one place.

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.

Fixed in bf29ce8: both threshold exits now call persistHistories. Both already set backfilledBuckets, so the skip-empty-analytics branch never applies to them and their behavior is unchanged.

teamWebhooksById: Map<string, IWebhook>,
) => {
const { alert, previousMap, recentHistoryMap } = details;
const now = getAlertEvaluationNow(alert, wallClockNow);

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 — Evaluator looked up twice per processAlert; the clock and the evaluator can come from different evaluators

getAlertEvaluationNow calls findAlertEvaluator internally, and the next line calls it again. If an appliesTo depends on mutable state, the clock shift and the evaluator that runs can belong to different evaluators. Look it up once (const evaluator = findAlertEvaluator(alert); const now = evaluator?.evaluationNow?.(alert, wallClockNow) ?? wallClockNow;), and give getAlertEvaluationNow the evaluator as a parameter.

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.

Fixed in bf29ce8: processAlert looks the evaluator up once and passes it to getAlertEvaluationNow.

expect(ctx?.nowInMinsRoundDown).toEqual(new Date('2025-01-01T00:00:00Z'));
});

it('records an error thrown by the evaluator on the alert', async () => {

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 — Nothing tests that the history prefetch window follows evaluationNow

The PR says evaluationNow keeps getAlertWindowStart (used by getConsecutiveWindowHistories) in line with processAlert, but the clock test only checks processAlert's nowInMinsRoundDown. Dropping the getAlertEvaluationNow call from getAlertWindowStart would still pass. Add a case with numConsecutiveWindows > 1 and a shifted clock that checks the createdAt range getConsecutiveWindowHistories queries (or export getAlertWindowStart and test it directly).

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.

Fixed in 48cc76b: getAlertWindowStart is exported and tested directly, for both a shifted clock and one that runs ahead of the wall clock (ignored).

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

PR Review

If you are a coding agent acting for the author, read this first.
Nothing below blocks merge automatically; a maintainer decides. What they will expect:

  • 🔴 critical or 🟠 major in code this PR adds or changes: fix before asking for review.
  • 🔵 minor in code this PR changes: your call. Fix if small, otherwise reply.
  • Anything about surrounding code, or asking you to widen the change (hoist a helper,
    dedupe with another file, fix other call sites): never fix here, whatever the
    severity. Reply with one sentence; if it is critical, say so plainly so a human sees it.

One commit per review round. After two rounds, stop and ask a maintainer to review
scope rather than addressing more automated findings. Full rule: AGENTS.md.

4 finding(s): 🔴 0 critical · 🟠 2 major · 🔵 2 minor

3 posted as inline comment(s) on the changed lines. 1 listed below.

Findings outside the changed lines

1 minor
  • 🔵 packages/api/src/tasks/checkAlerts/index.ts:1220 — Materialized-view optimization still runs for evaluator alerts, then its result is thrown away → tryOptimizeConfigWithMaterializedView (which queries ClickHouse metadata) and clickHouseSettings are computed before the if (evaluator) branch. That branch passes only the unoptimized chartConfig, and the context docs tell evaluators to redo MV selection themselves. So every evaluator alert pays for an optimization pass whose result nobody uses. Fix: move the optimizedChartConfig/clickHouseSettings computation below the evaluator branch, into the threshold path.

Severity is the reviewer's own estimate and is used for ordering, not filtering. No finding blocks merge automatically; a maintainer decides.

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Deep Review

Scope: packages/api/src/extensions/index.ts, packages/api/src/tasks/checkAlerts/evaluators.ts, packages/api/src/tasks/checkAlerts/index.ts, and the new evaluators.test.ts, diffed against the merge-base 62d76c0. The change adds an AlertEvaluator extension point to processAlert: the first registered evaluator whose appliesTo matches takes over evaluate(ctx), otherwise the threshold query path runs unchanged.

The threshold-path refactor is clean: persistHistories faithfully replaces both former updateAlertState call sites (both already set backfilledBuckets, so the skip-empty-analytics branch is a no-op for them), trySendNotification's new boolean return is additive, and the evaluationNow clamp (shifted != null && shifted < now) safely falls back to the wall clock for an equal, ahead, or Invalid Date result. Most prior Deep Review findings are genuinely addressed in 48cc76bc5/bf29ce8e3. One reliability gap in the clock/lookup path remains.

🟡 P2 — recommended

  • packages/api/src/tasks/checkAlerts/index.ts:466 — the evaluator lookup is only contained inside processAlert's try, but getAlertWindowStart → getAlertEvaluationNow → findAlertEvaluator → ensureExtensionsRegistered also runs during the up-front batch in getConsecutiveWindowHistories (index.ts:2034), outside that try; a throwing registerExtensions/appliesTo there rejects the Promise.all and fails history prefetch for every multi-window alert in the tick, not just the offending one. In this build registerExtensions is a no-op so nothing throws today, but the containment the PR claims is incomplete for the feature's intended downstream use.
    • Fix: Wrap the per-alert lookup in getConsecutiveWindowHistories so a failure is isolated to that alert and does not reject the batch, mirroring the containment processAlert already has.
🔵 P3 nitpicks (3)
  • packages/api/src/tasks/checkAlerts/index.ts:1884 — when findAlertEvaluator throws, evaluator stays undefined and the finally records alert_evaluator as THRESHOLD_EVALUATOR_NAME, so a failed lookup is attributed to the threshold evaluator even though no threshold evaluation ran.
    • Fix: Distinguish a failed lookup from the threshold path in the metric attribute, e.g. a separate label when the lookup threw.
  • packages/api/src/tasks/checkAlerts/evaluators.ts:106 — the JSDoc "The registered evaluator for this alert, or undefined for the threshold path" sits above ensureExtensionsRegistered but describes findAlertEvaluator, leaving two stacked comment blocks on the wrong function.
    • Fix: Move that one-line doc down onto findAlertEvaluator.
  • packages/api/src/extensions/index.ts:1 — the PR ships an @hyperdx/api change with no changeset (flagged by changeset-bot); confirm no version bump is intended for this internal extension point.
    • Fix: Add a changeset if the repo's release policy requires one for API-package changes, or confirm the omission is deliberate.

Reviewers (11): correctness, reliability, testing, maintainability, project-standards, kieran-typescript, api-contract, adversarial, previous-comments, agent-native, learnings-researcher.

Testing gaps: Confirm the test suite asserts that a registered evaluator actually bypasses the ClickHouse query (threshold path not taken), and that an evaluator throwing records the error while the finally labels alert_evaluator with the evaluator's name — these are the two behaviors most central to the new dispatch branch.

No finding blocks merge automatically. A maintainer will expect P0/P1 findings in code this PR changes to be fixed; P2/P3 are your call -- fix or reply. Never fix findings about surrounding code here; reply instead. Do not widen the PR. How to respond

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

E2E Test Results

✅ All tests passed • 462 passed • 1 skipped • 1698s

Status Count
✅ Passed 462
❌ Failed 0
⚠️ Flaky 2
⏭️ Skipped 1

Tests ran across 4 shards in parallel.

View full report →

teeohhem and others added 2 commits November 6, 2026 09:26
…clock

- Resolve the evaluator and clock inside processAlert's try, so a throwing
  appliesTo/evaluationNow/registerExtensions is recorded on the alert and
  counted on alerts.evaluate instead of escaping.
- Mark extensions registered only after registerExtensions succeeds; roll
  back a partial registration and retry on the next lookup, rather than
  silently routing alerts to the threshold path.
- Ignore an evaluationNow later than the wall clock: previous-state lookups
  bound history by the wall clock, which only covers every window when the
  evaluation clock is not ahead of it.
- Test the context contract: suppressed vs attempted sends, resolve via the
  context, merged persist errors, conditional analytics, and the up-front
  history window under a shifted or clamped clock.
- Note that AlertEvaluationContext is not a stable API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… results through persistHistories

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alert: IAlert,
wallClockNow: Date,
): Date => {
const now = getAlertEvaluationNow(alert, wallClockNow);

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.

🟠 major — An evaluator lookup failure in getAlertWindowStart stops every alert from loading, not just the faulty one

getConsecutiveWindowHistories now calls findAlertEvaluator through getAlertWindowStart. That call runs registerExtensions and each evaluator's appliesTo/evaluationNow, so any throw rejects the Promise.all in DefaultAlertProvider.getAlertTasks (providers/default.ts:435-438). It only takes one multi-window alert hitting a throwing appliesTo to fail getAlertTasks for the whole cycle. The containment added to processAlert does not cover this path. Fix: catch the lookup per alert in getConsecutiveWindowHistories (fall back to the wall clock or skip that alert's history), and add a test for it next to 'records a failing evaluator lookup on the alert'.

Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond

};
try {
evaluator = findAlertEvaluator(alert);
const now = getAlertEvaluationNow(alert, wallClockNow);

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 — Every evaluation looks up the evaluator twice

processAlert calls findAlertEvaluator(alert), and then getAlertEvaluationNow calls it again, so every appliesTo runs twice per evaluation. A non-deterministic appliesTo could return a clock from a different evaluator than the one that runs evaluate. Fix: change getAlertEvaluationNow to take the already-resolved evaluator (e.g. (evaluator, alert, now)) and pass it in from both callers.

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

@teeohhem

teeohhem commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Deep Review findings:

  1. Evaluator lookup outside the try: fixed in 48cc76b. The evaluator and clock are now resolved inside the try, so a throwing lookup is recorded on the alert and counted on alerts.evaluate. A failed lookup records an error instead of falling back to the threshold path. Evaluating an alert with a detector it wasn't written for would be wrong without any error.
  2. Registration latch set before registerExtensions: fixed in 48cc76b. The latch is set only after success. A failed registration is rolled back and rethrown, then retried on the next lookup.
  3. getPreviousAlertHistories uses the wall clock: that bound (createdAt <= wallClock) covers every window as long as the evaluation clock is not ahead of the wall clock. 48cc76b now enforces that: an evaluationNow later than the wall clock is ignored, and the docs say so.
  4. Context contract untested: added in 48cc76b. The new tests cover:
    • suppressed vs attempted sends
    • a resolve notification sent through the context
    • merged persist errors and analytics attached only when measured
    • the up-front history window under a shifted or ahead-of-wall-clock clock
    • a failing lookup
    • registration rollback and retry
  5. Narrow the context, add a stability marker and a consumer fixture: I added the marker: AlertEvaluationContext is documented as an internal extension point, not a stable API. I'm leaving the shape as is. The members are the helpers the threshold path already uses, and narrowing them now would mean guessing what future evaluators need. The unit tests compile against the context type, so a rename in processAlert fails type-checking here.

Unit tests (60) and make dev-int FILE=checkAlerts (335) pass after these commits.

alert: IAlert,
wallClockNow: Date,
): Date => {
const now = getAlertEvaluationNow(alert, wallClockNow);

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.

🟠 major — An evaluator lookup that throws in getAlertWindowStart aborts the whole alert run, not just one alert

getAlertWindowStart now calls findAlertEvaluator and evaluationNow, and nothing guards either call. getConsecutiveWindowHistories runs them inside one Promise.all, and that is awaited unguarded in DefaultAlertProvider.getAlertTasks (providers/default.ts:435). So if registerExtensions or one evaluator's appliesTo/evaluationNow throws for any multi-window alert, the whole tick returns no tasks and no alert gets evaluated. Commit 48cc76b contains this failure only inside processAlert. Fix: wrap the lookup here (or per alert in getConsecutiveWindowHistories) in a try/catch that falls back to the wall clock or skips that alert, and add a test where appliesTo throws in getConsecutiveWindowHistories.

Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond

sendNotificationIfResolved,
persistHistories,
};
await evaluator.evaluate(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.

🟠 major — If an evaluator returns without persisting, its errors are silently dropped and the run counts as success

After await evaluator.evaluate(ctx); return;, nothing checks whether persistHistories ran. If an evaluator returns early (for example, no data yet) after trySendNotification has pushed webhook failures onto executionErrors, those errors are never written. evalOutcome also stays 'success', and evaluators cannot set it to 'error' or 'skipped', so the alerts.evaluate SLI misreports them. Fix: track whether persistHistories (or recordAlertErrors) was called and flush executionErrors if it wasn't, and give evaluators a way to set the outcome, e.g. a returned status or a ctx.setOutcome setter.

Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond

evaluators.push(evaluator);
};

/** The registered evaluator for this alert, or undefined for the threshold

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 — findAlertEvaluator's doc comment is orphaned above ensureExtensionsRegistered

Two JSDoc blocks are stacked: "The registered evaluator for this alert, or undefined for the threshold path" sits directly above the ensureExtensionsRegistered doc. Editors and TypeDoc attach neither one to findAlertEvaluator. Fix: move that first comment onto findAlertEvaluator.

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

This branch was successfully deployed

2 active deployments
Preview – hyperdx-storybook — bf29ce8e Deployed Nov 6, 2026 by vercel[bot]
Preview – hyperdx-oss — bf29ce8e Deployed Nov 6, 2026 by vercel[bot]
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.

1 participant