@obsunified/collector 2.0.1 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
- package/dist/chunk-KMEZKGSO.js.map +1 -0
- package/dist/index.d.ts +322 -26
- package/dist/index.js +1478 -415
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
- package/package.json +5 -5
- package/src/analyses/derive.ts +26 -25
- package/src/analyses/investigations.ts +8 -8
- package/src/analyses/tier0.ts +22 -22
- package/src/auth/dashboard-auth.test.ts +791 -0
- package/src/auth/dashboard-auth.ts +187 -38
- package/src/auth/login-rate-limit.test.ts +129 -0
- package/src/auth/login-rate-limit.ts +143 -0
- package/src/framework/body-limit.test.ts +165 -0
- package/src/framework/collector.ts +355 -115
- package/src/framework/env.ts +9 -0
- package/src/framework/limits.ts +31 -0
- package/src/framework/scheduled-handlers.test.ts +342 -0
- package/src/index.ts +16 -1
- package/src/lib/action-retention.test.ts +472 -0
- package/src/lib/action-retention.ts +420 -0
- package/src/lib/actions-store.ts +6 -12
- package/src/lib/ai-store/calls.ts +2 -2
- package/src/lib/ai-store/sessions.ts +3 -3
- package/src/lib/ai-store/spans.ts +1 -1
- package/src/lib/ai-store/types.ts +2 -1
- package/src/lib/alerts-store.test.ts +153 -0
- package/src/lib/alerts-store.ts +122 -21
- package/src/lib/analyses-runner.test.ts +75 -0
- package/src/lib/analyses-runner.ts +60 -3
- package/src/lib/analyses-store-latest.test.ts +61 -0
- package/src/lib/analyses-store.ts +17 -10
- package/src/lib/blob-store.ts +1 -1
- package/src/lib/housekeeping.test.ts +73 -0
- package/src/lib/housekeeping.ts +93 -0
- package/src/lib/identity-index/action-lookups.ts +6 -6
- package/src/lib/identity-index/key-lookups.ts +2 -2
- package/src/lib/identity-index/user-lookup.ts +8 -5
- package/src/lib/identity-index.test.ts +2 -3
- package/src/lib/metrics-store-series.test.ts +99 -0
- package/src/lib/metrics-store.test.ts +23 -0
- package/src/lib/metrics-store.ts +27 -3
- package/src/lib/projects-store.test.ts +25 -0
- package/src/lib/projects-store.ts +4 -3
- package/src/lib/sql-db-dialect.test.ts +12 -3
- package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
- package/src/lib/sql-db-postgres.test.ts +314 -0
- package/src/lib/sql-db-postgres.ts +392 -54
- package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
- package/src/lib/sql-db.ts +40 -6
- package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
- package/src/lib/test-utils/node-sqlite-db.ts +188 -0
- package/src/lib/webhook-url.test.ts +122 -0
- package/src/lib/webhook-url.ts +155 -0
- package/src/migrations/041_ingest_key_hash_text.sql +5 -0
- package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
- package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
- package/src/migrations/044_actions_started_at_index.sql +10 -0
- package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
- package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
- package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
- package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
- package/src/migrations/049_alert_state_notified.sql +17 -0
- package/src/migrations/alert-state-notified.test.ts +111 -0
- package/src/migrations/parity.test.ts +64 -0
- package/src/migrations/project-scoped-keys.test.ts +432 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
- package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
- package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
- package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
- package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
- package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
- package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
- package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
- package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
- package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
- package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
- package/src/migrations-postgres/README.md +58 -5
- package/src/otlp/decode/body.test.ts +76 -0
- package/src/otlp/decode/body.ts +67 -6
- package/src/plugins/_json.ts +21 -0
- package/src/plugins/action-graph-processor.ts +6 -12
- package/src/plugins/ai-receiver.ts +5 -12
- package/src/plugins/ai-span-payloads-processor.ts +1 -2
- package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
- package/src/plugins/alerts-evaluator.test.ts +464 -3
- package/src/plugins/alerts-evaluator.ts +165 -41
- package/src/plugins/alerts-routes.ts +10 -6
- package/src/plugins/ask-routes.ts +4 -5
- package/src/plugins/connected-routes.test.ts +106 -0
- package/src/plugins/connected-routes.ts +14 -5
- package/src/plugins/dashboard-routes.test.ts +120 -0
- package/src/plugins/dashboard-routes.ts +11 -2
- package/src/plugins/eval-cases-routes.ts +8 -17
- package/src/plugins/identity-receiver.ts +9 -11
- package/src/plugins/json-body.test.ts +91 -0
- package/src/plugins/onboarding-routes.test.ts +105 -0
- package/src/plugins/onboarding-routes.ts +26 -11
- package/src/plugins/projects-routes.ts +2 -1
- package/src/plugins/replay-isolation.test.ts +922 -0
- package/src/plugins/replay-receiver.ts +8 -8
- package/src/plugins/usage-receiver.ts +3 -6
- package/dist/chunk-UGWDOGGH.js.map +0 -1
|
@@ -2,9 +2,18 @@ import type { StoredSpan, UsageEventRecord } from "@obsunified/types";
|
|
|
2
2
|
import { getConfiguredRetentionHours } from "@obsunified/types/constants";
|
|
3
3
|
import type { MiddlewareHandler } from "hono";
|
|
4
4
|
import { Hono } from "hono";
|
|
5
|
+
import { bodyLimit } from "hono/body-limit";
|
|
6
|
+
import {
|
|
7
|
+
type ActionGraphPurgeSummary,
|
|
8
|
+
purgeExpiredActionGraph,
|
|
9
|
+
} from "../lib/action-retention";
|
|
5
10
|
import { AIStore } from "../lib/ai-store";
|
|
6
11
|
import { runAllDueAnalyses } from "../lib/analyses-runner";
|
|
7
12
|
import { AnalysesStore } from "../lib/analyses-store";
|
|
13
|
+
import {
|
|
14
|
+
type HousekeepingSummary,
|
|
15
|
+
purgeSecondaryRows,
|
|
16
|
+
} from "../lib/housekeeping";
|
|
8
17
|
import { LogsStore } from "../lib/logs-store";
|
|
9
18
|
import { MetricsStore } from "../lib/metrics-store";
|
|
10
19
|
import { aggregatePropagation } from "../lib/propagation-metric";
|
|
@@ -12,6 +21,11 @@ import { D1Adapter, dialectFor, type SqlDb, sqlDbFor } from "../lib/sql-db";
|
|
|
12
21
|
import { TelemetryStore } from "../lib/store";
|
|
13
22
|
import { UsageStore } from "../lib/usage-store";
|
|
14
23
|
import type { CollectorEnv, CollectorRouteContext } from "./env";
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_MAX_AUTH_BODY_BYTES,
|
|
26
|
+
DEFAULT_MAX_BODY_BYTES,
|
|
27
|
+
parseByteLimit,
|
|
28
|
+
} from "./limits";
|
|
15
29
|
import {
|
|
16
30
|
type ChildSpanRunner,
|
|
17
31
|
consoleLogger,
|
|
@@ -103,6 +117,18 @@ export interface CollectorConfig {
|
|
|
103
117
|
* a tracing layer at the storage seam.
|
|
104
118
|
*/
|
|
105
119
|
sqlDb?: SqlDbFactory;
|
|
120
|
+
/**
|
|
121
|
+
* Raw request body cap for `/v1/*` and `/internal/*` in bytes. Defaults
|
|
122
|
+
* to `MAX_BODY_BYTES` from the environment, then 10 MiB. Oversized
|
|
123
|
+
* requests get a 413 before any handler buffers the body.
|
|
124
|
+
*/
|
|
125
|
+
maxBodyBytes?: number;
|
|
126
|
+
/**
|
|
127
|
+
* Body cap for the unauthenticated `/auth/*` routes (login/check/logout).
|
|
128
|
+
* Defaults to 16 KiB and is independent of `maxBodyBytes`, so an anonymous
|
|
129
|
+
* client can never make the host buffer an ingest-sized body.
|
|
130
|
+
*/
|
|
131
|
+
maxAuthBodyBytes?: number;
|
|
106
132
|
}
|
|
107
133
|
|
|
108
134
|
export class CollectorRuntime implements CollectorPluginContext {
|
|
@@ -221,6 +247,10 @@ export class CollectorRuntime implements CollectorPluginContext {
|
|
|
221
247
|
}
|
|
222
248
|
}
|
|
223
249
|
|
|
250
|
+
/** `/dashboard/login` and anything beneath it (the SPA's login route). */
|
|
251
|
+
export const isDashboardLoginPath = (path: string): boolean =>
|
|
252
|
+
path === "/dashboard/login" || path.startsWith("/dashboard/login/");
|
|
253
|
+
|
|
224
254
|
export const createTelemetryCollectorApp = (
|
|
225
255
|
pluginsOrConfig: CollectorPlugin[] | CollectorConfig,
|
|
226
256
|
): Hono<{ Bindings: CollectorEnv }> => {
|
|
@@ -285,6 +315,35 @@ export const createTelemetryCollectorApp = (
|
|
|
285
315
|
}
|
|
286
316
|
});
|
|
287
317
|
|
|
318
|
+
// Body size cap — before auth and every handler, so an oversized body is
|
|
319
|
+
// refused by Content-Length or cut off mid-stream instead of buffered.
|
|
320
|
+
const bodyCap: MiddlewareHandler<{ Bindings: CollectorEnv }> = (c, next) => {
|
|
321
|
+
const maxSize =
|
|
322
|
+
config.maxBodyBytes ??
|
|
323
|
+
parseByteLimit(c.env?.MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
|
|
324
|
+
return bodyLimit({
|
|
325
|
+
maxSize,
|
|
326
|
+
onError: (ctx) =>
|
|
327
|
+
ctx.json({ error: `Payload too large (max ${maxSize} bytes)` }, 413),
|
|
328
|
+
})(c, next);
|
|
329
|
+
};
|
|
330
|
+
app.use("/v1/*", bodyCap);
|
|
331
|
+
app.use("/internal/*", bodyCap);
|
|
332
|
+
// `/auth/*` is reachable without any credential, so it gets its own,
|
|
333
|
+
// much smaller cap regardless of what MAX_BODY_BYTES allows for ingest.
|
|
334
|
+
const authMaxSize = config.maxAuthBodyBytes ?? DEFAULT_MAX_AUTH_BODY_BYTES;
|
|
335
|
+
app.use(
|
|
336
|
+
"/auth/*",
|
|
337
|
+
bodyLimit({
|
|
338
|
+
maxSize: authMaxSize,
|
|
339
|
+
onError: (ctx) =>
|
|
340
|
+
ctx.json(
|
|
341
|
+
{ error: `Payload too large (max ${authMaxSize} bytes)` },
|
|
342
|
+
413,
|
|
343
|
+
),
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
346
|
+
|
|
288
347
|
// Ingest auth (API key) — always on /v1/* when configured.
|
|
289
348
|
if (config.auth?.middleware) {
|
|
290
349
|
app.use("/v1/*", config.auth.middleware);
|
|
@@ -293,20 +352,28 @@ export const createTelemetryCollectorApp = (
|
|
|
293
352
|
app.use("/v1/*", config.auth.ingest);
|
|
294
353
|
}
|
|
295
354
|
|
|
296
|
-
// Dashboard auth (session cookie) — owns /internal/* when configured.
|
|
355
|
+
// Dashboard auth (session cookie or programmatic bearer token) — owns /internal/* and /dashboard/* when configured.
|
|
297
356
|
// Routes for login/check/logout are always registered so the dashboard
|
|
298
357
|
// can authenticate regardless of subsequent middleware placement.
|
|
299
358
|
if (config.dashboardAuth) {
|
|
359
|
+
const { middleware } = config.dashboardAuth;
|
|
300
360
|
config.dashboardAuth.registerRoutes(app);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
app.use("/
|
|
361
|
+
// The login page is where unauthenticated HTML requests get sent;
|
|
362
|
+
// guarding it with the same middleware redirects it to itself.
|
|
363
|
+
const guard: MiddlewareHandler<{
|
|
364
|
+
Bindings: CollectorEnv;
|
|
365
|
+
// biome-ignore lint/suspicious/noExplicitAny: same contravariant Variables shape as CollectorConfig.dashboardAuth.middleware
|
|
366
|
+
Variables: any;
|
|
367
|
+
}> = async (c, next) =>
|
|
368
|
+
isDashboardLoginPath(c.req.path) ? next() : middleware(c, next);
|
|
369
|
+
app.use("/dashboard/*", guard);
|
|
370
|
+
app.use("/internal/*", middleware);
|
|
371
|
+
} else {
|
|
372
|
+
// Fail closed for /internal/* and /dashboard/*: ingest keys must never grant read/admin access
|
|
373
|
+
app.use("/internal/*", async (c) => c.json({ error: "Unauthorized" }, 401));
|
|
374
|
+
app.use("/dashboard/*", async (c) =>
|
|
375
|
+
c.json({ error: "Unauthorized" }, 401),
|
|
376
|
+
);
|
|
310
377
|
}
|
|
311
378
|
|
|
312
379
|
for (const plugin of config.plugins) {
|
|
@@ -324,100 +391,260 @@ export const createTelemetryCollectorApp = (
|
|
|
324
391
|
return app;
|
|
325
392
|
};
|
|
326
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Runtime-neutral entry point for a background maintenance task.
|
|
396
|
+
*
|
|
397
|
+
* `run(env)` does the work and needs nothing but a `CollectorEnv`, so any
|
|
398
|
+
* host (Cloudflare cron, a Node `setInterval`, a Kubernetes CronJob hitting
|
|
399
|
+
* a one-shot script) can drive it. `scheduled(event, env, ctx)` is the thin
|
|
400
|
+
* Cloudflare Workers adapter over `run` — the event and context are accepted
|
|
401
|
+
* for signature compatibility with `ExportedHandler.scheduled` but are never
|
|
402
|
+
* consulted, so non-Workers hosts should call `run` directly rather than
|
|
403
|
+
* fabricating placeholder event objects.
|
|
404
|
+
*/
|
|
405
|
+
export interface ScheduledTaskHandler<TSummary = void> {
|
|
406
|
+
run(env: CollectorEnv): Promise<TSummary>;
|
|
407
|
+
scheduled(
|
|
408
|
+
event: ScheduledEvent,
|
|
409
|
+
env: CollectorEnv,
|
|
410
|
+
ctx: ExecutionContext,
|
|
411
|
+
): Promise<void>;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Per-table purge counts reported by the retention cleanup task. */
|
|
415
|
+
export interface RetentionCleanupSummary {
|
|
416
|
+
spans: number;
|
|
417
|
+
usage: number;
|
|
418
|
+
logs: number;
|
|
419
|
+
ai_calls: number;
|
|
420
|
+
metric_points: number;
|
|
421
|
+
analysis_results: number;
|
|
422
|
+
profile_blobs: number;
|
|
423
|
+
/** RFC 0010 action spine rows removed (trees older than RETENTION_HOURS). */
|
|
424
|
+
actions: number;
|
|
425
|
+
/** Leaf rows removed with their actions: agent_runs, tool_calls,
|
|
426
|
+
* retrieval_events, eval_results and artifacts. */
|
|
427
|
+
action_leaves: number;
|
|
428
|
+
/** Secondary tables (see lib/housekeeping.ts); zeros when that step failed. */
|
|
429
|
+
housekeeping: HousekeepingSummary;
|
|
430
|
+
}
|
|
431
|
+
|
|
327
432
|
/**
|
|
328
433
|
* Scheduled handler that purges expired telemetry and usage rows.
|
|
329
|
-
* Wire into wrangler.toml: `[triggers] crons = ["0 * * * *"]
|
|
434
|
+
* Wire into wrangler.toml: `[triggers] crons = ["0 * * * *"]`, or call
|
|
435
|
+
* `.run(env)` from a non-Workers host scheduler.
|
|
330
436
|
* Source: AgentOwl + DecisionOps
|
|
331
437
|
*/
|
|
438
|
+
/** Expired profile rows examined per sweep: pages × page size. */
|
|
439
|
+
const PROFILE_PURGE_PAGE_SIZE = 500;
|
|
440
|
+
const PROFILE_PURGE_MAX_PAGES = 10;
|
|
441
|
+
|
|
332
442
|
export const createRetentionCleanupHandler = (options?: {
|
|
333
443
|
logger?: Logger;
|
|
334
|
-
}) => {
|
|
444
|
+
}): ScheduledTaskHandler<RetentionCleanupSummary> => {
|
|
335
445
|
const logger = options?.logger ?? consoleLogger;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
446
|
+
const run = async (env: CollectorEnv): Promise<RetentionCleanupSummary> => {
|
|
447
|
+
const db = sqlDbFor(env);
|
|
448
|
+
const telemetryStore = new TelemetryStore(db);
|
|
449
|
+
const usageStore = new UsageStore(db);
|
|
450
|
+
const logsStore = new LogsStore(db);
|
|
451
|
+
const aiStore = new AIStore(db);
|
|
452
|
+
const metricsStore = new MetricsStore(db);
|
|
453
|
+
const analysesStore = new AnalysesStore(db);
|
|
454
|
+
|
|
455
|
+
const [
|
|
456
|
+
telemetryPurged,
|
|
457
|
+
usagePurged,
|
|
458
|
+
logsPurged,
|
|
459
|
+
aiPurged,
|
|
460
|
+
metricsPurged,
|
|
461
|
+
analysesPurged,
|
|
462
|
+
] = await Promise.all([
|
|
463
|
+
telemetryStore.purgeExpired(),
|
|
464
|
+
usageStore.purgeExpired(),
|
|
465
|
+
logsStore.purgeExpired(),
|
|
466
|
+
aiStore.purgeExpired(),
|
|
467
|
+
metricsStore.purgeExpired(),
|
|
468
|
+
analysesStore.purgeExpired(),
|
|
469
|
+
]);
|
|
470
|
+
|
|
471
|
+
// RFC 0007 Phase 4.10 — profile_blobs retention. Cascade
|
|
472
|
+
// foreign key on profile_trace_index handles the join rows;
|
|
473
|
+
// we still need to delete the R2 blobs, which we do
|
|
474
|
+
// best-effort by reading the URLs first.
|
|
475
|
+
// Rows go only for blobs whose delete settled, in pages, so a row never
|
|
476
|
+
// outlives its blob reference while the object is still in storage
|
|
477
|
+
// (previously one page of blobs was deleted and then every expired
|
|
478
|
+
// row, orphaning anything past the page).
|
|
479
|
+
let profilesPurged = 0;
|
|
480
|
+
try {
|
|
481
|
+
const dialect = dialectFor(db);
|
|
482
|
+
if (!env.PROFILES_BUCKET) {
|
|
483
|
+
logger.warn(
|
|
484
|
+
"[retention-cleanup] PROFILES_BUCKET is not bound; expired profile rows are removed without deleting their blobs",
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
for (let page = 0; page < PROFILE_PURGE_MAX_PAGES; page += 1) {
|
|
488
|
+
const expired = await db
|
|
374
489
|
.prepare(
|
|
375
|
-
`SELECT id, blob_url FROM profile_blobs WHERE expires_at < ${dialect.now()} LIMIT
|
|
490
|
+
`SELECT id, blob_url FROM profile_blobs WHERE expires_at < ${dialect.now()} LIMIT ${PROFILE_PURGE_PAGE_SIZE}`,
|
|
376
491
|
)
|
|
377
492
|
.all<{ id: string; blob_url: string }>();
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
493
|
+
if (expired.results.length === 0) break;
|
|
494
|
+
const removable: string[] = [];
|
|
495
|
+
if (env.PROFILES_BUCKET) {
|
|
496
|
+
const bucket = env.PROFILES_BUCKET;
|
|
497
|
+
const settled = await Promise.allSettled(
|
|
498
|
+
expired.results.map((p) => bucket.delete(p.blob_url)),
|
|
383
499
|
);
|
|
500
|
+
settled.forEach((outcome, i) => {
|
|
501
|
+
const row = expired.results[i];
|
|
502
|
+
if (!row) return;
|
|
503
|
+
if (outcome.status === "fulfilled") removable.push(row.id);
|
|
504
|
+
else
|
|
505
|
+
logger.warn(
|
|
506
|
+
"[retention-cleanup] profile blob delete failed; row kept for retry",
|
|
507
|
+
{
|
|
508
|
+
profile_id: row.id,
|
|
509
|
+
error:
|
|
510
|
+
outcome.reason instanceof Error
|
|
511
|
+
? outcome.reason.message
|
|
512
|
+
: String(outcome.reason),
|
|
513
|
+
},
|
|
514
|
+
);
|
|
515
|
+
});
|
|
516
|
+
} else {
|
|
517
|
+
removable.push(...expired.results.map((p) => p.id));
|
|
384
518
|
}
|
|
385
|
-
|
|
386
|
-
.
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
519
|
+
for (let i = 0; i < removable.length; i += 90) {
|
|
520
|
+
const chunk = removable.slice(i, i + 90);
|
|
521
|
+
const deleted = await db
|
|
522
|
+
.prepare(
|
|
523
|
+
`DELETE FROM profile_blobs WHERE id IN (${chunk.map(() => "?").join(", ")})`,
|
|
524
|
+
)
|
|
525
|
+
.bind(...chunk)
|
|
526
|
+
.run();
|
|
527
|
+
profilesPurged += deleted.meta.changes;
|
|
528
|
+
}
|
|
529
|
+
// A page with kept rows would be re-selected forever; stop here
|
|
530
|
+
// and let the next sweep retry the failed blobs.
|
|
531
|
+
if (
|
|
532
|
+
expired.results.length < PROFILE_PURGE_PAGE_SIZE ||
|
|
533
|
+
removable.length < expired.results.length
|
|
534
|
+
)
|
|
535
|
+
break;
|
|
395
536
|
}
|
|
537
|
+
} catch (err) {
|
|
538
|
+
logger.error("[retention-cleanup] profile purge failed", {
|
|
539
|
+
error: err instanceof Error ? err.message : String(err),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
396
542
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
543
|
+
let housekeeping: HousekeepingSummary = {
|
|
544
|
+
alert_evaluations: 0,
|
|
545
|
+
ask_evidence_events: 0,
|
|
546
|
+
metric_series: 0,
|
|
547
|
+
evidence_refs: 0,
|
|
548
|
+
evidence_ref_expansions: 0,
|
|
549
|
+
};
|
|
550
|
+
try {
|
|
551
|
+
housekeeping = await purgeSecondaryRows(db, {
|
|
552
|
+
retentionHours: getConfiguredRetentionHours(env.RETENTION_HOURS),
|
|
553
|
+
});
|
|
554
|
+
} catch (err) {
|
|
555
|
+
logger.error("[retention-cleanup] housekeeping purge failed", {
|
|
556
|
+
error: err instanceof Error ? err.message : String(err),
|
|
405
557
|
});
|
|
558
|
+
}
|
|
406
559
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
logger.
|
|
418
|
-
|
|
419
|
-
|
|
560
|
+
// RFC 0010 — action graph retention. Derived from `actions.started_at`
|
|
561
|
+
// because the spine has no `expires_at`; whole causal trees go at once,
|
|
562
|
+
// in bounded batches, and in-progress or eval-case-pinned trees stay.
|
|
563
|
+
// Runs after the per-table purges so a slow sweep never delays them.
|
|
564
|
+
let actionGraph: ActionGraphPurgeSummary | undefined;
|
|
565
|
+
try {
|
|
566
|
+
actionGraph = await purgeExpiredActionGraph(db, {
|
|
567
|
+
retentionHours: getConfiguredRetentionHours(env.RETENTION_HOURS),
|
|
568
|
+
});
|
|
569
|
+
if (actionGraph.truncated) {
|
|
570
|
+
logger.warn(
|
|
571
|
+
"[retention-cleanup] action graph purge hit its batch cap; remaining expired trees wait for the next run",
|
|
572
|
+
{ batches: actionGraph.batches, trees: actionGraph.trees },
|
|
573
|
+
);
|
|
420
574
|
}
|
|
575
|
+
if (actionGraph.oversized_trees > 0) {
|
|
576
|
+
// Retained on purpose: deleting them would exceed the per-batch
|
|
577
|
+
// bound, and a partial tree is worse than an old one. Operators
|
|
578
|
+
// can raise `maxActionsPerBatch` or remove them by hand.
|
|
579
|
+
logger.warn(
|
|
580
|
+
"[retention-cleanup] expired action trees retained because they exceed the per-batch action cap",
|
|
581
|
+
{
|
|
582
|
+
oversized_trees: actionGraph.oversized_trees,
|
|
583
|
+
trees: actionGraph.oversized,
|
|
584
|
+
},
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
} catch (err) {
|
|
588
|
+
logger.error("[retention-cleanup] action graph purge failed", {
|
|
589
|
+
error: err instanceof Error ? err.message : String(err),
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const summary: RetentionCleanupSummary = {
|
|
594
|
+
spans: telemetryPurged,
|
|
595
|
+
usage: usagePurged,
|
|
596
|
+
logs: logsPurged,
|
|
597
|
+
ai_calls: aiPurged,
|
|
598
|
+
metric_points: metricsPurged,
|
|
599
|
+
analysis_results: analysesPurged,
|
|
600
|
+
profile_blobs: profilesPurged,
|
|
601
|
+
housekeeping,
|
|
602
|
+
actions: actionGraph?.actions ?? 0,
|
|
603
|
+
action_leaves: actionGraph
|
|
604
|
+
? actionGraph.agent_runs +
|
|
605
|
+
actionGraph.tool_calls +
|
|
606
|
+
actionGraph.retrieval_events +
|
|
607
|
+
actionGraph.eval_results +
|
|
608
|
+
actionGraph.artifacts
|
|
609
|
+
: 0,
|
|
610
|
+
};
|
|
611
|
+
logger.info("[retention-cleanup] purged expired rows", {
|
|
612
|
+
...summary,
|
|
613
|
+
...(actionGraph
|
|
614
|
+
? {
|
|
615
|
+
action_agent_runs: actionGraph.agent_runs,
|
|
616
|
+
action_tool_calls: actionGraph.tool_calls,
|
|
617
|
+
action_retrieval_events: actionGraph.retrieval_events,
|
|
618
|
+
action_eval_results: actionGraph.eval_results,
|
|
619
|
+
action_artifacts: actionGraph.artifacts,
|
|
620
|
+
action_trees: actionGraph.trees,
|
|
621
|
+
action_batches: actionGraph.batches,
|
|
622
|
+
action_oversized_trees: actionGraph.oversized_trees,
|
|
623
|
+
}
|
|
624
|
+
: {}),
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// RFC 0004 Phase 1.8 — emit interaction_id propagation counters.
|
|
628
|
+
// Hourly aggregation rides the retention cron because cadence
|
|
629
|
+
// matches and both touch the same projects' tables.
|
|
630
|
+
try {
|
|
631
|
+
const propagation = await aggregatePropagation(db, new Date(), logger);
|
|
632
|
+
logger.info("[propagation-metric] hourly aggregation complete", {
|
|
633
|
+
projects: propagation.projects,
|
|
634
|
+
points_written: propagation.pointsWritten,
|
|
635
|
+
});
|
|
636
|
+
} catch (err) {
|
|
637
|
+
logger.error("[propagation-metric] aggregation failed", {
|
|
638
|
+
error: err instanceof Error ? err.message : String(err),
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return summary;
|
|
643
|
+
};
|
|
644
|
+
return {
|
|
645
|
+
run,
|
|
646
|
+
scheduled: async (_event, env, _ctx) => {
|
|
647
|
+
await run(env);
|
|
421
648
|
},
|
|
422
649
|
};
|
|
423
650
|
};
|
|
@@ -427,41 +654,54 @@ export const createRetentionCleanupHandler = (options?: {
|
|
|
427
654
|
* `runAllDueAnalyses` filters by `last_run + refreshSeconds < now` before
|
|
428
655
|
* touching D1.
|
|
429
656
|
*
|
|
430
|
-
* Wire to a per-minute trigger in wrangler.toml: `crons = ["* * * * *"]
|
|
431
|
-
*
|
|
432
|
-
* retention table every
|
|
657
|
+
* Wire to a per-minute trigger in wrangler.toml: `crons = ["* * * * *"]`,
|
|
658
|
+
* or call `.run(env)` from a non-Workers host scheduler. Kept distinct from
|
|
659
|
+
* retention so the per-minute tick doesn't sweep every retention table every
|
|
660
|
+
* cycle.
|
|
433
661
|
*/
|
|
434
662
|
export const createAnalysesRunHandler = (options?: {
|
|
435
663
|
logger?: Logger;
|
|
436
664
|
tracer?: ChildSpanRunner;
|
|
437
|
-
}) => {
|
|
665
|
+
}): ScheduledTaskHandler<AnalysesRunSummary | undefined> => {
|
|
438
666
|
const logger = options?.logger ?? consoleLogger;
|
|
439
667
|
const tracer = options?.tracer;
|
|
668
|
+
/**
|
|
669
|
+
* Runs every due analysis. Resolves to the run summary, or `undefined`
|
|
670
|
+
* when the run failed — failures are logged rather than thrown so a
|
|
671
|
+
* transient DB hiccup never breaks a host's cron loop.
|
|
672
|
+
*/
|
|
673
|
+
const run = async (
|
|
674
|
+
env: CollectorEnv,
|
|
675
|
+
): Promise<AnalysesRunSummary | undefined> => {
|
|
676
|
+
try {
|
|
677
|
+
const retentionHours = getConfiguredRetentionHours(env.RETENTION_HOURS);
|
|
678
|
+
const summary = await runAllDueAnalyses({
|
|
679
|
+
env,
|
|
680
|
+
retentionHours,
|
|
681
|
+
logger,
|
|
682
|
+
tracer,
|
|
683
|
+
});
|
|
684
|
+
logger.info("[analyses] scheduled run summary", {
|
|
685
|
+
refreshed: summary.refreshed,
|
|
686
|
+
ran: summary.ran,
|
|
687
|
+
failed: summary.failed,
|
|
688
|
+
narrated: summary.narrated,
|
|
689
|
+
});
|
|
690
|
+
return summary;
|
|
691
|
+
} catch (error) {
|
|
692
|
+
logger.error("[analyses] scheduled run failed", {
|
|
693
|
+
error: error instanceof Error ? error.message : String(error),
|
|
694
|
+
});
|
|
695
|
+
return undefined;
|
|
696
|
+
}
|
|
697
|
+
};
|
|
440
698
|
return {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
env
|
|
444
|
-
_ctx: ExecutionContext,
|
|
445
|
-
): Promise<void> {
|
|
446
|
-
try {
|
|
447
|
-
const retentionHours = getConfiguredRetentionHours(env.RETENTION_HOURS);
|
|
448
|
-
const summary = await runAllDueAnalyses({
|
|
449
|
-
env,
|
|
450
|
-
retentionHours,
|
|
451
|
-
logger,
|
|
452
|
-
tracer,
|
|
453
|
-
});
|
|
454
|
-
logger.info("[analyses] scheduled run summary", {
|
|
455
|
-
refreshed: summary.refreshed,
|
|
456
|
-
ran: summary.ran,
|
|
457
|
-
failed: summary.failed,
|
|
458
|
-
narrated: summary.narrated,
|
|
459
|
-
});
|
|
460
|
-
} catch (error) {
|
|
461
|
-
logger.error("[analyses] scheduled run failed", {
|
|
462
|
-
error: error instanceof Error ? error.message : String(error),
|
|
463
|
-
});
|
|
464
|
-
}
|
|
699
|
+
run,
|
|
700
|
+
scheduled: async (_event, env, _ctx) => {
|
|
701
|
+
await run(env);
|
|
465
702
|
},
|
|
466
703
|
};
|
|
467
704
|
};
|
|
705
|
+
|
|
706
|
+
/** Shape returned by `runAllDueAnalyses` and surfaced via the run handler. */
|
|
707
|
+
export type AnalysesRunSummary = Awaited<ReturnType<typeof runAllDueAnalyses>>;
|
package/src/framework/env.ts
CHANGED
|
@@ -26,10 +26,19 @@ export interface CollectorEnv {
|
|
|
26
26
|
INGEST_KEY?: string;
|
|
27
27
|
/** Password for dashboard login */
|
|
28
28
|
DASHBOARD_PASSWORD?: string;
|
|
29
|
+
/** Programmatic read/admin bearer token for /internal/* endpoints (e.g. MCP server) */
|
|
30
|
+
DASHBOARD_TOKEN?: string;
|
|
29
31
|
/** Comma-separated allowed origins for CORS (e.g. "https://my-app.com,https://staging.my-app.com") */
|
|
30
32
|
ALLOWED_ORIGINS?: string;
|
|
31
33
|
/** Set to "true" to allow unauthenticated ingest (local dev only) */
|
|
32
34
|
ALLOW_UNAUTHENTICATED?: string;
|
|
35
|
+
/** Raw request body cap in bytes for /v1/* and /internal/* (default 10 MiB). */
|
|
36
|
+
MAX_BODY_BYTES?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Set to "true" to let alert webhooks target loopback / private / link-local
|
|
39
|
+
* hosts (self-hosted receivers). Off by default to block SSRF.
|
|
40
|
+
*/
|
|
41
|
+
ALERT_WEBHOOK_ALLOW_PRIVATE?: string;
|
|
33
42
|
TELEMETRY_REDACT_FIELDS?: string;
|
|
34
43
|
/**
|
|
35
44
|
* Local/test escape hatch for payload capture. Production should prefer the
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request body limits for ingest (`/v1/*`) and admin (`/internal/*`) routes.
|
|
3
|
+
*
|
|
4
|
+
* Without a cap the collector buffered whole bodies (and fully gunzipped
|
|
5
|
+
* OTLP payloads) before the per-item caps applied. Cloudflare bounds the raw
|
|
6
|
+
* request size per plan; the Node host has no platform limit, so a single
|
|
7
|
+
* ingest key could exhaust memory with one request or one gzip bomb.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Raw request body cap. Overridable via `MAX_BODY_BYTES` or `CollectorConfig.maxBodyBytes`. */
|
|
11
|
+
export const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Cap for the unauthenticated `/auth/*` routes. `POST /auth/login` carries a
|
|
15
|
+
* JSON object with one password; anything near this size is not a login.
|
|
16
|
+
* Deliberately small and separate from `MAX_BODY_BYTES`: raising the ingest
|
|
17
|
+
* cap for large OTLP batches must not widen what an anonymous client can
|
|
18
|
+
* make the host buffer.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_MAX_AUTH_BODY_BYTES = 16 * 1024;
|
|
21
|
+
|
|
22
|
+
/** Cap on the gunzipped OTLP payload (a 10 MiB gzip can expand far beyond this). */
|
|
23
|
+
export const DEFAULT_MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
export const parseByteLimit = (
|
|
26
|
+
raw: string | undefined,
|
|
27
|
+
fallback: number,
|
|
28
|
+
): number => {
|
|
29
|
+
const parsed = Number.parseInt(raw ?? "", 10);
|
|
30
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
31
|
+
};
|