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:
89
products/05-aws-cost-anomaly/src/notifications/slack.ts
Normal file
89
products/05-aws-cost-anomaly/src/notifications/slack.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import pino from 'pino';
|
||||
|
||||
const logger = pino({ name: 'cost-notifications' });
|
||||
|
||||
export interface CostAnomalyNotification {
|
||||
anomalyId: string;
|
||||
accountId: string;
|
||||
resourceType: string;
|
||||
region: string;
|
||||
hourlyCost: number;
|
||||
score: number;
|
||||
baselineMean: number;
|
||||
baselineStddev: number;
|
||||
dashboardUrl: string;
|
||||
}
|
||||
|
||||
// --- Slack Block Kit for Cost Anomalies ---
|
||||
|
||||
export class CostSlackNotifier {
|
||||
private webhookUrl: string;
|
||||
|
||||
constructor(webhookUrl: string) {
|
||||
this.webhookUrl = webhookUrl;
|
||||
}
|
||||
|
||||
async send(anomaly: CostAnomalyNotification): Promise<boolean> {
|
||||
const scoreEmoji = anomaly.score >= 75 ? '🔴' : anomaly.score >= 50 ? '🟠' : '🟡';
|
||||
const deviation = anomaly.baselineStddev > 0
|
||||
? ((anomaly.hourlyCost - anomaly.baselineMean) / anomaly.baselineStddev).toFixed(1)
|
||||
: '∞';
|
||||
|
||||
const blocks = [
|
||||
{
|
||||
type: 'header',
|
||||
text: { type: 'plain_text', text: `${scoreEmoji} Cost Anomaly Detected`, emoji: true },
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
fields: [
|
||||
{ type: 'mrkdwn', text: `*Resource:*\n\`${anomaly.resourceType}\`` },
|
||||
{ type: 'mrkdwn', text: `*Account:*\n${anomaly.accountId}` },
|
||||
{ type: 'mrkdwn', text: `*Region:*\n${anomaly.region}` },
|
||||
{ type: 'mrkdwn', text: `*Score:*\n${anomaly.score}/100` },
|
||||
{ type: 'mrkdwn', text: `*Hourly Cost:*\n$${anomaly.hourlyCost.toFixed(4)}` },
|
||||
{ type: 'mrkdwn', text: `*Baseline:*\n$${anomaly.baselineMean.toFixed(4)} ± ${anomaly.baselineStddev.toFixed(4)}` },
|
||||
{ type: 'mrkdwn', text: `*Deviation:*\n${deviation}σ` },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'actions',
|
||||
elements: [
|
||||
{
|
||||
type: 'button',
|
||||
text: { type: 'plain_text', text: '📊 View Details' },
|
||||
url: anomaly.dashboardUrl,
|
||||
action_id: `view_anomaly:${anomaly.anomalyId}`,
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: { type: 'plain_text', text: '✅ Expected' },
|
||||
action_id: `mark_expected:${anomaly.anomalyId}`,
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: { type: 'plain_text', text: '😴 Snooze 24h' },
|
||||
action_id: `snooze_anomaly:${anomaly.anomalyId}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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, anomalyId: anomaly.anomalyId }, 'Slack cost notification failed');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error({ error: (err as Error).message }, 'Slack cost send error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user