Skip to content

fix(notifications): fan out alarm destinations#520

Merged
izadoesdev merged 2 commits into
stagingfrom
codex/fix-alarm-destination-collisions
Jun 30, 2026
Merged

fix(notifications): fan out alarm destinations#520
izadoesdev merged 2 commits into
stagingfrom
codex/fix-alarm-destination-collisions

Conversation

@izadoesdev

@izadoesdev izadoesdev commented Jun 30, 2026

Copy link
Copy Markdown
Member

Summary

  • add per-destination notification targets so duplicate same-channel alarm destinations do not collapse to the last config
  • update uptime transition alerts, anomaly notifications, and alarm test sends to deliver one target at a time
  • capture failed uptime NotificationResult[] entries and count only successful target sends as fired
  • add regression coverage for same-channel target fanout and webhook header sanitization

PR #407 follow-up

Verification

  • bunx ultracite check packages/notifications/src/alarm-config.ts packages/notifications/src/__tests__/alarm-config.test.ts apps/uptime/src/uptime-transition-alerts.ts packages/rpc/src/routers/alarms.ts packages/rpc/src/routers/anomalies.ts packages/rpc/src/lib/alarm-notifications.ts packages/notifications/src/index.ts
  • cd packages/notifications && bun run test && bun run check-types
  • cd apps/uptime && bun run test && bun run check-types
  • cd packages/rpc && bun run check-types
  • bun run lint
  • bun run check-types
  • bun run test

AI disclosure

  • Implemented and verified with AI assistance; changes were reviewed against the current staging code path.

Summary by cubic

Fixes alarm destination collisions by sending each destination separately and tracking per-channel results, so multiple Slack/webhook/email targets don’t overwrite each other. Also improves error handling and counts only successful deliveries.

  • Bug Fixes
    • Added buildAlarmNotificationTargets in @databuddy/notifications to fan out destinations; kept buildAlarmNotificationConfig as a legacy, de-duplicated adapter.
    • Switched uptime alerts, anomaly notifications, and alarm “test send” to send per target via sendNotificationTarget in @databuddy/rpc, aggregate NotificationResult[], and sum only successes.
    • Captures and logs failures with channel context; sanitizes webhook headers. Added tests for header sanitization, same-channel fan-out, and legacy config dedupe.

Written for commit ce0a06a. Summary will update on new commits.

Review in cubic

@vercel

vercel Bot commented Jun 30, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
dashboard Ready Ready Preview, Comment Jun 30, 2026 6:24pm
databuddy-status Ready Ready Preview, Comment Jun 30, 2026 6:24pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
documentation Skipped Skipped Jun 30, 2026 6:24pm

@unkey-deploy

unkey-deploy Bot commented Jun 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Unkey Deploy

Name Status Preview Inspect Updated (UTC)
api (preview) Ready Visit Preview Inspect Jun 30, 2026 6:24pm

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a5178f82-944b-4ef1-9008-2d6a0cf3d1ef

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/fix-alarm-destination-collisions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 7 files

Confidence score: 3/5

  • In packages/notifications/src/alarm-config.ts, buildAlarmNotificationConfig appears to overwrite same-channel destinations, which can cause duplicate sends to the last Slack/webhook/email target while silently skipping earlier duplicates; this is user-facing notification behavior, so preserve all duplicate destinations (or explicitly dedupe by target) before merging.
  • In apps/uptime/src/uptime-transition-alerts.ts, alarms_fired is still derived from any successful destination instead of per-target successes, so fanout metrics can undercount/overcount real delivery outcomes and mask notification gaps; switch the return/count logic to per-target success accounting before merging.
Architecture diagram
sequenceDiagram
    participant Trigger as Notification Trigger (Uptime/Anomaly/Test)
    participant TargetBuilder as buildAlarmNotificationTargets
    participant Client as NotificationClient (per target)
    participant Slack as Slack Webhook
    participant Webhook as Custom Webhook
    participant Email as Email (Resend)
    participant ErrorCapture as Error Logger

    Note over Trigger,TargetBuilder: NEW: Fan-out each destination as separate target

    Trigger->>TargetBuilder: alarm.destinations[]
    TargetBuilder->>TargetBuilder: For each dest:<br/>- sanitize webhook headers<br/>- create {channel, clientConfig}
    TargetBuilder-->>Trigger: AlarmNotificationTarget[]

    alt No targets (empty array)
        Trigger->>Trigger: Return false / skip
    else Targets exist
        loop Per target
            Trigger->>Client: new NotificationClient(target.clientConfig)
            Trigger->>Client: send(payload, { channels: [target.channel] })
            alt target.channel == "slack"
                Client->>Slack: POST webhook (single URL)
                Slack-->>Client: response
            else target.channel == "webhook"
                Client->>Webhook: POST with sanitized headers
                Webhook-->>Client: response
            else target.channel == "email"
                Client->>Email: sendEmailAction (Resend)
                Email-->>Client: delivery status
            end
            Client-->>Trigger: NotificationResult[] (per channel)

            alt At least one successful result
                Trigger->>Trigger: Mark as success
            else All failures
                Trigger->>ErrorCapture: captureError({ alarm_id, channel, cause })
                Note over Trigger,ErrorCapture: CHANGED: capture per-target with channel context
            end
        end
        Trigger->>Trigger: Aggregate: any success ? true : false
    end

    Note over Trigger,ErrorCapture: CHANGED: per-target delivery replaces aggregated multi-channel send<br/>Results are now counted by successful target sends
Loading

Shadow auto-approve: would not auto-approve because issues were found.

Re-trigger cubic

Comment thread packages/notifications/src/alarm-config.ts
Comment thread apps/uptime/src/uptime-transition-alerts.ts Outdated
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the same-channel alarm destination collapse bug where multiple Slack/email/webhook destinations of the same type would collapse to the last config. It introduces buildAlarmNotificationTargets to produce one {channel, clientConfig} pair per destination, and updates uptime transition alerts, anomaly notifications, and the alarm test-send to fan out deliveries one target at a time.

  • buildAlarmNotificationTargets replaces the single shared clientConfig + deduplicated channels pattern, ensuring each destination gets its own isolated NotificationClient and a single-channel send call.
  • Uptime alerts (Effect-based) gain per-target NotificationSendError catching with channel context added to error telemetry; the outer handleTransition loop is simplified accordingly.
  • Anomaly and alarm-test send paths are updated via Promise.all fanout with .flat() result collection; a new test covers same-channel Slack fanout and webhook header sanitization.

Confidence Score: 4/5

The core fan-out logic is correct and all active call sites have been migrated to buildAlarmNotificationTargets. The main concern is the exported buildAlarmNotificationConfig wrapper, which now silently misfires for same-channel destinations — an issue that is dormant today but could surprise future callers.

The fan-out implementation is sound: each destination gets its own NotificationClient and a single-channel send call, and the uptime Effect path has robust per-target error handling. The two non-blocking concerns are the buildAlarmNotificationConfig wrapper regression (same-channel destinations now invoke the last config multiple times rather than once) and the unguarded Promise.all in the anomaly/alarm-test paths, which differs from the more defensive uptime implementation. Neither is likely to cause an incident since all current callers use the new function and NotificationClient.send almost never throws, but both represent latent rough edges worth cleaning up before the wrapper finds new callers.

packages/notifications/src/alarm-config.ts — the buildAlarmNotificationConfig wrapper behaviour for same-channel inputs; packages/rpc/src/routers/anomalies.ts and packages/rpc/src/routers/alarms.ts for the unguarded Promise.all fanout.

Important Files Changed

Filename Overview
packages/notifications/src/alarm-config.ts Introduces buildAlarmNotificationTargets (correctly fan-outs per-destination), but the legacy buildAlarmNotificationConfig wrapper now regresses same-channel behavior: it collects duplicate channel entries while only keeping the last config, causing the last destination to be invoked N times
packages/rpc/src/routers/anomalies.ts Updated to use per-target fanout via Promise.all; notificationsSent counting logic is correct, but missing per-target error isolation unlike the Effect-based uptime implementation
apps/uptime/src/uptime-transition-alerts.ts Correctly fans out per target using Effect with per-target NotificationSendError catch and captureError telemetry; error handling is cleaner than before with channel added to the error context
packages/rpc/src/routers/alarms.ts Test-send path updated to fan out per target, flattening results correctly; same unguarded Promise.all as anomalies.ts but lower practical risk since this is a user-triggered action
packages/notifications/src/tests/alarm-config.test.ts New test covers same-channel Slack fanout and webhook header sanitization correctly; no issues found
packages/notifications/src/index.ts Re-exports both buildAlarmNotificationConfig and buildAlarmNotificationTargets from alarm-config; trivial change
packages/rpc/src/lib/alarm-notifications.ts Adds toNotificationTargets re-export alias; keeps toNotificationConfig for any remaining legacy consumers

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller as Alarm Trigger
    participant BT as buildAlarmNotificationTargets
    participant NC1 as NotificationClient (target 1)
    participant NC2 as NotificationClient (target 2)
    participant P1 as Provider (Slack URL #1)
    participant P2 as Provider (Slack URL #2)

    Caller->>BT: destinations[]
    BT-->>Caller: "[{channel:slack, clientConfig:{url:#1}}, {channel:slack, clientConfig:{url:#2}}]"

    par Fan-out (concurrent)
        Caller->>NC1: "send(payload, {channels:[slack]})"
        NC1->>P1: provider.send(payload)
        P1-->>NC1: NotificationResult
        NC1-->>Caller: [NotificationResult]
    and
        Caller->>NC2: "send(payload, {channels:[slack]})"
        NC2->>P2: provider.send(payload)
        P2-->>NC2: NotificationResult
        NC2-->>Caller: [NotificationResult]
    end

    Caller->>Caller: results.flat() → count successes / log failures
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 Caller as Alarm Trigger
    participant BT as buildAlarmNotificationTargets
    participant NC1 as NotificationClient (target 1)
    participant NC2 as NotificationClient (target 2)
    participant P1 as Provider (Slack URL #1)
    participant P2 as Provider (Slack URL #2)

    Caller->>BT: destinations[]
    BT-->>Caller: "[{channel:slack, clientConfig:{url:#1}}, {channel:slack, clientConfig:{url:#2}}]"

    par Fan-out (concurrent)
        Caller->>NC1: "send(payload, {channels:[slack]})"
        NC1->>P1: provider.send(payload)
        P1-->>NC1: NotificationResult
        NC1-->>Caller: [NotificationResult]
    and
        Caller->>NC2: "send(payload, {channels:[slack]})"
        NC2->>P2: provider.send(payload)
        P2-->>NC2: NotificationResult
        NC2-->>Caller: [NotificationResult]
    end

    Caller->>Caller: results.flat() → count successes / log failures
Loading

Reviews (1): Last reviewed commit: "fix(notifications): fan out alarm destin..." | Re-trigger Greptile

Comment on lines 65 to +75
export function buildAlarmNotificationConfig(destinations: AlarmDestination[]) {
const clientConfig: Record<string, Record<string, unknown>> = {};
const clientConfig: NotificationClientConfig = {};
const channels: NotificationChannel[] = [];

for (const target of buildAlarmNotificationTargets(destinations)) {
Object.assign(clientConfig, target.clientConfig);
channels.push(target.channel);
}

return { clientConfig, channels };
}

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.

P2 buildAlarmNotificationConfig now silently fires the last same-channel destination multiple times

The wrapper rebuilds clientConfig with Object.assign, so for two Slack destinations the second URL overwrites the first. channels still gets ["slack", "slack"]. When a caller creates new NotificationClient(clientConfig).send(payload, { channels }), the single Slack provider (URL #2) is invoked twice — URL #1 never fires. This is strictly worse than the pre-PR behavior (last one wins, called once) and is a regression introduced by this wrapper. All callers in this PR have been updated to use buildAlarmNotificationTargets directly, but the exported buildAlarmNotificationConfig is still reachable and silently broken for the case the PR is fixing.

Comment on lines +248 to 257
const results = (
await Promise.all(
targets.map((target) =>
new NotificationClient(target.clientConfig).send(payload, {
channels: [target.channel],
})
)
)
).flat();
notificationsSent += results.filter((r) => r.success).length;

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.

P2 No per-target error isolation in the fanout Promise.all

The uptime code uses Effect.catchTag("NotificationSendError") per target so a single failing destination never aborts the others. Here, if any NotificationClient.send() call throws (e.g., an unexpected error from the email sendEmailAction before the internal Promise.allSettled catches it), the entire Promise.all rejects, leaving notificationsSent under-counted for the current alarm iteration. The alarms.ts test-send path has the same gap. Wrapping each target.send() in .catch(...) (or using Promise.allSettled) would align this with the Effect-based uptime behavior.

@vercel
vercel Bot temporarily deployed to Preview – documentation June 30, 2026 18:23 Inactive
@izadoesdev

Copy link
Copy Markdown
Member Author

Addressed the review follow-ups in ce0a06a4a:

  • buildAlarmNotificationConfig now keeps legacy channels unique so duplicate same-channel destinations do not preserve duplicate channel entries against a collapsed provider config.
  • uptime transition alert accounting now sums successful target deliveries instead of returning an alarm-level boolean.
  • anomaly notification and alarm test-send fanout now use a shared per-target send helper that converts thrown target sends into failed NotificationResults, so one failing destination does not abort the rest.

Re-ran bun run lint, bun run check-types, and bun run test locally before pushing.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 6 files (changes from recent commits).

Shadow auto-approve: would require human review. Refactors core notification fan-out logic across uptime alerts, anomaly notifications, and test sends; changes error handling and delivery semantics – high risk for production breakage.

Re-trigger cubic

@izadoesdev
izadoesdev merged commit 400b9a0 into staging Jun 30, 2026
18 checks passed
@izadoesdev
izadoesdev deleted the codex/fix-alarm-destination-collisions branch June 30, 2026 18:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant