@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,2420 @@
1
+ /**
2
+ * Usage Metrics Handler Module
3
+ *
4
+ * Handles usage metrics endpoints for the platform-usage worker.
5
+ * Extracted from platform-usage.ts as part of Phase B migration.
6
+ *
7
+ * Endpoints handled:
8
+ * - GET /usage - Get usage metrics with cost breakdown
9
+ * - GET /usage/costs - Get cost breakdown only
10
+ * - GET /usage/thresholds - Get threshold warnings only
11
+ * - GET /usage/enhanced - Get enhanced usage metrics with sparklines and trends
12
+ * - GET /usage/compare - Get period comparison (task-17.3, 17.4)
13
+ * - GET /usage/daily - Get daily cost breakdown for chart/table (task-18)
14
+ * - GET /usage/status - Project status for unified dashboard
15
+ * - GET /usage/projects - Project list with resource counts
16
+ * - GET /usage/anomalies - Detected usage anomalies
17
+ * - GET /usage/utilization - Burn rate and per-project utilization
18
+ */
19
+
20
+ import {
21
+ CloudflareGraphQL,
22
+ calculateMonthlyCosts,
23
+ calculateProjectCosts,
24
+ analyseThresholds,
25
+ formatCurrency,
26
+ getProjects,
27
+ type TimePeriod,
28
+ type DateRange,
29
+ type CompareMode,
30
+ type AccountUsage,
31
+ type CostBreakdown,
32
+ type ProjectCostBreakdown,
33
+ type Project,
34
+ } from '../../shared/cloudflare';
35
+ import {
36
+ CF_SIMPLE_ALLOWANCES,
37
+ type SimpleAllowanceType,
38
+ } from '../../shared/allowances';
39
+ import { createLoggerFromEnv } from '@littlebearapps/platform-consumer-sdk';
40
+ import { queryD1UsageData, queryD1DailyCosts, calculateProjectedBurn } from './data-queries';
41
+ import {
42
+ type Env,
43
+ type UsageResponse,
44
+ type EnhancedUsageResponse,
45
+ type ComparisonResponse,
46
+ type DailyCostResponse,
47
+ type BurnRateResponse,
48
+ type ProjectUtilizationData,
49
+ type GitHubUsageResponse,
50
+ type ResourceMetricData,
51
+ type ProviderHealthData,
52
+ type ServiceUtilizationStatus,
53
+ type AnomalyRecord,
54
+ type AnomaliesResponse,
55
+ type ProjectedBurn,
56
+ type DailyCostData,
57
+ type TimePeriod as SharedTimePeriod,
58
+ getCacheKey,
59
+ parseQueryParams,
60
+ parseQueryParamsWithRegistry,
61
+ jsonResponse,
62
+ buildProjectLookupCache,
63
+ filterByProject,
64
+ filterByProjectWithRegistry,
65
+ calculateSummary,
66
+ calcTrend,
67
+ getBudgetThresholds,
68
+ getServiceUtilizationStatus,
69
+ CB_KEYS,
70
+ CF_OVERAGE_PRICING,
71
+ FALLBACK_PROJECT_CONFIGS,
72
+ } from '../shared';
73
+ import { getUtilizationStatus } from '../../platform-settings';
74
+
75
+ // =============================================================================
76
+ // LOCAL TYPE DEFINITIONS
77
+ // =============================================================================
78
+
79
+ /**
80
+ * Projected cost calculation based on MTD usage
81
+ */
82
+ interface ProjectedCost {
83
+ /** Current month-to-date cost in USD */
84
+ currentCost: number;
85
+ /** Number of days elapsed this month */
86
+ daysPassed: number;
87
+ /** Total days in the current month */
88
+ daysInMonth: number;
89
+ /** Projected end-of-month cost based on current burn rate */
90
+ projectedMonthlyCost: number;
91
+ }
92
+
93
+ /**
94
+ * Service allowance definition for API response
95
+ */
96
+ interface AllowanceInfo {
97
+ limit: number;
98
+ unit: string;
99
+ }
100
+
101
+ /**
102
+ * Project list response type
103
+ */
104
+ interface ProjectListResponse {
105
+ success: boolean;
106
+ projects: Array<Project & { resourceCount: number }>;
107
+ totalResources: number;
108
+ timestamp: string;
109
+ cached: boolean;
110
+ /** Cloudflare account-level service allowances */
111
+ allowances: {
112
+ workers: AllowanceInfo;
113
+ d1_writes: AllowanceInfo;
114
+ kv_writes: AllowanceInfo;
115
+ r2_storage: AllowanceInfo;
116
+ durableObjects: AllowanceInfo;
117
+ vectorize: AllowanceInfo;
118
+ github_actions_minutes: AllowanceInfo;
119
+ };
120
+ /** Projected monthly cost based on MTD burn rate */
121
+ projectedCost: ProjectedCost;
122
+ }
123
+
124
+ // =============================================================================
125
+ // CLOUDFLARE ALLOWANCES ALIAS
126
+ // =============================================================================
127
+
128
+ const CF_ALLOWANCES = CF_SIMPLE_ALLOWANCES;
129
+
130
+ // =============================================================================
131
+ // HELPER FUNCTIONS
132
+ // =============================================================================
133
+
134
+ /**
135
+ * Get project config from D1 registry or fallback to hardcoded config.
136
+ * Once migration 009 is applied, this will primarily use D1.
137
+ */
138
+ function getProjectConfig(
139
+ project: Project
140
+ ): { name: string; primaryResource: SimpleAllowanceType; customLimit?: number } | null {
141
+ // Use D1 registry values if available
142
+ if (project.primaryResource) {
143
+ const primaryResource = project.primaryResource as SimpleAllowanceType;
144
+ if (CF_ALLOWANCES[primaryResource]) {
145
+ return {
146
+ name: project.displayName,
147
+ primaryResource,
148
+ customLimit: project.customLimit ?? undefined,
149
+ };
150
+ }
151
+ }
152
+
153
+ // Fallback to hardcoded config
154
+ return FALLBACK_PROJECT_CONFIGS[project.projectId] ?? null;
155
+ }
156
+
157
+ /**
158
+ * Query GitHub usage data from D1 third_party_usage table.
159
+ * Returns MTD aggregated data for display in the dashboard.
160
+ */
161
+ async function queryGitHubUsage(env: Env): Promise<GitHubUsageResponse | null> {
162
+ try {
163
+ const now = new Date();
164
+ const currentYear = now.getUTCFullYear();
165
+ const currentMonth = now.getUTCMonth();
166
+ const mtdStartDate = new Date(Date.UTC(currentYear, currentMonth, 1))
167
+ .toISOString()
168
+ .slice(0, 10);
169
+
170
+ // Query all GitHub metrics for the current month
171
+ const result = await env.PLATFORM_DB.prepare(
172
+ `
173
+ SELECT resource_type, usage_value, cost_usd, usage_unit,
174
+ MAX(snapshot_date) as latest_date,
175
+ MAX(collection_timestamp) as latest_ts
176
+ FROM third_party_usage
177
+ WHERE provider = 'github'
178
+ AND snapshot_date >= ?
179
+ GROUP BY resource_type
180
+ `
181
+ )
182
+ .bind(mtdStartDate)
183
+ .all<{
184
+ resource_type: string;
185
+ usage_value: number;
186
+ cost_usd: number;
187
+ usage_unit: string;
188
+ latest_date: string;
189
+ latest_ts: number;
190
+ }>();
191
+
192
+ if (!result.results || result.results.length === 0) {
193
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:github');
194
+ log.info('No GitHub data found for current month', { tag: 'GITHUB_NO_DATA' });
195
+ return null;
196
+ }
197
+
198
+ // Build lookup map
199
+ const metrics: Record<string, { value: number; cost: number; unit: string }> = {};
200
+ let latestTimestamp: number | null = null;
201
+
202
+ for (const row of result.results) {
203
+ metrics[row.resource_type] = {
204
+ value: row.usage_value,
205
+ cost: row.cost_usd,
206
+ unit: row.usage_unit,
207
+ };
208
+ if (row.latest_ts && (latestTimestamp === null || row.latest_ts > latestTimestamp)) {
209
+ latestTimestamp = row.latest_ts;
210
+ }
211
+ }
212
+
213
+ // Check if data is stale (older than 24 hours)
214
+ const twentyFourHoursAgo = Date.now() / 1000 - 24 * 60 * 60;
215
+ const isStale = latestTimestamp ? latestTimestamp < twentyFourHoursAgo : true;
216
+
217
+ // Extract values with defaults
218
+ const actionsMinutes = metrics['actions_minutes']?.value ?? 0;
219
+ const actionsMinutesIncluded = metrics['actions_minutes_included']?.value ?? 50000; // Default to Enterprise
220
+ const actionsMinutesUsagePct =
221
+ metrics['actions_minutes_usage_pct']?.value ??
222
+ (actionsMinutesIncluded > 0 ? (actionsMinutes / actionsMinutesIncluded) * 100 : 0);
223
+ const actionsStorageGbHours = metrics['actions_storage_gb_hours']?.value ?? 0;
224
+ const actionsStorageGbIncluded = metrics['actions_storage_gb_included']?.value ?? 50; // Default to Enterprise
225
+ const ghecUserMonths = metrics['ghec_user_months']?.value ?? 0;
226
+ const ghasCodeSecuritySeats = metrics['ghas_code_security_user_months']?.value ?? 0;
227
+ const ghasSecretProtectionSeats = metrics['ghas_secret_protection_user_months']?.value ?? 0;
228
+ const totalCost = metrics['total_net_cost']?.value ?? 0;
229
+
230
+ // Plan info
231
+ const planName = metrics['plan_name']?.unit ?? 'unknown'; // plan_name stores the name in usage_unit
232
+ const filledSeats = metrics['filled_seats']?.value ?? 0;
233
+ const totalSeats = metrics['total_seats']?.value ?? 0;
234
+
235
+ const response: GitHubUsageResponse = {
236
+ mtdUsage: {
237
+ actionsMinutes: Math.round(actionsMinutes),
238
+ actionsMinutesIncluded: Math.round(actionsMinutesIncluded),
239
+ actionsMinutesUsagePct: Math.round(actionsMinutesUsagePct * 10) / 10,
240
+ actionsStorageGbHours: Math.round(actionsStorageGbHours * 100) / 100,
241
+ actionsStorageGbIncluded: actionsStorageGbIncluded,
242
+ ghecUserMonths: Math.round(ghecUserMonths * 100) / 100,
243
+ ghasCodeSecuritySeats: Math.round(ghasCodeSecuritySeats),
244
+ ghasSecretProtectionSeats: Math.round(ghasSecretProtectionSeats),
245
+ totalCost: Math.round(totalCost * 100) / 100,
246
+ },
247
+ plan: {
248
+ name: planName,
249
+ filledSeats: Math.round(filledSeats),
250
+ totalSeats: Math.round(totalSeats),
251
+ },
252
+ lastUpdated: latestTimestamp ? new Date(latestTimestamp * 1000).toISOString() : null,
253
+ isStale,
254
+ };
255
+
256
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:github');
257
+ log.info(
258
+ `GitHub data: ${response.mtdUsage.actionsMinutes}/${response.mtdUsage.actionsMinutesIncluded} mins (${response.mtdUsage.actionsMinutesUsagePct}%), $${response.mtdUsage.totalCost}`,
259
+ { tag: 'USAGE' }
260
+ );
261
+
262
+ return response;
263
+ } catch (error) {
264
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:github');
265
+ log.error(
266
+ 'Error querying GitHub usage',
267
+ error instanceof Error ? error : new Error(String(error))
268
+ );
269
+ return null;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Build service-level utilization metrics from D1 totals.
275
+ * Returns CloudFlare service utilization data for the usage overview.
276
+ */
277
+ function buildCloudflareServiceMetrics(
278
+ totals: DailyCostData['totals'],
279
+ dayOfMonth: number
280
+ ): ResourceMetricData[] {
281
+ const metrics: ResourceMetricData[] = [];
282
+
283
+ // Workers Requests (estimated from cost)
284
+ const workersRequests = totals.workers > 0 ? Math.round((totals.workers / 0.3) * 1_000_000) : 0;
285
+ const workersLimit = CF_ALLOWANCES.workers.limit;
286
+ const workersPct = workersLimit > 0 ? (workersRequests / workersLimit) * 100 : 0;
287
+ const workersOverage = Math.max(0, workersRequests - workersLimit);
288
+ metrics.push({
289
+ id: 'cf-workers',
290
+ label: 'Workers Requests',
291
+ provider: 'cloudflare',
292
+ current: workersRequests,
293
+ limit: workersLimit,
294
+ unit: 'requests',
295
+ percentage: Math.round(workersPct * 10) / 10,
296
+ costEstimate: totals.workers,
297
+ status: getServiceUtilizationStatus(workersPct),
298
+ overage: workersOverage,
299
+ overageCost: workersOverage * (CF_OVERAGE_PRICING.workers ?? 0),
300
+ });
301
+
302
+ // D1 Writes (estimated from cost)
303
+ const d1Writes = totals.d1 > 0 ? Math.round(totals.d1 * 1_000_000) : 0;
304
+ const d1Limit = CF_ALLOWANCES.d1.limit;
305
+ const d1Pct = d1Limit > 0 ? (d1Writes / d1Limit) * 100 : 0;
306
+ const d1Overage = Math.max(0, d1Writes - d1Limit);
307
+ metrics.push({
308
+ id: 'cf-d1',
309
+ label: 'D1 Writes',
310
+ provider: 'cloudflare',
311
+ current: d1Writes,
312
+ limit: d1Limit,
313
+ unit: 'rows',
314
+ percentage: Math.round(d1Pct * 10) / 10,
315
+ costEstimate: totals.d1,
316
+ status: getServiceUtilizationStatus(d1Pct),
317
+ overage: d1Overage,
318
+ overageCost: d1Overage * (CF_OVERAGE_PRICING.d1 ?? 0),
319
+ });
320
+
321
+ // KV Writes (estimated from cost)
322
+ const kvWrites = totals.kv > 0 ? Math.round((totals.kv / 5) * 1_000_000) : 0;
323
+ const kvLimit = CF_ALLOWANCES.kv.limit;
324
+ const kvPct = kvLimit > 0 ? (kvWrites / kvLimit) * 100 : 0;
325
+ const kvOverage = Math.max(0, kvWrites - kvLimit);
326
+ metrics.push({
327
+ id: 'cf-kv',
328
+ label: 'KV Writes',
329
+ provider: 'cloudflare',
330
+ current: kvWrites,
331
+ limit: kvLimit,
332
+ unit: 'writes',
333
+ percentage: Math.round(kvPct * 10) / 10,
334
+ costEstimate: totals.kv,
335
+ status: getServiceUtilizationStatus(kvPct),
336
+ overage: kvOverage,
337
+ overageCost: kvOverage * (CF_OVERAGE_PRICING.kv ?? 0),
338
+ });
339
+
340
+ // R2 Storage (estimated - assuming cost reflects storage)
341
+ const r2Bytes = totals.r2 > 0 ? Math.round((totals.r2 / 0.015) * 1_000_000_000) : 0;
342
+ const r2Limit = CF_ALLOWANCES.r2.limit;
343
+ const r2Pct = r2Limit > 0 ? (r2Bytes / r2Limit) * 100 : 0;
344
+ const r2Overage = Math.max(0, r2Bytes - r2Limit);
345
+ metrics.push({
346
+ id: 'cf-r2',
347
+ label: 'R2 Storage',
348
+ provider: 'cloudflare',
349
+ current: r2Bytes,
350
+ limit: r2Limit,
351
+ unit: 'bytes',
352
+ percentage: Math.round(r2Pct * 10) / 10,
353
+ costEstimate: totals.r2,
354
+ status: getServiceUtilizationStatus(r2Pct),
355
+ overage: r2Overage,
356
+ overageCost: (r2Overage / 1_000_000_000) * (CF_OVERAGE_PRICING.r2 ?? 0),
357
+ });
358
+
359
+ // Durable Objects Requests
360
+ const doRequests = totals.durableObjects > 0 ? Math.round(totals.durableObjects * 1_000_000) : 0;
361
+ const doLimit = CF_ALLOWANCES.durableObjects.limit;
362
+ const doPct = doLimit > 0 ? (doRequests / doLimit) * 100 : 0;
363
+ const doOverage = Math.max(0, doRequests - doLimit);
364
+ metrics.push({
365
+ id: 'cf-do',
366
+ label: 'Durable Objects',
367
+ provider: 'cloudflare',
368
+ current: doRequests,
369
+ limit: doLimit,
370
+ unit: 'requests',
371
+ percentage: Math.round(doPct * 10) / 10,
372
+ costEstimate: totals.durableObjects,
373
+ status: getServiceUtilizationStatus(doPct),
374
+ overage: doOverage,
375
+ overageCost: doOverage * (CF_OVERAGE_PRICING.durableObjects ?? 0),
376
+ });
377
+
378
+ // Vectorize Dimensions
379
+ const vectorizeDims =
380
+ totals.vectorize > 0 ? Math.round((totals.vectorize / 0.01) * 1_000_000) : 0;
381
+ const vectorizeLimit = CF_ALLOWANCES.vectorize.limit;
382
+ const vectorizePct = vectorizeLimit > 0 ? (vectorizeDims / vectorizeLimit) * 100 : 0;
383
+ const vectorizeOverage = Math.max(0, vectorizeDims - vectorizeLimit);
384
+ metrics.push({
385
+ id: 'cf-vectorize',
386
+ label: 'Vectorize Dimensions',
387
+ provider: 'cloudflare',
388
+ current: vectorizeDims,
389
+ limit: vectorizeLimit,
390
+ unit: 'dimensions',
391
+ percentage: Math.round(vectorizePct * 10) / 10,
392
+ costEstimate: totals.vectorize,
393
+ status: getServiceUtilizationStatus(vectorizePct),
394
+ overage: vectorizeOverage,
395
+ overageCost: vectorizeOverage * (CF_OVERAGE_PRICING.vectorize ?? 0),
396
+ });
397
+
398
+ // Workers AI Neurons
399
+ const aiNeurons = totals.workersAI > 0 ? Math.round((totals.workersAI / 0.011) * 1000) : 0;
400
+ const aiLimit = CF_ALLOWANCES.workersAI.limit * dayOfMonth; // Daily limit x days
401
+ const aiPct = aiLimit > 0 ? (aiNeurons / aiLimit) * 100 : 0;
402
+ const aiOverage = Math.max(0, aiNeurons - aiLimit);
403
+ metrics.push({
404
+ id: 'cf-workers-ai',
405
+ label: 'Workers AI',
406
+ provider: 'cloudflare',
407
+ current: aiNeurons,
408
+ limit: aiLimit,
409
+ unit: 'neurons',
410
+ percentage: Math.round(aiPct * 10) / 10,
411
+ costEstimate: totals.workersAI,
412
+ status: getServiceUtilizationStatus(aiPct),
413
+ overage: aiOverage,
414
+ overageCost: (aiOverage / 1000) * (CF_OVERAGE_PRICING.workersAI ?? 0),
415
+ });
416
+
417
+ // Queues Messages
418
+ const queuesMsgs = totals.queues > 0 ? Math.round((totals.queues / 0.4) * 1_000_000) : 0;
419
+ const queuesLimit = CF_ALLOWANCES.queues.limit;
420
+ const queuesPct = queuesLimit > 0 ? (queuesMsgs / queuesLimit) * 100 : 0;
421
+ const queuesOverage = Math.max(0, queuesMsgs - queuesLimit);
422
+ metrics.push({
423
+ id: 'cf-queues',
424
+ label: 'Queues Messages',
425
+ provider: 'cloudflare',
426
+ current: queuesMsgs,
427
+ limit: queuesLimit,
428
+ unit: 'messages',
429
+ percentage: Math.round(queuesPct * 10) / 10,
430
+ costEstimate: totals.queues,
431
+ status: getServiceUtilizationStatus(queuesPct),
432
+ overage: queuesOverage,
433
+ overageCost: queuesOverage * (CF_OVERAGE_PRICING.queues ?? 0),
434
+ });
435
+
436
+ // AI Gateway (unlimited, just show usage)
437
+ metrics.push({
438
+ id: 'cf-ai-gateway',
439
+ label: 'AI Gateway',
440
+ provider: 'cloudflare',
441
+ current: 0, // Not tracked in rollups
442
+ limit: null, // Unlimited
443
+ unit: 'requests',
444
+ percentage: 0,
445
+ costEstimate: totals.aiGateway,
446
+ status: 'ok',
447
+ overage: 0,
448
+ overageCost: 0,
449
+ });
450
+
451
+ return metrics.filter((m) => m.current > 0 || m.costEstimate > 0);
452
+ }
453
+
454
+ /**
455
+ * Build GitHub service metrics from third_party_usage data.
456
+ */
457
+ function buildGitHubServiceMetrics(github: GitHubUsageResponse | null): ResourceMetricData[] {
458
+ if (!github) return [];
459
+
460
+ const metrics: ResourceMetricData[] = [];
461
+ const usage = github.mtdUsage;
462
+
463
+ // Actions Minutes
464
+ const actionsLimit = usage.actionsMinutesIncluded || 50000;
465
+ const actionsPct = actionsLimit > 0 ? (usage.actionsMinutes / actionsLimit) * 100 : 0;
466
+ const actionsOverage = Math.max(0, usage.actionsMinutes - actionsLimit);
467
+ metrics.push({
468
+ id: 'gh-actions-minutes',
469
+ label: 'Actions Minutes',
470
+ provider: 'github',
471
+ current: usage.actionsMinutes,
472
+ limit: actionsLimit,
473
+ unit: 'minutes',
474
+ percentage: Math.round(actionsPct * 10) / 10,
475
+ costEstimate: actionsOverage * 0.008, // Overage at $0.008/min
476
+ status: getServiceUtilizationStatus(actionsPct),
477
+ overage: actionsOverage,
478
+ overageCost: actionsOverage * 0.008,
479
+ });
480
+
481
+ // Actions Storage
482
+ const storageLimit = usage.actionsStorageGbIncluded || 50;
483
+ const storageGb = usage.actionsStorageGbHours / 24; // Convert to GB (approx)
484
+ const storagePct = storageLimit > 0 ? (storageGb / storageLimit) * 100 : 0;
485
+ const storageOverage = Math.max(0, storageGb - storageLimit);
486
+ metrics.push({
487
+ id: 'gh-actions-storage',
488
+ label: 'Actions Storage',
489
+ provider: 'github',
490
+ current: Math.round(storageGb * 100) / 100,
491
+ limit: storageLimit,
492
+ unit: 'GB',
493
+ percentage: Math.round(storagePct * 10) / 10,
494
+ costEstimate: storageOverage * 0.25, // Overage at $0.25/GB
495
+ status: getServiceUtilizationStatus(storagePct),
496
+ overage: storageOverage,
497
+ overageCost: storageOverage * 0.25,
498
+ });
499
+
500
+ // GHAS Code Security (subscription, not utilization-based)
501
+ if (usage.ghasCodeSecuritySeats > 0) {
502
+ metrics.push({
503
+ id: 'gh-ghas-code',
504
+ label: 'GHAS Code Security',
505
+ provider: 'github',
506
+ current: usage.ghasCodeSecuritySeats,
507
+ limit: null, // Subscription-based
508
+ unit: 'seats',
509
+ percentage: 100, // Fixed subscription
510
+ costEstimate: usage.ghasCodeSecuritySeats * 49,
511
+ status: 'ok',
512
+ overage: 0,
513
+ overageCost: 0,
514
+ });
515
+ }
516
+
517
+ // GHAS Secret Protection
518
+ if (usage.ghasSecretProtectionSeats > 0) {
519
+ metrics.push({
520
+ id: 'gh-ghas-secrets',
521
+ label: 'GHAS Secret Protection',
522
+ provider: 'github',
523
+ current: usage.ghasSecretProtectionSeats,
524
+ limit: null, // Subscription-based
525
+ unit: 'seats',
526
+ percentage: 100,
527
+ costEstimate: usage.ghasSecretProtectionSeats * 31,
528
+ status: 'ok',
529
+ overage: 0,
530
+ overageCost: 0,
531
+ });
532
+ }
533
+
534
+ // GHEC Users
535
+ if (usage.ghecUserMonths > 0) {
536
+ metrics.push({
537
+ id: 'gh-ghec',
538
+ label: 'GHEC Seats',
539
+ provider: 'github',
540
+ current: usage.ghecUserMonths,
541
+ limit: null, // Subscription-based
542
+ unit: 'users',
543
+ percentage: 100,
544
+ costEstimate: usage.ghecUserMonths * 21,
545
+ status: 'ok',
546
+ overage: 0,
547
+ overageCost: 0,
548
+ });
549
+ }
550
+
551
+ return metrics;
552
+ }
553
+
554
+ /**
555
+ * Calculate provider health summary from service metrics.
556
+ */
557
+ function calculateProviderHealth(
558
+ metrics: ResourceMetricData[],
559
+ provider: 'cloudflare' | 'github'
560
+ ): ProviderHealthData {
561
+ const utilizationMetrics = metrics.filter((m) => m.limit !== null && m.limit > 0);
562
+
563
+ if (utilizationMetrics.length === 0) {
564
+ return { provider, percentage: 0, warnings: 0, status: 'ok' };
565
+ }
566
+
567
+ // Calculate weighted average by cost, or simple max
568
+ const maxPct = Math.max(...utilizationMetrics.map((m) => m.percentage));
569
+ const warnings = utilizationMetrics.filter(
570
+ (m) => m.status === 'warning' || m.status === 'critical' || m.status === 'overage'
571
+ ).length;
572
+
573
+ return {
574
+ provider,
575
+ percentage: Math.round(maxPct),
576
+ warnings,
577
+ status: getServiceUtilizationStatus(maxPct),
578
+ };
579
+ }
580
+
581
+ // =============================================================================
582
+ // HANDLER FUNCTIONS
583
+ // =============================================================================
584
+
585
+ /**
586
+ * Handle GET /usage
587
+ *
588
+ * Primary data source: D1 (hourly/daily rollups)
589
+ * Fallback: Live GraphQL API if D1 data is missing
590
+ * Added: Projected monthly burn calculation
591
+ */
592
+ export async function handleUsage(url: URL, env: Env): Promise<Response> {
593
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:handle');
594
+ const startTime = Date.now();
595
+ const { period, project } = await parseQueryParamsWithRegistry(url, env);
596
+ const cacheKey = getCacheKey('usage', period, project);
597
+
598
+ // Build project lookup cache for filtering (used in GraphQL fallback)
599
+ const projectLookupCache = await buildProjectLookupCache(env);
600
+
601
+ // Check KV cache first
602
+ try {
603
+ const cached = (await env.PLATFORM_CACHE.get(cacheKey, 'json')) as
604
+ | (UsageResponse & { dataSource?: string; projectedBurn?: ProjectedBurn })
605
+ | null;
606
+ if (cached) {
607
+ log.info('Cache hit', { tag: 'USAGE', cacheKey });
608
+ return jsonResponse({
609
+ ...cached,
610
+ cached: true,
611
+ responseTimeMs: Date.now() - startTime,
612
+ });
613
+ }
614
+ } catch (error) {
615
+ log.error('Cache read error', error as Error, { tag: 'USAGE', cacheKey });
616
+ }
617
+
618
+ log.info('Cache miss, fetching fresh data', { tag: 'USAGE', cacheKey });
619
+
620
+ // Try D1 first as primary data source
621
+ const d1Data = await queryD1UsageData(env, period, project);
622
+ const projectedBurn = await calculateProjectedBurn(env, project);
623
+
624
+ if (d1Data && d1Data.rowCount > 0) {
625
+ // D1 has data - use it as primary source
626
+ log.info('Using D1 data source', { tag: 'USAGE', rowCount: d1Data.rowCount });
627
+
628
+ const costs = d1Data.costs;
629
+ const response = {
630
+ success: true,
631
+ period,
632
+ project,
633
+ timestamp: new Date().toISOString(),
634
+ cached: false,
635
+ dataSource: 'd1' as const,
636
+ data: {
637
+ // D1 doesn't store per-resource details, provide summary only
638
+ workers: [] as AccountUsage['workers'],
639
+ d1: [] as AccountUsage['d1'],
640
+ kv: [] as AccountUsage['kv'],
641
+ r2: [] as AccountUsage['r2'],
642
+ durableObjects: {
643
+ requests: 0,
644
+ responseBodySize: 0,
645
+ gbSeconds: 0,
646
+ storageReadUnits: 0,
647
+ storageWriteUnits: 0,
648
+ storageDeleteUnits: 0,
649
+ } as AccountUsage['durableObjects'],
650
+ vectorize: [] as AccountUsage['vectorize'],
651
+ aiGateway: [] as AccountUsage['aiGateway'],
652
+ pages: [] as AccountUsage['pages'],
653
+ summary: {
654
+ totalWorkers: 0,
655
+ totalD1Databases: 0,
656
+ totalKVNamespaces: 0,
657
+ totalR2Buckets: 0,
658
+ totalVectorizeIndexes: 0,
659
+ totalAIGateways: 0,
660
+ totalPagesProjects: 0,
661
+ totalRequests: 0,
662
+ totalRowsRead: 0,
663
+ totalRowsWritten: 0,
664
+ },
665
+ },
666
+ costs: {
667
+ ...costs,
668
+ formatted: {
669
+ workers: formatCurrency(costs.workers),
670
+ d1: formatCurrency(costs.d1),
671
+ kv: formatCurrency(costs.kv),
672
+ r2: formatCurrency(costs.r2),
673
+ durableObjects: formatCurrency(costs.durableObjects),
674
+ vectorize: formatCurrency(costs.vectorize),
675
+ aiGateway: formatCurrency(costs.aiGateway),
676
+ pages: formatCurrency(costs.pages),
677
+ queues: formatCurrency(costs.queues),
678
+ workersAI: formatCurrency(costs.workersAI),
679
+ total: formatCurrency(costs.total),
680
+ },
681
+ },
682
+ projectCosts: [] as ProjectCostBreakdown[],
683
+ thresholds: {
684
+ workers: { level: 'normal' as const, percentage: 0 },
685
+ d1: { level: 'normal' as const, percentage: 0 },
686
+ kv: { level: 'normal' as const, percentage: 0 },
687
+ r2: { level: 'normal' as const, percentage: 0 },
688
+ durableObjects: { level: 'normal' as const, percentage: 0 },
689
+ vectorize: { level: 'normal' as const, percentage: 0 },
690
+ aiGateway: { level: 'normal' as const, percentage: 0 },
691
+ pages: { level: 'normal' as const, percentage: 0 },
692
+ },
693
+ projectedBurn,
694
+ };
695
+
696
+ // Cache the response in KV (1hr TTL)
697
+ try {
698
+ await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 1800 });
699
+ log.info('Cached D1 response', { tag: 'USAGE', cacheKey });
700
+ } catch (error) {
701
+ log.error('Cache write error', error as Error, { tag: 'USAGE', cacheKey });
702
+ }
703
+
704
+ const duration = Date.now() - startTime;
705
+ log.info('Fetched D1 data', { tag: 'USAGE', durationMs: duration });
706
+
707
+ return jsonResponse({
708
+ ...response,
709
+ responseTimeMs: duration,
710
+ });
711
+ }
712
+
713
+ // Fallback to live GraphQL if D1 is empty
714
+ log.info('D1 empty, falling back to GraphQL', { tag: 'USAGE' });
715
+
716
+ if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) {
717
+ return jsonResponse(
718
+ {
719
+ success: false,
720
+ error: 'Configuration Error',
721
+ message: 'Missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN',
722
+ },
723
+ 500
724
+ );
725
+ }
726
+
727
+ try {
728
+ const client = new CloudflareGraphQL(env);
729
+ const allMetrics = await client.getAllMetrics(period);
730
+ // Use D1 registry-backed filtering with pattern fallback
731
+ const filteredMetrics = filterByProjectWithRegistry(allMetrics, project, projectLookupCache);
732
+ const costs = calculateMonthlyCosts(filteredMetrics);
733
+ const projectCosts = calculateProjectCosts(allMetrics);
734
+ const thresholds = analyseThresholds(allMetrics);
735
+
736
+ const response = {
737
+ success: true,
738
+ period,
739
+ project,
740
+ timestamp: new Date().toISOString(),
741
+ cached: false,
742
+ dataSource: 'graphql' as const,
743
+ data: {
744
+ workers: filteredMetrics.workers,
745
+ d1: filteredMetrics.d1,
746
+ kv: filteredMetrics.kv,
747
+ r2: filteredMetrics.r2,
748
+ durableObjects: filteredMetrics.durableObjects,
749
+ vectorize: filteredMetrics.vectorize,
750
+ aiGateway: filteredMetrics.aiGateway,
751
+ pages: filteredMetrics.pages,
752
+ summary: calculateSummary(filteredMetrics),
753
+ },
754
+ costs: {
755
+ ...costs,
756
+ formatted: {
757
+ workers: formatCurrency(costs.workers),
758
+ d1: formatCurrency(costs.d1),
759
+ kv: formatCurrency(costs.kv),
760
+ r2: formatCurrency(costs.r2),
761
+ durableObjects: formatCurrency(costs.durableObjects),
762
+ vectorize: formatCurrency(costs.vectorize),
763
+ aiGateway: formatCurrency(costs.aiGateway),
764
+ pages: formatCurrency(costs.pages),
765
+ queues: formatCurrency(costs.queues),
766
+ workflows: formatCurrency(costs.workflows),
767
+ total: formatCurrency(costs.total),
768
+ },
769
+ },
770
+ projectCosts,
771
+ thresholds,
772
+ projectedBurn,
773
+ };
774
+
775
+ // Cache the response in KV (1hr TTL)
776
+ try {
777
+ await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 1800 });
778
+ log.info('Cached GraphQL response', { tag: 'USAGE', cacheKey });
779
+ } catch (error) {
780
+ log.error('Cache write error', error as Error, { tag: 'USAGE', cacheKey });
781
+ }
782
+
783
+ const duration = Date.now() - startTime;
784
+ log.info('Fetched GraphQL data', { tag: 'USAGE', durationMs: duration });
785
+
786
+ return jsonResponse({
787
+ ...response,
788
+ responseTimeMs: duration,
789
+ });
790
+ } catch (error) {
791
+ const errorMessage = error instanceof Error ? error.message : String(error);
792
+ log.error('Error fetching usage data', error as Error, { tag: 'USAGE' });
793
+
794
+ return jsonResponse(
795
+ {
796
+ success: false,
797
+ error: 'Failed to fetch usage data',
798
+ message: errorMessage,
799
+ },
800
+ 500
801
+ );
802
+ }
803
+ }
804
+
805
+ /**
806
+ * Handle GET /usage/costs
807
+ */
808
+ export async function handleCosts(url: URL, env: Env): Promise<Response> {
809
+ const startTime = Date.now();
810
+ const { period } = parseQueryParams(url);
811
+ const cacheKey = getCacheKey('costs', period, 'all');
812
+
813
+ try {
814
+ const cached = (await env.PLATFORM_CACHE.get(cacheKey, 'json')) as Record<
815
+ string,
816
+ unknown
817
+ > | null;
818
+ if (cached) {
819
+ return jsonResponse({
820
+ success: true,
821
+ cached: true,
822
+ ...cached,
823
+ responseTimeMs: Date.now() - startTime,
824
+ });
825
+ }
826
+ } catch {
827
+ // Continue with fresh fetch
828
+ }
829
+
830
+ if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) {
831
+ return jsonResponse(
832
+ {
833
+ success: false,
834
+ error: 'Configuration Error',
835
+ message: 'Missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN',
836
+ },
837
+ 500
838
+ );
839
+ }
840
+
841
+ try {
842
+ const client = new CloudflareGraphQL(env);
843
+ const metrics = await client.getAllMetrics(period);
844
+ const costs = calculateMonthlyCosts(metrics);
845
+ const projectCosts = calculateProjectCosts(metrics);
846
+
847
+ const response = {
848
+ period,
849
+ timestamp: new Date().toISOString(),
850
+ costs: {
851
+ ...costs,
852
+ formatted: {
853
+ workers: formatCurrency(costs.workers),
854
+ d1: formatCurrency(costs.d1),
855
+ kv: formatCurrency(costs.kv),
856
+ r2: formatCurrency(costs.r2),
857
+ durableObjects: formatCurrency(costs.durableObjects),
858
+ vectorize: formatCurrency(costs.vectorize),
859
+ aiGateway: formatCurrency(costs.aiGateway),
860
+ pages: formatCurrency(costs.pages),
861
+ queues: formatCurrency(costs.queues),
862
+ workflows: formatCurrency(costs.workflows),
863
+ total: formatCurrency(costs.total),
864
+ },
865
+ },
866
+ projectCosts,
867
+ };
868
+
869
+ try {
870
+ await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 1800 });
871
+ } catch {
872
+ // Continue without caching
873
+ }
874
+
875
+ return jsonResponse({
876
+ success: true,
877
+ cached: false,
878
+ ...response,
879
+ responseTimeMs: Date.now() - startTime,
880
+ });
881
+ } catch (error) {
882
+ const errorMessage = error instanceof Error ? error.message : String(error);
883
+ return jsonResponse(
884
+ {
885
+ success: false,
886
+ error: 'Failed to fetch cost data',
887
+ message: errorMessage,
888
+ },
889
+ 500
890
+ );
891
+ }
892
+ }
893
+
894
+ /**
895
+ * Handle GET /usage/thresholds
896
+ */
897
+ export async function handleThresholds(url: URL, env: Env): Promise<Response> {
898
+ const startTime = Date.now();
899
+ const { period } = parseQueryParams(url);
900
+
901
+ if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) {
902
+ return jsonResponse(
903
+ {
904
+ success: false,
905
+ error: 'Configuration Error',
906
+ message: 'Missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN',
907
+ },
908
+ 500
909
+ );
910
+ }
911
+
912
+ try {
913
+ const client = new CloudflareGraphQL(env);
914
+ const metrics = await client.getAllMetrics(period);
915
+ const thresholds = analyseThresholds(metrics);
916
+
917
+ return jsonResponse({
918
+ success: true,
919
+ period,
920
+ timestamp: new Date().toISOString(),
921
+ thresholds,
922
+ responseTimeMs: Date.now() - startTime,
923
+ });
924
+ } catch (error) {
925
+ const errorMessage = error instanceof Error ? error.message : String(error);
926
+ return jsonResponse(
927
+ {
928
+ success: false,
929
+ error: 'Failed to analyse thresholds',
930
+ message: errorMessage,
931
+ },
932
+ 500
933
+ );
934
+ }
935
+ }
936
+
937
+ /**
938
+ * Handle GET /usage/enhanced
939
+ */
940
+ export async function handleEnhanced(url: URL, env: Env): Promise<Response> {
941
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:enhanced');
942
+ const startTime = Date.now();
943
+ const { period, project } = await parseQueryParamsWithRegistry(url, env);
944
+ const cacheKey = getCacheKey('enhanced', period, project);
945
+
946
+ try {
947
+ const cached = (await env.PLATFORM_CACHE.get(cacheKey, 'json')) as EnhancedUsageResponse | null;
948
+ if (cached) {
949
+ log.info('Enhanced cache hit', { tag: 'USAGE', cacheKey });
950
+ return jsonResponse({
951
+ ...cached,
952
+ cached: true,
953
+ responseTimeMs: Date.now() - startTime,
954
+ });
955
+ }
956
+ } catch (error) {
957
+ log.error('Enhanced cache read error', error as Error, { tag: 'USAGE', cacheKey });
958
+ }
959
+
960
+ log.info('Enhanced cache miss, fetching fresh data', { tag: 'USAGE', cacheKey });
961
+
962
+ if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) {
963
+ return jsonResponse(
964
+ {
965
+ success: false,
966
+ error: 'Configuration Error',
967
+ message: 'Missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN',
968
+ },
969
+ 500
970
+ );
971
+ }
972
+
973
+ // Build project lookup cache for D1 registry-backed filtering
974
+ const projectLookupCache = await buildProjectLookupCache(env);
975
+
976
+ try {
977
+ const client = new CloudflareGraphQL(env);
978
+ const enhancedMetrics = await client.getAllEnhancedMetrics(period);
979
+ const filteredMetrics = filterByProjectWithRegistry(
980
+ enhancedMetrics,
981
+ project,
982
+ projectLookupCache
983
+ );
984
+ const costs = calculateMonthlyCosts(filteredMetrics);
985
+ const projectCosts = calculateProjectCosts(enhancedMetrics);
986
+ const thresholds = analyseThresholds(enhancedMetrics);
987
+ const totalErrors = filteredMetrics.workers.reduce((sum, w) => sum + w.errors, 0);
988
+
989
+ const response: EnhancedUsageResponse = {
990
+ success: true,
991
+ period,
992
+ project,
993
+ timestamp: new Date().toISOString(),
994
+ cached: false,
995
+ data: {
996
+ workers: filteredMetrics.workers,
997
+ d1: filteredMetrics.d1,
998
+ kv: filteredMetrics.kv,
999
+ r2: filteredMetrics.r2,
1000
+ durableObjects: filteredMetrics.durableObjects,
1001
+ vectorize: filteredMetrics.vectorize,
1002
+ aiGateway: filteredMetrics.aiGateway,
1003
+ pages: filteredMetrics.pages,
1004
+ summary: {
1005
+ ...calculateSummary(filteredMetrics),
1006
+ totalErrors,
1007
+ } as UsageResponse['data']['summary'] & { totalErrors: number },
1008
+ },
1009
+ sparklines: enhancedMetrics.sparklines,
1010
+ errorBreakdown: enhancedMetrics.errorBreakdown,
1011
+ queues: enhancedMetrics.queues,
1012
+ cache: enhancedMetrics.cache,
1013
+ comparison: enhancedMetrics.comparison,
1014
+ costs: {
1015
+ ...costs,
1016
+ formatted: {
1017
+ workers: formatCurrency(costs.workers),
1018
+ d1: formatCurrency(costs.d1),
1019
+ kv: formatCurrency(costs.kv),
1020
+ r2: formatCurrency(costs.r2),
1021
+ durableObjects: formatCurrency(costs.durableObjects),
1022
+ vectorize: formatCurrency(costs.vectorize),
1023
+ aiGateway: formatCurrency(costs.aiGateway),
1024
+ pages: formatCurrency(costs.pages),
1025
+ queues: formatCurrency(costs.queues),
1026
+ workflows: formatCurrency(costs.workflows),
1027
+ total: formatCurrency(costs.total),
1028
+ },
1029
+ },
1030
+ projectCosts,
1031
+ thresholds,
1032
+ };
1033
+
1034
+ try {
1035
+ await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 1800 });
1036
+ log.info('Enhanced cached response', { tag: 'USAGE', cacheKey });
1037
+ } catch (error) {
1038
+ log.error('Enhanced cache write error', error as Error, { tag: 'USAGE', cacheKey });
1039
+ }
1040
+
1041
+ const duration = Date.now() - startTime;
1042
+ log.info('Enhanced usage data fetched', { tag: 'USAGE', durationMs: duration });
1043
+
1044
+ return jsonResponse({
1045
+ ...response,
1046
+ responseTimeMs: duration,
1047
+ });
1048
+ } catch (error) {
1049
+ const errorMessage = error instanceof Error ? error.message : String(error);
1050
+ log.error('Enhanced error fetching usage data', error as Error, { tag: 'USAGE' });
1051
+
1052
+ return jsonResponse(
1053
+ {
1054
+ success: false,
1055
+ error: 'Failed to fetch enhanced usage data',
1056
+ message: errorMessage,
1057
+ },
1058
+ 500
1059
+ );
1060
+ }
1061
+ }
1062
+
1063
+ /**
1064
+ * Handle GET /usage/compare (task-17.3, 17.4)
1065
+ *
1066
+ * Query params:
1067
+ * - compare: 'lastMonth' | 'custom' (required)
1068
+ * - period: '24h' | '7d' | '30d' (for compare=lastMonth)
1069
+ * - startDate, endDate: YYYY-MM-DD (for compare=custom)
1070
+ * - priorStartDate, priorEndDate: YYYY-MM-DD (optional, for compare=custom)
1071
+ * - project: 'all' | <your-project-ids> (from project_registry)
1072
+ */
1073
+ export async function handleCompare(url: URL, env: Env): Promise<Response> {
1074
+ const startTime = Date.now();
1075
+ const compareParam = url.searchParams.get('compare') as CompareMode | null;
1076
+ const { period, project } = parseQueryParams(url);
1077
+
1078
+ // Validate compare mode
1079
+ if (!compareParam || (compareParam !== 'lastMonth' && compareParam !== 'custom')) {
1080
+ return jsonResponse(
1081
+ {
1082
+ success: false,
1083
+ error: 'Invalid compare mode',
1084
+ message: "compare parameter must be 'lastMonth' or 'custom'",
1085
+ },
1086
+ 400
1087
+ );
1088
+ }
1089
+
1090
+ if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) {
1091
+ return jsonResponse(
1092
+ {
1093
+ success: false,
1094
+ error: 'Configuration Error',
1095
+ message: 'Missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN',
1096
+ },
1097
+ 500
1098
+ );
1099
+ }
1100
+
1101
+ try {
1102
+ const client = new CloudflareGraphQL(env);
1103
+ let currentRange: DateRange;
1104
+ let priorRange: DateRange;
1105
+
1106
+ if (compareParam === 'lastMonth') {
1107
+ // Use period to determine date range, then get same period last month
1108
+ const now = new Date();
1109
+ const endDate = now.toISOString().split('T')[0]!;
1110
+
1111
+ const startDate = new Date(now);
1112
+ switch (period) {
1113
+ case '24h':
1114
+ startDate.setDate(startDate.getDate() - 1);
1115
+ break;
1116
+ case '7d':
1117
+ startDate.setDate(startDate.getDate() - 7);
1118
+ break;
1119
+ case '30d':
1120
+ startDate.setDate(startDate.getDate() - 30);
1121
+ break;
1122
+ }
1123
+
1124
+ currentRange = {
1125
+ startDate: startDate.toISOString().split('T')[0]!,
1126
+ endDate,
1127
+ };
1128
+
1129
+ priorRange = CloudflareGraphQL.getSamePeriodLastMonth(
1130
+ currentRange.startDate,
1131
+ currentRange.endDate
1132
+ );
1133
+ } else {
1134
+ // Custom date range
1135
+ const startDate = url.searchParams.get('startDate');
1136
+ const endDate = url.searchParams.get('endDate');
1137
+ const priorStartDate = url.searchParams.get('priorStartDate') ?? undefined;
1138
+ const priorEndDate = url.searchParams.get('priorEndDate') ?? undefined;
1139
+
1140
+ if (!startDate || !endDate) {
1141
+ return jsonResponse(
1142
+ {
1143
+ success: false,
1144
+ error: 'Missing date parameters',
1145
+ message: 'startDate and endDate are required for compare=custom',
1146
+ },
1147
+ 400
1148
+ );
1149
+ }
1150
+
1151
+ const validation = CloudflareGraphQL.validateCustomDateRange({
1152
+ startDate,
1153
+ endDate,
1154
+ priorStartDate,
1155
+ priorEndDate,
1156
+ });
1157
+
1158
+ if ('error' in validation) {
1159
+ return jsonResponse(
1160
+ {
1161
+ success: false,
1162
+ error: 'Invalid date range',
1163
+ message: validation.error,
1164
+ },
1165
+ 400
1166
+ );
1167
+ }
1168
+
1169
+ currentRange = validation.current;
1170
+ priorRange = validation.prior;
1171
+ }
1172
+
1173
+ // Fetch metrics for both periods in parallel
1174
+ const [currentMetricsRaw, priorMetricsRaw] = await Promise.all([
1175
+ client.getMetricsForDateRange(currentRange),
1176
+ client.getMetricsForDateRange(priorRange),
1177
+ ]);
1178
+
1179
+ // Filter by project
1180
+ const currentMetrics = filterByProject(currentMetricsRaw, project);
1181
+ const priorMetrics = filterByProject(priorMetricsRaw, project);
1182
+
1183
+ // Calculate costs and summaries
1184
+ const currentCosts = calculateMonthlyCosts(currentMetrics);
1185
+ const priorCosts = calculateMonthlyCosts(priorMetrics);
1186
+ const currentSummary = calculateSummary(currentMetrics);
1187
+ const priorSummary = calculateSummary(priorMetrics);
1188
+
1189
+ // Calculate comparisons
1190
+ const currentRequests = currentMetrics.workers.reduce((s, w) => s + w.requests, 0);
1191
+ const priorRequests = priorMetrics.workers.reduce((s, w) => s + w.requests, 0);
1192
+ const currentErrors = currentMetrics.workers.reduce((s, w) => s + w.errors, 0);
1193
+ const priorErrors = priorMetrics.workers.reduce((s, w) => s + w.errors, 0);
1194
+ const currentD1Rows = currentMetrics.d1.reduce((s, d) => s + d.rowsRead, 0);
1195
+ const priorD1Rows = priorMetrics.d1.reduce((s, d) => s + d.rowsRead, 0);
1196
+
1197
+ const response: ComparisonResponse = {
1198
+ success: true,
1199
+ compareMode: compareParam,
1200
+ current: {
1201
+ dateRange: currentRange,
1202
+ summary: currentSummary,
1203
+ costs: currentCosts,
1204
+ data: currentMetrics,
1205
+ },
1206
+ prior: {
1207
+ dateRange: priorRange,
1208
+ summary: priorSummary,
1209
+ costs: priorCosts,
1210
+ data: priorMetrics,
1211
+ },
1212
+ comparison: {
1213
+ workersRequests: {
1214
+ current: currentRequests,
1215
+ previous: priorRequests,
1216
+ ...calcTrend(currentRequests, priorRequests),
1217
+ },
1218
+ workersErrors: {
1219
+ current: currentErrors,
1220
+ previous: priorErrors,
1221
+ ...calcTrend(currentErrors, priorErrors),
1222
+ },
1223
+ d1RowsRead: {
1224
+ current: currentD1Rows,
1225
+ previous: priorD1Rows,
1226
+ ...calcTrend(currentD1Rows, priorD1Rows),
1227
+ },
1228
+ totalCost: {
1229
+ current: currentCosts.total,
1230
+ previous: priorCosts.total,
1231
+ ...calcTrend(currentCosts.total, priorCosts.total),
1232
+ },
1233
+ },
1234
+ timestamp: new Date().toISOString(),
1235
+ cached: false,
1236
+ };
1237
+
1238
+ const duration = Date.now() - startTime;
1239
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:compare');
1240
+ log.info('Comparison data fetched', { tag: 'COMPARE_FETCHED', durationMs: duration });
1241
+
1242
+ return jsonResponse({
1243
+ ...response,
1244
+ responseTimeMs: duration,
1245
+ });
1246
+ } catch (error) {
1247
+ const errorMessage = error instanceof Error ? error.message : String(error);
1248
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:compare');
1249
+ log.error('Error fetching comparison data', error instanceof Error ? error : undefined, {
1250
+ tag: 'COMPARE_ERROR',
1251
+ errorMessage,
1252
+ });
1253
+
1254
+ return jsonResponse(
1255
+ {
1256
+ success: false,
1257
+ error: 'Failed to fetch comparison data',
1258
+ message: errorMessage,
1259
+ },
1260
+ 500
1261
+ );
1262
+ }
1263
+ }
1264
+
1265
+ /**
1266
+ * Handle GET /usage/daily (task-18)
1267
+ *
1268
+ * Returns daily cost breakdown for interactive chart and table.
1269
+ * Supports period-based or custom date range queries.
1270
+ *
1271
+ * Query params:
1272
+ * - period: '24h' | '7d' | '30d' | 'custom' (default: '30d')
1273
+ * - startDate: YYYY-MM-DD (required for period=custom)
1274
+ * - endDate: YYYY-MM-DD (required for period=custom)
1275
+ */
1276
+ export async function handleDaily(url: URL, env: Env): Promise<Response> {
1277
+ const startTime = Date.now();
1278
+ const periodParam = url.searchParams.get('period') ?? '30d';
1279
+ const startDateParam = url.searchParams.get('startDate');
1280
+ const endDateParam = url.searchParams.get('endDate');
1281
+ const projectParam = url.searchParams.get('project') ?? 'all';
1282
+
1283
+ // Build cache key (include project in key for per-project caching)
1284
+ let cacheKeyPart: string;
1285
+ if (periodParam === 'custom' && startDateParam && endDateParam) {
1286
+ cacheKeyPart = `custom:${startDateParam}:${endDateParam}`;
1287
+ } else {
1288
+ const validPeriods: SharedTimePeriod[] = ['24h', '7d', '30d'];
1289
+ const period: SharedTimePeriod = validPeriods.includes(periodParam as SharedTimePeriod)
1290
+ ? (periodParam as SharedTimePeriod)
1291
+ : '30d';
1292
+ cacheKeyPart = period;
1293
+ }
1294
+
1295
+ const hourTimestamp = Math.floor(Date.now() / (60 * 60 * 1000));
1296
+ const cacheKey = `daily:${projectParam}:${cacheKeyPart}:${hourTimestamp}`;
1297
+
1298
+ // Check for cache bypass parameter
1299
+ const noCache = url.searchParams.get('nocache') === 'true';
1300
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:daily');
1301
+
1302
+ // Check cache first (unless nocache=true)
1303
+ if (!noCache) {
1304
+ try {
1305
+ const cached = (await env.PLATFORM_CACHE.get(cacheKey, 'json')) as DailyCostResponse | null;
1306
+ if (cached) {
1307
+ log.info('Daily cache hit', { tag: 'CACHE_HIT', cacheKey });
1308
+ return jsonResponse({
1309
+ ...cached,
1310
+ cached: true,
1311
+ responseTimeMs: Date.now() - startTime,
1312
+ });
1313
+ }
1314
+ } catch (error) {
1315
+ log.error('Daily cache read error', error instanceof Error ? error : undefined, {
1316
+ tag: 'CACHE_READ_ERROR',
1317
+ cacheKey,
1318
+ });
1319
+ }
1320
+ } else {
1321
+ log.info('Daily cache bypassed', { tag: 'CACHE_BYPASS', cacheKey });
1322
+ }
1323
+
1324
+ log.info('Daily cache miss, fetching fresh data', { tag: 'CACHE_MISS', cacheKey });
1325
+
1326
+ // Parse period for D1 query
1327
+ let d1Period: SharedTimePeriod | { start: string; end: string };
1328
+ let periodDisplay: string;
1329
+
1330
+ if (periodParam === 'custom' && startDateParam && endDateParam) {
1331
+ // Validate date range (max 90 days)
1332
+ const startDate = new Date(startDateParam);
1333
+ const endDate = new Date(endDateParam);
1334
+ const diffDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
1335
+
1336
+ if (diffDays > 90) {
1337
+ return jsonResponse(
1338
+ {
1339
+ success: false,
1340
+ error: 'Invalid date range',
1341
+ message: 'Date range cannot exceed 90 days',
1342
+ },
1343
+ 400
1344
+ );
1345
+ }
1346
+
1347
+ if (diffDays < 1) {
1348
+ return jsonResponse(
1349
+ {
1350
+ success: false,
1351
+ error: 'Invalid date range',
1352
+ message: 'End date must be after start date',
1353
+ },
1354
+ 400
1355
+ );
1356
+ }
1357
+
1358
+ d1Period = { start: startDateParam, end: endDateParam };
1359
+ periodDisplay = `${startDateParam} to ${endDateParam}`;
1360
+ } else {
1361
+ // Standard period-based query
1362
+ const validPeriods: SharedTimePeriod[] = ['24h', '7d', '30d'];
1363
+ d1Period = validPeriods.includes(periodParam as SharedTimePeriod)
1364
+ ? (periodParam as SharedTimePeriod)
1365
+ : '30d';
1366
+ periodDisplay = periodParam;
1367
+ }
1368
+
1369
+ try {
1370
+ // Try D1 Data Warehouse first (pass project filter)
1371
+ const d1Data = await queryD1DailyCosts(env, d1Period, projectParam);
1372
+
1373
+ if (d1Data && d1Data.days.length > 0) {
1374
+ log.info('Daily data from D1', {
1375
+ tag: 'D1_DATA',
1376
+ dayCount: d1Data.days.length,
1377
+ project: projectParam,
1378
+ });
1379
+
1380
+ const response: DailyCostResponse & { dataSource: string; project: string } = {
1381
+ success: true,
1382
+ period: periodDisplay,
1383
+ project: projectParam,
1384
+ dataSource: 'd1',
1385
+ data: d1Data,
1386
+ cached: false,
1387
+ timestamp: new Date().toISOString(),
1388
+ };
1389
+
1390
+ // Cache for 1 hour
1391
+ try {
1392
+ await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 1800 });
1393
+ log.info('Daily cached D1 response', { tag: 'CACHE_WRITE', cacheKey });
1394
+ } catch (error) {
1395
+ log.error('Daily cache write error', error instanceof Error ? error : undefined, {
1396
+ tag: 'CACHE_WRITE_ERROR',
1397
+ cacheKey,
1398
+ });
1399
+ }
1400
+
1401
+ const duration = Date.now() - startTime;
1402
+ log.info('Daily data from D1', { tag: 'D1_COMPLETE', durationMs: duration });
1403
+
1404
+ return jsonResponse({
1405
+ ...response,
1406
+ responseTimeMs: duration,
1407
+ });
1408
+ }
1409
+
1410
+ // D1 is empty - return no_data response (GraphQL fallback disabled)
1411
+ log.info('D1 empty - returning no_data response (GraphQL fallback disabled)', {
1412
+ tag: 'D1_EMPTY',
1413
+ });
1414
+
1415
+ const emptyTotals: DailyCostData['totals'] = {
1416
+ workers: 0,
1417
+ d1: 0,
1418
+ kv: 0,
1419
+ r2: 0,
1420
+ durableObjects: 0,
1421
+ vectorize: 0,
1422
+ aiGateway: 0,
1423
+ workersAI: 0,
1424
+ pages: 0,
1425
+ queues: 0,
1426
+ workflows: 0,
1427
+ total: 0,
1428
+ };
1429
+
1430
+ const emptyData: DailyCostData = {
1431
+ days: [],
1432
+ totals: emptyTotals,
1433
+ period: {
1434
+ start: typeof d1Period === 'object' ? d1Period.start : '',
1435
+ end: typeof d1Period === 'object' ? d1Period.end : '',
1436
+ },
1437
+ };
1438
+
1439
+ const response: DailyCostResponse & {
1440
+ dataSource: string;
1441
+ dataAvailability: string;
1442
+ message: string;
1443
+ } = {
1444
+ success: true,
1445
+ period: periodDisplay,
1446
+ dataSource: 'none',
1447
+ dataAvailability: 'no_data',
1448
+ message:
1449
+ 'Daily rollups not yet available. Data collection started recently and will populate after midnight UTC.',
1450
+ data: emptyData,
1451
+ cached: false,
1452
+ timestamp: new Date().toISOString(),
1453
+ };
1454
+
1455
+ const duration = Date.now() - startTime;
1456
+ log.info('Returning empty daily data', { tag: 'EMPTY_RESPONSE', durationMs: duration });
1457
+
1458
+ return jsonResponse({
1459
+ ...response,
1460
+ responseTimeMs: duration,
1461
+ });
1462
+ } catch (error) {
1463
+ const errorMessage = error instanceof Error ? error.message : String(error);
1464
+ log.error('Error fetching daily data', error instanceof Error ? error : undefined, {
1465
+ tag: 'DAILY_ERROR',
1466
+ errorMessage,
1467
+ });
1468
+
1469
+ return jsonResponse(
1470
+ {
1471
+ success: false,
1472
+ error: 'Failed to fetch daily cost data',
1473
+ message: errorMessage,
1474
+ },
1475
+ 500
1476
+ );
1477
+ }
1478
+ }
1479
+
1480
+ /**
1481
+ * Handle GET /usage/status
1482
+ *
1483
+ * Returns project status data for the unified dashboard including:
1484
+ * - Circuit breaker state per project
1485
+ * - MTD spend and cap
1486
+ * - Usage percentage
1487
+ * - Operational status (RUN/WARN/STOP)
1488
+ *
1489
+ * Supports ?period parameter for different time ranges.
1490
+ */
1491
+ export async function handleStatus(url: URL, env: Env): Promise<Response> {
1492
+ const startTime = Date.now();
1493
+
1494
+ try {
1495
+ // Get period param (default 30d for MTD)
1496
+ const period = url.searchParams.get('period') || '30d';
1497
+
1498
+ // Get projects from D1 registry
1499
+ const allProjects = await getProjects(env.PLATFORM_DB);
1500
+ const projectsWithConfig = allProjects
1501
+ .map((p: Project) => ({ project: p, config: getProjectConfig(p) }))
1502
+ .filter(
1503
+ (p: {
1504
+ project: Project;
1505
+ config: ReturnType<typeof getProjectConfig>;
1506
+ }): p is { project: Project; config: NonNullable<ReturnType<typeof getProjectConfig>> } =>
1507
+ p.config !== null
1508
+ );
1509
+
1510
+ // Get date range based on period
1511
+ const now = new Date();
1512
+ const currentYear = now.getUTCFullYear();
1513
+ const currentMonth = now.getUTCMonth();
1514
+ const mtdStartDate = new Date(Date.UTC(currentYear, currentMonth, 1))
1515
+ .toISOString()
1516
+ .slice(0, 10);
1517
+ const mtdEndDate = now.toISOString().slice(0, 10);
1518
+
1519
+ // Query per-project MTD costs
1520
+ const projectIds = projectsWithConfig.map(
1521
+ (p: { project: Project; config: NonNullable<ReturnType<typeof getProjectConfig>> }) =>
1522
+ p.project.projectId
1523
+ );
1524
+ const projectCostPromises = projectIds.map(async (projectId: string) => {
1525
+ const projectData = await queryD1DailyCosts(
1526
+ env,
1527
+ { start: mtdStartDate, end: mtdEndDate },
1528
+ projectId
1529
+ );
1530
+ return { projectId, mtdCost: projectData?.totals?.total ?? 0 };
1531
+ });
1532
+ const projectCostResults = await Promise.all(projectCostPromises);
1533
+ const perProjectCosts: Record<string, number> = {};
1534
+ for (const result of projectCostResults) {
1535
+ perProjectCosts[result.projectId] = result.mtdCost;
1536
+ }
1537
+
1538
+ // Get circuit breaker status from KV for all registered projects
1539
+ // TODO: Add your project IDs to project_registry in D1
1540
+ const cbStatuses: Record<string, string> = {};
1541
+ {
1542
+ const projectRows = await env.PLATFORM_DB.prepare(
1543
+ `SELECT project_id FROM project_registry WHERE project_id != 'all'`
1544
+ ).all<{ project_id: string }>();
1545
+ const projectIds = projectRows.results?.map((r) => r.project_id) ?? ['platform'];
1546
+ const cbResults = await Promise.all(
1547
+ projectIds.map(async (pid) => {
1548
+ const cbKey = `PROJECT:${pid.toUpperCase().replace(/-/g, '-')}:STATUS`;
1549
+ const status = await env.PLATFORM_CACHE.get(cbKey);
1550
+ return { pid, status };
1551
+ })
1552
+ );
1553
+ for (const { pid, status } of cbResults) {
1554
+ cbStatuses[pid] = status ?? 'active';
1555
+ }
1556
+ }
1557
+
1558
+ // Query feature_registry to find which projects have CB-enabled features
1559
+ const projectsWithCBEnabled = new Set<string>();
1560
+ try {
1561
+ const cbEnabledResult = await env.PLATFORM_DB.prepare(
1562
+ `
1563
+ SELECT DISTINCT project_id
1564
+ FROM feature_registry
1565
+ WHERE circuit_breaker_enabled = 1
1566
+ `
1567
+ ).all();
1568
+ for (const row of cbEnabledResult.results ?? []) {
1569
+ projectsWithCBEnabled.add(row.project_id as string);
1570
+ }
1571
+ } catch {
1572
+ // Fallback: add all known projects from cbStatuses
1573
+ for (const pid of Object.keys(cbStatuses)) {
1574
+ projectsWithCBEnabled.add(pid);
1575
+ }
1576
+ }
1577
+
1578
+ // Build project status map
1579
+ const projects: Record<
1580
+ string,
1581
+ {
1582
+ status: 'RUN' | 'WARN' | 'STOP';
1583
+ spend: number;
1584
+ cap: number;
1585
+ percentage: number;
1586
+ circuitBreaker: 'active' | 'tripped' | 'disabled';
1587
+ lastSeen?: string;
1588
+ }
1589
+ > = {};
1590
+
1591
+ // Get budget thresholds
1592
+ const { softBudgetLimit, warningThreshold } = await getBudgetThresholds(env);
1593
+
1594
+ for (const { project, config } of projectsWithConfig) {
1595
+ const projectId = project.projectId;
1596
+ const spend = perProjectCosts[projectId] ?? 0;
1597
+
1598
+ // Use project-specific cap if available, else account-level soft limit
1599
+ const cap = config.customLimit ?? softBudgetLimit;
1600
+ const percentage = cap > 0 ? (spend / cap) * 100 : 0;
1601
+
1602
+ // Get circuit breaker state
1603
+ const cbKvState = cbStatuses[projectId] ?? 'active';
1604
+ const hasCBEnabled = projectsWithCBEnabled.has(projectId);
1605
+
1606
+ let circuitBreaker: 'active' | 'tripped' | 'disabled' = 'disabled';
1607
+ if (hasCBEnabled) {
1608
+ // Map KV status values to CB state
1609
+ if (cbKvState === 'paused') {
1610
+ circuitBreaker = 'tripped';
1611
+ } else if (cbKvState === 'warning') {
1612
+ circuitBreaker = 'active'; // Warning is still operational
1613
+ } else {
1614
+ circuitBreaker = 'active';
1615
+ }
1616
+ }
1617
+
1618
+ // Determine operational status
1619
+ let status: 'RUN' | 'WARN' | 'STOP' = 'RUN';
1620
+ if (circuitBreaker === 'tripped') {
1621
+ status = 'STOP';
1622
+ } else if (percentage > 100) {
1623
+ status = 'STOP';
1624
+ } else if (percentage > 80 || cbKvState === 'warning') {
1625
+ status = 'WARN';
1626
+ }
1627
+
1628
+ projects[projectId] = {
1629
+ status,
1630
+ spend,
1631
+ cap,
1632
+ percentage: Math.round(percentage * 10) / 10,
1633
+ circuitBreaker,
1634
+ };
1635
+ }
1636
+
1637
+ return jsonResponse({
1638
+ success: true,
1639
+ period,
1640
+ projects,
1641
+ timestamp: new Date().toISOString(),
1642
+ responseTimeMs: Date.now() - startTime,
1643
+ });
1644
+ } catch (error) {
1645
+ const errorMessage = error instanceof Error ? error.message : String(error);
1646
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:status');
1647
+ log.error('Error fetching status', error instanceof Error ? error : undefined, {
1648
+ errorMessage,
1649
+ });
1650
+
1651
+ return jsonResponse(
1652
+ {
1653
+ success: false,
1654
+ error: 'Failed to fetch status',
1655
+ message: errorMessage,
1656
+ },
1657
+ 500
1658
+ );
1659
+ }
1660
+ }
1661
+
1662
+ /**
1663
+ * Handle GET /usage/projects
1664
+ *
1665
+ * Returns the list of projects from the D1 registry with resource counts,
1666
+ * service allowances, and projected monthly cost based on MTD burn rate.
1667
+ * Used by the dashboard to populate project selectors and show overview.
1668
+ */
1669
+ export async function handleProjects(env: Env): Promise<Response> {
1670
+ const startTime = Date.now();
1671
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:projects');
1672
+
1673
+ // Cache key (hourly refresh)
1674
+ const hourTimestamp = Math.floor(Date.now() / (60 * 60 * 1000));
1675
+ const cacheKey = `projects:list:v2:${hourTimestamp}`;
1676
+
1677
+ // Check cache first
1678
+ try {
1679
+ const cached = (await env.PLATFORM_CACHE.get(cacheKey, 'json')) as ProjectListResponse | null;
1680
+ if (cached) {
1681
+ log.info('Projects cache hit', { tag: 'CACHE_HIT', cacheKey });
1682
+ return jsonResponse({
1683
+ ...cached,
1684
+ cached: true,
1685
+ responseTimeMs: Date.now() - startTime,
1686
+ });
1687
+ }
1688
+ } catch (error) {
1689
+ log.error('Projects cache read error', error instanceof Error ? error : undefined, {
1690
+ tag: 'CACHE_READ_ERROR',
1691
+ cacheKey,
1692
+ });
1693
+ }
1694
+
1695
+ try {
1696
+ // Get all projects from registry
1697
+ const projects = await getProjects(env.PLATFORM_DB);
1698
+
1699
+ // Get resource counts per project
1700
+ const resourceCounts = new Map<string, number>();
1701
+ const countResult = await env.PLATFORM_DB.prepare(
1702
+ `
1703
+ SELECT project_id, COUNT(*) as count
1704
+ FROM resource_project_mapping
1705
+ GROUP BY project_id
1706
+ `
1707
+ ).all<{ project_id: string; count: number }>();
1708
+
1709
+ for (const row of countResult.results ?? []) {
1710
+ resourceCounts.set(row.project_id, row.count);
1711
+ }
1712
+
1713
+ // Merge counts with projects
1714
+ const projectsWithCounts = projects.map((p: Project) => ({
1715
+ ...p,
1716
+ resourceCount: resourceCounts.get(p.projectId) ?? 0,
1717
+ }));
1718
+
1719
+ // Sort by resource count (most resources first)
1720
+ projectsWithCounts.sort(
1721
+ (a: Project & { resourceCount: number }, b: Project & { resourceCount: number }) =>
1722
+ b.resourceCount - a.resourceCount
1723
+ );
1724
+
1725
+ const totalResources = projectsWithCounts.reduce(
1726
+ (sum: number, p: Project & { resourceCount: number }) => sum + p.resourceCount,
1727
+ 0
1728
+ );
1729
+
1730
+ // Calculate projected cost from MTD daily_usage_rollups
1731
+ const now = new Date();
1732
+ const currentMonth = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
1733
+ const daysPassed = now.getUTCDate();
1734
+ const daysInMonth = new Date(now.getUTCFullYear(), now.getUTCMonth() + 1, 0).getDate();
1735
+
1736
+ // Get MTD cost from daily rollups (sum across individual projects, not 'all' row)
1737
+ // This ensures we get complete data even if the 'all' rollup row is missing for some dates
1738
+ const mtdResult = await env.PLATFORM_DB.prepare(
1739
+ `
1740
+ SELECT SUM(total_cost_usd) as total_cost
1741
+ FROM daily_usage_rollups
1742
+ WHERE snapshot_date LIKE ? || '%'
1743
+ AND project NOT IN ('all', '_unattributed')
1744
+ `
1745
+ )
1746
+ .bind(currentMonth)
1747
+ .first<{ total_cost: number | null }>();
1748
+
1749
+ const currentCost = mtdResult?.total_cost ?? 0;
1750
+ const projectedMonthlyCost = daysPassed > 0 ? (currentCost / daysPassed) * daysInMonth : 0;
1751
+
1752
+ // Build allowances object from CF_SIMPLE_ALLOWANCES
1753
+ const allowances = {
1754
+ workers: { limit: CF_ALLOWANCES.workers.limit, unit: CF_ALLOWANCES.workers.unit },
1755
+ d1_writes: { limit: CF_ALLOWANCES.d1.limit, unit: CF_ALLOWANCES.d1.unit },
1756
+ kv_writes: { limit: CF_ALLOWANCES.kv.limit, unit: CF_ALLOWANCES.kv.unit },
1757
+ r2_storage: { limit: CF_ALLOWANCES.r2.limit, unit: CF_ALLOWANCES.r2.unit },
1758
+ durableObjects: {
1759
+ limit: CF_ALLOWANCES.durableObjects.limit,
1760
+ unit: CF_ALLOWANCES.durableObjects.unit,
1761
+ },
1762
+ vectorize: { limit: CF_ALLOWANCES.vectorize.limit, unit: CF_ALLOWANCES.vectorize.unit },
1763
+ // GitHub Enterprise allowance (50K minutes/month)
1764
+ github_actions_minutes: { limit: 50000, unit: 'minutes' },
1765
+ };
1766
+
1767
+ const projectedCost: ProjectedCost = {
1768
+ currentCost,
1769
+ daysPassed,
1770
+ daysInMonth,
1771
+ projectedMonthlyCost,
1772
+ };
1773
+
1774
+ const response: ProjectListResponse = {
1775
+ success: true,
1776
+ projects: projectsWithCounts,
1777
+ totalResources,
1778
+ timestamp: new Date().toISOString(),
1779
+ cached: false,
1780
+ allowances,
1781
+ projectedCost,
1782
+ };
1783
+
1784
+ // Cache for 1 hour
1785
+ try {
1786
+ await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 3600 });
1787
+ log.info('Projects cached', { tag: 'CACHE_WRITE', cacheKey });
1788
+ } catch (error) {
1789
+ log.error('Projects cache write error', error instanceof Error ? error : undefined, {
1790
+ tag: 'CACHE_WRITE_ERROR',
1791
+ cacheKey,
1792
+ });
1793
+ }
1794
+
1795
+ log.info('Projects fetched', {
1796
+ tag: 'PROJECTS_FETCHED',
1797
+ durationMs: Date.now() - startTime,
1798
+ projectCount: projects.length,
1799
+ resourceCount: totalResources,
1800
+ projectedMonthlyCost: projectedMonthlyCost.toFixed(2),
1801
+ });
1802
+
1803
+ return jsonResponse({
1804
+ ...response,
1805
+ responseTimeMs: Date.now() - startTime,
1806
+ });
1807
+ } catch (error) {
1808
+ const errorMessage = error instanceof Error ? error.message : String(error);
1809
+ log.error('Error fetching projects', error instanceof Error ? error : undefined, {
1810
+ tag: 'PROJECTS_ERROR',
1811
+ errorMessage,
1812
+ });
1813
+
1814
+ return jsonResponse(
1815
+ {
1816
+ success: false,
1817
+ error: 'Failed to fetch projects',
1818
+ message: errorMessage,
1819
+ },
1820
+ 500
1821
+ );
1822
+ }
1823
+ }
1824
+
1825
+ /**
1826
+ * Handle GET /usage/anomalies
1827
+ *
1828
+ * Returns detected usage anomalies from the D1 warehouse.
1829
+ * Supports filtering by days lookback and resolved status.
1830
+ *
1831
+ * Query params:
1832
+ * - days: Number of days to look back (default: 7, max: 30)
1833
+ * - resolved: 'all' | 'true' | 'false' (default: 'all')
1834
+ * - limit: Max results (default: 50, max: 100)
1835
+ */
1836
+ export async function handleAnomalies(url: URL, env: Env): Promise<Response> {
1837
+ const startTime = Date.now();
1838
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:anomalies');
1839
+
1840
+ // Parse query params
1841
+ const daysParam = url.searchParams.get('days');
1842
+ const resolvedParam = url.searchParams.get('resolved') ?? 'all';
1843
+ const limitParam = url.searchParams.get('limit');
1844
+
1845
+ const days = Math.min(Math.max(parseInt(daysParam ?? '7', 10) || 7, 1), 30);
1846
+ const limit = Math.min(Math.max(parseInt(limitParam ?? '50', 10) || 50, 1), 100);
1847
+
1848
+ // Calculate lookback timestamp (days ago)
1849
+ const lookbackMs = days * 24 * 60 * 60 * 1000;
1850
+ const sinceTimestamp = Math.floor((Date.now() - lookbackMs) / 1000);
1851
+
1852
+ // Cache key (15-min TTL to balance freshness with cost)
1853
+ const cacheTimestamp = Math.floor(Date.now() / (15 * 60 * 1000));
1854
+ const cacheKey = `anomalies:${days}:${resolvedParam}:${limit}:${cacheTimestamp}`;
1855
+
1856
+ // Check cache first
1857
+ try {
1858
+ const cached = (await env.PLATFORM_CACHE.get(cacheKey, 'json')) as AnomaliesResponse | null;
1859
+ if (cached) {
1860
+ log.info('Anomalies cache hit', { tag: 'CACHE_HIT', cacheKey });
1861
+ return jsonResponse({
1862
+ ...cached,
1863
+ cached: true,
1864
+ responseTimeMs: Date.now() - startTime,
1865
+ });
1866
+ }
1867
+ } catch (error) {
1868
+ log.error('Anomalies cache read error', error instanceof Error ? error : undefined, {
1869
+ tag: 'CACHE_READ_ERROR',
1870
+ cacheKey,
1871
+ });
1872
+ }
1873
+
1874
+ try {
1875
+ // Build query based on resolved filter
1876
+ let whereClause = 'WHERE detected_at >= ?';
1877
+ const params: (string | number)[] = [sinceTimestamp];
1878
+
1879
+ if (resolvedParam === 'true') {
1880
+ whereClause += ' AND resolved = 1';
1881
+ } else if (resolvedParam === 'false') {
1882
+ whereClause += ' AND resolved = 0';
1883
+ }
1884
+ // 'all' doesn't add a filter
1885
+
1886
+ const query = `
1887
+ SELECT id, detected_at, metric_name, project,
1888
+ current_value, rolling_avg, rolling_stddev, deviation_factor,
1889
+ alert_sent, alert_channel, resolved, resolved_at, resolved_by
1890
+ FROM usage_anomalies
1891
+ ${whereClause}
1892
+ ORDER BY detected_at DESC
1893
+ LIMIT ?
1894
+ `;
1895
+ params.push(limit);
1896
+
1897
+ const result = await env.PLATFORM_DB.prepare(query)
1898
+ .bind(...params)
1899
+ .all<AnomalyRecord>();
1900
+
1901
+ // Count total anomalies (for pagination info)
1902
+ const countQuery = `SELECT COUNT(*) as count FROM usage_anomalies ${whereClause}`;
1903
+ const countResult = await env.PLATFORM_DB.prepare(countQuery)
1904
+ .bind(...params.slice(0, -1)) // Exclude LIMIT param
1905
+ .first<{ count: number }>();
1906
+
1907
+ const anomalies = (result.results ?? []).map((row) => ({
1908
+ id: row.id,
1909
+ detectedAt: new Date(row.detected_at * 1000).toISOString(),
1910
+ metric: row.metric_name,
1911
+ project: row.project,
1912
+ currentValue: row.current_value,
1913
+ rollingAvg: row.rolling_avg,
1914
+ deviationFactor: Math.round(row.deviation_factor * 10) / 10,
1915
+ alertSent: row.alert_sent === 1,
1916
+ alertChannel: row.alert_channel,
1917
+ resolved: row.resolved === 1,
1918
+ resolvedAt: row.resolved_at ? new Date(row.resolved_at * 1000).toISOString() : null,
1919
+ resolvedBy: row.resolved_by,
1920
+ }));
1921
+
1922
+ const response: AnomaliesResponse = {
1923
+ success: true,
1924
+ anomalies,
1925
+ total: countResult?.count ?? anomalies.length,
1926
+ timestamp: new Date().toISOString(),
1927
+ cached: false,
1928
+ };
1929
+
1930
+ // Cache for 15 minutes
1931
+ try {
1932
+ await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), {
1933
+ expirationTtl: 15 * 60,
1934
+ });
1935
+ } catch (error) {
1936
+ log.error('Anomalies cache write error', error instanceof Error ? error : undefined, {
1937
+ tag: 'CACHE_WRITE_ERROR',
1938
+ cacheKey,
1939
+ });
1940
+ }
1941
+
1942
+ log.info('Anomalies fetched', {
1943
+ tag: 'ANOMALIES_FETCHED',
1944
+ durationMs: Date.now() - startTime,
1945
+ anomalyCount: anomalies.length,
1946
+ totalCount: countResult?.count ?? 0,
1947
+ });
1948
+
1949
+ return jsonResponse({
1950
+ ...response,
1951
+ responseTimeMs: Date.now() - startTime,
1952
+ });
1953
+ } catch (error) {
1954
+ const errorMessage = error instanceof Error ? error.message : String(error);
1955
+ log.error('Error fetching anomalies', error instanceof Error ? error : undefined, {
1956
+ tag: 'ANOMALIES_ERROR',
1957
+ errorMessage,
1958
+ });
1959
+
1960
+ // Return empty array on error (table may not exist yet)
1961
+ return jsonResponse({
1962
+ success: true,
1963
+ anomalies: [],
1964
+ total: 0,
1965
+ timestamp: new Date().toISOString(),
1966
+ cached: false,
1967
+ error: errorMessage,
1968
+ responseTimeMs: Date.now() - startTime,
1969
+ });
1970
+ }
1971
+ }
1972
+
1973
+ /**
1974
+ * Handle GET /usage/utilization (task-26)
1975
+ *
1976
+ * Returns burn rate data and per-project utilization for the dashboard.
1977
+ * Includes MTD spend, projected monthly total, and project-level metrics.
1978
+ */
1979
+ export async function handleUtilization(url: URL, env: Env): Promise<Response> {
1980
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:utilization');
1981
+ const startTime = Date.now();
1982
+
1983
+ // Cache key (hourly)
1984
+ const hourTimestamp = Math.floor(Date.now() / (60 * 60 * 1000));
1985
+ const cacheKey = `utilization:${hourTimestamp}`;
1986
+
1987
+ // Check cache bypass
1988
+ const noCache = url.searchParams.get('nocache') === 'true';
1989
+
1990
+ if (!noCache) {
1991
+ try {
1992
+ const cached = (await env.PLATFORM_CACHE.get(cacheKey, 'json')) as BurnRateResponse | null;
1993
+ if (cached) {
1994
+ log.info(`Utilization cache hit for ${cacheKey}`, { tag: 'USAGE' });
1995
+ return jsonResponse({
1996
+ ...cached,
1997
+ cached: true,
1998
+ responseTimeMs: Date.now() - startTime,
1999
+ });
2000
+ }
2001
+ } catch (error) {
2002
+ log.error(
2003
+ 'Utilization cache read error',
2004
+ error instanceof Error ? error : new Error(String(error))
2005
+ );
2006
+ }
2007
+ }
2008
+
2009
+ try {
2010
+ const now = new Date();
2011
+ const currentYear = now.getUTCFullYear();
2012
+ const currentMonth = now.getUTCMonth();
2013
+ const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
2014
+ const dayOfMonth = now.getUTCDate();
2015
+ const daysRemaining = daysInMonth - dayOfMonth;
2016
+
2017
+ // Get MTD dates
2018
+ const mtdStartDate = new Date(Date.UTC(currentYear, currentMonth, 1))
2019
+ .toISOString()
2020
+ .slice(0, 10);
2021
+ const mtdEndDate = now.toISOString().slice(0, 10);
2022
+
2023
+ // Billing period (first to last of month)
2024
+ const billingStart = new Date(Date.UTC(currentYear, currentMonth, 1))
2025
+ .toISOString()
2026
+ .slice(0, 10);
2027
+ const billingEnd = new Date(Date.UTC(currentYear, currentMonth + 1, 0))
2028
+ .toISOString()
2029
+ .slice(0, 10);
2030
+
2031
+ // Query D1 for MTD costs (using 30d period or custom range)
2032
+ const d1Data = await queryD1DailyCosts(env, { start: mtdStartDate, end: mtdEndDate }, 'all');
2033
+
2034
+ // Get projects from D1 registry
2035
+ const allProjects = await getProjects(env.PLATFORM_DB);
2036
+ // Filter to only projects with usage config (from D1 or fallback)
2037
+ const projectsWithConfig = allProjects
2038
+ .map((p: Project) => ({ project: p, config: getProjectConfig(p) }))
2039
+ .filter(
2040
+ (p: {
2041
+ project: Project;
2042
+ config: ReturnType<typeof getProjectConfig>;
2043
+ }): p is { project: Project; config: NonNullable<ReturnType<typeof getProjectConfig>> } =>
2044
+ p.config !== null
2045
+ );
2046
+
2047
+ // Query per-project MTD costs
2048
+ const perProjectCosts: Record<string, { mtdCost: number; sparkline: number[] }> = {};
2049
+ const projectIds = projectsWithConfig.map(
2050
+ (p: { project: Project; config: NonNullable<ReturnType<typeof getProjectConfig>> }) =>
2051
+ p.project.projectId
2052
+ );
2053
+
2054
+ // Batch query per-project costs in parallel
2055
+ const projectCostPromises = projectIds.map(async (projectId: string) => {
2056
+ const projectData = await queryD1DailyCosts(
2057
+ env,
2058
+ { start: mtdStartDate, end: mtdEndDate },
2059
+ projectId
2060
+ );
2061
+ const projectMtdCost = projectData?.totals?.total ?? 0;
2062
+ const projectSparkline = projectData?.days?.map((d) => d.total) ?? [];
2063
+ return { projectId, mtdCost: projectMtdCost, sparkline: projectSparkline };
2064
+ });
2065
+ const projectCostResults = await Promise.all(projectCostPromises);
2066
+ for (const result of projectCostResults) {
2067
+ perProjectCosts[result.projectId] = { mtdCost: result.mtdCost, sparkline: result.sparkline };
2068
+ }
2069
+
2070
+ // Query last month's per-project MTD costs for individual delta calculations
2071
+ const lastMonth = new Date(Date.UTC(currentYear, currentMonth - 1, 1));
2072
+ const lastMonthStart = lastMonth.toISOString().slice(0, 10);
2073
+ const lastMonthEnd = new Date(
2074
+ Date.UTC(lastMonth.getFullYear(), lastMonth.getMonth(), dayOfMonth)
2075
+ )
2076
+ .toISOString()
2077
+ .slice(0, 10);
2078
+
2079
+ const perProjectLastMonthCosts: Record<string, number> = {};
2080
+ const lastMonthProjectPromises = projectIds.map(async (projectId: string) => {
2081
+ try {
2082
+ const lastMonthProjectData = await queryD1DailyCosts(
2083
+ env,
2084
+ { start: lastMonthStart, end: lastMonthEnd },
2085
+ projectId
2086
+ );
2087
+ return { projectId, lastMonthCost: lastMonthProjectData?.totals?.total ?? 0 };
2088
+ } catch {
2089
+ return { projectId, lastMonthCost: 0 };
2090
+ }
2091
+ });
2092
+ const lastMonthProjectResults = await Promise.all(lastMonthProjectPromises);
2093
+ for (const result of lastMonthProjectResults) {
2094
+ perProjectLastMonthCosts[result.projectId] = result.lastMonthCost;
2095
+ }
2096
+
2097
+ // Calculate total MTD cost (use 'all' query which aggregates correctly)
2098
+ const mtdCost = d1Data?.totals?.total ?? 0;
2099
+ const dailyBurnRate = dayOfMonth > 0 ? mtdCost / dayOfMonth : 0;
2100
+ const projectedMonthlyCost = dailyBurnRate * daysInMonth;
2101
+
2102
+ // Confidence based on days of data
2103
+ let confidence: 'low' | 'medium' | 'high' = 'low';
2104
+ if (dayOfMonth >= 15) confidence = 'high';
2105
+ else if (dayOfMonth >= 7) confidence = 'medium';
2106
+
2107
+ // Get last month's MTD cost for account-level comparison (uses dates defined above)
2108
+ let vsLastMonthPct: number | null = null;
2109
+ try {
2110
+ const lastMonthData = await queryD1DailyCosts(env, {
2111
+ start: lastMonthStart,
2112
+ end: lastMonthEnd,
2113
+ });
2114
+ if (lastMonthData?.totals?.total && lastMonthData.totals.total > 0) {
2115
+ vsLastMonthPct =
2116
+ ((mtdCost - lastMonthData.totals.total) / lastMonthData.totals.total) * 100;
2117
+ }
2118
+ } catch (error) {
2119
+ log.warn('Could not fetch last month data for comparison', undefined, {
2120
+ tag: 'USAGE',
2121
+ error: String(error),
2122
+ });
2123
+ }
2124
+
2125
+ // Get budget thresholds from D1 (with fallback defaults)
2126
+ const { softBudgetLimit, warningThreshold } = await getBudgetThresholds(env);
2127
+
2128
+ // Determine overall status
2129
+ let status: 'green' | 'yellow' | 'red' = 'green';
2130
+ let statusLabel = 'On Track';
2131
+ let statusDetail = 'Under budget';
2132
+
2133
+ if (projectedMonthlyCost > softBudgetLimit) {
2134
+ const overageAmount = projectedMonthlyCost - softBudgetLimit;
2135
+ status = 'red';
2136
+ statusLabel = 'Over Budget';
2137
+ statusDetail = `Projected $${overageAmount.toFixed(2)} over $${softBudgetLimit} limit`;
2138
+ } else if (projectedMonthlyCost > warningThreshold) {
2139
+ status = 'yellow';
2140
+ statusLabel = 'Elevated';
2141
+ statusDetail = `$${(softBudgetLimit - projectedMonthlyCost).toFixed(2)} headroom to $${softBudgetLimit} limit`;
2142
+ }
2143
+
2144
+ // Get project-level data
2145
+ const projectData: ProjectUtilizationData[] = [];
2146
+
2147
+ // Query for 7-day sparkline data per project (already fetched above in perProjectCosts)
2148
+ const sparklineStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
2149
+ .toISOString()
2150
+ .slice(0, 10);
2151
+
2152
+ // Fetch 7-day sparkline data per project in parallel
2153
+ const sparklinePromises = projectIds.map(async (projectId: string) => {
2154
+ const sparkData = await queryD1DailyCosts(
2155
+ env,
2156
+ { start: sparklineStart, end: mtdEndDate },
2157
+ projectId
2158
+ );
2159
+ return { projectId, sparkline: sparkData?.days?.map((d) => d.total) ?? [] };
2160
+ });
2161
+ const sparklineResults = await Promise.all(sparklinePromises);
2162
+ const projectSparklines: Record<string, number[]> = {};
2163
+ for (const result of sparklineResults) {
2164
+ projectSparklines[result.projectId] = result.sparkline;
2165
+ }
2166
+
2167
+ // Get circuit breaker status from KV for all registered projects
2168
+ const cbStatuses: Record<string, string> = {};
2169
+ {
2170
+ const projectRows2 = await env.PLATFORM_DB.prepare(
2171
+ `SELECT project_id FROM project_registry WHERE project_id != 'all'`
2172
+ ).all<{ project_id: string }>();
2173
+ const projectIds2 = projectRows2.results?.map((r) => r.project_id) ?? ['platform'];
2174
+ const cbResults2 = await Promise.all(
2175
+ projectIds2.map(async (pid) => {
2176
+ const cbKey = `PROJECT:${pid.toUpperCase().replace(/-/g, '-')}:STATUS`;
2177
+ const status = await env.PLATFORM_CACHE.get(cbKey);
2178
+ return { pid, status };
2179
+ })
2180
+ );
2181
+ for (const { pid, status } of cbResults2) {
2182
+ cbStatuses[pid] = status ?? 'active';
2183
+ }
2184
+ }
2185
+
2186
+ // Query feature_registry to find which projects have CB-enabled features
2187
+ // This determines whether to show CB indicator as active vs disabled
2188
+ const projectsWithCBEnabled = new Set<string>();
2189
+ try {
2190
+ const cbEnabledResult = await env.PLATFORM_DB.prepare(
2191
+ `
2192
+ SELECT DISTINCT project_id
2193
+ FROM feature_registry
2194
+ WHERE circuit_breaker_enabled = 1
2195
+ `
2196
+ ).all();
2197
+ for (const row of cbEnabledResult.results ?? []) {
2198
+ projectsWithCBEnabled.add(row.project_id as string);
2199
+ }
2200
+ log.info(`Projects with CB enabled: ${[...projectsWithCBEnabled].join(', ')}`, {
2201
+ tag: 'USAGE',
2202
+ });
2203
+ } catch (err) {
2204
+ // feature_registry may not exist - fall back to showing all as enabled
2205
+ log.warn('Could not query feature_registry for CB status', undefined, {
2206
+ tag: 'USAGE',
2207
+ error: String(err),
2208
+ });
2209
+ // Fallback: add all known projects from cbStatuses
2210
+ for (const pid of Object.keys(cbStatuses)) {
2211
+ projectsWithCBEnabled.add(pid);
2212
+ }
2213
+ }
2214
+
2215
+ // Build project-level utilization using ACTUAL per-project data
2216
+ for (const { project, config } of projectsWithConfig) {
2217
+ const projectId = project.projectId;
2218
+ const primaryResource = config.primaryResource;
2219
+ const limit = config.customLimit ?? CF_ALLOWANCES[primaryResource].limit;
2220
+ const unit = CF_ALLOWANCES[primaryResource].unit;
2221
+
2222
+ // Get project-specific costs from per-project query (not divided total)
2223
+ const projectCostData = perProjectCosts[projectId];
2224
+ const projectMtdCost = projectCostData?.mtdCost ?? 0;
2225
+
2226
+ // Get actual current usage for primary resource from per-project D1 query
2227
+ // We query per-project daily data to get proper attribution
2228
+ let currentUsage = 0;
2229
+ const projectD1Data = await queryD1DailyCosts(
2230
+ env,
2231
+ { start: mtdStartDate, end: mtdEndDate },
2232
+ projectId
2233
+ );
2234
+ if (projectD1Data?.totals) {
2235
+ switch (primaryResource) {
2236
+ case 'workers': {
2237
+ // Reverse cost calculation: cost / $0.30 per million * 1M = requests
2238
+ // Plus base cost consideration (~$0.17/day for $5/month)
2239
+ const workersUsageCost = Math.max(
2240
+ 0,
2241
+ projectD1Data.totals.workers - (5 / 30) * dayOfMonth
2242
+ );
2243
+ currentUsage = (workersUsageCost / 0.3) * 1_000_000;
2244
+ break;
2245
+ }
2246
+ case 'd1':
2247
+ // D1 writes: cost * 1M rows (since $1 per million rows written)
2248
+ currentUsage = projectD1Data.totals.d1 * 1_000_000;
2249
+ break;
2250
+ case 'vectorize':
2251
+ // Vectorize: cost / $0.01 per million * 1M = dimensions
2252
+ currentUsage = (projectD1Data.totals.vectorize / 0.01) * 1_000_000;
2253
+ break;
2254
+ case 'kv':
2255
+ // KV writes: cost / $5 per million * 1M = writes
2256
+ currentUsage = (projectD1Data.totals.kv / 5) * 1_000_000;
2257
+ break;
2258
+ case 'r2':
2259
+ // R2: cost / $4.50 per million * 1M = Class A ops
2260
+ currentUsage = (projectD1Data.totals.r2 / 4.5) * 1_000_000;
2261
+ break;
2262
+ case 'durableObjects':
2263
+ // DO: cost / $1 per million * 1M = requests (after 3M included in Workers Paid)
2264
+ currentUsage = (projectD1Data.totals.durableObjects / 1) * 1_000_000;
2265
+ break;
2266
+ case 'queues':
2267
+ // Queues: cost / $0.40 per million * 1M = messages (after 1M free)
2268
+ currentUsage = (projectD1Data.totals.queues / 0.4) * 1_000_000;
2269
+ break;
2270
+ default:
2271
+ currentUsage = 0;
2272
+ }
2273
+ }
2274
+
2275
+ const utilizationPct = limit > 0 ? (currentUsage / limit) * 100 : 0;
2276
+ const projectStatus = getUtilizationStatus(utilizationPct);
2277
+
2278
+ // Use per-project sparkline data
2279
+ const sparkline = projectSparklines[projectId] ?? [];
2280
+
2281
+ // Check if this project has CB enabled in feature_registry
2282
+ const hasCBEnabled = projectsWithCBEnabled.has(projectId);
2283
+
2284
+ // Only show actual CB status if the project has CB enabled features
2285
+ let cbStatusMapped: 'active' | 'tripped' | 'degraded' | 'disabled';
2286
+ let cbLabel: string;
2287
+
2288
+ if (hasCBEnabled) {
2289
+ const cbStatus = cbStatuses[projectId];
2290
+ cbStatusMapped =
2291
+ cbStatus === 'paused' ? 'tripped' : cbStatus === 'degraded' ? 'degraded' : 'active';
2292
+ cbLabel = cbStatusMapped === 'active' ? 'CB Active' : 'CB Tripped';
2293
+ } else {
2294
+ cbStatusMapped = 'disabled';
2295
+ cbLabel = 'No CB';
2296
+ }
2297
+
2298
+ // Calculate per-project cost delta vs last month (same period)
2299
+ // Use $0.10 threshold to avoid astronomical percentages from near-zero baselines
2300
+ const projectLastMonthCost = perProjectLastMonthCosts[projectId] ?? 0;
2301
+ const MIN_BASELINE_COST = 0.1;
2302
+ let projectCostDeltaPct: number | null = null;
2303
+ if (projectLastMonthCost >= MIN_BASELINE_COST) {
2304
+ projectCostDeltaPct =
2305
+ ((projectMtdCost - projectLastMonthCost) / projectLastMonthCost) * 100;
2306
+ } else if (projectMtdCost >= MIN_BASELINE_COST) {
2307
+ // New project with meaningful current cost but no baseline - show as NEW
2308
+ projectCostDeltaPct = null;
2309
+ }
2310
+ // If both are below threshold, leave as null (will show "--" in UI)
2311
+
2312
+ projectData.push({
2313
+ projectId,
2314
+ projectName: project.displayName,
2315
+ primaryResource:
2316
+ primaryResource.charAt(0).toUpperCase() + primaryResource.slice(1).replace('AI', ' AI'),
2317
+ mtdCost: projectMtdCost,
2318
+ costDeltaPct: projectCostDeltaPct ?? 0,
2319
+ utilizationPct: Math.min(utilizationPct, 999),
2320
+ utilizationCurrent: Math.round(currentUsage),
2321
+ utilizationLimit: limit,
2322
+ utilizationUnit: unit,
2323
+ status: projectStatus,
2324
+ sparklineData: sparkline.length > 0 ? sparkline : [0, 0, 0, 0, 0, 0, 0],
2325
+ circuitBreakerStatus: cbStatusMapped,
2326
+ circuitBreakerLabel: cbLabel,
2327
+ hasCBEnabled,
2328
+ });
2329
+ }
2330
+
2331
+ // Query GitHub usage data (third-party provider)
2332
+ const githubData = await queryGitHubUsage(env);
2333
+
2334
+ // Build service-level utilization metrics for overview page
2335
+ const defaultTotals = {
2336
+ workers: 0,
2337
+ d1: 0,
2338
+ kv: 0,
2339
+ r2: 0,
2340
+ vectorize: 0,
2341
+ aiGateway: 0,
2342
+ durableObjects: 0,
2343
+ workersAI: 0,
2344
+ queues: 0,
2345
+ pages: 0,
2346
+ workflows: 0,
2347
+ total: 0,
2348
+ };
2349
+ const cloudflareServices = buildCloudflareServiceMetrics(
2350
+ d1Data?.totals ?? defaultTotals,
2351
+ dayOfMonth
2352
+ );
2353
+ const githubServices = buildGitHubServiceMetrics(githubData);
2354
+
2355
+ // Calculate provider health summaries
2356
+ const cloudflareHealth = calculateProviderHealth(cloudflareServices, 'cloudflare');
2357
+ const githubHealth = calculateProviderHealth(githubServices, 'github');
2358
+
2359
+ const response: BurnRateResponse = {
2360
+ success: true,
2361
+ burnRate: {
2362
+ mtdCost,
2363
+ mtdStartDate,
2364
+ mtdEndDate,
2365
+ projectedMonthlyCost,
2366
+ dailyBurnRate,
2367
+ daysIntoMonth: dayOfMonth,
2368
+ daysRemaining,
2369
+ confidence,
2370
+ vsLastMonthPct,
2371
+ billingPeriodStart: billingStart,
2372
+ billingPeriodEnd: billingEnd,
2373
+ status,
2374
+ statusLabel,
2375
+ statusDetail,
2376
+ },
2377
+ projects: projectData,
2378
+ github: githubData,
2379
+ health: {
2380
+ cloudflare: cloudflareHealth,
2381
+ github: githubHealth,
2382
+ },
2383
+ cloudflareServices,
2384
+ githubServices,
2385
+ timestamp: new Date().toISOString(),
2386
+ cached: false,
2387
+ };
2388
+
2389
+ // Cache for 1 hour
2390
+ try {
2391
+ await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 3600 });
2392
+ log.info(`Utilization cached for ${cacheKey}`, { tag: 'USAGE' });
2393
+ } catch (error) {
2394
+ log.error(
2395
+ 'Utilization cache write error',
2396
+ error instanceof Error ? error : new Error(String(error))
2397
+ );
2398
+ }
2399
+
2400
+ return jsonResponse({
2401
+ ...response,
2402
+ responseTimeMs: Date.now() - startTime,
2403
+ });
2404
+ } catch (error) {
2405
+ const errorMessage = error instanceof Error ? error.message : String(error);
2406
+ log.error(
2407
+ 'Error fetching utilization data',
2408
+ error instanceof Error ? error : new Error(errorMessage)
2409
+ );
2410
+
2411
+ return jsonResponse(
2412
+ {
2413
+ success: false,
2414
+ error: 'Failed to fetch utilization data',
2415
+ message: errorMessage,
2416
+ },
2417
+ 500
2418
+ );
2419
+ }
2420
+ }