@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.
- package/README.md +2 -2
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +86 -2
- package/package.json +1 -1
- package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
- package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
- package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
- package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
- package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
- package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
- package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
- package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
- package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
- package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
- package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
- package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
- package/templates/full/dashboard/src/lib/search/api.ts +258 -0
- package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
- package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
- package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
- package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
- package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
- package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
- package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
- package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
- package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
- package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
- package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
- package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
- package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
- package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
- package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
- package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
- package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
- package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
- package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
- package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
- package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
- package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
- package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
- package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
- package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
- package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
- package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
- package/templates/shared/tests/helpers/mock-storage.ts +166 -0
- package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
- package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
- package/templates/shared/tests/unit/billing.test.ts +331 -0
- package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
- package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
- package/templates/shared/tests/unit/control.test.ts +226 -0
- package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
- package/templates/shared/tests/unit/economics.test.ts +365 -0
- package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
- package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
- package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
- package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
- package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
- package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
- package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
- package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
- package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
- package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
- package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
- package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
- package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
- package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
- package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
- package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
- package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
- package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
- package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
- 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
|
+
}
|