@littlebearapps/platform-admin-sdk 1.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.
Files changed (94) hide show
  1. package/README.md +112 -0
  2. package/dist/index.d.ts +16 -0
  3. package/dist/index.js +89 -0
  4. package/dist/prompts.d.ts +27 -0
  5. package/dist/prompts.js +80 -0
  6. package/dist/scaffold.d.ts +5 -0
  7. package/dist/scaffold.js +65 -0
  8. package/dist/templates.d.ts +16 -0
  9. package/dist/templates.js +131 -0
  10. package/package.json +46 -0
  11. package/templates/full/migrations/006_pattern_discovery.sql +199 -0
  12. package/templates/full/migrations/007_notifications_search.sql +127 -0
  13. package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
  14. package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
  15. package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
  16. package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
  17. package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
  18. package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
  19. package/templates/full/workers/pattern-discovery.ts +661 -0
  20. package/templates/full/workers/platform-alert-router.ts +1809 -0
  21. package/templates/full/workers/platform-notifications.ts +424 -0
  22. package/templates/full/workers/platform-search.ts +480 -0
  23. package/templates/full/workers/platform-settings.ts +436 -0
  24. package/templates/full/wrangler.alert-router.jsonc.hbs +34 -0
  25. package/templates/full/wrangler.notifications.jsonc.hbs +23 -0
  26. package/templates/full/wrangler.pattern-discovery.jsonc.hbs +33 -0
  27. package/templates/full/wrangler.search.jsonc.hbs +16 -0
  28. package/templates/full/wrangler.settings.jsonc.hbs +23 -0
  29. package/templates/shared/README.md.hbs +69 -0
  30. package/templates/shared/config/budgets.yaml.hbs +72 -0
  31. package/templates/shared/config/services.yaml.hbs +45 -0
  32. package/templates/shared/migrations/001_core_tables.sql +117 -0
  33. package/templates/shared/migrations/002_usage_warehouse.sql +830 -0
  34. package/templates/shared/migrations/003_feature_tracking.sql +250 -0
  35. package/templates/shared/migrations/004_settings_alerts.sql +452 -0
  36. package/templates/shared/migrations/seed.sql.hbs +4 -0
  37. package/templates/shared/package.json.hbs +21 -0
  38. package/templates/shared/scripts/sync-config.ts +242 -0
  39. package/templates/shared/tsconfig.json +12 -0
  40. package/templates/shared/workers/lib/analytics-engine.ts +357 -0
  41. package/templates/shared/workers/lib/billing.ts +293 -0
  42. package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
  43. package/templates/shared/workers/lib/control.ts +292 -0
  44. package/templates/shared/workers/lib/economics.ts +368 -0
  45. package/templates/shared/workers/lib/metrics.ts +103 -0
  46. package/templates/shared/workers/lib/platform-settings.ts +407 -0
  47. package/templates/shared/workers/lib/shared/allowances.ts +333 -0
  48. package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
  49. package/templates/shared/workers/lib/shared/types.ts +58 -0
  50. package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
  51. package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
  52. package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
  53. package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
  54. package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
  55. package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
  56. package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
  57. package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
  58. package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
  59. package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
  60. package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
  61. package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
  62. package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
  63. package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
  64. package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
  65. package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
  66. package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
  67. package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
  68. package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
  69. package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
  70. package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
  71. package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
  72. package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
  73. package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
  74. package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
  75. package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
  76. package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
  77. package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
  78. package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
  79. package/templates/shared/workers/platform-usage.ts +1915 -0
  80. package/templates/shared/wrangler.usage.jsonc.hbs +58 -0
  81. package/templates/standard/migrations/005_error_collection.sql +162 -0
  82. package/templates/standard/workers/error-collector.ts +2670 -0
  83. package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
  84. package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
  85. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
  86. package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
  87. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
  88. package/templates/standard/workers/lib/error-collector/github.ts +329 -0
  89. package/templates/standard/workers/lib/error-collector/types.ts +262 -0
  90. package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
  91. package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
  92. package/templates/standard/workers/platform-sentinel.ts +1744 -0
  93. package/templates/standard/wrangler.error-collector.jsonc.hbs +44 -0
  94. package/templates/standard/wrangler.sentinel.jsonc.hbs +45 -0
@@ -0,0 +1,795 @@
1
+ /**
2
+ * Platform Usage Utilities
3
+ *
4
+ * Shared utility functions used across handlers, scheduled tasks, and queue processing.
5
+ */
6
+
7
+ import type {
8
+ Env,
9
+ SamplingMode,
10
+ PreviousHourMetrics,
11
+ PlatformPricing,
12
+ ProjectLookupCache,
13
+ VectorizeAttribution,
14
+ BudgetThresholds,
15
+ TimePeriod,
16
+ AccountUsage,
17
+ Project,
18
+ PlatformSettings,
19
+ BillingSettings,
20
+ } from './types';
21
+ import { SamplingMode as SamplingModeEnum } from './types';
22
+ import {
23
+ CB_KEYS,
24
+ DEFAULT_PRICING,
25
+ BILLING_SETTINGS_CACHE_TTL_MS,
26
+ } from './constants';
27
+ import {
28
+ getPlatformSettings as getPlatformSettingsFromLib,
29
+ DEFAULT_PLATFORM_SETTINGS,
30
+ } from '../../platform-settings';
31
+ import {
32
+ getDefaultBillingSettings,
33
+ type BillingSettings as BillingSettingsType,
34
+ } from '../../billing';
35
+ import { identifyProject, getProjects } from '../../shared/cloudflare';
36
+
37
+ // =============================================================================
38
+ // CACHE KEY GENERATION
39
+ // =============================================================================
40
+
41
+ /**
42
+ * KV cache key format: usage:{period}:{project}:{hour}
43
+ */
44
+ export function getCacheKey(prefix: string, period: TimePeriod, project: string): string {
45
+ const hourTimestamp = Math.floor(Date.now() / (60 * 60 * 1000));
46
+ return `${prefix}:${period}:${project}:${hourTimestamp}`;
47
+ }
48
+
49
+ // =============================================================================
50
+ // QUERY PARAMETER PARSING
51
+ // =============================================================================
52
+
53
+ /**
54
+ * Parse and validate query parameters (sync version with hardcoded projects)
55
+ * @deprecated Use parseQueryParamsWithRegistry for D1-backed project validation
56
+ */
57
+ export function parseQueryParams(url: URL): { period: TimePeriod; project: string } {
58
+ const periodParam = url.searchParams.get('period');
59
+ const projectParam = url.searchParams.get('project') ?? 'all';
60
+
61
+ const validPeriods: TimePeriod[] = ['24h', '7d', '30d'];
62
+ const period: TimePeriod = validPeriods.includes(periodParam as TimePeriod)
63
+ ? (periodParam as TimePeriod)
64
+ : '30d';
65
+
66
+ // TODO: Replace with your project IDs
67
+ const validProjects = ['all'];
68
+ const project = validProjects.includes(projectParam) ? projectParam : 'all';
69
+
70
+ return { period, project };
71
+ }
72
+
73
+ /**
74
+ * Get list of valid projects from D1 registry.
75
+ */
76
+ export async function getValidProjects(env: Env): Promise<string[]> {
77
+ try {
78
+ const projects = await getProjects(env.PLATFORM_DB);
79
+ return ['all', ...projects.map((p) => p.projectId)];
80
+ } catch {
81
+ // TODO: Replace with your fallback project IDs
82
+ return ['all'];
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Parse and validate query parameters using D1 registry for project validation
88
+ */
89
+ export async function parseQueryParamsWithRegistry(
90
+ url: URL,
91
+ env: Env
92
+ ): Promise<{ period: TimePeriod; project: string }> {
93
+ const periodParam = url.searchParams.get('period');
94
+ const projectParam = url.searchParams.get('project') ?? 'all';
95
+
96
+ const validPeriods: TimePeriod[] = ['24h', '7d', '30d'];
97
+ const period: TimePeriod = validPeriods.includes(periodParam as TimePeriod)
98
+ ? (periodParam as TimePeriod)
99
+ : '30d';
100
+
101
+ const validProjects = await getValidProjects(env);
102
+ const project = validProjects.includes(projectParam) ? projectParam : 'all';
103
+
104
+ return { period, project };
105
+ }
106
+
107
+ // =============================================================================
108
+ // JSON RESPONSE HELPER
109
+ // =============================================================================
110
+
111
+ /**
112
+ * JSON response helper
113
+ */
114
+ export function jsonResponse(data: unknown, status = 200): Response {
115
+ return new Response(JSON.stringify(data), {
116
+ status,
117
+ headers: { 'Content-Type': 'application/json' },
118
+ });
119
+ }
120
+
121
+ // =============================================================================
122
+ // PROJECT FILTERING
123
+ // =============================================================================
124
+
125
+ /**
126
+ * Normalize resource type from snapshot format to mapping table format.
127
+ * Snapshots use: 'queues', 'workflows', 'do', 'aigateway'
128
+ * Mapping table uses: 'queue', 'workflow', 'durable_object', 'ai_gateway'
129
+ */
130
+ function normalizeResourceType(resourceType: string): string {
131
+ const typeMap: Record<string, string> = {
132
+ queues: 'queue',
133
+ workflows: 'workflow',
134
+ do: 'durable_object',
135
+ aigateway: 'ai_gateway',
136
+ };
137
+ return typeMap[resourceType] ?? resourceType;
138
+ }
139
+
140
+ /**
141
+ * Build a project lookup cache from the D1 registry.
142
+ */
143
+ export async function buildProjectLookupCache(env: Env): Promise<ProjectLookupCache> {
144
+ const cache = new Map<string, string>();
145
+
146
+ try {
147
+ const result = await env.PLATFORM_DB.prepare(
148
+ `SELECT resource_type, resource_name, project_id FROM resource_project_mapping`
149
+ ).all<{
150
+ resource_type: string;
151
+ resource_name: string;
152
+ project_id: string;
153
+ }>();
154
+
155
+ for (const row of result.results ?? []) {
156
+ const key = `${row.resource_type}:${row.resource_name.toLowerCase()}`;
157
+ cache.set(key, row.project_id);
158
+ }
159
+ } catch {
160
+ // Failed to build cache, will fall back to patterns
161
+ }
162
+
163
+ return cache;
164
+ }
165
+
166
+ /**
167
+ * Identify project for a resource using the lookup cache.
168
+ * Normalizes resource type to handle snapshot vs mapping table differences.
169
+ */
170
+ export function identifyProjectWithCache(
171
+ cache: ProjectLookupCache,
172
+ resourceType: string,
173
+ resourceName: string
174
+ ): string {
175
+ // Normalize the resource type to match mapping table format
176
+ const normalizedType = normalizeResourceType(resourceType);
177
+ const key = `${normalizedType}:${resourceName.toLowerCase()}`;
178
+ const cached = cache.get(key);
179
+ if (cached) {
180
+ return cached;
181
+ }
182
+ return identifyProject(resourceName) ?? 'unknown';
183
+ }
184
+
185
+ /**
186
+ * Filter usage data by project (uses pattern-based identification)
187
+ * @deprecated Use filterByProjectWithRegistry for D1-backed lookups
188
+ */
189
+ export function filterByProject(usage: AccountUsage, project: string): AccountUsage {
190
+ if (project === 'all') {
191
+ return usage;
192
+ }
193
+
194
+ return {
195
+ ...usage,
196
+ workers: usage.workers.filter((w) => identifyProject(w.scriptName) === project),
197
+ d1: usage.d1.filter((db) => identifyProject(db.databaseName) === project),
198
+ kv: usage.kv.filter((ns) => identifyProject(ns.namespaceName) === project),
199
+ r2: usage.r2.filter((b) => identifyProject(b.bucketName) === project),
200
+ vectorize: usage.vectorize.filter((v) => identifyProject(v.name) === project),
201
+ aiGateway: usage.aiGateway.filter((gw) => identifyProject(gw.gatewayId) === project),
202
+ pages: usage.pages.filter((p) => identifyProject(p.projectName) === project),
203
+ durableObjects: usage.durableObjects,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Filter usage data by project using D1 registry cache.
209
+ */
210
+ export function filterByProjectWithRegistry(
211
+ usage: AccountUsage,
212
+ project: string,
213
+ cache: ProjectLookupCache
214
+ ): AccountUsage {
215
+ if (project === 'all') {
216
+ return usage;
217
+ }
218
+
219
+ return {
220
+ ...usage,
221
+ workers: usage.workers.filter(
222
+ (w) => identifyProjectWithCache(cache, 'worker', w.scriptName) === project
223
+ ),
224
+ d1: usage.d1.filter((db) => identifyProjectWithCache(cache, 'd1', db.databaseName) === project),
225
+ kv: usage.kv.filter(
226
+ (ns) => identifyProjectWithCache(cache, 'kv', ns.namespaceName) === project
227
+ ),
228
+ r2: usage.r2.filter((b) => identifyProjectWithCache(cache, 'r2', b.bucketName) === project),
229
+ vectorize: usage.vectorize.filter(
230
+ (v) => identifyProjectWithCache(cache, 'vectorize', v.name) === project
231
+ ),
232
+ aiGateway: usage.aiGateway.filter(
233
+ (gw) => identifyProjectWithCache(cache, 'ai_gateway', gw.gatewayId) === project
234
+ ),
235
+ pages: usage.pages.filter(
236
+ (p) => identifyProjectWithCache(cache, 'pages', p.projectName) === project
237
+ ),
238
+ durableObjects: usage.durableObjects,
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Attribute Vectorize queries to projects using the D1 registry cache.
244
+ */
245
+ export function attributeVectorizeByProject(
246
+ byIndex: Array<{ indexName: string; queriedDimensions: number }>,
247
+ cache: ProjectLookupCache,
248
+ accountTotal: number
249
+ ): VectorizeAttribution {
250
+ const byProject = new Map<string, number>();
251
+
252
+ for (const index of byIndex) {
253
+ const projectId = identifyProjectWithCache(cache, 'vectorize', index.indexName);
254
+ const dimensions = index.queriedDimensions;
255
+
256
+ if (projectId && projectId !== 'unknown') {
257
+ byProject.set(projectId, (byProject.get(projectId) ?? 0) + dimensions);
258
+ }
259
+ }
260
+
261
+ const sumAttributed = Array.from(byProject.values()).reduce((sum, d) => sum + d, 0);
262
+ const unattributed = Math.max(0, accountTotal - sumAttributed);
263
+
264
+ return {
265
+ byProject,
266
+ unattributed,
267
+ total: sumAttributed + unattributed,
268
+ };
269
+ }
270
+
271
+ // =============================================================================
272
+ // SUMMARY CALCULATION
273
+ // =============================================================================
274
+
275
+ /**
276
+ * Calculate summary statistics
277
+ */
278
+ export function calculateSummary(data: AccountUsage) {
279
+ return {
280
+ totalWorkers: data.workers.length,
281
+ totalD1Databases: data.d1.length,
282
+ totalKVNamespaces: data.kv.length,
283
+ totalR2Buckets: data.r2.length,
284
+ totalVectorizeIndexes: data.vectorize.length,
285
+ totalAIGateways: data.aiGateway.length,
286
+ totalPagesProjects: data.pages.length,
287
+ totalRequests: data.workers.reduce((sum, w) => sum + w.requests, 0),
288
+ totalRowsRead: data.d1.reduce((sum, db) => sum + db.rowsRead, 0),
289
+ totalRowsWritten: data.d1.reduce((sum, db) => sum + db.rowsWritten, 0),
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Calculate trend for comparison
295
+ */
296
+ export function calcTrend(
297
+ current: number,
298
+ prior: number
299
+ ): { trend: 'up' | 'down' | 'stable'; percentChange: number } {
300
+ if (prior === 0) {
301
+ return { trend: current > 0 ? 'up' : 'stable', percentChange: current > 0 ? 100 : 0 };
302
+ }
303
+
304
+ const percentChange = ((current - prior) / prior) * 100;
305
+
306
+ let trend: 'up' | 'down' | 'stable' = 'stable';
307
+ if (percentChange > 5) trend = 'up';
308
+ else if (percentChange < -5) trend = 'down';
309
+
310
+ return { trend, percentChange: Math.round(percentChange * 10) / 10 };
311
+ }
312
+
313
+ // =============================================================================
314
+ // DELTA CALCULATION
315
+ // =============================================================================
316
+
317
+ /**
318
+ * Calculate delta between current and previous values.
319
+ *
320
+ * When previous is undefined (KV key expired or first run), returns the full
321
+ * cumulative value -- which can massively inflate SUM() totals in hourly snapshots.
322
+ * The optional maxReasonableDelta cap prevents this by limiting the result to a
323
+ * reasonable hourly maximum (e.g., 3x the prorated monthly allowance).
324
+ *
325
+ * @param current - Current cumulative value from GraphQL/REST API
326
+ * @param previous - Previous hour's cumulative value from KV (undefined if expired)
327
+ * @param maxReasonableDelta - Optional cap to prevent cumulative values stored as deltas
328
+ */
329
+ export function calculateDelta(
330
+ current: number,
331
+ previous: number | undefined,
332
+ maxReasonableDelta?: number
333
+ ): number {
334
+ if (previous === undefined) {
335
+ if (maxReasonableDelta !== undefined && current > maxReasonableDelta) {
336
+ return maxReasonableDelta;
337
+ }
338
+ return current;
339
+ }
340
+ const delta = current - previous;
341
+ if (delta < 0) {
342
+ // Counter reset (billing period rollover)
343
+ if (maxReasonableDelta !== undefined && current > maxReasonableDelta) {
344
+ return maxReasonableDelta;
345
+ }
346
+ return current;
347
+ }
348
+ if (maxReasonableDelta !== undefined && delta > maxReasonableDelta) {
349
+ return maxReasonableDelta;
350
+ }
351
+ return delta;
352
+ }
353
+
354
+ /**
355
+ * Load previous hour's cumulative metrics from KV.
356
+ */
357
+ export async function loadPreviousHourMetrics(env: Env): Promise<PreviousHourMetrics | null> {
358
+ try {
359
+ const stored = await env.PLATFORM_CACHE.get(CB_KEYS.PREV_HOUR_ACCOUNT_METRICS);
360
+ if (stored) {
361
+ const parsed = JSON.parse(stored);
362
+ return {
363
+ snapshotHour: parsed.snapshotHour ?? '',
364
+ timestamp: parsed.timestamp ?? 0,
365
+ do: {
366
+ requests: parsed.do?.requests ?? 0,
367
+ gbSeconds: parsed.do?.gbSeconds ?? 0,
368
+ storageReadUnits: parsed.do?.storageReadUnits ?? 0,
369
+ storageWriteUnits: parsed.do?.storageWriteUnits ?? 0,
370
+ storageDeleteUnits: parsed.do?.storageDeleteUnits ?? 0,
371
+ },
372
+ workersAI: {
373
+ neurons: parsed.workersAI?.neurons ?? 0,
374
+ requests: parsed.workersAI?.requests ?? 0,
375
+ },
376
+ vectorize: {
377
+ queries: parsed.vectorize?.queries ?? 0,
378
+ },
379
+ queues: {
380
+ produced: parsed.queues?.produced ?? 0,
381
+ consumed: parsed.queues?.consumed ?? 0,
382
+ },
383
+ workflows: {
384
+ executions: parsed.workflows?.executions ?? 0,
385
+ successes: parsed.workflows?.successes ?? 0,
386
+ failures: parsed.workflows?.failures ?? 0,
387
+ wallTimeMs: parsed.workflows?.wallTimeMs ?? 0,
388
+ cpuTimeMs: parsed.workflows?.cpuTimeMs ?? 0,
389
+ },
390
+ workers: {
391
+ requests: parsed.workers?.requests ?? 0,
392
+ errors: parsed.workers?.errors ?? 0,
393
+ cpuTimeMs: parsed.workers?.cpuTimeMs ?? 0,
394
+ },
395
+ d1: {
396
+ rowsRead: parsed.d1?.rowsRead ?? 0,
397
+ rowsWritten: parsed.d1?.rowsWritten ?? 0,
398
+ },
399
+ kv: {
400
+ reads: parsed.kv?.reads ?? 0,
401
+ writes: parsed.kv?.writes ?? 0,
402
+ deletes: parsed.kv?.deletes ?? 0,
403
+ lists: parsed.kv?.lists ?? 0,
404
+ },
405
+ r2: {
406
+ classAOps: parsed.r2?.classAOps ?? 0,
407
+ classBOps: parsed.r2?.classBOps ?? 0,
408
+ egressBytes: parsed.r2?.egressBytes ?? 0,
409
+ },
410
+ aiGateway: {
411
+ requests: parsed.aiGateway?.requests ?? 0,
412
+ tokensIn: parsed.aiGateway?.tokensIn ?? 0,
413
+ tokensOut: parsed.aiGateway?.tokensOut ?? 0,
414
+ cached: parsed.aiGateway?.cached ?? 0,
415
+ },
416
+ pages: {
417
+ deployments: parsed.pages?.deployments ?? 0,
418
+ bandwidthBytes: parsed.pages?.bandwidthBytes ?? 0,
419
+ },
420
+ projects: parsed.projects,
421
+ };
422
+ }
423
+ } catch {
424
+ // Return null on error
425
+ }
426
+ return null;
427
+ }
428
+
429
+ /**
430
+ * Save previous hour's cumulative metrics to KV.
431
+ */
432
+ export async function savePreviousHourMetrics(
433
+ env: Env,
434
+ metrics: PreviousHourMetrics
435
+ ): Promise<void> {
436
+ try {
437
+ await env.PLATFORM_CACHE.put(CB_KEYS.PREV_HOUR_ACCOUNT_METRICS, JSON.stringify(metrics), {
438
+ expirationTtl: 86400 * 7, // 7 days -- prevents delta calculation failures from KV expiry
439
+ });
440
+ await env.PLATFORM_CACHE.put(CB_KEYS.PREV_HOUR_LAST_COLLECTION, metrics.snapshotHour, {
441
+ expirationTtl: 86400 * 7,
442
+ });
443
+ } catch {
444
+ // Non-fatal error
445
+ }
446
+ }
447
+
448
+ // =============================================================================
449
+ // QUEUE/WORKFLOW PROJECT MAPPING
450
+ // =============================================================================
451
+
452
+ /**
453
+ * Map queue name to project ID for per-project cost attribution.
454
+ *
455
+ * TODO: Add your queue-to-project mappings here.
456
+ * This function is called during data collection to attribute queue costs.
457
+ */
458
+ export function getQueueProject(queueName: string): string {
459
+ const lowerName = queueName.toLowerCase();
460
+
461
+ // TODO: Add your project-specific queue patterns:
462
+ // if (lowerName.startsWith('my-project-')) return 'my-project';
463
+
464
+ if (lowerName.startsWith('platform-')) {
465
+ return 'platform';
466
+ }
467
+
468
+ return 'platform';
469
+ }
470
+
471
+ /**
472
+ * Map workflow name to project ID for per-project metrics attribution.
473
+ *
474
+ * TODO: Add your workflow-to-project mappings here.
475
+ */
476
+ export function getWorkflowProject(workflowName: string): string {
477
+ // TODO: Add your project-specific workflow patterns:
478
+ // if (MY_PROJECT_WORKFLOWS.has(workflowName)) return 'my-project';
479
+
480
+ return 'platform';
481
+ }
482
+
483
+ // =============================================================================
484
+ // PRICING
485
+ // =============================================================================
486
+
487
+ let cachedPricing: PlatformPricing | null = null;
488
+
489
+ /**
490
+ * Load pricing configuration from KV with fallback to defaults.
491
+ */
492
+ export async function loadPricing(env: Env): Promise<PlatformPricing> {
493
+ if (cachedPricing) {
494
+ return cachedPricing;
495
+ }
496
+
497
+ try {
498
+ const kvPricing = await env.PLATFORM_CACHE.get(CB_KEYS.PRICING, 'json');
499
+
500
+ if (kvPricing && typeof kvPricing === 'object') {
501
+ cachedPricing = {
502
+ ...DEFAULT_PRICING,
503
+ ...(kvPricing as Partial<PlatformPricing>),
504
+ workers: {
505
+ ...DEFAULT_PRICING.workers,
506
+ ...((kvPricing as Partial<PlatformPricing>).workers || {}),
507
+ },
508
+ d1: { ...DEFAULT_PRICING.d1, ...((kvPricing as Partial<PlatformPricing>).d1 || {}) },
509
+ kv: { ...DEFAULT_PRICING.kv, ...((kvPricing as Partial<PlatformPricing>).kv || {}) },
510
+ r2: { ...DEFAULT_PRICING.r2, ...((kvPricing as Partial<PlatformPricing>).r2 || {}) },
511
+ vectorize: {
512
+ ...DEFAULT_PRICING.vectorize,
513
+ ...((kvPricing as Partial<PlatformPricing>).vectorize || {}),
514
+ },
515
+ workersAI: {
516
+ ...DEFAULT_PRICING.workersAI,
517
+ ...((kvPricing as Partial<PlatformPricing>).workersAI || {}),
518
+ },
519
+ durableObjects: {
520
+ ...DEFAULT_PRICING.durableObjects,
521
+ ...((kvPricing as Partial<PlatformPricing>).durableObjects || {}),
522
+ },
523
+ queues: {
524
+ ...DEFAULT_PRICING.queues,
525
+ ...((kvPricing as Partial<PlatformPricing>).queues || {}),
526
+ },
527
+ pages: {
528
+ ...DEFAULT_PRICING.pages,
529
+ ...((kvPricing as Partial<PlatformPricing>).pages || {}),
530
+ },
531
+ };
532
+ return cachedPricing;
533
+ }
534
+ } catch {
535
+ // Fall back to defaults
536
+ }
537
+
538
+ cachedPricing = DEFAULT_PRICING;
539
+ return cachedPricing;
540
+ }
541
+
542
+ /**
543
+ * Reset cached pricing.
544
+ */
545
+ export function resetPricingCache(): void {
546
+ cachedPricing = null;
547
+ }
548
+
549
+ // =============================================================================
550
+ // BILLING SETTINGS
551
+ // =============================================================================
552
+
553
+ let cachedBillingSettings: BillingSettingsType | null = null;
554
+ let billingSettingsCacheTime = 0;
555
+
556
+ /**
557
+ * Fetch billing settings from D1, with in-memory caching.
558
+ */
559
+ export async function fetchBillingSettings(
560
+ env: Env,
561
+ accountId = 'default'
562
+ ): Promise<BillingSettingsType> {
563
+ const now = Date.now();
564
+ if (cachedBillingSettings && now - billingSettingsCacheTime < BILLING_SETTINGS_CACHE_TTL_MS) {
565
+ return cachedBillingSettings;
566
+ }
567
+
568
+ try {
569
+ const result = await env.PLATFORM_DB.prepare(
570
+ `SELECT account_id, plan_type, billing_cycle_day, billing_currency, base_cost_monthly, notes
571
+ FROM billing_settings
572
+ WHERE account_id = ?
573
+ LIMIT 1`
574
+ )
575
+ .bind(accountId)
576
+ .first<{
577
+ account_id: string;
578
+ plan_type: string;
579
+ billing_cycle_day: number;
580
+ billing_currency: string;
581
+ base_cost_monthly: number;
582
+ notes: string | null;
583
+ }>();
584
+
585
+ if (result) {
586
+ cachedBillingSettings = {
587
+ accountId: result.account_id,
588
+ planType: result.plan_type as BillingSettingsType['planType'],
589
+ billingCycleDay: result.billing_cycle_day,
590
+ billingCurrency: result.billing_currency,
591
+ baseCostMonthly: result.base_cost_monthly,
592
+ notes: result.notes ?? undefined,
593
+ };
594
+ billingSettingsCacheTime = now;
595
+ return cachedBillingSettings;
596
+ }
597
+
598
+ cachedBillingSettings = getDefaultBillingSettings();
599
+ billingSettingsCacheTime = now;
600
+ return cachedBillingSettings;
601
+ } catch {
602
+ cachedBillingSettings = getDefaultBillingSettings();
603
+ billingSettingsCacheTime = now;
604
+ return cachedBillingSettings;
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Reset billing settings cache.
610
+ */
611
+ export function resetBillingSettingsCache(): void {
612
+ cachedBillingSettings = null;
613
+ billingSettingsCacheTime = 0;
614
+ }
615
+
616
+ // =============================================================================
617
+ // PLATFORM SETTINGS
618
+ // =============================================================================
619
+
620
+ /**
621
+ * Get all platform settings from D1/KV.
622
+ */
623
+ export async function getPlatformSettings(env: Env): Promise<PlatformSettings> {
624
+ return getPlatformSettingsFromLib(env);
625
+ }
626
+
627
+ /**
628
+ * Get budget thresholds from D1 usage_settings table.
629
+ */
630
+ export async function getBudgetThresholds(env: Env): Promise<BudgetThresholds> {
631
+ const settings = await getPlatformSettings(env);
632
+ return {
633
+ softBudgetLimit: settings.budgetSoftLimit,
634
+ warningThreshold: settings.budgetWarningThreshold,
635
+ };
636
+ }
637
+
638
+ // =============================================================================
639
+ // SAMPLING MODE
640
+ // =============================================================================
641
+
642
+ /**
643
+ * Determine sampling mode based on D1 write usage.
644
+ */
645
+ export async function determineSamplingMode(
646
+ env: Env
647
+ ): Promise<(typeof SamplingModeEnum)[keyof typeof SamplingModeEnum]> {
648
+ const settings = await getPlatformSettings(env);
649
+ const d1Writes = parseInt((await env.PLATFORM_CACHE.get(CB_KEYS.D1_WRITES_24H)) || '0', 10);
650
+ const ratio = d1Writes / settings.d1WriteLimit;
651
+
652
+ if (ratio >= 0.9) return SamplingModeEnum.MINIMAL;
653
+ if (ratio >= 0.8) return SamplingModeEnum.QUARTER;
654
+ if (ratio >= 0.6) return SamplingModeEnum.HALF;
655
+ return SamplingModeEnum.FULL;
656
+ }
657
+
658
+ /**
659
+ * Check if we should run data collection this hour based on sampling mode.
660
+ */
661
+ export function shouldRunThisHour(
662
+ mode: (typeof SamplingModeEnum)[keyof typeof SamplingModeEnum],
663
+ hour: number
664
+ ): boolean {
665
+ return hour % mode === 0;
666
+ }
667
+
668
+ // =============================================================================
669
+ // TIME HELPERS
670
+ // =============================================================================
671
+
672
+ /**
673
+ * Generate a unique ID for records.
674
+ */
675
+ export function generateId(): string {
676
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
677
+ }
678
+
679
+ /**
680
+ * Get current hour in ISO format (YYYY-MM-DDTHH:00:00Z).
681
+ */
682
+ export function getCurrentHour(): string {
683
+ const now = new Date();
684
+ now.setMinutes(0, 0, 0);
685
+ // Format: 2026-01-28T12:00:00Z (19 chars + Z)
686
+ return now.toISOString().slice(0, 19) + 'Z';
687
+ }
688
+
689
+ /**
690
+ * Get today's date in YYYY-MM-DD format.
691
+ */
692
+ export function getTodayDate(): string {
693
+ return new Date().toISOString().slice(0, 10);
694
+ }
695
+
696
+ // =============================================================================
697
+ // API KEY VALIDATION
698
+ // =============================================================================
699
+
700
+ /**
701
+ * Validate API key for protected endpoints.
702
+ */
703
+ export function validateApiKey(request: Request, env: Env): Response | null {
704
+ if (!env.USAGE_API_KEY) {
705
+ return null;
706
+ }
707
+
708
+ const providedKey = request.headers.get('X-API-Key');
709
+
710
+ if (!providedKey) {
711
+ return jsonResponse({ error: 'Missing X-API-Key header', code: 'UNAUTHORIZED' }, 401);
712
+ }
713
+
714
+ if (providedKey !== env.USAGE_API_KEY) {
715
+ return jsonResponse({ error: 'Invalid API key', code: 'FORBIDDEN' }, 403);
716
+ }
717
+
718
+ return null;
719
+ }
720
+
721
+ // =============================================================================
722
+ // UTILIZATION STATUS
723
+ // =============================================================================
724
+
725
+ import type { ServiceUtilizationStatus } from './types';
726
+
727
+ /**
728
+ * Get service utilization status based on percentage of limit used.
729
+ */
730
+ export function getServiceUtilizationStatus(pct: number): ServiceUtilizationStatus {
731
+ if (pct >= 100) return 'overage';
732
+ if (pct >= 80) return 'critical';
733
+ if (pct >= 60) return 'warning';
734
+ return 'ok';
735
+ }
736
+
737
+ // =============================================================================
738
+ // FETCH WITH RETRY
739
+ // =============================================================================
740
+
741
+ /**
742
+ * Status codes that should trigger a retry.
743
+ * 429 = rate limited, 500/502/503/504 = transient server errors.
744
+ */
745
+ const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
746
+
747
+ /**
748
+ * Wrapper around fetch that handles retryable responses with exponential backoff.
749
+ * Retries on 429 (rate limit) and 5xx (server errors).
750
+ */
751
+ export async function fetchWithRetry(
752
+ url: string,
753
+ options: RequestInit,
754
+ maxRetries = 3,
755
+ baseDelayMs = 1000
756
+ ): Promise<Response> {
757
+ let lastResponse: Response | undefined;
758
+
759
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
760
+ const response = await fetch(url, options);
761
+
762
+ if (!RETRYABLE_STATUS_CODES.has(response.status)) {
763
+ return response;
764
+ }
765
+
766
+ // Consume body to avoid stalled connections
767
+ await response.text().catch(() => {});
768
+ lastResponse = response;
769
+
770
+ if (attempt < maxRetries) {
771
+ const retryAfter = response.headers.get('Retry-After');
772
+ let delayMs: number;
773
+
774
+ if (retryAfter) {
775
+ const seconds = parseInt(retryAfter, 10);
776
+ if (!isNaN(seconds)) {
777
+ delayMs = seconds * 1000;
778
+ } else {
779
+ const date = new Date(retryAfter);
780
+ delayMs = date.getTime() - Date.now();
781
+ }
782
+ } else {
783
+ delayMs = baseDelayMs * Math.pow(2, attempt);
784
+ }
785
+
786
+ delayMs = Math.min(Math.max(delayMs, 100), 30000);
787
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
788
+ }
789
+ }
790
+
791
+ // Return the last response status rather than always 429
792
+ return new Response(`Request failed after ${maxRetries} retries`, {
793
+ status: lastResponse?.status ?? 429,
794
+ });
795
+ }