@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.
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +232 -2
- package/package.json +1 -1
- package/templates/full/config/audit-targets.yaml +72 -0
- package/templates/full/dashboard/src/components/notifications/NotificationBell.tsx +30 -0
- package/templates/full/dashboard/src/components/notifications/NotificationList.tsx +116 -0
- package/templates/full/dashboard/src/components/notifications/index.ts +2 -0
- package/templates/full/dashboard/src/components/patterns/ActivePatterns.tsx +62 -0
- package/templates/full/dashboard/src/components/patterns/PatternStats.tsx +60 -0
- package/templates/full/dashboard/src/components/patterns/PatternTabs.tsx +116 -0
- package/templates/full/dashboard/src/components/patterns/SuggestionsQueue.tsx +115 -0
- package/templates/full/dashboard/src/components/patterns/SystemPatterns.tsx +52 -0
- package/templates/full/dashboard/src/components/patterns/index.ts +5 -0
- package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -0
- package/templates/full/dashboard/src/components/reports/SdkAuditReport.tsx +72 -0
- package/templates/full/dashboard/src/components/reports/index.ts +2 -0
- package/templates/full/dashboard/src/components/search/SearchModal.tsx +108 -0
- package/templates/full/dashboard/src/pages/api/notifications/[id]/read.ts +37 -0
- package/templates/full/dashboard/src/pages/api/notifications/index.ts +47 -0
- package/templates/full/dashboard/src/pages/api/notifications/read-all.ts +28 -0
- package/templates/full/dashboard/src/pages/api/notifications/unread-count.ts +31 -0
- package/templates/full/dashboard/src/pages/api/patterns/approve.ts +55 -0
- package/templates/full/dashboard/src/pages/api/patterns/cache-refresh.ts +38 -0
- package/templates/full/dashboard/src/pages/api/patterns/discover.ts +36 -0
- package/templates/full/dashboard/src/pages/api/patterns/index.ts +36 -0
- package/templates/full/dashboard/src/pages/api/patterns/ready-for-review.ts +39 -0
- package/templates/full/dashboard/src/pages/api/patterns/reject.ts +54 -0
- package/templates/full/dashboard/src/pages/api/patterns/stats.ts +39 -0
- package/templates/full/dashboard/src/pages/api/patterns/suggestions.ts +43 -0
- package/templates/full/dashboard/src/pages/api/reports/audit.ts +45 -0
- package/templates/full/dashboard/src/pages/api/reports/usage.ts +52 -0
- package/templates/full/dashboard/src/pages/api/search/index.ts +74 -0
- package/templates/full/dashboard/src/pages/api/search/reindex.ts +28 -0
- package/templates/full/dashboard/src/pages/api/search/stats.ts +27 -0
- package/templates/full/dashboard/src/pages/api/settings/index.ts +37 -0
- package/templates/full/dashboard/src/pages/api/settings/update.ts +41 -0
- package/templates/full/dashboard/src/pages/api/topology/index.ts +56 -0
- package/templates/full/dashboard/src/pages/notifications.astro +11 -0
- package/templates/full/migrations/008_auditor.sql +99 -0
- package/templates/full/migrations/010_pricing_versions.sql +110 -0
- package/templates/full/migrations/011_multi_account.sql +51 -0
- package/templates/full/scripts/ops/set-kv-pricing.ts +182 -0
- package/templates/full/scripts/ops/universal-backfill.ts +147 -0
- package/templates/full/workers/lib/ai-judge-schema.ts +181 -0
- package/templates/full/workers/lib/auditor/comprehensive-report.ts +407 -0
- package/templates/full/workers/lib/auditor/feature-coverage.ts +348 -0
- package/templates/full/workers/lib/auditor/index.ts +9 -0
- package/templates/full/workers/lib/auditor/types.ts +167 -0
- package/templates/full/workers/platform-auditor.ts +1071 -0
- package/templates/full/wrangler.auditor.jsonc.hbs +75 -0
- package/templates/shared/.github/workflows/contract-check.yml.hbs +42 -0
- package/templates/shared/.github/workflows/dashboard-deploy.yml.hbs +39 -0
- package/templates/shared/.github/workflows/platform-check.yml.hbs +28 -0
- package/templates/shared/.github/workflows/security.yml +33 -0
- package/templates/shared/config/observability.yaml.hbs +276 -0
- package/templates/shared/contracts/schemas/envelope.v1.schema.json +64 -0
- package/templates/shared/contracts/schemas/error_report.v1.schema.json +65 -0
- package/templates/shared/contracts/types/telemetry-envelope.types.ts +139 -0
- package/templates/shared/dashboard/astro.config.mjs +21 -0
- package/templates/shared/dashboard/package.json.hbs +29 -0
- package/templates/shared/dashboard/src/components/Header.astro +29 -0
- package/templates/shared/dashboard/src/components/Nav.astro.hbs +59 -0
- package/templates/shared/dashboard/src/components/infrastructure/AlertHistory.tsx +57 -0
- package/templates/shared/dashboard/src/components/infrastructure/InfrastructureStats.tsx +73 -0
- package/templates/shared/dashboard/src/components/infrastructure/ServiceRegistry.tsx +55 -0
- package/templates/shared/dashboard/src/components/infrastructure/UptimeStatus.tsx +56 -0
- package/templates/shared/dashboard/src/components/infrastructure/index.ts +4 -0
- package/templates/shared/dashboard/src/components/overview/ActivityFeed.tsx +134 -0
- package/templates/shared/dashboard/src/components/overview/CostQuadrant.tsx +131 -0
- package/templates/shared/dashboard/src/components/overview/ErrorsQuadrant.tsx +113 -0
- package/templates/shared/dashboard/src/components/overview/HealthQuadrant.tsx +87 -0
- package/templates/shared/dashboard/src/components/overview/MissionControl.tsx +139 -0
- package/templates/shared/dashboard/src/components/resources/AllowanceStatus.tsx +44 -0
- package/templates/shared/dashboard/src/components/resources/CostCentreOverview.tsx +42 -0
- package/templates/shared/dashboard/src/components/resources/ResourceTabs.tsx +69 -0
- package/templates/shared/dashboard/src/components/resources/index.ts +3 -0
- package/templates/shared/dashboard/src/components/settings/SettingsCard.tsx +21 -0
- package/templates/shared/dashboard/src/components/settings/index.ts +1 -0
- package/templates/shared/dashboard/src/components/ui/AlertBanner.tsx +39 -0
- package/templates/shared/dashboard/src/components/ui/Breadcrumbs.tsx +27 -0
- package/templates/shared/dashboard/src/components/ui/EmptyState.tsx +26 -0
- package/templates/shared/dashboard/src/components/ui/ErrorBoundary.tsx +42 -0
- package/templates/shared/dashboard/src/components/ui/LoadingSkeleton.tsx +18 -0
- package/templates/shared/dashboard/src/components/ui/PageShell.tsx +26 -0
- package/templates/shared/dashboard/src/components/ui/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/ui/StatusDot.tsx +21 -0
- package/templates/shared/dashboard/src/components/ui/Toast.tsx +44 -0
- package/templates/shared/dashboard/src/components/ui/index.ts +9 -0
- package/templates/shared/dashboard/src/components/usage/AnomaliesWidget.tsx +68 -0
- package/templates/shared/dashboard/src/components/usage/HourlyUsageChart.tsx +55 -0
- package/templates/shared/dashboard/src/components/usage/PlanAllowanceDashboard.tsx +67 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCostBreakdown.tsx +55 -0
- package/templates/shared/dashboard/src/components/usage/index.ts +4 -0
- package/templates/shared/dashboard/src/env.d.ts.hbs +34 -0
- package/templates/shared/dashboard/src/layouts/DashboardLayout.astro +37 -0
- package/templates/shared/dashboard/src/lib/cloudflare/costs.ts +21 -0
- package/templates/shared/dashboard/src/lib/fetch.ts +29 -0
- package/templates/shared/dashboard/src/lib/types.ts +72 -0
- package/templates/shared/dashboard/src/middleware/auth.ts +100 -0
- package/templates/shared/dashboard/src/middleware/index.ts +1 -0
- package/templates/shared/dashboard/src/pages/api/costs/overview.ts +65 -0
- package/templates/shared/dashboard/src/pages/api/costs/providers.ts +47 -0
- package/templates/shared/dashboard/src/pages/api/infrastructure/services.ts +55 -0
- package/templates/shared/dashboard/src/pages/api/infrastructure/stats.ts +99 -0
- package/templates/shared/dashboard/src/pages/api/overview/summary.ts +311 -0
- package/templates/shared/dashboard/src/pages/api/usage/allowances.ts +56 -0
- package/templates/shared/dashboard/src/pages/api/usage/anomalies.ts +45 -0
- package/templates/shared/dashboard/src/pages/api/usage/billing.ts +53 -0
- package/templates/shared/dashboard/src/pages/api/usage/circuit-breakers.ts +44 -0
- package/templates/shared/dashboard/src/pages/api/usage/granular.ts +50 -0
- package/templates/shared/dashboard/src/pages/api/usage/hourly.ts +45 -0
- package/templates/shared/dashboard/src/pages/api/usage/projects.ts +51 -0
- package/templates/shared/dashboard/src/pages/api/usage/status.ts +42 -0
- package/templates/shared/dashboard/src/pages/api/user/identity.ts +11 -0
- package/templates/shared/dashboard/src/pages/dashboard.astro +11 -0
- package/templates/shared/dashboard/src/pages/index.astro +3 -0
- package/templates/shared/dashboard/src/pages/resources.astro +11 -0
- package/templates/shared/dashboard/src/pages/settings/index.astro +28 -0
- package/templates/shared/dashboard/src/pages/settings/notifications.astro +34 -0
- package/templates/shared/dashboard/src/pages/settings/thresholds.astro +39 -0
- package/templates/shared/dashboard/src/pages/settings/usage.astro +28 -0
- package/templates/shared/dashboard/src/styles/global.css +29 -0
- package/templates/shared/dashboard/tailwind.config.mjs +9 -0
- package/templates/shared/dashboard/tsconfig.json +9 -0
- package/templates/shared/dashboard/wrangler.json.hbs +47 -0
- package/templates/shared/docs/architecture.md +89 -0
- package/templates/shared/docs/post-deploy-runbook.md +126 -0
- package/templates/shared/docs/troubleshooting.md +91 -0
- package/templates/shared/package.json.hbs +17 -1
- package/templates/shared/scripts/ops/backfill-cloudflare-daily.ts +145 -0
- package/templates/shared/scripts/ops/backfill-cloudflare-hourly.ts +473 -0
- package/templates/shared/scripts/ops/backfill-monthly-rollups.ts +125 -0
- package/templates/shared/scripts/ops/discover-graphql-datasets.ts +482 -0
- package/templates/shared/scripts/ops/reset-budget-state.ts +279 -0
- package/templates/shared/scripts/ops/validate-controls.js +141 -0
- package/templates/shared/scripts/ops/validate-pipeline.ts +237 -0
- package/templates/shared/scripts/ops/verify-account-completeness.ts +236 -0
- package/templates/shared/scripts/validate-schemas.js +61 -0
- package/templates/shared/tests/contract/validate-schemas.test.ts +130 -0
- package/templates/shared/tests/fixtures/telemetry-envelope-invalid.json +9 -0
- package/templates/shared/tests/fixtures/telemetry-envelope-valid.json +27 -0
- package/templates/shared/tests/helpers/mock-d1.ts +61 -0
- package/templates/shared/tests/helpers/mock-kv.ts +37 -0
- package/templates/shared/tests/unit/workers/batch-persistence.test.ts +133 -0
- package/templates/shared/tests/unit/workers/budget-enforcement.test.ts +214 -0
- package/templates/shared/vitest.config.ts +18 -0
- package/templates/shared/workers/lib/usage/collectors/anthropic.ts +114 -0
- package/templates/shared/workers/lib/usage/collectors/apify.ts +96 -0
- package/templates/shared/workers/lib/usage/collectors/custom-http.ts +151 -0
- package/templates/shared/workers/lib/usage/collectors/deepseek.ts +92 -0
- package/templates/shared/workers/lib/usage/collectors/gemini.ts +263 -0
- package/templates/shared/workers/lib/usage/collectors/github.ts +362 -0
- package/templates/shared/workers/lib/usage/collectors/index.ts +31 -15
- package/templates/shared/workers/lib/usage/collectors/minimax.ts +106 -0
- package/templates/shared/workers/lib/usage/collectors/openai.ts +171 -0
- package/templates/shared/workers/lib/usage/collectors/resend.ts +79 -0
- package/templates/shared/workers/lib/usage/collectors/stripe.ts +192 -0
- package/templates/shared/workers/lib/usage/shared/types.ts +46 -0
- package/templates/shared/workers/platform-usage.ts +98 -8
- package/templates/standard/dashboard/src/components/errors/ErrorStats.tsx +53 -0
- package/templates/standard/dashboard/src/components/errors/ErrorsTable.tsx +133 -0
- package/templates/standard/dashboard/src/components/errors/index.ts +2 -0
- package/templates/standard/dashboard/src/components/health/CircuitBreakerEvents.tsx +69 -0
- package/templates/standard/dashboard/src/components/health/CircuitBreakerPanel.tsx +97 -0
- package/templates/standard/dashboard/src/components/health/DlqStatusCard.tsx +52 -0
- package/templates/standard/dashboard/src/components/health/HealthTabs.tsx +86 -0
- package/templates/standard/dashboard/src/components/health/index.ts +4 -0
- package/templates/standard/dashboard/src/lib/errors.ts +28 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/mute.ts +49 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/resolve.ts +36 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint].ts +55 -0
- package/templates/standard/dashboard/src/pages/api/errors/index.ts +58 -0
- package/templates/standard/dashboard/src/pages/api/errors/stats.ts +55 -0
- package/templates/standard/dashboard/src/pages/api/health/audit-history.ts +37 -0
- package/templates/standard/dashboard/src/pages/api/health/dlq.ts +43 -0
- package/templates/standard/dashboard/src/pages/circuit-breakers.astro +13 -0
- package/templates/standard/dashboard/src/pages/errors.astro +13 -0
- package/templates/standard/dashboard/src/pages/health.astro +11 -0
- package/templates/standard/migrations/009_topology_mapper.sql +65 -0
- package/templates/standard/tests/unit/error-collector/capture.test.ts +106 -0
- package/templates/standard/tests/unit/error-collector/fingerprint.test.ts +155 -0
- package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +37 -3
- package/templates/standard/workers/lib/error-collector/gap-alerts.ts +32 -1
- package/templates/standard/workers/lib/mapper/attribution-check.ts +339 -0
- package/templates/standard/workers/lib/mapper/index.ts +7 -0
- package/templates/standard/workers/platform-mapper.ts +482 -0
- package/templates/standard/workers/platform-sdk-test-client.ts +125 -0
- package/templates/standard/wrangler.mapper.jsonc.hbs +85 -0
- 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
|
+
});
|