From d0fca1ea8809dc7b53162cfa555c0c59b2c73deb Mon Sep 17 00:00:00 2001 From: gadgethd <111318106+gadgethd@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:30:47 +0000 Subject: [PATCH] fix: format alert forwards for Discord --- backend/src/workers/alert-receiver.test.ts | 34 ++++++++++++++++++++- backend/src/workers/alert-receiver.ts | 35 ++++++++++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/backend/src/workers/alert-receiver.test.ts b/backend/src/workers/alert-receiver.test.ts index beaa5f1..6299fee 100644 --- a/backend/src/workers/alert-receiver.test.ts +++ b/backend/src/workers/alert-receiver.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { summarizeAlertPayload } from './alert-receiver.js'; +import { buildAlertForwardPayload, summarizeAlertPayload } from './alert-receiver.js'; test('summarizes Alertmanager notifications without persisting annotations or labels', () => { const receipt = summarizeAlertPayload({ @@ -47,3 +47,35 @@ test('summarizes synthetic alert and recovery events', () => { assert.equal(recovery.status, 'recovery'); assert.equal(recovery.resolved, 1); }); + +test('builds a bounded Discord-compatible payload without mentions or alert details', () => { + const receipt = summarizeAlertPayload({ + receiver: 'operations-receiver', + alerts: [{ + status: 'firing', + labels: { alertname: '@everyone BackendDown', secret: 'must-not-be-forwarded' }, + annotations: { description: 'potentially sensitive detail' }, + }], + }, new Date('2026-07-29T12:00:00.000Z')); + + const payload = buildAlertForwardPayload(receipt); + + assert.match(payload.content, /UKMesh alert firing/); + assert.match(payload.content, /@everyone BackendDown/); + assert.match(payload.content, /Firing: 1 · Resolved: 0/); + assert.ok(payload.content.length <= 2_000); + assert.deepEqual(payload.allowed_mentions, { parse: [] }); + assert.equal(JSON.stringify(payload).includes('must-not-be-forwarded'), false); + assert.equal(JSON.stringify(payload).includes('potentially sensitive detail'), false); +}); + +test('bounds Discord content when an Alertmanager notification has many long names', () => { + const receipt = summarizeAlertPayload({ + alerts: Array.from({ length: 100 }, (_, index) => ({ + status: 'firing', + labels: { alertname: `${index}-${'x'.repeat(200)}` }, + })), + }); + + assert.equal(buildAlertForwardPayload(receipt).content.length, 2_000); +}); diff --git a/backend/src/workers/alert-receiver.ts b/backend/src/workers/alert-receiver.ts index 30e8f36..9144053 100644 --- a/backend/src/workers/alert-receiver.ts +++ b/backend/src/workers/alert-receiver.ts @@ -19,6 +19,11 @@ export type AlertReceipt = { resolved: number; }; +export type AlertForwardPayload = { + content: string; + allowed_mentions: { parse: string[] }; +}; + function boundedInteger( raw: string | undefined, fallback: number, @@ -96,6 +101,29 @@ export function summarizeAlertPayload(payload: unknown, now = new Date()): Alert }; } +export function buildAlertForwardPayload(receipt: AlertReceipt): AlertForwardPayload { + const state = receipt.status === 'firing' + ? { icon: '🚨', label: 'firing' } + : receipt.status === 'resolved' || receipt.status === 'recovery' + ? { icon: '✅', label: receipt.status } + : { icon: '⚠️', label: 'unknown' }; + const names = receipt.alert_names.length > 0 + ? receipt.alert_names.join(', ') + : 'unnamed alert'; + const content = [ + `${state.icon} **UKMesh alert ${state.label}**`, + `Source: ${receipt.source}`, + `Alerts: ${names}`, + `Firing: ${receipt.firing} · Resolved: ${receipt.resolved}`, + `Received: ${receipt.received_at}`, + ].join('\n').slice(0, 2_000); + + return { + content, + allowed_mentions: { parse: [] }, + }; +} + async function readBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; let size = 0; @@ -129,7 +157,7 @@ function persistReceipt(receipt: AlertReceipt): Promise { return writeChain; } -async function forward(body: Buffer): Promise { +async function forward(receipt: AlertReceipt): Promise { if (!FORWARD_URL) return; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FORWARD_TIMEOUT_MS); @@ -137,10 +165,11 @@ async function forward(body: Buffer): Promise { const response = await fetch(FORWARD_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, - body, + body: JSON.stringify(buildAlertForwardPayload(receipt)), signal: controller.signal, }); if (!response.ok) throw new Error(`HTTP ${response.status}`); + console.log(`[alert-receiver] forward succeeded: HTTP ${response.status}`); } finally { clearTimeout(timeout); } @@ -169,7 +198,7 @@ const server = http.createServer(async (req, res) => { const payload = JSON.parse(body.toString('utf8')) as unknown; const receipt = summarizeAlertPayload(payload); await persistReceipt(receipt); - void forward(body).catch((error) => { + void forward(receipt).catch((error) => { console.error('[alert-receiver] forward failed:', (error as Error).message); }); json(res, 202, { accepted: true });