Add notification dispatchers (P3 Slack/Email/Webhook, P5 Slack), full YAML parser for P6
- P3 alert: NotificationDispatcher with Slack Block Kit, Resend email, generic webhook; severity-gated dispatch
- P5 cost: CostSlackNotifier with anomaly Block Kit (score, deviation, snooze/expected buttons)
- P6 run: Full YAML runbook parser with serde_yaml, variable substitution ({{var}}), failure actions, 7 tests
- P6 parser: validates non-empty steps, default timeout (300s), default abort on failure
This commit is contained in:
210
products/03-alert-intelligence/src/notifications/dispatcher.ts
Normal file
210
products/03-alert-intelligence/src/notifications/dispatcher.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import pino from 'pino';
|
||||
import type { CorrelationWindow } from '../correlation/engine.js';
|
||||
|
||||
const logger = pino({ name: 'notifications' });
|
||||
|
||||
export interface NotificationChannel {
|
||||
send(incident: IncidentNotification): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface IncidentNotification {
|
||||
incidentId: string;
|
||||
title: string;
|
||||
severity: string;
|
||||
service: string;
|
||||
alertCount: number;
|
||||
firstAlertAt: Date;
|
||||
fingerprint: string;
|
||||
dashboardUrl: string;
|
||||
}
|
||||
|
||||
// --- Slack Block Kit ---
|
||||
|
||||
export class SlackNotifier implements NotificationChannel {
|
||||
private webhookUrl: string;
|
||||
|
||||
constructor(webhookUrl: string) {
|
||||
this.webhookUrl = webhookUrl;
|
||||
}
|
||||
|
||||
async send(incident: IncidentNotification): Promise<boolean> {
|
||||
const severityEmoji: Record<string, string> = {
|
||||
critical: '🔴', high: '🟠', medium: '🟡', low: '🔵', info: 'ℹ️',
|
||||
};
|
||||
const emoji = severityEmoji[incident.severity] ?? '⚪';
|
||||
|
||||
const blocks = [
|
||||
{
|
||||
type: 'header',
|
||||
text: { type: 'plain_text', text: `${emoji} ${incident.title}`, emoji: true },
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
fields: [
|
||||
{ type: 'mrkdwn', text: `*Severity:*\n${incident.severity.toUpperCase()}` },
|
||||
{ type: 'mrkdwn', text: `*Service:*\n${incident.service}` },
|
||||
{ type: 'mrkdwn', text: `*Alerts:*\n${incident.alertCount}` },
|
||||
{ type: 'mrkdwn', text: `*First seen:*\n<!date^${Math.floor(incident.firstAlertAt.getTime() / 1000)}^{date_short_pretty} {time}|${incident.firstAlertAt.toISOString()}>` },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'actions',
|
||||
elements: [
|
||||
{
|
||||
type: 'button',
|
||||
text: { type: 'plain_text', text: '👀 View Incident' },
|
||||
url: incident.dashboardUrl,
|
||||
action_id: `view_incident:${incident.incidentId}`,
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: { type: 'plain_text', text: '✅ Acknowledge' },
|
||||
style: 'primary',
|
||||
action_id: `ack_incident:${incident.incidentId}`,
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: { type: 'plain_text', text: '🔇 Suppress' },
|
||||
action_id: `suppress_incident:${incident.incidentId}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const res = await fetch(this.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ blocks }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
logger.warn({ status: res.status, incidentId: incident.incidentId }, 'Slack notification failed');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error({ error: (err as Error).message, incidentId: incident.incidentId }, 'Slack send error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Email (via Resend) ---
|
||||
|
||||
export class EmailNotifier implements NotificationChannel {
|
||||
private apiKey: string;
|
||||
private from: string;
|
||||
private to: string;
|
||||
|
||||
constructor(apiKey: string, from: string, to: string) {
|
||||
this.apiKey = apiKey;
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
}
|
||||
|
||||
async send(incident: IncidentNotification): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
subject: `[dd0c/alert] ${incident.severity.toUpperCase()}: ${incident.title}`,
|
||||
html: `
|
||||
<h2>${incident.title}</h2>
|
||||
<p><strong>Severity:</strong> ${incident.severity}</p>
|
||||
<p><strong>Service:</strong> ${incident.service}</p>
|
||||
<p><strong>Alerts grouped:</strong> ${incident.alertCount}</p>
|
||||
<p><a href="${incident.dashboardUrl}">View in Dashboard</a></p>
|
||||
`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
logger.warn({ status: res.status }, 'Email notification failed');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error({ error: (err as Error).message }, 'Email send error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Webhook (generic) ---
|
||||
|
||||
export class WebhookNotifier implements NotificationChannel {
|
||||
private url: string;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
async send(incident: IncidentNotification): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(this.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(incident),
|
||||
});
|
||||
return res.ok;
|
||||
} catch (err) {
|
||||
logger.error({ error: (err as Error).message }, 'Webhook send error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dispatcher ---
|
||||
|
||||
const SEVERITY_ORDER: Record<string, number> = { critical: 5, high: 4, medium: 3, low: 2, info: 1 };
|
||||
|
||||
export class NotificationDispatcher {
|
||||
private channels: Array<{ channel: NotificationChannel; minSeverity: string }> = [];
|
||||
|
||||
addChannel(channel: NotificationChannel, minSeverity = 'medium') {
|
||||
this.channels.push({ channel, minSeverity });
|
||||
}
|
||||
|
||||
async dispatch(incident: IncidentNotification): Promise<number> {
|
||||
const incidentLevel = SEVERITY_ORDER[incident.severity] ?? 0;
|
||||
let sent = 0;
|
||||
|
||||
for (const { channel, minSeverity } of this.channels) {
|
||||
const minLevel = SEVERITY_ORDER[minSeverity] ?? 0;
|
||||
if (incidentLevel >= minLevel) {
|
||||
const ok = await channel.send(incident);
|
||||
if (ok) sent++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ incidentId: incident.incidentId, sent, total: this.channels.length }, 'Notifications dispatched');
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a shipped correlation window into a notification.
|
||||
*/
|
||||
static fromWindow(window: CorrelationWindow, baseUrl: string): IncidentNotification {
|
||||
const topSeverity = window.alerts.reduce((max, a) => {
|
||||
return (SEVERITY_ORDER[a.severity] ?? 0) > (SEVERITY_ORDER[max] ?? 0) ? a.severity : max;
|
||||
}, 'info');
|
||||
|
||||
return {
|
||||
incidentId: window.incidentId ?? 'unknown',
|
||||
title: window.alerts[0]?.title ?? 'Alert Incident',
|
||||
severity: topSeverity,
|
||||
service: window.service,
|
||||
alertCount: window.alerts.length,
|
||||
firstAlertAt: new Date(window.openedAt),
|
||||
fingerprint: window.fingerprint,
|
||||
dashboardUrl: `${baseUrl}/incidents/${window.incidentId}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user