@littlebearapps/platform-admin-sdk 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/dist/templates.d.ts +1 -1
  2. package/dist/templates.js +121 -2
  3. package/package.json +1 -1
  4. package/templates/full/config/audit-targets.yaml +72 -0
  5. package/templates/full/dashboard/src/components/notifications/NotificationBell.tsx +30 -0
  6. package/templates/full/dashboard/src/components/notifications/NotificationList.tsx +116 -0
  7. package/templates/full/dashboard/src/components/notifications/index.ts +2 -0
  8. package/templates/full/dashboard/src/components/patterns/PatternStats.tsx +60 -0
  9. package/templates/full/dashboard/src/components/patterns/SuggestionsQueue.tsx +115 -0
  10. package/templates/full/dashboard/src/components/patterns/index.ts +2 -0
  11. package/templates/full/dashboard/src/components/search/SearchModal.tsx +108 -0
  12. package/templates/full/dashboard/src/pages/api/notifications/index.ts +47 -0
  13. package/templates/full/dashboard/src/pages/api/notifications/unread-count.ts +31 -0
  14. package/templates/full/dashboard/src/pages/api/patterns/approve.ts +55 -0
  15. package/templates/full/dashboard/src/pages/api/patterns/index.ts +36 -0
  16. package/templates/full/dashboard/src/pages/api/patterns/reject.ts +54 -0
  17. package/templates/full/dashboard/src/pages/api/search/index.ts +74 -0
  18. package/templates/full/dashboard/src/pages/notifications.astro +11 -0
  19. package/templates/full/migrations/008_auditor.sql +99 -0
  20. package/templates/full/migrations/010_pricing_versions.sql +110 -0
  21. package/templates/full/migrations/011_multi_account.sql +51 -0
  22. package/templates/full/scripts/ops/set-kv-pricing.ts +182 -0
  23. package/templates/full/workers/lib/ai-judge-schema.ts +181 -0
  24. package/templates/full/workers/lib/auditor/comprehensive-report.ts +407 -0
  25. package/templates/full/workers/lib/auditor/feature-coverage.ts +348 -0
  26. package/templates/full/workers/lib/auditor/index.ts +9 -0
  27. package/templates/full/workers/lib/auditor/types.ts +167 -0
  28. package/templates/full/workers/platform-auditor.ts +1071 -0
  29. package/templates/full/wrangler.auditor.jsonc.hbs +75 -0
  30. package/templates/shared/.github/workflows/platform-check.yml.hbs +28 -0
  31. package/templates/shared/config/observability.yaml.hbs +276 -0
  32. package/templates/shared/contracts/schemas/envelope.v1.schema.json +64 -0
  33. package/templates/shared/contracts/schemas/error_report.v1.schema.json +65 -0
  34. package/templates/shared/contracts/types/telemetry-envelope.types.ts +139 -0
  35. package/templates/shared/dashboard/astro.config.mjs +21 -0
  36. package/templates/shared/dashboard/package.json.hbs +29 -0
  37. package/templates/shared/dashboard/src/components/Header.astro +29 -0
  38. package/templates/shared/dashboard/src/components/Nav.astro.hbs +57 -0
  39. package/templates/shared/dashboard/src/components/overview/ActivityFeed.tsx +134 -0
  40. package/templates/shared/dashboard/src/components/overview/CostQuadrant.tsx +131 -0
  41. package/templates/shared/dashboard/src/components/overview/ErrorsQuadrant.tsx +113 -0
  42. package/templates/shared/dashboard/src/components/overview/HealthQuadrant.tsx +87 -0
  43. package/templates/shared/dashboard/src/components/overview/MissionControl.tsx +139 -0
  44. package/templates/shared/dashboard/src/components/resources/AllowanceStatus.tsx +44 -0
  45. package/templates/shared/dashboard/src/components/resources/CostCentreOverview.tsx +42 -0
  46. package/templates/shared/dashboard/src/components/resources/ResourceTabs.tsx +69 -0
  47. package/templates/shared/dashboard/src/components/resources/index.ts +3 -0
  48. package/templates/shared/dashboard/src/components/settings/SettingsCard.tsx +21 -0
  49. package/templates/shared/dashboard/src/components/settings/index.ts +1 -0
  50. package/templates/shared/dashboard/src/components/ui/AlertBanner.tsx +39 -0
  51. package/templates/shared/dashboard/src/components/ui/Sparkline.tsx +127 -0
  52. package/templates/shared/dashboard/src/components/ui/StatusDot.tsx +21 -0
  53. package/templates/shared/dashboard/src/components/ui/index.ts +3 -0
  54. package/templates/shared/dashboard/src/env.d.ts.hbs +34 -0
  55. package/templates/shared/dashboard/src/layouts/DashboardLayout.astro +37 -0
  56. package/templates/shared/dashboard/src/lib/fetch.ts +29 -0
  57. package/templates/shared/dashboard/src/lib/types.ts +72 -0
  58. package/templates/shared/dashboard/src/middleware/auth.ts +100 -0
  59. package/templates/shared/dashboard/src/middleware/index.ts +1 -0
  60. package/templates/shared/dashboard/src/pages/api/overview/summary.ts +311 -0
  61. package/templates/shared/dashboard/src/pages/api/usage/circuit-breakers.ts +44 -0
  62. package/templates/shared/dashboard/src/pages/api/usage/status.ts +42 -0
  63. package/templates/shared/dashboard/src/pages/dashboard.astro +11 -0
  64. package/templates/shared/dashboard/src/pages/index.astro +3 -0
  65. package/templates/shared/dashboard/src/pages/resources.astro +11 -0
  66. package/templates/shared/dashboard/src/pages/settings/index.astro +28 -0
  67. package/templates/shared/dashboard/src/styles/global.css +29 -0
  68. package/templates/shared/dashboard/tailwind.config.mjs +9 -0
  69. package/templates/shared/dashboard/tsconfig.json +9 -0
  70. package/templates/shared/dashboard/wrangler.json.hbs +47 -0
  71. package/templates/shared/package.json.hbs +12 -1
  72. package/templates/shared/scripts/ops/backfill-cloudflare-hourly.ts +473 -0
  73. package/templates/shared/scripts/ops/discover-graphql-datasets.ts +482 -0
  74. package/templates/shared/scripts/ops/reset-budget-state.ts +279 -0
  75. package/templates/shared/scripts/ops/validate-pipeline.ts +237 -0
  76. package/templates/shared/scripts/ops/verify-account-completeness.ts +236 -0
  77. package/templates/shared/scripts/validate-schemas.js +61 -0
  78. package/templates/shared/workers/lib/usage/collectors/anthropic.ts +114 -0
  79. package/templates/shared/workers/lib/usage/collectors/apify.ts +96 -0
  80. package/templates/shared/workers/lib/usage/collectors/custom-http.ts +151 -0
  81. package/templates/shared/workers/lib/usage/collectors/deepseek.ts +92 -0
  82. package/templates/shared/workers/lib/usage/collectors/gemini.ts +263 -0
  83. package/templates/shared/workers/lib/usage/collectors/github.ts +362 -0
  84. package/templates/shared/workers/lib/usage/collectors/index.ts +31 -15
  85. package/templates/shared/workers/lib/usage/collectors/minimax.ts +106 -0
  86. package/templates/shared/workers/lib/usage/collectors/openai.ts +171 -0
  87. package/templates/shared/workers/lib/usage/collectors/resend.ts +79 -0
  88. package/templates/shared/workers/lib/usage/collectors/stripe.ts +192 -0
  89. package/templates/shared/workers/lib/usage/shared/types.ts +46 -0
  90. package/templates/shared/workers/platform-usage.ts +98 -8
  91. package/templates/standard/dashboard/src/components/errors/ErrorStats.tsx +53 -0
  92. package/templates/standard/dashboard/src/components/errors/ErrorsTable.tsx +133 -0
  93. package/templates/standard/dashboard/src/components/errors/index.ts +2 -0
  94. package/templates/standard/dashboard/src/components/health/DlqStatusCard.tsx +52 -0
  95. package/templates/standard/dashboard/src/components/health/HealthTabs.tsx +86 -0
  96. package/templates/standard/dashboard/src/components/health/index.ts +2 -0
  97. package/templates/standard/dashboard/src/lib/errors.ts +28 -0
  98. package/templates/standard/dashboard/src/pages/api/errors/index.ts +58 -0
  99. package/templates/standard/dashboard/src/pages/api/errors/stats.ts +55 -0
  100. package/templates/standard/dashboard/src/pages/api/health/dlq.ts +43 -0
  101. package/templates/standard/dashboard/src/pages/errors.astro +13 -0
  102. package/templates/standard/dashboard/src/pages/health.astro +11 -0
  103. package/templates/standard/migrations/009_topology_mapper.sql +65 -0
  104. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +37 -3
  105. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +32 -1
  106. package/templates/standard/workers/lib/mapper/attribution-check.ts +339 -0
  107. package/templates/standard/workers/lib/mapper/index.ts +7 -0
  108. package/templates/standard/workers/platform-mapper.ts +482 -0
  109. package/templates/standard/workers/platform-sdk-test-client.ts +125 -0
  110. package/templates/standard/wrangler.mapper.jsonc.hbs +85 -0
  111. package/templates/standard/wrangler.sdk-test-client.jsonc.hbs +62 -0
@@ -0,0 +1,473 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Cloudflare Hourly Usage Backfill Script
4
+ *
5
+ * Backfills hourly Cloudflare usage data into hourly_usage_snapshots via the
6
+ * Cloudflare GraphQL Analytics API and D1 REST API.
7
+ *
8
+ * Queries Workers, D1, KV, R2, and Durable Objects metrics per hour, calculates
9
+ * estimated costs, and upserts into D1. Skips hours that already have data.
10
+ *
11
+ * Prerequisites:
12
+ * CLOUDFLARE_API_TOKEN — API token with Analytics:Read + D1:Write permissions
13
+ * CLOUDFLARE_ACCOUNT_ID — Your Cloudflare account ID
14
+ * D1_DATABASE_ID — Your platform-metrics D1 database ID
15
+ *
16
+ * Usage:
17
+ * npx tsx scripts/ops/backfill-cloudflare-hourly.ts
18
+ * npx tsx scripts/ops/backfill-cloudflare-hourly.ts --dry-run
19
+ * npx tsx scripts/ops/backfill-cloudflare-hourly.ts --start 2026-02-01 --end 2026-02-28
20
+ * npx tsx scripts/ops/backfill-cloudflare-hourly.ts --days 7
21
+ *
22
+ * Note: Cloudflare GraphQL hourly data is retained for ~7 days. Older dates
23
+ * will return zeros. The script handles this gracefully.
24
+ */
25
+
26
+ const GRAPHQL_ENDPOINT = 'https://api.cloudflare.com/client/v4/graphql';
27
+ const REST_API_BASE = 'https://api.cloudflare.com/client/v4';
28
+
29
+ // Rate limiting
30
+ const RATE_LIMIT_MS = 500;
31
+ const BUDGET_WAIT_MS = 310_000;
32
+
33
+ // Pricing constants (Cloudflare Workers Paid Plan — current as of March 2026)
34
+ const CF_PRICING = {
35
+ workers: {
36
+ baseCostMonthly: 5.0,
37
+ includedRequests: 10_000_000,
38
+ requestsPerMillion: 0.3,
39
+ cpuMsPerMillion: 0.02,
40
+ },
41
+ d1: {
42
+ rowsReadPerBillion: 0.001,
43
+ rowsWrittenPerMillion: 1.0,
44
+ },
45
+ kv: {
46
+ readsPerMillion: 0.5,
47
+ writesPerMillion: 5.0,
48
+ deletesPerMillion: 5.0,
49
+ listsPerMillion: 5.0,
50
+ },
51
+ r2: {
52
+ classAPerMillion: 4.5,
53
+ classBPerMillion: 0.36,
54
+ },
55
+ durableObjects: {
56
+ requestsPerMillion: 0.15,
57
+ gbSecondsPerMillion: 12.5,
58
+ },
59
+ };
60
+
61
+ interface HourlyMetrics {
62
+ hour: string;
63
+ workers: {
64
+ requests: number;
65
+ errors: number;
66
+ cpuTimeMs: number;
67
+ duration50thMs: number;
68
+ duration99thMs: number;
69
+ };
70
+ d1: { rowsRead: number; rowsWritten: number };
71
+ kv: { reads: number; writes: number; deletes: number; lists: number };
72
+ r2: { classAOps: number; classBOps: number; egressBytes: number };
73
+ durableObjects: { requests: number; gbSeconds: number };
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // CLI argument parsing
78
+ // ---------------------------------------------------------------------------
79
+
80
+ function parseArgs(): { startDate: string; endDate: string; dryRun: boolean } {
81
+ const args = process.argv.slice(2);
82
+ const now = new Date();
83
+ let startDate = '';
84
+ let endDate = '';
85
+ let dryRun = false;
86
+ let days = 7; // default lookback
87
+
88
+ for (let i = 0; i < args.length; i++) {
89
+ if (args[i] === '--start' && args[i + 1]) {
90
+ startDate = args[++i];
91
+ } else if (args[i] === '--end' && args[i + 1]) {
92
+ endDate = args[++i];
93
+ } else if (args[i] === '--days' && args[i + 1]) {
94
+ days = parseInt(args[++i], 10);
95
+ } else if (args[i] === '--dry-run') {
96
+ dryRun = true;
97
+ }
98
+ }
99
+
100
+ if (!startDate) {
101
+ const start = new Date(now);
102
+ start.setDate(start.getDate() - days);
103
+ startDate = start.toISOString().split('T')[0];
104
+ }
105
+ if (!endDate) {
106
+ const end = new Date(now);
107
+ end.setDate(end.getDate() - 1);
108
+ endDate = end.toISOString().split('T')[0];
109
+ }
110
+
111
+ return { startDate, endDate, dryRun };
112
+ }
113
+
114
+ function sleep(ms: number): Promise<void> {
115
+ return new Promise((resolve) => setTimeout(resolve, ms));
116
+ }
117
+
118
+ function generateId(): string {
119
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
120
+ const r = (Math.random() * 16) | 0;
121
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
122
+ return v.toString(16);
123
+ });
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // GraphQL helper with retry + budget-depleted handling
128
+ // ---------------------------------------------------------------------------
129
+
130
+ async function graphqlQuery(
131
+ apiToken: string,
132
+ query: string,
133
+ variables: Record<string, unknown>,
134
+ maxRetries = 3
135
+ ): Promise<unknown> {
136
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
137
+ const response = await fetch(GRAPHQL_ENDPOINT, {
138
+ method: 'POST',
139
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiToken}` },
140
+ body: JSON.stringify({ query, variables }),
141
+ });
142
+
143
+ if (!response.ok) {
144
+ throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`);
145
+ }
146
+
147
+ const result = (await response.json()) as {
148
+ data?: unknown;
149
+ errors?: Array<{ message: string; extensions?: { code?: string } }>;
150
+ };
151
+
152
+ if (result.errors) {
153
+ const budgetError = result.errors.find(
154
+ (e) => e.message?.includes('budget depleted') || e.extensions?.code === 'budget'
155
+ );
156
+ if (budgetError && attempt < maxRetries) {
157
+ console.log(` ⏳ Rate limit hit — waiting 5 min before retry (${attempt}/${maxRetries})…`);
158
+ await sleep(BUDGET_WAIT_MS);
159
+ continue;
160
+ }
161
+ throw new Error(`GraphQL errors: ${JSON.stringify(result.errors)}`);
162
+ }
163
+
164
+ return result.data;
165
+ }
166
+ throw new Error('Max retries exceeded');
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Fetch hourly metrics from Cloudflare GraphQL
171
+ // ---------------------------------------------------------------------------
172
+
173
+ async function fetchHourlyMetrics(
174
+ apiToken: string,
175
+ accountId: string,
176
+ startHour: string,
177
+ endHour: string
178
+ ): Promise<Map<string, HourlyMetrics>> {
179
+ const metricsMap = new Map<string, HourlyMetrics>();
180
+
181
+ // Initialise all hours in range
182
+ const start = new Date(startHour);
183
+ const end = new Date(endHour);
184
+ for (let d = new Date(start); d <= end; d.setTime(d.getTime() + 3_600_000)) {
185
+ const hour = d.toISOString().replace(/:\d{2}\.\d{3}Z$/, ':00Z');
186
+ metricsMap.set(hour, {
187
+ hour,
188
+ workers: { requests: 0, errors: 0, cpuTimeMs: 0, duration50thMs: 0, duration99thMs: 0 },
189
+ d1: { rowsRead: 0, rowsWritten: 0 },
190
+ kv: { reads: 0, writes: 0, deletes: 0, lists: 0 },
191
+ r2: { classAOps: 0, classBOps: 0, egressBytes: 0 },
192
+ durableObjects: { requests: 0, gbSeconds: 0 },
193
+ });
194
+ }
195
+
196
+ const vars = { accountTag: accountId, startHour, endHour };
197
+
198
+ // Workers
199
+ try {
200
+ const workersQuery = `query($accountTag:String!,$startHour:Time!,$endHour:Time!){viewer{accounts(filter:{accountTag:$accountTag}){workersInvocationsAdaptive(filter:{datetimeHour_geq:$startHour,datetimeHour_leq:$endHour},limit:10000){sum{requests,errors}quantiles{cpuTimeP50,durationP50,durationP99}dimensions{datetimeHour}}}}}`;
201
+ const data = (await graphqlQuery(apiToken, workersQuery, vars)) as Record<string, unknown>;
202
+ const accounts = (data as { viewer?: { accounts?: unknown[] } })?.viewer?.accounts as
203
+ | Array<{ workersInvocationsAdaptive?: Array<{ sum: { requests: number; errors: number }; quantiles: { cpuTimeP50: number; durationP50: number; durationP99: number }; dimensions: { datetimeHour: string } }> }>
204
+ | undefined;
205
+ for (const w of accounts?.[0]?.workersInvocationsAdaptive ?? []) {
206
+ const m = metricsMap.get(w.dimensions.datetimeHour);
207
+ if (m) {
208
+ m.workers.requests += w.sum?.requests ?? 0;
209
+ m.workers.errors += w.sum?.errors ?? 0;
210
+ m.workers.cpuTimeMs += (w.quantiles?.cpuTimeP50 ?? 0) / 1000;
211
+ m.workers.duration50thMs = w.quantiles?.durationP50 ?? 0;
212
+ m.workers.duration99thMs = w.quantiles?.durationP99 ?? 0;
213
+ }
214
+ }
215
+ } catch (e) { console.warn(` Workers query failed: ${e}`); }
216
+ await sleep(RATE_LIMIT_MS);
217
+
218
+ // D1
219
+ try {
220
+ const d1Query = `query($accountTag:String!,$startHour:Time!,$endHour:Time!){viewer{accounts(filter:{accountTag:$accountTag}){d1AnalyticsAdaptiveGroups(filter:{datetimeHour_geq:$startHour,datetimeHour_leq:$endHour},limit:10000){sum{rowsRead,rowsWritten}dimensions{datetimeHour}}}}}`;
221
+ const data = (await graphqlQuery(apiToken, d1Query, vars)) as Record<string, unknown>;
222
+ const accounts = (data as { viewer?: { accounts?: unknown[] } })?.viewer?.accounts as
223
+ | Array<{ d1AnalyticsAdaptiveGroups?: Array<{ sum: { rowsRead: number; rowsWritten: number }; dimensions: { datetimeHour: string } }> }>
224
+ | undefined;
225
+ for (const d of accounts?.[0]?.d1AnalyticsAdaptiveGroups ?? []) {
226
+ const m = metricsMap.get(d.dimensions.datetimeHour);
227
+ if (m) {
228
+ m.d1.rowsRead += d.sum?.rowsRead ?? 0;
229
+ m.d1.rowsWritten += d.sum?.rowsWritten ?? 0;
230
+ }
231
+ }
232
+ } catch (e) { console.warn(` D1 query failed: ${e}`); }
233
+ await sleep(RATE_LIMIT_MS);
234
+
235
+ // KV
236
+ try {
237
+ const kvQuery = `query($accountTag:String!,$startHour:Time!,$endHour:Time!){viewer{accounts(filter:{accountTag:$accountTag}){kvOperationsAdaptiveGroups(filter:{datetimeHour_geq:$startHour,datetimeHour_leq:$endHour},limit:10000){sum{requests}dimensions{datetimeHour,actionType}}}}}`;
238
+ const data = (await graphqlQuery(apiToken, kvQuery, vars)) as Record<string, unknown>;
239
+ const accounts = (data as { viewer?: { accounts?: unknown[] } })?.viewer?.accounts as
240
+ | Array<{ kvOperationsAdaptiveGroups?: Array<{ sum: { requests: number }; dimensions: { datetimeHour: string; actionType: string } }> }>
241
+ | undefined;
242
+ for (const k of accounts?.[0]?.kvOperationsAdaptiveGroups ?? []) {
243
+ const m = metricsMap.get(k.dimensions.datetimeHour);
244
+ if (m) {
245
+ const action = k.dimensions.actionType ?? '';
246
+ const reqs = k.sum?.requests ?? 0;
247
+ if (action === 'read') m.kv.reads += reqs;
248
+ else if (action === 'write') m.kv.writes += reqs;
249
+ else if (action === 'delete') m.kv.deletes += reqs;
250
+ else if (action === 'list') m.kv.lists += reqs;
251
+ }
252
+ }
253
+ } catch (e) { console.warn(` KV query failed: ${e}`); }
254
+ await sleep(RATE_LIMIT_MS);
255
+
256
+ // R2
257
+ try {
258
+ const r2Query = `query($accountTag:String!,$startHour:Time!,$endHour:Time!){viewer{accounts(filter:{accountTag:$accountTag}){r2OperationsAdaptiveGroups(filter:{datetimeHour_geq:$startHour,datetimeHour_leq:$endHour},limit:10000){sum{requests,responseObjectSize}dimensions{datetimeHour,actionType}}}}}`;
259
+ const data = (await graphqlQuery(apiToken, r2Query, vars)) as Record<string, unknown>;
260
+ const accounts = (data as { viewer?: { accounts?: unknown[] } })?.viewer?.accounts as
261
+ | Array<{ r2OperationsAdaptiveGroups?: Array<{ sum: { requests: number; responseObjectSize: number }; dimensions: { datetimeHour: string; actionType: string } }> }>
262
+ | undefined;
263
+ for (const r of accounts?.[0]?.r2OperationsAdaptiveGroups ?? []) {
264
+ const m = metricsMap.get(r.dimensions.datetimeHour);
265
+ if (m) {
266
+ const action = r.dimensions.actionType?.toUpperCase() ?? '';
267
+ if (['GET', 'HEAD'].includes(action)) {
268
+ m.r2.classBOps += r.sum?.requests ?? 0;
269
+ m.r2.egressBytes += r.sum?.responseObjectSize ?? 0;
270
+ } else {
271
+ m.r2.classAOps += r.sum?.requests ?? 0;
272
+ }
273
+ }
274
+ }
275
+ } catch (e) { console.warn(` R2 query failed: ${e}`); }
276
+ await sleep(RATE_LIMIT_MS);
277
+
278
+ // Durable Objects
279
+ try {
280
+ const doQuery = `query($accountTag:String!,$startHour:Time!,$endHour:Time!){viewer{accounts(filter:{accountTag:$accountTag}){durableObjectsInvocationsAdaptiveGroups(filter:{datetimeHour_geq:$startHour,datetimeHour_leq:$endHour},limit:10000){sum{requests}dimensions{datetimeHour}}}}}`;
281
+ const data = (await graphqlQuery(apiToken, doQuery, vars)) as Record<string, unknown>;
282
+ const accounts = (data as { viewer?: { accounts?: unknown[] } })?.viewer?.accounts as
283
+ | Array<{ durableObjectsInvocationsAdaptiveGroups?: Array<{ sum: { requests: number }; dimensions: { datetimeHour: string } }> }>
284
+ | undefined;
285
+ for (const d of accounts?.[0]?.durableObjectsInvocationsAdaptiveGroups ?? []) {
286
+ const m = metricsMap.get(d.dimensions.datetimeHour);
287
+ if (m) m.durableObjects.requests += d.sum?.requests ?? 0;
288
+ }
289
+ } catch (e) { console.warn(` DO query failed: ${e}`); }
290
+
291
+ return metricsMap;
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Cost calculation
296
+ // ---------------------------------------------------------------------------
297
+
298
+ function calculateHourlyCosts(metrics: HourlyMetrics) {
299
+ const hourlyIncluded = CF_PRICING.workers.includedRequests / 30 / 24;
300
+ const overageReqs = Math.max(0, metrics.workers.requests - hourlyIncluded);
301
+ const workersCost =
302
+ (overageReqs / 1_000_000) * CF_PRICING.workers.requestsPerMillion +
303
+ (metrics.workers.cpuTimeMs / 1_000_000) * CF_PRICING.workers.cpuMsPerMillion;
304
+ const d1Cost =
305
+ (metrics.d1.rowsRead / 1_000_000_000) * CF_PRICING.d1.rowsReadPerBillion +
306
+ (metrics.d1.rowsWritten / 1_000_000) * CF_PRICING.d1.rowsWrittenPerMillion;
307
+ const kvCost =
308
+ (metrics.kv.reads / 1_000_000) * CF_PRICING.kv.readsPerMillion +
309
+ (metrics.kv.writes / 1_000_000) * CF_PRICING.kv.writesPerMillion +
310
+ (metrics.kv.deletes / 1_000_000) * CF_PRICING.kv.deletesPerMillion +
311
+ (metrics.kv.lists / 1_000_000) * CF_PRICING.kv.listsPerMillion;
312
+ const r2Cost =
313
+ (metrics.r2.classAOps / 1_000_000) * CF_PRICING.r2.classAPerMillion +
314
+ (metrics.r2.classBOps / 1_000_000) * CF_PRICING.r2.classBPerMillion;
315
+ const doCost =
316
+ (metrics.durableObjects.requests / 1_000_000) * CF_PRICING.durableObjects.requestsPerMillion +
317
+ (metrics.durableObjects.gbSeconds / 1_000_000) * CF_PRICING.durableObjects.gbSecondsPerMillion;
318
+ const totalCost = workersCost + d1Cost + kvCost + r2Cost + doCost;
319
+ return { workersCost, d1Cost, kvCost, r2Cost, doCost, totalCost };
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // D1 REST API helpers
324
+ // ---------------------------------------------------------------------------
325
+
326
+ async function upsertHourlySnapshot(
327
+ apiToken: string,
328
+ accountId: string,
329
+ databaseId: string,
330
+ metrics: HourlyMetrics,
331
+ costs: ReturnType<typeof calculateHourlyCosts>
332
+ ): Promise<void> {
333
+ const sql = `INSERT INTO hourly_usage_snapshots (
334
+ id, snapshot_hour, project,
335
+ workers_requests, workers_errors, workers_cpu_time_ms,
336
+ workers_duration_p50_ms, workers_duration_p99_ms, workers_cost_usd,
337
+ d1_rows_read, d1_rows_written, d1_cost_usd,
338
+ kv_reads, kv_writes, kv_deletes, kv_list_ops, kv_cost_usd,
339
+ r2_class_a_ops, r2_class_b_ops, r2_egress_bytes, r2_cost_usd,
340
+ do_requests, do_gb_seconds, do_cost_usd,
341
+ total_cost_usd, collection_timestamp, sampling_mode
342
+ ) VALUES (
343
+ '${generateId()}', '${metrics.hour}', 'all',
344
+ ${metrics.workers.requests}, ${metrics.workers.errors}, ${metrics.workers.cpuTimeMs},
345
+ ${metrics.workers.duration50thMs}, ${metrics.workers.duration99thMs}, ${costs.workersCost},
346
+ ${metrics.d1.rowsRead}, ${metrics.d1.rowsWritten}, ${costs.d1Cost},
347
+ ${metrics.kv.reads}, ${metrics.kv.writes}, ${metrics.kv.deletes}, ${metrics.kv.lists}, ${costs.kvCost},
348
+ ${metrics.r2.classAOps}, ${metrics.r2.classBOps}, ${metrics.r2.egressBytes}, ${costs.r2Cost},
349
+ ${metrics.durableObjects.requests}, ${metrics.durableObjects.gbSeconds}, ${costs.doCost},
350
+ ${costs.totalCost}, ${Math.floor(Date.now() / 1000)}, 'BACKFILL'
351
+ ) ON CONFLICT (snapshot_hour, project) DO UPDATE SET
352
+ workers_requests = excluded.workers_requests,
353
+ workers_errors = excluded.workers_errors,
354
+ d1_rows_read = excluded.d1_rows_read,
355
+ d1_rows_written = excluded.d1_rows_written,
356
+ total_cost_usd = excluded.total_cost_usd`;
357
+
358
+ const response = await fetch(
359
+ `${REST_API_BASE}/accounts/${accountId}/d1/database/${databaseId}/query`,
360
+ {
361
+ method: 'POST',
362
+ headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
363
+ body: JSON.stringify({ sql }),
364
+ }
365
+ );
366
+ if (!response.ok) {
367
+ const error = await response.text();
368
+ throw new Error(`D1 upsert failed: ${response.status} ${error}`);
369
+ }
370
+ }
371
+
372
+ async function hasExistingData(
373
+ apiToken: string,
374
+ accountId: string,
375
+ databaseId: string,
376
+ hour: string
377
+ ): Promise<boolean> {
378
+ const sql = `SELECT workers_requests FROM hourly_usage_snapshots WHERE snapshot_hour = '${hour}' AND project = 'all' AND workers_requests > 0`;
379
+ const response = await fetch(
380
+ `${REST_API_BASE}/accounts/${accountId}/d1/database/${databaseId}/query`,
381
+ {
382
+ method: 'POST',
383
+ headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
384
+ body: JSON.stringify({ sql }),
385
+ }
386
+ );
387
+ if (!response.ok) return false;
388
+ const result = (await response.json()) as { result?: Array<{ results?: unknown[] }> };
389
+ return (result?.result?.[0]?.results?.length ?? 0) > 0;
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Main
394
+ // ---------------------------------------------------------------------------
395
+
396
+ async function main(): Promise<void> {
397
+ const { startDate, endDate, dryRun } = parseArgs();
398
+
399
+ const apiToken = process.env.CLOUDFLARE_API_TOKEN;
400
+ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
401
+ const databaseId = process.env.D1_DATABASE_ID;
402
+
403
+ if (!apiToken || !accountId || !databaseId) {
404
+ console.error('Error: Required environment variables:');
405
+ if (!apiToken) console.error(' CLOUDFLARE_API_TOKEN');
406
+ if (!accountId) console.error(' CLOUDFLARE_ACCOUNT_ID');
407
+ if (!databaseId) console.error(' D1_DATABASE_ID');
408
+ process.exit(1);
409
+ }
410
+
411
+ console.log('='.repeat(60));
412
+ console.log('Cloudflare Hourly Usage Backfill');
413
+ console.log('='.repeat(60));
414
+ console.log(`Start Date: ${startDate}`);
415
+ console.log(`End Date: ${endDate}`);
416
+ console.log(`Account: ${accountId}`);
417
+ console.log(`Database: ${databaseId}`);
418
+ console.log(`Dry Run: ${dryRun}`);
419
+ console.log('='.repeat(60));
420
+ console.log('');
421
+
422
+ const startHour = `${startDate}T00:00:00Z`;
423
+ const endHour = `${endDate}T23:00:00Z`;
424
+
425
+ console.log('Fetching hourly metrics from Cloudflare GraphQL…');
426
+ const metricsMap = await fetchHourlyMetrics(apiToken, accountId, startHour, endHour);
427
+ console.log(` Received data for ${metricsMap.size} hours`);
428
+ console.log('');
429
+
430
+ let processed = 0;
431
+ let skipped = 0;
432
+ let errors = 0;
433
+
434
+ for (const [hour, metrics] of metricsMap) {
435
+ process.stdout.write(`[${processed + skipped + 1}/${metricsMap.size}] ${hour}: `);
436
+
437
+ if (!dryRun) {
438
+ const hasData = await hasExistingData(apiToken, accountId, databaseId, hour);
439
+ if (hasData) {
440
+ console.log('SKIPPED (already has data)');
441
+ skipped++;
442
+ continue;
443
+ }
444
+ }
445
+
446
+ try {
447
+ const costs = calculateHourlyCosts(metrics);
448
+ console.log(
449
+ `requests=${metrics.workers.requests}, d1_reads=${metrics.d1.rowsRead}, cost=$${costs.totalCost.toFixed(6)}`
450
+ );
451
+ if (!dryRun) {
452
+ await upsertHourlySnapshot(apiToken, accountId, databaseId, metrics, costs);
453
+ }
454
+ processed++;
455
+ } catch (e) {
456
+ console.log(`ERROR: ${e}`);
457
+ errors++;
458
+ }
459
+
460
+ await sleep(100);
461
+ }
462
+
463
+ console.log('');
464
+ console.log('='.repeat(60));
465
+ console.log(`Processed: ${processed} Skipped: ${skipped} Errors: ${errors} Total: ${metricsMap.size}`);
466
+ console.log('='.repeat(60));
467
+ if (dryRun) console.log('\nDRY RUN — no data was written to D1');
468
+ }
469
+
470
+ main().catch((e) => {
471
+ console.error('Fatal error:', e);
472
+ process.exit(1);
473
+ });