Skip to content

feat: attribute alert notification time to each target - #3003

Open
jordan-simonovski wants to merge 3 commits into
mainfrom
jordansimonovski/alert-per-target-notification-timings
Open

feat: attribute alert notification time to each target#3003
jordan-simonovski wants to merge 3 commits into
mainfrom
jordansimonovski/alert-per-target-notification-timings

Conversation

@jordan-simonovski

Copy link
Copy Markdown
Contributor

webhookDurationMs recorded one number for an evaluation's whole notification delivery. Targets are dispatched concurrently, so the slowest one sets that number — a three-target alert where one webhook takes 4s and two take 50ms reports 4s, with nothing to say which webhook was responsible or that the other two were healthy. This times each dispatch and attributes the total.

Follows #3001, which renamed the column to "Notification duration" and explained what the single number meant. This gives it a breakdown to expand into.

What changed

Each dispatch is timed individually inside the existing Promise.all fan-out and aggregated per target across the evaluation. Each entry carries the target's summed duration, how many dispatches it took, and how many failed. The evaluation history cell expands in place to show it.

Background

An evaluation can dispatch to the same target several times: a grouped alert notifies per firing group, and a resolve notification is a further dispatch. So "the time for this target" is a sum over dispatches, while the evaluation's total is a max over concurrent targets within each round. The two genuinely don't add up to each other, and the schema comment says so — otherwise the next reader will file the discrepancy as a bug.

Key decisions

Aggregated per evaluation, not per dispatch. A 50-group alert notifying 10 targets would write 500 entries onto every history row, on a collection that already has a TTL index because of its volume. Per-target totals are bounded by the distinct targets an evaluation can reach. The cost is that the expander shows "Team Slack — 4.1s across 50 dispatches" rather than which group was slow; the dispatch count is surfaced so the number doesn't read as one slow send.

Timed in the finally, so failures count too. A target that fails after 30s spent 30s, and webhookDurationMs already includes it. Targets that fail before dispatch have no timing at all — there was nothing to time — so timings is not the complement of failures. Those still surface in the Errors column.

Expands within the cell, not as another table row. The row already owns a chevron for groups and errors; a second row-level expander competing with it would be ambiguous to click. The cell's toggle stops propagation so it doesn't also fire the row's.

The label is the webhook's name at dispatch time. A renamed webhook leaves historical rows showing the old name, which is what was actually notified. Aggregation keys on the webhook id, so two webhooks sharing a name stay separate entries — they will render identically, which is the one case where the breakdown is ambiguous.

Impact

New optional field on alert history analytics; nothing reads it as required. Records written before this keep rendering their total with no expander, covered by a test. No migration — the field is absent on old documents and Mongoose leaves them alone.

Implementation detail

renderAlertTemplate returns timings alongside failures; fireChannelEvent widens from NotificationFailure[] to Pick<RenderedAlert, 'failures' | 'timings'>. Aggregation lives in processAlert as a Map keyed by webhook id, flushed onto the analytics object before the records are written — from the error path too, so an evaluation that notifies some targets and then fails still reports what it delivered.

The stored array is sorted slowest-first and capped at ALERT_NOTIFICATION_TARGETS_LIMIT, so the cap drops the least interesting rows rather than an arbitrary set.

This branch deliberately leaves AlertEvaluationsTable's heading alone — #3001 renames it and sentence-cases the rest, and touching the same line here would conflict for no benefit. If this lands first the heading reads "Webhook Duration" until #3001 merges.

Tests: 4 on the cell (no-delivery dash, pre-change records with a total but no breakdown, the toggle, and the per-target rows). The toggle is asserted via aria-expanded because Mantine's Collapse keeps children mounted — asserting on breakdown content alone passed without ever clicking, which I confirmed before rewriting it. Integration: the existing checkAlerts analytics assertion now also checks the per-target entry; 296 pass. make ci-unit 3343 pass, make ci-lint 0 errors.

webhookDurationMs covered the whole delivery, and since targets dispatch
concurrently the slowest one sets it — so a multi-target alert reported a
number with no way to tell which webhook was responsible.

Time each dispatch and aggregate per target across the evaluation: a
grouped alert notifies the same target once per firing group and again on
resolve, so entries carry a summed duration, a dispatch count and a
failure count. Stored per evaluation rather than per dispatch, since 50
groups x 10 targets would write 500 entries onto every history row.

The evaluation history cell expands in place to show the breakdown, rather
than adding a second row-level expander to compete with the existing one.
@vercel

vercel Bot commented Aug 26, 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 Aug 26, 2026 8:01am
hyperdx-storybook Ready Ready Preview Aug 26, 2026 8:01am

Request Review

@changeset-bot

changeset-bot Bot commented Aug 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b8b30e1

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/app Minor
@hyperdx/common-utils 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

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

github-actions Bot commented Aug 26, 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 — 91 lines (bar: 30):
    • packages/api/src/tasks/checkAlerts/index.ts
    • packages/api/src/tasks/checkAlerts/template.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: 6
  • Production lines changed: 246 (+ 142 in test files, excluded from tier calculation)
  • Critical-path lines changed: 91
  • Branch: jordansimonovski/alert-per-target-notification-timings
  • 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 Aug 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR records notification delivery timing per target and exposes an expandable evaluation-history breakdown.

  • Times each concurrent notification dispatch and aggregates durations, dispatch counts, and failures by stable target ID.
  • Persists the bounded, slowest-first breakdown on alert-history analytics.
  • Adds the expandable notification-duration cell and regression coverage for legacy records, duplicate names, and click behavior.

Confidence Score: 5/5

The PR appears safe to merge with no blocking failure remaining.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/api/src/tasks/checkAlerts/template.ts Times each dispatched notification job and returns its target identity, duration, and outcome.
packages/api/src/tasks/checkAlerts/index.ts Aggregates target timings across an evaluation and attaches a bounded, slowest-first breakdown to history analytics.
packages/api/src/models/alertHistory.ts Adds the optional per-target notification timing subdocuments to the alert-history model.
packages/common-utils/src/types.ts Defines the shared timing schema, analytics field, and maximum stored-target count.
packages/app/src/components/alerts/NotificationDurationCell.tsx Renders the total notification duration and an expandable per-target breakdown.
packages/app/src/components/alerts/tests/NotificationDurationCell.test.tsx Covers legacy data, empty timing data, expansion behavior, stable duplicate-name rendering, and click propagation.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Alert evaluation] --> B[Render notification jobs]
  B --> C[Dispatch targets concurrently]
  C --> D[Record each target duration and outcome]
  D --> E[Aggregate by webhook ID]
  E --> F[Persist evaluation analytics]
  F --> G[Expandable notification-duration cell]
Loading

Reviews (3): Last reviewed commit: "Merge branch 'main' into jordansimonovsk..." | Re-trigger Greptile

Comment thread packages/app/src/components/alerts/NotificationDurationCell.tsx Outdated
Comment thread packages/app/src/components/alerts/NotificationDurationCell.tsx Outdated
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found.

This is a well-scoped change. The backend logic checks out on direct reading: recordNotificationTimings always populates all five subdocument fields (so the Mongoose required: true subfields can never throw on save), flushNotificationTimings is idempotent and is called before every write path (single-value success, time-series success, and the error/catch path), and aggregation keys correctly on the webhook id so same-named targets stay distinct. Both prior greptile P2 comments (non-unique React keys, raw c="red") are resolved in the current diff.

🟡 P2 — recommended

  • packages/api/src/tasks/checkAlerts/index.ts:1422 — The evaluation-level evaluationAnalytics object (now carrying a notificationTargets array of up to 20 subdocuments) is assigned by reference onto every history record the evaluation writes, so a grouped alert with many firing groups persists the full breakdown on each row, multiplying stored bytes on the TTL-indexed alertHistory collection.
    • Fix: Persist the per-target breakdown on a single representative history row per evaluation instead of duplicating the array across every grouped row.
  • packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts:129 — The only backend assertion for the new aggregation covers a single-dispatch, single-webhook, zero-failure case, leaving the core new behavior (summing durationMs across repeated dispatches, dispatches > 1, failures counting, and the error-path flush) unverified at the integration layer.
    • Fix: Add a case that dispatches to one target multiple times and includes a failed dispatch, asserting the summed durationMs, dispatches, and failures.
🔵 P3 nitpicks (1)
  • packages/api/src/tasks/checkAlerts/template.ts:517 — Under a queued dispatcher, dispatcher.dispatch(job) resolves after enqueue rather than delivery, so the per-target durationMs reflects enqueue latency (near-zero) instead of actual send time; this matches the existing webhookDurationMs behavior but is now surfaced per target where a reader may misread it as delivery time.
    • Fix: Note in the schema comment or UI that recorded timing reflects enqueue time for queued dispatchers.

Reviewers (10): correctness, testing, maintainability, project-standards, kieran-typescript, reliability, performance, api-contract, adversarial, previous-comments.

Coverage note: Only the previous-comments reviewer (which returned no unaddressed findings) completed before synthesis was required; the remaining findings above are grounded in direct analysis of the diff and surrounding code (index.ts write paths, template.ts dispatch loop, the Mongoose/zod schema pair, and the UI cell), not in the other subagents' returns.

Testing gaps:

  • Backend aggregation across multiple dispatches to the same target and failure counting are untested.
  • The error-path flush (evaluation notifies some targets then fails) has no test asserting timings are still recorded.

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 308 passed • 1 skipped • 1266s

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

Tests ran across 4 shards in parallel.

View full report →

Two webhooks can share a display name, so keying the breakdown rows on the
label collided. Persist the webhook id alongside it — the aggregation
already keyed on it, the id was just stripped before storing.

Also use the semantic danger token for the failure count rather than a raw
Mantine colour.
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