@littlebearapps/platform-admin-sdk 1.4.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/dist/templates.d.ts +1 -1
  2. package/dist/templates.js +232 -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/ActivePatterns.tsx +62 -0
  9. package/templates/full/dashboard/src/components/patterns/PatternStats.tsx +60 -0
  10. package/templates/full/dashboard/src/components/patterns/PatternTabs.tsx +116 -0
  11. package/templates/full/dashboard/src/components/patterns/SuggestionsQueue.tsx +115 -0
  12. package/templates/full/dashboard/src/components/patterns/SystemPatterns.tsx +52 -0
  13. package/templates/full/dashboard/src/components/patterns/index.ts +5 -0
  14. package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -0
  15. package/templates/full/dashboard/src/components/reports/SdkAuditReport.tsx +72 -0
  16. package/templates/full/dashboard/src/components/reports/index.ts +2 -0
  17. package/templates/full/dashboard/src/components/search/SearchModal.tsx +108 -0
  18. package/templates/full/dashboard/src/pages/api/notifications/[id]/read.ts +37 -0
  19. package/templates/full/dashboard/src/pages/api/notifications/index.ts +47 -0
  20. package/templates/full/dashboard/src/pages/api/notifications/read-all.ts +28 -0
  21. package/templates/full/dashboard/src/pages/api/notifications/unread-count.ts +31 -0
  22. package/templates/full/dashboard/src/pages/api/patterns/approve.ts +55 -0
  23. package/templates/full/dashboard/src/pages/api/patterns/cache-refresh.ts +38 -0
  24. package/templates/full/dashboard/src/pages/api/patterns/discover.ts +36 -0
  25. package/templates/full/dashboard/src/pages/api/patterns/index.ts +36 -0
  26. package/templates/full/dashboard/src/pages/api/patterns/ready-for-review.ts +39 -0
  27. package/templates/full/dashboard/src/pages/api/patterns/reject.ts +54 -0
  28. package/templates/full/dashboard/src/pages/api/patterns/stats.ts +39 -0
  29. package/templates/full/dashboard/src/pages/api/patterns/suggestions.ts +43 -0
  30. package/templates/full/dashboard/src/pages/api/reports/audit.ts +45 -0
  31. package/templates/full/dashboard/src/pages/api/reports/usage.ts +52 -0
  32. package/templates/full/dashboard/src/pages/api/search/index.ts +74 -0
  33. package/templates/full/dashboard/src/pages/api/search/reindex.ts +28 -0
  34. package/templates/full/dashboard/src/pages/api/search/stats.ts +27 -0
  35. package/templates/full/dashboard/src/pages/api/settings/index.ts +37 -0
  36. package/templates/full/dashboard/src/pages/api/settings/update.ts +41 -0
  37. package/templates/full/dashboard/src/pages/api/topology/index.ts +56 -0
  38. package/templates/full/dashboard/src/pages/notifications.astro +11 -0
  39. package/templates/full/migrations/008_auditor.sql +99 -0
  40. package/templates/full/migrations/010_pricing_versions.sql +110 -0
  41. package/templates/full/migrations/011_multi_account.sql +51 -0
  42. package/templates/full/scripts/ops/set-kv-pricing.ts +182 -0
  43. package/templates/full/scripts/ops/universal-backfill.ts +147 -0
  44. package/templates/full/workers/lib/ai-judge-schema.ts +181 -0
  45. package/templates/full/workers/lib/auditor/comprehensive-report.ts +407 -0
  46. package/templates/full/workers/lib/auditor/feature-coverage.ts +348 -0
  47. package/templates/full/workers/lib/auditor/index.ts +9 -0
  48. package/templates/full/workers/lib/auditor/types.ts +167 -0
  49. package/templates/full/workers/platform-auditor.ts +1071 -0
  50. package/templates/full/wrangler.auditor.jsonc.hbs +75 -0
  51. package/templates/shared/.github/workflows/contract-check.yml.hbs +42 -0
  52. package/templates/shared/.github/workflows/dashboard-deploy.yml.hbs +39 -0
  53. package/templates/shared/.github/workflows/platform-check.yml.hbs +28 -0
  54. package/templates/shared/.github/workflows/security.yml +33 -0
  55. package/templates/shared/config/observability.yaml.hbs +276 -0
  56. package/templates/shared/contracts/schemas/envelope.v1.schema.json +64 -0
  57. package/templates/shared/contracts/schemas/error_report.v1.schema.json +65 -0
  58. package/templates/shared/contracts/types/telemetry-envelope.types.ts +139 -0
  59. package/templates/shared/dashboard/astro.config.mjs +21 -0
  60. package/templates/shared/dashboard/package.json.hbs +29 -0
  61. package/templates/shared/dashboard/src/components/Header.astro +29 -0
  62. package/templates/shared/dashboard/src/components/Nav.astro.hbs +59 -0
  63. package/templates/shared/dashboard/src/components/infrastructure/AlertHistory.tsx +57 -0
  64. package/templates/shared/dashboard/src/components/infrastructure/InfrastructureStats.tsx +73 -0
  65. package/templates/shared/dashboard/src/components/infrastructure/ServiceRegistry.tsx +55 -0
  66. package/templates/shared/dashboard/src/components/infrastructure/UptimeStatus.tsx +56 -0
  67. package/templates/shared/dashboard/src/components/infrastructure/index.ts +4 -0
  68. package/templates/shared/dashboard/src/components/overview/ActivityFeed.tsx +134 -0
  69. package/templates/shared/dashboard/src/components/overview/CostQuadrant.tsx +131 -0
  70. package/templates/shared/dashboard/src/components/overview/ErrorsQuadrant.tsx +113 -0
  71. package/templates/shared/dashboard/src/components/overview/HealthQuadrant.tsx +87 -0
  72. package/templates/shared/dashboard/src/components/overview/MissionControl.tsx +139 -0
  73. package/templates/shared/dashboard/src/components/resources/AllowanceStatus.tsx +44 -0
  74. package/templates/shared/dashboard/src/components/resources/CostCentreOverview.tsx +42 -0
  75. package/templates/shared/dashboard/src/components/resources/ResourceTabs.tsx +69 -0
  76. package/templates/shared/dashboard/src/components/resources/index.ts +3 -0
  77. package/templates/shared/dashboard/src/components/settings/SettingsCard.tsx +21 -0
  78. package/templates/shared/dashboard/src/components/settings/index.ts +1 -0
  79. package/templates/shared/dashboard/src/components/ui/AlertBanner.tsx +39 -0
  80. package/templates/shared/dashboard/src/components/ui/Breadcrumbs.tsx +27 -0
  81. package/templates/shared/dashboard/src/components/ui/EmptyState.tsx +26 -0
  82. package/templates/shared/dashboard/src/components/ui/ErrorBoundary.tsx +42 -0
  83. package/templates/shared/dashboard/src/components/ui/LoadingSkeleton.tsx +18 -0
  84. package/templates/shared/dashboard/src/components/ui/PageShell.tsx +26 -0
  85. package/templates/shared/dashboard/src/components/ui/Sparkline.tsx +127 -0
  86. package/templates/shared/dashboard/src/components/ui/StatusDot.tsx +21 -0
  87. package/templates/shared/dashboard/src/components/ui/Toast.tsx +44 -0
  88. package/templates/shared/dashboard/src/components/ui/index.ts +9 -0
  89. package/templates/shared/dashboard/src/components/usage/AnomaliesWidget.tsx +68 -0
  90. package/templates/shared/dashboard/src/components/usage/HourlyUsageChart.tsx +55 -0
  91. package/templates/shared/dashboard/src/components/usage/PlanAllowanceDashboard.tsx +67 -0
  92. package/templates/shared/dashboard/src/components/usage/ProjectCostBreakdown.tsx +55 -0
  93. package/templates/shared/dashboard/src/components/usage/index.ts +4 -0
  94. package/templates/shared/dashboard/src/env.d.ts.hbs +34 -0
  95. package/templates/shared/dashboard/src/layouts/DashboardLayout.astro +37 -0
  96. package/templates/shared/dashboard/src/lib/cloudflare/costs.ts +21 -0
  97. package/templates/shared/dashboard/src/lib/fetch.ts +29 -0
  98. package/templates/shared/dashboard/src/lib/types.ts +72 -0
  99. package/templates/shared/dashboard/src/middleware/auth.ts +100 -0
  100. package/templates/shared/dashboard/src/middleware/index.ts +1 -0
  101. package/templates/shared/dashboard/src/pages/api/costs/overview.ts +65 -0
  102. package/templates/shared/dashboard/src/pages/api/costs/providers.ts +47 -0
  103. package/templates/shared/dashboard/src/pages/api/infrastructure/services.ts +55 -0
  104. package/templates/shared/dashboard/src/pages/api/infrastructure/stats.ts +99 -0
  105. package/templates/shared/dashboard/src/pages/api/overview/summary.ts +311 -0
  106. package/templates/shared/dashboard/src/pages/api/usage/allowances.ts +56 -0
  107. package/templates/shared/dashboard/src/pages/api/usage/anomalies.ts +45 -0
  108. package/templates/shared/dashboard/src/pages/api/usage/billing.ts +53 -0
  109. package/templates/shared/dashboard/src/pages/api/usage/circuit-breakers.ts +44 -0
  110. package/templates/shared/dashboard/src/pages/api/usage/granular.ts +50 -0
  111. package/templates/shared/dashboard/src/pages/api/usage/hourly.ts +45 -0
  112. package/templates/shared/dashboard/src/pages/api/usage/projects.ts +51 -0
  113. package/templates/shared/dashboard/src/pages/api/usage/status.ts +42 -0
  114. package/templates/shared/dashboard/src/pages/api/user/identity.ts +11 -0
  115. package/templates/shared/dashboard/src/pages/dashboard.astro +11 -0
  116. package/templates/shared/dashboard/src/pages/index.astro +3 -0
  117. package/templates/shared/dashboard/src/pages/resources.astro +11 -0
  118. package/templates/shared/dashboard/src/pages/settings/index.astro +28 -0
  119. package/templates/shared/dashboard/src/pages/settings/notifications.astro +34 -0
  120. package/templates/shared/dashboard/src/pages/settings/thresholds.astro +39 -0
  121. package/templates/shared/dashboard/src/pages/settings/usage.astro +28 -0
  122. package/templates/shared/dashboard/src/styles/global.css +29 -0
  123. package/templates/shared/dashboard/tailwind.config.mjs +9 -0
  124. package/templates/shared/dashboard/tsconfig.json +9 -0
  125. package/templates/shared/dashboard/wrangler.json.hbs +47 -0
  126. package/templates/shared/docs/architecture.md +89 -0
  127. package/templates/shared/docs/post-deploy-runbook.md +126 -0
  128. package/templates/shared/docs/troubleshooting.md +91 -0
  129. package/templates/shared/package.json.hbs +17 -1
  130. package/templates/shared/scripts/ops/backfill-cloudflare-daily.ts +145 -0
  131. package/templates/shared/scripts/ops/backfill-cloudflare-hourly.ts +473 -0
  132. package/templates/shared/scripts/ops/backfill-monthly-rollups.ts +125 -0
  133. package/templates/shared/scripts/ops/discover-graphql-datasets.ts +482 -0
  134. package/templates/shared/scripts/ops/reset-budget-state.ts +279 -0
  135. package/templates/shared/scripts/ops/validate-controls.js +141 -0
  136. package/templates/shared/scripts/ops/validate-pipeline.ts +237 -0
  137. package/templates/shared/scripts/ops/verify-account-completeness.ts +236 -0
  138. package/templates/shared/scripts/validate-schemas.js +61 -0
  139. package/templates/shared/tests/contract/validate-schemas.test.ts +130 -0
  140. package/templates/shared/tests/fixtures/telemetry-envelope-invalid.json +9 -0
  141. package/templates/shared/tests/fixtures/telemetry-envelope-valid.json +27 -0
  142. package/templates/shared/tests/helpers/mock-d1.ts +61 -0
  143. package/templates/shared/tests/helpers/mock-kv.ts +37 -0
  144. package/templates/shared/tests/unit/workers/batch-persistence.test.ts +133 -0
  145. package/templates/shared/tests/unit/workers/budget-enforcement.test.ts +214 -0
  146. package/templates/shared/vitest.config.ts +18 -0
  147. package/templates/shared/workers/lib/usage/collectors/anthropic.ts +114 -0
  148. package/templates/shared/workers/lib/usage/collectors/apify.ts +96 -0
  149. package/templates/shared/workers/lib/usage/collectors/custom-http.ts +151 -0
  150. package/templates/shared/workers/lib/usage/collectors/deepseek.ts +92 -0
  151. package/templates/shared/workers/lib/usage/collectors/gemini.ts +263 -0
  152. package/templates/shared/workers/lib/usage/collectors/github.ts +362 -0
  153. package/templates/shared/workers/lib/usage/collectors/index.ts +31 -15
  154. package/templates/shared/workers/lib/usage/collectors/minimax.ts +106 -0
  155. package/templates/shared/workers/lib/usage/collectors/openai.ts +171 -0
  156. package/templates/shared/workers/lib/usage/collectors/resend.ts +79 -0
  157. package/templates/shared/workers/lib/usage/collectors/stripe.ts +192 -0
  158. package/templates/shared/workers/lib/usage/shared/types.ts +46 -0
  159. package/templates/shared/workers/platform-usage.ts +98 -8
  160. package/templates/standard/dashboard/src/components/errors/ErrorStats.tsx +53 -0
  161. package/templates/standard/dashboard/src/components/errors/ErrorsTable.tsx +133 -0
  162. package/templates/standard/dashboard/src/components/errors/index.ts +2 -0
  163. package/templates/standard/dashboard/src/components/health/CircuitBreakerEvents.tsx +69 -0
  164. package/templates/standard/dashboard/src/components/health/CircuitBreakerPanel.tsx +97 -0
  165. package/templates/standard/dashboard/src/components/health/DlqStatusCard.tsx +52 -0
  166. package/templates/standard/dashboard/src/components/health/HealthTabs.tsx +86 -0
  167. package/templates/standard/dashboard/src/components/health/index.ts +4 -0
  168. package/templates/standard/dashboard/src/lib/errors.ts +28 -0
  169. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/mute.ts +49 -0
  170. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/resolve.ts +36 -0
  171. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint].ts +55 -0
  172. package/templates/standard/dashboard/src/pages/api/errors/index.ts +58 -0
  173. package/templates/standard/dashboard/src/pages/api/errors/stats.ts +55 -0
  174. package/templates/standard/dashboard/src/pages/api/health/audit-history.ts +37 -0
  175. package/templates/standard/dashboard/src/pages/api/health/dlq.ts +43 -0
  176. package/templates/standard/dashboard/src/pages/circuit-breakers.astro +13 -0
  177. package/templates/standard/dashboard/src/pages/errors.astro +13 -0
  178. package/templates/standard/dashboard/src/pages/health.astro +11 -0
  179. package/templates/standard/migrations/009_topology_mapper.sql +65 -0
  180. package/templates/standard/tests/unit/error-collector/capture.test.ts +106 -0
  181. package/templates/standard/tests/unit/error-collector/fingerprint.test.ts +155 -0
  182. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +37 -3
  183. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +32 -1
  184. package/templates/standard/workers/lib/mapper/attribution-check.ts +339 -0
  185. package/templates/standard/workers/lib/mapper/index.ts +7 -0
  186. package/templates/standard/workers/platform-mapper.ts +482 -0
  187. package/templates/standard/workers/platform-sdk-test-client.ts +125 -0
  188. package/templates/standard/wrangler.mapper.jsonc.hbs +85 -0
  189. package/templates/standard/wrangler.sdk-test-client.jsonc.hbs +62 -0
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Cloudflare Daily Rollup Backfill Script
4
+ *
5
+ * Aggregates hourly_usage_snapshots into daily_usage_rollups via the D1 REST API.
6
+ * Queries existing hourly data and rolls it up to daily granularity.
7
+ *
8
+ * Prerequisites:
9
+ * CLOUDFLARE_API_TOKEN — API token with D1:Write permissions
10
+ * CLOUDFLARE_ACCOUNT_ID — Your Cloudflare account ID
11
+ * D1_DATABASE_ID — Your platform-metrics D1 database ID
12
+ *
13
+ * Usage:
14
+ * npx tsx scripts/ops/backfill-cloudflare-daily.ts
15
+ * npx tsx scripts/ops/backfill-cloudflare-daily.ts --dry-run
16
+ * npx tsx scripts/ops/backfill-cloudflare-daily.ts --start 2026-02-01 --end 2026-02-28
17
+ * npx tsx scripts/ops/backfill-cloudflare-daily.ts --limit 30
18
+ */
19
+
20
+ const REST_API_BASE = 'https://api.cloudflare.com/client/v4';
21
+ const RATE_LIMIT_MS = 200;
22
+
23
+ interface Args {
24
+ start?: string;
25
+ end?: string;
26
+ dryRun: boolean;
27
+ limit: number;
28
+ }
29
+
30
+ function parseArgs(): Args {
31
+ const args = process.argv.slice(2);
32
+ const result: Args = { dryRun: false, limit: 90 };
33
+
34
+ for (let i = 0; i < args.length; i++) {
35
+ if (args[i] === '--start' && args[i + 1]) result.start = args[++i];
36
+ else if (args[i] === '--end' && args[i + 1]) result.end = args[++i];
37
+ else if (args[i] === '--limit' && args[i + 1]) result.limit = Number(args[++i]);
38
+ else if (args[i] === '--dry-run') result.dryRun = true;
39
+ }
40
+
41
+ if (!result.start) {
42
+ const d = new Date();
43
+ d.setDate(d.getDate() - 30);
44
+ result.start = d.toISOString().slice(0, 10);
45
+ }
46
+ if (!result.end) {
47
+ result.end = new Date().toISOString().slice(0, 10);
48
+ }
49
+
50
+ return result;
51
+ }
52
+
53
+ function getEnvOrThrow(key: string): string {
54
+ const val = process.env[key];
55
+ if (!val) throw new Error(`Missing required env var: ${key}`);
56
+ return val;
57
+ }
58
+
59
+ async function d1Query(accountId: string, dbId: string, token: string, sql: string, params: unknown[] = []) {
60
+ const res = await fetch(`${REST_API_BASE}/accounts/${accountId}/d1/database/${dbId}/query`, {
61
+ method: 'POST',
62
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({ sql, params }),
64
+ });
65
+ if (!res.ok) {
66
+ const text = await res.text();
67
+ throw new Error(`D1 query failed (${res.status}): ${text}`);
68
+ }
69
+ return res.json() as Promise<{ result: Array<{ results: unknown[] }> }>;
70
+ }
71
+
72
+ async function sleep(ms: number) {
73
+ return new Promise((resolve) => setTimeout(resolve, ms));
74
+ }
75
+
76
+ async function main() {
77
+ const args = parseArgs();
78
+ const token = getEnvOrThrow('CLOUDFLARE_API_TOKEN');
79
+ const accountId = getEnvOrThrow('CLOUDFLARE_ACCOUNT_ID');
80
+ const dbId = getEnvOrThrow('D1_DATABASE_ID');
81
+
82
+ console.log(`Backfilling daily rollups: ${args.start} → ${args.end} (limit: ${args.limit}, dry-run: ${args.dryRun})`);
83
+
84
+ // Find dates that have hourly data but no daily rollup
85
+ const missingDays = await d1Query(accountId, dbId, token, `
86
+ SELECT DISTINCT DATE(snapshot_hour) as snapshot_date
87
+ FROM hourly_usage_snapshots
88
+ WHERE snapshot_hour >= ? AND snapshot_hour < ? AND project = 'all'
89
+ AND DATE(snapshot_hour) NOT IN (
90
+ SELECT snapshot_date FROM daily_usage_rollups WHERE project = 'all'
91
+ )
92
+ ORDER BY snapshot_date ASC
93
+ LIMIT ?
94
+ `, [args.start + ' 00:00:00', args.end + ' 23:59:59', args.limit]);
95
+
96
+ const dates = (missingDays.result?.[0]?.results ?? []) as Array<{ snapshot_date: string }>;
97
+ console.log(`Found ${dates.length} dates needing daily rollups`);
98
+
99
+ let inserted = 0;
100
+ for (const { snapshot_date } of dates) {
101
+ const nextDate = new Date(snapshot_date);
102
+ nextDate.setDate(nextDate.getDate() + 1);
103
+ const nextDateStr = nextDate.toISOString().slice(0, 10);
104
+
105
+ if (args.dryRun) {
106
+ console.log(`[DRY-RUN] Would roll up ${snapshot_date}`);
107
+ continue;
108
+ }
109
+
110
+ await d1Query(accountId, dbId, token, `
111
+ INSERT INTO daily_usage_rollups (
112
+ project, snapshot_date,
113
+ d1_reads, d1_writes, kv_reads, kv_writes,
114
+ r2_reads, r2_writes, worker_requests, total_cost_usd
115
+ )
116
+ SELECT
117
+ project, DATE(snapshot_hour) as snapshot_date,
118
+ SUM(d1_reads), SUM(d1_writes), SUM(kv_reads), SUM(kv_writes),
119
+ SUM(r2_reads), SUM(r2_writes), SUM(worker_requests), SUM(total_cost_usd)
120
+ FROM hourly_usage_snapshots
121
+ WHERE snapshot_hour >= ? AND snapshot_hour < ? AND project = 'all'
122
+ GROUP BY project, DATE(snapshot_hour)
123
+ ON CONFLICT (project, snapshot_date) DO UPDATE SET
124
+ d1_reads = excluded.d1_reads,
125
+ d1_writes = excluded.d1_writes,
126
+ kv_reads = excluded.kv_reads,
127
+ kv_writes = excluded.kv_writes,
128
+ r2_reads = excluded.r2_reads,
129
+ r2_writes = excluded.r2_writes,
130
+ worker_requests = excluded.worker_requests,
131
+ total_cost_usd = excluded.total_cost_usd
132
+ `, [snapshot_date + ' 00:00:00', nextDateStr + ' 00:00:00']);
133
+
134
+ inserted++;
135
+ console.log(` Rolled up ${snapshot_date} (${inserted}/${dates.length})`);
136
+ await sleep(RATE_LIMIT_MS);
137
+ }
138
+
139
+ console.log(`Done. Inserted ${inserted} daily rollup rows.`);
140
+ }
141
+
142
+ main().catch((err) => {
143
+ console.error('Fatal error:', err);
144
+ process.exit(1);
145
+ });
@@ -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
+ });