Skip to content

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

Merged
jordan-simonovski merged 5 commits into
mainfrom
jordansimonovski/alert-per-target-notification-timings
Aug 28, 2026
Merged

feat: attribute alert notification time to each target#3003
jordan-simonovski merged 5 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 27, 2026 9:19pm
hyperdx-storybook Ready Ready Preview Aug 27, 2026 9:19pm

Request Review

@changeset-bot

changeset-bot Bot commented Aug 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2a7f0b6

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 per-target notification timing analytics and adds an expandable breakdown to alert evaluation history.

  • Times each concurrently dispatched notification target, including failed dispatches.
  • Aggregates dispatch duration, count, and failures by stable webhook ID.
  • Persists a bounded, slowest-first breakdown and preserves compatibility with older history records.
  • Renders per-target timing details with stable React keys and semantic failure styling.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/api/src/tasks/checkAlerts/template.ts Times each dispatched notification job and returns target identity, duration, and success status for aggregation.
packages/api/src/tasks/checkAlerts/index.ts Aggregates notification timings by webhook ID and attaches a bounded, slowest-first breakdown to successful and failed evaluation history.
packages/api/src/models/alertHistory.ts Adds the optional per-target notification timing subdocument to persisted alert analytics.
packages/common-utils/src/types.ts Defines the shared per-target timing schema, storage limit, and optional alert-history analytics field.
packages/app/src/components/alerts/NotificationDurationCell.tsx Displays the total notification duration and an expandable per-target breakdown using stable IDs and semantic failure styling.
packages/app/src/components/alerts/tests/NotificationDurationCell.test.tsx Covers compatibility, expansion behavior, event propagation, timing details, and duplicate display-name handling.

Flowchart

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

Reviews (5): 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

Scope: PR #3003 — attribute alert notification time to each target. Diff base 892cc653. 9 files (3 packages: api, app, common-utils) plus a changeset and tests.

Intent: Replace the single webhookDurationMs figure with a per-target breakdown. Each dispatch is timed individually inside the existing Promise.all fan-out in renderAlertTemplate, aggregated per target (keyed by webhook id) across an evaluation in processAlert, stored capped/sorted on AlertHistoryAnalytics.notificationTargets, and rendered as an expandable cell.

The change is well-scoped and internally consistent: both the single-value and grouped evaluation paths route through trySendNotificationfireChannelEventrecordNotificationTimings, and flushNotificationTimings reassigns (rather than appends) so its calls on the success and error paths are idempotent. The Zod .max() and the writer .slice() share the same ALERT_NOTIFICATION_TARGETS_LIMIT constant, so the stored array cannot exceed the schema bound. The two prior reviewer comments (non-unique React keys, raw c="red") are both resolved in the current code.

✅ No critical issues found.

No P0/P1 findings. The items below are optional.

🟡 P2 — recommended

  • packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts:119 — the only integration assertion for the new breakdown covers a single target with a single dispatch (dispatches: 1, failures: 0), leaving the core aggregation logic — summing durationMs/dispatches/failures across repeated dispatches to one target, the slowest-first ordering across multiple targets, and the ALERT_NOTIFICATION_TARGETS_LIMIT cap — unverified server-side.
    • Fix: Add a case exercising a grouped or resolve scenario that dispatches to the same target more than once and to multiple targets, asserting summed durations, dispatches > 1, and slowest-first order.
🔵 P3 nitpicks (2)
  • packages/common-utils/src/types.ts:823 — the per-target shape is declared three times (AlertNotificationTargetTimingSchema here, the Mongoose subdocument in alertHistory.ts, and the internal NotificationTiming in template.ts with different field names key/ok), so a future field addition must be mirrored in three spots or the shapes drift.
    • Fix: Consider deriving the Mongoose subdocument fields from the shared type, or add a comment cross-linking the three definitions so the coupling is visible.
  • packages/app/src/components/alerts/NotificationDurationCell.tsx:24 — the empty-breakdown branch keys entirely off webhookDurationMs being non-null; a record with notificationTargets populated but webhookDurationMs absent would silently render a dash, which is fine today only because the writer always sets both in the same finally.
    • Fix: Optionally guard on targets.length independently so the cell degrades predictably if the two fields ever diverge.

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

Testing gaps:

  • Server-side aggregation (multi-dispatch summing, multi-target ordering, cap truncation) is exercised only by the client component test, not by an integration test against processAlert.

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 323 passed • 1 skipped • 1354s

Status Count
✅ Passed 323
❌ Failed 0
⚠️ Flaky 0
⏭️ 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.
@jordan-simonovski
jordan-simonovski merged commit 0558f77 into main Aug 28, 2026
26 of 27 checks passed
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