@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,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,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Validate Controls Script
|
|
4
|
+
*
|
|
5
|
+
* Validates budgets.yaml configuration:
|
|
6
|
+
* - No zero or negative limits
|
|
7
|
+
* - All features have d1_write_limit
|
|
8
|
+
* - Warns on suspiciously high/low values
|
|
9
|
+
* - Checks for orphaned entries (feature keys not in services.yaml)
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/ops/validate-controls.js
|
|
13
|
+
* node scripts/ops/validate-controls.js --strict
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
import { parse } from 'yaml';
|
|
19
|
+
|
|
20
|
+
const ROOT = resolve(import.meta.dirname, '..', '..');
|
|
21
|
+
const strict = process.argv.includes('--strict');
|
|
22
|
+
|
|
23
|
+
let exitCode = 0;
|
|
24
|
+
let warnings = 0;
|
|
25
|
+
let errors = 0;
|
|
26
|
+
|
|
27
|
+
function warn(msg) {
|
|
28
|
+
console.warn(` WARN: ${msg}`);
|
|
29
|
+
warnings++;
|
|
30
|
+
if (strict) exitCode = 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function error(msg) {
|
|
34
|
+
console.error(` ERROR: ${msg}`);
|
|
35
|
+
errors++;
|
|
36
|
+
exitCode = 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normaliseBudgetValue(val) {
|
|
40
|
+
if (typeof val === 'number') return val;
|
|
41
|
+
if (typeof val === 'string') return Number(val.replace(/_/g, ''));
|
|
42
|
+
return NaN;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Load budgets.yaml
|
|
46
|
+
let budgets;
|
|
47
|
+
try {
|
|
48
|
+
const raw = readFileSync(resolve(ROOT, 'platform/config/budgets.yaml'), 'utf-8');
|
|
49
|
+
budgets = parse(raw);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error('Failed to load budgets.yaml:', err.message);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Load services.yaml for cross-reference
|
|
56
|
+
let services;
|
|
57
|
+
try {
|
|
58
|
+
const raw = readFileSync(resolve(ROOT, 'platform/config/services.yaml'), 'utf-8');
|
|
59
|
+
services = parse(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
services = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log('Validating budgets.yaml...\n');
|
|
65
|
+
|
|
66
|
+
// Validate feature budgets
|
|
67
|
+
const featureBudgets = budgets?.features ?? budgets?.feature_budgets ?? {};
|
|
68
|
+
const featureKeys = Object.keys(featureBudgets);
|
|
69
|
+
|
|
70
|
+
console.log(`Found ${featureKeys.length} feature budget entries\n`);
|
|
71
|
+
|
|
72
|
+
for (const key of featureKeys) {
|
|
73
|
+
const budget = featureBudgets[key];
|
|
74
|
+
console.log(` Checking ${key}...`);
|
|
75
|
+
|
|
76
|
+
if (!budget || typeof budget !== 'object') {
|
|
77
|
+
error(`${key}: budget is not an object`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check for zero limits
|
|
82
|
+
for (const [field, rawValue] of Object.entries(budget)) {
|
|
83
|
+
const value = normaliseBudgetValue(rawValue);
|
|
84
|
+
if (isNaN(value)) {
|
|
85
|
+
error(`${key}.${field}: value "${rawValue}" is not a valid number`);
|
|
86
|
+
} else if (value <= 0) {
|
|
87
|
+
error(`${key}.${field}: value is ${value} (must be positive)`);
|
|
88
|
+
} else if (value < 100 && field.includes('d1')) {
|
|
89
|
+
warn(`${key}.${field}: suspiciously low D1 limit (${value})`);
|
|
90
|
+
} else if (value > 100_000_000_000) {
|
|
91
|
+
warn(`${key}.${field}: suspiciously high limit (${value})`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check for d1_write_limit
|
|
96
|
+
const hasD1Write = Object.keys(budget).some(
|
|
97
|
+
(f) => f.includes('d1') && (f.includes('write') || f.includes('written'))
|
|
98
|
+
);
|
|
99
|
+
if (!hasD1Write) {
|
|
100
|
+
warn(`${key}: no d1_write_limit or d1_rows_written field`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check feature key format (project:category:feature)
|
|
104
|
+
const parts = key.split(':');
|
|
105
|
+
if (parts.length < 3) {
|
|
106
|
+
warn(`${key}: feature key should have format project:category:feature (has ${parts.length} parts)`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Cross-reference with services.yaml
|
|
111
|
+
if (services) {
|
|
112
|
+
const registeredProjects = new Set();
|
|
113
|
+
const projects = services?.projects ?? [];
|
|
114
|
+
for (const p of Array.isArray(projects) ? projects : Object.keys(projects)) {
|
|
115
|
+
const name = typeof p === 'string' ? p : p?.name ?? p?.id;
|
|
116
|
+
if (name) registeredProjects.add(name);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const key of featureKeys) {
|
|
120
|
+
const project = key.split(':')[0];
|
|
121
|
+
if (registeredProjects.size > 0 && !registeredProjects.has(project)) {
|
|
122
|
+
warn(`${key}: project "${project}" not found in services.yaml`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Global budget check
|
|
128
|
+
const globalBudget = budgets?.global ?? budgets?.global_budget;
|
|
129
|
+
if (!globalBudget) {
|
|
130
|
+
warn('No global budget section found');
|
|
131
|
+
} else {
|
|
132
|
+
for (const [field, rawValue] of Object.entries(globalBudget)) {
|
|
133
|
+
const value = normaliseBudgetValue(rawValue);
|
|
134
|
+
if (isNaN(value) || value <= 0) {
|
|
135
|
+
error(`global.${field}: invalid value "${rawValue}"`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log(`\nResults: ${errors} errors, ${warnings} warnings`);
|
|
141
|
+
process.exit(exitCode);
|
|
@@ -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
|
+
});
|