@littlebearapps/platform-admin-sdk 2.0.0 → 2.1.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 (74) hide show
  1. package/README.md +2 -2
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +86 -2
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
  6. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  7. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  8. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  9. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  10. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  11. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  12. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  13. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  14. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  15. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  16. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  17. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  18. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  19. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  20. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  21. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  22. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  23. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  24. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  25. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  26. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  27. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  28. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  29. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  30. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  31. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  32. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  33. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  34. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  35. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  36. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  37. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  38. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  39. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  40. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  41. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  42. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  43. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  44. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  45. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  46. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  47. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  48. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  49. package/templates/shared/tests/unit/billing.test.ts +331 -0
  50. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  51. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  52. package/templates/shared/tests/unit/control.test.ts +226 -0
  53. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  54. package/templates/shared/tests/unit/economics.test.ts +365 -0
  55. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  56. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  57. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  58. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  59. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  60. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  61. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  62. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  63. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  64. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  65. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  66. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  67. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  68. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  69. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  70. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  71. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  72. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  73. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  74. package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
@@ -0,0 +1,4785 @@
1
+ /**
2
+ * Cloudflare GraphQL Analytics Client
3
+ *
4
+ * Unified client for querying Cloudflare's GraphQL Analytics API.
5
+ * Supports Workers, D1, KV, R2, and Durable Objects metrics.
6
+ *
7
+ * Part of task-257: Unified Cloudflare Account Usage Dashboard
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * const client = new CloudflareGraphQL(env);
12
+ * const metrics = await client.getAllMetrics('30d');
13
+ * ```
14
+ */
15
+
16
+ import { calculateDailyCosts, type DailyUsageMetrics } from './costs';
17
+
18
+ const GRAPHQL_ENDPOINT = 'https://api.cloudflare.com/client/v4/graphql';
19
+
20
+ /**
21
+ * Fetch with exponential backoff retry for transient errors.
22
+ * Retries on: 429 (rate limit), 500/502/503/504 (server errors), network errors.
23
+ *
24
+ * @param url - URL to fetch
25
+ * @param options - Fetch options
26
+ * @param maxRetries - Maximum number of retries (default: 3)
27
+ * @param baseDelayMs - Base delay in milliseconds (default: 1000)
28
+ * @returns Response from fetch
29
+ */
30
+ async function fetchWithRetry(
31
+ url: string,
32
+ options: RequestInit,
33
+ maxRetries = 3,
34
+ baseDelayMs = 1000
35
+ ): Promise<Response> {
36
+ let lastError: Error | null = null;
37
+ let lastResponse: Response | null = null;
38
+
39
+ // Status codes that should trigger a retry
40
+ const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
41
+
42
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
43
+ try {
44
+ const response = await fetch(url, options);
45
+
46
+ // If not a retryable status, return response immediately
47
+ if (!RETRYABLE_STATUS_CODES.has(response.status)) {
48
+ return response;
49
+ }
50
+
51
+ // Store for returning if retries exhausted
52
+ lastResponse = response;
53
+
54
+ // If retryable and we have retries left
55
+ if (attempt < maxRetries) {
56
+ // Check for Retry-After header (Cloudflare may include this)
57
+ const retryAfter = response.headers.get('Retry-After');
58
+ let delayMs: number;
59
+
60
+ if (retryAfter) {
61
+ // Retry-After can be seconds or a date
62
+ const retryAfterSecs = parseInt(retryAfter, 10);
63
+ delayMs = isNaN(retryAfterSecs)
64
+ ? baseDelayMs * Math.pow(2, attempt)
65
+ : retryAfterSecs * 1000;
66
+ } else {
67
+ // Exponential backoff: baseDelay * 2^attempt + random jitter (0-500ms)
68
+ delayMs = baseDelayMs * Math.pow(2, attempt) + Math.random() * 500;
69
+ }
70
+
71
+ const reason = response.status === 429 ? 'rate limit' : `HTTP ${response.status}`;
72
+ console.log(
73
+ `[CloudflareGraphQL] Retry ${attempt + 1}/${maxRetries} (${reason}) after ${Math.round(delayMs)}ms for ${url.split('?')[0]}`
74
+ );
75
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
76
+ continue;
77
+ }
78
+
79
+ // Max retries exhausted
80
+ console.warn(
81
+ `[CloudflareGraphQL] Retries exhausted (HTTP ${response.status}) after ${maxRetries} retries for ${url.split('?')[0]}`
82
+ );
83
+ return response;
84
+ } catch (error) {
85
+ lastError = error as Error;
86
+
87
+ // Network errors should also retry
88
+ if (attempt < maxRetries) {
89
+ const delayMs = baseDelayMs * Math.pow(2, attempt) + Math.random() * 500;
90
+ console.log(
91
+ `[CloudflareGraphQL] Network error, retry ${attempt + 1}/${maxRetries} after ${Math.round(delayMs)}ms: ${(error as Error).message}`
92
+ );
93
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
94
+ continue;
95
+ }
96
+ }
97
+ }
98
+
99
+ // Return last response if we had one, otherwise throw
100
+ if (lastResponse) {
101
+ return lastResponse;
102
+ }
103
+ throw lastError || new Error('fetchWithRetry failed unexpectedly');
104
+ }
105
+
106
+ /**
107
+ * Time period for metrics queries
108
+ */
109
+ export type TimePeriod = '24h' | '7d' | '30d';
110
+
111
+ /**
112
+ * Date range for GraphQL queries
113
+ */
114
+ export interface DateRange {
115
+ startDate: string; // YYYY-MM-DD
116
+ endDate: string; // YYYY-MM-DD
117
+ }
118
+
119
+ /**
120
+ * Custom date range query parameters
121
+ */
122
+ export interface CustomDateRangeParams {
123
+ startDate: string;
124
+ endDate: string;
125
+ priorStartDate?: string;
126
+ priorEndDate?: string;
127
+ }
128
+
129
+ /**
130
+ * Comparison mode
131
+ */
132
+ export type CompareMode = 'none' | 'lastMonth' | 'custom';
133
+
134
+ /**
135
+ * Workers usage metrics
136
+ */
137
+ export interface WorkersMetrics {
138
+ scriptName: string;
139
+ requests: number;
140
+ errors: number;
141
+ cpuTimeMs: number;
142
+ duration50thMs: number;
143
+ duration99thMs: number;
144
+ }
145
+
146
+ /**
147
+ * D1 database usage metrics
148
+ */
149
+ export interface D1Metrics {
150
+ databaseId: string;
151
+ databaseName: string;
152
+ rowsRead: number;
153
+ rowsWritten: number;
154
+ readQueries: number;
155
+ writeQueries: number;
156
+ storageBytes: number; // From d1StorageAdaptiveGroups
157
+ }
158
+
159
+ /**
160
+ * KV namespace usage metrics
161
+ */
162
+ export interface KVMetrics {
163
+ namespaceId: string;
164
+ namespaceName: string;
165
+ reads: number;
166
+ writes: number;
167
+ deletes: number;
168
+ lists: number;
169
+ storageBytes: number; // From kvStorageAdaptiveGroups
170
+ keyCount: number; // From kvStorageAdaptiveGroups
171
+ }
172
+
173
+ /**
174
+ * R2 bucket usage metrics
175
+ */
176
+ export interface R2Metrics {
177
+ bucketName: string;
178
+ classAOperations: number; // PUT, POST, DELETE, LIST, etc.
179
+ classBOperations: number; // GET, HEAD
180
+ storageBytes: number;
181
+ egressBytes: number;
182
+ }
183
+
184
+ /**
185
+ * Per-script Durable Objects metrics for project attribution
186
+ */
187
+ export interface DOScriptMetrics {
188
+ scriptName: string;
189
+ requests: number;
190
+ gbSeconds: number;
191
+ storageBytes?: number;
192
+ }
193
+
194
+ /**
195
+ * Durable Objects usage metrics
196
+ * Note: gbSeconds requires durableObjectsPeriodicGroups dataset (not currently queried).
197
+ * We default gbSeconds to 0 - duration costs won't be calculated but dashboard won't break.
198
+ */
199
+ export interface DOMetrics {
200
+ requests: number;
201
+ responseBodySize: number;
202
+ gbSeconds: number; // Duration billing - requires separate query to durableObjectsPeriodicGroups
203
+ storageBytes: number; // Storage at rest - from durableObjectsStorageGroups (max storedBytes)
204
+ storageReadUnits: number;
205
+ storageWriteUnits: number;
206
+ storageDeleteUnits: number;
207
+ byScript?: DOScriptMetrics[]; // Per-script breakdown for project attribution
208
+ }
209
+
210
+ /**
211
+ * Vectorize index info (from REST API)
212
+ */
213
+ export interface VectorizeInfo {
214
+ name: string;
215
+ vectorCount: number;
216
+ dimensions: number;
217
+ }
218
+
219
+ /**
220
+ * AI Gateway model breakdown metrics
221
+ */
222
+ export interface AIGatewayModelBreakdown {
223
+ provider: string; // openai, google-ai-studio, workers-ai, anthropic, etc.
224
+ model: string;
225
+ requests: number;
226
+ cachedRequests: number;
227
+ tokensIn: number;
228
+ tokensOut: number;
229
+ costUsd: number;
230
+ }
231
+
232
+ /**
233
+ * AI Gateway usage metrics (from REST API)
234
+ */
235
+ export interface AIGatewayMetrics {
236
+ gatewayId: string;
237
+ totalRequests: number;
238
+ cachedRequests: number;
239
+ totalTokens: number;
240
+ estimatedCostUsd: number;
241
+ /** Model breakdown from AI Gateway logs - optional for backwards compatibility */
242
+ byModel?: AIGatewayModelBreakdown[];
243
+ }
244
+
245
+ /**
246
+ * Workflows execution metrics (from GraphQL API)
247
+ * Tracks workflow executions, steps, and timing
248
+ */
249
+ export interface WorkflowsMetrics {
250
+ workflowName: string;
251
+ executions: number; // WORKFLOW_START events
252
+ successes: number; // WORKFLOW_SUCCESS events
253
+ failures: number; // WORKFLOW_FAILURE events
254
+ wallTimeMs: number; // Total wall time in milliseconds
255
+ cpuTimeMs: number; // Total CPU time in milliseconds
256
+ }
257
+
258
+ /**
259
+ * Workflows usage summary
260
+ */
261
+ export interface WorkflowsSummary {
262
+ totalExecutions: number;
263
+ totalSuccesses: number;
264
+ totalFailures: number;
265
+ totalWallTimeMs: number;
266
+ totalCpuTimeMs: number;
267
+ byWorkflow: WorkflowsMetrics[];
268
+ }
269
+
270
+ /**
271
+ * Workers AI metrics from Analytics Engine
272
+ */
273
+ export interface WorkersAIMetrics {
274
+ project: string;
275
+ model: string;
276
+ requests: number;
277
+ inputTokens: number;
278
+ outputTokens: number;
279
+ costUsd: number;
280
+ isEstimated: boolean; // true for Brand Copilot (doesn't track tokens)
281
+ }
282
+
283
+ /**
284
+ * AI Gateway aggregated metrics from D1
285
+ */
286
+ export interface AIGatewaySummary {
287
+ totalRequests: number;
288
+ totalCachedRequests: number;
289
+ cacheHitRate: number; // percentage
290
+ tokensIn: number;
291
+ tokensOut: number;
292
+ totalCostUsd: number;
293
+ byProvider: Record<
294
+ string,
295
+ {
296
+ requests: number;
297
+ cachedRequests: number;
298
+ tokensIn: number;
299
+ tokensOut: number;
300
+ costUsd: number;
301
+ }
302
+ >;
303
+ byModel: Record<
304
+ string,
305
+ {
306
+ requests: number;
307
+ cachedRequests: number;
308
+ tokensIn: number;
309
+ tokensOut: number;
310
+ costUsd: number;
311
+ }
312
+ >;
313
+ }
314
+
315
+ /**
316
+ * Workers AI usage summary
317
+ */
318
+ export interface WorkersAISummary {
319
+ totalRequests: number;
320
+ totalInputTokens: number;
321
+ totalOutputTokens: number;
322
+ totalCostUsd: number;
323
+ byProject: Record<string, { requests: number; costUsd: number; isEstimated: boolean }>;
324
+ byModel: Record<
325
+ string,
326
+ { requests: number; inputTokens: number; outputTokens: number; costUsd: number }
327
+ >;
328
+ metrics: WorkersAIMetrics[];
329
+ aiGateway?: AIGatewaySummary; // Optional AI Gateway aggregated metrics
330
+ }
331
+
332
+ /**
333
+ * Analytics Engine SQL API response type
334
+ */
335
+ interface AnalyticsEngineResult {
336
+ data?: Array<Record<string, unknown>>;
337
+ meta?: Array<{ name: string; type: string }>;
338
+ rows?: number;
339
+ rows_before_limit_at_least?: number;
340
+ }
341
+
342
+ /**
343
+ * Sparkline data point for time-series visualisation
344
+ */
345
+ export interface SparklinePoint {
346
+ date: string; // YYYY-MM-DD
347
+ value: number;
348
+ }
349
+
350
+ /**
351
+ * Sparkline data for a metric
352
+ */
353
+ export interface SparklineData {
354
+ metricName: string;
355
+ points: SparklinePoint[];
356
+ trend: 'up' | 'down' | 'stable';
357
+ percentChange: number;
358
+ }
359
+
360
+ /**
361
+ * Daily cost breakdown by resource type (for interactive chart)
362
+ * Part of task-18: Usage Dashboard Interactive Chart & Table Enhancement
363
+ */
364
+ export interface DailyCostBreakdown {
365
+ date: string; // YYYY-MM-DD
366
+ workers: number;
367
+ d1: number;
368
+ kv: number;
369
+ r2: number;
370
+ vectorize: number;
371
+ aiGateway: number;
372
+ durableObjects: number;
373
+ workersAI: number;
374
+ pages: number;
375
+ queues: number;
376
+ workflows: number;
377
+ total: number;
378
+ rollupVersion?: number; // 1 = legacy (inflated), 2 = accurate (MAX aggregation)
379
+ }
380
+
381
+ /**
382
+ * Daily cost data with aggregated totals
383
+ * Part of task-18: Usage Dashboard Interactive Chart & Table Enhancement
384
+ */
385
+ export interface DailyCostData {
386
+ days: DailyCostBreakdown[];
387
+ totals: Omit<DailyCostBreakdown, 'date'>;
388
+ period: {
389
+ start: string; // YYYY-MM-DD
390
+ end: string; // YYYY-MM-DD
391
+ };
392
+ hasLegacyData?: boolean; // true if any day has rollupVersion=1 (inflated values from SUM() on cumulative data)
393
+ }
394
+
395
+ /**
396
+ * Workers error breakdown for health monitoring
397
+ */
398
+ export interface WorkersErrorBreakdown {
399
+ scriptName: string;
400
+ totalRequests: number;
401
+ totalErrors: number;
402
+ errorRate: number; // percentage
403
+ errors4xx: number;
404
+ errors5xx: number;
405
+ latencyP50Ms: number;
406
+ latencyP99Ms: number;
407
+ cpuTimeP50Ms: number;
408
+ cpuTimeP99Ms: number;
409
+ subrequests: number;
410
+ }
411
+
412
+ /**
413
+ * Pages project metrics
414
+ */
415
+ export interface PagesMetrics {
416
+ projectName: string;
417
+ subdomain: string;
418
+ productionDeployments: number;
419
+ previewDeployments: number;
420
+ totalBuilds: number;
421
+ lastDeployedAt: string | null;
422
+ }
423
+
424
+ /**
425
+ * Queues metrics for message processing
426
+ */
427
+ export interface QueuesMetrics {
428
+ queueId: string;
429
+ queueName: string;
430
+ messagesProduced: number;
431
+ messagesConsumed: number;
432
+ messagesAcked: number;
433
+ messagesRetried: number;
434
+ messagesFailed: number;
435
+ backlogSize: number;
436
+ avgProcessingTimeMs: number;
437
+ }
438
+
439
+ /**
440
+ * Cache analytics for Workers
441
+ */
442
+ export interface CacheAnalytics {
443
+ totalRequests: number;
444
+ cacheHits: number;
445
+ cacheMisses: number;
446
+ hitRate: number; // percentage
447
+ bandwidthSavedBytes: number;
448
+ }
449
+
450
+ /**
451
+ * Period comparison data
452
+ */
453
+ export interface PeriodComparison {
454
+ current: number;
455
+ previous: number;
456
+ percentChange: number;
457
+ trend: 'up' | 'down' | 'stable';
458
+ }
459
+
460
+ /**
461
+ * Aggregated account usage
462
+ */
463
+ export interface AccountUsage {
464
+ period: TimePeriod;
465
+ workers: WorkersMetrics[];
466
+ d1: D1Metrics[];
467
+ kv: KVMetrics[];
468
+ r2: R2Metrics[];
469
+ durableObjects: DOMetrics;
470
+ vectorize: VectorizeInfo[];
471
+ aiGateway: AIGatewayMetrics[];
472
+ pages: PagesMetrics[];
473
+ }
474
+
475
+ /**
476
+ * Enhanced account usage with sparklines, error breakdown, and comparison
477
+ */
478
+ export interface EnhancedAccountUsage extends AccountUsage {
479
+ sparklines: {
480
+ workersRequests: SparklineData;
481
+ workersErrors: SparklineData;
482
+ d1RowsRead: SparklineData;
483
+ kvReads: SparklineData;
484
+ };
485
+ errorBreakdown: WorkersErrorBreakdown[];
486
+ queues: QueuesMetrics[];
487
+ cache: CacheAnalytics;
488
+ comparison: {
489
+ workersRequests: PeriodComparison;
490
+ workersErrors: PeriodComparison;
491
+ d1RowsRead: PeriodComparison;
492
+ totalCost: PeriodComparison;
493
+ };
494
+ }
495
+
496
+ /**
497
+ * Cloudflare subscription/plan information
498
+ */
499
+ export interface CloudflareSubscription {
500
+ id: string;
501
+ ratePlanId: string;
502
+ ratePlanName: string;
503
+ price: number;
504
+ currency: string;
505
+ frequency: string; // 'monthly' | 'yearly' | 'not-applicable'
506
+ currentPeriodStart: string | null;
507
+ currentPeriodEnd: string | null;
508
+ state: string;
509
+ zoneName: string | null; // null for account-level subscriptions
510
+ createdDate: string;
511
+ }
512
+
513
+ /**
514
+ * Workers Paid plan inclusions (free tier amounts per month)
515
+ * Based on Cloudflare pricing as of January 2025
516
+ */
517
+ export interface WorkersPaidPlanInclusions {
518
+ // Workers
519
+ requestsIncluded: number; // 10,000,000
520
+ cpuTimeIncluded: number; // 30,000,000 ms
521
+ // D1
522
+ d1RowsReadIncluded: number; // 25,000,000,000 (25B)
523
+ d1RowsWrittenIncluded: number; // 50,000,000 (50M)
524
+ d1StorageIncluded: number; // 5,000,000,000 bytes (5GB)
525
+ // KV
526
+ kvReadsIncluded: number; // 10,000,000
527
+ kvWritesIncluded: number; // 1,000,000
528
+ kvDeletesIncluded: number; // 1,000,000
529
+ kvListsIncluded: number; // 1,000,000
530
+ kvStorageIncluded: number; // 1,000,000,000 bytes (1GB)
531
+ // R2 (separate subscription but related)
532
+ r2ClassAIncluded: number; // 1,000,000
533
+ r2ClassBIncluded: number; // 10,000,000
534
+ r2StorageIncluded: number; // 10,000,000,000 bytes (10GB)
535
+ r2EgressIncluded: number; // 0 (egress free to internet)
536
+ // Durable Objects
537
+ doRequestsIncluded: number; // 1,000,000
538
+ doDurationIncluded: number; // 400,000 GB-seconds
539
+ doStorageIncluded: number; // 1,000,000,000 bytes (1GB)
540
+ // Vectorize
541
+ vectorizeQueriedDimensionsIncluded: number; // 30,000,000
542
+ vectorizeStoredDimensionsIncluded: number; // 5,000,000
543
+ // Workers AI
544
+ workersAINeuronsIncluded: number; // 10,000 neurons/day (gateway dependent)
545
+ // Queues
546
+ queuesOperationsIncluded: number; // 1,000,000
547
+ }
548
+
549
+ /**
550
+ * Cloudflare billing profile
551
+ */
552
+ export interface CloudflareBillingProfile {
553
+ id: string;
554
+ firstName: string;
555
+ lastName: string;
556
+ company: string;
557
+ billingEmail: string;
558
+ accountType: string; // 'personal' | 'business'
559
+ country: string;
560
+ }
561
+
562
+ /**
563
+ * Cloudflare account subscription summary
564
+ */
565
+ export interface CloudflareAccountSubscriptions {
566
+ subscriptions: CloudflareSubscription[];
567
+ billingProfile: CloudflareBillingProfile | null;
568
+ planInclusions: WorkersPaidPlanInclusions;
569
+ hasWorkersPaid: boolean;
570
+ hasR2Paid: boolean;
571
+ hasAnalyticsEngine: boolean;
572
+ monthlyBaseCost: number; // Total of subscription prices
573
+ }
574
+
575
+ /**
576
+ * GraphQL response types
577
+ */
578
+ interface GraphQLResponse<T> {
579
+ data?: T;
580
+ errors?: Array<{ message: string; extensions?: { code?: string } }>;
581
+ }
582
+
583
+ /**
584
+ * Cloudflare GraphQL Analytics Client
585
+ */
586
+ export class CloudflareGraphQL {
587
+ private accountId: string;
588
+ private apiToken: string;
589
+
590
+ constructor(env: { CLOUDFLARE_ACCOUNT_ID: string; CLOUDFLARE_API_TOKEN: string }) {
591
+ this.accountId = env.CLOUDFLARE_ACCOUNT_ID;
592
+ this.apiToken = env.CLOUDFLARE_API_TOKEN;
593
+ }
594
+
595
+ /**
596
+ * Convert a non-hyphenated UUID (from REST API) to hyphenated format (for GraphQL).
597
+ * REST API returns: 2fa618b3a78a4c0c884284569f2b59d6
598
+ * GraphQL expects: 2fa618b3-a78a-4c0c-8842-84569f2b59d6
599
+ */
600
+ private formatUuidWithHyphens(uuid: string): string {
601
+ // If already hyphenated, return as-is
602
+ if (uuid.includes('-')) return uuid;
603
+ // Format: 8-4-4-4-12 characters
604
+ return `${uuid.slice(0, 8)}-${uuid.slice(8, 12)}-${uuid.slice(12, 16)}-${uuid.slice(16, 20)}-${uuid.slice(20)}`;
605
+ }
606
+
607
+ /**
608
+ * Calculate date range for a given time period
609
+ */
610
+ private getDateRange(period: TimePeriod): DateRange {
611
+ const now = new Date();
612
+ const endDate = now.toISOString().split('T')[0]!;
613
+
614
+ const startDate = new Date(now);
615
+ switch (period) {
616
+ case '24h':
617
+ startDate.setDate(startDate.getDate() - 1);
618
+ break;
619
+ case '7d':
620
+ startDate.setDate(startDate.getDate() - 7);
621
+ break;
622
+ case '30d':
623
+ startDate.setDate(startDate.getDate() - 30);
624
+ break;
625
+ }
626
+
627
+ return {
628
+ startDate: startDate.toISOString().split('T')[0]!,
629
+ endDate,
630
+ };
631
+ }
632
+
633
+ /**
634
+ * Calculate the same period from the previous month
635
+ * e.g., Jan 1-7 → Dec 1-7
636
+ */
637
+ static getSamePeriodLastMonth(startDate: string, endDate: string): DateRange {
638
+ const start = new Date(startDate);
639
+ const end = new Date(endDate);
640
+
641
+ // Move to previous month
642
+ const priorStart = new Date(start);
643
+ priorStart.setMonth(priorStart.getMonth() - 1);
644
+
645
+ const priorEnd = new Date(end);
646
+ priorEnd.setMonth(priorEnd.getMonth() - 1);
647
+
648
+ // Handle edge case: if current period ends on day 31 but prior month only has 28-30 days
649
+ // Clamp to the last day of the prior month
650
+ const priorMonthLastDay = new Date(
651
+ priorStart.getFullYear(),
652
+ priorStart.getMonth() + 1,
653
+ 0
654
+ ).getDate();
655
+ if (priorStart.getDate() > priorMonthLastDay) {
656
+ priorStart.setDate(priorMonthLastDay);
657
+ }
658
+ if (priorEnd.getDate() > priorMonthLastDay) {
659
+ priorEnd.setDate(priorMonthLastDay);
660
+ }
661
+
662
+ return {
663
+ startDate: priorStart.toISOString().split('T')[0]!,
664
+ endDate: priorEnd.toISOString().split('T')[0]!,
665
+ };
666
+ }
667
+
668
+ /**
669
+ * Validate and parse custom date range
670
+ * Returns null if validation fails
671
+ */
672
+ static validateCustomDateRange(params: CustomDateRangeParams):
673
+ | {
674
+ current: DateRange;
675
+ prior: DateRange;
676
+ }
677
+ | { error: string } {
678
+ const { startDate, endDate, priorStartDate, priorEndDate } = params;
679
+
680
+ // Parse dates
681
+ const start = new Date(startDate);
682
+ const end = new Date(endDate);
683
+
684
+ if (isNaN(start.getTime()) || isNaN(end.getTime())) {
685
+ return { error: 'Invalid date format. Use YYYY-MM-DD' };
686
+ }
687
+
688
+ // End must be >= start
689
+ if (end < start) {
690
+ return { error: 'End date must be on or after start date' };
691
+ }
692
+
693
+ // Max range: 90 days
694
+ const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
695
+ if (daysDiff > 90) {
696
+ return { error: 'Maximum date range is 90 days' };
697
+ }
698
+
699
+ // Dates must be in the past
700
+ const now = new Date();
701
+ if (end > now) {
702
+ return { error: 'Dates must be in the past' };
703
+ }
704
+
705
+ const current: DateRange = {
706
+ startDate: startDate,
707
+ endDate: endDate,
708
+ };
709
+
710
+ // Calculate prior period
711
+ let prior: DateRange;
712
+ if (priorStartDate && priorEndDate) {
713
+ const priorStart = new Date(priorStartDate);
714
+ const priorEnd = new Date(priorEndDate);
715
+
716
+ if (isNaN(priorStart.getTime()) || isNaN(priorEnd.getTime())) {
717
+ return { error: 'Invalid prior date format. Use YYYY-MM-DD' };
718
+ }
719
+
720
+ prior = { startDate: priorStartDate, endDate: priorEndDate };
721
+ } else {
722
+ // Default: same duration before start date
723
+ const durationMs = end.getTime() - start.getTime();
724
+ const priorEnd = new Date(start.getTime() - 1); // Day before start
725
+ const priorStart = new Date(priorEnd.getTime() - durationMs);
726
+
727
+ prior = {
728
+ startDate: priorStart.toISOString().split('T')[0]!,
729
+ endDate: priorEnd.toISOString().split('T')[0]!,
730
+ };
731
+ }
732
+
733
+ return { current, prior };
734
+ }
735
+
736
+ /**
737
+ * Get metrics for a custom date range
738
+ */
739
+ async getMetricsForDateRange(dateRange: DateRange): Promise<AccountUsage> {
740
+ // Run all queries in parallel with custom date range
741
+ const [workers, d1, kv, r2, durableObjects, vectorize, aiGateway, pages] = await Promise.all([
742
+ this.getWorkersMetricsForRange(dateRange),
743
+ this.getD1MetricsForRange(dateRange),
744
+ this.getKVMetricsForRange(dateRange),
745
+ this.getR2MetricsForRange(dateRange),
746
+ this.getDOMetricsForRange(dateRange),
747
+ this.getVectorizeInfo(),
748
+ this.getAIGatewayMetricsForRange(dateRange),
749
+ this.getPagesMetrics(),
750
+ ]);
751
+
752
+ return {
753
+ period: '30d', // Default period label for custom ranges
754
+ workers,
755
+ d1,
756
+ kv,
757
+ r2,
758
+ durableObjects,
759
+ vectorize,
760
+ aiGateway,
761
+ pages,
762
+ };
763
+ }
764
+
765
+ /**
766
+ * Get Workers metrics for a specific date range
767
+ */
768
+ private async getWorkersMetricsForRange(dateRange: DateRange): Promise<WorkersMetrics[]> {
769
+ const { startDate, endDate } = dateRange;
770
+
771
+ const queryStr = `
772
+ query WorkersMetrics($accountTag: String!, $startDate: Date!, $endDate: Date!) {
773
+ viewer {
774
+ accounts(filter: { accountTag: $accountTag }) {
775
+ workersInvocationsAdaptive(
776
+ filter: {
777
+ date_geq: $startDate
778
+ date_leq: $endDate
779
+ }
780
+ limit: 100
781
+ ) {
782
+ dimensions {
783
+ scriptName
784
+ }
785
+ sum {
786
+ requests
787
+ errors
788
+ }
789
+ quantiles {
790
+ cpuTimeP50
791
+ cpuTimeP99
792
+ durationP50
793
+ durationP99
794
+ }
795
+ }
796
+ }
797
+ }
798
+ }
799
+ `;
800
+
801
+ interface WorkersResponse {
802
+ viewer?: {
803
+ accounts?: Array<{
804
+ workersInvocationsAdaptive?: Array<{
805
+ dimensions?: { scriptName?: string };
806
+ sum?: { requests?: number; errors?: number };
807
+ quantiles?: {
808
+ cpuTimeP50?: number;
809
+ cpuTimeP99?: number;
810
+ durationP50?: number;
811
+ durationP99?: number;
812
+ };
813
+ }>;
814
+ }>;
815
+ };
816
+ }
817
+
818
+ const data = await this.query<WorkersResponse>(queryStr, {
819
+ accountTag: this.accountId,
820
+ startDate,
821
+ endDate,
822
+ });
823
+
824
+ if (!data?.viewer?.accounts?.[0]?.workersInvocationsAdaptive) {
825
+ return [];
826
+ }
827
+
828
+ return data.viewer.accounts[0].workersInvocationsAdaptive
829
+ .filter((item) => item.dimensions?.scriptName)
830
+ .map((item) => ({
831
+ scriptName: item.dimensions!.scriptName!,
832
+ requests: item.sum?.requests ?? 0,
833
+ errors: item.sum?.errors ?? 0,
834
+ // cpuTimeP50 from GraphQL is in microseconds, convert to milliseconds
835
+ cpuTimeMs: (item.quantiles?.cpuTimeP50 ?? 0) / 1000,
836
+ duration50thMs: item.quantiles?.durationP50 ?? 0,
837
+ duration99thMs: item.quantiles?.durationP99 ?? 0,
838
+ }));
839
+ }
840
+
841
+ /**
842
+ * Get D1 metrics for a specific date range
843
+ * Queries both d1AnalyticsAdaptiveGroups (operations) and d1StorageAdaptiveGroups (storage)
844
+ */
845
+ private async getD1MetricsForRange(dateRange: DateRange): Promise<D1Metrics[]> {
846
+ const { startDate, endDate } = dateRange;
847
+ const databases = await this.listD1Databases();
848
+ const results: D1Metrics[] = [];
849
+
850
+ for (const db of databases) {
851
+ const queryStr = `
852
+ query D1Metrics($accountTag: String!, $databaseId: String!, $startDate: Date!, $endDate: Date!) {
853
+ viewer {
854
+ accounts(filter: { accountTag: $accountTag }) {
855
+ d1AnalyticsAdaptiveGroups(
856
+ filter: {
857
+ databaseId: $databaseId
858
+ date_geq: $startDate
859
+ date_leq: $endDate
860
+ }
861
+ limit: 100
862
+ ) {
863
+ sum {
864
+ rowsRead
865
+ rowsWritten
866
+ readQueries
867
+ writeQueries
868
+ }
869
+ }
870
+ d1StorageAdaptiveGroups(
871
+ filter: {
872
+ databaseId: $databaseId
873
+ date_geq: $startDate
874
+ date_leq: $endDate
875
+ }
876
+ limit: 1
877
+ ) {
878
+ max {
879
+ databaseSizeBytes
880
+ }
881
+ }
882
+ }
883
+ }
884
+ }
885
+ `;
886
+
887
+ interface D1Response {
888
+ viewer?: {
889
+ accounts?: Array<{
890
+ d1AnalyticsAdaptiveGroups?: Array<{
891
+ sum?: {
892
+ rowsRead?: number;
893
+ rowsWritten?: number;
894
+ readQueries?: number;
895
+ writeQueries?: number;
896
+ };
897
+ }>;
898
+ d1StorageAdaptiveGroups?: Array<{
899
+ max?: {
900
+ databaseSizeBytes?: number;
901
+ };
902
+ }>;
903
+ }>;
904
+ };
905
+ }
906
+
907
+ const data = await this.query<D1Response>(queryStr, {
908
+ accountTag: this.accountId,
909
+ databaseId: db.id,
910
+ startDate,
911
+ endDate,
912
+ });
913
+
914
+ const account = data?.viewer?.accounts?.[0];
915
+ const analyticsGroups = account?.d1AnalyticsAdaptiveGroups ?? [];
916
+ const storageGroups = account?.d1StorageAdaptiveGroups ?? [];
917
+
918
+ // Sum operations metrics
919
+ const totals = analyticsGroups.reduce(
920
+ (acc, group) => ({
921
+ rowsRead: acc.rowsRead + (group.sum?.rowsRead ?? 0),
922
+ rowsWritten: acc.rowsWritten + (group.sum?.rowsWritten ?? 0),
923
+ readQueries: acc.readQueries + (group.sum?.readQueries ?? 0),
924
+ writeQueries: acc.writeQueries + (group.sum?.writeQueries ?? 0),
925
+ }),
926
+ { rowsRead: 0, rowsWritten: 0, readQueries: 0, writeQueries: 0 }
927
+ );
928
+
929
+ // Get max storage bytes (most recent)
930
+ const storageBytes = storageGroups[0]?.max?.databaseSizeBytes ?? 0;
931
+
932
+ results.push({
933
+ databaseId: db.id,
934
+ databaseName: db.name,
935
+ ...totals,
936
+ storageBytes,
937
+ });
938
+ }
939
+
940
+ return results;
941
+ }
942
+
943
+ /**
944
+ * Get KV metrics for a specific date range
945
+ * Queries both kvOperationsAdaptiveGroups (operations) and kvStorageAdaptiveGroups (storage)
946
+ */
947
+ private async getKVMetricsForRange(dateRange: DateRange): Promise<KVMetrics[]> {
948
+ const { startDate, endDate } = dateRange;
949
+ const namespaces = await this.listKVNamespaces();
950
+ const results: KVMetrics[] = [];
951
+
952
+ for (const ns of namespaces) {
953
+ const queryStr = `
954
+ query KVMetrics($accountTag: String!, $namespaceId: String!, $startDate: Date!, $endDate: Date!) {
955
+ viewer {
956
+ accounts(filter: { accountTag: $accountTag }) {
957
+ kvOperationsAdaptiveGroups(
958
+ filter: {
959
+ namespaceId: $namespaceId
960
+ date_geq: $startDate
961
+ date_leq: $endDate
962
+ }
963
+ limit: 100
964
+ ) {
965
+ sum {
966
+ requests
967
+ }
968
+ dimensions {
969
+ actionType
970
+ }
971
+ }
972
+ kvStorageAdaptiveGroups(
973
+ filter: {
974
+ namespaceId: $namespaceId
975
+ date_geq: $startDate
976
+ date_leq: $endDate
977
+ }
978
+ limit: 1
979
+ ) {
980
+ max {
981
+ byteCount
982
+ keyCount
983
+ }
984
+ }
985
+ }
986
+ }
987
+ }
988
+ `;
989
+
990
+ interface KVResponse {
991
+ viewer?: {
992
+ accounts?: Array<{
993
+ kvOperationsAdaptiveGroups?: Array<{
994
+ sum?: { requests?: number };
995
+ dimensions?: { actionType?: string };
996
+ }>;
997
+ kvStorageAdaptiveGroups?: Array<{
998
+ max?: {
999
+ byteCount?: number;
1000
+ keyCount?: number;
1001
+ };
1002
+ }>;
1003
+ }>;
1004
+ };
1005
+ }
1006
+
1007
+ const data = await this.query<KVResponse>(queryStr, {
1008
+ accountTag: this.accountId,
1009
+ namespaceId: this.formatUuidWithHyphens(ns.id),
1010
+ startDate,
1011
+ endDate,
1012
+ });
1013
+
1014
+ const operationsGroups = data?.viewer?.accounts?.[0]?.kvOperationsAdaptiveGroups;
1015
+ const storageGroups = data?.viewer?.accounts?.[0]?.kvStorageAdaptiveGroups;
1016
+
1017
+ // Aggregate operations by action type
1018
+ const totals = { reads: 0, writes: 0, deletes: 0, lists: 0 };
1019
+ if (operationsGroups?.length) {
1020
+ for (const group of operationsGroups) {
1021
+ const requests = group.sum?.requests ?? 0;
1022
+ const actionType = group.dimensions?.actionType?.toLowerCase() ?? '';
1023
+ if (actionType === 'read' || actionType === 'get') {
1024
+ totals.reads += requests;
1025
+ } else if (actionType === 'write' || actionType === 'put') {
1026
+ totals.writes += requests;
1027
+ } else if (actionType === 'delete') {
1028
+ totals.deletes += requests;
1029
+ } else if (actionType === 'list') {
1030
+ totals.lists += requests;
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ // Extract storage metrics (most recent snapshot)
1036
+ const storageBytes = storageGroups?.[0]?.max?.byteCount ?? 0;
1037
+ const keyCount = storageGroups?.[0]?.max?.keyCount ?? 0;
1038
+
1039
+ // Only add if we have any data
1040
+ if (
1041
+ totals.reads > 0 ||
1042
+ totals.writes > 0 ||
1043
+ totals.deletes > 0 ||
1044
+ totals.lists > 0 ||
1045
+ storageBytes > 0 ||
1046
+ keyCount > 0
1047
+ ) {
1048
+ results.push({
1049
+ namespaceId: ns.id,
1050
+ namespaceName: ns.title,
1051
+ ...totals,
1052
+ storageBytes,
1053
+ keyCount,
1054
+ });
1055
+ }
1056
+ }
1057
+
1058
+ return results;
1059
+ }
1060
+
1061
+ /**
1062
+ * Get R2 metrics for a specific date range
1063
+ */
1064
+ private async getR2MetricsForRange(dateRange: DateRange): Promise<R2Metrics[]> {
1065
+ const { startDate, endDate } = dateRange;
1066
+ // R2 storage uses datetime filters (Time type), operations uses date filters (Date type)
1067
+ const datetimeStart = `${startDate}T00:00:00Z`;
1068
+ const datetimeEnd = `${endDate}T23:59:59Z`;
1069
+
1070
+ // Query 1: Operations (uses Date type filters)
1071
+ const operationsQuery = `
1072
+ query R2Operations($accountTag: String!, $startDate: Date!, $endDate: Date!) {
1073
+ viewer {
1074
+ accounts(filter: { accountTag: $accountTag }) {
1075
+ r2OperationsAdaptiveGroups(
1076
+ filter: {
1077
+ date_geq: $startDate
1078
+ date_leq: $endDate
1079
+ }
1080
+ limit: 100
1081
+ ) {
1082
+ dimensions {
1083
+ bucketName
1084
+ actionType
1085
+ }
1086
+ sum {
1087
+ requests
1088
+ responseObjectSize
1089
+ }
1090
+ }
1091
+ }
1092
+ }
1093
+ }
1094
+ `;
1095
+
1096
+ // Query 2: Storage (uses Time type filters per CF docs)
1097
+ const storageQuery = `
1098
+ query R2Storage($accountTag: String!, $datetimeStart: Time!, $datetimeEnd: Time!) {
1099
+ viewer {
1100
+ accounts(filter: { accountTag: $accountTag }) {
1101
+ r2StorageAdaptiveGroups(
1102
+ filter: {
1103
+ datetime_geq: $datetimeStart
1104
+ datetime_leq: $datetimeEnd
1105
+ }
1106
+ limit: 100
1107
+ ) {
1108
+ dimensions {
1109
+ bucketName
1110
+ }
1111
+ max {
1112
+ payloadSize
1113
+ }
1114
+ }
1115
+ }
1116
+ }
1117
+ }
1118
+ `;
1119
+
1120
+ interface R2OperationsResponse {
1121
+ viewer?: {
1122
+ accounts?: Array<{
1123
+ r2OperationsAdaptiveGroups?: Array<{
1124
+ dimensions?: { bucketName?: string; actionType?: string };
1125
+ sum?: { requests?: number; responseObjectSize?: number };
1126
+ }>;
1127
+ }>;
1128
+ };
1129
+ }
1130
+
1131
+ interface R2StorageResponse {
1132
+ viewer?: {
1133
+ accounts?: Array<{
1134
+ r2StorageAdaptiveGroups?: Array<{
1135
+ dimensions?: { bucketName?: string };
1136
+ max?: { payloadSize?: number };
1137
+ }>;
1138
+ }>;
1139
+ };
1140
+ }
1141
+
1142
+ // Execute both queries in parallel
1143
+ const [operationsData, storageData] = await Promise.all([
1144
+ this.query<R2OperationsResponse>(operationsQuery, {
1145
+ accountTag: this.accountId,
1146
+ startDate,
1147
+ endDate,
1148
+ }),
1149
+ this.query<R2StorageResponse>(storageQuery, {
1150
+ accountTag: this.accountId,
1151
+ datetimeStart,
1152
+ datetimeEnd,
1153
+ }).catch(() => null), // Storage query may fail if no data exists
1154
+ ]);
1155
+
1156
+ const operations = operationsData?.viewer?.accounts?.[0]?.r2OperationsAdaptiveGroups ?? [];
1157
+ const storage = storageData?.viewer?.accounts?.[0]?.r2StorageAdaptiveGroups ?? [];
1158
+
1159
+ const bucketMap = new Map<string, R2Metrics>();
1160
+ const classAActions = new Set([
1161
+ 'PutObject',
1162
+ 'DeleteObject',
1163
+ 'ListObjects',
1164
+ 'ListObjectsV2',
1165
+ 'CreateMultipartUpload',
1166
+ 'CompleteMultipartUpload',
1167
+ 'AbortMultipartUpload',
1168
+ 'UploadPart',
1169
+ 'CopyObject',
1170
+ ]);
1171
+
1172
+ for (const op of operations) {
1173
+ const bucketName = op.dimensions?.bucketName ?? 'unknown';
1174
+ if (!bucketMap.has(bucketName)) {
1175
+ bucketMap.set(bucketName, {
1176
+ bucketName,
1177
+ classAOperations: 0,
1178
+ classBOperations: 0,
1179
+ storageBytes: 0,
1180
+ egressBytes: 0,
1181
+ });
1182
+ }
1183
+
1184
+ const bucket = bucketMap.get(bucketName)!;
1185
+ const requests = op.sum?.requests ?? 0;
1186
+ const actionType = op.dimensions?.actionType ?? '';
1187
+
1188
+ if (classAActions.has(actionType)) {
1189
+ bucket.classAOperations += requests;
1190
+ } else {
1191
+ bucket.classBOperations += requests;
1192
+ }
1193
+
1194
+ bucket.egressBytes += op.sum?.responseObjectSize ?? 0;
1195
+ }
1196
+
1197
+ for (const s of storage) {
1198
+ const bucketName = s.dimensions?.bucketName ?? 'unknown';
1199
+ if (bucketMap.has(bucketName)) {
1200
+ bucketMap.get(bucketName)!.storageBytes = s.max?.payloadSize ?? 0;
1201
+ }
1202
+ }
1203
+
1204
+ return Array.from(bucketMap.values());
1205
+ }
1206
+
1207
+ /**
1208
+ * Get Durable Objects metrics for a specific date range
1209
+ * Combines invocation metrics and duration metrics (GB-seconds for billing)
1210
+ * Includes per-script breakdown for project attribution
1211
+ */
1212
+ private async getDOMetricsForRange(dateRange: DateRange): Promise<DOMetrics> {
1213
+ const { startDate, endDate } = dateRange;
1214
+
1215
+ // Query 1: Per-script invocation metrics with scriptName dimension for project attribution
1216
+ const invocationsQuery = `
1217
+ query DOInvocations($accountTag: String!, $startDate: Date!, $endDate: Date!) {
1218
+ viewer {
1219
+ accounts(filter: { accountTag: $accountTag }) {
1220
+ durableObjectsInvocationsAdaptiveGroups(
1221
+ filter: {
1222
+ date_geq: $startDate
1223
+ date_leq: $endDate
1224
+ }
1225
+ limit: 10000
1226
+ ) {
1227
+ dimensions {
1228
+ scriptName
1229
+ }
1230
+ sum {
1231
+ requests
1232
+ responseBodySize
1233
+ }
1234
+ }
1235
+ }
1236
+ }
1237
+ }
1238
+ `;
1239
+
1240
+ // Query 2: Account-level duration metrics from durableObjectsPeriodicGroups
1241
+ // Note: This dataset only supports date dimension, not scriptName
1242
+ // Duration is in GB-seconds (the billable unit) - will be allocated proportionally per script
1243
+ const durationQuery = `
1244
+ query DODuration($accountTag: String!, $startDate: Date!, $endDate: Date!) {
1245
+ viewer {
1246
+ accounts(filter: { accountTag: $accountTag }) {
1247
+ durableObjectsPeriodicGroups(
1248
+ filter: {
1249
+ date_geq: $startDate
1250
+ date_leq: $endDate
1251
+ }
1252
+ limit: 10000
1253
+ ) {
1254
+ sum {
1255
+ duration
1256
+ }
1257
+ }
1258
+ }
1259
+ }
1260
+ }
1261
+ `;
1262
+
1263
+ // Query 3: Account-level storage metrics from durableObjectsStorageGroups
1264
+ // Note: This dataset only supports date dimension, not scriptName
1265
+ // Storage will be allocated proportionally per script based on request count
1266
+ const storageQuery = `
1267
+ query DOStorage($accountTag: String!, $startDate: Date!, $endDate: Date!) {
1268
+ viewer {
1269
+ accounts(filter: { accountTag: $accountTag }) {
1270
+ durableObjectsStorageGroups(
1271
+ filter: {
1272
+ date_geq: $startDate
1273
+ date_leq: $endDate
1274
+ }
1275
+ limit: 10000
1276
+ ) {
1277
+ max {
1278
+ storedBytes
1279
+ }
1280
+ }
1281
+ }
1282
+ }
1283
+ }
1284
+ `;
1285
+
1286
+ interface InvocationsResponse {
1287
+ viewer?: {
1288
+ accounts?: Array<{
1289
+ durableObjectsInvocationsAdaptiveGroups?: Array<{
1290
+ dimensions?: {
1291
+ scriptName?: string;
1292
+ };
1293
+ sum?: {
1294
+ requests?: number;
1295
+ responseBodySize?: number;
1296
+ };
1297
+ }>;
1298
+ }>;
1299
+ };
1300
+ }
1301
+
1302
+ // Note: durableObjectsPeriodicGroups doesn't support scriptName dimension
1303
+ // Returns account-level totals only
1304
+ interface DurationResponse {
1305
+ viewer?: {
1306
+ accounts?: Array<{
1307
+ durableObjectsPeriodicGroups?: Array<{
1308
+ sum?: {
1309
+ duration?: number; // GB-seconds (billable unit from Cloudflare)
1310
+ };
1311
+ }>;
1312
+ }>;
1313
+ };
1314
+ }
1315
+
1316
+ // Note: durableObjectsStorageGroups doesn't support scriptName dimension
1317
+ // Returns account-level totals only
1318
+ interface StorageResponse {
1319
+ viewer?: {
1320
+ accounts?: Array<{
1321
+ durableObjectsStorageGroups?: Array<{
1322
+ max?: {
1323
+ storedBytes?: number;
1324
+ };
1325
+ }>;
1326
+ }>;
1327
+ };
1328
+ }
1329
+
1330
+ // Execute all queries in parallel
1331
+ const [invocationsData, durationData, storageData] = await Promise.all([
1332
+ this.query<InvocationsResponse>(invocationsQuery, {
1333
+ accountTag: this.accountId,
1334
+ startDate,
1335
+ endDate,
1336
+ }),
1337
+ this.query<DurationResponse>(durationQuery, {
1338
+ accountTag: this.accountId,
1339
+ startDate,
1340
+ endDate,
1341
+ }).catch(() => null), // Duration query may fail if no data exists
1342
+ this.query<StorageResponse>(storageQuery, {
1343
+ accountTag: this.accountId,
1344
+ startDate,
1345
+ endDate,
1346
+ }).catch(() => null), // Storage query may fail if no data exists
1347
+ ]);
1348
+
1349
+ // Build per-script breakdown for project attribution
1350
+ const scriptMap = new Map<
1351
+ string,
1352
+ { requests: number; gbSeconds: number; storageBytes: number; responseBodySize: number }
1353
+ >();
1354
+
1355
+ // Process invocation metrics per script (only dataset with scriptName dimension)
1356
+ const invocationsGroups =
1357
+ invocationsData?.viewer?.accounts?.[0]?.durableObjectsInvocationsAdaptiveGroups ?? [];
1358
+
1359
+ // Debug: Log DO invocations data for troubleshooting
1360
+ console.log(`[CloudflareGraphQL] DO invocations groups count: ${invocationsGroups.length}`);
1361
+ if (invocationsGroups.length > 0) {
1362
+ console.log(
1363
+ `[CloudflareGraphQL] DO invocations sample: ${JSON.stringify(invocationsGroups.slice(0, 3))}`
1364
+ );
1365
+ }
1366
+
1367
+ for (const group of invocationsGroups) {
1368
+ const scriptName = group.dimensions?.scriptName ?? 'unknown';
1369
+ const existing = scriptMap.get(scriptName) ?? {
1370
+ requests: 0,
1371
+ gbSeconds: 0,
1372
+ storageBytes: 0,
1373
+ responseBodySize: 0,
1374
+ };
1375
+ existing.requests += group.sum?.requests ?? 0;
1376
+ existing.responseBodySize += group.sum?.responseBodySize ?? 0;
1377
+ scriptMap.set(scriptName, existing);
1378
+ }
1379
+
1380
+ // Get account-level duration total (durableObjectsPeriodicGroups doesn't support scriptName)
1381
+ const durationGroups = durationData?.viewer?.accounts?.[0]?.durableObjectsPeriodicGroups ?? [];
1382
+ let totalAccountGbSeconds = 0;
1383
+ for (const group of durationGroups) {
1384
+ totalAccountGbSeconds += group.sum?.duration ?? 0;
1385
+ }
1386
+
1387
+ // Get account-level storage total (durableObjectsStorageGroups doesn't support scriptName)
1388
+ const storageGroups = storageData?.viewer?.accounts?.[0]?.durableObjectsStorageGroups ?? [];
1389
+ let maxAccountStorageBytes = 0;
1390
+ for (const group of storageGroups) {
1391
+ maxAccountStorageBytes = Math.max(maxAccountStorageBytes, group.max?.storedBytes ?? 0);
1392
+ }
1393
+
1394
+ // Calculate total requests for proportional allocation
1395
+ let totalRequestsForAllocation = 0;
1396
+ for (const [, metrics] of scriptMap) {
1397
+ totalRequestsForAllocation += metrics.requests;
1398
+ }
1399
+
1400
+ // Allocate duration and storage proportionally to each script based on request count
1401
+ if (totalRequestsForAllocation > 0) {
1402
+ for (const [scriptName, metrics] of scriptMap) {
1403
+ const proportion = metrics.requests / totalRequestsForAllocation;
1404
+ metrics.gbSeconds = totalAccountGbSeconds * proportion;
1405
+ metrics.storageBytes = maxAccountStorageBytes * proportion;
1406
+ scriptMap.set(scriptName, metrics);
1407
+ }
1408
+ }
1409
+
1410
+ // Calculate totals
1411
+ let totalRequests = 0;
1412
+ let totalResponseBodySize = 0;
1413
+ let totalGbSeconds = 0;
1414
+
1415
+ const byScript: DOScriptMetrics[] = [];
1416
+ for (const [scriptName, metrics] of scriptMap) {
1417
+ totalRequests += metrics.requests;
1418
+ totalResponseBodySize += metrics.responseBodySize;
1419
+ totalGbSeconds += metrics.gbSeconds;
1420
+ byScript.push({
1421
+ scriptName,
1422
+ requests: metrics.requests,
1423
+ gbSeconds: metrics.gbSeconds,
1424
+ storageBytes: metrics.storageBytes > 0 ? metrics.storageBytes : undefined,
1425
+ });
1426
+ }
1427
+
1428
+ // Debug: Log byScript breakdown for troubleshooting
1429
+ console.log(
1430
+ `[CloudflareGraphQL] DO byScript count: ${byScript.length}, scripts: ${byScript.map((s) => s.scriptName).join(', ')}`
1431
+ );
1432
+
1433
+ return {
1434
+ requests: totalRequests,
1435
+ responseBodySize: totalResponseBodySize,
1436
+ gbSeconds: totalGbSeconds,
1437
+ storageBytes: maxAccountStorageBytes, // Account-level storage (current state, not cumulative)
1438
+ // Storage operation metrics not available from API - returning 0s for interface compatibility
1439
+ storageReadUnits: 0,
1440
+ storageWriteUnits: 0,
1441
+ storageDeleteUnits: 0,
1442
+ byScript: byScript.length > 0 ? byScript : undefined,
1443
+ };
1444
+ }
1445
+
1446
+ /**
1447
+ * Get AI Gateway metrics for a specific date range
1448
+ */
1449
+ private async getAIGatewayMetricsForRange(dateRange: DateRange): Promise<AIGatewayMetrics[]> {
1450
+ const { startDate, endDate } = dateRange;
1451
+
1452
+ try {
1453
+ const listResponse = await fetchWithRetry(
1454
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/ai-gateway/gateways`,
1455
+ {
1456
+ headers: {
1457
+ Authorization: `Bearer ${this.apiToken}`,
1458
+ 'Content-Type': 'application/json',
1459
+ },
1460
+ }
1461
+ );
1462
+
1463
+ if (!listResponse.ok) {
1464
+ return [];
1465
+ }
1466
+
1467
+ interface GatewayListResponse {
1468
+ result?: Array<{ id: string; name: string }>;
1469
+ success?: boolean;
1470
+ }
1471
+
1472
+ const listData = (await listResponse.json()) as GatewayListResponse;
1473
+
1474
+ if (!listData.success || !listData.result) {
1475
+ return [];
1476
+ }
1477
+
1478
+ const results: AIGatewayMetrics[] = [];
1479
+
1480
+ for (const gateway of listData.result) {
1481
+ // Get aggregate analytics and model breakdown in parallel
1482
+ const [analyticsResponse, modelBreakdown] = await Promise.all([
1483
+ fetchWithRetry(
1484
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/ai-gateway/gateways/${gateway.id}/analytics?` +
1485
+ `start=${startDate}T00:00:00Z&end=${endDate}T23:59:59Z`,
1486
+ {
1487
+ headers: {
1488
+ Authorization: `Bearer ${this.apiToken}`,
1489
+ 'Content-Type': 'application/json',
1490
+ },
1491
+ }
1492
+ ),
1493
+ this.getAIGatewayModelBreakdown(gateway.id, startDate, endDate),
1494
+ ]);
1495
+
1496
+ if (analyticsResponse.ok) {
1497
+ interface AnalyticsResponse {
1498
+ result?: {
1499
+ totalRequests?: number;
1500
+ cachedRequests?: number;
1501
+ totalTokens?: number;
1502
+ cost?: number;
1503
+ };
1504
+ }
1505
+ const analyticsData = (await analyticsResponse.json()) as AnalyticsResponse;
1506
+
1507
+ results.push({
1508
+ gatewayId: gateway.id,
1509
+ totalRequests: analyticsData.result?.totalRequests ?? 0,
1510
+ cachedRequests: analyticsData.result?.cachedRequests ?? 0,
1511
+ totalTokens: analyticsData.result?.totalTokens ?? 0,
1512
+ estimatedCostUsd: analyticsData.result?.cost ?? 0,
1513
+ byModel: modelBreakdown.length > 0 ? modelBreakdown : undefined,
1514
+ });
1515
+ }
1516
+ }
1517
+
1518
+ return results;
1519
+ } catch (error) {
1520
+ console.error('[CloudflareGraphQL] AI Gateway error:', error);
1521
+ return [];
1522
+ }
1523
+ }
1524
+
1525
+ /**
1526
+ * Execute a GraphQL query with retry logic for rate limits
1527
+ */
1528
+ private async query<T>(
1529
+ query: string,
1530
+ variables: Record<string, unknown>,
1531
+ queryRetryCount = 0
1532
+ ): Promise<T | null> {
1533
+ // Extract operation name from query for better logging
1534
+ const opMatch = query.match(/query\s+(\w+)/);
1535
+ const operationName = opMatch?.[1] || 'UnnamedQuery';
1536
+
1537
+ try {
1538
+ const response = await fetchWithRetry(GRAPHQL_ENDPOINT, {
1539
+ method: 'POST',
1540
+ headers: {
1541
+ 'Content-Type': 'application/json',
1542
+ Authorization: `Bearer ${this.apiToken}`,
1543
+ },
1544
+ body: JSON.stringify({ query, variables }),
1545
+ });
1546
+
1547
+ if (!response.ok) {
1548
+ const errorBody = await response.text().catch(() => '(unable to read body)');
1549
+ console.error(
1550
+ `[CloudflareGraphQL] HTTP ${response.status} for ${operationName}: ${errorBody}`
1551
+ );
1552
+ return null;
1553
+ }
1554
+
1555
+ const result = (await response.json()) as GraphQLResponse<T>;
1556
+
1557
+ if (result.errors?.length) {
1558
+ // Check if errors are retryable (INTERNAL_SERVER_ERROR from CF)
1559
+ const hasRetryableError = result.errors.some(
1560
+ (e) => e.extensions?.code === 'INTERNAL_SERVER_ERROR'
1561
+ );
1562
+
1563
+ if (hasRetryableError && queryRetryCount < 2) {
1564
+ queryRetryCount++;
1565
+ console.log(
1566
+ `[CloudflareGraphQL] Retryable GraphQL error for ${operationName} (attempt ${queryRetryCount}), retrying...`
1567
+ );
1568
+ await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, queryRetryCount)));
1569
+ return this.query<T>(query, variables, queryRetryCount);
1570
+ }
1571
+
1572
+ console.error(
1573
+ `[CloudflareGraphQL] GraphQL errors for ${operationName}:`,
1574
+ JSON.stringify(result.errors)
1575
+ );
1576
+ return null;
1577
+ }
1578
+
1579
+ // Log successful query with operation name (helpful for debugging empty results)
1580
+ console.log(`[CloudflareGraphQL] ${operationName} succeeded`);
1581
+
1582
+ return result.data ?? null;
1583
+ } catch (error) {
1584
+ console.error(`[CloudflareGraphQL] Query error for ${operationName}:`, error);
1585
+ return null;
1586
+ }
1587
+ }
1588
+
1589
+ /**
1590
+ * Get Workers metrics for all scripts
1591
+ */
1592
+ async getWorkersMetrics(period: TimePeriod): Promise<WorkersMetrics[]> {
1593
+ const { startDate, endDate } = this.getDateRange(period);
1594
+
1595
+ const queryStr = `
1596
+ query WorkersMetrics($accountTag: String!, $startDate: Date!, $endDate: Date!) {
1597
+ viewer {
1598
+ accounts(filter: { accountTag: $accountTag }) {
1599
+ workersInvocationsAdaptive(
1600
+ filter: {
1601
+ date_geq: $startDate
1602
+ date_leq: $endDate
1603
+ }
1604
+ limit: 100
1605
+ ) {
1606
+ dimensions {
1607
+ scriptName
1608
+ }
1609
+ sum {
1610
+ requests
1611
+ errors
1612
+ }
1613
+ quantiles {
1614
+ cpuTimeP50
1615
+ cpuTimeP99
1616
+ durationP50
1617
+ durationP99
1618
+ }
1619
+ }
1620
+ }
1621
+ }
1622
+ }
1623
+ `;
1624
+
1625
+ interface WorkersResponse {
1626
+ viewer?: {
1627
+ accounts?: Array<{
1628
+ workersInvocationsAdaptive?: Array<{
1629
+ dimensions?: { scriptName?: string };
1630
+ sum?: { requests?: number; errors?: number };
1631
+ quantiles?: {
1632
+ cpuTimeP50?: number;
1633
+ cpuTimeP99?: number;
1634
+ durationP50?: number;
1635
+ durationP99?: number;
1636
+ };
1637
+ }>;
1638
+ }>;
1639
+ };
1640
+ }
1641
+
1642
+ const data = await this.query<WorkersResponse>(queryStr, {
1643
+ accountTag: this.accountId,
1644
+ startDate,
1645
+ endDate,
1646
+ });
1647
+
1648
+ if (!data?.viewer?.accounts?.[0]?.workersInvocationsAdaptive) {
1649
+ console.log(
1650
+ `[CloudflareGraphQL] getWorkersMetrics empty - query for ${startDate} to ${endDate}, data path: viewer=${!!data?.viewer}, accounts=${!!data?.viewer?.accounts?.[0]}, data=${!!data?.viewer?.accounts?.[0]?.workersInvocationsAdaptive}`
1651
+ );
1652
+ return [];
1653
+ }
1654
+
1655
+ const results = data.viewer.accounts[0].workersInvocationsAdaptive;
1656
+ console.log(`[CloudflareGraphQL] getWorkersMetrics found ${results.length} workers`);
1657
+
1658
+ return results
1659
+ .filter((item) => item.dimensions?.scriptName)
1660
+ .map((item) => ({
1661
+ scriptName: item.dimensions!.scriptName!,
1662
+ requests: item.sum?.requests ?? 0,
1663
+ errors: item.sum?.errors ?? 0,
1664
+ // cpuTimeP50 from GraphQL is in microseconds, convert to milliseconds
1665
+ cpuTimeMs: (item.quantiles?.cpuTimeP50 ?? 0) / 1000,
1666
+ duration50thMs: item.quantiles?.durationP50 ?? 0,
1667
+ duration99thMs: item.quantiles?.durationP99 ?? 0,
1668
+ }));
1669
+ }
1670
+
1671
+ /**
1672
+ * Get D1 database metrics for all databases
1673
+ * Queries both d1AnalyticsAdaptiveGroups (operations) and d1StorageAdaptiveGroups (storage)
1674
+ *
1675
+ * Note: GraphQL only returns aggregated metrics, not per-database.
1676
+ * We need to fetch database list via REST API first.
1677
+ */
1678
+ async getD1Metrics(period: TimePeriod): Promise<D1Metrics[]> {
1679
+ const { startDate, endDate } = this.getDateRange(period);
1680
+
1681
+ // First, get list of D1 databases via REST API
1682
+ const databases = await this.listD1Databases();
1683
+
1684
+ const results: D1Metrics[] = [];
1685
+
1686
+ for (const db of databases) {
1687
+ const queryStr = `
1688
+ query D1Metrics($accountTag: String!, $databaseId: String!, $startDate: Date!, $endDate: Date!) {
1689
+ viewer {
1690
+ accounts(filter: { accountTag: $accountTag }) {
1691
+ d1AnalyticsAdaptiveGroups(
1692
+ filter: {
1693
+ databaseId: $databaseId
1694
+ date_geq: $startDate
1695
+ date_leq: $endDate
1696
+ }
1697
+ limit: 100
1698
+ ) {
1699
+ sum {
1700
+ rowsRead
1701
+ rowsWritten
1702
+ readQueries
1703
+ writeQueries
1704
+ }
1705
+ }
1706
+ d1StorageAdaptiveGroups(
1707
+ filter: {
1708
+ databaseId: $databaseId
1709
+ date_geq: $startDate
1710
+ date_leq: $endDate
1711
+ }
1712
+ limit: 1
1713
+ ) {
1714
+ max {
1715
+ databaseSizeBytes
1716
+ }
1717
+ }
1718
+ }
1719
+ }
1720
+ }
1721
+ `;
1722
+
1723
+ interface D1Response {
1724
+ viewer?: {
1725
+ accounts?: Array<{
1726
+ d1AnalyticsAdaptiveGroups?: Array<{
1727
+ sum?: {
1728
+ rowsRead?: number;
1729
+ rowsWritten?: number;
1730
+ readQueries?: number;
1731
+ writeQueries?: number;
1732
+ };
1733
+ }>;
1734
+ d1StorageAdaptiveGroups?: Array<{
1735
+ max?: {
1736
+ databaseSizeBytes?: number;
1737
+ };
1738
+ }>;
1739
+ }>;
1740
+ };
1741
+ }
1742
+
1743
+ const data = await this.query<D1Response>(queryStr, {
1744
+ accountTag: this.accountId,
1745
+ databaseId: db.id,
1746
+ startDate,
1747
+ endDate,
1748
+ });
1749
+
1750
+ const groups = data?.viewer?.accounts?.[0]?.d1AnalyticsAdaptiveGroups;
1751
+ const storageGroups = data?.viewer?.accounts?.[0]?.d1StorageAdaptiveGroups;
1752
+
1753
+ // Aggregate operations
1754
+ const totals = groups?.length
1755
+ ? groups.reduce(
1756
+ (acc, group) => ({
1757
+ rowsRead: acc.rowsRead + (group.sum?.rowsRead ?? 0),
1758
+ rowsWritten: acc.rowsWritten + (group.sum?.rowsWritten ?? 0),
1759
+ readQueries: acc.readQueries + (group.sum?.readQueries ?? 0),
1760
+ writeQueries: acc.writeQueries + (group.sum?.writeQueries ?? 0),
1761
+ }),
1762
+ { rowsRead: 0, rowsWritten: 0, readQueries: 0, writeQueries: 0 }
1763
+ )
1764
+ : { rowsRead: 0, rowsWritten: 0, readQueries: 0, writeQueries: 0 };
1765
+
1766
+ // Use storage from REST API (db.fileSize) as primary source
1767
+ // Fall back to GraphQL d1StorageAdaptiveGroups if REST doesn't have it
1768
+ const graphqlStorageBytes = storageGroups?.[0]?.max?.databaseSizeBytes ?? 0;
1769
+ const storageBytes = db.fileSize > 0 ? db.fileSize : graphqlStorageBytes;
1770
+
1771
+ // Only add if we have any data (operations OR storage)
1772
+ if (
1773
+ totals.rowsRead > 0 ||
1774
+ totals.rowsWritten > 0 ||
1775
+ totals.readQueries > 0 ||
1776
+ totals.writeQueries > 0 ||
1777
+ storageBytes > 0
1778
+ ) {
1779
+ results.push({
1780
+ databaseId: db.id,
1781
+ databaseName: db.name,
1782
+ ...totals,
1783
+ storageBytes,
1784
+ });
1785
+ }
1786
+ }
1787
+
1788
+ return results;
1789
+ }
1790
+
1791
+ /**
1792
+ * List D1 databases via REST API
1793
+ * Returns id, name, and file_size (storage bytes) from the API
1794
+ */
1795
+ private async listD1Databases(): Promise<Array<{ id: string; name: string; fileSize: number }>> {
1796
+ try {
1797
+ const response = await fetchWithRetry(
1798
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/d1/database`,
1799
+ {
1800
+ headers: {
1801
+ Authorization: `Bearer ${this.apiToken}`,
1802
+ 'Content-Type': 'application/json',
1803
+ },
1804
+ }
1805
+ );
1806
+
1807
+ if (!response.ok) {
1808
+ console.error(`[CloudflareGraphQL] D1 list error: ${response.status}`);
1809
+ return [];
1810
+ }
1811
+
1812
+ interface D1ListResponse {
1813
+ result?: Array<{ uuid: string; name: string; file_size?: number }>;
1814
+ success?: boolean;
1815
+ }
1816
+
1817
+ const data = (await response.json()) as D1ListResponse;
1818
+
1819
+ if (!data.success || !data.result) {
1820
+ return [];
1821
+ }
1822
+
1823
+ return data.result.map((db) => ({
1824
+ id: db.uuid,
1825
+ name: db.name,
1826
+ fileSize: db.file_size ?? 0,
1827
+ }));
1828
+ } catch (error) {
1829
+ console.error('[CloudflareGraphQL] D1 list error:', error);
1830
+ return [];
1831
+ }
1832
+ }
1833
+
1834
+ /**
1835
+ * Get KV namespace metrics
1836
+ * Queries both kvOperationsAdaptiveGroups (operations) and kvStorageAdaptiveGroups (storage)
1837
+ */
1838
+ async getKVMetrics(period: TimePeriod): Promise<KVMetrics[]> {
1839
+ const { startDate, endDate } = this.getDateRange(period);
1840
+
1841
+ // First, get list of KV namespaces via REST API
1842
+ const namespaces = await this.listKVNamespaces();
1843
+
1844
+ const results: KVMetrics[] = [];
1845
+
1846
+ for (const ns of namespaces) {
1847
+ const queryStr = `
1848
+ query KVMetrics($accountTag: String!, $namespaceId: String!, $startDate: Date!, $endDate: Date!) {
1849
+ viewer {
1850
+ accounts(filter: { accountTag: $accountTag }) {
1851
+ kvOperationsAdaptiveGroups(
1852
+ filter: {
1853
+ namespaceId: $namespaceId
1854
+ date_geq: $startDate
1855
+ date_leq: $endDate
1856
+ }
1857
+ limit: 100
1858
+ ) {
1859
+ sum {
1860
+ requests
1861
+ }
1862
+ dimensions {
1863
+ actionType
1864
+ }
1865
+ }
1866
+ kvStorageAdaptiveGroups(
1867
+ filter: {
1868
+ namespaceId: $namespaceId
1869
+ date_geq: $startDate
1870
+ date_leq: $endDate
1871
+ }
1872
+ limit: 1
1873
+ ) {
1874
+ max {
1875
+ byteCount
1876
+ keyCount
1877
+ }
1878
+ }
1879
+ }
1880
+ }
1881
+ }
1882
+ `;
1883
+
1884
+ interface KVResponse {
1885
+ viewer?: {
1886
+ accounts?: Array<{
1887
+ kvOperationsAdaptiveGroups?: Array<{
1888
+ sum?: { requests?: number };
1889
+ dimensions?: { actionType?: string };
1890
+ }>;
1891
+ kvStorageAdaptiveGroups?: Array<{
1892
+ max?: {
1893
+ byteCount?: number;
1894
+ keyCount?: number;
1895
+ };
1896
+ }>;
1897
+ }>;
1898
+ };
1899
+ }
1900
+
1901
+ const data = await this.query<KVResponse>(queryStr, {
1902
+ accountTag: this.accountId,
1903
+ namespaceId: this.formatUuidWithHyphens(ns.id),
1904
+ startDate,
1905
+ endDate,
1906
+ });
1907
+
1908
+ const operationsGroups = data?.viewer?.accounts?.[0]?.kvOperationsAdaptiveGroups;
1909
+ const storageGroups = data?.viewer?.accounts?.[0]?.kvStorageAdaptiveGroups;
1910
+
1911
+ // Aggregate operations by action type
1912
+ const totals = { reads: 0, writes: 0, deletes: 0, lists: 0 };
1913
+ if (operationsGroups?.length) {
1914
+ for (const group of operationsGroups) {
1915
+ const requests = group.sum?.requests ?? 0;
1916
+ const actionType = group.dimensions?.actionType?.toLowerCase() ?? '';
1917
+ if (actionType === 'read' || actionType === 'get') {
1918
+ totals.reads += requests;
1919
+ } else if (actionType === 'write' || actionType === 'put') {
1920
+ totals.writes += requests;
1921
+ } else if (actionType === 'delete') {
1922
+ totals.deletes += requests;
1923
+ } else if (actionType === 'list') {
1924
+ totals.lists += requests;
1925
+ }
1926
+ }
1927
+ }
1928
+
1929
+ // Extract storage metrics (most recent snapshot)
1930
+ const storageBytes = storageGroups?.[0]?.max?.byteCount ?? 0;
1931
+ const keyCount = storageGroups?.[0]?.max?.keyCount ?? 0;
1932
+
1933
+ // Only add if we have any data
1934
+ if (
1935
+ totals.reads > 0 ||
1936
+ totals.writes > 0 ||
1937
+ totals.deletes > 0 ||
1938
+ totals.lists > 0 ||
1939
+ storageBytes > 0 ||
1940
+ keyCount > 0
1941
+ ) {
1942
+ results.push({
1943
+ namespaceId: ns.id,
1944
+ namespaceName: ns.title,
1945
+ ...totals,
1946
+ storageBytes,
1947
+ keyCount,
1948
+ });
1949
+ }
1950
+ }
1951
+
1952
+ return results;
1953
+ }
1954
+
1955
+ /**
1956
+ * List KV namespaces via REST API
1957
+ */
1958
+ private async listKVNamespaces(): Promise<Array<{ id: string; title: string }>> {
1959
+ try {
1960
+ const response = await fetchWithRetry(
1961
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/storage/kv/namespaces`,
1962
+ {
1963
+ headers: {
1964
+ Authorization: `Bearer ${this.apiToken}`,
1965
+ 'Content-Type': 'application/json',
1966
+ },
1967
+ }
1968
+ );
1969
+
1970
+ if (!response.ok) {
1971
+ console.error(`[CloudflareGraphQL] KV list error: ${response.status}`);
1972
+ return [];
1973
+ }
1974
+
1975
+ interface KVListResponse {
1976
+ result?: Array<{ id: string; title: string }>;
1977
+ success?: boolean;
1978
+ }
1979
+
1980
+ const data = (await response.json()) as KVListResponse;
1981
+
1982
+ if (!data.success || !data.result) {
1983
+ return [];
1984
+ }
1985
+
1986
+ return data.result;
1987
+ } catch (error) {
1988
+ console.error('[CloudflareGraphQL] KV list error:', error);
1989
+ return [];
1990
+ }
1991
+ }
1992
+
1993
+ /**
1994
+ * Get R2 bucket metrics
1995
+ */
1996
+ async getR2Metrics(period: TimePeriod): Promise<R2Metrics[]> {
1997
+ const { startDate, endDate } = this.getDateRange(period);
1998
+ // R2 storage uses datetime filters (Time type), operations uses date filters (Date type)
1999
+ const datetimeStart = `${startDate}T00:00:00Z`;
2000
+ const datetimeEnd = `${endDate}T23:59:59Z`;
2001
+
2002
+ // Query 1: Operations (uses Date type filters)
2003
+ const operationsQuery = `
2004
+ query R2Operations($accountTag: String!, $startDate: Date!, $endDate: Date!) {
2005
+ viewer {
2006
+ accounts(filter: { accountTag: $accountTag }) {
2007
+ r2OperationsAdaptiveGroups(
2008
+ filter: {
2009
+ date_geq: $startDate
2010
+ date_leq: $endDate
2011
+ }
2012
+ limit: 100
2013
+ ) {
2014
+ dimensions {
2015
+ bucketName
2016
+ actionType
2017
+ }
2018
+ sum {
2019
+ requests
2020
+ responseObjectSize
2021
+ }
2022
+ }
2023
+ }
2024
+ }
2025
+ }
2026
+ `;
2027
+
2028
+ // Query 2: Storage (uses Time type filters per CF docs)
2029
+ const storageQuery = `
2030
+ query R2Storage($accountTag: String!, $datetimeStart: Time!, $datetimeEnd: Time!) {
2031
+ viewer {
2032
+ accounts(filter: { accountTag: $accountTag }) {
2033
+ r2StorageAdaptiveGroups(
2034
+ filter: {
2035
+ datetime_geq: $datetimeStart
2036
+ datetime_leq: $datetimeEnd
2037
+ }
2038
+ limit: 100
2039
+ ) {
2040
+ dimensions {
2041
+ bucketName
2042
+ }
2043
+ max {
2044
+ payloadSize
2045
+ }
2046
+ }
2047
+ }
2048
+ }
2049
+ }
2050
+ `;
2051
+
2052
+ interface R2OperationsResponse {
2053
+ viewer?: {
2054
+ accounts?: Array<{
2055
+ r2OperationsAdaptiveGroups?: Array<{
2056
+ dimensions?: { bucketName?: string; actionType?: string };
2057
+ sum?: { requests?: number; responseObjectSize?: number };
2058
+ }>;
2059
+ }>;
2060
+ };
2061
+ }
2062
+
2063
+ interface R2StorageResponse {
2064
+ viewer?: {
2065
+ accounts?: Array<{
2066
+ r2StorageAdaptiveGroups?: Array<{
2067
+ dimensions?: { bucketName?: string };
2068
+ max?: { payloadSize?: number };
2069
+ }>;
2070
+ }>;
2071
+ };
2072
+ }
2073
+
2074
+ // Execute both queries in parallel
2075
+ const [operationsData, storageData] = await Promise.all([
2076
+ this.query<R2OperationsResponse>(operationsQuery, {
2077
+ accountTag: this.accountId,
2078
+ startDate,
2079
+ endDate,
2080
+ }),
2081
+ this.query<R2StorageResponse>(storageQuery, {
2082
+ accountTag: this.accountId,
2083
+ datetimeStart,
2084
+ datetimeEnd,
2085
+ }).catch(() => null), // Storage query may fail if no data exists
2086
+ ]);
2087
+
2088
+ const operations = operationsData?.viewer?.accounts?.[0]?.r2OperationsAdaptiveGroups ?? [];
2089
+ const storage = storageData?.viewer?.accounts?.[0]?.r2StorageAdaptiveGroups ?? [];
2090
+
2091
+ // Aggregate by bucket
2092
+ const bucketMap = new Map<string, R2Metrics>();
2093
+
2094
+ // Class A: PUT, POST, DELETE, LIST, etc.
2095
+ // Class B: GET, HEAD
2096
+ const classAActions = new Set([
2097
+ 'PutObject',
2098
+ 'DeleteObject',
2099
+ 'ListObjects',
2100
+ 'ListObjectsV2',
2101
+ 'CreateMultipartUpload',
2102
+ 'CompleteMultipartUpload',
2103
+ 'AbortMultipartUpload',
2104
+ 'UploadPart',
2105
+ 'CopyObject',
2106
+ ]);
2107
+
2108
+ for (const op of operations) {
2109
+ const bucketName = op.dimensions?.bucketName ?? 'unknown';
2110
+ if (!bucketMap.has(bucketName)) {
2111
+ bucketMap.set(bucketName, {
2112
+ bucketName,
2113
+ classAOperations: 0,
2114
+ classBOperations: 0,
2115
+ storageBytes: 0,
2116
+ egressBytes: 0,
2117
+ });
2118
+ }
2119
+
2120
+ const bucket = bucketMap.get(bucketName)!;
2121
+ const requests = op.sum?.requests ?? 0;
2122
+ const actionType = op.dimensions?.actionType ?? '';
2123
+
2124
+ if (classAActions.has(actionType)) {
2125
+ bucket.classAOperations += requests;
2126
+ } else {
2127
+ bucket.classBOperations += requests;
2128
+ }
2129
+
2130
+ bucket.egressBytes += op.sum?.responseObjectSize ?? 0;
2131
+ }
2132
+
2133
+ for (const s of storage) {
2134
+ const bucketName = s.dimensions?.bucketName ?? 'unknown';
2135
+ if (bucketMap.has(bucketName)) {
2136
+ bucketMap.get(bucketName)!.storageBytes = s.max?.payloadSize ?? 0;
2137
+ }
2138
+ }
2139
+
2140
+ return Array.from(bucketMap.values());
2141
+ }
2142
+
2143
+ /**
2144
+ * Get Durable Objects metrics including duration (GB-seconds)
2145
+ * Includes per-script breakdown for project attribution via getDOMetricsForRange
2146
+ */
2147
+ async getDOMetrics(period: TimePeriod): Promise<DOMetrics> {
2148
+ // Delegate to getDOMetricsForRange to get per-script breakdown for project attribution
2149
+ const dateRange = this.getDateRange(period);
2150
+ return this.getDOMetricsForRange(dateRange);
2151
+ }
2152
+
2153
+ /**
2154
+ * Get Vectorize index info via REST API
2155
+ */
2156
+ async getVectorizeInfo(): Promise<VectorizeInfo[]> {
2157
+ try {
2158
+ const response = await fetchWithRetry(
2159
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/vectorize/v2/indexes`,
2160
+ {
2161
+ headers: {
2162
+ Authorization: `Bearer ${this.apiToken}`,
2163
+ 'Content-Type': 'application/json',
2164
+ },
2165
+ }
2166
+ );
2167
+
2168
+ if (!response.ok) {
2169
+ console.error(`[CloudflareGraphQL] Vectorize list error: ${response.status}`);
2170
+ return [];
2171
+ }
2172
+
2173
+ interface VectorizeListResponse {
2174
+ result?: Array<{
2175
+ name: string;
2176
+ config?: {
2177
+ dimensions?: number;
2178
+ };
2179
+ }>;
2180
+ success?: boolean;
2181
+ }
2182
+
2183
+ const data = (await response.json()) as VectorizeListResponse;
2184
+
2185
+ if (!data.success || !data.result) {
2186
+ return [];
2187
+ }
2188
+
2189
+ // For each index, get the vector count
2190
+ const results: VectorizeInfo[] = [];
2191
+
2192
+ for (const index of data.result) {
2193
+ // Get index info for vector count
2194
+ const infoResponse = await fetchWithRetry(
2195
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/vectorize/v2/indexes/${index.name}/info`,
2196
+ {
2197
+ headers: {
2198
+ Authorization: `Bearer ${this.apiToken}`,
2199
+ 'Content-Type': 'application/json',
2200
+ },
2201
+ }
2202
+ );
2203
+
2204
+ let vectorCount = 0;
2205
+ if (infoResponse.ok) {
2206
+ interface IndexInfoResponse {
2207
+ result?: { vectorsCount?: number };
2208
+ }
2209
+ const infoData = (await infoResponse.json()) as IndexInfoResponse;
2210
+ vectorCount = infoData.result?.vectorsCount ?? 0;
2211
+ }
2212
+
2213
+ results.push({
2214
+ name: index.name,
2215
+ vectorCount,
2216
+ dimensions: index.config?.dimensions ?? 0,
2217
+ });
2218
+ }
2219
+
2220
+ return results;
2221
+ } catch (error) {
2222
+ console.error('[CloudflareGraphQL] Vectorize error:', error);
2223
+ return [];
2224
+ }
2225
+ }
2226
+
2227
+ /**
2228
+ * Get AI Gateway model breakdown from logs API
2229
+ * Fetches logs and aggregates by provider+model
2230
+ */
2231
+ private async getAIGatewayModelBreakdown(
2232
+ gatewayId: string,
2233
+ startDate: string,
2234
+ endDate: string
2235
+ ): Promise<AIGatewayModelBreakdown[]> {
2236
+ const modelMap = new Map<string, AIGatewayModelBreakdown>();
2237
+
2238
+ try {
2239
+ // Fetch logs with pagination (API returns up to 50 per page)
2240
+ let page = 1;
2241
+ const perPage = 50;
2242
+ let hasMore = true;
2243
+ let totalFetched = 0;
2244
+ const maxLogs = 500; // Limit to avoid excessive API calls
2245
+
2246
+ while (hasMore && totalFetched < maxLogs) {
2247
+ const logsUrl = new URL(
2248
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/ai-gateway/gateways/${gatewayId}/logs`
2249
+ );
2250
+ logsUrl.searchParams.set('start_date', `${startDate}T00:00:00Z`);
2251
+ logsUrl.searchParams.set('end_date', `${endDate}T23:59:59Z`);
2252
+ logsUrl.searchParams.set('page', page.toString());
2253
+ logsUrl.searchParams.set('per_page', perPage.toString());
2254
+ logsUrl.searchParams.set('order_by', 'created_at');
2255
+ logsUrl.searchParams.set('order_by_direction', 'desc');
2256
+
2257
+ const logsResponse = await fetchWithRetry(logsUrl.toString(), {
2258
+ headers: {
2259
+ Authorization: `Bearer ${this.apiToken}`,
2260
+ 'Content-Type': 'application/json',
2261
+ },
2262
+ });
2263
+
2264
+ if (!logsResponse.ok) {
2265
+ const errBody = await logsResponse.text().catch(() => '');
2266
+ console.debug(
2267
+ `[CloudflareGraphQL] AI Gateway logs unavailable (${logsResponse.status}): ${errBody.slice(0, 200)}`
2268
+ );
2269
+ break;
2270
+ }
2271
+
2272
+ interface LogEntry {
2273
+ provider?: string;
2274
+ model?: string;
2275
+ tokens_in?: number;
2276
+ tokens_out?: number;
2277
+ cost?: number;
2278
+ cached?: boolean;
2279
+ success?: boolean;
2280
+ }
2281
+
2282
+ interface LogsResponse {
2283
+ result?: LogEntry[];
2284
+ success?: boolean;
2285
+ result_info?: { page?: number; per_page?: number; total_count?: number };
2286
+ }
2287
+
2288
+ const logsData = (await logsResponse.json()) as LogsResponse;
2289
+
2290
+ if (!logsData.success || !logsData.result || logsData.result.length === 0) {
2291
+ console.log(
2292
+ `[CloudflareGraphQL] AI Gateway ${gatewayId} logs: success=${logsData.success}, result.length=${logsData.result?.length ?? 'null'}, page=${page}`
2293
+ );
2294
+ break;
2295
+ }
2296
+
2297
+ // Aggregate by provider+model
2298
+ for (const log of logsData.result) {
2299
+ const provider = log.provider || 'unknown';
2300
+ const model = log.model || 'unknown';
2301
+ const key = `${provider}::${model}`;
2302
+
2303
+ const existing = modelMap.get(key);
2304
+ if (existing) {
2305
+ existing.requests += 1;
2306
+ existing.cachedRequests += log.cached ? 1 : 0;
2307
+ existing.tokensIn += log.tokens_in ?? 0;
2308
+ existing.tokensOut += log.tokens_out ?? 0;
2309
+ existing.costUsd += log.cost ?? 0;
2310
+ } else {
2311
+ modelMap.set(key, {
2312
+ provider,
2313
+ model,
2314
+ requests: 1,
2315
+ cachedRequests: log.cached ? 1 : 0,
2316
+ tokensIn: log.tokens_in ?? 0,
2317
+ tokensOut: log.tokens_out ?? 0,
2318
+ costUsd: log.cost ?? 0,
2319
+ });
2320
+ }
2321
+ }
2322
+
2323
+ totalFetched += logsData.result.length;
2324
+
2325
+ // Check if there are more pages
2326
+ const totalCount = logsData.result_info?.total_count ?? 0;
2327
+ hasMore = totalFetched < totalCount && logsData.result.length === perPage;
2328
+ page++;
2329
+ }
2330
+ } catch (error) {
2331
+ console.warn(`[CloudflareGraphQL] AI Gateway model breakdown error:`, error);
2332
+ }
2333
+
2334
+ // Sort by requests descending
2335
+ const result = Array.from(modelMap.values()).sort((a, b) => b.requests - a.requests);
2336
+ console.log(
2337
+ `[CloudflareGraphQL] AI Gateway ${gatewayId} model breakdown complete: ${result.length} models from logs`
2338
+ );
2339
+ return result;
2340
+ }
2341
+
2342
+ /**
2343
+ * Get AI Gateway metrics via REST API
2344
+ */
2345
+ async getAIGatewayMetrics(period: TimePeriod): Promise<AIGatewayMetrics[]> {
2346
+ try {
2347
+ // First, list all gateways
2348
+ const listResponse = await fetchWithRetry(
2349
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/ai-gateway/gateways`,
2350
+ {
2351
+ headers: {
2352
+ Authorization: `Bearer ${this.apiToken}`,
2353
+ 'Content-Type': 'application/json',
2354
+ },
2355
+ }
2356
+ );
2357
+
2358
+ if (!listResponse.ok) {
2359
+ console.error(`[CloudflareGraphQL] AI Gateway list error: ${listResponse.status}`);
2360
+ return [];
2361
+ }
2362
+
2363
+ interface GatewayListResponse {
2364
+ result?: Array<{ id: string; name: string }>;
2365
+ success?: boolean;
2366
+ }
2367
+
2368
+ const listData = (await listResponse.json()) as GatewayListResponse;
2369
+
2370
+ if (!listData.success || !listData.result) {
2371
+ return [];
2372
+ }
2373
+
2374
+ const { startDate, endDate } = this.getDateRange(period);
2375
+ const results: AIGatewayMetrics[] = [];
2376
+
2377
+ for (const gateway of listData.result) {
2378
+ // Get aggregate analytics and model breakdown in parallel
2379
+ const [analyticsResponse, modelBreakdown] = await Promise.all([
2380
+ fetchWithRetry(
2381
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/ai-gateway/gateways/${gateway.id}/analytics?` +
2382
+ `start=${startDate}T00:00:00Z&end=${endDate}T23:59:59Z`,
2383
+ {
2384
+ headers: {
2385
+ Authorization: `Bearer ${this.apiToken}`,
2386
+ 'Content-Type': 'application/json',
2387
+ },
2388
+ }
2389
+ ),
2390
+ this.getAIGatewayModelBreakdown(gateway.id, startDate, endDate),
2391
+ ]);
2392
+
2393
+ // Always include gateway if model breakdown was fetched, even if analytics fails
2394
+ // This ensures we don't lose model data due to analytics API issues
2395
+ if (analyticsResponse.ok) {
2396
+ interface AnalyticsResponse {
2397
+ result?: {
2398
+ totalRequests?: number;
2399
+ cachedRequests?: number;
2400
+ totalTokens?: number;
2401
+ cost?: number;
2402
+ };
2403
+ }
2404
+ const analyticsData = (await analyticsResponse.json()) as AnalyticsResponse;
2405
+
2406
+ results.push({
2407
+ gatewayId: gateway.id,
2408
+ totalRequests: analyticsData.result?.totalRequests ?? 0,
2409
+ cachedRequests: analyticsData.result?.cachedRequests ?? 0,
2410
+ totalTokens: analyticsData.result?.totalTokens ?? 0,
2411
+ estimatedCostUsd: analyticsData.result?.cost ?? 0,
2412
+ byModel: modelBreakdown.length > 0 ? modelBreakdown : undefined,
2413
+ });
2414
+ } else {
2415
+ // Analytics endpoint may not exist (404) - this is expected, fall back to model breakdown
2416
+ console.debug(
2417
+ `[CloudflareGraphQL] AI Gateway ${gateway.id} analytics API returned ${analyticsResponse.status}, using model breakdown only`
2418
+ );
2419
+ // Include gateway with model data even if analytics failed
2420
+ if (modelBreakdown.length > 0) {
2421
+ results.push({
2422
+ gatewayId: gateway.id,
2423
+ totalRequests: 0,
2424
+ cachedRequests: 0,
2425
+ totalTokens: 0,
2426
+ estimatedCostUsd: 0,
2427
+ byModel: modelBreakdown,
2428
+ });
2429
+ }
2430
+ }
2431
+ }
2432
+
2433
+ return results;
2434
+ } catch (error) {
2435
+ console.error('[CloudflareGraphQL] AI Gateway error:', error);
2436
+ return [];
2437
+ }
2438
+ }
2439
+
2440
+ /**
2441
+ * Get Pages project metrics
2442
+ */
2443
+ async getPagesMetrics(): Promise<PagesMetrics[]> {
2444
+ try {
2445
+ // List all Pages projects
2446
+ const response = await fetchWithRetry(
2447
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/pages/projects`,
2448
+ {
2449
+ headers: {
2450
+ Authorization: `Bearer ${this.apiToken}`,
2451
+ 'Content-Type': 'application/json',
2452
+ },
2453
+ }
2454
+ );
2455
+
2456
+ if (!response.ok) {
2457
+ console.error(`[CloudflareGraphQL] Pages list error: ${response.status}`);
2458
+ return [];
2459
+ }
2460
+
2461
+ interface PagesListResponse {
2462
+ result?: Array<{
2463
+ name: string;
2464
+ subdomain: string;
2465
+ latest_deployment?: {
2466
+ environment?: string;
2467
+ created_on?: string;
2468
+ };
2469
+ }>;
2470
+ success?: boolean;
2471
+ }
2472
+
2473
+ const data = (await response.json()) as PagesListResponse;
2474
+ const projects = data.result ?? [];
2475
+ const results: PagesMetrics[] = [];
2476
+
2477
+ // For each project, get deployment counts
2478
+ for (const project of projects) {
2479
+ // Get deployments for this project
2480
+ const deploymentsResponse = await fetchWithRetry(
2481
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/pages/projects/${project.name}/deployments`,
2482
+ {
2483
+ headers: {
2484
+ Authorization: `Bearer ${this.apiToken}`,
2485
+ 'Content-Type': 'application/json',
2486
+ },
2487
+ }
2488
+ );
2489
+
2490
+ let productionDeployments = 0;
2491
+ let previewDeployments = 0;
2492
+ let lastDeployedAt: string | null = null;
2493
+
2494
+ if (deploymentsResponse.ok) {
2495
+ interface DeploymentsResponse {
2496
+ result?: Array<{
2497
+ environment: string;
2498
+ created_on: string;
2499
+ }>;
2500
+ }
2501
+ const deploymentsData = (await deploymentsResponse.json()) as DeploymentsResponse;
2502
+ const deployments = deploymentsData.result ?? [];
2503
+
2504
+ productionDeployments = deployments.filter((d) => d.environment === 'production').length;
2505
+ previewDeployments = deployments.filter((d) => d.environment === 'preview').length;
2506
+
2507
+ if (deployments.length > 0 && deployments[0]) {
2508
+ lastDeployedAt = deployments[0].created_on;
2509
+ }
2510
+ }
2511
+
2512
+ results.push({
2513
+ projectName: project.name,
2514
+ subdomain: project.subdomain,
2515
+ productionDeployments,
2516
+ previewDeployments,
2517
+ totalBuilds: productionDeployments + previewDeployments,
2518
+ lastDeployedAt,
2519
+ });
2520
+ }
2521
+
2522
+ return results;
2523
+ } catch (error) {
2524
+ console.error('[CloudflareGraphQL] Pages error:', error);
2525
+ return [];
2526
+ }
2527
+ }
2528
+
2529
+ /**
2530
+ * Get Workflows execution metrics from GraphQL API
2531
+ * Queries the workflowsAdaptiveGroups dataset for execution counts and timing
2532
+ */
2533
+ async getWorkflowsMetrics(period: TimePeriod): Promise<WorkflowsSummary> {
2534
+ try {
2535
+ const { startDate, endDate } = this.getDateRange(period);
2536
+
2537
+ const query = `
2538
+ query WorkflowsMetrics($accountTag: String!, $startDate: Date!, $endDate: Date!) {
2539
+ viewer {
2540
+ accounts(filter: { accountTag: $accountTag }) {
2541
+ workflowsAdaptiveGroups(
2542
+ filter: {
2543
+ date_geq: $startDate
2544
+ date_leq: $endDate
2545
+ }
2546
+ limit: 10000
2547
+ ) {
2548
+ dimensions {
2549
+ workflowName
2550
+ eventType
2551
+ }
2552
+ sum {
2553
+ wallTime
2554
+ cpuTime
2555
+ }
2556
+ count
2557
+ }
2558
+ }
2559
+ }
2560
+ }
2561
+ `;
2562
+
2563
+ const response = await fetchWithRetry(GRAPHQL_ENDPOINT, {
2564
+ method: 'POST',
2565
+ headers: {
2566
+ Authorization: `Bearer ${this.apiToken}`,
2567
+ 'Content-Type': 'application/json',
2568
+ },
2569
+ body: JSON.stringify({
2570
+ query,
2571
+ variables: {
2572
+ accountTag: this.accountId,
2573
+ startDate,
2574
+ endDate,
2575
+ },
2576
+ }),
2577
+ });
2578
+
2579
+ if (!response.ok) {
2580
+ console.error(`[CloudflareGraphQL] Workflows GraphQL error: ${response.status}`);
2581
+ return this.emptyWorkflowsSummary();
2582
+ }
2583
+
2584
+ interface WorkflowsGraphQLResponse {
2585
+ data?: {
2586
+ viewer?: {
2587
+ accounts?: Array<{
2588
+ workflowsAdaptiveGroups?: Array<{
2589
+ dimensions?: {
2590
+ workflowName?: string;
2591
+ eventType?: string;
2592
+ };
2593
+ sum?: {
2594
+ wallTime?: number;
2595
+ cpuTime?: number;
2596
+ };
2597
+ count?: number;
2598
+ }>;
2599
+ }>;
2600
+ };
2601
+ };
2602
+ errors?: Array<{ message: string }>;
2603
+ }
2604
+
2605
+ const data = (await response.json()) as WorkflowsGraphQLResponse;
2606
+
2607
+ if (data.errors) {
2608
+ console.error('[CloudflareGraphQL] Workflows GraphQL errors:', data.errors);
2609
+ return this.emptyWorkflowsSummary();
2610
+ }
2611
+
2612
+ const groups = data.data?.viewer?.accounts?.[0]?.workflowsAdaptiveGroups ?? [];
2613
+
2614
+ // Aggregate by workflow
2615
+ const workflowMap = new Map<string, WorkflowsMetrics>();
2616
+
2617
+ for (const group of groups) {
2618
+ const workflowName = group.dimensions?.workflowName ?? 'unknown';
2619
+ const eventType = group.dimensions?.eventType ?? '';
2620
+ const count = group.count ?? 0;
2621
+ const wallTime = group.sum?.wallTime ?? 0;
2622
+ const cpuTime = group.sum?.cpuTime ?? 0;
2623
+
2624
+ if (!workflowMap.has(workflowName)) {
2625
+ workflowMap.set(workflowName, {
2626
+ workflowName,
2627
+ executions: 0,
2628
+ successes: 0,
2629
+ failures: 0,
2630
+ wallTimeMs: 0,
2631
+ cpuTimeMs: 0,
2632
+ });
2633
+ }
2634
+
2635
+ const metrics = workflowMap.get(workflowName)!;
2636
+
2637
+ // Map event types to metrics
2638
+ if (eventType === 'WORKFLOW_START') {
2639
+ metrics.executions += count;
2640
+ } else if (eventType === 'WORKFLOW_SUCCESS') {
2641
+ metrics.successes += count;
2642
+ metrics.wallTimeMs += wallTime;
2643
+ } else if (eventType === 'WORKFLOW_FAILURE' || eventType === 'WORKFLOW_ERROR') {
2644
+ metrics.failures += count;
2645
+ }
2646
+ // WORKFLOW_RUNNING events only have cpuTime, add to total
2647
+ if (eventType === 'WORKFLOW_RUNNING') {
2648
+ metrics.cpuTimeMs += cpuTime;
2649
+ }
2650
+ }
2651
+
2652
+ const byWorkflow = Array.from(workflowMap.values());
2653
+
2654
+ // Calculate totals
2655
+ const summary: WorkflowsSummary = {
2656
+ totalExecutions: byWorkflow.reduce((sum, w) => sum + w.executions, 0),
2657
+ totalSuccesses: byWorkflow.reduce((sum, w) => sum + w.successes, 0),
2658
+ totalFailures: byWorkflow.reduce((sum, w) => sum + w.failures, 0),
2659
+ totalWallTimeMs: byWorkflow.reduce((sum, w) => sum + w.wallTimeMs, 0),
2660
+ totalCpuTimeMs: byWorkflow.reduce((sum, w) => sum + w.cpuTimeMs, 0),
2661
+ byWorkflow,
2662
+ };
2663
+
2664
+ return summary;
2665
+ } catch (error) {
2666
+ console.error('[CloudflareGraphQL] Workflows error:', error);
2667
+ return this.emptyWorkflowsSummary();
2668
+ }
2669
+ }
2670
+
2671
+ /**
2672
+ * Returns an empty WorkflowsSummary for error cases
2673
+ */
2674
+ private emptyWorkflowsSummary(): WorkflowsSummary {
2675
+ return {
2676
+ totalExecutions: 0,
2677
+ totalSuccesses: 0,
2678
+ totalFailures: 0,
2679
+ totalWallTimeMs: 0,
2680
+ totalCpuTimeMs: 0,
2681
+ byWorkflow: [],
2682
+ };
2683
+ }
2684
+
2685
+ /**
2686
+ * Get all metrics for the account
2687
+ */
2688
+ async getAllMetrics(period: TimePeriod): Promise<AccountUsage> {
2689
+ // Run all queries in parallel for better performance
2690
+ const [workers, d1, kv, r2, durableObjects, vectorize, aiGateway, pages] = await Promise.all([
2691
+ this.getWorkersMetrics(period),
2692
+ this.getD1Metrics(period),
2693
+ this.getKVMetrics(period),
2694
+ this.getR2Metrics(period),
2695
+ this.getDOMetrics(period),
2696
+ this.getVectorizeInfo(),
2697
+ this.getAIGatewayMetrics(period),
2698
+ this.getPagesMetrics(),
2699
+ ]);
2700
+
2701
+ return {
2702
+ period,
2703
+ workers,
2704
+ d1,
2705
+ kv,
2706
+ r2,
2707
+ durableObjects,
2708
+ vectorize,
2709
+ aiGateway,
2710
+ pages,
2711
+ };
2712
+ }
2713
+
2714
+ /**
2715
+ * Calculate trend from current and previous values
2716
+ */
2717
+ private calculateTrend(
2718
+ current: number,
2719
+ previous: number
2720
+ ): { trend: 'up' | 'down' | 'stable'; percentChange: number } {
2721
+ if (previous === 0) {
2722
+ return { trend: current > 0 ? 'up' : 'stable', percentChange: current > 0 ? 100 : 0 };
2723
+ }
2724
+
2725
+ const percentChange = ((current - previous) / previous) * 100;
2726
+
2727
+ let trend: 'up' | 'down' | 'stable' = 'stable';
2728
+ if (percentChange > 5) trend = 'up';
2729
+ else if (percentChange < -5) trend = 'down';
2730
+
2731
+ return { trend, percentChange: Math.round(percentChange * 10) / 10 };
2732
+ }
2733
+
2734
+ /**
2735
+ * Get Workers sparkline data (daily data points for the period)
2736
+ */
2737
+ async getWorkersSparklineData(period: TimePeriod): Promise<{
2738
+ requests: SparklineData;
2739
+ errors: SparklineData;
2740
+ }> {
2741
+ const { startDate, endDate } = this.getDateRange(period);
2742
+
2743
+ const queryStr = `
2744
+ query WorkersSparkline($accountTag: String!, $startDate: Date!, $endDate: Date!) {
2745
+ viewer {
2746
+ accounts(filter: { accountTag: $accountTag }) {
2747
+ workersInvocationsAdaptive(
2748
+ filter: {
2749
+ date_geq: $startDate
2750
+ date_leq: $endDate
2751
+ }
2752
+ limit: 10000
2753
+ orderBy: [date_ASC]
2754
+ ) {
2755
+ dimensions {
2756
+ date
2757
+ }
2758
+ sum {
2759
+ requests
2760
+ errors
2761
+ }
2762
+ }
2763
+ }
2764
+ }
2765
+ }
2766
+ `;
2767
+
2768
+ interface SparklineResponse {
2769
+ viewer?: {
2770
+ accounts?: Array<{
2771
+ workersInvocationsAdaptive?: Array<{
2772
+ dimensions?: { date?: string };
2773
+ sum?: { requests?: number; errors?: number };
2774
+ }>;
2775
+ }>;
2776
+ };
2777
+ }
2778
+
2779
+ const data = await this.query<SparklineResponse>(queryStr, {
2780
+ accountTag: this.accountId,
2781
+ startDate,
2782
+ endDate,
2783
+ });
2784
+
2785
+ const items = data?.viewer?.accounts?.[0]?.workersInvocationsAdaptive ?? [];
2786
+
2787
+ // Aggregate by date
2788
+ const byDate = new Map<string, { requests: number; errors: number }>();
2789
+ for (const item of items) {
2790
+ const date = item.dimensions?.date;
2791
+ if (!date) continue;
2792
+
2793
+ const existing = byDate.get(date) ?? { requests: 0, errors: 0 };
2794
+ existing.requests += item.sum?.requests ?? 0;
2795
+ existing.errors += item.sum?.errors ?? 0;
2796
+ byDate.set(date, existing);
2797
+ }
2798
+
2799
+ // Convert to sparkline points
2800
+ const requestPoints: SparklinePoint[] = [];
2801
+ const errorPoints: SparklinePoint[] = [];
2802
+
2803
+ const sortedDates = Array.from(byDate.keys()).sort();
2804
+ for (const date of sortedDates) {
2805
+ const vals = byDate.get(date)!;
2806
+ requestPoints.push({ date, value: vals.requests });
2807
+ errorPoints.push({ date, value: vals.errors });
2808
+ }
2809
+
2810
+ // Calculate trends (compare first half to second half)
2811
+ const midpoint = Math.floor(requestPoints.length / 2);
2812
+ const firstHalfRequests = requestPoints.slice(0, midpoint).reduce((s, p) => s + p.value, 0);
2813
+ const secondHalfRequests = requestPoints.slice(midpoint).reduce((s, p) => s + p.value, 0);
2814
+ const firstHalfErrors = errorPoints.slice(0, midpoint).reduce((s, p) => s + p.value, 0);
2815
+ const secondHalfErrors = errorPoints.slice(midpoint).reduce((s, p) => s + p.value, 0);
2816
+
2817
+ const requestsTrend = this.calculateTrend(secondHalfRequests, firstHalfRequests);
2818
+ const errorsTrend = this.calculateTrend(secondHalfErrors, firstHalfErrors);
2819
+
2820
+ return {
2821
+ requests: {
2822
+ metricName: 'Workers Requests',
2823
+ points: requestPoints,
2824
+ ...requestsTrend,
2825
+ },
2826
+ errors: {
2827
+ metricName: 'Workers Errors',
2828
+ points: errorPoints,
2829
+ ...errorsTrend,
2830
+ },
2831
+ };
2832
+ }
2833
+
2834
+ /**
2835
+ * Get D1 sparkline data (daily data points)
2836
+ */
2837
+ async getD1SparklineData(period: TimePeriod): Promise<SparklineData> {
2838
+ const { startDate, endDate } = this.getDateRange(period);
2839
+ const databases = await this.listD1Databases();
2840
+
2841
+ // Aggregate data from all databases by date
2842
+ const byDate = new Map<string, number>();
2843
+
2844
+ for (const db of databases) {
2845
+ const queryStr = `
2846
+ query D1Sparkline($accountTag: String!, $databaseId: String!, $startDate: Date!, $endDate: Date!) {
2847
+ viewer {
2848
+ accounts(filter: { accountTag: $accountTag }) {
2849
+ d1AnalyticsAdaptiveGroups(
2850
+ filter: {
2851
+ databaseId: $databaseId
2852
+ date_geq: $startDate
2853
+ date_leq: $endDate
2854
+ }
2855
+ limit: 1000
2856
+ orderBy: [date_ASC]
2857
+ ) {
2858
+ dimensions {
2859
+ date
2860
+ }
2861
+ sum {
2862
+ rowsRead
2863
+ }
2864
+ }
2865
+ }
2866
+ }
2867
+ }
2868
+ `;
2869
+
2870
+ interface D1SparklineResponse {
2871
+ viewer?: {
2872
+ accounts?: Array<{
2873
+ d1AnalyticsAdaptiveGroups?: Array<{
2874
+ dimensions?: { date?: string };
2875
+ sum?: { rowsRead?: number };
2876
+ }>;
2877
+ }>;
2878
+ };
2879
+ }
2880
+
2881
+ const data = await this.query<D1SparklineResponse>(queryStr, {
2882
+ accountTag: this.accountId,
2883
+ databaseId: db.id,
2884
+ startDate,
2885
+ endDate,
2886
+ });
2887
+
2888
+ const groups = data?.viewer?.accounts?.[0]?.d1AnalyticsAdaptiveGroups ?? [];
2889
+ for (const g of groups) {
2890
+ const date = g.dimensions?.date;
2891
+ if (!date) continue;
2892
+ byDate.set(date, (byDate.get(date) ?? 0) + (g.sum?.rowsRead ?? 0));
2893
+ }
2894
+ }
2895
+
2896
+ const points: SparklinePoint[] = Array.from(byDate.entries())
2897
+ .sort(([a], [b]) => a.localeCompare(b))
2898
+ .map(([date, value]) => ({ date, value }));
2899
+
2900
+ const midpoint = Math.floor(points.length / 2);
2901
+ const firstHalf = points.slice(0, midpoint).reduce((s, p) => s + p.value, 0);
2902
+ const secondHalf = points.slice(midpoint).reduce((s, p) => s + p.value, 0);
2903
+ const trend = this.calculateTrend(secondHalf, firstHalf);
2904
+
2905
+ return {
2906
+ metricName: 'D1 Rows Read',
2907
+ points,
2908
+ ...trend,
2909
+ };
2910
+ }
2911
+
2912
+ /**
2913
+ * Get KV sparkline data (daily data points)
2914
+ */
2915
+ async getKVSparklineData(period: TimePeriod): Promise<SparklineData> {
2916
+ const { startDate, endDate } = this.getDateRange(period);
2917
+ const namespaces = await this.listKVNamespaces();
2918
+
2919
+ const byDate = new Map<string, number>();
2920
+
2921
+ for (const ns of namespaces) {
2922
+ const queryStr = `
2923
+ query KVSparkline($accountTag: String!, $namespaceId: String!, $startDate: Date!, $endDate: Date!) {
2924
+ viewer {
2925
+ accounts(filter: { accountTag: $accountTag }) {
2926
+ kvOperationsAdaptiveGroups(
2927
+ filter: {
2928
+ namespaceId: $namespaceId
2929
+ date_geq: $startDate
2930
+ date_leq: $endDate
2931
+ }
2932
+ limit: 1000
2933
+ orderBy: [date_ASC]
2934
+ ) {
2935
+ dimensions {
2936
+ date
2937
+ actionType
2938
+ }
2939
+ sum {
2940
+ requests
2941
+ }
2942
+ }
2943
+ }
2944
+ }
2945
+ }
2946
+ `;
2947
+
2948
+ interface KVSparklineResponse {
2949
+ viewer?: {
2950
+ accounts?: Array<{
2951
+ kvOperationsAdaptiveGroups?: Array<{
2952
+ dimensions?: { date?: string; actionType?: string };
2953
+ sum?: { requests?: number };
2954
+ }>;
2955
+ }>;
2956
+ };
2957
+ }
2958
+
2959
+ const data = await this.query<KVSparklineResponse>(queryStr, {
2960
+ accountTag: this.accountId,
2961
+ namespaceId: this.formatUuidWithHyphens(ns.id),
2962
+ startDate,
2963
+ endDate,
2964
+ });
2965
+
2966
+ const groups = data?.viewer?.accounts?.[0]?.kvOperationsAdaptiveGroups ?? [];
2967
+ for (const g of groups) {
2968
+ const date = g.dimensions?.date;
2969
+ const actionType = g.dimensions?.actionType?.toLowerCase() ?? '';
2970
+ // Only count read operations for sparkline
2971
+ if (!date || (actionType !== 'read' && actionType !== 'get')) continue;
2972
+ byDate.set(date, (byDate.get(date) ?? 0) + (g.sum?.requests ?? 0));
2973
+ }
2974
+ }
2975
+
2976
+ const points: SparklinePoint[] = Array.from(byDate.entries())
2977
+ .sort(([a], [b]) => a.localeCompare(b))
2978
+ .map(([date, value]) => ({ date, value }));
2979
+
2980
+ const midpoint = Math.floor(points.length / 2);
2981
+ const firstHalf = points.slice(0, midpoint).reduce((s, p) => s + p.value, 0);
2982
+ const secondHalf = points.slice(midpoint).reduce((s, p) => s + p.value, 0);
2983
+ const trend = this.calculateTrend(secondHalf, firstHalf);
2984
+
2985
+ return {
2986
+ metricName: 'KV Reads',
2987
+ points,
2988
+ ...trend,
2989
+ };
2990
+ }
2991
+
2992
+ /**
2993
+ * Get detailed Workers error breakdown with status codes and latency
2994
+ */
2995
+ async getWorkersErrorBreakdown(period: TimePeriod): Promise<WorkersErrorBreakdown[]> {
2996
+ const { startDate, endDate } = this.getDateRange(period);
2997
+
2998
+ const queryStr = `
2999
+ query WorkersErrorBreakdown($accountTag: String!, $startDate: Date!, $endDate: Date!) {
3000
+ viewer {
3001
+ accounts(filter: { accountTag: $accountTag }) {
3002
+ workersInvocationsAdaptive(
3003
+ filter: {
3004
+ date_geq: $startDate
3005
+ date_leq: $endDate
3006
+ }
3007
+ limit: 100
3008
+ ) {
3009
+ dimensions {
3010
+ scriptName
3011
+ status
3012
+ }
3013
+ sum {
3014
+ requests
3015
+ errors
3016
+ subrequests
3017
+ }
3018
+ quantiles {
3019
+ durationP50
3020
+ durationP99
3021
+ cpuTimeP50
3022
+ cpuTimeP99
3023
+ }
3024
+ }
3025
+ }
3026
+ }
3027
+ }
3028
+ `;
3029
+
3030
+ interface ErrorBreakdownResponse {
3031
+ viewer?: {
3032
+ accounts?: Array<{
3033
+ workersInvocationsAdaptive?: Array<{
3034
+ dimensions?: { scriptName?: string; status?: number };
3035
+ sum?: { requests?: number; errors?: number; subrequests?: number };
3036
+ quantiles?: {
3037
+ durationP50?: number;
3038
+ durationP99?: number;
3039
+ cpuTimeP50?: number;
3040
+ cpuTimeP99?: number;
3041
+ };
3042
+ }>;
3043
+ }>;
3044
+ };
3045
+ }
3046
+
3047
+ const data = await this.query<ErrorBreakdownResponse>(queryStr, {
3048
+ accountTag: this.accountId,
3049
+ startDate,
3050
+ endDate,
3051
+ });
3052
+
3053
+ const items = data?.viewer?.accounts?.[0]?.workersInvocationsAdaptive ?? [];
3054
+
3055
+ // Aggregate by script
3056
+ const byScript = new Map<
3057
+ string,
3058
+ {
3059
+ totalRequests: number;
3060
+ totalErrors: number;
3061
+ errors4xx: number;
3062
+ errors5xx: number;
3063
+ subrequests: number;
3064
+ latencyP50Ms: number;
3065
+ latencyP99Ms: number;
3066
+ cpuTimeP50Ms: number;
3067
+ cpuTimeP99Ms: number;
3068
+ count: number;
3069
+ }
3070
+ >();
3071
+
3072
+ for (const item of items) {
3073
+ const scriptName = item.dimensions?.scriptName;
3074
+ if (!scriptName) continue;
3075
+
3076
+ const status = item.dimensions?.status ?? 0;
3077
+ const requests = item.sum?.requests ?? 0;
3078
+ const errors = item.sum?.errors ?? 0;
3079
+
3080
+ const existing = byScript.get(scriptName) ?? {
3081
+ totalRequests: 0,
3082
+ totalErrors: 0,
3083
+ errors4xx: 0,
3084
+ errors5xx: 0,
3085
+ subrequests: 0,
3086
+ latencyP50Ms: 0,
3087
+ latencyP99Ms: 0,
3088
+ cpuTimeP50Ms: 0,
3089
+ cpuTimeP99Ms: 0,
3090
+ count: 0,
3091
+ };
3092
+
3093
+ existing.totalRequests += requests;
3094
+ existing.totalErrors += errors;
3095
+ existing.subrequests += item.sum?.subrequests ?? 0;
3096
+
3097
+ // Categorise by status code
3098
+ if (status >= 400 && status < 500) {
3099
+ existing.errors4xx += requests;
3100
+ } else if (status >= 500) {
3101
+ existing.errors5xx += requests;
3102
+ }
3103
+
3104
+ // Average latencies (we'll average them across all entries)
3105
+ existing.latencyP50Ms += item.quantiles?.durationP50 ?? 0;
3106
+ existing.latencyP99Ms += item.quantiles?.durationP99 ?? 0;
3107
+ existing.cpuTimeP50Ms += item.quantiles?.cpuTimeP50 ?? 0;
3108
+ existing.cpuTimeP99Ms += item.quantiles?.cpuTimeP99 ?? 0;
3109
+ existing.count++;
3110
+
3111
+ byScript.set(scriptName, existing);
3112
+ }
3113
+
3114
+ return Array.from(byScript.entries()).map(([scriptName, data]) => ({
3115
+ scriptName,
3116
+ totalRequests: data.totalRequests,
3117
+ totalErrors: data.totalErrors,
3118
+ errorRate:
3119
+ data.totalRequests > 0
3120
+ ? Math.round((data.totalErrors / data.totalRequests) * 10000) / 100
3121
+ : 0,
3122
+ errors4xx: data.errors4xx,
3123
+ errors5xx: data.errors5xx,
3124
+ latencyP50Ms: data.count > 0 ? Math.round(data.latencyP50Ms / data.count) : 0,
3125
+ latencyP99Ms: data.count > 0 ? Math.round(data.latencyP99Ms / data.count) : 0,
3126
+ cpuTimeP50Ms: data.count > 0 ? Math.round(data.cpuTimeP50Ms / data.count) : 0,
3127
+ cpuTimeP99Ms: data.count > 0 ? Math.round(data.cpuTimeP99Ms / data.count) : 0,
3128
+ subrequests: data.subrequests,
3129
+ }));
3130
+ }
3131
+
3132
+ /**
3133
+ * Get Queues metrics via GraphQL
3134
+ *
3135
+ * Uses queueMessageOperationsAdaptiveGroups with actionType dimension.
3136
+ * Per Cloudflare docs: https://developers.cloudflare.com/queues/observability/metrics/
3137
+ * - actionType: WriteMessage (produced), ReadMessage/DeleteMessage (consumed)
3138
+ * - count: number of operations
3139
+ * - sum: billableOperations, bytes (NOT messages)
3140
+ * - avg: lagTime, retryCount
3141
+ *
3142
+ * queueConsumerMetricsAdaptiveGroups only has avg.concurrency, not acked/retried.
3143
+ */
3144
+ async getQueuesMetrics(period: TimePeriod): Promise<QueuesMetrics[]> {
3145
+ const { startDate, endDate } = this.getDateRange(period);
3146
+
3147
+ // First, list all queues via REST API
3148
+ const queues = await this.listQueues();
3149
+ if (queues.length === 0) return [];
3150
+
3151
+ const results: QueuesMetrics[] = [];
3152
+
3153
+ for (const queue of queues) {
3154
+ // Build datetime range (ISO 8601 format for Time type)
3155
+ const datetimeStart = `${startDate}T00:00:00Z`;
3156
+ const datetimeEnd = `${endDate}T23:59:59Z`;
3157
+
3158
+ // Query message operations with actionType dimension (correct schema per CF docs)
3159
+ const messageOpsQuery = `
3160
+ query QueueMessageOperations($accountTag: String!, $queueId: String!, $datetimeStart: Time!, $datetimeEnd: Time!) {
3161
+ viewer {
3162
+ accounts(filter: { accountTag: $accountTag }) {
3163
+ queueMessageOperationsAdaptiveGroups(
3164
+ filter: {
3165
+ queueId: $queueId
3166
+ datetime_geq: $datetimeStart
3167
+ datetime_leq: $datetimeEnd
3168
+ }
3169
+ limit: 1000
3170
+ ) {
3171
+ count
3172
+ dimensions {
3173
+ actionType
3174
+ }
3175
+ avg {
3176
+ lagTime
3177
+ retryCount
3178
+ }
3179
+ }
3180
+ }
3181
+ }
3182
+ }
3183
+ `;
3184
+
3185
+ // Query consumer concurrency (only metric available in queueConsumerMetricsAdaptiveGroups)
3186
+ const consumerQuery = `
3187
+ query QueueConsumerConcurrency($accountTag: String!, $queueId: String!, $datetimeStart: Time!, $datetimeEnd: Time!) {
3188
+ viewer {
3189
+ accounts(filter: { accountTag: $accountTag }) {
3190
+ queueConsumerMetricsAdaptiveGroups(
3191
+ filter: {
3192
+ queueId: $queueId
3193
+ datetime_geq: $datetimeStart
3194
+ datetime_leq: $datetimeEnd
3195
+ }
3196
+ limit: 100
3197
+ ) {
3198
+ avg {
3199
+ concurrency
3200
+ }
3201
+ }
3202
+ }
3203
+ }
3204
+ }
3205
+ `;
3206
+
3207
+ interface MessageOpsResponse {
3208
+ viewer?: {
3209
+ accounts?: Array<{
3210
+ queueMessageOperationsAdaptiveGroups?: Array<{
3211
+ count?: number;
3212
+ dimensions?: { actionType?: string };
3213
+ avg?: { lagTime?: number; retryCount?: number };
3214
+ }>;
3215
+ }>;
3216
+ };
3217
+ }
3218
+
3219
+ interface ConsumerResponse {
3220
+ viewer?: {
3221
+ accounts?: Array<{
3222
+ queueConsumerMetricsAdaptiveGroups?: Array<{
3223
+ avg?: { concurrency?: number };
3224
+ }>;
3225
+ }>;
3226
+ };
3227
+ }
3228
+
3229
+ try {
3230
+ const [messageOpsData, consumerData] = await Promise.all([
3231
+ this.query<MessageOpsResponse>(messageOpsQuery, {
3232
+ accountTag: this.accountId,
3233
+ queueId: queue.id,
3234
+ datetimeStart,
3235
+ datetimeEnd,
3236
+ }),
3237
+ this.query<ConsumerResponse>(consumerQuery, {
3238
+ accountTag: this.accountId,
3239
+ queueId: queue.id,
3240
+ datetimeStart,
3241
+ datetimeEnd,
3242
+ }),
3243
+ ]);
3244
+
3245
+ const opsGroups =
3246
+ messageOpsData?.viewer?.accounts?.[0]?.queueMessageOperationsAdaptiveGroups ?? [];
3247
+ const consumerGroups =
3248
+ consumerData?.viewer?.accounts?.[0]?.queueConsumerMetricsAdaptiveGroups ?? [];
3249
+
3250
+ // Aggregate message operations by actionType
3251
+ let produced = 0;
3252
+ let consumed = 0;
3253
+ let totalLagTime = 0;
3254
+ let totalRetryCount = 0;
3255
+ let opsCount = 0;
3256
+
3257
+ for (const g of opsGroups) {
3258
+ const count = g.count ?? 0;
3259
+ const actionType = g.dimensions?.actionType ?? '';
3260
+
3261
+ // WriteMessage = produced, ReadMessage/DeleteMessage = consumed
3262
+ if (actionType === 'WriteMessage') {
3263
+ produced += count;
3264
+ } else if (actionType === 'ReadMessage' || actionType === 'DeleteMessage') {
3265
+ consumed += count;
3266
+ }
3267
+
3268
+ // Track averages for processing time estimate
3269
+ totalLagTime += (g.avg?.lagTime ?? 0) * count;
3270
+ totalRetryCount += (g.avg?.retryCount ?? 0) * count;
3271
+ opsCount += count;
3272
+ }
3273
+
3274
+ // Aggregate consumer concurrency (reserved for future concurrency metrics)
3275
+ let _avgConcurrency = 0;
3276
+ let _concurrencyCount = 0;
3277
+ for (const g of consumerGroups) {
3278
+ _avgConcurrency += g.avg?.concurrency ?? 0;
3279
+ _concurrencyCount++;
3280
+ }
3281
+
3282
+ // Calculate average lag time in ms (lagTime is in ms per CF docs)
3283
+ const avgLagTimeMs = opsCount > 0 ? Math.round(totalLagTime / opsCount) : 0;
3284
+
3285
+ results.push({
3286
+ queueId: queue.id,
3287
+ queueName: queue.name,
3288
+ messagesProduced: produced,
3289
+ messagesConsumed: consumed,
3290
+ messagesAcked: consumed, // Use consumed as acked (acked field doesn't exist)
3291
+ messagesRetried: opsCount > 0 ? Math.round(totalRetryCount / opsCount) : 0,
3292
+ messagesFailed: 0, // Not available in current schema (would need outcome dimension)
3293
+ backlogSize: Math.max(0, produced - consumed), // Estimate from produced - consumed
3294
+ avgProcessingTimeMs: avgLagTimeMs,
3295
+ });
3296
+ } catch (error) {
3297
+ // Individual queue query failed - continue with others
3298
+ console.warn(`[CloudflareGraphQL] Queue ${queue.name} query failed:`, error);
3299
+ results.push({
3300
+ queueId: queue.id,
3301
+ queueName: queue.name,
3302
+ messagesProduced: 0,
3303
+ messagesConsumed: 0,
3304
+ messagesAcked: 0,
3305
+ messagesRetried: 0,
3306
+ messagesFailed: 0,
3307
+ backlogSize: 0,
3308
+ avgProcessingTimeMs: 0,
3309
+ });
3310
+ }
3311
+ }
3312
+
3313
+ return results;
3314
+ }
3315
+
3316
+ /**
3317
+ * List Queues via REST API
3318
+ */
3319
+ private async listQueues(): Promise<Array<{ id: string; name: string }>> {
3320
+ try {
3321
+ const response = await fetchWithRetry(
3322
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/queues`,
3323
+ {
3324
+ headers: {
3325
+ Authorization: `Bearer ${this.apiToken}`,
3326
+ 'Content-Type': 'application/json',
3327
+ },
3328
+ }
3329
+ );
3330
+
3331
+ if (!response.ok) {
3332
+ console.error(`[CloudflareGraphQL] Queues list error: ${response.status}`);
3333
+ return [];
3334
+ }
3335
+
3336
+ interface QueuesListResponse {
3337
+ result?: Array<{ queue_id: string; queue_name: string }>;
3338
+ success?: boolean;
3339
+ }
3340
+
3341
+ const data = (await response.json()) as QueuesListResponse;
3342
+
3343
+ if (!data.success || !data.result) {
3344
+ return [];
3345
+ }
3346
+
3347
+ return data.result.map((q) => ({ id: q.queue_id, name: q.queue_name }));
3348
+ } catch (error) {
3349
+ console.error('[CloudflareGraphQL] Queues list error:', error);
3350
+ return [];
3351
+ }
3352
+ }
3353
+
3354
+ /**
3355
+ * Get cache analytics for Workers (via CDN Cache API)
3356
+ */
3357
+ async getCacheAnalytics(period: TimePeriod): Promise<CacheAnalytics> {
3358
+ const { startDate, endDate } = this.getDateRange(period);
3359
+
3360
+ const queryStr = `
3361
+ query CacheAnalytics($accountTag: String!, $startDate: Date!, $endDate: Date!) {
3362
+ viewer {
3363
+ accounts(filter: { accountTag: $accountTag }) {
3364
+ httpRequestsAdaptiveGroups(
3365
+ filter: {
3366
+ date_geq: $startDate
3367
+ date_leq: $endDate
3368
+ }
3369
+ limit: 1000
3370
+ ) {
3371
+ dimensions {
3372
+ cacheStatus
3373
+ }
3374
+ sum {
3375
+ requests
3376
+ bytes
3377
+ }
3378
+ }
3379
+ }
3380
+ }
3381
+ }
3382
+ `;
3383
+
3384
+ interface CacheResponse {
3385
+ viewer?: {
3386
+ accounts?: Array<{
3387
+ httpRequestsAdaptiveGroups?: Array<{
3388
+ dimensions?: { cacheStatus?: string };
3389
+ sum?: { requests?: number; bytes?: number };
3390
+ }>;
3391
+ }>;
3392
+ };
3393
+ }
3394
+
3395
+ const data = await this.query<CacheResponse>(queryStr, {
3396
+ accountTag: this.accountId,
3397
+ startDate,
3398
+ endDate,
3399
+ });
3400
+
3401
+ const groups = data?.viewer?.accounts?.[0]?.httpRequestsAdaptiveGroups ?? [];
3402
+
3403
+ let totalRequests = 0;
3404
+ let cacheHits = 0;
3405
+ let cacheMisses = 0;
3406
+ let bandwidthSavedBytes = 0;
3407
+
3408
+ for (const g of groups) {
3409
+ const status = g.dimensions?.cacheStatus?.toLowerCase() ?? '';
3410
+ const requests = g.sum?.requests ?? 0;
3411
+ const bytes = g.sum?.bytes ?? 0;
3412
+
3413
+ totalRequests += requests;
3414
+
3415
+ if (status === 'hit' || status === 'stale' || status === 'revalidated') {
3416
+ cacheHits += requests;
3417
+ bandwidthSavedBytes += bytes;
3418
+ } else if (status === 'miss' || status === 'expired' || status === 'bypass') {
3419
+ cacheMisses += requests;
3420
+ }
3421
+ }
3422
+
3423
+ return {
3424
+ totalRequests,
3425
+ cacheHits,
3426
+ cacheMisses,
3427
+ hitRate: totalRequests > 0 ? Math.round((cacheHits / totalRequests) * 10000) / 100 : 0,
3428
+ bandwidthSavedBytes,
3429
+ };
3430
+ }
3431
+
3432
+ /**
3433
+ * Query Cloudflare Analytics Engine via SQL API
3434
+ * Used for Workers AI metrics from project Analytics Engine datasets
3435
+ */
3436
+ private async queryAnalyticsEngine(
3437
+ sql: string
3438
+ ): Promise<{ success: boolean; result?: AnalyticsEngineResult; error?: string }> {
3439
+ try {
3440
+ const response = await fetchWithRetry(
3441
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/analytics_engine/sql`,
3442
+ {
3443
+ method: 'POST',
3444
+ headers: {
3445
+ Authorization: `Bearer ${this.apiToken}`,
3446
+ 'Content-Type': 'text/plain',
3447
+ },
3448
+ body: sql,
3449
+ }
3450
+ );
3451
+
3452
+ if (!response.ok) {
3453
+ const text = await response.text();
3454
+ console.error(`[CloudflareGraphQL] Analytics Engine error: ${response.status}`, text);
3455
+ return { success: false, error: `HTTP ${response.status}: ${text}` };
3456
+ }
3457
+
3458
+ const result = (await response.json()) as AnalyticsEngineResult;
3459
+ return { success: true, result };
3460
+ } catch (error) {
3461
+ console.error('[CloudflareGraphQL] Analytics Engine query error:', error);
3462
+ return { success: false, error: String(error) };
3463
+ }
3464
+ }
3465
+
3466
+ /**
3467
+ * Get Workers AI metrics from AI Gateway data.
3468
+ * Iterates all gateways and aggregates workers-ai provider usage by project.
3469
+ */
3470
+ async getWorkersAIMetrics(period: TimePeriod): Promise<WorkersAISummary> {
3471
+ const summary: WorkersAISummary = {
3472
+ totalRequests: 0,
3473
+ totalInputTokens: 0,
3474
+ totalOutputTokens: 0,
3475
+ totalCostUsd: 0,
3476
+ byProject: {},
3477
+ byModel: {},
3478
+ metrics: [],
3479
+ };
3480
+
3481
+ const aiGatewayMetrics = await this.getAIGatewayMetrics(period);
3482
+
3483
+ // Iterate all gateways and extract workers-ai provider data
3484
+ for (const gateway of aiGatewayMetrics) {
3485
+ const workersAIEntries = gateway.byModel?.filter((m) => m.provider === 'workers-ai');
3486
+ if (!workersAIEntries || workersAIEntries.length === 0) continue;
3487
+
3488
+ // Use gateway ID as project name (capitalised)
3489
+ const projectName = gateway.gatewayId
3490
+ .split('-')
3491
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
3492
+ .join(' ');
3493
+
3494
+ // Track models already seen for this project (dedup)
3495
+ const existingModels = new Set(
3496
+ summary.metrics.filter((m) => m.project === projectName).map((m) => m.model)
3497
+ );
3498
+
3499
+ for (const entry of workersAIEntries) {
3500
+ const model = entry.model || 'unknown';
3501
+ if (existingModels.has(model)) continue;
3502
+
3503
+ summary.metrics.push({
3504
+ project: projectName,
3505
+ model,
3506
+ requests: entry.requests,
3507
+ inputTokens: entry.tokensIn,
3508
+ outputTokens: entry.tokensOut,
3509
+ costUsd: entry.costUsd,
3510
+ isEstimated: false,
3511
+ });
3512
+
3513
+ if (!summary.byModel[model]) {
3514
+ summary.byModel[model] = { requests: 0, inputTokens: 0, outputTokens: 0, costUsd: 0 };
3515
+ }
3516
+ summary.byModel[model].requests += entry.requests;
3517
+ summary.byModel[model].inputTokens += entry.tokensIn;
3518
+ summary.byModel[model].outputTokens += entry.tokensOut;
3519
+ summary.byModel[model].costUsd += entry.costUsd;
3520
+
3521
+ summary.totalRequests += entry.requests;
3522
+ summary.totalInputTokens += entry.tokensIn;
3523
+ summary.totalOutputTokens += entry.tokensOut;
3524
+ summary.totalCostUsd += entry.costUsd;
3525
+
3526
+ if (!summary.byProject[projectName]) {
3527
+ summary.byProject[projectName] = { requests: 0, costUsd: 0, isEstimated: false };
3528
+ }
3529
+ summary.byProject[projectName].requests += entry.requests;
3530
+ summary.byProject[projectName].costUsd += entry.costUsd;
3531
+ }
3532
+ }
3533
+
3534
+ return summary;
3535
+ }
3536
+
3537
+ /**
3538
+ * Get all enhanced metrics including sparklines, error breakdown, and period comparison
3539
+ */
3540
+ async getAllEnhancedMetrics(period: TimePeriod): Promise<EnhancedAccountUsage> {
3541
+ // Get base metrics and enhanced data in parallel
3542
+ const [
3543
+ baseMetrics,
3544
+ workersSparkline,
3545
+ d1Sparkline,
3546
+ kvSparkline,
3547
+ errorBreakdown,
3548
+ queues,
3549
+ cache,
3550
+ previousMetrics,
3551
+ ] = await Promise.all([
3552
+ this.getAllMetrics(period),
3553
+ this.getWorkersSparklineData(period),
3554
+ this.getD1SparklineData(period),
3555
+ this.getKVSparklineData(period),
3556
+ this.getWorkersErrorBreakdown(period),
3557
+ this.getQueuesMetrics(period),
3558
+ this.getCacheAnalytics(period),
3559
+ this.getAllMetrics(period === '24h' ? '7d' : period === '7d' ? '30d' : '30d'), // Get previous period for comparison
3560
+ ]);
3561
+
3562
+ // Calculate period comparisons
3563
+ const currentRequests = baseMetrics.workers.reduce((s, w) => s + w.requests, 0);
3564
+ const previousRequests = previousMetrics.workers.reduce((s, w) => s + w.requests, 0);
3565
+
3566
+ const currentErrors = baseMetrics.workers.reduce((s, w) => s + w.errors, 0);
3567
+ const previousErrors = previousMetrics.workers.reduce((s, w) => s + w.errors, 0);
3568
+
3569
+ const currentD1Rows = baseMetrics.d1.reduce((s, d) => s + d.rowsRead, 0);
3570
+ const previousD1Rows = previousMetrics.d1.reduce((s, d) => s + d.rowsRead, 0);
3571
+
3572
+ return {
3573
+ ...baseMetrics,
3574
+ sparklines: {
3575
+ workersRequests: workersSparkline.requests,
3576
+ workersErrors: workersSparkline.errors,
3577
+ d1RowsRead: d1Sparkline,
3578
+ kvReads: kvSparkline,
3579
+ },
3580
+ errorBreakdown,
3581
+ queues,
3582
+ cache,
3583
+ comparison: {
3584
+ workersRequests: {
3585
+ current: currentRequests,
3586
+ previous: previousRequests,
3587
+ ...this.calculateTrend(currentRequests, previousRequests),
3588
+ },
3589
+ workersErrors: {
3590
+ current: currentErrors,
3591
+ previous: previousErrors,
3592
+ ...this.calculateTrend(currentErrors, previousErrors),
3593
+ },
3594
+ d1RowsRead: {
3595
+ current: currentD1Rows,
3596
+ previous: previousD1Rows,
3597
+ ...this.calculateTrend(currentD1Rows, previousD1Rows),
3598
+ },
3599
+ totalCost: {
3600
+ current: 0, // Will be calculated by costCalculator
3601
+ previous: 0,
3602
+ trend: 'stable',
3603
+ percentChange: 0,
3604
+ },
3605
+ },
3606
+ };
3607
+ }
3608
+
3609
+ /**
3610
+ * Get daily cost breakdown for interactive chart (task-18)
3611
+ *
3612
+ * Queries daily metrics for all resource types and calculates
3613
+ * costs using the shared cost calculation engine.
3614
+ *
3615
+ * @param period - Time period or custom date range
3616
+ * @returns Daily cost breakdown with totals
3617
+ */
3618
+ async getDailyCostBreakdown(
3619
+ period: TimePeriod | { start: string; end: string }
3620
+ ): Promise<DailyCostData> {
3621
+ // Get date range
3622
+ const { startDate, endDate } =
3623
+ typeof period === 'string'
3624
+ ? this.getDateRange(period)
3625
+ : { startDate: period.start, endDate: period.end };
3626
+
3627
+ // Query all resource types in parallel
3628
+ const [workersDaily, d1Daily, kvDaily, r2Daily, doDaily] = await Promise.all([
3629
+ this.getWorkersDailyMetrics(startDate, endDate),
3630
+ this.getD1DailyMetrics(startDate, endDate),
3631
+ this.getKVDailyMetrics(startDate, endDate),
3632
+ this.getR2DailyMetrics(startDate, endDate),
3633
+ this.getDurableObjectsDailyMetrics(startDate, endDate),
3634
+ ]);
3635
+
3636
+ // AI Gateway, Vectorize, Workers AI, and Queues don't have daily granularity in the API
3637
+ // We'll estimate by distributing evenly across the period
3638
+ const periodDays = this.getDayCount(startDate, endDate);
3639
+ const aiGatewayTotal = await this.getAIGatewayRequests(startDate, endDate);
3640
+ const vectorizeTotal = await this.getVectorizeQueries(startDate, endDate);
3641
+
3642
+ // Get Workers AI metrics (total tokens for period)
3643
+ const workersAI = await this.getWorkersAIMetrics(typeof period === 'string' ? period : '30d');
3644
+ const workersAITotalTokens = workersAI.totalInputTokens + workersAI.totalOutputTokens;
3645
+
3646
+ // Get Queues metrics (total messages consumed for period)
3647
+ const queues = await this.getQueuesMetrics(typeof period === 'string' ? period : '30d');
3648
+ const queuesTotalMessages = queues.reduce((sum, q) => sum + q.messagesConsumed, 0);
3649
+
3650
+ // Create a set of all dates in the period
3651
+ const allDates = new Set<string>();
3652
+ const addDates = (map: Map<string, unknown>) => {
3653
+ Array.from(map.keys()).forEach((date) => allDates.add(date));
3654
+ };
3655
+
3656
+ addDates(workersDaily);
3657
+ addDates(d1Daily);
3658
+ addDates(kvDaily);
3659
+ addDates(r2Daily);
3660
+ addDates(doDaily);
3661
+
3662
+ // Fill in missing dates in the range
3663
+ const current = new Date(startDate);
3664
+ const end = new Date(endDate);
3665
+ while (current <= end) {
3666
+ allDates.add(current.toISOString().split('T')[0]);
3667
+ current.setDate(current.getDate() + 1);
3668
+ }
3669
+
3670
+ // Calculate costs for each day
3671
+ const days: DailyCostBreakdown[] = [];
3672
+ const sortedDates = Array.from(allDates).sort();
3673
+
3674
+ for (const date of sortedDates) {
3675
+ const workers = workersDaily.get(date) ?? { requests: 0, cpuMs: 0 };
3676
+ const d1 = d1Daily.get(date) ?? { reads: 0, writes: 0 };
3677
+ const kv = kvDaily.get(date) ?? { reads: 0, writes: 0, deletes: 0, lists: 0 };
3678
+ const r2 = r2Daily.get(date) ?? { classA: 0, classB: 0 };
3679
+ const dObjects = doDaily.get(date) ?? { requests: 0, gbSeconds: 0 };
3680
+
3681
+ // Distribute AI Gateway, Vectorize, Workers AI, and Queues evenly
3682
+ const aiGatewayDaily = Math.round(aiGatewayTotal / periodDays);
3683
+ const vectorizeDaily = Math.round(vectorizeTotal / periodDays);
3684
+ const workersAIDaily = Math.round(workersAITotalTokens / periodDays);
3685
+ const queuesDaily = Math.round(queuesTotalMessages / periodDays);
3686
+
3687
+ const usage: DailyUsageMetrics = {
3688
+ workersRequests: workers.requests,
3689
+ workersCpuMs: workers.cpuMs,
3690
+ d1Reads: d1.reads,
3691
+ d1Writes: d1.writes,
3692
+ kvReads: kv.reads,
3693
+ kvWrites: kv.writes,
3694
+ kvDeletes: kv.deletes,
3695
+ kvLists: kv.lists,
3696
+ r2ClassA: r2.classA,
3697
+ r2ClassB: r2.classB,
3698
+ vectorizeQueries: vectorizeDaily,
3699
+ aiGatewayRequests: aiGatewayDaily,
3700
+ durableObjectsRequests: dObjects.requests,
3701
+ durableObjectsGbSeconds: dObjects.gbSeconds,
3702
+ workersAITokens: workersAIDaily,
3703
+ queuesMessages: queuesDaily,
3704
+ };
3705
+
3706
+ const costs = calculateDailyCosts(usage);
3707
+ days.push({ date, ...costs });
3708
+ }
3709
+
3710
+ // Calculate totals
3711
+ const totals = days.reduce(
3712
+ (acc, day) => ({
3713
+ workers: acc.workers + day.workers,
3714
+ d1: acc.d1 + day.d1,
3715
+ kv: acc.kv + day.kv,
3716
+ r2: acc.r2 + day.r2,
3717
+ vectorize: acc.vectorize + day.vectorize,
3718
+ aiGateway: acc.aiGateway + day.aiGateway,
3719
+ durableObjects: acc.durableObjects + day.durableObjects,
3720
+ workersAI: acc.workersAI + day.workersAI,
3721
+ pages: acc.pages + day.pages,
3722
+ queues: acc.queues + day.queues,
3723
+ workflows: acc.workflows + day.workflows,
3724
+ total: acc.total + day.total,
3725
+ }),
3726
+ {
3727
+ workers: 0,
3728
+ d1: 0,
3729
+ kv: 0,
3730
+ r2: 0,
3731
+ vectorize: 0,
3732
+ aiGateway: 0,
3733
+ durableObjects: 0,
3734
+ workersAI: 0,
3735
+ pages: 0,
3736
+ queues: 0,
3737
+ workflows: 0,
3738
+ total: 0,
3739
+ }
3740
+ );
3741
+
3742
+ return {
3743
+ days,
3744
+ totals,
3745
+ period: { start: startDate, end: endDate },
3746
+ };
3747
+ }
3748
+
3749
+ /**
3750
+ * Get number of days in a date range
3751
+ */
3752
+ private getDayCount(startDate: string, endDate: string): number {
3753
+ const start = new Date(startDate);
3754
+ const end = new Date(endDate);
3755
+ return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
3756
+ }
3757
+
3758
+ /**
3759
+ * Get Workers daily metrics (requests + CPU time)
3760
+ */
3761
+ private async getWorkersDailyMetrics(
3762
+ startDate: string,
3763
+ endDate: string
3764
+ ): Promise<Map<string, { requests: number; cpuMs: number }>> {
3765
+ const queryStr = `
3766
+ query WorkersDaily($accountTag: String!, $startDate: Date!, $endDate: Date!) {
3767
+ viewer {
3768
+ accounts(filter: { accountTag: $accountTag }) {
3769
+ workersInvocationsAdaptive(
3770
+ filter: {
3771
+ date_geq: $startDate
3772
+ date_leq: $endDate
3773
+ }
3774
+ limit: 10000
3775
+ orderBy: [date_ASC]
3776
+ ) {
3777
+ dimensions {
3778
+ date
3779
+ }
3780
+ sum {
3781
+ requests
3782
+ }
3783
+ quantiles {
3784
+ cpuTimeP50
3785
+ }
3786
+ }
3787
+ }
3788
+ }
3789
+ }
3790
+ `;
3791
+
3792
+ interface Response {
3793
+ viewer?: {
3794
+ accounts?: Array<{
3795
+ workersInvocationsAdaptive?: Array<{
3796
+ dimensions?: { date?: string };
3797
+ sum?: { requests?: number };
3798
+ quantiles?: { cpuTimeP50?: number };
3799
+ }>;
3800
+ }>;
3801
+ };
3802
+ }
3803
+
3804
+ const data = await this.query<Response>(queryStr, {
3805
+ accountTag: this.accountId,
3806
+ startDate,
3807
+ endDate,
3808
+ });
3809
+
3810
+ const byDate = new Map<string, { requests: number; cpuMs: number }>();
3811
+ const items = data?.viewer?.accounts?.[0]?.workersInvocationsAdaptive ?? [];
3812
+
3813
+ for (const item of items) {
3814
+ const date = item.dimensions?.date;
3815
+ if (!date) continue;
3816
+
3817
+ const existing = byDate.get(date) ?? { requests: 0, cpuMs: 0 };
3818
+ existing.requests += item.sum?.requests ?? 0;
3819
+ // Estimate total CPU time: P50 * requests (approximation)
3820
+ const cpuP50 = item.quantiles?.cpuTimeP50 ?? 0;
3821
+ existing.cpuMs += cpuP50 * (item.sum?.requests ?? 0);
3822
+ byDate.set(date, existing);
3823
+ }
3824
+
3825
+ return byDate;
3826
+ }
3827
+
3828
+ /**
3829
+ * Get D1 daily metrics (reads + writes)
3830
+ */
3831
+ private async getD1DailyMetrics(
3832
+ startDate: string,
3833
+ endDate: string
3834
+ ): Promise<Map<string, { reads: number; writes: number }>> {
3835
+ const databases = await this.listD1Databases();
3836
+ const byDate = new Map<string, { reads: number; writes: number }>();
3837
+
3838
+ for (const db of databases) {
3839
+ const queryStr = `
3840
+ query D1Daily($accountTag: String!, $databaseId: String!, $startDate: Date!, $endDate: Date!) {
3841
+ viewer {
3842
+ accounts(filter: { accountTag: $accountTag }) {
3843
+ d1AnalyticsAdaptiveGroups(
3844
+ filter: {
3845
+ databaseId: $databaseId
3846
+ date_geq: $startDate
3847
+ date_leq: $endDate
3848
+ }
3849
+ limit: 1000
3850
+ orderBy: [date_ASC]
3851
+ ) {
3852
+ dimensions {
3853
+ date
3854
+ }
3855
+ sum {
3856
+ rowsRead
3857
+ rowsWritten
3858
+ }
3859
+ }
3860
+ }
3861
+ }
3862
+ }
3863
+ `;
3864
+
3865
+ interface Response {
3866
+ viewer?: {
3867
+ accounts?: Array<{
3868
+ d1AnalyticsAdaptiveGroups?: Array<{
3869
+ dimensions?: { date?: string };
3870
+ sum?: { rowsRead?: number; rowsWritten?: number };
3871
+ }>;
3872
+ }>;
3873
+ };
3874
+ }
3875
+
3876
+ const data = await this.query<Response>(queryStr, {
3877
+ accountTag: this.accountId,
3878
+ databaseId: db.id,
3879
+ startDate,
3880
+ endDate,
3881
+ });
3882
+
3883
+ const groups = data?.viewer?.accounts?.[0]?.d1AnalyticsAdaptiveGroups ?? [];
3884
+ for (const g of groups) {
3885
+ const date = g.dimensions?.date;
3886
+ if (!date) continue;
3887
+
3888
+ const existing = byDate.get(date) ?? { reads: 0, writes: 0 };
3889
+ existing.reads += g.sum?.rowsRead ?? 0;
3890
+ existing.writes += g.sum?.rowsWritten ?? 0;
3891
+ byDate.set(date, existing);
3892
+ }
3893
+ }
3894
+
3895
+ return byDate;
3896
+ }
3897
+
3898
+ /**
3899
+ * Get KV daily metrics (reads, writes, deletes, lists)
3900
+ */
3901
+ private async getKVDailyMetrics(
3902
+ startDate: string,
3903
+ endDate: string
3904
+ ): Promise<Map<string, { reads: number; writes: number; deletes: number; lists: number }>> {
3905
+ const namespaces = await this.listKVNamespaces();
3906
+ const byDate = new Map<
3907
+ string,
3908
+ { reads: number; writes: number; deletes: number; lists: number }
3909
+ >();
3910
+
3911
+ for (const ns of namespaces) {
3912
+ const queryStr = `
3913
+ query KVDaily($accountTag: String!, $namespaceId: String!, $startDate: Date!, $endDate: Date!) {
3914
+ viewer {
3915
+ accounts(filter: { accountTag: $accountTag }) {
3916
+ kvOperationsAdaptiveGroups(
3917
+ filter: {
3918
+ namespaceId: $namespaceId
3919
+ date_geq: $startDate
3920
+ date_leq: $endDate
3921
+ }
3922
+ limit: 1000
3923
+ orderBy: [date_ASC]
3924
+ ) {
3925
+ dimensions {
3926
+ date
3927
+ actionType
3928
+ }
3929
+ sum {
3930
+ requests
3931
+ }
3932
+ }
3933
+ }
3934
+ }
3935
+ }
3936
+ `;
3937
+
3938
+ interface Response {
3939
+ viewer?: {
3940
+ accounts?: Array<{
3941
+ kvOperationsAdaptiveGroups?: Array<{
3942
+ dimensions?: { date?: string; actionType?: string };
3943
+ sum?: { requests?: number };
3944
+ }>;
3945
+ }>;
3946
+ };
3947
+ }
3948
+
3949
+ const data = await this.query<Response>(queryStr, {
3950
+ accountTag: this.accountId,
3951
+ namespaceId: this.formatUuidWithHyphens(ns.id),
3952
+ startDate,
3953
+ endDate,
3954
+ });
3955
+
3956
+ const groups = data?.viewer?.accounts?.[0]?.kvOperationsAdaptiveGroups ?? [];
3957
+ for (const g of groups) {
3958
+ const date = g.dimensions?.date;
3959
+ if (!date) continue;
3960
+
3961
+ const existing = byDate.get(date) ?? { reads: 0, writes: 0, deletes: 0, lists: 0 };
3962
+ const requests = g.sum?.requests ?? 0;
3963
+ const actionType = g.dimensions?.actionType?.toLowerCase() ?? '';
3964
+
3965
+ if (actionType === 'read') existing.reads += requests;
3966
+ else if (actionType === 'write') existing.writes += requests;
3967
+ else if (actionType === 'delete') existing.deletes += requests;
3968
+ else if (actionType === 'list') existing.lists += requests;
3969
+
3970
+ byDate.set(date, existing);
3971
+ }
3972
+ }
3973
+
3974
+ return byDate;
3975
+ }
3976
+
3977
+ /**
3978
+ * Get R2 daily metrics (class A + class B operations)
3979
+ */
3980
+ private async getR2DailyMetrics(
3981
+ startDate: string,
3982
+ endDate: string
3983
+ ): Promise<Map<string, { classA: number; classB: number }>> {
3984
+ // R2 analytics via GraphQL
3985
+ const queryStr = `
3986
+ query R2Daily($accountTag: String!, $startDate: Date!, $endDate: Date!) {
3987
+ viewer {
3988
+ accounts(filter: { accountTag: $accountTag }) {
3989
+ r2OperationsAdaptiveGroups(
3990
+ filter: {
3991
+ date_geq: $startDate
3992
+ date_leq: $endDate
3993
+ }
3994
+ limit: 10000
3995
+ orderBy: [date_ASC]
3996
+ ) {
3997
+ dimensions {
3998
+ date
3999
+ actionType
4000
+ }
4001
+ sum {
4002
+ requests
4003
+ }
4004
+ }
4005
+ }
4006
+ }
4007
+ }
4008
+ `;
4009
+
4010
+ interface Response {
4011
+ viewer?: {
4012
+ accounts?: Array<{
4013
+ r2OperationsAdaptiveGroups?: Array<{
4014
+ dimensions?: { date?: string; actionType?: string };
4015
+ sum?: { requests?: number };
4016
+ }>;
4017
+ }>;
4018
+ };
4019
+ }
4020
+
4021
+ const data = await this.query<Response>(queryStr, {
4022
+ accountTag: this.accountId,
4023
+ startDate,
4024
+ endDate,
4025
+ });
4026
+
4027
+ const byDate = new Map<string, { classA: number; classB: number }>();
4028
+ const groups = data?.viewer?.accounts?.[0]?.r2OperationsAdaptiveGroups ?? [];
4029
+
4030
+ // Class A operations: PUT, POST, DELETE, LIST, CreateMultipartUpload, etc.
4031
+ // Class B operations: GET, HEAD
4032
+ const classAActions = new Set([
4033
+ 'PUT',
4034
+ 'POST',
4035
+ 'DELETE',
4036
+ 'LIST',
4037
+ 'CreateMultipartUpload',
4038
+ 'UploadPart',
4039
+ 'CompleteMultipartUpload',
4040
+ 'AbortMultipartUpload',
4041
+ 'CopyObject',
4042
+ ]);
4043
+ const classBActions = new Set(['GET', 'HEAD']);
4044
+
4045
+ for (const g of groups) {
4046
+ const date = g.dimensions?.date;
4047
+ if (!date) continue;
4048
+
4049
+ const existing = byDate.get(date) ?? { classA: 0, classB: 0 };
4050
+ const requests = g.sum?.requests ?? 0;
4051
+ const actionType = (g.dimensions?.actionType ?? '').toUpperCase();
4052
+
4053
+ if (classAActions.has(actionType)) {
4054
+ existing.classA += requests;
4055
+ } else if (classBActions.has(actionType)) {
4056
+ existing.classB += requests;
4057
+ } else {
4058
+ // Unknown action - assume class A (more expensive, conservative estimate)
4059
+ existing.classA += requests;
4060
+ }
4061
+
4062
+ byDate.set(date, existing);
4063
+ }
4064
+
4065
+ return byDate;
4066
+ }
4067
+
4068
+ /**
4069
+ * Get Durable Objects daily metrics including duration (GB-seconds)
4070
+ */
4071
+ private async getDurableObjectsDailyMetrics(
4072
+ startDate: string,
4073
+ endDate: string
4074
+ ): Promise<Map<string, { requests: number; gbSeconds: number }>> {
4075
+ // Query 1: Invocation metrics per day
4076
+ const invocationsQuery = `
4077
+ query DODailyInvocations($accountTag: String!, $startDate: Date!, $endDate: Date!) {
4078
+ viewer {
4079
+ accounts(filter: { accountTag: $accountTag }) {
4080
+ durableObjectsInvocationsAdaptiveGroups(
4081
+ filter: {
4082
+ date_geq: $startDate
4083
+ date_leq: $endDate
4084
+ }
4085
+ limit: 10000
4086
+ orderBy: [date_ASC]
4087
+ ) {
4088
+ dimensions {
4089
+ date
4090
+ }
4091
+ sum {
4092
+ requests
4093
+ }
4094
+ }
4095
+ }
4096
+ }
4097
+ }
4098
+ `;
4099
+
4100
+ // Query 2: Duration metrics per day
4101
+ // Note: duration field is already in GB-seconds (the billable unit)
4102
+ const durationQuery = `
4103
+ query DODailyDuration($accountTag: String!, $startDate: Date!, $endDate: Date!) {
4104
+ viewer {
4105
+ accounts(filter: { accountTag: $accountTag }) {
4106
+ durableObjectsPeriodicGroups(
4107
+ filter: {
4108
+ date_geq: $startDate
4109
+ date_leq: $endDate
4110
+ }
4111
+ limit: 10000
4112
+ orderBy: [date_ASC]
4113
+ ) {
4114
+ dimensions {
4115
+ date
4116
+ }
4117
+ sum {
4118
+ duration
4119
+ }
4120
+ }
4121
+ }
4122
+ }
4123
+ }
4124
+ `;
4125
+
4126
+ interface InvocationsResponse {
4127
+ viewer?: {
4128
+ accounts?: Array<{
4129
+ durableObjectsInvocationsAdaptiveGroups?: Array<{
4130
+ dimensions?: { date?: string };
4131
+ sum?: { requests?: number };
4132
+ }>;
4133
+ }>;
4134
+ };
4135
+ }
4136
+
4137
+ interface DurationResponse {
4138
+ viewer?: {
4139
+ accounts?: Array<{
4140
+ durableObjectsPeriodicGroups?: Array<{
4141
+ dimensions?: { date?: string };
4142
+ sum?: { duration?: number }; // GB-seconds (billable unit from Cloudflare)
4143
+ }>;
4144
+ }>;
4145
+ };
4146
+ }
4147
+
4148
+ // Execute both queries in parallel
4149
+ const [invocationsData, durationData] = await Promise.all([
4150
+ this.query<InvocationsResponse>(invocationsQuery, {
4151
+ accountTag: this.accountId,
4152
+ startDate,
4153
+ endDate,
4154
+ }),
4155
+ this.query<DurationResponse>(durationQuery, {
4156
+ accountTag: this.accountId,
4157
+ startDate,
4158
+ endDate,
4159
+ }).catch(() => null),
4160
+ ]);
4161
+
4162
+ const byDate = new Map<string, { requests: number; gbSeconds: number }>();
4163
+
4164
+ // Process invocation metrics
4165
+ const invocationsGroups =
4166
+ invocationsData?.viewer?.accounts?.[0]?.durableObjectsInvocationsAdaptiveGroups ?? [];
4167
+ for (const g of invocationsGroups) {
4168
+ const date = g.dimensions?.date;
4169
+ if (!date) continue;
4170
+
4171
+ const existing = byDate.get(date) ?? { requests: 0, gbSeconds: 0 };
4172
+ existing.requests += g.sum?.requests ?? 0;
4173
+ byDate.set(date, existing);
4174
+ }
4175
+
4176
+ // Process duration metrics - duration field is already in GB-seconds per day
4177
+ // This is the official billable metric from Cloudflare, pre-calculated
4178
+ const durationGroups = durationData?.viewer?.accounts?.[0]?.durableObjectsPeriodicGroups ?? [];
4179
+ for (const g of durationGroups) {
4180
+ const date = g.dimensions?.date;
4181
+ if (!date) continue;
4182
+
4183
+ const existing = byDate.get(date) ?? { requests: 0, gbSeconds: 0 };
4184
+ existing.gbSeconds += g.sum?.duration ?? 0;
4185
+ byDate.set(date, existing);
4186
+ }
4187
+
4188
+ return byDate;
4189
+ }
4190
+
4191
+ /**
4192
+ * Get total AI Gateway requests for a period (no daily granularity available)
4193
+ */
4194
+ private async getAIGatewayRequests(startDate: string, endDate: string): Promise<number> {
4195
+ try {
4196
+ // Use the range-based method instead of period-based
4197
+ const dateRange: DateRange = { startDate, endDate };
4198
+ const metrics = await this.getAIGatewayMetricsForRange(dateRange);
4199
+ // Sum all gateway requests for the period
4200
+ return metrics.reduce((sum, m) => sum + m.totalRequests, 0);
4201
+ } catch {
4202
+ return 0;
4203
+ }
4204
+ }
4205
+
4206
+ /**
4207
+ * Get total Vectorize queried dimensions for a period
4208
+ * Now uses GraphQL API (vectorizeV2QueriesAdaptiveGroups) for accurate data
4209
+ */
4210
+ private async getVectorizeQueries(startDate: string, endDate: string): Promise<number> {
4211
+ try {
4212
+ const metrics = await this.getVectorizeQueriesGraphQL({ startDate, endDate });
4213
+ // Return queried dimensions (used for billing calculation)
4214
+ return metrics.totalQueriedDimensions;
4215
+ } catch (error) {
4216
+ console.error('[CloudflareGraphQL] getVectorizeQueries error:', error);
4217
+ // Fallback to rough estimate if GraphQL fails
4218
+ const periodDays = this.getDayCount(startDate, endDate);
4219
+ const indexCount = (await this.getVectorizeInfo()).length;
4220
+ return indexCount * 100 * periodDays; // Rough estimate: 100 queries/day per index
4221
+ }
4222
+ }
4223
+
4224
+ /**
4225
+ * Get Cloudflare account subscriptions and billing information
4226
+ * Uses REST API endpoints:
4227
+ * - GET /accounts/{id}/subscriptions
4228
+ * - GET /accounts/{id}/billing/profile
4229
+ */
4230
+ async getAccountSubscriptions(): Promise<CloudflareAccountSubscriptions> {
4231
+ // Workers Paid plan inclusions (free tier amounts per month)
4232
+ // Based on Cloudflare pricing as of January 2025
4233
+ const planInclusions: WorkersPaidPlanInclusions = {
4234
+ // Workers
4235
+ requestsIncluded: 10_000_000,
4236
+ cpuTimeIncluded: 30_000_000, // ms
4237
+ // D1
4238
+ d1RowsReadIncluded: 25_000_000_000, // 25B
4239
+ d1RowsWrittenIncluded: 50_000_000, // 50M
4240
+ d1StorageIncluded: 5_000_000_000, // 5GB
4241
+ // KV
4242
+ kvReadsIncluded: 10_000_000,
4243
+ kvWritesIncluded: 1_000_000,
4244
+ kvDeletesIncluded: 1_000_000,
4245
+ kvListsIncluded: 1_000_000,
4246
+ kvStorageIncluded: 1_000_000_000, // 1GB
4247
+ // R2 (separate subscription but related)
4248
+ r2ClassAIncluded: 1_000_000,
4249
+ r2ClassBIncluded: 10_000_000,
4250
+ r2StorageIncluded: 10_000_000_000, // 10GB
4251
+ r2EgressIncluded: 0, // Egress free to internet
4252
+ // Durable Objects
4253
+ doRequestsIncluded: 1_000_000,
4254
+ doDurationIncluded: 400_000, // GB-seconds
4255
+ doStorageIncluded: 1_000_000_000, // 1GB
4256
+ // Vectorize
4257
+ vectorizeQueriedDimensionsIncluded: 30_000_000,
4258
+ vectorizeStoredDimensionsIncluded: 5_000_000,
4259
+ // Workers AI
4260
+ workersAINeuronsIncluded: 10_000, // neurons/day (gateway dependent)
4261
+ // Queues
4262
+ queuesOperationsIncluded: 1_000_000,
4263
+ };
4264
+
4265
+ const subscriptions: CloudflareSubscription[] = [];
4266
+ let billingProfile: CloudflareBillingProfile | null = null;
4267
+ let hasWorkersPaid = false;
4268
+ let hasR2Paid = false;
4269
+ let hasAnalyticsEngine = false;
4270
+ let monthlyBaseCost = 0;
4271
+
4272
+ // Fetch subscriptions
4273
+ try {
4274
+ const subscriptionsRes = await fetchWithRetry(
4275
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/subscriptions`,
4276
+ {
4277
+ headers: {
4278
+ Authorization: `Bearer ${this.apiToken}`,
4279
+ 'Content-Type': 'application/json',
4280
+ },
4281
+ }
4282
+ );
4283
+
4284
+ if (subscriptionsRes.ok) {
4285
+ const data = (await subscriptionsRes.json()) as {
4286
+ success: boolean;
4287
+ result: Array<{
4288
+ id: string;
4289
+ rate_plan?: {
4290
+ id: string;
4291
+ public_name: string;
4292
+ currency: string;
4293
+ };
4294
+ price: number;
4295
+ frequency: string;
4296
+ current_period_start: string | null;
4297
+ current_period_end: string | null;
4298
+ state: string;
4299
+ zone?: { name: string } | null;
4300
+ created_on: string;
4301
+ }>;
4302
+ };
4303
+
4304
+ if (data.success && data.result) {
4305
+ for (const sub of data.result) {
4306
+ const planName = sub.rate_plan?.public_name || 'Unknown';
4307
+ const subscription: CloudflareSubscription = {
4308
+ id: sub.id,
4309
+ ratePlanId: sub.rate_plan?.id || '',
4310
+ ratePlanName: planName,
4311
+ price: sub.price || 0,
4312
+ currency: sub.rate_plan?.currency || 'USD',
4313
+ frequency: sub.frequency || 'monthly',
4314
+ currentPeriodStart: sub.current_period_start,
4315
+ currentPeriodEnd: sub.current_period_end,
4316
+ state: sub.state || 'active',
4317
+ zoneName: sub.zone?.name || null,
4318
+ createdDate: sub.created_on,
4319
+ };
4320
+
4321
+ subscriptions.push(subscription);
4322
+
4323
+ // Detect plan types
4324
+ const lowerPlanName = planName.toLowerCase();
4325
+ if (lowerPlanName.includes('workers paid')) {
4326
+ hasWorkersPaid = true;
4327
+ }
4328
+ if (lowerPlanName.includes('r2') && !lowerPlanName.includes('free')) {
4329
+ hasR2Paid = true;
4330
+ }
4331
+ if (lowerPlanName.includes('analytics engine')) {
4332
+ hasAnalyticsEngine = true;
4333
+ }
4334
+
4335
+ // Sum monthly costs (only for monthly frequency)
4336
+ if (sub.frequency === 'monthly' && sub.price > 0) {
4337
+ monthlyBaseCost += sub.price;
4338
+ }
4339
+ }
4340
+ }
4341
+ }
4342
+ } catch (error) {
4343
+ console.error('Failed to fetch Cloudflare subscriptions:', error);
4344
+ }
4345
+
4346
+ // Fetch billing profile
4347
+ try {
4348
+ const billingRes = await fetchWithRetry(
4349
+ `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/billing/profile`,
4350
+ {
4351
+ headers: {
4352
+ Authorization: `Bearer ${this.apiToken}`,
4353
+ 'Content-Type': 'application/json',
4354
+ },
4355
+ }
4356
+ );
4357
+
4358
+ if (billingRes.ok) {
4359
+ const data = (await billingRes.json()) as {
4360
+ success: boolean;
4361
+ result: {
4362
+ id: string;
4363
+ first_name: string;
4364
+ last_name: string;
4365
+ company: string;
4366
+ email: string;
4367
+ account_type: string;
4368
+ country: string;
4369
+ };
4370
+ };
4371
+
4372
+ if (data.success && data.result) {
4373
+ billingProfile = {
4374
+ id: data.result.id,
4375
+ firstName: data.result.first_name || '',
4376
+ lastName: data.result.last_name || '',
4377
+ company: data.result.company || '',
4378
+ billingEmail: data.result.email || '',
4379
+ accountType: data.result.account_type || 'personal',
4380
+ country: data.result.country || '',
4381
+ };
4382
+ }
4383
+ }
4384
+ } catch (error) {
4385
+ console.error('Failed to fetch Cloudflare billing profile:', error);
4386
+ }
4387
+
4388
+ return {
4389
+ subscriptions,
4390
+ billingProfile,
4391
+ planInclusions,
4392
+ hasWorkersPaid,
4393
+ hasR2Paid,
4394
+ hasAnalyticsEngine,
4395
+ monthlyBaseCost,
4396
+ };
4397
+ }
4398
+
4399
+ // ============================================================================
4400
+ // Workers AI Neurons via GraphQL (aiInferenceAdaptive)
4401
+ // Discovered via GraphQL introspection - provides per-request neuron usage
4402
+ // ============================================================================
4403
+
4404
+ /**
4405
+ * Get Workers AI neuron metrics via GraphQL API
4406
+ * Uses aiInferenceAdaptive dataset which provides per-request neuron usage
4407
+ *
4408
+ * @param dateRange - The date range to query
4409
+ * @returns Array of neuron usage metrics by model
4410
+ */
4411
+ async getWorkersAINeuronsGraphQL(dateRange: DateRange): Promise<{
4412
+ totalNeurons: number;
4413
+ totalInputTokens: number;
4414
+ totalOutputTokens: number;
4415
+ byModel: Array<{
4416
+ modelId: string;
4417
+ neurons: number;
4418
+ inputTokens: number;
4419
+ outputTokens: number;
4420
+ requestCount: number;
4421
+ }>;
4422
+ }> {
4423
+ const { startDate, endDate } = dateRange;
4424
+ const startDatetime = `${startDate}T00:00:00Z`;
4425
+ const endDatetime = `${endDate}T23:59:59Z`;
4426
+
4427
+ const queryStr = `
4428
+ query WorkersAINeurons($accountTag: String!, $startDatetime: Time!, $endDatetime: Time!) {
4429
+ viewer {
4430
+ accounts(filter: { accountTag: $accountTag }) {
4431
+ aiInferenceAdaptive(
4432
+ filter: {
4433
+ datetime_gt: $startDatetime
4434
+ datetime_lt: $endDatetime
4435
+ }
4436
+ limit: 1000
4437
+ ) {
4438
+ neurons
4439
+ modelId
4440
+ datetime
4441
+ inputTokens
4442
+ outputTokens
4443
+ }
4444
+ }
4445
+ }
4446
+ }
4447
+ `;
4448
+
4449
+ interface WorkersAINeuronsResponse {
4450
+ viewer?: {
4451
+ accounts?: Array<{
4452
+ aiInferenceAdaptive?: Array<{
4453
+ neurons?: number;
4454
+ modelId?: string;
4455
+ datetime?: string;
4456
+ inputTokens?: number;
4457
+ outputTokens?: number;
4458
+ }>;
4459
+ }>;
4460
+ };
4461
+ }
4462
+
4463
+ const data = await this.query<WorkersAINeuronsResponse>(queryStr, {
4464
+ accountTag: this.accountId,
4465
+ startDatetime,
4466
+ endDatetime,
4467
+ });
4468
+
4469
+ const result = {
4470
+ totalNeurons: 0,
4471
+ totalInputTokens: 0,
4472
+ totalOutputTokens: 0,
4473
+ byModel: [] as Array<{
4474
+ modelId: string;
4475
+ neurons: number;
4476
+ inputTokens: number;
4477
+ outputTokens: number;
4478
+ requestCount: number;
4479
+ }>,
4480
+ };
4481
+
4482
+ const inferences = data?.viewer?.accounts?.[0]?.aiInferenceAdaptive;
4483
+ if (!inferences?.length) {
4484
+ return result;
4485
+ }
4486
+
4487
+ // Aggregate by model
4488
+ const modelMap = new Map<
4489
+ string,
4490
+ { neurons: number; inputTokens: number; outputTokens: number; requestCount: number }
4491
+ >();
4492
+
4493
+ for (const inf of inferences) {
4494
+ const modelId = inf.modelId || 'unknown';
4495
+ const neurons = inf.neurons || 0;
4496
+ const inputTokens = inf.inputTokens || 0;
4497
+ const outputTokens = inf.outputTokens || 0;
4498
+
4499
+ result.totalNeurons += neurons;
4500
+ result.totalInputTokens += inputTokens;
4501
+ result.totalOutputTokens += outputTokens;
4502
+
4503
+ const existing = modelMap.get(modelId);
4504
+ if (existing) {
4505
+ existing.neurons += neurons;
4506
+ existing.inputTokens += inputTokens;
4507
+ existing.outputTokens += outputTokens;
4508
+ existing.requestCount += 1;
4509
+ } else {
4510
+ modelMap.set(modelId, { neurons, inputTokens, outputTokens, requestCount: 1 });
4511
+ }
4512
+ }
4513
+
4514
+ result.byModel = Array.from(modelMap.entries()).map(([modelId, data]) => ({
4515
+ modelId,
4516
+ ...data,
4517
+ }));
4518
+
4519
+ console.log(
4520
+ `[CloudflareGraphQL] WorkersAINeurons: ${result.totalNeurons} neurons across ${result.byModel.length} models`
4521
+ );
4522
+ return result;
4523
+ }
4524
+
4525
+ // ============================================================================
4526
+ // Vectorize Queries via GraphQL (vectorizeV2QueriesAdaptiveGroups)
4527
+ // Discovered via GraphQL introspection - provides query metrics per index
4528
+ // ============================================================================
4529
+
4530
+ /**
4531
+ * Get Vectorize query metrics via GraphQL API
4532
+ * Uses vectorizeV2QueriesAdaptiveGroups dataset for V2 query metrics
4533
+ *
4534
+ * @param dateRange - The date range to query
4535
+ * @returns Query metrics by index
4536
+ */
4537
+ async getVectorizeQueriesGraphQL(dateRange: DateRange): Promise<{
4538
+ totalQueriedDimensions: number;
4539
+ totalDurationMs: number;
4540
+ totalServedVectors: number;
4541
+ byIndex: Array<{
4542
+ indexName: string;
4543
+ queriedDimensions: number;
4544
+ durationMs: number;
4545
+ servedVectors: number;
4546
+ }>;
4547
+ }> {
4548
+ const { startDate, endDate } = dateRange;
4549
+ const startDatetime = `${startDate}T00:00:00Z`;
4550
+ const endDatetime = `${endDate}T23:59:59Z`;
4551
+
4552
+ const queryStr = `
4553
+ query VectorizeQueries($accountTag: String!, $startDatetime: Time!, $endDatetime: Time!) {
4554
+ viewer {
4555
+ accounts(filter: { accountTag: $accountTag }) {
4556
+ vectorizeV2QueriesAdaptiveGroups(
4557
+ filter: {
4558
+ datetime_gt: $startDatetime
4559
+ datetime_lt: $endDatetime
4560
+ }
4561
+ limit: 100
4562
+ ) {
4563
+ sum {
4564
+ queriedVectorDimensions
4565
+ requestDurationMs
4566
+ servedVectorCount
4567
+ }
4568
+ dimensions {
4569
+ indexName
4570
+ operation
4571
+ requestStatus
4572
+ date
4573
+ datetime
4574
+ }
4575
+ }
4576
+ }
4577
+ }
4578
+ }
4579
+ `;
4580
+
4581
+ interface VectorizeQueriesResponse {
4582
+ viewer?: {
4583
+ accounts?: Array<{
4584
+ vectorizeV2QueriesAdaptiveGroups?: Array<{
4585
+ sum?: {
4586
+ queriedVectorDimensions?: number;
4587
+ requestDurationMs?: number;
4588
+ servedVectorCount?: number;
4589
+ };
4590
+ dimensions?: {
4591
+ indexName?: string;
4592
+ operation?: string;
4593
+ requestStatus?: string;
4594
+ date?: string;
4595
+ datetime?: string;
4596
+ };
4597
+ }>;
4598
+ }>;
4599
+ };
4600
+ }
4601
+
4602
+ const data = await this.query<VectorizeQueriesResponse>(queryStr, {
4603
+ accountTag: this.accountId,
4604
+ startDatetime,
4605
+ endDatetime,
4606
+ });
4607
+
4608
+ const result = {
4609
+ totalQueriedDimensions: 0,
4610
+ totalDurationMs: 0,
4611
+ totalServedVectors: 0,
4612
+ byIndex: [] as Array<{
4613
+ indexName: string;
4614
+ queriedDimensions: number;
4615
+ durationMs: number;
4616
+ servedVectors: number;
4617
+ }>,
4618
+ };
4619
+
4620
+ const groups = data?.viewer?.accounts?.[0]?.vectorizeV2QueriesAdaptiveGroups;
4621
+ if (!groups?.length) {
4622
+ return result;
4623
+ }
4624
+
4625
+ // Aggregate by index
4626
+ const indexMap = new Map<
4627
+ string,
4628
+ { queriedDimensions: number; durationMs: number; servedVectors: number }
4629
+ >();
4630
+
4631
+ for (const group of groups) {
4632
+ const indexName = group.dimensions?.indexName || 'unknown';
4633
+ const queriedDimensions = group.sum?.queriedVectorDimensions || 0;
4634
+ const durationMs = group.sum?.requestDurationMs || 0;
4635
+ const servedVectors = group.sum?.servedVectorCount || 0;
4636
+
4637
+ result.totalQueriedDimensions += queriedDimensions;
4638
+ result.totalDurationMs += durationMs;
4639
+ result.totalServedVectors += servedVectors;
4640
+
4641
+ const existing = indexMap.get(indexName);
4642
+ if (existing) {
4643
+ existing.queriedDimensions += queriedDimensions;
4644
+ existing.durationMs += durationMs;
4645
+ existing.servedVectors += servedVectors;
4646
+ } else {
4647
+ indexMap.set(indexName, { queriedDimensions, durationMs, servedVectors });
4648
+ }
4649
+ }
4650
+
4651
+ result.byIndex = Array.from(indexMap.entries()).map(([indexName, data]) => ({
4652
+ indexName,
4653
+ ...data,
4654
+ }));
4655
+
4656
+ console.log(
4657
+ `[CloudflareGraphQL] VectorizeQueries: ${result.totalQueriedDimensions} dimensions across ${result.byIndex.length} indexes`
4658
+ );
4659
+ return result;
4660
+ }
4661
+
4662
+ // ============================================================================
4663
+ // Vectorize Storage via GraphQL (vectorizeV2StorageAdaptiveGroups)
4664
+ // Discovered via GraphQL introspection - provides storage metrics per index
4665
+ // ============================================================================
4666
+
4667
+ /**
4668
+ * Get Vectorize storage metrics via GraphQL API
4669
+ * Uses vectorizeV2StorageAdaptiveGroups dataset for V2 storage metrics
4670
+ *
4671
+ * @param dateRange - The date range to query
4672
+ * @returns Storage metrics by index
4673
+ */
4674
+ async getVectorizeStorageGraphQL(dateRange: DateRange): Promise<{
4675
+ totalStoredDimensions: number;
4676
+ totalVectorCount: number;
4677
+ byIndex: Array<{
4678
+ indexName: string;
4679
+ storedDimensions: number;
4680
+ vectorCount: number;
4681
+ }>;
4682
+ }> {
4683
+ const { startDate, endDate } = dateRange;
4684
+
4685
+ // Storage API uses date filter, not datetime
4686
+ const queryStr = `
4687
+ query VectorizeStorage($accountTag: String!, $startDate: Date!, $endDate: Date!) {
4688
+ viewer {
4689
+ accounts(filter: { accountTag: $accountTag }) {
4690
+ vectorizeV2StorageAdaptiveGroups(
4691
+ filter: {
4692
+ date_gt: $startDate
4693
+ date_leq: $endDate
4694
+ }
4695
+ limit: 100
4696
+ ) {
4697
+ max {
4698
+ storedVectorDimensions
4699
+ vectorCount
4700
+ }
4701
+ dimensions {
4702
+ date
4703
+ datetime
4704
+ indexName
4705
+ }
4706
+ }
4707
+ }
4708
+ }
4709
+ }
4710
+ `;
4711
+
4712
+ interface VectorizeStorageResponse {
4713
+ viewer?: {
4714
+ accounts?: Array<{
4715
+ vectorizeV2StorageAdaptiveGroups?: Array<{
4716
+ max?: {
4717
+ storedVectorDimensions?: number;
4718
+ vectorCount?: number;
4719
+ };
4720
+ dimensions?: {
4721
+ date?: string;
4722
+ datetime?: string;
4723
+ indexName?: string;
4724
+ };
4725
+ }>;
4726
+ }>;
4727
+ };
4728
+ }
4729
+
4730
+ const data = await this.query<VectorizeStorageResponse>(queryStr, {
4731
+ accountTag: this.accountId,
4732
+ startDate,
4733
+ endDate,
4734
+ });
4735
+
4736
+ const result = {
4737
+ totalStoredDimensions: 0,
4738
+ totalVectorCount: 0,
4739
+ byIndex: [] as Array<{
4740
+ indexName: string;
4741
+ storedDimensions: number;
4742
+ vectorCount: number;
4743
+ }>,
4744
+ };
4745
+
4746
+ const groups = data?.viewer?.accounts?.[0]?.vectorizeV2StorageAdaptiveGroups;
4747
+ if (!groups?.length) {
4748
+ return result;
4749
+ }
4750
+
4751
+ // Aggregate by index (using max values - storage is a point-in-time metric)
4752
+ const indexMap = new Map<string, { storedDimensions: number; vectorCount: number }>();
4753
+
4754
+ for (const group of groups) {
4755
+ const indexName = group.dimensions?.indexName || 'unknown';
4756
+ const storedDimensions = group.max?.storedVectorDimensions || 0;
4757
+ const vectorCount = group.max?.vectorCount || 0;
4758
+
4759
+ // For storage, we want the max across the period (not sum)
4760
+ const existing = indexMap.get(indexName);
4761
+ if (existing) {
4762
+ existing.storedDimensions = Math.max(existing.storedDimensions, storedDimensions);
4763
+ existing.vectorCount = Math.max(existing.vectorCount, vectorCount);
4764
+ } else {
4765
+ indexMap.set(indexName, { storedDimensions, vectorCount });
4766
+ }
4767
+ }
4768
+
4769
+ // Calculate totals from index maxes
4770
+ for (const data of indexMap.values()) {
4771
+ result.totalStoredDimensions += data.storedDimensions;
4772
+ result.totalVectorCount += data.vectorCount;
4773
+ }
4774
+
4775
+ result.byIndex = Array.from(indexMap.entries()).map(([indexName, data]) => ({
4776
+ indexName,
4777
+ ...data,
4778
+ }));
4779
+
4780
+ console.log(
4781
+ `[CloudflareGraphQL] VectorizeStorage: ${result.totalStoredDimensions} dimensions, ${result.totalVectorCount} vectors across ${result.byIndex.length} indexes`
4782
+ );
4783
+ return result;
4784
+ }
4785
+ }