Skip to content

fix: report only the delivery time as an alert's notification duration - #3110

Open
jordan-simonovski wants to merge 5 commits into
mainfrom
jordansimonovski/alert-notification-render-time
Open

fix: report only the delivery time as an alert's notification duration#3110
jordan-simonovski wants to merge 5 commits into
mainfrom
jordansimonovski/alert-notification-render-time

Conversation

@jordan-simonovski

@jordan-simonovski jordan-simonovski commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

The notification duration on an alert's evaluation list was timing everything an alert does once it decides to fire: building the message title and links, querying the log lines that go in the body, rendering the template, and then delivering it. The result was a column reading 2.69s directly above its own per-target breakdown reading 4ms — the webhook answered in milliseconds and almost all of the rest was spent assembling the message.

The column is meant to be the webhook response time, so it now times the dispatch alone. The message-building time is no longer recorded or displayed anywhere; we agreed that isn't something users need to see.

Two behaviour notes for review. Evaluations already in the database keep their old figure and will read high, with nothing to distinguish them — the field's meaning changes rather than a new field being added, which is the simpler option given the column was its only consumer. And an evaluation that fails before dispatch (a template that won't compile, for instance) now records no delivery time at all and shows a dash, where previously it reported the time it spent failing to build the message.

Implementation detail

renderAlertTemplate returns the wall time of its dispatch Promise.all, which is the concurrent phase, so the slowest target of each round sets it. The accumulation moved out of the finally and into the try: a pre-dispatch throw then records nothing instead of attributing its own render time to delivery.

An integration test asserts the recorded figure matches the single target's own response time within rounding, which fails if the render time leaks back in, and another asserts a compile failure records no delivery time. The tooltip also picked up a per-round qualifier from review: a grouped alert notifies once per group, so no single target sets the figure across the whole evaluation.

The notification duration on an alert's evaluation list timed the whole
fireChannelEvent call - building the title and links, querying the log lines
for the message body, compiling the template, then dispatching - while the
per-target breakdown only covered the dispatch. With one target the two
figures should match, and instead the target read as 4ms against a 2.69s
total.

Record the render share as analytics.renderDurationMs and show it as its own
row, so the expansion accounts for the total. The column tooltip claimed the
slowest target sets the figure, which was never true.
@changeset-bot

changeset-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: eed9fb8

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

This PR includes changesets to release 3 packages
Name Type
@hyperdx/app Patch
@hyperdx/api Patch
@hyperdx/otel-collector Patch

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 Sep 11, 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 Sep 11, 2026 10:39pm UTC
hyperdx-storybook Ready Ready Preview Sep 11, 2026 10:39pm UTC

Request Review

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

github-actions Bot commented Sep 11, 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 — 66 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: 102 (+ 83 in test files, excluded from tier calculation)
  • Critical-path lines changed: 66
  • Branch: jordansimonovski/alert-notification-render-time
  • 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.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 357 passed • 1 skipped • 1413s

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

Tests ran across 4 shards in parallel.

View full report →

@greptile-apps

greptile-apps Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR narrows alert notification duration to the concurrent dispatch phase and updates the persisted contract, UI explanation, and integration coverage.

  • Measures target delivery separately from message construction.
  • Leaves delivery duration absent for pre-dispatch template failures.
  • Clarifies that the slowest target determines each dispatch round.
  • Still records 0ms when a successful render produces no dispatch jobs.

Confidence Score: 4/5

The PR is not yet safe to merge because alerts with no resolvable notification targets report a delivery time even though nothing was dispatched.

The unconditional accumulation initializes webhookDurationMs after an empty dispatch round, violating the new absent-when-nothing-was-dispatched contract and showing 0ms instead of a dash. The previous tooltip thread was manually resolved without explanation; the current tooltip now includes the requested per-dispatch-round qualifier.

Files Needing Attention: packages/api/src/tasks/checkAlerts/index.ts and packages/api/src/tasks/checkAlerts/template.ts

Important Files Changed

Filename Overview
packages/api/src/tasks/checkAlerts/template.ts Measures the concurrent dispatch phase and returns its rounded wall time, including for an empty job list.
packages/api/src/tasks/checkAlerts/index.ts Accumulates dispatch-only duration but incorrectly initializes the metric when no target was dispatched.
packages/common-utils/src/types.ts Updates the analytics schema documentation to define dispatch-only timing and absence when nothing was dispatched.
packages/app/src/components/alerts/AlertEvaluationsTable.tsx Correctly clarifies that the slowest target determines timing within each dispatch round.
packages/app/src/components/alerts/NotificationDurationCell.tsx Removes render-time presentation and retains the dash for absent delivery timing.
packages/api/src/tasks/checkAlerts/tests/checkAlerts.int.test.ts Covers single-target timing and compile failures but not successful renders with zero dispatch jobs.

Sequence Diagram

sequenceDiagram
    participant Evaluation
    participant Renderer
    participant Targets
    participant History
    Evaluation->>Renderer: Build alert message
    Renderer->>Targets: Dispatch jobs concurrently
    Targets-->>Renderer: Delivery results and timings
    Renderer-->>Evaluation: dispatchDurationMs
    Evaluation->>History: Accumulate webhookDurationMs
Loading

Fix all with Greploop Fix All in Claude Code Fix All in Conductor Fix All in Cursor Fix All in Codex

Reviews (3): Last reviewed commit: "fix: report only the delivery time as no..." | Re-trigger Greptile

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

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. The dispatch/render timing split is correct: dispatchDurationMs is always defined on a successful renderAlertTemplate return (every other exit throws), cross-group accumulation preserves earlier groups' totals when a later group throws pre-dispatch, and round(dispatch) ≥ round(job) holds so the "slowest target per round" claim is honest. The two stale prior-review comments (renderDurationMs changeset omission; tooltip wording) are confirmed resolved or moot. The items below are recommended, not blocking.

🟡 P2 -- recommended

  • packages/api/src/tasks/checkAlerts/index.ts:1357 -- When an alert fires but the render resolves to zero dispatchable targets (e.g. a configured webhook was deleted, so channel resolution records a pre-failure and returns no jobs), Promise.all([]) yields dispatchDurationMs of 0, so webhookDurationMs is persisted as 0 rather than left absent — contradicting this diff's own new webhookDurationMs doc ("Absent when nothing was dispatched") and making NotificationDurationCell render 0ms instead of the dash on an evaluation that reached no target and wrote a WEBHOOK_ERROR.
    • Fix: Accumulate webhookDurationMs only when a dispatch actually occurred (e.g. guard on timings.length > 0), or return dispatchDurationMs as undefined for an empty job set so the field stays absent.
    • correctness, adversarial, testing, kieran-typescript
  • packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts:3663 -- The ±2ms assertion bounds the gap between webhookDurationMs (timed around Promise.all) and the target's own durationMs (timed inside the job callback, a microtask later), which is a scheduling hop plus two independent roundings; a GC pause or scheduler stall on a loaded CI runner can exceed 2ms and fail the test with no real regression.
    • Fix: Widen the upper bound to absorb CI jitter (e.g. durationMs - 1 to durationMs + 25), which still catches a render-inclusion regression that would inflate the figure by seconds.
    • testing, previous-comments
  • packages/api/src/tasks/checkAlerts/index.ts:1358 -- The new cross-group summation of dispatchDurationMs into webhookDurationMs (grouped alerts call fireChannelEvent once per group/resolve) has no test; every existing webhookDurationMs assertion exercises only a single-dispatch evaluation, leaving the sum-across-rounds behavior unverified.
    • Fix: Add a group-by evaluation test asserting webhookDurationMs equals the sum of each group's dispatch-phase wall time, including a round that dispatches nothing.
    • testing, correctness, reliability, adversarial
🔵 P3 nitpicks (2)
  • packages/api/src/tasks/checkAlerts/template.ts:505 -- The new dispatchDurationMs doc says "the caller subtracts it to get the render share," but no caller subtracts anything — the only consumer accumulates it — leaving a misleading reference to the render-timing concept this PR removed.
    • Fix: Reword the comment to state that the caller accumulates this into the evaluation's notification duration, and drop the "subtracts / render share" claim.
  • .changeset/notification-render-breakdown.md:1 -- packages/common-utils/src/types.ts was edited (comments only) but @hyperdx/common-utils is absent from the changeset and from the fixed/linked groups in .changeset/config.json, so the published .d.ts JSDoc drifts from source until an unrelated release; runtime shape and types are unchanged, so this is release hygiene only.
    • Fix: Optionally add a '@hyperdx/common-utils': patch entry, or accept the doc-only .d.ts drift as not worth a release.

Reviewers (7): correctness, reliability, testing, adversarial, api-contract, kieran-typescript, previous-comments.

Testing gaps:

  • No coverage for the render-succeeds-but-zero-dispatchable-targets path (the 0ms vs "absent" divergence).
  • No coverage for grouped/multi-round accumulation of dispatch time across fireChannelEvent calls in one evaluation.

Each field was documented in the Mongoose interface, the zod schema, the
implementation and the tests. The zod schema owns the description now; the
rest get a line.
A grouped evaluation notifies once per group, and the delivery share takes the
slowest target of each round, so no single target sets it for the evaluation.
@@ -0,0 +1,15 @@
---
'@hyperdx/app': patch
'@hyperdx/api': patch

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 — Changeset omits @hyperdx/common-utils although types.ts changed

packages/common-utils/src/types.ts gains renderDurationMs on AlertHistoryAnalyticsSchema, but the changeset only lists @hyperdx/app and @hyperdx/api. common-utils is not in the fixed group in .changeset/config.json, so it gets no bump or changelog entry from this PR — unlike the analogous per-target-timing change (see the 0558f77e entry in packages/common-utils/CHANGELOG.md) and the five pending changesets that do declare it (e.g. .changeset/tile-editor-dashboard-filters.md). Add '@hyperdx/common-utils': patch.

expect(renderDurationMs).toBeGreaterThan(0);
expect(
renderDurationMs! + targets![0].durationMs,
).toBeGreaterThanOrEqual(webhookDurationMs! - 2);

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 — ±2 ms reconciliation tolerance is tighter than the quantity it bounds

Substituting renderDurationMs = round(T) − round(D), the assertion reduces to |round(d_job) − round(D_promiseAll)| ≤ 2 — i.e. it bounds the gap between the per-job timer stopping in the job's finally (template.ts:946) and performance.now() after await Promise.all settles (template.ts:957). That gap is a microtask hop plus whatever the event loop is doing, so a GC pause or scheduler stall on a loaded CI runner makes this fail spuriously with no regression present. Widen to something like ±25 ms, which still catches the mismatch the test targets (4 ms vs 2.69 s).

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

PR Review

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

2 posted as inline comment(s) on the changed lines.


Severity is the reviewer's own estimate and is used for ordering, not filtering.

Building the message — the title and links, the log-line query for the body,
the template render — is not what the column is for. Time the dispatch alone
and drop the render share rather than displaying it.

Per the team's call in #alerting: the intent of the column is the webhook
response time, and the body-building latency isn't something users need.
@jordan-simonovski jordan-simonovski changed the title fix: account for message render time in alert notification duration fix: report only the delivery time as an alert's notification duration Sep 11, 2026
Comment on lines +1357 to +1358
evaluationAnalytics.webhookDurationMs =
(evaluationAnalytics.webhookDurationMs ?? 0) + dispatchDurationMs;

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.

P1 Empty dispatch records duration

When a notification round has no dispatch jobs—for example, because its configured webhook was deleted or all mentioned targets are unresolvable—Promise.all([]) succeeds and returns a rounded duration, normally 0. These lines then create webhookDurationMs, so the evaluation displays 0ms even though no delivery occurred. This conflicts with the new contract that the field is absent when nothing was dispatched; only pre-dispatch exceptions currently produce the intended dash. Avoid setting the aggregate unless at least one job was dispatched.

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

});
// Only the dispatch phase: the column reports how long the targets
// took to respond, not the time spent building the message.
evaluationAnalytics.webhookDurationMs =

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 — An evaluation that dispatches nothing records webhookDurationMs: 0, not absent, so the cell reads "0ms"

When the render succeeds but no job is queued — e.g. the alert's configured webhook was deleted, so resolveConfiguredChannel (packages/api/src/tasks/checkAlerts/template.ts:673-687) records a pre-failure and returns []Promise.all([]) gives dispatchDurationMs: 0 (template.ts:957), and this line stores 0. NotificationDurationCell only dashes on total == null, so formatDurationMs(0) renders "0ms" (packages/app/src/utils.ts:1066) next to a WEBHOOK error, reading as an instant successful delivery; it also contradicts the "Absent when nothing was dispatched" contract this PR adds at packages/common-utils/src/types.ts:1112. Return dispatchDurationMs as undefined when jobs.length === 0 and only accumulate here when it is defined, so the no-dispatch path dashes like the compile-failure path the new test covers.

* time — so this is not the complement of `failures`.
*/
timings: NotificationTiming[];
/** Wall time of the concurrent dispatch phase (ms); the caller subtracts it to get the render share. */

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 — Doc comment on dispatchDurationMs describes a subtraction no caller performs

The only caller adds the value straight onto evaluationAnalytics.webhookDurationMs (packages/api/src/tasks/checkAlerts/index.ts:1357) and the render share is deliberately discarded per this PR's own description, so "the caller subtracts it to get the render share" will mislead the next reader into hunting for a render metric that does not exist — drop that clause and say it is the wall time of the concurrent dispatch phase, reported as the evaluation's delivery time.

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