@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.
- package/README.md +112 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +89 -0
- package/dist/prompts.d.ts +27 -0
- package/dist/prompts.js +80 -0
- package/dist/scaffold.d.ts +5 -0
- package/dist/scaffold.js +65 -0
- package/dist/templates.d.ts +16 -0
- package/dist/templates.js +131 -0
- package/package.json +46 -0
- package/templates/full/migrations/006_pattern_discovery.sql +199 -0
- package/templates/full/migrations/007_notifications_search.sql +127 -0
- package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
- package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
- package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
- package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
- package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
- package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
- package/templates/full/workers/pattern-discovery.ts +661 -0
- package/templates/full/workers/platform-alert-router.ts +1809 -0
- package/templates/full/workers/platform-notifications.ts +424 -0
- package/templates/full/workers/platform-search.ts +480 -0
- package/templates/full/workers/platform-settings.ts +436 -0
- package/templates/full/wrangler.alert-router.jsonc.hbs +34 -0
- package/templates/full/wrangler.notifications.jsonc.hbs +23 -0
- package/templates/full/wrangler.pattern-discovery.jsonc.hbs +33 -0
- package/templates/full/wrangler.search.jsonc.hbs +16 -0
- package/templates/full/wrangler.settings.jsonc.hbs +23 -0
- package/templates/shared/README.md.hbs +69 -0
- package/templates/shared/config/budgets.yaml.hbs +72 -0
- package/templates/shared/config/services.yaml.hbs +45 -0
- package/templates/shared/migrations/001_core_tables.sql +117 -0
- package/templates/shared/migrations/002_usage_warehouse.sql +830 -0
- package/templates/shared/migrations/003_feature_tracking.sql +250 -0
- package/templates/shared/migrations/004_settings_alerts.sql +452 -0
- package/templates/shared/migrations/seed.sql.hbs +4 -0
- package/templates/shared/package.json.hbs +21 -0
- package/templates/shared/scripts/sync-config.ts +242 -0
- package/templates/shared/tsconfig.json +12 -0
- package/templates/shared/workers/lib/analytics-engine.ts +357 -0
- package/templates/shared/workers/lib/billing.ts +293 -0
- package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
- package/templates/shared/workers/lib/control.ts +292 -0
- package/templates/shared/workers/lib/economics.ts +368 -0
- package/templates/shared/workers/lib/metrics.ts +103 -0
- package/templates/shared/workers/lib/platform-settings.ts +407 -0
- package/templates/shared/workers/lib/shared/allowances.ts +333 -0
- package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
- package/templates/shared/workers/lib/shared/types.ts +58 -0
- package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
- package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
- package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
- package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
- package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
- package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
- package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
- package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
- package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
- package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
- package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
- package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
- package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
- package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
- package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
- package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
- package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
- package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
- package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
- package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
- package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
- package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
- package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
- package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
- package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
- package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
- package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
- package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
- package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
- package/templates/shared/workers/platform-usage.ts +1915 -0
- package/templates/shared/wrangler.usage.jsonc.hbs +58 -0
- package/templates/standard/migrations/005_error_collection.sql +162 -0
- package/templates/standard/workers/error-collector.ts +2670 -0
- package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
- package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
- package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
- package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
- package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
- package/templates/standard/workers/lib/error-collector/github.ts +329 -0
- package/templates/standard/workers/lib/error-collector/types.ts +262 -0
- package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
- package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
- package/templates/standard/workers/platform-sentinel.ts +1744 -0
- package/templates/standard/wrangler.error-collector.jsonc.hbs +44 -0
- 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
|
+
}
|