@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,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage Settings Handlers
|
|
3
|
+
*
|
|
4
|
+
* Handler functions for settings, circuit breaker status, and live usage endpoints.
|
|
5
|
+
* Extracted from platform-usage.ts as part of Phase B migration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type Env,
|
|
10
|
+
type BudgetThresholds,
|
|
11
|
+
type SettingsResponse,
|
|
12
|
+
type LiveUsageResponse,
|
|
13
|
+
SamplingMode,
|
|
14
|
+
} from '../shared';
|
|
15
|
+
import { CB_KEYS, SETTINGS_KEY, EXPECTED_USAGE_SETTINGS } from '../shared/constants';
|
|
16
|
+
import {
|
|
17
|
+
jsonResponse,
|
|
18
|
+
getPlatformSettings,
|
|
19
|
+
getBudgetThresholds,
|
|
20
|
+
validateApiKey,
|
|
21
|
+
} from '../shared/utils';
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_ALERT_THRESHOLDS,
|
|
24
|
+
mergeThresholds,
|
|
25
|
+
type AlertThresholds,
|
|
26
|
+
type ServiceThreshold,
|
|
27
|
+
} from '../../shared/cloudflare';
|
|
28
|
+
import { createLoggerFromEnv } from '@littlebearapps/platform-consumer-sdk';
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// HELPER FUNCTIONS
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Save budget thresholds to D1 usage_settings table.
|
|
36
|
+
*/
|
|
37
|
+
async function saveBudgetThresholds(
|
|
38
|
+
env: Env,
|
|
39
|
+
thresholds: Partial<BudgetThresholds>
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const now = Math.floor(Date.now() / 1000);
|
|
42
|
+
|
|
43
|
+
if (thresholds.softBudgetLimit !== undefined) {
|
|
44
|
+
await env.PLATFORM_DB.prepare(
|
|
45
|
+
`
|
|
46
|
+
INSERT INTO usage_settings (id, project, setting_key, setting_value, updated_at)
|
|
47
|
+
VALUES (?, 'all', 'budget_soft_limit', ?, ?)
|
|
48
|
+
ON CONFLICT (project, setting_key) DO UPDATE SET
|
|
49
|
+
setting_value = excluded.setting_value,
|
|
50
|
+
updated_at = excluded.updated_at
|
|
51
|
+
`
|
|
52
|
+
)
|
|
53
|
+
.bind(`budget_soft_limit_all`, thresholds.softBudgetLimit.toString(), now)
|
|
54
|
+
.run();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (thresholds.warningThreshold !== undefined) {
|
|
58
|
+
await env.PLATFORM_DB.prepare(
|
|
59
|
+
`
|
|
60
|
+
INSERT INTO usage_settings (id, project, setting_key, setting_value, updated_at)
|
|
61
|
+
VALUES (?, 'all', 'budget_warning_threshold', ?, ?)
|
|
62
|
+
ON CONFLICT (project, setting_key) DO UPDATE SET
|
|
63
|
+
setting_value = excluded.setting_value,
|
|
64
|
+
updated_at = excluded.updated_at
|
|
65
|
+
`
|
|
66
|
+
)
|
|
67
|
+
.bind(`budget_warning_threshold_all`, thresholds.warningThreshold.toString(), now)
|
|
68
|
+
.run();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// =============================================================================
|
|
73
|
+
// SETTINGS HANDLERS
|
|
74
|
+
// =============================================================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Handle GET /usage/settings (task-17.16)
|
|
78
|
+
*
|
|
79
|
+
* Returns current alert threshold configuration.
|
|
80
|
+
* Thresholds are stored in KV and merged with defaults.
|
|
81
|
+
*/
|
|
82
|
+
export async function handleGetSettings(env: Env): Promise<Response> {
|
|
83
|
+
const startTime = Date.now();
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
// Fetch alert thresholds from KV and budget thresholds from D1 in parallel
|
|
87
|
+
const [stored, budgetThresholds] = await Promise.all([
|
|
88
|
+
env.PLATFORM_CACHE.get(SETTINGS_KEY, 'json') as Promise<{
|
|
89
|
+
thresholds: Partial<AlertThresholds>;
|
|
90
|
+
updated: string;
|
|
91
|
+
} | null>,
|
|
92
|
+
getBudgetThresholds(env),
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
if (stored) {
|
|
96
|
+
// Merge stored thresholds with defaults to ensure all services are present
|
|
97
|
+
const thresholds = mergeThresholds(stored.thresholds);
|
|
98
|
+
return jsonResponse({
|
|
99
|
+
success: true,
|
|
100
|
+
thresholds,
|
|
101
|
+
budgetThresholds,
|
|
102
|
+
updated: stored.updated,
|
|
103
|
+
cached: true,
|
|
104
|
+
responseTimeMs: Date.now() - startTime,
|
|
105
|
+
} satisfies SettingsResponse & { responseTimeMs: number });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// No custom config, return defaults
|
|
109
|
+
return jsonResponse({
|
|
110
|
+
success: true,
|
|
111
|
+
thresholds: DEFAULT_ALERT_THRESHOLDS,
|
|
112
|
+
budgetThresholds,
|
|
113
|
+
cached: false,
|
|
114
|
+
responseTimeMs: Date.now() - startTime,
|
|
115
|
+
} satisfies SettingsResponse & { responseTimeMs: number });
|
|
116
|
+
} catch (error) {
|
|
117
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
118
|
+
// Error fetching settings - log at warn level since defaults will be used
|
|
119
|
+
|
|
120
|
+
return jsonResponse(
|
|
121
|
+
{
|
|
122
|
+
success: false,
|
|
123
|
+
error: 'Failed to fetch settings',
|
|
124
|
+
message: errorMessage,
|
|
125
|
+
},
|
|
126
|
+
500
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Handle PUT /usage/settings (task-17.16)
|
|
133
|
+
*
|
|
134
|
+
* Updates the alert threshold configuration and budget thresholds.
|
|
135
|
+
* Request body should contain partial AlertThresholds and/or budgetThresholds.
|
|
136
|
+
*/
|
|
137
|
+
export async function handlePutSettings(request: Request, env: Env): Promise<Response> {
|
|
138
|
+
const startTime = Date.now();
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const body = (await request.json()) as {
|
|
142
|
+
thresholds?: Partial<AlertThresholds>;
|
|
143
|
+
budgetThresholds?: Partial<BudgetThresholds>;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
(!body.thresholds || typeof body.thresholds !== 'object') &&
|
|
148
|
+
(!body.budgetThresholds || typeof body.budgetThresholds !== 'object')
|
|
149
|
+
) {
|
|
150
|
+
return jsonResponse(
|
|
151
|
+
{
|
|
152
|
+
success: false,
|
|
153
|
+
error: 'Invalid request body',
|
|
154
|
+
message: 'Request body must contain a thresholds and/or budgetThresholds object',
|
|
155
|
+
},
|
|
156
|
+
400
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Validate alert threshold values if provided
|
|
161
|
+
if (body.thresholds) {
|
|
162
|
+
for (const [service, config] of Object.entries(body.thresholds)) {
|
|
163
|
+
if (config) {
|
|
164
|
+
// Validate percentage values are 0-100
|
|
165
|
+
if (
|
|
166
|
+
config.warningPct !== undefined &&
|
|
167
|
+
(config.warningPct < 0 || config.warningPct > 100)
|
|
168
|
+
) {
|
|
169
|
+
return jsonResponse(
|
|
170
|
+
{
|
|
171
|
+
success: false,
|
|
172
|
+
error: 'Invalid threshold value',
|
|
173
|
+
message: `${service}.warningPct must be between 0 and 100`,
|
|
174
|
+
},
|
|
175
|
+
400
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (config.highPct !== undefined && (config.highPct < 0 || config.highPct > 100)) {
|
|
179
|
+
return jsonResponse(
|
|
180
|
+
{
|
|
181
|
+
success: false,
|
|
182
|
+
error: 'Invalid threshold value',
|
|
183
|
+
message: `${service}.highPct must be between 0 and 100`,
|
|
184
|
+
},
|
|
185
|
+
400
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (
|
|
189
|
+
config.criticalPct !== undefined &&
|
|
190
|
+
(config.criticalPct < 0 || config.criticalPct > 100)
|
|
191
|
+
) {
|
|
192
|
+
return jsonResponse(
|
|
193
|
+
{
|
|
194
|
+
success: false,
|
|
195
|
+
error: 'Invalid threshold value',
|
|
196
|
+
message: `${service}.criticalPct must be between 0 and 100`,
|
|
197
|
+
},
|
|
198
|
+
400
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
// Validate absoluteMax is non-negative
|
|
202
|
+
if (config.absoluteMax !== undefined && config.absoluteMax < 0) {
|
|
203
|
+
return jsonResponse(
|
|
204
|
+
{
|
|
205
|
+
success: false,
|
|
206
|
+
error: 'Invalid threshold value',
|
|
207
|
+
message: `${service}.absoluteMax must be non-negative`,
|
|
208
|
+
},
|
|
209
|
+
400
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Validate budget threshold values if provided
|
|
217
|
+
if (body.budgetThresholds) {
|
|
218
|
+
if (
|
|
219
|
+
body.budgetThresholds.softBudgetLimit !== undefined &&
|
|
220
|
+
(body.budgetThresholds.softBudgetLimit < 0 || body.budgetThresholds.softBudgetLimit > 10000)
|
|
221
|
+
) {
|
|
222
|
+
return jsonResponse(
|
|
223
|
+
{
|
|
224
|
+
success: false,
|
|
225
|
+
error: 'Invalid budget threshold value',
|
|
226
|
+
message: 'softBudgetLimit must be between 0 and 10000',
|
|
227
|
+
},
|
|
228
|
+
400
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (
|
|
232
|
+
body.budgetThresholds.warningThreshold !== undefined &&
|
|
233
|
+
(body.budgetThresholds.warningThreshold < 0 ||
|
|
234
|
+
body.budgetThresholds.warningThreshold > 10000)
|
|
235
|
+
) {
|
|
236
|
+
return jsonResponse(
|
|
237
|
+
{
|
|
238
|
+
success: false,
|
|
239
|
+
error: 'Invalid budget threshold value',
|
|
240
|
+
message: 'warningThreshold must be between 0 and 10000',
|
|
241
|
+
},
|
|
242
|
+
400
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const updated = new Date().toISOString();
|
|
248
|
+
let fullThresholds: AlertThresholds = DEFAULT_ALERT_THRESHOLDS;
|
|
249
|
+
|
|
250
|
+
// Update alert thresholds in KV if provided
|
|
251
|
+
if (body.thresholds) {
|
|
252
|
+
// Get existing settings to merge with
|
|
253
|
+
const existing = (await env.PLATFORM_CACHE.get(SETTINGS_KEY, 'json')) as {
|
|
254
|
+
thresholds: Partial<AlertThresholds>;
|
|
255
|
+
updated: string;
|
|
256
|
+
} | null;
|
|
257
|
+
|
|
258
|
+
// Merge new thresholds with existing (deep merge per service)
|
|
259
|
+
const mergedThresholds: Record<string, Partial<ServiceThreshold>> = {};
|
|
260
|
+
const allServices = Array.from(
|
|
261
|
+
new Set([...Object.keys(existing?.thresholds ?? {}), ...Object.keys(body.thresholds)])
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
for (const service of allServices) {
|
|
265
|
+
const existingService = existing?.thresholds?.[service];
|
|
266
|
+
const newService = body.thresholds[service];
|
|
267
|
+
if (existingService || newService) {
|
|
268
|
+
mergedThresholds[service] = {
|
|
269
|
+
...existingService,
|
|
270
|
+
...newService,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await env.PLATFORM_CACHE.put(
|
|
276
|
+
SETTINGS_KEY,
|
|
277
|
+
JSON.stringify({ thresholds: mergedThresholds, updated }),
|
|
278
|
+
{ expirationTtl: 60 * 60 * 24 * 365 } // 1 year TTL
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
fullThresholds = mergeThresholds(mergedThresholds as Partial<AlertThresholds>);
|
|
282
|
+
// Alert thresholds updated
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Update budget thresholds in D1 if provided
|
|
286
|
+
if (body.budgetThresholds) {
|
|
287
|
+
await saveBudgetThresholds(env, body.budgetThresholds);
|
|
288
|
+
// Budget thresholds updated
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Fetch current budget thresholds for the response
|
|
292
|
+
const currentBudgetThresholds = await getBudgetThresholds(env);
|
|
293
|
+
|
|
294
|
+
return jsonResponse({
|
|
295
|
+
success: true,
|
|
296
|
+
thresholds: fullThresholds,
|
|
297
|
+
budgetThresholds: currentBudgetThresholds,
|
|
298
|
+
updated,
|
|
299
|
+
responseTimeMs: Date.now() - startTime,
|
|
300
|
+
} satisfies SettingsResponse & { responseTimeMs: number });
|
|
301
|
+
} catch (error) {
|
|
302
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
303
|
+
// Error updating settings
|
|
304
|
+
|
|
305
|
+
return jsonResponse(
|
|
306
|
+
{
|
|
307
|
+
success: false,
|
|
308
|
+
error: 'Failed to update settings',
|
|
309
|
+
message: errorMessage,
|
|
310
|
+
},
|
|
311
|
+
500
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Handle GET /usage/settings/verify
|
|
318
|
+
*
|
|
319
|
+
* Returns all settings from D1 usage_settings table and validates completeness.
|
|
320
|
+
* Used to verify that all expected settings exist after migrations/sync.
|
|
321
|
+
*/
|
|
322
|
+
export async function handleSettingsVerify(env: Env): Promise<Response> {
|
|
323
|
+
try {
|
|
324
|
+
// Fetch all settings from D1
|
|
325
|
+
const result = await env.PLATFORM_DB.prepare(
|
|
326
|
+
`
|
|
327
|
+
SELECT setting_key, setting_value, project, updated_at
|
|
328
|
+
FROM usage_settings
|
|
329
|
+
WHERE project = 'all'
|
|
330
|
+
ORDER BY setting_key
|
|
331
|
+
`
|
|
332
|
+
).all<{ setting_key: string; setting_value: string; project: string; updated_at: number }>();
|
|
333
|
+
|
|
334
|
+
const settings = result.results ?? [];
|
|
335
|
+
const foundKeys = new Set(settings.map((s) => s.setting_key));
|
|
336
|
+
|
|
337
|
+
// Check for missing expected settings
|
|
338
|
+
const missingKeys = EXPECTED_USAGE_SETTINGS.filter((key) => !foundKeys.has(key));
|
|
339
|
+
|
|
340
|
+
// Check for unexpected settings (not in expected list)
|
|
341
|
+
const unexpectedKeys = settings
|
|
342
|
+
.map((s) => s.setting_key)
|
|
343
|
+
.filter((key) => !EXPECTED_USAGE_SETTINGS.includes(key));
|
|
344
|
+
|
|
345
|
+
const status = missingKeys.length === 0 ? 'complete' : 'incomplete';
|
|
346
|
+
|
|
347
|
+
return jsonResponse({
|
|
348
|
+
status,
|
|
349
|
+
totalExpected: EXPECTED_USAGE_SETTINGS.length,
|
|
350
|
+
totalFound: settings.length,
|
|
351
|
+
missingCount: missingKeys.length,
|
|
352
|
+
unexpectedCount: unexpectedKeys.length,
|
|
353
|
+
missing: missingKeys,
|
|
354
|
+
unexpected: unexpectedKeys,
|
|
355
|
+
settings: settings.map((s) => ({
|
|
356
|
+
key: s.setting_key,
|
|
357
|
+
value: s.setting_value,
|
|
358
|
+
project: s.project,
|
|
359
|
+
updatedAt: s.updated_at ? new Date(s.updated_at * 1000).toISOString() : null,
|
|
360
|
+
})),
|
|
361
|
+
});
|
|
362
|
+
} catch (error) {
|
|
363
|
+
return jsonResponse(
|
|
364
|
+
{
|
|
365
|
+
status: 'error',
|
|
366
|
+
error: error instanceof Error ? error.message : String(error),
|
|
367
|
+
},
|
|
368
|
+
500
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// =============================================================================
|
|
374
|
+
// CIRCUIT BREAKER STATUS HANDLER
|
|
375
|
+
// =============================================================================
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Handle GET /usage/circuit-breaker-status
|
|
379
|
+
*
|
|
380
|
+
* Returns current circuit breaker status for all services.
|
|
381
|
+
*/
|
|
382
|
+
export async function handleCircuitBreakerStatus(env: Env): Promise<Response> {
|
|
383
|
+
const startTime = Date.now();
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
// Fetch settings and KV values in parallel
|
|
387
|
+
const [settings, globalStop, samplingMode, d1Writes] = await Promise.all([
|
|
388
|
+
getPlatformSettings(env),
|
|
389
|
+
env.PLATFORM_CACHE.get(CB_KEYS.GLOBAL_STOP),
|
|
390
|
+
env.PLATFORM_CACHE.get(CB_KEYS.USAGE_SAMPLING_MODE),
|
|
391
|
+
env.PLATFORM_CACHE.get(CB_KEYS.D1_WRITES_24H),
|
|
392
|
+
]);
|
|
393
|
+
|
|
394
|
+
// Get registered projects from D1 and fetch their CB statuses dynamically
|
|
395
|
+
// TODO: Ensure your projects are registered in project_registry
|
|
396
|
+
const projectRows = await env.PLATFORM_DB.prepare(
|
|
397
|
+
`SELECT project_id FROM project_registry WHERE project_id != 'all'`
|
|
398
|
+
).all<{ project_id: string }>();
|
|
399
|
+
const registeredProjects = projectRows.results?.map((r) => r.project_id) ?? ['platform'];
|
|
400
|
+
|
|
401
|
+
// Fetch all project CB statuses in parallel
|
|
402
|
+
const projectStatusEntries = await Promise.all(
|
|
403
|
+
registeredProjects.map(async (pid) => {
|
|
404
|
+
const cbKey = `PROJECT:${pid.toUpperCase().replace(/-/g, '-')}:STATUS`;
|
|
405
|
+
const status = await env.PLATFORM_CACHE.get(cbKey);
|
|
406
|
+
return { id: pid, status: status ?? 'active' };
|
|
407
|
+
})
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
const d1WriteLimit = settings.d1WriteLimit;
|
|
411
|
+
const d1WritesNum = d1Writes ? parseInt(d1Writes, 10) : 0;
|
|
412
|
+
const d1WritePercentage = (d1WritesNum / d1WriteLimit) * 100;
|
|
413
|
+
|
|
414
|
+
// Determine sampling mode name
|
|
415
|
+
const samplingModeName = samplingMode
|
|
416
|
+
? (Object.keys(SamplingMode).find(
|
|
417
|
+
(k) => SamplingMode[k as keyof typeof SamplingMode] === parseInt(samplingMode, 10)
|
|
418
|
+
) ?? 'FULL')
|
|
419
|
+
: 'FULL';
|
|
420
|
+
|
|
421
|
+
// Build dynamic circuit breaker status objects
|
|
422
|
+
const circuitBreakers: Record<string, { status: string; paused: boolean }> = {
|
|
423
|
+
globalStop: { status: globalStop === 'true' ? 'true' : 'false', paused: globalStop === 'true' },
|
|
424
|
+
};
|
|
425
|
+
for (const entry of projectStatusEntries) {
|
|
426
|
+
circuitBreakers[entry.id] = {
|
|
427
|
+
status: entry.status,
|
|
428
|
+
paused: entry.status === 'paused',
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return jsonResponse({
|
|
433
|
+
success: true,
|
|
434
|
+
circuitBreakers,
|
|
435
|
+
// Array format for UI consumption (matches CircuitBreakerStatus component)
|
|
436
|
+
projects: projectStatusEntries.map((entry) => ({
|
|
437
|
+
id: entry.id,
|
|
438
|
+
status: entry.status === 'paused' ? 'tripped' : 'active',
|
|
439
|
+
label: entry.status === 'paused' ? 'Paused' : 'Active',
|
|
440
|
+
})),
|
|
441
|
+
adaptiveSampling: {
|
|
442
|
+
samplingMode: samplingModeName,
|
|
443
|
+
d1Writes24h: d1WritesNum,
|
|
444
|
+
d1WriteLimit,
|
|
445
|
+
d1WritePercentage: Math.round(d1WritePercentage * 100) / 100,
|
|
446
|
+
},
|
|
447
|
+
timestamp: new Date().toISOString(),
|
|
448
|
+
responseTimeMs: Date.now() - startTime,
|
|
449
|
+
});
|
|
450
|
+
} catch (error) {
|
|
451
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
452
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:circuitbreaker');
|
|
453
|
+
log.error('Error fetching circuit breaker status', error instanceof Error ? error : undefined, {
|
|
454
|
+
errorMessage,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
return jsonResponse(
|
|
458
|
+
{
|
|
459
|
+
success: false,
|
|
460
|
+
error: 'Failed to fetch circuit breaker status',
|
|
461
|
+
message: errorMessage,
|
|
462
|
+
},
|
|
463
|
+
500
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// =============================================================================
|
|
469
|
+
// LIVE USAGE HANDLER
|
|
470
|
+
// =============================================================================
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Handle GET /usage/live
|
|
474
|
+
*
|
|
475
|
+
* Returns real-time KV data for monitoring:
|
|
476
|
+
* - Circuit breaker states (global + per-project)
|
|
477
|
+
* - Adaptive sampling mode and D1 write tracking
|
|
478
|
+
* - Latest hourly snapshot metrics
|
|
479
|
+
*
|
|
480
|
+
* Requires X-API-Key header for authentication.
|
|
481
|
+
*/
|
|
482
|
+
export async function handleLiveUsage(request: Request, env: Env): Promise<Response> {
|
|
483
|
+
const startTime = Date.now();
|
|
484
|
+
|
|
485
|
+
// Validate API key
|
|
486
|
+
const authError = validateApiKey(request, env);
|
|
487
|
+
if (authError) return authError;
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
// Fetch settings and KV data in parallel for minimal latency
|
|
491
|
+
const [settings, globalStop, samplingMode, d1Writes] = await Promise.all([
|
|
492
|
+
getPlatformSettings(env),
|
|
493
|
+
env.PLATFORM_CACHE.get(CB_KEYS.GLOBAL_STOP),
|
|
494
|
+
env.PLATFORM_CACHE.get(CB_KEYS.USAGE_SAMPLING_MODE),
|
|
495
|
+
env.PLATFORM_CACHE.get(CB_KEYS.D1_WRITES_24H),
|
|
496
|
+
]);
|
|
497
|
+
|
|
498
|
+
// Get registered projects and fetch their CB statuses dynamically
|
|
499
|
+
const liveProjectRows = await env.PLATFORM_DB.prepare(
|
|
500
|
+
`SELECT project_id FROM project_registry WHERE project_id != 'all'`
|
|
501
|
+
).all<{ project_id: string }>();
|
|
502
|
+
const liveRegisteredProjects = liveProjectRows.results?.map((r) => r.project_id) ?? ['platform'];
|
|
503
|
+
|
|
504
|
+
const projectStatuses: Array<{ project: string; status: string | null }> = await Promise.all(
|
|
505
|
+
liveRegisteredProjects.map(async (pid) => {
|
|
506
|
+
const cbKey = `PROJECT:${pid.toUpperCase().replace(/-/g, '-')}:STATUS`;
|
|
507
|
+
const status = await env.PLATFORM_CACHE.get(cbKey);
|
|
508
|
+
return { project: pid, status };
|
|
509
|
+
})
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
const d1WriteLimit = settings.d1WriteLimit;
|
|
513
|
+
|
|
514
|
+
// Build list of active circuit breakers
|
|
515
|
+
const activeBreakers: LiveUsageResponse['circuitBreakers']['activeBreakers'] = [];
|
|
516
|
+
|
|
517
|
+
for (const { project, status } of projectStatuses) {
|
|
518
|
+
if (status === 'paused') {
|
|
519
|
+
activeBreakers.push({
|
|
520
|
+
project,
|
|
521
|
+
status: 'paused',
|
|
522
|
+
reason: 'Resource limit exceeded',
|
|
523
|
+
});
|
|
524
|
+
} else if (status === 'degraded') {
|
|
525
|
+
activeBreakers.push({
|
|
526
|
+
project,
|
|
527
|
+
status: 'degraded',
|
|
528
|
+
reason: 'Operating in degraded mode',
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Calculate D1 write metrics
|
|
534
|
+
const d1WritesNum = d1Writes ? parseInt(d1Writes, 10) : 0;
|
|
535
|
+
const d1WritePercentage = (d1WritesNum / d1WriteLimit) * 100;
|
|
536
|
+
|
|
537
|
+
// Determine sampling mode name
|
|
538
|
+
const samplingModeName = samplingMode
|
|
539
|
+
? (Object.keys(SamplingMode).find(
|
|
540
|
+
(k) => SamplingMode[k as keyof typeof SamplingMode] === parseInt(samplingMode, 10)
|
|
541
|
+
) ?? 'FULL')
|
|
542
|
+
: 'FULL';
|
|
543
|
+
|
|
544
|
+
// Fetch latest hourly snapshot from D1 for request estimates
|
|
545
|
+
let latestSnapshot: LiveUsageResponse['latestSnapshot'] = null;
|
|
546
|
+
try {
|
|
547
|
+
const snapshotResult = await env.PLATFORM_DB.prepare(
|
|
548
|
+
`SELECT snapshot_hour, workers_requests, d1_rows_read, kv_reads
|
|
549
|
+
FROM hourly_usage_snapshots
|
|
550
|
+
WHERE project = 'all'
|
|
551
|
+
ORDER BY snapshot_hour DESC
|
|
552
|
+
LIMIT 1`
|
|
553
|
+
).first<{
|
|
554
|
+
snapshot_hour: string;
|
|
555
|
+
workers_requests: number | null;
|
|
556
|
+
d1_rows_read: number | null;
|
|
557
|
+
kv_reads: number | null;
|
|
558
|
+
}>();
|
|
559
|
+
|
|
560
|
+
if (snapshotResult) {
|
|
561
|
+
latestSnapshot = {
|
|
562
|
+
snapshotHour: snapshotResult.snapshot_hour,
|
|
563
|
+
workersRequests: snapshotResult.workers_requests,
|
|
564
|
+
d1RowsRead: snapshotResult.d1_rows_read,
|
|
565
|
+
kvReads: snapshotResult.kv_reads,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
} catch (dbError) {
|
|
569
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:live');
|
|
570
|
+
log.error(
|
|
571
|
+
'Failed to fetch latest snapshot from D1',
|
|
572
|
+
dbError instanceof Error ? dbError : undefined
|
|
573
|
+
);
|
|
574
|
+
// Continue without snapshot data - KV data is primary
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const response: LiveUsageResponse = {
|
|
578
|
+
timestamp: new Date().toISOString(),
|
|
579
|
+
circuitBreakers: {
|
|
580
|
+
globalStop: globalStop === 'true',
|
|
581
|
+
activeBreakers,
|
|
582
|
+
},
|
|
583
|
+
adaptiveSampling: {
|
|
584
|
+
mode: samplingModeName,
|
|
585
|
+
d1Writes24h: d1WritesNum,
|
|
586
|
+
d1WriteLimit,
|
|
587
|
+
d1WritePercentage: Math.round(d1WritePercentage * 100) / 100,
|
|
588
|
+
},
|
|
589
|
+
latestSnapshot,
|
|
590
|
+
responseTimeMs: Date.now() - startTime,
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
return jsonResponse(response);
|
|
594
|
+
} catch (error) {
|
|
595
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
596
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:live');
|
|
597
|
+
log.error('Error fetching live usage', error instanceof Error ? error : undefined, {
|
|
598
|
+
errorMessage,
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
return jsonResponse(
|
|
602
|
+
{
|
|
603
|
+
success: false,
|
|
604
|
+
error: 'Failed to fetch live usage data',
|
|
605
|
+
message: errorMessage,
|
|
606
|
+
},
|
|
607
|
+
500
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
}
|