From ebffae9a069e69880ea07233e4de3fe5c4273826 Mon Sep 17 00:00:00 2001 From: capacitron Date: Fri, 10 Apr 2026 21:59:54 -0600 Subject: [PATCH 1/3] feat(uptime): wire alarm notifications on uptime transitions Query enabled uptime alarms for the monitored website on DOWN/RECOVERED transitions and dispatch notifications via NotificationClient to all configured destinations (Slack, Discord, Teams, Google Chat, Telegram, Webhook). No schema changes required. Closes #268 --- apps/uptime/src/index.ts | 6 ++ apps/uptime/src/uptime-transition-emails.ts | 109 +++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/apps/uptime/src/index.ts b/apps/uptime/src/index.ts index 65e0f237f..5c488fa19 100644 --- a/apps/uptime/src/index.ts +++ b/apps/uptime/src/index.ts @@ -14,6 +14,7 @@ import { disconnectProducer, sendUptimeEvent } from "./lib/producer"; import { captureError, mergeWideEvent } from "./lib/tracing"; import { getPreviousMonitorStatus, + sendUptimeAlarmNotificationsIfNeeded, sendUptimeTransitionEmailsIfNeeded, } from "./uptime-transition-emails"; @@ -263,6 +264,11 @@ const app = new Elysia() data: result.data, previousStatus, }); + await sendUptimeAlarmNotificationsIfNeeded({ + schedule: schedule.data, + data: result.data, + previousStatus, + }); } catch (error) { captureError(error, { error_step: "producer_pipeline", diff --git a/apps/uptime/src/uptime-transition-emails.ts b/apps/uptime/src/uptime-transition-emails.ts index 07a8c7343..8c4f502cc 100644 --- a/apps/uptime/src/uptime-transition-emails.ts +++ b/apps/uptime/src/uptime-transition-emails.ts @@ -1,7 +1,12 @@ import { and, db, eq } from "@databuddy/db"; -import { member, user } from "@databuddy/db/schema"; +import { alarms, member, user } from "@databuddy/db/schema"; import { chQuery } from "@databuddy/db/clickhouse"; import { UptimeAlertEmail } from "@databuddy/email"; +import { + type NotificationChannel, + NotificationClient, +} from "@databuddy/notifications"; +import { buildUptimeNotificationPayload } from "@databuddy/notifications/templates/uptime"; import { Resend } from "resend"; import type { ScheduleData } from "./actions"; import { captureError } from "./lib/tracing"; @@ -159,3 +164,105 @@ export async function sendUptimeTransitionEmailsIfNeeded(options: { captureError(error, { error_step: "transition_email" }); } } + +export async function sendUptimeAlarmNotificationsIfNeeded(options: { + schedule: ScheduleData; + data: UptimeData; + previousStatus: number | undefined; +}): Promise { + const kind = resolveTransitionKind( + options.previousStatus, + options.data.status + ); + if (kind === null) { + return; + } + + if (!options.schedule.websiteId) { + return; + } + + const matchingAlarms = await db.query.alarms.findMany({ + where: and( + eq(alarms.websiteId, options.schedule.websiteId), + eq(alarms.enabled, true), + eq(alarms.triggerType, "uptime") + ), + with: { destinations: true }, + }); + + if (matchingAlarms.length === 0) { + return; + } + + const siteLabel = buildSiteLabel(options.schedule); + const sslExpiry = + options.data.ssl_expiry > 0 ? options.data.ssl_expiry : undefined; + + const payload = buildUptimeNotificationPayload({ + kind, + siteLabel, + url: options.data.url, + checkedAt: options.data.timestamp, + httpCode: options.data.http_code, + error: options.data.error ?? "", + probeRegion: options.data.probe_region, + totalMs: options.data.total_ms, + ttfbMs: options.data.ttfb_ms, + sslValid: options.data.ssl_valid === 1, + sslExpiryMs: sslExpiry, + }); + + for (const alarm of matchingAlarms) { + if (!alarm.destinations || alarm.destinations.length === 0) { + continue; + } + + const clientConfig: Record> = {}; + const channels: NotificationChannel[] = []; + + for (const dest of alarm.destinations) { + const cfg = (dest.config ?? {}) as Record; + + if (dest.type === "slack") { + clientConfig.slack = { webhookUrl: dest.identifier }; + channels.push("slack"); + } else if (dest.type === "discord") { + clientConfig.discord = { webhookUrl: dest.identifier }; + channels.push("discord"); + } else if (dest.type === "teams") { + clientConfig.teams = { webhookUrl: dest.identifier }; + channels.push("teams"); + } else if (dest.type === "google_chat") { + clientConfig.googleChat = { webhookUrl: dest.identifier }; + channels.push("google-chat"); + } else if (dest.type === "telegram") { + clientConfig.telegram = { + botToken: cfg.botToken as string, + chatId: dest.identifier || (cfg.chatId as string), + }; + channels.push("telegram"); + } else if (dest.type === "webhook") { + clientConfig.webhook = { + url: dest.identifier, + headers: cfg.headers as Record | undefined, + }; + channels.push("webhook"); + } + } + + if (channels.length === 0) { + continue; + } + + try { + const client = new NotificationClient(clientConfig); + await client.send(payload, { channels }); + } catch (error) { + captureError(error, { + error_step: "uptime_alarm_notification", + alarm_id: alarm.id, + }); + } + } +} From d35213fa0be0e508c8a57f7abd83f33e2f6fcaf4 Mon Sep 17 00:00:00 2001 From: capacitron Date: Fri, 10 Apr 2026 23:34:39 -0600 Subject: [PATCH 2/3] fix: add runtime type guards for Telegram config + parallelize alarm dispatch - Replace unsafe `as string` casts on botToken/chatId with runtime typeof checks, skipping the destination if config is malformed - Add typeof guard for webhook headers cast - Parallelize alarm dispatch with Promise.allSettled for better performance when multiple alarms have slow destinations --- apps/uptime/src/uptime-transition-emails.ts | 97 +++++++++++---------- plan.md | 67 ++++++++++++++ 2 files changed, 118 insertions(+), 46 deletions(-) create mode 100644 plan.md diff --git a/apps/uptime/src/uptime-transition-emails.ts b/apps/uptime/src/uptime-transition-emails.ts index 8c4f502cc..afd8cbcd6 100644 --- a/apps/uptime/src/uptime-transition-emails.ts +++ b/apps/uptime/src/uptime-transition-emails.ts @@ -213,56 +213,61 @@ export async function sendUptimeAlarmNotificationsIfNeeded(options: { sslExpiryMs: sslExpiry, }); - for (const alarm of matchingAlarms) { - if (!alarm.destinations || alarm.destinations.length === 0) { - continue; - } + const dispatchPromises = matchingAlarms + .filter((alarm) => alarm.destinations && alarm.destinations.length > 0) + .map(async (alarm) => { + const clientConfig: Record> = {}; + const channels: NotificationChannel[] = []; - const clientConfig: Record> = {}; - const channels: NotificationChannel[] = []; + for (const dest of alarm.destinations) { + const cfg = (dest.config ?? {}) as Record; - for (const dest of alarm.destinations) { - const cfg = (dest.config ?? {}) as Record; + if (dest.type === "slack") { + clientConfig.slack = { webhookUrl: dest.identifier }; + channels.push("slack"); + } else if (dest.type === "discord") { + clientConfig.discord = { webhookUrl: dest.identifier }; + channels.push("discord"); + } else if (dest.type === "teams") { + clientConfig.teams = { webhookUrl: dest.identifier }; + channels.push("teams"); + } else if (dest.type === "google_chat") { + clientConfig.googleChat = { webhookUrl: dest.identifier }; + channels.push("google-chat"); + } else if (dest.type === "telegram") { + const botToken = typeof cfg.botToken === "string" ? cfg.botToken : ""; + const chatId = dest.identifier || (typeof cfg.chatId === "string" ? cfg.chatId : ""); + if (!botToken || !chatId) { + return; + } + clientConfig.telegram = { botToken, chatId }; + channels.push("telegram"); + } else if (dest.type === "webhook") { + const headers = cfg.headers && typeof cfg.headers === "object" + ? cfg.headers as Record + : undefined; + clientConfig.webhook = { + url: dest.identifier, + headers, + }; + channels.push("webhook"); + } + } - if (dest.type === "slack") { - clientConfig.slack = { webhookUrl: dest.identifier }; - channels.push("slack"); - } else if (dest.type === "discord") { - clientConfig.discord = { webhookUrl: dest.identifier }; - channels.push("discord"); - } else if (dest.type === "teams") { - clientConfig.teams = { webhookUrl: dest.identifier }; - channels.push("teams"); - } else if (dest.type === "google_chat") { - clientConfig.googleChat = { webhookUrl: dest.identifier }; - channels.push("google-chat"); - } else if (dest.type === "telegram") { - clientConfig.telegram = { - botToken: cfg.botToken as string, - chatId: dest.identifier || (cfg.chatId as string), - }; - channels.push("telegram"); - } else if (dest.type === "webhook") { - clientConfig.webhook = { - url: dest.identifier, - headers: cfg.headers as Record | undefined, - }; - channels.push("webhook"); + if (channels.length === 0) { + return; } - } - if (channels.length === 0) { - continue; - } + try { + const client = new NotificationClient(clientConfig); + await client.send(payload, { channels }); + } catch (error) { + captureError(error, { + error_step: "uptime_alarm_notification", + alarm_id: alarm.id, + }); + } + }); - try { - const client = new NotificationClient(clientConfig); - await client.send(payload, { channels }); - } catch (error) { - captureError(error, { - error_step: "uptime_alarm_notification", - alarm_id: alarm.id, - }); - } - } + await Promise.allSettled(dispatchPromises); } diff --git a/plan.md b/plan.md new file mode 100644 index 000000000..10a9b8e8a --- /dev/null +++ b/plan.md @@ -0,0 +1,67 @@ +## Implementation Plan: Uptime Alarm Integration (Issue #268) + +### Architecture Summary + +The integration point is `apps/uptime/src/uptime-transition-emails.ts`. It already detects status transitions (down/recovered) and sends emails. The alarms system (`packages/rpc/src/routers/alarms.ts`) has a fully working `NotificationClient` dispatch pattern. The `packages/notifications/src/templates/uptime.ts` already exports `buildUptimeNotificationPayload` for exactly this purpose. The wiring simply needs to query alarms for the relevant `websiteId` on transition and fire them. + +### Files to Modify + +**1. `apps/uptime/src/uptime-transition-emails.ts`** + +Add a new exported function `sendUptimeAlarmNotificationsIfNeeded` that: +- Takes the same `{ schedule, data, previousStatus }` shape as `sendUptimeTransitionEmailsIfNeeded` +- Resolves the transition kind via the existing `resolveTransitionKind` helper +- If a transition occurred, queries the `alarms` table for all enabled alarms where `websiteId = schedule.websiteId` AND `triggerType = "uptime"` +- For each alarm, fetches its `destinations` (eager-loaded via Drizzle `with: { destinations: true }`) +- Builds a `NotificationClient` per alarm using the same per-destination config-mapping logic as the `test` handler in `alarms.ts` +- Calls `client.send(buildUptimeNotificationPayload({ kind, siteLabel, url, checkedAt, ... }), { channels })` +- Wraps everything in try/catch, calls `captureError` on failure + +New imports needed: +```ts +import { alarms, alarmDestinations } from "@databuddy/db/schema"; +import { and, eq, isNotNull } from "@databuddy/db"; +import { NotificationClient } from "@databuddy/notifications"; +import type { NotificationChannel } from "@databuddy/notifications"; +import { buildUptimeNotificationPayload } from "@databuddy/notifications/templates/uptime"; +``` + +**2. `apps/uptime/src/index.ts`** + +After the existing call to `sendUptimeTransitionEmailsIfNeeded`, add: +```ts +await sendUptimeAlarmNotificationsIfNeeded({ + schedule: schedule.data, + data: result.data, + previousStatus, +}); +``` + +### No Schema Changes Required + +The `alarms` table already has `websiteId` (nullable FK to `websites`) and `triggerType` (with `"uptime"` as a valid value). The `alarmDestinations` table is already related. No migrations needed. + +### Trigger Logic + +``` +on every uptime check: + previousStatus = last status from ClickHouse + currentStatus = result of this check + if (prev=DOWN, curr=UP) → kind = "recovered" + if (prev≠DOWN, curr=DOWN) → kind = "down" + otherwise → no-op + +on transition: + query alarms WHERE websiteId = schedule.websiteId + AND enabled = true + AND triggerType = "uptime" + for each alarm → build NotificationClient from destinations → send +``` + +### Critical Files + +- `apps/uptime/src/uptime-transition-emails.ts` — add `sendUptimeAlarmNotificationsIfNeeded` +- `apps/uptime/src/index.ts` — wire call after email transition handler +- `packages/notifications/src/templates/uptime.ts` — payload builder (already exists) +- `packages/rpc/src/routers/alarms.ts` — reference for NotificationClient pattern +- `packages/db/src/drizzle/schema.ts` — alarms schema (no changes needed) From 8325fc4e3b55d3808cd5520b0f6346011505e5d1 Mon Sep 17 00:00:00 2001 From: capacitron Date: Fri, 10 Apr 2026 23:42:49 -0600 Subject: [PATCH 3/3] chore: remove stale file --- plan.md | 67 --------------------------------------------------------- 1 file changed, 67 deletions(-) delete mode 100644 plan.md diff --git a/plan.md b/plan.md deleted file mode 100644 index 10a9b8e8a..000000000 --- a/plan.md +++ /dev/null @@ -1,67 +0,0 @@ -## Implementation Plan: Uptime Alarm Integration (Issue #268) - -### Architecture Summary - -The integration point is `apps/uptime/src/uptime-transition-emails.ts`. It already detects status transitions (down/recovered) and sends emails. The alarms system (`packages/rpc/src/routers/alarms.ts`) has a fully working `NotificationClient` dispatch pattern. The `packages/notifications/src/templates/uptime.ts` already exports `buildUptimeNotificationPayload` for exactly this purpose. The wiring simply needs to query alarms for the relevant `websiteId` on transition and fire them. - -### Files to Modify - -**1. `apps/uptime/src/uptime-transition-emails.ts`** - -Add a new exported function `sendUptimeAlarmNotificationsIfNeeded` that: -- Takes the same `{ schedule, data, previousStatus }` shape as `sendUptimeTransitionEmailsIfNeeded` -- Resolves the transition kind via the existing `resolveTransitionKind` helper -- If a transition occurred, queries the `alarms` table for all enabled alarms where `websiteId = schedule.websiteId` AND `triggerType = "uptime"` -- For each alarm, fetches its `destinations` (eager-loaded via Drizzle `with: { destinations: true }`) -- Builds a `NotificationClient` per alarm using the same per-destination config-mapping logic as the `test` handler in `alarms.ts` -- Calls `client.send(buildUptimeNotificationPayload({ kind, siteLabel, url, checkedAt, ... }), { channels })` -- Wraps everything in try/catch, calls `captureError` on failure - -New imports needed: -```ts -import { alarms, alarmDestinations } from "@databuddy/db/schema"; -import { and, eq, isNotNull } from "@databuddy/db"; -import { NotificationClient } from "@databuddy/notifications"; -import type { NotificationChannel } from "@databuddy/notifications"; -import { buildUptimeNotificationPayload } from "@databuddy/notifications/templates/uptime"; -``` - -**2. `apps/uptime/src/index.ts`** - -After the existing call to `sendUptimeTransitionEmailsIfNeeded`, add: -```ts -await sendUptimeAlarmNotificationsIfNeeded({ - schedule: schedule.data, - data: result.data, - previousStatus, -}); -``` - -### No Schema Changes Required - -The `alarms` table already has `websiteId` (nullable FK to `websites`) and `triggerType` (with `"uptime"` as a valid value). The `alarmDestinations` table is already related. No migrations needed. - -### Trigger Logic - -``` -on every uptime check: - previousStatus = last status from ClickHouse - currentStatus = result of this check - if (prev=DOWN, curr=UP) → kind = "recovered" - if (prev≠DOWN, curr=DOWN) → kind = "down" - otherwise → no-op - -on transition: - query alarms WHERE websiteId = schedule.websiteId - AND enabled = true - AND triggerType = "uptime" - for each alarm → build NotificationClient from destinations → send -``` - -### Critical Files - -- `apps/uptime/src/uptime-transition-emails.ts` — add `sendUptimeAlarmNotificationsIfNeeded` -- `apps/uptime/src/index.ts` — wire call after email transition handler -- `packages/notifications/src/templates/uptime.ts` — payload builder (already exists) -- `packages/rpc/src/routers/alarms.ts` — reference for NotificationClient pattern -- `packages/db/src/drizzle/schema.ts` — alarms schema (no changes needed)