@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,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform Mapper Worker
|
|
3
|
+
*
|
|
4
|
+
* Auto-discovers Cloudflare infrastructure every 15 minutes:
|
|
5
|
+
* - Cloudflare Workers (via REST API)
|
|
6
|
+
* - Merges with Service Registry (from KV)
|
|
7
|
+
* - Stores topology in D1 + KV
|
|
8
|
+
* - Runs attribution checks (which resources belong to which projects)
|
|
9
|
+
*
|
|
10
|
+
* Cron: Every 15 minutes
|
|
11
|
+
* Cost: $0/month (within free tier for typical usage)
|
|
12
|
+
*
|
|
13
|
+
* Extension points (uncomment when ready):
|
|
14
|
+
* - GitHub deployment discovery
|
|
15
|
+
* - Health endpoint monitoring
|
|
16
|
+
* - Custom resource discovery
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
withCronBudget,
|
|
21
|
+
withFeatureBudget,
|
|
22
|
+
CircuitBreakerError,
|
|
23
|
+
completeTracking,
|
|
24
|
+
createLogger,
|
|
25
|
+
createLoggerFromRequest,
|
|
26
|
+
health,
|
|
27
|
+
pingHeartbeat,
|
|
28
|
+
type Logger,
|
|
29
|
+
} from '@littlebearapps/platform-consumer-sdk';
|
|
30
|
+
import {
|
|
31
|
+
checkAttribution,
|
|
32
|
+
storeAttributionReport,
|
|
33
|
+
alertUnattributedResources,
|
|
34
|
+
type DiscoveredResource,
|
|
35
|
+
} from './lib/mapper';
|
|
36
|
+
|
|
37
|
+
// TODO: Update this feature ID to match your project slug
|
|
38
|
+
const FEATURE_ID = 'platform:discovery:topology';
|
|
39
|
+
const HEARTBEAT_ID = 'platform:health:mapper';
|
|
40
|
+
|
|
41
|
+
interface Env {
|
|
42
|
+
PLATFORM_DB: D1Database;
|
|
43
|
+
PLATFORM_CACHE: KVNamespace;
|
|
44
|
+
SERVICE_REGISTRY: KVNamespace;
|
|
45
|
+
PLATFORM_TELEMETRY: Queue;
|
|
46
|
+
CLOUDFLARE_API_TOKEN: string;
|
|
47
|
+
CLOUDFLARE_ACCOUNT_ID: string;
|
|
48
|
+
GATUS_HEARTBEAT_URL?: string;
|
|
49
|
+
GATUS_TOKEN?: string;
|
|
50
|
+
SLACK_WEBHOOK_URL?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Service {
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
type: string;
|
|
57
|
+
tier: number;
|
|
58
|
+
status: string;
|
|
59
|
+
version?: string;
|
|
60
|
+
health_endpoint?: string;
|
|
61
|
+
health_status?: string;
|
|
62
|
+
last_seen?: string;
|
|
63
|
+
metadata?: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Connection {
|
|
67
|
+
from_service: string;
|
|
68
|
+
to_service: string;
|
|
69
|
+
connection_type: string;
|
|
70
|
+
protocol?: string;
|
|
71
|
+
status: string;
|
|
72
|
+
metadata?: Record<string, unknown>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Topology {
|
|
76
|
+
services: Service[];
|
|
77
|
+
connections: Connection[];
|
|
78
|
+
timestamp: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface ServiceRegistry {
|
|
82
|
+
metadata?: {
|
|
83
|
+
version: string;
|
|
84
|
+
lastUpdated: string;
|
|
85
|
+
autoDiscovery: boolean;
|
|
86
|
+
};
|
|
87
|
+
services: Service[];
|
|
88
|
+
connections: Connection[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface CloudflareWorkerScript {
|
|
92
|
+
id: string;
|
|
93
|
+
created_on: string;
|
|
94
|
+
modified_on: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface CloudflareAPIResponse {
|
|
98
|
+
result: CloudflareWorkerScript[];
|
|
99
|
+
success: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default {
|
|
103
|
+
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
|
|
104
|
+
const log = createLogger({ worker: 'platform-mapper', featureId: FEATURE_ID });
|
|
105
|
+
log.info('Starting infrastructure mapping');
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const trackedEnv = withCronBudget(env, FEATURE_ID, {
|
|
109
|
+
ctx,
|
|
110
|
+
cronExpression: '*/15 * * * *',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const topology = await discoverInfrastructure(trackedEnv, log);
|
|
114
|
+
|
|
115
|
+
// Store in D1 (historical)
|
|
116
|
+
await storeSnapshot(trackedEnv.PLATFORM_DB, topology, log);
|
|
117
|
+
|
|
118
|
+
// Cache in KV (latest)
|
|
119
|
+
await cacheTopology(trackedEnv.PLATFORM_CACHE, topology, log);
|
|
120
|
+
|
|
121
|
+
// Run attribution check
|
|
122
|
+
const resources = topology.services.map(
|
|
123
|
+
(s): DiscoveredResource => ({
|
|
124
|
+
resourceType: s.type,
|
|
125
|
+
resourceId: s.id,
|
|
126
|
+
resourceName: s.name,
|
|
127
|
+
metadata: s.metadata,
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
const attributionReport = checkAttribution(resources);
|
|
131
|
+
await storeAttributionReport(trackedEnv, attributionReport, log);
|
|
132
|
+
if (attributionReport.unattributedCount > 0) {
|
|
133
|
+
await alertUnattributedResources(trackedEnv, attributionReport, log);
|
|
134
|
+
}
|
|
135
|
+
log.debug('Attribution check complete', {
|
|
136
|
+
total: attributionReport.totalResources,
|
|
137
|
+
attributed: attributionReport.attributedCount,
|
|
138
|
+
unattributed: attributionReport.unattributedCount,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await completeTracking(trackedEnv);
|
|
142
|
+
|
|
143
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
144
|
+
await health(HEARTBEAT_ID, env.PLATFORM_CACHE as any, env.PLATFORM_TELEMETRY, ctx);
|
|
145
|
+
|
|
146
|
+
log.info('Infrastructure mapping complete', {
|
|
147
|
+
services: topology.services.length,
|
|
148
|
+
connections: topology.connections.length,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
pingHeartbeat(ctx, env.GATUS_HEARTBEAT_URL, env.GATUS_TOKEN, true);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error instanceof CircuitBreakerError) {
|
|
154
|
+
log.warn('Circuit breaker STOP', error, { reason: error.reason });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
log.error('Infrastructure mapping failed', error);
|
|
159
|
+
pingHeartbeat(ctx, env.GATUS_HEARTBEAT_URL, env.GATUS_TOKEN, false);
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
// Manual trigger endpoint for testing
|
|
165
|
+
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
166
|
+
const url = new URL(request.url);
|
|
167
|
+
|
|
168
|
+
if (url.pathname === '/health') {
|
|
169
|
+
return new Response('OK', { status: 200 });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const log = createLoggerFromRequest(request, env, 'platform-mapper', FEATURE_ID);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const trackedEnv = withFeatureBudget(env, FEATURE_ID, { ctx });
|
|
176
|
+
|
|
177
|
+
if (url.pathname === '/trigger') {
|
|
178
|
+
try {
|
|
179
|
+
const topology = await discoverInfrastructure(trackedEnv, log);
|
|
180
|
+
await storeSnapshot(trackedEnv.PLATFORM_DB, topology, log);
|
|
181
|
+
await cacheTopology(trackedEnv.PLATFORM_CACHE, topology, log);
|
|
182
|
+
await completeTracking(trackedEnv);
|
|
183
|
+
|
|
184
|
+
return new Response(JSON.stringify(topology, null, 2), {
|
|
185
|
+
headers: { 'Content-Type': 'application/json' },
|
|
186
|
+
});
|
|
187
|
+
} catch (discoverError) {
|
|
188
|
+
const err =
|
|
189
|
+
discoverError instanceof Error ? discoverError : new Error(String(discoverError));
|
|
190
|
+
log.error('Discovery failed', err, { stack: err.stack });
|
|
191
|
+
return new Response(JSON.stringify({ error: err.message }), {
|
|
192
|
+
status: 500,
|
|
193
|
+
headers: { 'Content-Type': 'application/json' },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
await completeTracking(trackedEnv);
|
|
199
|
+
return new Response('Not Found', { status: 404 });
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error instanceof CircuitBreakerError) {
|
|
202
|
+
log.warn('Circuit breaker STOP', error, { reason: error.reason });
|
|
203
|
+
return new Response(JSON.stringify({ error: 'Service temporarily unavailable' }), {
|
|
204
|
+
status: 503,
|
|
205
|
+
headers: { 'Content-Type': 'application/json' },
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
209
|
+
return new Response(JSON.stringify({ error: err.message }), {
|
|
210
|
+
status: 500,
|
|
211
|
+
headers: { 'Content-Type': 'application/json' },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
async function discoverInfrastructure(env: Env, log: Logger): Promise<Topology> {
|
|
218
|
+
const topology: Topology = {
|
|
219
|
+
services: [],
|
|
220
|
+
connections: [],
|
|
221
|
+
timestamp: new Date().toISOString(),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// 1. Load base registry (static config)
|
|
225
|
+
const baseRegistry = await loadServiceRegistry(env, log);
|
|
226
|
+
|
|
227
|
+
// 2. Discover Cloudflare Workers via REST API
|
|
228
|
+
const cfWorkers = await discoverCloudflareWorkers(env, log);
|
|
229
|
+
|
|
230
|
+
// =============================================================================
|
|
231
|
+
// EXTENSION POINTS
|
|
232
|
+
// Uncomment and implement when ready:
|
|
233
|
+
//
|
|
234
|
+
// 3. Discover GitHub deployments
|
|
235
|
+
// const githubDeployments = await discoverGitHubDeployments(env, log);
|
|
236
|
+
//
|
|
237
|
+
// 4. Fetch health status from monitoring
|
|
238
|
+
// const healthStatuses = await fetchHealthStatuses(env, log);
|
|
239
|
+
// =============================================================================
|
|
240
|
+
|
|
241
|
+
// Merge all sources (priority: health > deployments > CF API > base config)
|
|
242
|
+
topology.services = mergeServiceData(baseRegistry.services, cfWorkers);
|
|
243
|
+
|
|
244
|
+
// Use connections from base registry (defensive filter)
|
|
245
|
+
topology.connections = (baseRegistry.connections ?? []).filter(
|
|
246
|
+
(conn): conn is Connection =>
|
|
247
|
+
conn != null &&
|
|
248
|
+
typeof conn.from_service === 'string' &&
|
|
249
|
+
typeof conn.to_service === 'string' &&
|
|
250
|
+
typeof conn.connection_type === 'string'
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
return topology;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function loadServiceRegistry(env: Env, log: Logger): Promise<ServiceRegistry> {
|
|
257
|
+
const registryJSON = await env.SERVICE_REGISTRY.get('registry:latest');
|
|
258
|
+
|
|
259
|
+
if (!registryJSON) {
|
|
260
|
+
log.warn('Service registry not found in KV, using empty registry');
|
|
261
|
+
return { services: [], connections: [] };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return JSON.parse(registryJSON) as ServiceRegistry;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function discoverCloudflareWorkers(env: Env, log: Logger): Promise<Service[]> {
|
|
268
|
+
const maxRetries = 2;
|
|
269
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
270
|
+
try {
|
|
271
|
+
const response = await fetch(
|
|
272
|
+
`https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/workers/scripts`,
|
|
273
|
+
{
|
|
274
|
+
headers: {
|
|
275
|
+
Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`,
|
|
276
|
+
'Content-Type': 'application/json',
|
|
277
|
+
},
|
|
278
|
+
}
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
if (!response.ok) {
|
|
282
|
+
// Consume response body to avoid stalled connections
|
|
283
|
+
await response.text().catch(() => {});
|
|
284
|
+
if (response.status >= 500 && attempt < maxRetries) {
|
|
285
|
+
log.warn('Cloudflare API 5xx, retrying', undefined, { status: response.status, attempt });
|
|
286
|
+
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
log.error('Cloudflare API error', undefined, { status: response.status });
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const data = (await response.json()) as CloudflareAPIResponse;
|
|
294
|
+
|
|
295
|
+
return data.result.map((script) => ({
|
|
296
|
+
id: `cloudflare-worker-${script.id}`,
|
|
297
|
+
name: script.id,
|
|
298
|
+
type: 'cloudflare-worker',
|
|
299
|
+
tier: 1,
|
|
300
|
+
status: 'deployed',
|
|
301
|
+
version: 'unknown',
|
|
302
|
+
health_status: 'unknown',
|
|
303
|
+
last_seen: new Date().toISOString(),
|
|
304
|
+
metadata: {
|
|
305
|
+
created_on: script.created_on,
|
|
306
|
+
modified_on: script.modified_on,
|
|
307
|
+
},
|
|
308
|
+
}));
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (attempt < maxRetries) {
|
|
311
|
+
log.warn(
|
|
312
|
+
'Cloudflare API fetch error, retrying',
|
|
313
|
+
error instanceof Error ? error : undefined,
|
|
314
|
+
{ attempt }
|
|
315
|
+
);
|
|
316
|
+
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
log.error('Failed to discover Cloudflare Workers', error);
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function mergeServiceData(...sources: (Service[] | Partial<Service>[])[]): Service[] {
|
|
327
|
+
const merged: Record<string, Partial<Service>> = {};
|
|
328
|
+
|
|
329
|
+
for (const source of sources) {
|
|
330
|
+
if (!source || !Array.isArray(source)) continue;
|
|
331
|
+
|
|
332
|
+
for (const service of source) {
|
|
333
|
+
if (!service || !service.id) continue;
|
|
334
|
+
|
|
335
|
+
if (!merged[service.id]) {
|
|
336
|
+
merged[service.id] = { ...service };
|
|
337
|
+
} else {
|
|
338
|
+
merged[service.id] = {
|
|
339
|
+
...merged[service.id],
|
|
340
|
+
...service,
|
|
341
|
+
health_status: service.health_status || merged[service.id].health_status,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return Object.values(merged)
|
|
348
|
+
.filter((service): service is Service => !!service.id)
|
|
349
|
+
.map((service) => ({
|
|
350
|
+
id: service.id,
|
|
351
|
+
name: service.name ?? service.id,
|
|
352
|
+
type: service.type ?? 'unknown',
|
|
353
|
+
tier: service.tier ?? 2,
|
|
354
|
+
status: service.status ?? 'unknown',
|
|
355
|
+
version: service.version,
|
|
356
|
+
health_endpoint: service.health_endpoint,
|
|
357
|
+
health_status: service.health_status ?? 'unknown',
|
|
358
|
+
last_seen: service.last_seen ?? new Date().toISOString(),
|
|
359
|
+
metadata: service.metadata,
|
|
360
|
+
}));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function storeSnapshot(db: D1Database, topology: Topology, log: Logger): Promise<void> {
|
|
364
|
+
const snapshotData = JSON.stringify(topology);
|
|
365
|
+
const serviceCount = topology.services.length;
|
|
366
|
+
const connCount = topology.connections.length;
|
|
367
|
+
|
|
368
|
+
// Batch all D1 writes to minimise row-level overhead (cost rule #1)
|
|
369
|
+
const stmts: D1PreparedStatement[] = [];
|
|
370
|
+
|
|
371
|
+
// Snapshot insert
|
|
372
|
+
stmts.push(
|
|
373
|
+
db
|
|
374
|
+
.prepare(
|
|
375
|
+
'INSERT INTO topology_snapshots (data, change_summary, service_count, connection_count) VALUES (?, ?, ?, ?)'
|
|
376
|
+
)
|
|
377
|
+
.bind(snapshotData, '', serviceCount, connCount)
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// Services upserts
|
|
381
|
+
for (const service of topology.services) {
|
|
382
|
+
stmts.push(
|
|
383
|
+
db
|
|
384
|
+
.prepare(
|
|
385
|
+
`INSERT INTO services (id, name, type, tier, status, version, health_endpoint, health_status, last_seen, metadata, updated_at)
|
|
386
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
387
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
388
|
+
name = excluded.name, type = excluded.type, tier = excluded.tier,
|
|
389
|
+
status = excluded.status, version = excluded.version,
|
|
390
|
+
health_endpoint = excluded.health_endpoint, health_status = excluded.health_status,
|
|
391
|
+
last_seen = excluded.last_seen, metadata = excluded.metadata,
|
|
392
|
+
updated_at = CURRENT_TIMESTAMP`
|
|
393
|
+
)
|
|
394
|
+
.bind(
|
|
395
|
+
service.id,
|
|
396
|
+
service.name ?? 'unknown',
|
|
397
|
+
service.type ?? 'unknown',
|
|
398
|
+
service.tier ?? 0,
|
|
399
|
+
service.status ?? 'unknown',
|
|
400
|
+
service.version ?? null,
|
|
401
|
+
service.health_endpoint ?? null,
|
|
402
|
+
service.health_status ?? 'unknown',
|
|
403
|
+
service.last_seen ?? new Date().toISOString(),
|
|
404
|
+
service.metadata ? JSON.stringify(service.metadata) : null
|
|
405
|
+
)
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Connections upserts
|
|
410
|
+
for (const conn of topology.connections) {
|
|
411
|
+
stmts.push(
|
|
412
|
+
db
|
|
413
|
+
.prepare(
|
|
414
|
+
`INSERT INTO connections (from_service, to_service, connection_type, protocol, status, metadata, updated_at)
|
|
415
|
+
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
416
|
+
ON CONFLICT(from_service, to_service, connection_type) DO UPDATE SET
|
|
417
|
+
protocol = excluded.protocol, status = excluded.status,
|
|
418
|
+
metadata = excluded.metadata, updated_at = CURRENT_TIMESTAMP`
|
|
419
|
+
)
|
|
420
|
+
.bind(
|
|
421
|
+
conn.from_service,
|
|
422
|
+
conn.to_service,
|
|
423
|
+
conn.connection_type,
|
|
424
|
+
conn.protocol ?? null,
|
|
425
|
+
conn.status ?? 'unknown',
|
|
426
|
+
conn.metadata ? JSON.stringify(conn.metadata) : null
|
|
427
|
+
)
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
await db.batch(stmts);
|
|
432
|
+
|
|
433
|
+
log.debug('Snapshot stored in D1');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function cacheTopology(kv: KVNamespace, topology: Topology, log: Logger): Promise<void> {
|
|
437
|
+
// Cache full topology (15-minute TTL matches cron interval)
|
|
438
|
+
await kv.put('topology:latest', JSON.stringify(topology), {
|
|
439
|
+
expirationTtl: 900,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// Cache derived health summary
|
|
443
|
+
const healthSummary = {
|
|
444
|
+
total: topology.services.length,
|
|
445
|
+
up: topology.services.filter((s) => s.health_status === 'up').length,
|
|
446
|
+
down: topology.services.filter((s) => s.health_status === 'down').length,
|
|
447
|
+
degraded: topology.services.filter((s) => s.health_status === 'degraded').length,
|
|
448
|
+
unknown: topology.services.filter((s) => s.health_status === 'unknown').length,
|
|
449
|
+
timestamp: topology.timestamp,
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
await kv.put('topology:health', JSON.stringify(healthSummary), {
|
|
453
|
+
expirationTtl: 900,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// Cache version matrix
|
|
457
|
+
const versionMatrix = topology.services
|
|
458
|
+
.filter((s) => s.version && s.version !== 'unknown')
|
|
459
|
+
.map((s) => ({
|
|
460
|
+
id: s.id,
|
|
461
|
+
name: s.name,
|
|
462
|
+
version: s.version,
|
|
463
|
+
}));
|
|
464
|
+
|
|
465
|
+
await kv.put('topology:versions', JSON.stringify(versionMatrix), {
|
|
466
|
+
expirationTtl: 900,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// Cache configuration gaps
|
|
470
|
+
const configGaps = topology.services.filter(
|
|
471
|
+
(s) =>
|
|
472
|
+
s.status === 'partial' ||
|
|
473
|
+
s.status === 'not-deployed' ||
|
|
474
|
+
(s.metadata && JSON.stringify(s.metadata).includes('TBD'))
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
await kv.put('topology:config-gaps', JSON.stringify(configGaps), {
|
|
478
|
+
expirationTtl: 900,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
log.debug('Topology cached in KV');
|
|
482
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform SDK Test Client Worker
|
|
3
|
+
*
|
|
4
|
+
* Validates Platform SDK integration by triggering telemetry flow
|
|
5
|
+
* and testing circuit breaker functionality.
|
|
6
|
+
*
|
|
7
|
+
* Endpoints:
|
|
8
|
+
* GET /trigger-usage - Trigger SDK telemetry (KV operations)
|
|
9
|
+
* GET /sdk-health - Dual-plane health check (control + data plane)
|
|
10
|
+
* GET /health - Basic liveness probe
|
|
11
|
+
*
|
|
12
|
+
* Extension points:
|
|
13
|
+
* - Add cron schedule (every 6 hours) for automated SDK health monitoring
|
|
14
|
+
* - Extend coverage to test all SDK service proxies (D1, AI, R2, Queue, etc.)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
withFeatureBudget,
|
|
19
|
+
CircuitBreakerError,
|
|
20
|
+
completeTracking,
|
|
21
|
+
} from '@littlebearapps/platform-consumer-sdk';
|
|
22
|
+
|
|
23
|
+
// TODO: Update these feature IDs to match your project slug
|
|
24
|
+
const FEATURE_VALIDATION = 'platform:sdk-test:validation';
|
|
25
|
+
const FEATURE_HEALTH_CHECK = 'platform:sdk-test:health-check';
|
|
26
|
+
|
|
27
|
+
interface Env {
|
|
28
|
+
TEST_KV: KVNamespace; // Use a non-reserved name so SDK tracks operations
|
|
29
|
+
PLATFORM_CACHE: KVNamespace; // Reserved for SDK circuit breaker state
|
|
30
|
+
PLATFORM_TELEMETRY: Queue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default {
|
|
34
|
+
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
35
|
+
const url = new URL(request.url);
|
|
36
|
+
|
|
37
|
+
if (url.pathname === '/trigger-usage') {
|
|
38
|
+
try {
|
|
39
|
+
// Wrap env with SDK tracking for test feature
|
|
40
|
+
const trackedEnv = withFeatureBudget(env, FEATURE_VALIDATION, { ctx });
|
|
41
|
+
|
|
42
|
+
// Make a test KV operation (will be tracked)
|
|
43
|
+
// Use TEST_KV (not PLATFORM_CACHE which is reserved and skipped by SDK)
|
|
44
|
+
const testKey = `test:${Date.now()}`;
|
|
45
|
+
await trackedEnv.TEST_KV.put(testKey, 'sdk-validation-test', { expirationTtl: 60 });
|
|
46
|
+
const value = await trackedEnv.TEST_KV.get(testKey);
|
|
47
|
+
await trackedEnv.TEST_KV.delete(testKey);
|
|
48
|
+
|
|
49
|
+
// Explicitly flush telemetry after all operations complete
|
|
50
|
+
await completeTracking(trackedEnv);
|
|
51
|
+
|
|
52
|
+
return Response.json({
|
|
53
|
+
success: true,
|
|
54
|
+
message: 'SDK telemetry triggered successfully',
|
|
55
|
+
featureId: FEATURE_VALIDATION,
|
|
56
|
+
operations: {
|
|
57
|
+
kvWrites: 1,
|
|
58
|
+
kvReads: 1,
|
|
59
|
+
kvDeletes: 1,
|
|
60
|
+
},
|
|
61
|
+
testValue: value,
|
|
62
|
+
timestamp: new Date().toISOString(),
|
|
63
|
+
});
|
|
64
|
+
} catch (e) {
|
|
65
|
+
if (e instanceof CircuitBreakerError) {
|
|
66
|
+
return Response.json(
|
|
67
|
+
{
|
|
68
|
+
success: false,
|
|
69
|
+
error: 'CircuitBreakerError',
|
|
70
|
+
featureId: e.featureId,
|
|
71
|
+
level: e.level,
|
|
72
|
+
reason: e.reason,
|
|
73
|
+
timestamp: new Date().toISOString(),
|
|
74
|
+
},
|
|
75
|
+
{ status: 503 }
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (url.pathname === '/sdk-health') {
|
|
83
|
+
try {
|
|
84
|
+
const trackedEnv = withFeatureBudget(env, FEATURE_HEALTH_CHECK, { ctx });
|
|
85
|
+
const healthResult = await trackedEnv.health();
|
|
86
|
+
await completeTracking(trackedEnv);
|
|
87
|
+
|
|
88
|
+
return Response.json({
|
|
89
|
+
success: true,
|
|
90
|
+
healthResult,
|
|
91
|
+
timestamp: new Date().toISOString(),
|
|
92
|
+
});
|
|
93
|
+
} catch (e) {
|
|
94
|
+
if (e instanceof CircuitBreakerError) {
|
|
95
|
+
return Response.json(
|
|
96
|
+
{
|
|
97
|
+
success: false,
|
|
98
|
+
error: 'CircuitBreakerError',
|
|
99
|
+
featureId: e.featureId,
|
|
100
|
+
level: e.level,
|
|
101
|
+
reason: e.reason,
|
|
102
|
+
},
|
|
103
|
+
{ status: 503 }
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return Response.json(
|
|
107
|
+
{
|
|
108
|
+
success: false,
|
|
109
|
+
error: e instanceof Error ? e.message : String(e),
|
|
110
|
+
},
|
|
111
|
+
{ status: 500 }
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (url.pathname === '/health') {
|
|
117
|
+
return Response.json({ status: 'ok', worker: 'platform-sdk-test-client' });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return Response.json(
|
|
121
|
+
{ error: 'Not found', endpoints: ['/trigger-usage', '/sdk-health', '/health'] },
|
|
122
|
+
{ status: 404 }
|
|
123
|
+
);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// Platform Mapper Worker
|
|
4
|
+
// =============================================================================
|
|
5
|
+
// Auto-discovers Cloudflare infrastructure and services.
|
|
6
|
+
//
|
|
7
|
+
// Deploy: wrangler deploy -c wrangler.{{projectSlug}}-mapper.jsonc
|
|
8
|
+
// Tail logs: wrangler tail {{projectSlug}}-mapper --format pretty
|
|
9
|
+
// Cron: Every 15 minutes
|
|
10
|
+
// Secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID
|
|
11
|
+
// =============================================================================
|
|
12
|
+
|
|
13
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
14
|
+
|
|
15
|
+
"name": "{{projectSlug}}-mapper",
|
|
16
|
+
"main": "workers/platform-mapper.ts",
|
|
17
|
+
"compatibility_date": "2026-01-01",
|
|
18
|
+
"compatibility_flags": ["nodejs_compat_v2"],
|
|
19
|
+
|
|
20
|
+
// D1 Database
|
|
21
|
+
// TODO: Replace database_id with your D1 database ID
|
|
22
|
+
"d1_databases": [
|
|
23
|
+
{
|
|
24
|
+
"binding": "PLATFORM_DB",
|
|
25
|
+
"database_name": "{{projectSlug}}-metrics",
|
|
26
|
+
"database_id": "YOUR_D1_DATABASE_ID"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
|
|
30
|
+
// KV Namespaces
|
|
31
|
+
// TODO: Replace IDs with your KV namespace IDs
|
|
32
|
+
"kv_namespaces": [
|
|
33
|
+
{
|
|
34
|
+
"binding": "PLATFORM_CACHE",
|
|
35
|
+
"id": "YOUR_PLATFORM_CACHE_KV_ID"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"binding": "SERVICE_REGISTRY",
|
|
39
|
+
"id": "YOUR_SERVICE_REGISTRY_KV_ID"
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
|
|
43
|
+
// Cron Triggers - Every 15 minutes
|
|
44
|
+
"triggers": {
|
|
45
|
+
"crons": ["*/15 * * * *"]
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// Queue Producers (Platform SDK telemetry)
|
|
49
|
+
"queues": {
|
|
50
|
+
"producers": [
|
|
51
|
+
{
|
|
52
|
+
"queue": "platform-telemetry",
|
|
53
|
+
"binding": "PLATFORM_TELEMETRY"
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// Observability
|
|
59
|
+
"observability": {
|
|
60
|
+
"enabled": true,
|
|
61
|
+
"logs": {
|
|
62
|
+
"enabled": true,
|
|
63
|
+
"invocation_logs": true,
|
|
64
|
+
"head_sampling_rate": 1.0
|
|
65
|
+
},
|
|
66
|
+
"traces": {
|
|
67
|
+
"enabled": true,
|
|
68
|
+
"head_sampling_rate": 0.1
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// Source maps for readable stack traces
|
|
73
|
+
"upload_source_maps": true
|
|
74
|
+
|
|
75
|
+
// Tail Consumer (Error Collection) — uncomment if using error-collector
|
|
76
|
+
// "tail_consumers": [
|
|
77
|
+
// { "service": "{{projectSlug}}-error-collector" }
|
|
78
|
+
// ]
|
|
79
|
+
|
|
80
|
+
// Secrets (add via: wrangler secret put <NAME> -c wrangler.{{projectSlug}}-mapper.jsonc)
|
|
81
|
+
// - CLOUDFLARE_API_TOKEN
|
|
82
|
+
// - CLOUDFLARE_ACCOUNT_ID
|
|
83
|
+
// - SLACK_WEBHOOK_URL (optional, for attribution alerts)
|
|
84
|
+
// - GATUS_TOKEN (optional, for heartbeat monitoring)
|
|
85
|
+
}
|