@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.
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +121 -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/PatternStats.tsx +60 -0
- package/templates/full/dashboard/src/components/patterns/SuggestionsQueue.tsx +115 -0
- package/templates/full/dashboard/src/components/patterns/index.ts +2 -0
- package/templates/full/dashboard/src/components/search/SearchModal.tsx +108 -0
- package/templates/full/dashboard/src/pages/api/notifications/index.ts +47 -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/index.ts +36 -0
- package/templates/full/dashboard/src/pages/api/patterns/reject.ts +54 -0
- package/templates/full/dashboard/src/pages/api/search/index.ts +74 -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/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/platform-check.yml.hbs +28 -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 +57 -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/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/ui/StatusDot.tsx +21 -0
- package/templates/shared/dashboard/src/components/ui/index.ts +3 -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/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/overview/summary.ts +311 -0
- package/templates/shared/dashboard/src/pages/api/usage/circuit-breakers.ts +44 -0
- package/templates/shared/dashboard/src/pages/api/usage/status.ts +42 -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/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/package.json.hbs +12 -1
- package/templates/shared/scripts/ops/backfill-cloudflare-hourly.ts +473 -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-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/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/DlqStatusCard.tsx +52 -0
- package/templates/standard/dashboard/src/components/health/HealthTabs.tsx +86 -0
- package/templates/standard/dashboard/src/components/health/index.ts +2 -0
- package/templates/standard/dashboard/src/lib/errors.ts +28 -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/dlq.ts +43 -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/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,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
|
+
});
|