@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,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DLQ Admin Handlers
|
|
3
|
+
*
|
|
4
|
+
* Admin endpoints for managing Dead Letter Queue messages.
|
|
5
|
+
* Provides visibility into failed messages and replay capability.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Env, TelemetryMessage } from '../shared';
|
|
9
|
+
import { createLoggerFromEnv } from '@littlebearapps/platform-consumer-sdk';
|
|
10
|
+
import { jsonResponse, generateId } from '../shared';
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// TYPES
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
interface DLQMessage {
|
|
17
|
+
id: string;
|
|
18
|
+
feature_key: string;
|
|
19
|
+
project: string;
|
|
20
|
+
category: string | null;
|
|
21
|
+
feature: string | null;
|
|
22
|
+
error_message: string | null;
|
|
23
|
+
error_category: string | null;
|
|
24
|
+
error_fingerprint: string | null;
|
|
25
|
+
retry_count: number;
|
|
26
|
+
correlation_id: string | null;
|
|
27
|
+
status: string;
|
|
28
|
+
created_at: number;
|
|
29
|
+
replayed_at: number | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface DLQListResponse {
|
|
33
|
+
success: boolean;
|
|
34
|
+
messages: DLQMessage[];
|
|
35
|
+
total: number;
|
|
36
|
+
pending: number;
|
|
37
|
+
replayed: number;
|
|
38
|
+
discarded: number;
|
|
39
|
+
timestamp: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface DLQStatsResponse {
|
|
43
|
+
success: boolean;
|
|
44
|
+
stats: {
|
|
45
|
+
total: number;
|
|
46
|
+
pending: number;
|
|
47
|
+
replayed: number;
|
|
48
|
+
discarded: number;
|
|
49
|
+
byProject: Record<string, number>;
|
|
50
|
+
byErrorCategory: Record<string, number>;
|
|
51
|
+
oldestPending: string | null;
|
|
52
|
+
};
|
|
53
|
+
timestamp: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// =============================================================================
|
|
57
|
+
// LIST DLQ MESSAGES
|
|
58
|
+
// =============================================================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* List DLQ messages with optional filtering.
|
|
62
|
+
* GET /admin/dlq?status=pending&project=my-app&limit=50
|
|
63
|
+
*/
|
|
64
|
+
export async function handleListDLQ(url: URL, env: Env): Promise<Response> {
|
|
65
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:dlq-admin');
|
|
66
|
+
|
|
67
|
+
const status = url.searchParams.get('status') || 'pending';
|
|
68
|
+
const project = url.searchParams.get('project');
|
|
69
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 100);
|
|
70
|
+
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Build query with filters
|
|
74
|
+
let query = `SELECT id, feature_key, project, category, feature, error_message,
|
|
75
|
+
error_category, error_fingerprint, retry_count, correlation_id,
|
|
76
|
+
status, created_at, replayed_at
|
|
77
|
+
FROM dead_letter_queue WHERE 1=1`;
|
|
78
|
+
const params: (string | number)[] = [];
|
|
79
|
+
|
|
80
|
+
if (status !== 'all') {
|
|
81
|
+
query += ` AND status = ?`;
|
|
82
|
+
params.push(status);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (project) {
|
|
86
|
+
query += ` AND project = ?`;
|
|
87
|
+
params.push(project);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`;
|
|
91
|
+
params.push(limit, offset);
|
|
92
|
+
|
|
93
|
+
const result = await env.PLATFORM_DB.prepare(query)
|
|
94
|
+
.bind(...params)
|
|
95
|
+
.all<DLQMessage>();
|
|
96
|
+
|
|
97
|
+
// Get counts
|
|
98
|
+
const counts = await env.PLATFORM_DB.prepare(
|
|
99
|
+
`SELECT
|
|
100
|
+
COUNT(*) as total,
|
|
101
|
+
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
|
102
|
+
SUM(CASE WHEN status = 'replayed' THEN 1 ELSE 0 END) as replayed,
|
|
103
|
+
SUM(CASE WHEN status = 'discarded' THEN 1 ELSE 0 END) as discarded
|
|
104
|
+
FROM dead_letter_queue`
|
|
105
|
+
).first<{ total: number; pending: number; replayed: number; discarded: number }>();
|
|
106
|
+
|
|
107
|
+
const response: DLQListResponse = {
|
|
108
|
+
success: true,
|
|
109
|
+
messages: result.results || [],
|
|
110
|
+
total: counts?.total || 0,
|
|
111
|
+
pending: counts?.pending || 0,
|
|
112
|
+
replayed: counts?.replayed || 0,
|
|
113
|
+
discarded: counts?.discarded || 0,
|
|
114
|
+
timestamp: new Date().toISOString(),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
log.info('DLQ list retrieved', { count: response.messages.length, status });
|
|
118
|
+
return jsonResponse(response);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
log.error('Failed to list DLQ messages', error);
|
|
121
|
+
return jsonResponse({ success: false, error: 'Failed to list DLQ messages' }, 500);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// =============================================================================
|
|
126
|
+
// GET DLQ STATS
|
|
127
|
+
// =============================================================================
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get DLQ statistics for monitoring.
|
|
131
|
+
* GET /admin/dlq/stats
|
|
132
|
+
*/
|
|
133
|
+
export async function handleDLQStats(env: Env): Promise<Response> {
|
|
134
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:dlq-admin');
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
// Get overall counts
|
|
138
|
+
const counts = await env.PLATFORM_DB.prepare(
|
|
139
|
+
`SELECT
|
|
140
|
+
COUNT(*) as total,
|
|
141
|
+
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
|
142
|
+
SUM(CASE WHEN status = 'replayed' THEN 1 ELSE 0 END) as replayed,
|
|
143
|
+
SUM(CASE WHEN status = 'discarded' THEN 1 ELSE 0 END) as discarded
|
|
144
|
+
FROM dead_letter_queue`
|
|
145
|
+
).first<{ total: number; pending: number; replayed: number; discarded: number }>();
|
|
146
|
+
|
|
147
|
+
// Get counts by project
|
|
148
|
+
const byProject = await env.PLATFORM_DB.prepare(
|
|
149
|
+
`SELECT project, COUNT(*) as count FROM dead_letter_queue
|
|
150
|
+
WHERE status = 'pending' GROUP BY project`
|
|
151
|
+
).all<{ project: string; count: number }>();
|
|
152
|
+
|
|
153
|
+
// Get counts by error category
|
|
154
|
+
const byCategory = await env.PLATFORM_DB.prepare(
|
|
155
|
+
`SELECT error_category, COUNT(*) as count FROM dead_letter_queue
|
|
156
|
+
WHERE status = 'pending' GROUP BY error_category`
|
|
157
|
+
).all<{ error_category: string | null; count: number }>();
|
|
158
|
+
|
|
159
|
+
// Get oldest pending message
|
|
160
|
+
const oldest = await env.PLATFORM_DB.prepare(
|
|
161
|
+
`SELECT created_at FROM dead_letter_queue
|
|
162
|
+
WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1`
|
|
163
|
+
).first<{ created_at: number }>();
|
|
164
|
+
|
|
165
|
+
const projectMap: Record<string, number> = {};
|
|
166
|
+
for (const row of byProject.results || []) {
|
|
167
|
+
projectMap[row.project] = row.count;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const categoryMap: Record<string, number> = {};
|
|
171
|
+
for (const row of byCategory.results || []) {
|
|
172
|
+
categoryMap[row.error_category || 'unknown'] = row.count;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const response: DLQStatsResponse = {
|
|
176
|
+
success: true,
|
|
177
|
+
stats: {
|
|
178
|
+
total: counts?.total || 0,
|
|
179
|
+
pending: counts?.pending || 0,
|
|
180
|
+
replayed: counts?.replayed || 0,
|
|
181
|
+
discarded: counts?.discarded || 0,
|
|
182
|
+
byProject: projectMap,
|
|
183
|
+
byErrorCategory: categoryMap,
|
|
184
|
+
oldestPending: oldest ? new Date(oldest.created_at * 1000).toISOString() : null,
|
|
185
|
+
},
|
|
186
|
+
timestamp: new Date().toISOString(),
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return jsonResponse(response);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
log.error('Failed to get DLQ stats', error);
|
|
192
|
+
return jsonResponse({ success: false, error: 'Failed to get DLQ stats' }, 500);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// =============================================================================
|
|
197
|
+
// REPLAY DLQ MESSAGE
|
|
198
|
+
// =============================================================================
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Replay a DLQ message by re-queuing it.
|
|
202
|
+
* POST /admin/dlq/:id/replay
|
|
203
|
+
*/
|
|
204
|
+
export async function handleReplayDLQ(messageId: string, env: Env): Promise<Response> {
|
|
205
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:dlq-admin');
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
// Get the message
|
|
209
|
+
const message = await env.PLATFORM_DB.prepare(
|
|
210
|
+
`SELECT id, message_payload, status FROM dead_letter_queue WHERE id = ?`
|
|
211
|
+
)
|
|
212
|
+
.bind(messageId)
|
|
213
|
+
.first<{ id: string; message_payload: string; status: string }>();
|
|
214
|
+
|
|
215
|
+
if (!message) {
|
|
216
|
+
return jsonResponse({ success: false, error: 'Message not found' }, 404);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (message.status !== 'pending') {
|
|
220
|
+
return jsonResponse(
|
|
221
|
+
{ success: false, error: `Message is not pending (status: ${message.status})` },
|
|
222
|
+
400
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Parse the payload
|
|
227
|
+
const telemetry: TelemetryMessage = JSON.parse(message.message_payload);
|
|
228
|
+
|
|
229
|
+
// Re-queue the message
|
|
230
|
+
await env.PLATFORM_TELEMETRY.send(telemetry);
|
|
231
|
+
|
|
232
|
+
// Update the DLQ record
|
|
233
|
+
await env.PLATFORM_DB.prepare(
|
|
234
|
+
`UPDATE dead_letter_queue
|
|
235
|
+
SET status = 'replayed', replayed_at = unixepoch(), replayed_by = 'admin', updated_at = unixepoch()
|
|
236
|
+
WHERE id = ?`
|
|
237
|
+
)
|
|
238
|
+
.bind(messageId)
|
|
239
|
+
.run();
|
|
240
|
+
|
|
241
|
+
log.info('DLQ message replayed', {
|
|
242
|
+
messageId,
|
|
243
|
+
feature_key: telemetry.feature_key,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return jsonResponse({
|
|
247
|
+
success: true,
|
|
248
|
+
message: 'Message replayed successfully',
|
|
249
|
+
messageId,
|
|
250
|
+
feature_key: telemetry.feature_key,
|
|
251
|
+
});
|
|
252
|
+
} catch (error) {
|
|
253
|
+
log.error('Failed to replay DLQ message', error, { messageId });
|
|
254
|
+
return jsonResponse({ success: false, error: 'Failed to replay message' }, 500);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// =============================================================================
|
|
259
|
+
// DISCARD DLQ MESSAGE
|
|
260
|
+
// =============================================================================
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Discard a DLQ message (mark as not needing replay).
|
|
264
|
+
* POST /admin/dlq/:id/discard
|
|
265
|
+
*/
|
|
266
|
+
export async function handleDiscardDLQ(
|
|
267
|
+
messageId: string,
|
|
268
|
+
reason: string,
|
|
269
|
+
env: Env
|
|
270
|
+
): Promise<Response> {
|
|
271
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:dlq-admin');
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
// Update the DLQ record
|
|
275
|
+
const result = await env.PLATFORM_DB.prepare(
|
|
276
|
+
`UPDATE dead_letter_queue
|
|
277
|
+
SET status = 'discarded', discard_reason = ?, updated_at = unixepoch()
|
|
278
|
+
WHERE id = ? AND status = 'pending'`
|
|
279
|
+
)
|
|
280
|
+
.bind(reason || 'Manually discarded by admin', messageId)
|
|
281
|
+
.run();
|
|
282
|
+
|
|
283
|
+
if (result.meta.changes === 0) {
|
|
284
|
+
return jsonResponse({ success: false, error: 'Message not found or not pending' }, 404);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
log.info('DLQ message discarded', { messageId, reason });
|
|
288
|
+
|
|
289
|
+
return jsonResponse({
|
|
290
|
+
success: true,
|
|
291
|
+
message: 'Message discarded successfully',
|
|
292
|
+
messageId,
|
|
293
|
+
});
|
|
294
|
+
} catch (error) {
|
|
295
|
+
log.error('Failed to discard DLQ message', error, { messageId });
|
|
296
|
+
return jsonResponse({ success: false, error: 'Failed to discard message' }, 500);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// =============================================================================
|
|
301
|
+
// BULK OPERATIONS
|
|
302
|
+
// =============================================================================
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Replay all pending DLQ messages (with optional filter).
|
|
306
|
+
* POST /admin/dlq/replay-all?project=my-app
|
|
307
|
+
*/
|
|
308
|
+
export async function handleReplayAllDLQ(url: URL, env: Env): Promise<Response> {
|
|
309
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:dlq-admin');
|
|
310
|
+
const project = url.searchParams.get('project');
|
|
311
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '100', 10), 500);
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
// Get pending messages
|
|
315
|
+
let query = `SELECT id, message_payload FROM dead_letter_queue WHERE status = 'pending'`;
|
|
316
|
+
const params: (string | number)[] = [];
|
|
317
|
+
|
|
318
|
+
if (project) {
|
|
319
|
+
query += ` AND project = ?`;
|
|
320
|
+
params.push(project);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
query += ` LIMIT ?`;
|
|
324
|
+
params.push(limit);
|
|
325
|
+
|
|
326
|
+
const messages = await env.PLATFORM_DB.prepare(query)
|
|
327
|
+
.bind(...params)
|
|
328
|
+
.all<{ id: string; message_payload: string }>();
|
|
329
|
+
|
|
330
|
+
let replayed = 0;
|
|
331
|
+
let failed = 0;
|
|
332
|
+
|
|
333
|
+
for (const msg of messages.results || []) {
|
|
334
|
+
try {
|
|
335
|
+
const telemetry: TelemetryMessage = JSON.parse(msg.message_payload);
|
|
336
|
+
await env.PLATFORM_TELEMETRY.send(telemetry);
|
|
337
|
+
|
|
338
|
+
await env.PLATFORM_DB.prepare(
|
|
339
|
+
`UPDATE dead_letter_queue
|
|
340
|
+
SET status = 'replayed', replayed_at = unixepoch(), replayed_by = 'admin-bulk', updated_at = unixepoch()
|
|
341
|
+
WHERE id = ?`
|
|
342
|
+
)
|
|
343
|
+
.bind(msg.id)
|
|
344
|
+
.run();
|
|
345
|
+
|
|
346
|
+
replayed++;
|
|
347
|
+
} catch {
|
|
348
|
+
failed++;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
log.info('Bulk DLQ replay complete', { replayed, failed, project });
|
|
353
|
+
|
|
354
|
+
return jsonResponse({
|
|
355
|
+
success: true,
|
|
356
|
+
replayed,
|
|
357
|
+
failed,
|
|
358
|
+
total: (messages.results || []).length,
|
|
359
|
+
});
|
|
360
|
+
} catch (error) {
|
|
361
|
+
log.error('Failed to replay all DLQ messages', error);
|
|
362
|
+
return jsonResponse({ success: false, error: 'Failed to replay messages' }, 500);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Trends Handler
|
|
3
|
+
*
|
|
4
|
+
* API endpoints for querying project health trends from the health_trends table.
|
|
5
|
+
* Part of Phase 2 AI Judge enhancements - Dashboard Trends.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/plans/ai-judge-enhancements.md
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Env } from '../shared';
|
|
11
|
+
import { jsonResponse } from '../shared';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Health trend record from D1
|
|
15
|
+
*/
|
|
16
|
+
interface HealthTrendRecord {
|
|
17
|
+
id: number;
|
|
18
|
+
project: string;
|
|
19
|
+
audit_id: string;
|
|
20
|
+
audit_date: string;
|
|
21
|
+
composite_score: number;
|
|
22
|
+
sdk_score: number | null;
|
|
23
|
+
observability_score: number | null;
|
|
24
|
+
cost_score: number | null;
|
|
25
|
+
security_score: number | null;
|
|
26
|
+
trend: 'improving' | 'stable' | 'declining';
|
|
27
|
+
score_delta: number;
|
|
28
|
+
created_at: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* API response for health trends
|
|
33
|
+
*/
|
|
34
|
+
interface HealthTrendsResponse {
|
|
35
|
+
success: boolean;
|
|
36
|
+
data: {
|
|
37
|
+
project: string;
|
|
38
|
+
trends: {
|
|
39
|
+
date: string;
|
|
40
|
+
compositeScore: number;
|
|
41
|
+
rubricScores: {
|
|
42
|
+
sdk: number | null;
|
|
43
|
+
observability: number | null;
|
|
44
|
+
cost: number | null;
|
|
45
|
+
security: number | null;
|
|
46
|
+
};
|
|
47
|
+
trend: 'improving' | 'stable' | 'declining';
|
|
48
|
+
delta: number;
|
|
49
|
+
}[];
|
|
50
|
+
latestScore: number | null;
|
|
51
|
+
latestTrend: 'improving' | 'stable' | 'declining' | null;
|
|
52
|
+
}[];
|
|
53
|
+
period: {
|
|
54
|
+
days: number;
|
|
55
|
+
from: string;
|
|
56
|
+
to: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Handle GET /usage/health-trends
|
|
62
|
+
*
|
|
63
|
+
* Query params:
|
|
64
|
+
* - project: 'all' | <your-project-ids> (default: 'all')
|
|
65
|
+
* - days: number (default: 90)
|
|
66
|
+
*
|
|
67
|
+
* Returns health trends for the specified project(s) over the given period.
|
|
68
|
+
*/
|
|
69
|
+
export async function handleGetHealthTrends(url: URL, env: Env): Promise<Response> {
|
|
70
|
+
const project = url.searchParams.get('project') || 'all';
|
|
71
|
+
const days = parseInt(url.searchParams.get('days') || '90', 10);
|
|
72
|
+
|
|
73
|
+
// Calculate date range
|
|
74
|
+
const now = new Date();
|
|
75
|
+
const fromDate = new Date(now);
|
|
76
|
+
fromDate.setDate(fromDate.getDate() - days);
|
|
77
|
+
const fromDateStr = fromDate.toISOString().split('T')[0];
|
|
78
|
+
const toDateStr = now.toISOString().split('T')[0];
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
// Build query based on project filter
|
|
82
|
+
let query: string;
|
|
83
|
+
let params: unknown[];
|
|
84
|
+
|
|
85
|
+
if (project === 'all') {
|
|
86
|
+
query = `
|
|
87
|
+
SELECT * FROM health_trends
|
|
88
|
+
WHERE audit_date >= ?
|
|
89
|
+
ORDER BY project, audit_date DESC
|
|
90
|
+
`;
|
|
91
|
+
params = [fromDateStr];
|
|
92
|
+
} else {
|
|
93
|
+
query = `
|
|
94
|
+
SELECT * FROM health_trends
|
|
95
|
+
WHERE project = ? AND audit_date >= ?
|
|
96
|
+
ORDER BY audit_date DESC
|
|
97
|
+
`;
|
|
98
|
+
params = [project, fromDateStr];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const results = await env.PLATFORM_DB.prepare(query)
|
|
102
|
+
.bind(...params)
|
|
103
|
+
.all<HealthTrendRecord>();
|
|
104
|
+
|
|
105
|
+
if (!results.success) {
|
|
106
|
+
return jsonResponse({ success: false, error: 'Database query failed' }, 500);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Group results by project
|
|
110
|
+
const projectMap = new Map<string, HealthTrendRecord[]>();
|
|
111
|
+
for (const record of results.results) {
|
|
112
|
+
const existing = projectMap.get(record.project) || [];
|
|
113
|
+
existing.push(record);
|
|
114
|
+
projectMap.set(record.project, existing);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Format response
|
|
118
|
+
const data: HealthTrendsResponse['data'] = [];
|
|
119
|
+
for (const [projectName, records] of projectMap) {
|
|
120
|
+
const trends = records.map((r) => ({
|
|
121
|
+
date: r.audit_date,
|
|
122
|
+
compositeScore: r.composite_score,
|
|
123
|
+
rubricScores: {
|
|
124
|
+
sdk: r.sdk_score,
|
|
125
|
+
observability: r.observability_score,
|
|
126
|
+
cost: r.cost_score,
|
|
127
|
+
security: r.security_score,
|
|
128
|
+
},
|
|
129
|
+
trend: r.trend,
|
|
130
|
+
delta: r.score_delta,
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
const latest = records[0]; // Already sorted DESC
|
|
134
|
+
data.push({
|
|
135
|
+
project: projectName,
|
|
136
|
+
trends,
|
|
137
|
+
latestScore: latest?.composite_score ?? null,
|
|
138
|
+
latestTrend: latest?.trend ?? null,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const response: HealthTrendsResponse = {
|
|
143
|
+
success: true,
|
|
144
|
+
data,
|
|
145
|
+
period: {
|
|
146
|
+
days,
|
|
147
|
+
from: fromDateStr,
|
|
148
|
+
to: toDateStr,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return jsonResponse(response, 200);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.error('Failed to query health trends:', error);
|
|
155
|
+
return jsonResponse(
|
|
156
|
+
{
|
|
157
|
+
success: false,
|
|
158
|
+
error: 'Failed to query health trends',
|
|
159
|
+
details: error instanceof Error ? error.message : String(error),
|
|
160
|
+
},
|
|
161
|
+
500
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Handle GET /usage/health-trends/latest
|
|
168
|
+
*
|
|
169
|
+
* Returns only the most recent health score for each project.
|
|
170
|
+
* Useful for dashboard summary view.
|
|
171
|
+
*/
|
|
172
|
+
export async function handleGetLatestHealthTrends(env: Env): Promise<Response> {
|
|
173
|
+
try {
|
|
174
|
+
// Use the view we created for latest health per project
|
|
175
|
+
const results = await env.PLATFORM_DB.prepare(
|
|
176
|
+
`
|
|
177
|
+
SELECT * FROM v_project_health_latest
|
|
178
|
+
ORDER BY project
|
|
179
|
+
`
|
|
180
|
+
).all<HealthTrendRecord & { previous_score: number | null }>();
|
|
181
|
+
|
|
182
|
+
if (!results.success) {
|
|
183
|
+
return jsonResponse({ success: false, error: 'Database query failed' }, 500);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const data = results.results.map(
|
|
187
|
+
(r: HealthTrendRecord & { previous_score: number | null }) => ({
|
|
188
|
+
project: r.project,
|
|
189
|
+
compositeScore: r.composite_score,
|
|
190
|
+
previousScore: r.previous_score,
|
|
191
|
+
rubricScores: {
|
|
192
|
+
sdk: r.sdk_score,
|
|
193
|
+
observability: r.observability_score,
|
|
194
|
+
cost: r.cost_score,
|
|
195
|
+
security: r.security_score,
|
|
196
|
+
},
|
|
197
|
+
trend: r.trend,
|
|
198
|
+
delta: r.score_delta,
|
|
199
|
+
auditDate: r.audit_date,
|
|
200
|
+
})
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return jsonResponse(
|
|
204
|
+
{
|
|
205
|
+
success: true,
|
|
206
|
+
data,
|
|
207
|
+
timestamp: new Date().toISOString(),
|
|
208
|
+
},
|
|
209
|
+
200
|
|
210
|
+
);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
console.error('Failed to query latest health trends:', error);
|
|
213
|
+
return jsonResponse(
|
|
214
|
+
{
|
|
215
|
+
success: false,
|
|
216
|
+
error: 'Failed to query latest health trends',
|
|
217
|
+
details: error instanceof Error ? error.message : String(error),
|
|
218
|
+
},
|
|
219
|
+
500
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler Module Exports
|
|
3
|
+
*
|
|
4
|
+
* Barrel export for all usage handler modules.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Data query functions (used by handlers)
|
|
8
|
+
export * from './data-queries';
|
|
9
|
+
|
|
10
|
+
// Usage metrics handlers (handleUsage, handleCosts, etc.)
|
|
11
|
+
export * from './usage-metrics';
|
|
12
|
+
|
|
13
|
+
// Feature-related handlers (handleFeatures, handleWorkersAI, etc.)
|
|
14
|
+
export * from './usage-features';
|
|
15
|
+
|
|
16
|
+
// Settings handlers (handleGetSettings, handlePutSettings, etc.)
|
|
17
|
+
export * from './usage-settings';
|
|
18
|
+
|
|
19
|
+
// Admin handlers (handleResetCircuitBreaker, handleBackfill)
|
|
20
|
+
export * from './usage-admin';
|
|
21
|
+
|
|
22
|
+
// DLQ admin handlers (handleListDLQ, handleReplayDLQ, etc.)
|
|
23
|
+
export * from './dlq-admin';
|
|
24
|
+
|
|
25
|
+
// Health trends handlers (handleGetHealthTrends, handleGetLatestHealthTrends)
|
|
26
|
+
export * from './health-trends';
|
|
27
|
+
|
|
28
|
+
// Gap detection and backfill handlers
|
|
29
|
+
export * from './backfill';
|
|
30
|
+
|
|
31
|
+
// Audit handlers (handleGetAudit, handleGetAuditHistory, handleGetAttribution, handleGetFeatureCoverage)
|
|
32
|
+
export * from './audit';
|
|
33
|
+
|
|
34
|
+
// Behavioral analysis handlers (handleGetBehavioral, handleGetHotspots, handleGetRegressions, handleAcknowledgeRegression)
|
|
35
|
+
export * from './behavioral';
|