@obsunified/collector 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
  4. package/dist/chunk-KMEZKGSO.js.map +1 -0
  5. package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  6. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  7. package/dist/index.d.ts +630 -27
  8. package/dist/index.js +5819 -853
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  11. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  12. package/package.json +5 -5
  13. package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
  14. package/src/analyses/derive.ts +26 -25
  15. package/src/analyses/investigations.ts +8 -8
  16. package/src/analyses/tier0.ts +22 -22
  17. package/src/auth/dashboard-auth.test.ts +791 -0
  18. package/src/auth/dashboard-auth.ts +187 -38
  19. package/src/auth/login-rate-limit.test.ts +129 -0
  20. package/src/auth/login-rate-limit.ts +143 -0
  21. package/src/framework/body-limit.test.ts +165 -0
  22. package/src/framework/collector.ts +355 -115
  23. package/src/framework/env.ts +9 -0
  24. package/src/framework/limits.ts +31 -0
  25. package/src/framework/scheduled-handlers.test.ts +342 -0
  26. package/src/index.ts +59 -1
  27. package/src/lib/action-retention.test.ts +472 -0
  28. package/src/lib/action-retention.ts +420 -0
  29. package/src/lib/actions-store.ts +6 -12
  30. package/src/lib/ai-store/calls.ts +2 -2
  31. package/src/lib/ai-store/sessions.ts +3 -3
  32. package/src/lib/ai-store/spans.ts +1 -1
  33. package/src/lib/ai-store/types.ts +2 -1
  34. package/src/lib/alerts-store.test.ts +153 -0
  35. package/src/lib/alerts-store.ts +122 -21
  36. package/src/lib/analyses-runner.test.ts +75 -0
  37. package/src/lib/analyses-runner.ts +69 -3
  38. package/src/lib/analyses-store-latest.test.ts +61 -0
  39. package/src/lib/analyses-store.ts +17 -10
  40. package/src/lib/autosre-change-correlation.ts +780 -0
  41. package/src/lib/autosre-findings.ts +725 -0
  42. package/src/lib/autosre-ontology.ts +914 -0
  43. package/src/lib/autosre-remediation-gate.ts +1663 -0
  44. package/src/lib/blob-store.ts +1 -1
  45. package/src/lib/eval-cases-store.ts +63 -1
  46. package/src/lib/evidence-references.ts +6 -0
  47. package/src/lib/housekeeping.test.ts +153 -0
  48. package/src/lib/housekeeping.ts +193 -0
  49. package/src/lib/identity-index/action-lookups.ts +6 -6
  50. package/src/lib/identity-index/key-lookups.ts +2 -2
  51. package/src/lib/identity-index/user-lookup.ts +8 -5
  52. package/src/lib/identity-index.test.ts +2 -3
  53. package/src/lib/metrics-store-series.test.ts +99 -0
  54. package/src/lib/metrics-store.test.ts +23 -0
  55. package/src/lib/metrics-store.ts +27 -3
  56. package/src/lib/projects-store.test.ts +25 -0
  57. package/src/lib/projects-store.ts +4 -3
  58. package/src/lib/sql-db-dialect.test.ts +12 -3
  59. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  60. package/src/lib/sql-db-postgres.test.ts +314 -0
  61. package/src/lib/sql-db-postgres.ts +392 -54
  62. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  63. package/src/lib/sql-db.ts +40 -6
  64. package/src/lib/store/ingest.ts +94 -6
  65. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  66. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  67. package/src/lib/webhook-url.test.ts +122 -0
  68. package/src/lib/webhook-url.ts +155 -0
  69. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  70. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  71. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  72. package/src/migrations/044_actions_started_at_index.sql +10 -0
  73. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  74. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  75. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  76. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  77. package/src/migrations/049_alert_state_notified.sql +17 -0
  78. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  79. package/src/migrations/alert-state-notified.test.ts +111 -0
  80. package/src/migrations/parity.test.ts +68 -0
  81. package/src/migrations/project-scoped-keys.test.ts +432 -0
  82. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  83. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  84. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  85. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  86. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  87. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  88. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  89. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  90. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  91. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  92. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  93. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  94. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  95. package/src/migrations-postgres/README.md +58 -5
  96. package/src/otlp/decode/body.test.ts +76 -0
  97. package/src/otlp/decode/body.ts +67 -6
  98. package/src/plugins/_json.ts +21 -0
  99. package/src/plugins/action-graph-processor.ts +6 -12
  100. package/src/plugins/ai-receiver.ts +5 -12
  101. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  102. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  103. package/src/plugins/alerts-evaluator.test.ts +464 -3
  104. package/src/plugins/alerts-evaluator.ts +181 -41
  105. package/src/plugins/alerts-routes.ts +10 -6
  106. package/src/plugins/ask-routes.ts +4 -5
  107. package/src/plugins/autosre-routes.ts +1019 -0
  108. package/src/plugins/connected-routes/manifest.ts +13 -1
  109. package/src/plugins/connected-routes.test.ts +106 -0
  110. package/src/plugins/connected-routes.ts +285 -5
  111. package/src/plugins/dashboard-routes.test.ts +120 -0
  112. package/src/plugins/dashboard-routes.ts +11 -2
  113. package/src/plugins/default-span-enrichment.ts +13 -1
  114. package/src/plugins/eval-cases-routes.ts +65 -19
  115. package/src/plugins/evidence-retrieval-routes.ts +300 -4
  116. package/src/plugins/identity-receiver.ts +9 -11
  117. package/src/plugins/json-body.test.ts +91 -0
  118. package/src/plugins/onboarding-routes.test.ts +105 -0
  119. package/src/plugins/onboarding-routes.ts +26 -11
  120. package/src/plugins/projects-routes.ts +2 -1
  121. package/src/plugins/replay-isolation.test.ts +922 -0
  122. package/src/plugins/replay-receiver.ts +8 -8
  123. package/src/plugins/usage-receiver.ts +3 -6
  124. package/dist/chunk-UGWDOGGH.js.map +0 -1
  125. package/dist/chunk-XKQTVIRE.js.map +0 -1
  126. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -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
- app.use("/dashboard/*", config.dashboardAuth.middleware);
302
- app.use("/internal/*", config.dashboardAuth.middleware);
303
- } else if (config.auth?.middleware) {
304
- // No dashboard auth configured — fall back to applying the unified
305
- // ingest auth to /internal/* too, preserving the earlier single-auth
306
- // contract for deployments that haven't adopted dashboard auth yet.
307
- app.use("/internal/*", config.auth.middleware);
308
- } else if (config.auth?.query) {
309
- app.use("/internal/*", config.auth.query);
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
- return {
337
- async scheduled(
338
- _event: ScheduledEvent,
339
- env: CollectorEnv,
340
- _ctx: ExecutionContext,
341
- ): Promise<void> {
342
- const db = sqlDbFor(env);
343
- const telemetryStore = new TelemetryStore(db);
344
- const usageStore = new UsageStore(db);
345
- const logsStore = new LogsStore(db);
346
- const aiStore = new AIStore(db);
347
- const metricsStore = new MetricsStore(db);
348
- const analysesStore = new AnalysesStore(db);
349
-
350
- const [
351
- telemetryPurged,
352
- usagePurged,
353
- logsPurged,
354
- aiPurged,
355
- metricsPurged,
356
- analysesPurged,
357
- ] = await Promise.all([
358
- telemetryStore.purgeExpired(),
359
- usageStore.purgeExpired(),
360
- logsStore.purgeExpired(),
361
- aiStore.purgeExpired(),
362
- metricsStore.purgeExpired(),
363
- analysesStore.purgeExpired(),
364
- ]);
365
-
366
- // RFC 0007 Phase 4.10 — profile_blobs retention. Cascade
367
- // foreign key on profile_trace_index handles the join rows;
368
- // we still need to delete the R2 blobs, which we do
369
- // best-effort by reading the URLs first.
370
- let profilesPurged = 0;
371
- try {
372
- const dialect = dialectFor(db);
373
- const expiredProfiles = await db
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 500`,
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 (expiredProfiles.results.length > 0 && env.PROFILES_BUCKET) {
379
- await Promise.allSettled(
380
- expiredProfiles.results.map((p) =>
381
- env.PROFILES_BUCKET?.delete(p.blob_url),
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
- const deleted = await db
386
- .prepare(
387
- `DELETE FROM profile_blobs WHERE expires_at < ${dialect.now()}`,
388
- )
389
- .run();
390
- profilesPurged = deleted.meta.changes;
391
- } catch (err) {
392
- logger.error("[retention-cleanup] profile purge failed", {
393
- error: err instanceof Error ? err.message : String(err),
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
- logger.info("[retention-cleanup] purged expired rows", {
398
- spans: telemetryPurged,
399
- usage: usagePurged,
400
- logs: logsPurged,
401
- ai_calls: aiPurged,
402
- metric_points: metricsPurged,
403
- analysis_results: analysesPurged,
404
- profile_blobs: profilesPurged,
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
- // RFC 0004 Phase 1.8 — emit interaction_id propagation counters.
408
- // Hourly aggregation rides the retention cron because cadence
409
- // matches and both touch the same projects' tables.
410
- try {
411
- const propagation = await aggregatePropagation(db, new Date(), logger);
412
- logger.info("[propagation-metric] hourly aggregation complete", {
413
- projects: propagation.projects,
414
- points_written: propagation.pointsWritten,
415
- });
416
- } catch (err) {
417
- logger.error("[propagation-metric] aggregation failed", {
418
- error: err instanceof Error ? err.message : String(err),
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
- * Kept distinct from retention so the per-minute tick doesn't sweep every
432
- * retention table every cycle.
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
- async scheduled(
442
- _event: ScheduledEvent,
443
- env: CollectorEnv,
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>>;
@@ -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
+ };