@littlebearapps/platform-admin-sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +89 -0
- package/dist/prompts.d.ts +27 -0
- package/dist/prompts.js +80 -0
- package/dist/scaffold.d.ts +5 -0
- package/dist/scaffold.js +65 -0
- package/dist/templates.d.ts +16 -0
- package/dist/templates.js +131 -0
- package/package.json +46 -0
- package/templates/full/migrations/006_pattern_discovery.sql +199 -0
- package/templates/full/migrations/007_notifications_search.sql +127 -0
- package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
- package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
- package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
- package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
- package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
- package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
- package/templates/full/workers/pattern-discovery.ts +661 -0
- package/templates/full/workers/platform-alert-router.ts +1809 -0
- package/templates/full/workers/platform-notifications.ts +424 -0
- package/templates/full/workers/platform-search.ts +480 -0
- package/templates/full/workers/platform-settings.ts +436 -0
- package/templates/full/wrangler.alert-router.jsonc.hbs +34 -0
- package/templates/full/wrangler.notifications.jsonc.hbs +23 -0
- package/templates/full/wrangler.pattern-discovery.jsonc.hbs +33 -0
- package/templates/full/wrangler.search.jsonc.hbs +16 -0
- package/templates/full/wrangler.settings.jsonc.hbs +23 -0
- package/templates/shared/README.md.hbs +69 -0
- package/templates/shared/config/budgets.yaml.hbs +72 -0
- package/templates/shared/config/services.yaml.hbs +45 -0
- package/templates/shared/migrations/001_core_tables.sql +117 -0
- package/templates/shared/migrations/002_usage_warehouse.sql +830 -0
- package/templates/shared/migrations/003_feature_tracking.sql +250 -0
- package/templates/shared/migrations/004_settings_alerts.sql +452 -0
- package/templates/shared/migrations/seed.sql.hbs +4 -0
- package/templates/shared/package.json.hbs +21 -0
- package/templates/shared/scripts/sync-config.ts +242 -0
- package/templates/shared/tsconfig.json +12 -0
- package/templates/shared/workers/lib/analytics-engine.ts +357 -0
- package/templates/shared/workers/lib/billing.ts +293 -0
- package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
- package/templates/shared/workers/lib/control.ts +292 -0
- package/templates/shared/workers/lib/economics.ts +368 -0
- package/templates/shared/workers/lib/metrics.ts +103 -0
- package/templates/shared/workers/lib/platform-settings.ts +407 -0
- package/templates/shared/workers/lib/shared/allowances.ts +333 -0
- package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
- package/templates/shared/workers/lib/shared/types.ts +58 -0
- package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
- package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
- package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
- package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
- package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
- package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
- package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
- package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
- package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
- package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
- package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
- package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
- package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
- package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
- package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
- package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
- package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
- package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
- package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
- package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
- package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
- package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
- package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
- package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
- package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
- package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
- package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
- package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
- package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
- package/templates/shared/workers/platform-usage.ts +1915 -0
- package/templates/shared/wrangler.usage.jsonc.hbs +58 -0
- package/templates/standard/migrations/005_error_collection.sql +162 -0
- package/templates/standard/workers/error-collector.ts +2670 -0
- package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
- package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
- package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
- package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
- package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
- package/templates/standard/workers/lib/error-collector/github.ts +329 -0
- package/templates/standard/workers/lib/error-collector/types.ts +262 -0
- package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
- package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
- package/templates/standard/workers/platform-sentinel.ts +1744 -0
- package/templates/standard/wrangler.error-collector.jsonc.hbs +44 -0
- package/templates/standard/wrangler.sentinel.jsonc.hbs +45 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Slack Alerts Module
|
|
3
|
+
*
|
|
4
|
+
* Provides a single interface for:
|
|
5
|
+
* - Sending Slack alerts with consistent formatting
|
|
6
|
+
* - Creating in-app notifications (D1)
|
|
7
|
+
* - KV-based deduplication (1-hour window)
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { sendAlert, sendAlertWithNotification } from './lib/shared/slack-alerts';
|
|
12
|
+
*
|
|
13
|
+
* // Just Slack
|
|
14
|
+
* await sendAlert(env, {
|
|
15
|
+
* source: 'circuit-breaker',
|
|
16
|
+
* priority: 'critical',
|
|
17
|
+
* title: 'Circuit Breaker Tripped',
|
|
18
|
+
* message: 'Feature brand-copilot:scanner exceeded budget',
|
|
19
|
+
* project: 'brand-copilot',
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* // Slack + In-app notification
|
|
23
|
+
* await sendAlertWithNotification(env, {
|
|
24
|
+
* source: 'error-collector',
|
|
25
|
+
* priority: 'high',
|
|
26
|
+
* title: 'New P1 Error Detected',
|
|
27
|
+
* message: 'TypeError in my-project:worker',
|
|
28
|
+
* project: 'my-project',
|
|
29
|
+
* actionUrl: '/errors',
|
|
30
|
+
* actionLabel: 'View Errors',
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* @module workers/lib/shared/slack-alerts
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { KVNamespace, D1Database } from '@cloudflare/workers-types';
|
|
38
|
+
|
|
39
|
+
// TODO: Set your dashboard URL
|
|
40
|
+
const DASHBOARD_URL = 'https://your-dashboard.example.com';
|
|
41
|
+
|
|
42
|
+
// =============================================================================
|
|
43
|
+
// TYPES
|
|
44
|
+
// =============================================================================
|
|
45
|
+
|
|
46
|
+
export type AlertSource =
|
|
47
|
+
| 'error-collector'
|
|
48
|
+
| 'pattern-discovery'
|
|
49
|
+
| 'circuit-breaker'
|
|
50
|
+
| 'usage'
|
|
51
|
+
| 'gap-detection'
|
|
52
|
+
| 'gatus'
|
|
53
|
+
| 'system'
|
|
54
|
+
| 'sentinel';
|
|
55
|
+
|
|
56
|
+
export type AlertPriority = 'critical' | 'high' | 'medium' | 'low' | 'info';
|
|
57
|
+
|
|
58
|
+
export type NotificationCategory = 'error' | 'warning' | 'info' | 'success';
|
|
59
|
+
|
|
60
|
+
export interface SlackAlert {
|
|
61
|
+
source: AlertSource;
|
|
62
|
+
priority: AlertPriority;
|
|
63
|
+
title: string;
|
|
64
|
+
message: string;
|
|
65
|
+
project?: string;
|
|
66
|
+
context?: Record<string, string | number | boolean>;
|
|
67
|
+
actionUrl?: string;
|
|
68
|
+
actionLabel?: string;
|
|
69
|
+
/** Additional Slack blocks to include */
|
|
70
|
+
additionalBlocks?: Array<Record<string, unknown>>;
|
|
71
|
+
/** Skip deduplication for this alert */
|
|
72
|
+
skipDedup?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AlertEnv {
|
|
76
|
+
SLACK_WEBHOOK_URL?: string;
|
|
77
|
+
PLATFORM_CACHE?: KVNamespace;
|
|
78
|
+
PLATFORM_DB?: D1Database;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// =============================================================================
|
|
82
|
+
// CONSTANTS
|
|
83
|
+
// =============================================================================
|
|
84
|
+
|
|
85
|
+
const DEDUPE_TTL = 3600; // 1 hour in seconds
|
|
86
|
+
const DEDUPE_PREFIX = 'SLACK_ALERT:';
|
|
87
|
+
|
|
88
|
+
/** Priority to Slack colour mapping */
|
|
89
|
+
const PRIORITY_COLOURS: Record<AlertPriority, string> = {
|
|
90
|
+
critical: '#d32f2f', // Red
|
|
91
|
+
high: '#ff9800', // Orange
|
|
92
|
+
medium: '#ffc107', // Yellow
|
|
93
|
+
low: '#2196f3', // Blue
|
|
94
|
+
info: '#36a64f', // Green
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Priority to emoji mapping */
|
|
98
|
+
const PRIORITY_EMOJI: Record<AlertPriority, string> = {
|
|
99
|
+
critical: '🚨',
|
|
100
|
+
high: '⚠️',
|
|
101
|
+
medium: '📢',
|
|
102
|
+
low: 'ℹ️',
|
|
103
|
+
info: '✅',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Source to human-readable label */
|
|
107
|
+
const SOURCE_LABELS: Record<AlertSource, string> = {
|
|
108
|
+
'error-collector': 'Error Collector',
|
|
109
|
+
'pattern-discovery': 'Pattern Discovery',
|
|
110
|
+
'circuit-breaker': 'Circuit Breaker',
|
|
111
|
+
usage: 'Usage Monitor',
|
|
112
|
+
'gap-detection': 'Gap Detection',
|
|
113
|
+
gatus: 'Gatus',
|
|
114
|
+
system: 'System',
|
|
115
|
+
sentinel: 'Platform Sentinel',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Map priority to notification category */
|
|
119
|
+
const PRIORITY_TO_CATEGORY: Record<AlertPriority, NotificationCategory> = {
|
|
120
|
+
critical: 'error',
|
|
121
|
+
high: 'error',
|
|
122
|
+
medium: 'warning',
|
|
123
|
+
low: 'info',
|
|
124
|
+
info: 'success',
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// =============================================================================
|
|
128
|
+
// DEDUPLICATION
|
|
129
|
+
// =============================================================================
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Generate a deduplication key from alert content
|
|
133
|
+
*/
|
|
134
|
+
function generateDedupeKey(alert: SlackAlert): string {
|
|
135
|
+
const content = `${alert.source}:${alert.priority}:${alert.title}:${alert.project || 'all'}`;
|
|
136
|
+
// Simple hash for deduplication
|
|
137
|
+
let hash = 0;
|
|
138
|
+
for (let i = 0; i < content.length; i++) {
|
|
139
|
+
const char = content.charCodeAt(i);
|
|
140
|
+
hash = (hash << 5) - hash + char;
|
|
141
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
142
|
+
}
|
|
143
|
+
return `${DEDUPE_PREFIX}${Math.abs(hash).toString(36)}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check if alert was recently sent (within dedupe window)
|
|
148
|
+
*/
|
|
149
|
+
async function isDuplicate(kv: KVNamespace | undefined, alert: SlackAlert): Promise<boolean> {
|
|
150
|
+
if (!kv || alert.skipDedup) return false;
|
|
151
|
+
|
|
152
|
+
const key = generateDedupeKey(alert);
|
|
153
|
+
const existing = await kv.get(key);
|
|
154
|
+
return existing !== null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Mark alert as sent for deduplication
|
|
159
|
+
*/
|
|
160
|
+
async function markSent(kv: KVNamespace | undefined, alert: SlackAlert): Promise<void> {
|
|
161
|
+
if (!kv || alert.skipDedup) return;
|
|
162
|
+
|
|
163
|
+
const key = generateDedupeKey(alert);
|
|
164
|
+
await kv.put(key, new Date().toISOString(), { expirationTtl: DEDUPE_TTL });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// =============================================================================
|
|
168
|
+
// SLACK MESSAGE BUILDING
|
|
169
|
+
// =============================================================================
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build Slack message payload
|
|
173
|
+
*/
|
|
174
|
+
function buildSlackMessage(alert: SlackAlert): Record<string, unknown> {
|
|
175
|
+
const emoji = PRIORITY_EMOJI[alert.priority];
|
|
176
|
+
const colour = PRIORITY_COLOURS[alert.priority];
|
|
177
|
+
const sourceLabel = SOURCE_LABELS[alert.source] || alert.source;
|
|
178
|
+
const priorityLabel = alert.priority.toUpperCase();
|
|
179
|
+
|
|
180
|
+
// Build header
|
|
181
|
+
const headerText = `${emoji} [${priorityLabel}] ${alert.title}`;
|
|
182
|
+
|
|
183
|
+
// Build context fields
|
|
184
|
+
const contextFields: Array<{ type: string; text: string }> = [];
|
|
185
|
+
|
|
186
|
+
if (alert.project) {
|
|
187
|
+
contextFields.push({
|
|
188
|
+
type: 'mrkdwn',
|
|
189
|
+
text: `*Project:* ${alert.project}`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
contextFields.push({
|
|
194
|
+
type: 'mrkdwn',
|
|
195
|
+
text: `*Source:* ${sourceLabel}`,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
contextFields.push({
|
|
199
|
+
type: 'mrkdwn',
|
|
200
|
+
text: `*Time:* ${new Date().toISOString()}`,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Build additional context if provided
|
|
204
|
+
if (alert.context) {
|
|
205
|
+
for (const [key, value] of Object.entries(alert.context)) {
|
|
206
|
+
contextFields.push({
|
|
207
|
+
type: 'mrkdwn',
|
|
208
|
+
text: `*${key}:* ${value}`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Build blocks
|
|
214
|
+
const blocks: Array<Record<string, unknown>> = [
|
|
215
|
+
{
|
|
216
|
+
type: 'header',
|
|
217
|
+
text: {
|
|
218
|
+
type: 'plain_text',
|
|
219
|
+
text: headerText,
|
|
220
|
+
emoji: true,
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
type: 'section',
|
|
225
|
+
text: {
|
|
226
|
+
type: 'mrkdwn',
|
|
227
|
+
text: alert.message,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
type: 'section',
|
|
232
|
+
fields: contextFields.slice(0, 10), // Slack limit
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
// Add action buttons if URL provided
|
|
237
|
+
if (alert.actionUrl) {
|
|
238
|
+
blocks.push({
|
|
239
|
+
type: 'actions',
|
|
240
|
+
elements: [
|
|
241
|
+
{
|
|
242
|
+
type: 'button',
|
|
243
|
+
text: {
|
|
244
|
+
type: 'plain_text',
|
|
245
|
+
text: alert.actionLabel || 'View Details',
|
|
246
|
+
emoji: true,
|
|
247
|
+
},
|
|
248
|
+
url: alert.actionUrl,
|
|
249
|
+
action_id: 'view_details',
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
type: 'button',
|
|
253
|
+
text: {
|
|
254
|
+
type: 'plain_text',
|
|
255
|
+
text: '📊 Dashboard',
|
|
256
|
+
emoji: true,
|
|
257
|
+
},
|
|
258
|
+
url: `${DASHBOARD_URL}/dashboard`,
|
|
259
|
+
action_id: 'open_dashboard',
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Add any additional blocks
|
|
266
|
+
if (alert.additionalBlocks) {
|
|
267
|
+
blocks.push(...alert.additionalBlocks);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
text: headerText, // Fallback for notifications
|
|
272
|
+
blocks,
|
|
273
|
+
attachments: [
|
|
274
|
+
{
|
|
275
|
+
color: colour,
|
|
276
|
+
footer: `Platform Alerts | ${sourceLabel}`,
|
|
277
|
+
ts: Math.floor(Date.now() / 1000),
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// =============================================================================
|
|
284
|
+
// NOTIFICATION CREATION
|
|
285
|
+
// =============================================================================
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Generate notification ID
|
|
289
|
+
*/
|
|
290
|
+
function generateNotificationId(): string {
|
|
291
|
+
return `notif_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Create in-app notification in D1
|
|
296
|
+
*/
|
|
297
|
+
async function createNotification(
|
|
298
|
+
db: D1Database | undefined,
|
|
299
|
+
alert: SlackAlert
|
|
300
|
+
): Promise<string | null> {
|
|
301
|
+
if (!db) return null;
|
|
302
|
+
|
|
303
|
+
const id = generateNotificationId();
|
|
304
|
+
const now = Math.floor(Date.now() / 1000);
|
|
305
|
+
const category = PRIORITY_TO_CATEGORY[alert.priority];
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
await db
|
|
309
|
+
.prepare(
|
|
310
|
+
`INSERT INTO notifications (id, category, source, source_id, title, description, priority, action_url, action_label, project, created_at, expires_at)
|
|
311
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
312
|
+
)
|
|
313
|
+
.bind(
|
|
314
|
+
id,
|
|
315
|
+
category,
|
|
316
|
+
alert.source,
|
|
317
|
+
null, // source_id - could be extended for linking
|
|
318
|
+
alert.title,
|
|
319
|
+
alert.message,
|
|
320
|
+
alert.priority,
|
|
321
|
+
alert.actionUrl || null,
|
|
322
|
+
alert.actionLabel || null,
|
|
323
|
+
alert.project || null,
|
|
324
|
+
now,
|
|
325
|
+
null // no expiry by default
|
|
326
|
+
)
|
|
327
|
+
.run();
|
|
328
|
+
|
|
329
|
+
return id;
|
|
330
|
+
} catch (error) {
|
|
331
|
+
console.error('[slack-alerts] Failed to create notification:', error);
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// =============================================================================
|
|
337
|
+
// PUBLIC API
|
|
338
|
+
// =============================================================================
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Send Slack alert only (no in-app notification)
|
|
342
|
+
*
|
|
343
|
+
* @param env Environment with SLACK_WEBHOOK_URL and optionally PLATFORM_CACHE for dedup
|
|
344
|
+
* @param alert Alert details
|
|
345
|
+
* @returns true if sent, false if deduplicated or failed
|
|
346
|
+
*/
|
|
347
|
+
export async function sendAlert(env: AlertEnv, alert: SlackAlert): Promise<boolean> {
|
|
348
|
+
// Check deduplication
|
|
349
|
+
if (await isDuplicate(env.PLATFORM_CACHE, alert)) {
|
|
350
|
+
console.log('[slack-alerts] Alert deduplicated:', alert.title);
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Check webhook URL
|
|
355
|
+
if (!env.SLACK_WEBHOOK_URL) {
|
|
356
|
+
console.warn('[slack-alerts] SLACK_WEBHOOK_URL not configured, skipping Slack');
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Build and send message
|
|
361
|
+
const message = buildSlackMessage(alert);
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
const response = await fetch(env.SLACK_WEBHOOK_URL, {
|
|
365
|
+
method: 'POST',
|
|
366
|
+
headers: { 'Content-Type': 'application/json' },
|
|
367
|
+
body: JSON.stringify(message),
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (!response.ok) {
|
|
371
|
+
console.error('[slack-alerts] Slack webhook failed:', response.status);
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Mark as sent for deduplication
|
|
376
|
+
await markSent(env.PLATFORM_CACHE, alert);
|
|
377
|
+
|
|
378
|
+
return true;
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.error('[slack-alerts] Failed to send Slack alert:', error);
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Send Slack alert AND create in-app notification
|
|
387
|
+
*
|
|
388
|
+
* Creates notification FIRST (so it's available even if Slack fails),
|
|
389
|
+
* then sends Slack alert.
|
|
390
|
+
*
|
|
391
|
+
* @param env Environment with SLACK_WEBHOOK_URL, PLATFORM_CACHE, PLATFORM_DB
|
|
392
|
+
* @param alert Alert details
|
|
393
|
+
* @returns Object with notification ID and Slack success status
|
|
394
|
+
*/
|
|
395
|
+
export async function sendAlertWithNotification(
|
|
396
|
+
env: AlertEnv,
|
|
397
|
+
alert: SlackAlert
|
|
398
|
+
): Promise<{ notificationId: string | null; slackSent: boolean }> {
|
|
399
|
+
// Check deduplication first
|
|
400
|
+
if (await isDuplicate(env.PLATFORM_CACHE, alert)) {
|
|
401
|
+
console.log('[slack-alerts] Alert deduplicated:', alert.title);
|
|
402
|
+
return { notificationId: null, slackSent: false };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Create notification first (more reliable than Slack)
|
|
406
|
+
const notificationId = await createNotification(env.PLATFORM_DB, alert);
|
|
407
|
+
|
|
408
|
+
// Then try Slack (graceful degradation)
|
|
409
|
+
let slackSent = false;
|
|
410
|
+
|
|
411
|
+
if (env.SLACK_WEBHOOK_URL) {
|
|
412
|
+
const message = buildSlackMessage(alert);
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const response = await fetch(env.SLACK_WEBHOOK_URL, {
|
|
416
|
+
method: 'POST',
|
|
417
|
+
headers: { 'Content-Type': 'application/json' },
|
|
418
|
+
body: JSON.stringify(message),
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
slackSent = response.ok;
|
|
422
|
+
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
console.error('[slack-alerts] Slack webhook failed:', response.status);
|
|
425
|
+
}
|
|
426
|
+
} catch (error) {
|
|
427
|
+
console.error('[slack-alerts] Failed to send Slack alert:', error);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Mark as sent for deduplication (even if just notification was created)
|
|
432
|
+
if (notificationId || slackSent) {
|
|
433
|
+
await markSent(env.PLATFORM_CACHE, alert);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return { notificationId, slackSent };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Create notification only (no Slack)
|
|
441
|
+
*
|
|
442
|
+
* Useful for lower-priority alerts that don't need Slack.
|
|
443
|
+
*
|
|
444
|
+
* @param env Environment with PLATFORM_DB
|
|
445
|
+
* @param alert Alert details
|
|
446
|
+
* @returns Notification ID or null if failed
|
|
447
|
+
*/
|
|
448
|
+
export async function createNotificationOnly(
|
|
449
|
+
env: AlertEnv,
|
|
450
|
+
alert: SlackAlert
|
|
451
|
+
): Promise<string | null> {
|
|
452
|
+
return createNotification(env.PLATFORM_DB, alert);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// =============================================================================
|
|
456
|
+
// CONVENIENCE FUNCTIONS
|
|
457
|
+
// =============================================================================
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Send a critical circuit breaker alert
|
|
461
|
+
*/
|
|
462
|
+
export async function sendCircuitBreakerAlert(
|
|
463
|
+
env: AlertEnv,
|
|
464
|
+
options: {
|
|
465
|
+
featureKey: string;
|
|
466
|
+
project: string;
|
|
467
|
+
budgetLimit: number;
|
|
468
|
+
currentUsage: number;
|
|
469
|
+
reason: 'tripped' | 'warning' | 'recovered';
|
|
470
|
+
}
|
|
471
|
+
): Promise<{ notificationId: string | null; slackSent: boolean }> {
|
|
472
|
+
const { featureKey, project, budgetLimit, currentUsage, reason } = options;
|
|
473
|
+
|
|
474
|
+
const priority: AlertPriority = reason === 'tripped' ? 'critical' : reason === 'warning' ? 'high' : 'info';
|
|
475
|
+
const title =
|
|
476
|
+
reason === 'tripped'
|
|
477
|
+
? `Circuit Breaker Tripped: ${featureKey}`
|
|
478
|
+
: reason === 'warning'
|
|
479
|
+
? `Budget Warning (80%): ${featureKey}`
|
|
480
|
+
: `Circuit Breaker Recovered: ${featureKey}`;
|
|
481
|
+
|
|
482
|
+
const message =
|
|
483
|
+
reason === 'recovered'
|
|
484
|
+
? `Feature \`${featureKey}\` has recovered and is now operational.`
|
|
485
|
+
: `Feature \`${featureKey}\` is at ${((currentUsage / budgetLimit) * 100).toFixed(1)}% of budget.\n\n*Budget:* ${budgetLimit}\n*Current Usage:* ${currentUsage}`;
|
|
486
|
+
|
|
487
|
+
return sendAlertWithNotification(env, {
|
|
488
|
+
source: 'circuit-breaker',
|
|
489
|
+
priority,
|
|
490
|
+
title,
|
|
491
|
+
message,
|
|
492
|
+
project,
|
|
493
|
+
context: {
|
|
494
|
+
'Feature Key': featureKey,
|
|
495
|
+
'Budget Limit': budgetLimit,
|
|
496
|
+
'Current Usage': currentUsage,
|
|
497
|
+
'Usage %': `${((currentUsage / budgetLimit) * 100).toFixed(1)}%`,
|
|
498
|
+
},
|
|
499
|
+
actionUrl: `${DASHBOARD_URL}/circuit-breakers`,
|
|
500
|
+
actionLabel: 'View Circuit Breakers',
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Send a gap detection alert
|
|
506
|
+
*/
|
|
507
|
+
export async function sendGapAlert(
|
|
508
|
+
env: AlertEnv,
|
|
509
|
+
options: {
|
|
510
|
+
project: string;
|
|
511
|
+
coveragePercent: number;
|
|
512
|
+
missingResources: string[];
|
|
513
|
+
repoPath?: string;
|
|
514
|
+
}
|
|
515
|
+
): Promise<{ notificationId: string | null; slackSent: boolean }> {
|
|
516
|
+
const { project, coveragePercent, missingResources, repoPath } = options;
|
|
517
|
+
|
|
518
|
+
const title = `Coverage Gap Detected: ${project}`;
|
|
519
|
+
const message =
|
|
520
|
+
`Project \`${project}\` has ${coveragePercent.toFixed(1)}% coverage (target: 90%).\n\n` +
|
|
521
|
+
`*Missing Resources:*\n${missingResources.slice(0, 5).map((r) => `• ${r}`).join('\n')}` +
|
|
522
|
+
(missingResources.length > 5 ? `\n_...and ${missingResources.length - 5} more_` : '');
|
|
523
|
+
|
|
524
|
+
return sendAlertWithNotification(env, {
|
|
525
|
+
source: 'gap-detection',
|
|
526
|
+
priority: 'medium',
|
|
527
|
+
title,
|
|
528
|
+
message,
|
|
529
|
+
project,
|
|
530
|
+
context: {
|
|
531
|
+
Coverage: `${coveragePercent.toFixed(1)}%`,
|
|
532
|
+
'Missing Count': missingResources.length,
|
|
533
|
+
...(repoPath ? { Repository: repoPath } : {}),
|
|
534
|
+
},
|
|
535
|
+
actionUrl: `${DASHBOARD_URL}/reports/gap-detection`,
|
|
536
|
+
actionLabel: 'View Gap Report',
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Send a cost threshold alert
|
|
542
|
+
*/
|
|
543
|
+
export async function sendCostAlert(
|
|
544
|
+
env: AlertEnv,
|
|
545
|
+
options: {
|
|
546
|
+
project: string;
|
|
547
|
+
currentCost: number;
|
|
548
|
+
threshold: number;
|
|
549
|
+
period: 'daily' | 'weekly' | 'monthly';
|
|
550
|
+
costBreakdown?: Record<string, number>;
|
|
551
|
+
}
|
|
552
|
+
): Promise<{ notificationId: string | null; slackSent: boolean }> {
|
|
553
|
+
const { project, currentCost, threshold, period, costBreakdown } = options;
|
|
554
|
+
|
|
555
|
+
const priority: AlertPriority = currentCost > threshold * 1.5 ? 'critical' : 'high';
|
|
556
|
+
const title = `Cost ${priority === 'critical' ? 'Spike' : 'Warning'}: ${project}`;
|
|
557
|
+
|
|
558
|
+
let message = `${period.charAt(0).toUpperCase() + period.slice(1)} cost for \`${project}\` is $${currentCost.toFixed(2)} (threshold: $${threshold.toFixed(2)}).`;
|
|
559
|
+
|
|
560
|
+
if (costBreakdown && Object.keys(costBreakdown).length > 0) {
|
|
561
|
+
message +=
|
|
562
|
+
'\n\n*Cost Breakdown:*\n' +
|
|
563
|
+
Object.entries(costBreakdown)
|
|
564
|
+
.sort(([, a], [, b]) => b - a)
|
|
565
|
+
.slice(0, 5)
|
|
566
|
+
.map(([resource, cost]) => `• ${resource}: $${cost.toFixed(2)}`)
|
|
567
|
+
.join('\n');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return sendAlertWithNotification(env, {
|
|
571
|
+
source: 'sentinel',
|
|
572
|
+
priority,
|
|
573
|
+
title,
|
|
574
|
+
message,
|
|
575
|
+
project,
|
|
576
|
+
context: {
|
|
577
|
+
'Current Cost': `$${currentCost.toFixed(2)}`,
|
|
578
|
+
Threshold: `$${threshold.toFixed(2)}`,
|
|
579
|
+
Period: period,
|
|
580
|
+
'Over By': `${(((currentCost - threshold) / threshold) * 100).toFixed(1)}%`,
|
|
581
|
+
},
|
|
582
|
+
actionUrl: `${DASHBOARD_URL}/costs`,
|
|
583
|
+
actionLabel: 'View Costs',
|
|
584
|
+
});
|
|
585
|
+
}
|