@littlebearapps/platform-admin-sdk 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/dist/templates.d.ts +1 -1
  2. package/dist/templates.js +121 -2
  3. package/package.json +1 -1
  4. package/templates/full/config/audit-targets.yaml +72 -0
  5. package/templates/full/dashboard/src/components/notifications/NotificationBell.tsx +30 -0
  6. package/templates/full/dashboard/src/components/notifications/NotificationList.tsx +116 -0
  7. package/templates/full/dashboard/src/components/notifications/index.ts +2 -0
  8. package/templates/full/dashboard/src/components/patterns/PatternStats.tsx +60 -0
  9. package/templates/full/dashboard/src/components/patterns/SuggestionsQueue.tsx +115 -0
  10. package/templates/full/dashboard/src/components/patterns/index.ts +2 -0
  11. package/templates/full/dashboard/src/components/search/SearchModal.tsx +108 -0
  12. package/templates/full/dashboard/src/pages/api/notifications/index.ts +47 -0
  13. package/templates/full/dashboard/src/pages/api/notifications/unread-count.ts +31 -0
  14. package/templates/full/dashboard/src/pages/api/patterns/approve.ts +55 -0
  15. package/templates/full/dashboard/src/pages/api/patterns/index.ts +36 -0
  16. package/templates/full/dashboard/src/pages/api/patterns/reject.ts +54 -0
  17. package/templates/full/dashboard/src/pages/api/search/index.ts +74 -0
  18. package/templates/full/dashboard/src/pages/notifications.astro +11 -0
  19. package/templates/full/migrations/008_auditor.sql +99 -0
  20. package/templates/full/migrations/010_pricing_versions.sql +110 -0
  21. package/templates/full/migrations/011_multi_account.sql +51 -0
  22. package/templates/full/scripts/ops/set-kv-pricing.ts +182 -0
  23. package/templates/full/workers/lib/ai-judge-schema.ts +181 -0
  24. package/templates/full/workers/lib/auditor/comprehensive-report.ts +407 -0
  25. package/templates/full/workers/lib/auditor/feature-coverage.ts +348 -0
  26. package/templates/full/workers/lib/auditor/index.ts +9 -0
  27. package/templates/full/workers/lib/auditor/types.ts +167 -0
  28. package/templates/full/workers/platform-auditor.ts +1071 -0
  29. package/templates/full/wrangler.auditor.jsonc.hbs +75 -0
  30. package/templates/shared/.github/workflows/platform-check.yml.hbs +28 -0
  31. package/templates/shared/config/observability.yaml.hbs +276 -0
  32. package/templates/shared/contracts/schemas/envelope.v1.schema.json +64 -0
  33. package/templates/shared/contracts/schemas/error_report.v1.schema.json +65 -0
  34. package/templates/shared/contracts/types/telemetry-envelope.types.ts +139 -0
  35. package/templates/shared/dashboard/astro.config.mjs +21 -0
  36. package/templates/shared/dashboard/package.json.hbs +29 -0
  37. package/templates/shared/dashboard/src/components/Header.astro +29 -0
  38. package/templates/shared/dashboard/src/components/Nav.astro.hbs +57 -0
  39. package/templates/shared/dashboard/src/components/overview/ActivityFeed.tsx +134 -0
  40. package/templates/shared/dashboard/src/components/overview/CostQuadrant.tsx +131 -0
  41. package/templates/shared/dashboard/src/components/overview/ErrorsQuadrant.tsx +113 -0
  42. package/templates/shared/dashboard/src/components/overview/HealthQuadrant.tsx +87 -0
  43. package/templates/shared/dashboard/src/components/overview/MissionControl.tsx +139 -0
  44. package/templates/shared/dashboard/src/components/resources/AllowanceStatus.tsx +44 -0
  45. package/templates/shared/dashboard/src/components/resources/CostCentreOverview.tsx +42 -0
  46. package/templates/shared/dashboard/src/components/resources/ResourceTabs.tsx +69 -0
  47. package/templates/shared/dashboard/src/components/resources/index.ts +3 -0
  48. package/templates/shared/dashboard/src/components/settings/SettingsCard.tsx +21 -0
  49. package/templates/shared/dashboard/src/components/settings/index.ts +1 -0
  50. package/templates/shared/dashboard/src/components/ui/AlertBanner.tsx +39 -0
  51. package/templates/shared/dashboard/src/components/ui/Sparkline.tsx +127 -0
  52. package/templates/shared/dashboard/src/components/ui/StatusDot.tsx +21 -0
  53. package/templates/shared/dashboard/src/components/ui/index.ts +3 -0
  54. package/templates/shared/dashboard/src/env.d.ts.hbs +34 -0
  55. package/templates/shared/dashboard/src/layouts/DashboardLayout.astro +37 -0
  56. package/templates/shared/dashboard/src/lib/fetch.ts +29 -0
  57. package/templates/shared/dashboard/src/lib/types.ts +72 -0
  58. package/templates/shared/dashboard/src/middleware/auth.ts +100 -0
  59. package/templates/shared/dashboard/src/middleware/index.ts +1 -0
  60. package/templates/shared/dashboard/src/pages/api/overview/summary.ts +311 -0
  61. package/templates/shared/dashboard/src/pages/api/usage/circuit-breakers.ts +44 -0
  62. package/templates/shared/dashboard/src/pages/api/usage/status.ts +42 -0
  63. package/templates/shared/dashboard/src/pages/dashboard.astro +11 -0
  64. package/templates/shared/dashboard/src/pages/index.astro +3 -0
  65. package/templates/shared/dashboard/src/pages/resources.astro +11 -0
  66. package/templates/shared/dashboard/src/pages/settings/index.astro +28 -0
  67. package/templates/shared/dashboard/src/styles/global.css +29 -0
  68. package/templates/shared/dashboard/tailwind.config.mjs +9 -0
  69. package/templates/shared/dashboard/tsconfig.json +9 -0
  70. package/templates/shared/dashboard/wrangler.json.hbs +47 -0
  71. package/templates/shared/package.json.hbs +12 -1
  72. package/templates/shared/scripts/ops/backfill-cloudflare-hourly.ts +473 -0
  73. package/templates/shared/scripts/ops/discover-graphql-datasets.ts +482 -0
  74. package/templates/shared/scripts/ops/reset-budget-state.ts +279 -0
  75. package/templates/shared/scripts/ops/validate-pipeline.ts +237 -0
  76. package/templates/shared/scripts/ops/verify-account-completeness.ts +236 -0
  77. package/templates/shared/scripts/validate-schemas.js +61 -0
  78. package/templates/shared/workers/lib/usage/collectors/anthropic.ts +114 -0
  79. package/templates/shared/workers/lib/usage/collectors/apify.ts +96 -0
  80. package/templates/shared/workers/lib/usage/collectors/custom-http.ts +151 -0
  81. package/templates/shared/workers/lib/usage/collectors/deepseek.ts +92 -0
  82. package/templates/shared/workers/lib/usage/collectors/gemini.ts +263 -0
  83. package/templates/shared/workers/lib/usage/collectors/github.ts +362 -0
  84. package/templates/shared/workers/lib/usage/collectors/index.ts +31 -15
  85. package/templates/shared/workers/lib/usage/collectors/minimax.ts +106 -0
  86. package/templates/shared/workers/lib/usage/collectors/openai.ts +171 -0
  87. package/templates/shared/workers/lib/usage/collectors/resend.ts +79 -0
  88. package/templates/shared/workers/lib/usage/collectors/stripe.ts +192 -0
  89. package/templates/shared/workers/lib/usage/shared/types.ts +46 -0
  90. package/templates/shared/workers/platform-usage.ts +98 -8
  91. package/templates/standard/dashboard/src/components/errors/ErrorStats.tsx +53 -0
  92. package/templates/standard/dashboard/src/components/errors/ErrorsTable.tsx +133 -0
  93. package/templates/standard/dashboard/src/components/errors/index.ts +2 -0
  94. package/templates/standard/dashboard/src/components/health/DlqStatusCard.tsx +52 -0
  95. package/templates/standard/dashboard/src/components/health/HealthTabs.tsx +86 -0
  96. package/templates/standard/dashboard/src/components/health/index.ts +2 -0
  97. package/templates/standard/dashboard/src/lib/errors.ts +28 -0
  98. package/templates/standard/dashboard/src/pages/api/errors/index.ts +58 -0
  99. package/templates/standard/dashboard/src/pages/api/errors/stats.ts +55 -0
  100. package/templates/standard/dashboard/src/pages/api/health/dlq.ts +43 -0
  101. package/templates/standard/dashboard/src/pages/errors.astro +13 -0
  102. package/templates/standard/dashboard/src/pages/health.astro +11 -0
  103. package/templates/standard/migrations/009_topology_mapper.sql +65 -0
  104. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +37 -3
  105. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +32 -1
  106. package/templates/standard/workers/lib/mapper/attribution-check.ts +339 -0
  107. package/templates/standard/workers/lib/mapper/index.ts +7 -0
  108. package/templates/standard/workers/platform-mapper.ts +482 -0
  109. package/templates/standard/workers/platform-sdk-test-client.ts +125 -0
  110. package/templates/standard/wrangler.mapper.jsonc.hbs +85 -0
  111. package/templates/standard/wrangler.sdk-test-client.jsonc.hbs +62 -0
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Reset Budget State Script
4
+ *
5
+ * Resets circuit breaker state in KV when costs are back to safe levels.
6
+ * Used after cost corrections or billing anomalies to restore normal operation.
7
+ *
8
+ * Safety checks:
9
+ * - Compares MTD cost against soft budget limit before allowing reset
10
+ * - Costs > 1.5x soft limit require --force flag
11
+ * - Dry run by default (must pass --execute to modify KV)
12
+ *
13
+ * Usage:
14
+ * npx tsx scripts/ops/reset-budget-state.ts # Dry run (show state)
15
+ * npx tsx scripts/ops/reset-budget-state.ts --execute # Reset to 'active'
16
+ * npx tsx scripts/ops/reset-budget-state.ts --execute --force # Bypass safety check
17
+ *
18
+ * Environment Variables:
19
+ * CLOUDFLARE_API_TOKEN — API token with D1:Read and KV:Write
20
+ * CLOUDFLARE_ACCOUNT_ID — Your Cloudflare account ID
21
+ * D1_DATABASE_ID — Your platform-metrics D1 database ID
22
+ * KV_NAMESPACE_ID — Your PLATFORM_CACHE KV namespace ID
23
+ */
24
+
25
+ const REST_API_BASE = 'https://api.cloudflare.com/client/v4';
26
+
27
+ // Circuit breaker KV keys to reset
28
+ const CB_KEYS = {
29
+ GLOBAL_STOP: 'GLOBAL_STOP_ALL',
30
+ } as const;
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Types
34
+ // ---------------------------------------------------------------------------
35
+
36
+ interface D1QueryResponse {
37
+ success: boolean;
38
+ errors?: Array<{ message: string }>;
39
+ result: Array<{ results: Record<string, unknown>[]; success: boolean }>;
40
+ }
41
+
42
+ interface CBKeyState {
43
+ key: string;
44
+ name: string;
45
+ currentValue: string | null;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // API helpers
50
+ // ---------------------------------------------------------------------------
51
+
52
+ async function queryD1(
53
+ sql: string,
54
+ accountId: string,
55
+ apiToken: string,
56
+ databaseId: string
57
+ ): Promise<Record<string, unknown>[]> {
58
+ const url = `${REST_API_BASE}/accounts/${accountId}/d1/database/${databaseId}/query`;
59
+ const response = await fetch(url, {
60
+ method: 'POST',
61
+ headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({ sql }),
63
+ });
64
+ const body = (await response.json()) as D1QueryResponse;
65
+ if (!body.success) {
66
+ throw new Error(`D1 query failed: ${body.errors?.map((e) => e.message).join(', ') ?? 'Unknown'}`);
67
+ }
68
+ return body.result[0]?.results ?? [];
69
+ }
70
+
71
+ async function getKVKey(
72
+ key: string,
73
+ accountId: string,
74
+ apiToken: string,
75
+ namespaceId: string
76
+ ): Promise<string | null> {
77
+ const url = `${REST_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`;
78
+ const response = await fetch(url, { headers: { Authorization: `Bearer ${apiToken}` } });
79
+ if (response.status === 404) return null;
80
+ if (!response.ok) throw new Error(`KV read failed: ${response.status}`);
81
+ return await response.text();
82
+ }
83
+
84
+ async function setKVKey(
85
+ key: string,
86
+ value: string,
87
+ accountId: string,
88
+ apiToken: string,
89
+ namespaceId: string
90
+ ): Promise<void> {
91
+ const url = `${REST_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`;
92
+ const response = await fetch(url, {
93
+ method: 'PUT',
94
+ headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'text/plain' },
95
+ body: value,
96
+ });
97
+ if (!response.ok) throw new Error(`KV write failed for ${key}: ${response.status}`);
98
+ }
99
+
100
+ async function deleteKVKey(
101
+ key: string,
102
+ accountId: string,
103
+ apiToken: string,
104
+ namespaceId: string
105
+ ): Promise<void> {
106
+ const url = `${REST_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`;
107
+ const response = await fetch(url, {
108
+ method: 'DELETE',
109
+ headers: { Authorization: `Bearer ${apiToken}` },
110
+ });
111
+ if (!response.ok && response.status !== 404) {
112
+ throw new Error(`KV delete failed for ${key}: ${response.status}`);
113
+ }
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Billing state + safety
118
+ // ---------------------------------------------------------------------------
119
+
120
+ async function getMtdCost(
121
+ accountId: string,
122
+ apiToken: string,
123
+ databaseId: string
124
+ ): Promise<{ mtdCostUsd: number; softLimitUsd: number }> {
125
+ // Get soft limit
126
+ const settingsResult = await queryD1(
127
+ `SELECT setting_value FROM usage_settings WHERE project = 'all' AND setting_key = 'budget_soft_limit' LIMIT 1;`,
128
+ accountId, apiToken, databaseId
129
+ );
130
+ const softLimitUsd = settingsResult.length > 0
131
+ ? parseFloat(settingsResult[0].setting_value as string)
132
+ : 100;
133
+
134
+ // Get MTD cost
135
+ const now = new Date();
136
+ const monthStart = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-01`;
137
+ const mtdResult = await queryD1(
138
+ `SELECT SUM(total_cost_usd) as mtd_cost FROM daily_usage_rollups WHERE snapshot_date >= '${monthStart}';`,
139
+ accountId, apiToken, databaseId
140
+ );
141
+ const mtdCostUsd = mtdResult.length > 0 ? ((mtdResult[0].mtd_cost as number) ?? 0) : 0;
142
+
143
+ return { mtdCostUsd, softLimitUsd };
144
+ }
145
+
146
+ async function getCBKeyStates(
147
+ accountId: string,
148
+ apiToken: string,
149
+ namespaceId: string
150
+ ): Promise<CBKeyState[]> {
151
+ // Discover project-level CB keys from D1
152
+ const states: CBKeyState[] = [];
153
+
154
+ // Always check global stop
155
+ const globalValue = await getKVKey(CB_KEYS.GLOBAL_STOP, accountId, apiToken, namespaceId);
156
+ states.push({ key: CB_KEYS.GLOBAL_STOP, name: 'GLOBAL_STOP', currentValue: globalValue });
157
+
158
+ // Check common project status keys (user should extend for their projects)
159
+ const projectPrefixes = ['PROJECT:'];
160
+ for (const prefix of projectPrefixes) {
161
+ // KV list API to discover project keys
162
+ const listUrl = `${REST_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/keys?prefix=${encodeURIComponent(prefix)}&limit=100`;
163
+ try {
164
+ const response = await fetch(listUrl, { headers: { Authorization: `Bearer ${apiToken}` } });
165
+ if (response.ok) {
166
+ const data = (await response.json()) as { result: Array<{ name: string }> };
167
+ for (const key of data.result) {
168
+ if (key.name.endsWith(':STATUS')) {
169
+ const value = await getKVKey(key.name, accountId, apiToken, namespaceId);
170
+ states.push({ key: key.name, name: key.name, currentValue: value });
171
+ }
172
+ }
173
+ }
174
+ } catch { /* ignore — may not have list permission */ }
175
+ }
176
+
177
+ return states;
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Main
182
+ // ---------------------------------------------------------------------------
183
+
184
+ async function main(): Promise<void> {
185
+ const args = process.argv.slice(2);
186
+ const executeMode = args.includes('--execute');
187
+ const forceMode = args.includes('--force');
188
+
189
+ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
190
+ const apiToken = process.env.CLOUDFLARE_API_TOKEN;
191
+ const databaseId = process.env.D1_DATABASE_ID;
192
+ const namespaceId = process.env.KV_NAMESPACE_ID;
193
+
194
+ if (!apiToken || !accountId || !databaseId || !namespaceId) {
195
+ console.error('Error: Required environment variables:');
196
+ if (!apiToken) console.error(' CLOUDFLARE_API_TOKEN');
197
+ if (!accountId) console.error(' CLOUDFLARE_ACCOUNT_ID');
198
+ if (!databaseId) console.error(' D1_DATABASE_ID');
199
+ if (!namespaceId) console.error(' KV_NAMESPACE_ID');
200
+ process.exit(1);
201
+ }
202
+
203
+ console.log('='.repeat(80));
204
+ console.log('BUDGET STATE RESET');
205
+ console.log('='.repeat(80));
206
+ console.log(`Mode: ${executeMode ? 'EXECUTE (will modify KV)' : 'DRY RUN (preview only)'}`);
207
+ if (forceMode) console.log('Force: YES (bypassing safety checks)');
208
+ console.log('');
209
+
210
+ // Step 1: Check billing state
211
+ console.log('-'.repeat(80));
212
+ console.log('STEP 1: Billing State');
213
+ console.log('-'.repeat(80));
214
+ const { mtdCostUsd, softLimitUsd } = await getMtdCost(accountId, apiToken, databaseId);
215
+ const ratio = mtdCostUsd / softLimitUsd;
216
+ console.log(`Budget soft limit: $${softLimitUsd.toFixed(2)}`);
217
+ console.log(`MTD cost: $${mtdCostUsd.toFixed(2)} (${(ratio * 100).toFixed(1)}%)`);
218
+ console.log('');
219
+
220
+ // Step 2: Safety check
221
+ if (ratio >= 1.5 && executeMode && !forceMode) {
222
+ console.log('BLOCKED: Costs exceed 1.5x soft limit. Use --force to bypass.');
223
+ process.exit(1);
224
+ }
225
+
226
+ // Step 3: Current CB state
227
+ console.log('-'.repeat(80));
228
+ console.log('STEP 2: Current Circuit Breaker State');
229
+ console.log('-'.repeat(80));
230
+ const cbStates = await getCBKeyStates(accountId, apiToken, namespaceId);
231
+
232
+ let anyNeedReset = false;
233
+ for (const state of cbStates) {
234
+ const needsReset = state.key === CB_KEYS.GLOBAL_STOP
235
+ ? state.currentValue !== null
236
+ : state.currentValue !== 'active';
237
+ if (needsReset) anyNeedReset = true;
238
+ const status = state.currentValue ?? '(not set)';
239
+ const resetIcon = needsReset ? '→ will reset' : '✓ OK';
240
+ console.log(` ${state.name.padEnd(35)} ${status.padEnd(15)} ${resetIcon}`);
241
+ }
242
+ console.log('');
243
+
244
+ if (!anyNeedReset) {
245
+ console.log('All circuit breakers are already in normal state. No changes needed.');
246
+ return;
247
+ }
248
+
249
+ if (!executeMode) {
250
+ console.log('DRY RUN COMPLETE — run with --execute to reset circuit breakers.');
251
+ return;
252
+ }
253
+
254
+ // Step 4: Execute reset
255
+ console.log('-'.repeat(80));
256
+ console.log('STEP 3: Executing Reset');
257
+ console.log('-'.repeat(80));
258
+ for (const state of cbStates) {
259
+ if (state.key === CB_KEYS.GLOBAL_STOP) {
260
+ if (state.currentValue !== null) {
261
+ await deleteKVKey(state.key, accountId, apiToken, namespaceId);
262
+ console.log(` ${state.key}: deleted`);
263
+ }
264
+ } else if (state.currentValue !== 'active') {
265
+ await setKVKey(state.key, 'active', accountId, apiToken, namespaceId);
266
+ console.log(` ${state.key}: reset to 'active' (was: '${state.currentValue ?? 'null'}')`);
267
+ }
268
+ }
269
+
270
+ console.log('');
271
+ console.log('='.repeat(80));
272
+ console.log('RESET COMPLETE');
273
+ console.log('='.repeat(80));
274
+ }
275
+
276
+ main().catch((error) => {
277
+ console.error('Fatal error:', error);
278
+ process.exit(1);
279
+ });
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Pipeline Validation Script
4
+ *
5
+ * Validates the end-to-end telemetry pipeline:
6
+ * 1. Injects a test telemetry message into the platform-telemetry queue
7
+ * 2. Polls Analytics Engine (up to 90s) for the test data
8
+ * 3. Validates all metric types land correctly
9
+ * 4. Validates project/feature hierarchy integrity
10
+ * 5. Reports KV circuit breaker state (informational)
11
+ *
12
+ * Usage:
13
+ * npx tsx scripts/ops/validate-pipeline.ts
14
+ *
15
+ * Environment Variables:
16
+ * CLOUDFLARE_API_TOKEN — API token with Queue, Analytics Engine, D1 access
17
+ * CLOUDFLARE_ACCOUNT_ID — Your Cloudflare account ID
18
+ */
19
+
20
+ import type { TelemetryMessage, FeatureMetrics } from '@littlebearapps/platform-consumer-sdk';
21
+ import { METRIC_FIELDS } from '@littlebearapps/platform-consumer-sdk';
22
+
23
+ const API_TOKEN = process.env.CLOUDFLARE_API_TOKEN;
24
+ const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID;
25
+
26
+ // Test identifiers — unique per run to avoid picking up stale data
27
+ const TEST_RUN_ID = Math.floor(Date.now() / 1000).toString(36);
28
+ const TEST_PROJECT = 'TEST_PROJ_VALIDATOR';
29
+ const TEST_CATEGORY = 'validate';
30
+ const TEST_FEATURE = `pipeline_${TEST_RUN_ID}`;
31
+ const TEST_FEATURE_KEY = `${TEST_PROJECT}:${TEST_CATEGORY}:${TEST_FEATURE}`;
32
+
33
+ // Analytics Engine dataset name (must match your wrangler config binding)
34
+ const DATASET = '"platform-analytics"';
35
+ const QUEUE_NAME = 'platform-telemetry';
36
+
37
+ // Polling config — Analytics Engine is eventually consistent (10–30s typical, up to 90s)
38
+ const POLL_INTERVAL_MS = 5_000;
39
+ const POLL_TIMEOUT_MS = 90_000;
40
+ const MAX_POLLS = Math.ceil(POLL_TIMEOUT_MS / POLL_INTERVAL_MS);
41
+
42
+ // Service → metric groups for validation checklist
43
+ const SERVICE_METRICS: Record<string, readonly string[]> = {
44
+ D1: ['d1Writes', 'd1Reads', 'd1RowsRead', 'd1RowsWritten'],
45
+ KV: ['kvReads', 'kvWrites', 'kvDeletes', 'kvLists'],
46
+ AI: ['aiRequests', 'aiNeurons'],
47
+ Vectorize: ['vectorizeQueries', 'vectorizeInserts'],
48
+ R2: ['r2ClassA', 'r2ClassB'],
49
+ 'Durable Objects': ['doRequests', 'doGbSeconds'],
50
+ Queues: ['queueMessages'],
51
+ };
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Helpers
55
+ // ---------------------------------------------------------------------------
56
+
57
+ function sleep(ms: number): Promise<void> {
58
+ return new Promise((resolve) => setTimeout(resolve, ms));
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Step 1: Inject test telemetry
63
+ // ---------------------------------------------------------------------------
64
+
65
+ async function getQueueId(queueName: string): Promise<string | null> {
66
+ const url = `https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/queues`;
67
+ const response = await fetch(url, { headers: { Authorization: `Bearer ${API_TOKEN}` } });
68
+ if (!response.ok) return null;
69
+ const data = (await response.json()) as { success: boolean; result: Array<{ queue_id: string; queue_name: string }> };
70
+ return data.result.find((q) => q.queue_name === queueName)?.queue_id ?? null;
71
+ }
72
+
73
+ function createTestMetrics(): FeatureMetrics {
74
+ return {
75
+ d1Writes: 5, d1Reads: 10, d1RowsRead: 100, d1RowsWritten: 25,
76
+ kvReads: 20, kvWrites: 2, kvDeletes: 1, kvLists: 3,
77
+ aiRequests: 2, aiNeurons: 100,
78
+ vectorizeQueries: 3, vectorizeInserts: 5,
79
+ doRequests: 1, doGbSeconds: 0.5,
80
+ r2ClassA: 1, r2ClassB: 5,
81
+ queueMessages: 10,
82
+ requests: 1, cpuMs: 50,
83
+ };
84
+ }
85
+
86
+ async function injectTelemetry(): Promise<{ success: boolean; timestamp: number; metrics: FeatureMetrics; error?: string }> {
87
+ const timestamp = Date.now();
88
+ const queueId = await getQueueId(QUEUE_NAME);
89
+ if (!queueId) return { success: false, timestamp, metrics: createTestMetrics(), error: `Queue "${QUEUE_NAME}" not found` };
90
+
91
+ console.log(` Found queue ID: ${queueId}`);
92
+
93
+ const metrics = createTestMetrics();
94
+ const message: TelemetryMessage = {
95
+ feature_key: TEST_FEATURE_KEY,
96
+ project: TEST_PROJECT,
97
+ category: TEST_CATEGORY,
98
+ feature: TEST_FEATURE,
99
+ metrics,
100
+ timestamp,
101
+ };
102
+
103
+ const url = `https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/queues/${queueId}/messages`;
104
+ const response = await fetch(url, {
105
+ method: 'POST',
106
+ headers: { Authorization: `Bearer ${API_TOKEN}`, 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({ body: message, contentType: 'json' }),
108
+ });
109
+
110
+ if (!response.ok) {
111
+ const text = await response.text();
112
+ return { success: false, timestamp, metrics, error: `HTTP ${response.status}: ${text}` };
113
+ }
114
+
115
+ const data = (await response.json()) as { success: boolean; errors: Array<{ message: string }> };
116
+ if (!data.success) return { success: false, timestamp, metrics, error: data.errors.map((e) => e.message).join(', ') };
117
+
118
+ console.log(` Sent test message with ${Object.keys(metrics).length} metrics`);
119
+ return { success: true, timestamp, metrics };
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Step 2: Verify in Analytics Engine
124
+ // ---------------------------------------------------------------------------
125
+
126
+ async function verifyAnalyticsEngine(expectedMetrics: FeatureMetrics): Promise<boolean> {
127
+ if (!API_TOKEN || !ACCOUNT_ID) return false;
128
+
129
+ const metricClauses = METRIC_FIELDS.map((field, i) => `SUM(double${i + 1}) as ${field}`).join(', ');
130
+ const sql = `SELECT index1 as feature_key, blob1 as project, blob2 as category, blob3 as feature, ${metricClauses}, count() as row_count FROM ${DATASET} WHERE index1 = '${TEST_FEATURE_KEY}' AND timestamp >= NOW() - INTERVAL '1' HOUR GROUP BY index1, blob1, blob2, blob3`;
131
+
132
+ for (let attempt = 1; attempt <= MAX_POLLS; attempt++) {
133
+ const remaining = Math.round((POLL_TIMEOUT_MS - (attempt - 1) * POLL_INTERVAL_MS) / 1000);
134
+ console.log(` Polling attempt ${attempt}/${MAX_POLLS} (~${remaining}s remaining)…`);
135
+
136
+ const url = `https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/analytics_engine/sql`;
137
+ const response = await fetch(url, {
138
+ method: 'POST',
139
+ headers: { Authorization: `Bearer ${API_TOKEN}`, 'Content-Type': 'text/plain' },
140
+ body: sql,
141
+ });
142
+
143
+ if (response.ok) {
144
+ const data = (await response.json()) as { data?: Record<string, unknown>[]; rows?: number };
145
+ if (data.data && data.data.length > 0) {
146
+ const row = data.data[0];
147
+ console.log(` Found data!`);
148
+
149
+ // Validate hierarchy
150
+ const projectOk = String(row.project ?? '') === TEST_PROJECT;
151
+ const categoryOk = String(row.category ?? '') === TEST_CATEGORY;
152
+ const featureOk = String(row.feature ?? '') === TEST_FEATURE;
153
+ console.log(` Hierarchy: project=${projectOk ? 'OK' : 'FAIL'} category=${categoryOk ? 'OK' : 'FAIL'} feature=${featureOk ? 'OK' : 'FAIL'}`);
154
+
155
+ // Validate metrics by service
156
+ let allPassed = projectOk && categoryOk && featureOk;
157
+ for (const [service, metrics] of Object.entries(SERVICE_METRICS)) {
158
+ let serviceOk = true;
159
+ for (const metric of metrics) {
160
+ const expected = expectedMetrics[metric as keyof FeatureMetrics];
161
+ if (!expected) continue;
162
+ const actual = typeof row[metric] === 'string' ? parseFloat(row[metric] as string) : ((row[metric] as number) ?? 0);
163
+ if (actual < expected) { serviceOk = false; allPassed = false; }
164
+ }
165
+ const checkedMetrics = metrics.filter((m) => (expectedMetrics[m as keyof FeatureMetrics] ?? 0) > 0);
166
+ if (checkedMetrics.length > 0) {
167
+ console.log(` ${serviceOk ? 'PASS' : 'FAIL'} ${service}`);
168
+ }
169
+ }
170
+
171
+ return allPassed;
172
+ }
173
+ }
174
+
175
+ if (attempt < MAX_POLLS) await sleep(POLL_INTERVAL_MS);
176
+ }
177
+
178
+ console.log(` Timeout: no data found after ${POLL_TIMEOUT_MS / 1000}s`);
179
+ return false;
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Main
184
+ // ---------------------------------------------------------------------------
185
+
186
+ async function main(): Promise<void> {
187
+ console.log('='.repeat(70));
188
+ console.log('Platform Pipeline Validation');
189
+ console.log('='.repeat(70));
190
+ console.log('');
191
+
192
+ if (!API_TOKEN || !ACCOUNT_ID) {
193
+ console.error('ERROR: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are required');
194
+ process.exit(1);
195
+ }
196
+
197
+ // Step 1
198
+ console.log('Step 1: Inject Telemetry Message');
199
+ console.log('-'.repeat(50));
200
+ const injectResult = await injectTelemetry();
201
+ if (!injectResult.success) {
202
+ console.error(`FAILED: ${injectResult.error}`);
203
+ process.exit(1);
204
+ }
205
+ console.log('');
206
+
207
+ // Step 2
208
+ console.log('Step 2: Verify Analytics Engine');
209
+ console.log('-'.repeat(50));
210
+ console.log(' Analytics Engine is eventually consistent — polling for up to 90s…');
211
+ const aeOk = await verifyAnalyticsEngine(injectResult.metrics);
212
+ console.log('');
213
+
214
+ // Summary
215
+ console.log('='.repeat(70));
216
+ if (aeOk) {
217
+ console.log('PIPELINE VALIDATION PASSED');
218
+ console.log(` Queue: ${QUEUE_NAME}`);
219
+ console.log(` Analytics Engine: ${DATASET}`);
220
+ console.log(` Feature Key: ${TEST_FEATURE_KEY}`);
221
+ } else {
222
+ console.log('PIPELINE VALIDATION FAILED');
223
+ console.log('');
224
+ console.log('Troubleshooting:');
225
+ console.log(' 1. Check if platform-usage worker is deployed');
226
+ console.log(' 2. Verify queue consumer is processing: wrangler tail <your-usage-worker>');
227
+ console.log(' 3. Check queue: wrangler queues list');
228
+ console.log(' 4. Verify Analytics Engine binding in wrangler config');
229
+ process.exit(1);
230
+ }
231
+ console.log('='.repeat(70));
232
+ }
233
+
234
+ main().catch((error) => {
235
+ console.error('Unexpected error:', error);
236
+ process.exit(1);
237
+ });