@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.
Files changed (94) hide show
  1. package/README.md +112 -0
  2. package/dist/index.d.ts +16 -0
  3. package/dist/index.js +89 -0
  4. package/dist/prompts.d.ts +27 -0
  5. package/dist/prompts.js +80 -0
  6. package/dist/scaffold.d.ts +5 -0
  7. package/dist/scaffold.js +65 -0
  8. package/dist/templates.d.ts +16 -0
  9. package/dist/templates.js +131 -0
  10. package/package.json +46 -0
  11. package/templates/full/migrations/006_pattern_discovery.sql +199 -0
  12. package/templates/full/migrations/007_notifications_search.sql +127 -0
  13. package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
  14. package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
  15. package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
  16. package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
  17. package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
  18. package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
  19. package/templates/full/workers/pattern-discovery.ts +661 -0
  20. package/templates/full/workers/platform-alert-router.ts +1809 -0
  21. package/templates/full/workers/platform-notifications.ts +424 -0
  22. package/templates/full/workers/platform-search.ts +480 -0
  23. package/templates/full/workers/platform-settings.ts +436 -0
  24. package/templates/full/wrangler.alert-router.jsonc.hbs +34 -0
  25. package/templates/full/wrangler.notifications.jsonc.hbs +23 -0
  26. package/templates/full/wrangler.pattern-discovery.jsonc.hbs +33 -0
  27. package/templates/full/wrangler.search.jsonc.hbs +16 -0
  28. package/templates/full/wrangler.settings.jsonc.hbs +23 -0
  29. package/templates/shared/README.md.hbs +69 -0
  30. package/templates/shared/config/budgets.yaml.hbs +72 -0
  31. package/templates/shared/config/services.yaml.hbs +45 -0
  32. package/templates/shared/migrations/001_core_tables.sql +117 -0
  33. package/templates/shared/migrations/002_usage_warehouse.sql +830 -0
  34. package/templates/shared/migrations/003_feature_tracking.sql +250 -0
  35. package/templates/shared/migrations/004_settings_alerts.sql +452 -0
  36. package/templates/shared/migrations/seed.sql.hbs +4 -0
  37. package/templates/shared/package.json.hbs +21 -0
  38. package/templates/shared/scripts/sync-config.ts +242 -0
  39. package/templates/shared/tsconfig.json +12 -0
  40. package/templates/shared/workers/lib/analytics-engine.ts +357 -0
  41. package/templates/shared/workers/lib/billing.ts +293 -0
  42. package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
  43. package/templates/shared/workers/lib/control.ts +292 -0
  44. package/templates/shared/workers/lib/economics.ts +368 -0
  45. package/templates/shared/workers/lib/metrics.ts +103 -0
  46. package/templates/shared/workers/lib/platform-settings.ts +407 -0
  47. package/templates/shared/workers/lib/shared/allowances.ts +333 -0
  48. package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
  49. package/templates/shared/workers/lib/shared/types.ts +58 -0
  50. package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
  51. package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
  52. package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
  53. package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
  54. package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
  55. package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
  56. package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
  57. package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
  58. package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
  59. package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
  60. package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
  61. package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
  62. package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
  63. package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
  64. package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
  65. package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
  66. package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
  67. package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
  68. package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
  69. package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
  70. package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
  71. package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
  72. package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
  73. package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
  74. package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
  75. package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
  76. package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
  77. package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
  78. package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
  79. package/templates/shared/workers/platform-usage.ts +1915 -0
  80. package/templates/shared/wrangler.usage.jsonc.hbs +58 -0
  81. package/templates/standard/migrations/005_error_collection.sql +162 -0
  82. package/templates/standard/workers/error-collector.ts +2670 -0
  83. package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
  84. package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
  85. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
  86. package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
  87. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
  88. package/templates/standard/workers/lib/error-collector/github.ts +329 -0
  89. package/templates/standard/workers/lib/error-collector/types.ts +262 -0
  90. package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
  91. package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
  92. package/templates/standard/workers/platform-sentinel.ts +1744 -0
  93. package/templates/standard/wrangler.error-collector.jsonc.hbs +44 -0
  94. package/templates/standard/wrangler.sentinel.jsonc.hbs +45 -0
@@ -0,0 +1,610 @@
1
+ /**
2
+ * Usage Settings Handlers
3
+ *
4
+ * Handler functions for settings, circuit breaker status, and live usage endpoints.
5
+ * Extracted from platform-usage.ts as part of Phase B migration.
6
+ */
7
+
8
+ import {
9
+ type Env,
10
+ type BudgetThresholds,
11
+ type SettingsResponse,
12
+ type LiveUsageResponse,
13
+ SamplingMode,
14
+ } from '../shared';
15
+ import { CB_KEYS, SETTINGS_KEY, EXPECTED_USAGE_SETTINGS } from '../shared/constants';
16
+ import {
17
+ jsonResponse,
18
+ getPlatformSettings,
19
+ getBudgetThresholds,
20
+ validateApiKey,
21
+ } from '../shared/utils';
22
+ import {
23
+ DEFAULT_ALERT_THRESHOLDS,
24
+ mergeThresholds,
25
+ type AlertThresholds,
26
+ type ServiceThreshold,
27
+ } from '../../shared/cloudflare';
28
+ import { createLoggerFromEnv } from '@littlebearapps/platform-consumer-sdk';
29
+
30
+ // =============================================================================
31
+ // HELPER FUNCTIONS
32
+ // =============================================================================
33
+
34
+ /**
35
+ * Save budget thresholds to D1 usage_settings table.
36
+ */
37
+ async function saveBudgetThresholds(
38
+ env: Env,
39
+ thresholds: Partial<BudgetThresholds>
40
+ ): Promise<void> {
41
+ const now = Math.floor(Date.now() / 1000);
42
+
43
+ if (thresholds.softBudgetLimit !== undefined) {
44
+ await env.PLATFORM_DB.prepare(
45
+ `
46
+ INSERT INTO usage_settings (id, project, setting_key, setting_value, updated_at)
47
+ VALUES (?, 'all', 'budget_soft_limit', ?, ?)
48
+ ON CONFLICT (project, setting_key) DO UPDATE SET
49
+ setting_value = excluded.setting_value,
50
+ updated_at = excluded.updated_at
51
+ `
52
+ )
53
+ .bind(`budget_soft_limit_all`, thresholds.softBudgetLimit.toString(), now)
54
+ .run();
55
+ }
56
+
57
+ if (thresholds.warningThreshold !== undefined) {
58
+ await env.PLATFORM_DB.prepare(
59
+ `
60
+ INSERT INTO usage_settings (id, project, setting_key, setting_value, updated_at)
61
+ VALUES (?, 'all', 'budget_warning_threshold', ?, ?)
62
+ ON CONFLICT (project, setting_key) DO UPDATE SET
63
+ setting_value = excluded.setting_value,
64
+ updated_at = excluded.updated_at
65
+ `
66
+ )
67
+ .bind(`budget_warning_threshold_all`, thresholds.warningThreshold.toString(), now)
68
+ .run();
69
+ }
70
+ }
71
+
72
+ // =============================================================================
73
+ // SETTINGS HANDLERS
74
+ // =============================================================================
75
+
76
+ /**
77
+ * Handle GET /usage/settings (task-17.16)
78
+ *
79
+ * Returns current alert threshold configuration.
80
+ * Thresholds are stored in KV and merged with defaults.
81
+ */
82
+ export async function handleGetSettings(env: Env): Promise<Response> {
83
+ const startTime = Date.now();
84
+
85
+ try {
86
+ // Fetch alert thresholds from KV and budget thresholds from D1 in parallel
87
+ const [stored, budgetThresholds] = await Promise.all([
88
+ env.PLATFORM_CACHE.get(SETTINGS_KEY, 'json') as Promise<{
89
+ thresholds: Partial<AlertThresholds>;
90
+ updated: string;
91
+ } | null>,
92
+ getBudgetThresholds(env),
93
+ ]);
94
+
95
+ if (stored) {
96
+ // Merge stored thresholds with defaults to ensure all services are present
97
+ const thresholds = mergeThresholds(stored.thresholds);
98
+ return jsonResponse({
99
+ success: true,
100
+ thresholds,
101
+ budgetThresholds,
102
+ updated: stored.updated,
103
+ cached: true,
104
+ responseTimeMs: Date.now() - startTime,
105
+ } satisfies SettingsResponse & { responseTimeMs: number });
106
+ }
107
+
108
+ // No custom config, return defaults
109
+ return jsonResponse({
110
+ success: true,
111
+ thresholds: DEFAULT_ALERT_THRESHOLDS,
112
+ budgetThresholds,
113
+ cached: false,
114
+ responseTimeMs: Date.now() - startTime,
115
+ } satisfies SettingsResponse & { responseTimeMs: number });
116
+ } catch (error) {
117
+ const errorMessage = error instanceof Error ? error.message : String(error);
118
+ // Error fetching settings - log at warn level since defaults will be used
119
+
120
+ return jsonResponse(
121
+ {
122
+ success: false,
123
+ error: 'Failed to fetch settings',
124
+ message: errorMessage,
125
+ },
126
+ 500
127
+ );
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Handle PUT /usage/settings (task-17.16)
133
+ *
134
+ * Updates the alert threshold configuration and budget thresholds.
135
+ * Request body should contain partial AlertThresholds and/or budgetThresholds.
136
+ */
137
+ export async function handlePutSettings(request: Request, env: Env): Promise<Response> {
138
+ const startTime = Date.now();
139
+
140
+ try {
141
+ const body = (await request.json()) as {
142
+ thresholds?: Partial<AlertThresholds>;
143
+ budgetThresholds?: Partial<BudgetThresholds>;
144
+ };
145
+
146
+ if (
147
+ (!body.thresholds || typeof body.thresholds !== 'object') &&
148
+ (!body.budgetThresholds || typeof body.budgetThresholds !== 'object')
149
+ ) {
150
+ return jsonResponse(
151
+ {
152
+ success: false,
153
+ error: 'Invalid request body',
154
+ message: 'Request body must contain a thresholds and/or budgetThresholds object',
155
+ },
156
+ 400
157
+ );
158
+ }
159
+
160
+ // Validate alert threshold values if provided
161
+ if (body.thresholds) {
162
+ for (const [service, config] of Object.entries(body.thresholds)) {
163
+ if (config) {
164
+ // Validate percentage values are 0-100
165
+ if (
166
+ config.warningPct !== undefined &&
167
+ (config.warningPct < 0 || config.warningPct > 100)
168
+ ) {
169
+ return jsonResponse(
170
+ {
171
+ success: false,
172
+ error: 'Invalid threshold value',
173
+ message: `${service}.warningPct must be between 0 and 100`,
174
+ },
175
+ 400
176
+ );
177
+ }
178
+ if (config.highPct !== undefined && (config.highPct < 0 || config.highPct > 100)) {
179
+ return jsonResponse(
180
+ {
181
+ success: false,
182
+ error: 'Invalid threshold value',
183
+ message: `${service}.highPct must be between 0 and 100`,
184
+ },
185
+ 400
186
+ );
187
+ }
188
+ if (
189
+ config.criticalPct !== undefined &&
190
+ (config.criticalPct < 0 || config.criticalPct > 100)
191
+ ) {
192
+ return jsonResponse(
193
+ {
194
+ success: false,
195
+ error: 'Invalid threshold value',
196
+ message: `${service}.criticalPct must be between 0 and 100`,
197
+ },
198
+ 400
199
+ );
200
+ }
201
+ // Validate absoluteMax is non-negative
202
+ if (config.absoluteMax !== undefined && config.absoluteMax < 0) {
203
+ return jsonResponse(
204
+ {
205
+ success: false,
206
+ error: 'Invalid threshold value',
207
+ message: `${service}.absoluteMax must be non-negative`,
208
+ },
209
+ 400
210
+ );
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ // Validate budget threshold values if provided
217
+ if (body.budgetThresholds) {
218
+ if (
219
+ body.budgetThresholds.softBudgetLimit !== undefined &&
220
+ (body.budgetThresholds.softBudgetLimit < 0 || body.budgetThresholds.softBudgetLimit > 10000)
221
+ ) {
222
+ return jsonResponse(
223
+ {
224
+ success: false,
225
+ error: 'Invalid budget threshold value',
226
+ message: 'softBudgetLimit must be between 0 and 10000',
227
+ },
228
+ 400
229
+ );
230
+ }
231
+ if (
232
+ body.budgetThresholds.warningThreshold !== undefined &&
233
+ (body.budgetThresholds.warningThreshold < 0 ||
234
+ body.budgetThresholds.warningThreshold > 10000)
235
+ ) {
236
+ return jsonResponse(
237
+ {
238
+ success: false,
239
+ error: 'Invalid budget threshold value',
240
+ message: 'warningThreshold must be between 0 and 10000',
241
+ },
242
+ 400
243
+ );
244
+ }
245
+ }
246
+
247
+ const updated = new Date().toISOString();
248
+ let fullThresholds: AlertThresholds = DEFAULT_ALERT_THRESHOLDS;
249
+
250
+ // Update alert thresholds in KV if provided
251
+ if (body.thresholds) {
252
+ // Get existing settings to merge with
253
+ const existing = (await env.PLATFORM_CACHE.get(SETTINGS_KEY, 'json')) as {
254
+ thresholds: Partial<AlertThresholds>;
255
+ updated: string;
256
+ } | null;
257
+
258
+ // Merge new thresholds with existing (deep merge per service)
259
+ const mergedThresholds: Record<string, Partial<ServiceThreshold>> = {};
260
+ const allServices = Array.from(
261
+ new Set([...Object.keys(existing?.thresholds ?? {}), ...Object.keys(body.thresholds)])
262
+ );
263
+
264
+ for (const service of allServices) {
265
+ const existingService = existing?.thresholds?.[service];
266
+ const newService = body.thresholds[service];
267
+ if (existingService || newService) {
268
+ mergedThresholds[service] = {
269
+ ...existingService,
270
+ ...newService,
271
+ };
272
+ }
273
+ }
274
+
275
+ await env.PLATFORM_CACHE.put(
276
+ SETTINGS_KEY,
277
+ JSON.stringify({ thresholds: mergedThresholds, updated }),
278
+ { expirationTtl: 60 * 60 * 24 * 365 } // 1 year TTL
279
+ );
280
+
281
+ fullThresholds = mergeThresholds(mergedThresholds as Partial<AlertThresholds>);
282
+ // Alert thresholds updated
283
+ }
284
+
285
+ // Update budget thresholds in D1 if provided
286
+ if (body.budgetThresholds) {
287
+ await saveBudgetThresholds(env, body.budgetThresholds);
288
+ // Budget thresholds updated
289
+ }
290
+
291
+ // Fetch current budget thresholds for the response
292
+ const currentBudgetThresholds = await getBudgetThresholds(env);
293
+
294
+ return jsonResponse({
295
+ success: true,
296
+ thresholds: fullThresholds,
297
+ budgetThresholds: currentBudgetThresholds,
298
+ updated,
299
+ responseTimeMs: Date.now() - startTime,
300
+ } satisfies SettingsResponse & { responseTimeMs: number });
301
+ } catch (error) {
302
+ const errorMessage = error instanceof Error ? error.message : String(error);
303
+ // Error updating settings
304
+
305
+ return jsonResponse(
306
+ {
307
+ success: false,
308
+ error: 'Failed to update settings',
309
+ message: errorMessage,
310
+ },
311
+ 500
312
+ );
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Handle GET /usage/settings/verify
318
+ *
319
+ * Returns all settings from D1 usage_settings table and validates completeness.
320
+ * Used to verify that all expected settings exist after migrations/sync.
321
+ */
322
+ export async function handleSettingsVerify(env: Env): Promise<Response> {
323
+ try {
324
+ // Fetch all settings from D1
325
+ const result = await env.PLATFORM_DB.prepare(
326
+ `
327
+ SELECT setting_key, setting_value, project, updated_at
328
+ FROM usage_settings
329
+ WHERE project = 'all'
330
+ ORDER BY setting_key
331
+ `
332
+ ).all<{ setting_key: string; setting_value: string; project: string; updated_at: number }>();
333
+
334
+ const settings = result.results ?? [];
335
+ const foundKeys = new Set(settings.map((s) => s.setting_key));
336
+
337
+ // Check for missing expected settings
338
+ const missingKeys = EXPECTED_USAGE_SETTINGS.filter((key) => !foundKeys.has(key));
339
+
340
+ // Check for unexpected settings (not in expected list)
341
+ const unexpectedKeys = settings
342
+ .map((s) => s.setting_key)
343
+ .filter((key) => !EXPECTED_USAGE_SETTINGS.includes(key));
344
+
345
+ const status = missingKeys.length === 0 ? 'complete' : 'incomplete';
346
+
347
+ return jsonResponse({
348
+ status,
349
+ totalExpected: EXPECTED_USAGE_SETTINGS.length,
350
+ totalFound: settings.length,
351
+ missingCount: missingKeys.length,
352
+ unexpectedCount: unexpectedKeys.length,
353
+ missing: missingKeys,
354
+ unexpected: unexpectedKeys,
355
+ settings: settings.map((s) => ({
356
+ key: s.setting_key,
357
+ value: s.setting_value,
358
+ project: s.project,
359
+ updatedAt: s.updated_at ? new Date(s.updated_at * 1000).toISOString() : null,
360
+ })),
361
+ });
362
+ } catch (error) {
363
+ return jsonResponse(
364
+ {
365
+ status: 'error',
366
+ error: error instanceof Error ? error.message : String(error),
367
+ },
368
+ 500
369
+ );
370
+ }
371
+ }
372
+
373
+ // =============================================================================
374
+ // CIRCUIT BREAKER STATUS HANDLER
375
+ // =============================================================================
376
+
377
+ /**
378
+ * Handle GET /usage/circuit-breaker-status
379
+ *
380
+ * Returns current circuit breaker status for all services.
381
+ */
382
+ export async function handleCircuitBreakerStatus(env: Env): Promise<Response> {
383
+ const startTime = Date.now();
384
+
385
+ try {
386
+ // Fetch settings and KV values in parallel
387
+ const [settings, globalStop, samplingMode, d1Writes] = await Promise.all([
388
+ getPlatformSettings(env),
389
+ env.PLATFORM_CACHE.get(CB_KEYS.GLOBAL_STOP),
390
+ env.PLATFORM_CACHE.get(CB_KEYS.USAGE_SAMPLING_MODE),
391
+ env.PLATFORM_CACHE.get(CB_KEYS.D1_WRITES_24H),
392
+ ]);
393
+
394
+ // Get registered projects from D1 and fetch their CB statuses dynamically
395
+ // TODO: Ensure your projects are registered in project_registry
396
+ const projectRows = await env.PLATFORM_DB.prepare(
397
+ `SELECT project_id FROM project_registry WHERE project_id != 'all'`
398
+ ).all<{ project_id: string }>();
399
+ const registeredProjects = projectRows.results?.map((r) => r.project_id) ?? ['platform'];
400
+
401
+ // Fetch all project CB statuses in parallel
402
+ const projectStatusEntries = await Promise.all(
403
+ registeredProjects.map(async (pid) => {
404
+ const cbKey = `PROJECT:${pid.toUpperCase().replace(/-/g, '-')}:STATUS`;
405
+ const status = await env.PLATFORM_CACHE.get(cbKey);
406
+ return { id: pid, status: status ?? 'active' };
407
+ })
408
+ );
409
+
410
+ const d1WriteLimit = settings.d1WriteLimit;
411
+ const d1WritesNum = d1Writes ? parseInt(d1Writes, 10) : 0;
412
+ const d1WritePercentage = (d1WritesNum / d1WriteLimit) * 100;
413
+
414
+ // Determine sampling mode name
415
+ const samplingModeName = samplingMode
416
+ ? (Object.keys(SamplingMode).find(
417
+ (k) => SamplingMode[k as keyof typeof SamplingMode] === parseInt(samplingMode, 10)
418
+ ) ?? 'FULL')
419
+ : 'FULL';
420
+
421
+ // Build dynamic circuit breaker status objects
422
+ const circuitBreakers: Record<string, { status: string; paused: boolean }> = {
423
+ globalStop: { status: globalStop === 'true' ? 'true' : 'false', paused: globalStop === 'true' },
424
+ };
425
+ for (const entry of projectStatusEntries) {
426
+ circuitBreakers[entry.id] = {
427
+ status: entry.status,
428
+ paused: entry.status === 'paused',
429
+ };
430
+ }
431
+
432
+ return jsonResponse({
433
+ success: true,
434
+ circuitBreakers,
435
+ // Array format for UI consumption (matches CircuitBreakerStatus component)
436
+ projects: projectStatusEntries.map((entry) => ({
437
+ id: entry.id,
438
+ status: entry.status === 'paused' ? 'tripped' : 'active',
439
+ label: entry.status === 'paused' ? 'Paused' : 'Active',
440
+ })),
441
+ adaptiveSampling: {
442
+ samplingMode: samplingModeName,
443
+ d1Writes24h: d1WritesNum,
444
+ d1WriteLimit,
445
+ d1WritePercentage: Math.round(d1WritePercentage * 100) / 100,
446
+ },
447
+ timestamp: new Date().toISOString(),
448
+ responseTimeMs: Date.now() - startTime,
449
+ });
450
+ } catch (error) {
451
+ const errorMessage = error instanceof Error ? error.message : String(error);
452
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:circuitbreaker');
453
+ log.error('Error fetching circuit breaker status', error instanceof Error ? error : undefined, {
454
+ errorMessage,
455
+ });
456
+
457
+ return jsonResponse(
458
+ {
459
+ success: false,
460
+ error: 'Failed to fetch circuit breaker status',
461
+ message: errorMessage,
462
+ },
463
+ 500
464
+ );
465
+ }
466
+ }
467
+
468
+ // =============================================================================
469
+ // LIVE USAGE HANDLER
470
+ // =============================================================================
471
+
472
+ /**
473
+ * Handle GET /usage/live
474
+ *
475
+ * Returns real-time KV data for monitoring:
476
+ * - Circuit breaker states (global + per-project)
477
+ * - Adaptive sampling mode and D1 write tracking
478
+ * - Latest hourly snapshot metrics
479
+ *
480
+ * Requires X-API-Key header for authentication.
481
+ */
482
+ export async function handleLiveUsage(request: Request, env: Env): Promise<Response> {
483
+ const startTime = Date.now();
484
+
485
+ // Validate API key
486
+ const authError = validateApiKey(request, env);
487
+ if (authError) return authError;
488
+
489
+ try {
490
+ // Fetch settings and KV data in parallel for minimal latency
491
+ const [settings, globalStop, samplingMode, d1Writes] = await Promise.all([
492
+ getPlatformSettings(env),
493
+ env.PLATFORM_CACHE.get(CB_KEYS.GLOBAL_STOP),
494
+ env.PLATFORM_CACHE.get(CB_KEYS.USAGE_SAMPLING_MODE),
495
+ env.PLATFORM_CACHE.get(CB_KEYS.D1_WRITES_24H),
496
+ ]);
497
+
498
+ // Get registered projects and fetch their CB statuses dynamically
499
+ const liveProjectRows = await env.PLATFORM_DB.prepare(
500
+ `SELECT project_id FROM project_registry WHERE project_id != 'all'`
501
+ ).all<{ project_id: string }>();
502
+ const liveRegisteredProjects = liveProjectRows.results?.map((r) => r.project_id) ?? ['platform'];
503
+
504
+ const projectStatuses: Array<{ project: string; status: string | null }> = await Promise.all(
505
+ liveRegisteredProjects.map(async (pid) => {
506
+ const cbKey = `PROJECT:${pid.toUpperCase().replace(/-/g, '-')}:STATUS`;
507
+ const status = await env.PLATFORM_CACHE.get(cbKey);
508
+ return { project: pid, status };
509
+ })
510
+ );
511
+
512
+ const d1WriteLimit = settings.d1WriteLimit;
513
+
514
+ // Build list of active circuit breakers
515
+ const activeBreakers: LiveUsageResponse['circuitBreakers']['activeBreakers'] = [];
516
+
517
+ for (const { project, status } of projectStatuses) {
518
+ if (status === 'paused') {
519
+ activeBreakers.push({
520
+ project,
521
+ status: 'paused',
522
+ reason: 'Resource limit exceeded',
523
+ });
524
+ } else if (status === 'degraded') {
525
+ activeBreakers.push({
526
+ project,
527
+ status: 'degraded',
528
+ reason: 'Operating in degraded mode',
529
+ });
530
+ }
531
+ }
532
+
533
+ // Calculate D1 write metrics
534
+ const d1WritesNum = d1Writes ? parseInt(d1Writes, 10) : 0;
535
+ const d1WritePercentage = (d1WritesNum / d1WriteLimit) * 100;
536
+
537
+ // Determine sampling mode name
538
+ const samplingModeName = samplingMode
539
+ ? (Object.keys(SamplingMode).find(
540
+ (k) => SamplingMode[k as keyof typeof SamplingMode] === parseInt(samplingMode, 10)
541
+ ) ?? 'FULL')
542
+ : 'FULL';
543
+
544
+ // Fetch latest hourly snapshot from D1 for request estimates
545
+ let latestSnapshot: LiveUsageResponse['latestSnapshot'] = null;
546
+ try {
547
+ const snapshotResult = await env.PLATFORM_DB.prepare(
548
+ `SELECT snapshot_hour, workers_requests, d1_rows_read, kv_reads
549
+ FROM hourly_usage_snapshots
550
+ WHERE project = 'all'
551
+ ORDER BY snapshot_hour DESC
552
+ LIMIT 1`
553
+ ).first<{
554
+ snapshot_hour: string;
555
+ workers_requests: number | null;
556
+ d1_rows_read: number | null;
557
+ kv_reads: number | null;
558
+ }>();
559
+
560
+ if (snapshotResult) {
561
+ latestSnapshot = {
562
+ snapshotHour: snapshotResult.snapshot_hour,
563
+ workersRequests: snapshotResult.workers_requests,
564
+ d1RowsRead: snapshotResult.d1_rows_read,
565
+ kvReads: snapshotResult.kv_reads,
566
+ };
567
+ }
568
+ } catch (dbError) {
569
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:live');
570
+ log.error(
571
+ 'Failed to fetch latest snapshot from D1',
572
+ dbError instanceof Error ? dbError : undefined
573
+ );
574
+ // Continue without snapshot data - KV data is primary
575
+ }
576
+
577
+ const response: LiveUsageResponse = {
578
+ timestamp: new Date().toISOString(),
579
+ circuitBreakers: {
580
+ globalStop: globalStop === 'true',
581
+ activeBreakers,
582
+ },
583
+ adaptiveSampling: {
584
+ mode: samplingModeName,
585
+ d1Writes24h: d1WritesNum,
586
+ d1WriteLimit,
587
+ d1WritePercentage: Math.round(d1WritePercentage * 100) / 100,
588
+ },
589
+ latestSnapshot,
590
+ responseTimeMs: Date.now() - startTime,
591
+ };
592
+
593
+ return jsonResponse(response);
594
+ } catch (error) {
595
+ const errorMessage = error instanceof Error ? error.message : String(error);
596
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:live');
597
+ log.error('Error fetching live usage', error instanceof Error ? error : undefined, {
598
+ errorMessage,
599
+ });
600
+
601
+ return jsonResponse(
602
+ {
603
+ success: false,
604
+ error: 'Failed to fetch live usage data',
605
+ message: errorMessage,
606
+ },
607
+ 500
608
+ );
609
+ }
610
+ }