@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.
Files changed (107) 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/index.d.ts +322 -26
  6. package/dist/index.js +1478 -415
  7. package/dist/index.js.map +1 -1
  8. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  9. package/package.json +5 -5
  10. package/src/analyses/derive.ts +26 -25
  11. package/src/analyses/investigations.ts +8 -8
  12. package/src/analyses/tier0.ts +22 -22
  13. package/src/auth/dashboard-auth.test.ts +791 -0
  14. package/src/auth/dashboard-auth.ts +187 -38
  15. package/src/auth/login-rate-limit.test.ts +129 -0
  16. package/src/auth/login-rate-limit.ts +143 -0
  17. package/src/framework/body-limit.test.ts +165 -0
  18. package/src/framework/collector.ts +355 -115
  19. package/src/framework/env.ts +9 -0
  20. package/src/framework/limits.ts +31 -0
  21. package/src/framework/scheduled-handlers.test.ts +342 -0
  22. package/src/index.ts +16 -1
  23. package/src/lib/action-retention.test.ts +472 -0
  24. package/src/lib/action-retention.ts +420 -0
  25. package/src/lib/actions-store.ts +6 -12
  26. package/src/lib/ai-store/calls.ts +2 -2
  27. package/src/lib/ai-store/sessions.ts +3 -3
  28. package/src/lib/ai-store/spans.ts +1 -1
  29. package/src/lib/ai-store/types.ts +2 -1
  30. package/src/lib/alerts-store.test.ts +153 -0
  31. package/src/lib/alerts-store.ts +122 -21
  32. package/src/lib/analyses-runner.test.ts +75 -0
  33. package/src/lib/analyses-runner.ts +60 -3
  34. package/src/lib/analyses-store-latest.test.ts +61 -0
  35. package/src/lib/analyses-store.ts +17 -10
  36. package/src/lib/blob-store.ts +1 -1
  37. package/src/lib/housekeeping.test.ts +73 -0
  38. package/src/lib/housekeeping.ts +93 -0
  39. package/src/lib/identity-index/action-lookups.ts +6 -6
  40. package/src/lib/identity-index/key-lookups.ts +2 -2
  41. package/src/lib/identity-index/user-lookup.ts +8 -5
  42. package/src/lib/identity-index.test.ts +2 -3
  43. package/src/lib/metrics-store-series.test.ts +99 -0
  44. package/src/lib/metrics-store.test.ts +23 -0
  45. package/src/lib/metrics-store.ts +27 -3
  46. package/src/lib/projects-store.test.ts +25 -0
  47. package/src/lib/projects-store.ts +4 -3
  48. package/src/lib/sql-db-dialect.test.ts +12 -3
  49. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  50. package/src/lib/sql-db-postgres.test.ts +314 -0
  51. package/src/lib/sql-db-postgres.ts +392 -54
  52. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  53. package/src/lib/sql-db.ts +40 -6
  54. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  55. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  56. package/src/lib/webhook-url.test.ts +122 -0
  57. package/src/lib/webhook-url.ts +155 -0
  58. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  59. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  60. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  61. package/src/migrations/044_actions_started_at_index.sql +10 -0
  62. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  63. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  64. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  65. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  66. package/src/migrations/049_alert_state_notified.sql +17 -0
  67. package/src/migrations/alert-state-notified.test.ts +111 -0
  68. package/src/migrations/parity.test.ts +64 -0
  69. package/src/migrations/project-scoped-keys.test.ts +432 -0
  70. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  71. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  72. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  73. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  74. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  75. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  76. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  77. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  78. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  79. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  80. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  81. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  82. package/src/migrations-postgres/README.md +58 -5
  83. package/src/otlp/decode/body.test.ts +76 -0
  84. package/src/otlp/decode/body.ts +67 -6
  85. package/src/plugins/_json.ts +21 -0
  86. package/src/plugins/action-graph-processor.ts +6 -12
  87. package/src/plugins/ai-receiver.ts +5 -12
  88. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  89. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  90. package/src/plugins/alerts-evaluator.test.ts +464 -3
  91. package/src/plugins/alerts-evaluator.ts +165 -41
  92. package/src/plugins/alerts-routes.ts +10 -6
  93. package/src/plugins/ask-routes.ts +4 -5
  94. package/src/plugins/connected-routes.test.ts +106 -0
  95. package/src/plugins/connected-routes.ts +14 -5
  96. package/src/plugins/dashboard-routes.test.ts +120 -0
  97. package/src/plugins/dashboard-routes.ts +11 -2
  98. package/src/plugins/eval-cases-routes.ts +8 -17
  99. package/src/plugins/identity-receiver.ts +9 -11
  100. package/src/plugins/json-body.test.ts +91 -0
  101. package/src/plugins/onboarding-routes.test.ts +105 -0
  102. package/src/plugins/onboarding-routes.ts +26 -11
  103. package/src/plugins/projects-routes.ts +2 -1
  104. package/src/plugins/replay-isolation.test.ts +922 -0
  105. package/src/plugins/replay-receiver.ts +8 -8
  106. package/src/plugins/usage-receiver.ts +3 -6
  107. package/dist/chunk-UGWDOGGH.js.map +0 -1
package/dist/auth.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  import 'hono';
2
- export { e as createIngestAuth, r as resetIngestAuthCache } from './ingest-auth-HFVO1zIp.js';
2
+ export { e as createIngestAuth, r as resetIngestAuthCache } from './ingest-auth-C5-8Mly5.js';
package/dist/auth.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createIngestAuth,
3
3
  resetIngestAuthCache
4
- } from "./chunk-UGWDOGGH.js";
4
+ } from "./chunk-KMEZKGSO.js";
5
5
  export {
6
6
  createIngestAuth,
7
7
  resetIngestAuthCache
@@ -114,10 +114,11 @@ var ProjectsStore = class {
114
114
  warning: "This key will not be shown again. Store it somewhere safe."
115
115
  };
116
116
  }
117
- async revokeKey(keyId) {
117
+ async revokeKey(keyId, projectId) {
118
118
  const result = await this.db.prepare(
119
- `UPDATE ingest_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`
120
- ).bind((/* @__PURE__ */ new Date()).toISOString(), keyId).run();
119
+ `UPDATE ingest_keys SET revoked_at = ?
120
+ WHERE id = ? AND project_id = ? AND revoked_at IS NULL`
121
+ ).bind((/* @__PURE__ */ new Date()).toISOString(), keyId, projectId).run();
121
122
  return (result.meta?.changes ?? 0) > 0;
122
123
  }
123
124
  async findByKeyHash(keyHash) {
@@ -170,18 +171,27 @@ var D1Adapter = class {
170
171
  }
171
172
  };
172
173
  var sqlDbFor = (env) => isSqlDb(env.DB) ? env.DB : new D1Adapter(env.DB);
174
+ var SQLITE_ISO_FORMAT = "%Y-%m-%dT%H:%M:%fZ";
175
+ var sqliteIsoNow = (modifier) => modifier ? `strftime('${SQLITE_ISO_FORMAT}', 'now', ${modifier})` : `strftime('${SQLITE_ISO_FORMAT}', 'now')`;
173
176
  var sqliteDialect = {
174
177
  name: "sqlite",
175
- now: () => "datetime('now')",
176
- sinceHours: (param) => `datetime('now', '-' || ${param} || ' hours')`,
177
- sinceMinutes: (param) => `datetime('now', '-' || ${param} || ' minutes')`,
178
+ now: () => sqliteIsoNow(),
179
+ sinceHours: (param) => sqliteIsoNow(`'-' || ${param} || ' hours'`),
180
+ sinceMinutes: (param) => sqliteIsoNow(`'-' || ${param} || ' minutes'`),
178
181
  jsonText: (expr, path) => `json_extract(${expr}, '${path}')`
179
182
  };
183
+ var POSTGRES_ISO_TEXT_FORMAT = `'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'`;
184
+ var POSTGRES_UTC_NOW = "(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')";
185
+ var postgresIsoText = (expr) => `to_char(${expr}, ${POSTGRES_ISO_TEXT_FORMAT})`;
180
186
  var postgresDialect = {
181
187
  name: "postgres",
182
- now: () => "CURRENT_TIMESTAMP",
183
- sinceHours: (param) => `(CURRENT_TIMESTAMP - (${param}::text || ' hours')::interval)`,
184
- sinceMinutes: (param) => `(CURRENT_TIMESTAMP - (${param}::text || ' minutes')::interval)`,
188
+ now: () => postgresIsoText(POSTGRES_UTC_NOW),
189
+ sinceHours: (param) => postgresIsoText(
190
+ `${POSTGRES_UTC_NOW} - (${param}::text || ' hours')::interval`
191
+ ),
192
+ sinceMinutes: (param) => postgresIsoText(
193
+ `${POSTGRES_UTC_NOW} - (${param}::text || ' minutes')::interval`
194
+ ),
185
195
  jsonText: (expr, path) => {
186
196
  const key = path.replace(/^\$\./, "").replace(/^"|"$/g, "").replace(/\\u002E/g, ".");
187
197
  return `(${expr}::jsonb ->> '${key.replace(/'/g, "''")}')`;
@@ -308,6 +318,8 @@ function createIngestAuth(config) {
308
318
  export {
309
319
  D1Adapter,
310
320
  sqlDbFor,
321
+ SQLITE_ISO_FORMAT,
322
+ postgresIsoText,
311
323
  postgresDialect,
312
324
  dialectFor,
313
325
  sha256Hex,
@@ -316,4 +328,4 @@ export {
316
328
  resetIngestAuthCache,
317
329
  createIngestAuth
318
330
  };
319
- //# sourceMappingURL=chunk-UGWDOGGH.js.map
331
+ //# sourceMappingURL=chunk-KMEZKGSO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lib/hash.ts","../src/lib/projects-store.ts","../src/lib/sql-db.ts","../src/auth/ingest-auth.ts"],"sourcesContent":["/**\n * Cross-runtime crypto helpers using Web Crypto API.\n * Works in Cloudflare Workers, Node 20+, Deno, Bun.\n */\n\n/** SHA-256 hash of a string, returned as lowercase hex. */\nexport async function sha256Hex(input: string): Promise<string> {\n\tconst encoder = new TextEncoder();\n\tconst digest = await crypto.subtle.digest(\"SHA-256\", encoder.encode(input));\n\treturn bytesToHex(new Uint8Array(digest));\n}\n\n/** Generate `bytes` cryptographically random bytes and return as lowercase hex. */\nexport function randomHex(bytes: number): string {\n\tconst buf = new Uint8Array(bytes);\n\tcrypto.getRandomValues(buf);\n\treturn bytesToHex(buf);\n}\n\nfunction bytesToHex(bytes: Uint8Array): string {\n\tlet out = \"\";\n\tfor (let i = 0; i < bytes.length; i++) {\n\t\tout += bytes[i].toString(16).padStart(2, \"0\");\n\t}\n\treturn out;\n}\n","import type {\n\tIngestKey,\n\tIngestKeyRow,\n\tIngestKeyWithPlaintext,\n\tProject,\n\tProjectRow,\n} from \"@obsunified/types\";\nimport { randomHex, sha256Hex } from \"./hash\";\nimport type { SqlDb } from \"./sql-db\";\n\nconst rowToProject = (row: ProjectRow): Project => ({\n\tid: row.id,\n\tname: row.name,\n\tslug: row.slug,\n\tcreatedAt: row.created_at,\n\tpayloadCaptureEnabled:\n\t\trow.payload_capture_enabled === true ||\n\t\trow.payload_capture_enabled === 1 ||\n\t\trow.payload_capture_enabled === \"1\" ||\n\t\trow.payload_capture_enabled === \"true\",\n});\n\nconst rowToKey = (row: IngestKeyRow): IngestKey => ({\n\tid: row.id,\n\tprojectId: row.project_id,\n\tname: row.name,\n\tkeyPrefix: row.key_prefix,\n\tcreatedAt: row.created_at,\n\trevokedAt: row.revoked_at,\n});\n\nconst SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$/;\n\nexport class ProjectsStore {\n\tconstructor(private readonly db: SqlDb) {}\n\n\tasync listProjects(): Promise<Project[]> {\n\t\tconst rs = await this.db\n\t\t\t.prepare(`SELECT * FROM projects ORDER BY created_at ASC`)\n\t\t\t.all<ProjectRow>();\n\t\treturn (rs.results ?? []).map(rowToProject);\n\t}\n\n\tasync getProject(id: string): Promise<Project | null> {\n\t\tconst row = await this.db\n\t\t\t.prepare(`SELECT * FROM projects WHERE id = ?`)\n\t\t\t.bind(id)\n\t\t\t.first<ProjectRow>();\n\t\treturn row ? rowToProject(row) : null;\n\t}\n\n\tasync createProject(input: { name: string; slug: string }): Promise<Project> {\n\t\tconst name = input.name.trim();\n\t\tconst slug = input.slug.trim().toLowerCase();\n\t\tif (!name) throw new Error(\"Project name is required\");\n\t\tif (!SLUG_PATTERN.test(slug))\n\t\t\tthrow new Error(\n\t\t\t\t\"Slug must be lowercase letters, digits, and hyphens (max 50 chars)\",\n\t\t\t);\n\n\t\tconst id = randomHex(16);\n\t\tconst createdAt = new Date().toISOString();\n\n\t\tawait this.db\n\t\t\t.prepare(\n\t\t\t\t`INSERT INTO projects (id, name, slug, created_at) VALUES (?, ?, ?, ?)`,\n\t\t\t)\n\t\t\t.bind(id, name, slug, createdAt)\n\t\t\t.run();\n\n\t\treturn { id, name, slug, createdAt, payloadCaptureEnabled: false };\n\t}\n\n\tasync updateProject(\n\t\tid: string,\n\t\tpatch: { name?: string; payloadCaptureEnabled?: boolean },\n\t): Promise<Project | null> {\n\t\tconst existing = await this.getProject(id);\n\t\tif (!existing) return null;\n\n\t\tconst name = patch.name?.trim();\n\t\tlet next = existing;\n\t\tif (name !== undefined && name !== existing.name) {\n\t\t\tawait this.db\n\t\t\t\t.prepare(`UPDATE projects SET name = ? WHERE id = ?`)\n\t\t\t\t.bind(name, id)\n\t\t\t\t.run();\n\t\t\tnext = { ...next, name };\n\t\t}\n\t\tif (\n\t\t\tpatch.payloadCaptureEnabled !== undefined &&\n\t\t\tpatch.payloadCaptureEnabled !== existing.payloadCaptureEnabled\n\t\t) {\n\t\t\tawait this.db\n\t\t\t\t.prepare(`UPDATE projects SET payload_capture_enabled = ? WHERE id = ?`)\n\t\t\t\t.bind(patch.payloadCaptureEnabled ? 1 : 0, id)\n\t\t\t\t.run();\n\t\t\tnext = {\n\t\t\t\t...next,\n\t\t\t\tpayloadCaptureEnabled: patch.payloadCaptureEnabled,\n\t\t\t};\n\t\t}\n\t\treturn next;\n\t}\n\n\tasync ensureDefaultProject(): Promise<void> {\n\t\tawait this.db\n\t\t\t.prepare(\n\t\t\t\t`INSERT OR IGNORE INTO projects (id, name, slug, created_at) VALUES ('default', 'Default', 'default', ?)`,\n\t\t\t)\n\t\t\t.bind(new Date().toISOString())\n\t\t\t.run();\n\t}\n\n\t// ── Ingest keys ──\n\n\tasync listKeys(projectId: string): Promise<IngestKey[]> {\n\t\tconst rs = await this.db\n\t\t\t.prepare(\n\t\t\t\t`SELECT * FROM ingest_keys WHERE project_id = ? ORDER BY created_at DESC`,\n\t\t\t)\n\t\t\t.bind(projectId)\n\t\t\t.all<IngestKeyRow>();\n\t\treturn (rs.results ?? []).map(rowToKey);\n\t}\n\n\tasync createKey(\n\t\tprojectId: string,\n\t\tname: string,\n\t): Promise<IngestKeyWithPlaintext> {\n\t\tconst project = await this.getProject(projectId);\n\t\tif (!project) throw new Error(\"Project not found\");\n\n\t\tconst trimmedName = name.trim() || \"unnamed\";\n\t\tconst id = randomHex(16);\n\t\tconst plaintext = `obs_${project.slug}_${randomHex(24)}`;\n\t\tconst keyHash = await sha256Hex(plaintext);\n\t\t// Prefix: show enough to identify but not recover (first 16 chars).\n\t\tconst keyPrefix = plaintext.slice(0, 16);\n\t\tconst createdAt = new Date().toISOString();\n\n\t\tawait this.db\n\t\t\t.prepare(\n\t\t\t\t`INSERT INTO ingest_keys (id, project_id, key_hash, key_prefix, name, created_at) VALUES (?, ?, ?, ?, ?, ?)`,\n\t\t\t)\n\t\t\t.bind(id, projectId, keyHash, keyPrefix, trimmedName, createdAt)\n\t\t\t.run();\n\n\t\treturn {\n\t\t\tid,\n\t\t\tprojectId,\n\t\t\tname: trimmedName,\n\t\t\tkeyPrefix,\n\t\t\tcreatedAt,\n\t\t\trevokedAt: null,\n\t\t\tkey: plaintext,\n\t\t\twarning: \"This key will not be shown again. Store it somewhere safe.\",\n\t\t};\n\t}\n\n\tasync revokeKey(keyId: string, projectId: string): Promise<boolean> {\n\t\tconst result = await this.db\n\t\t\t.prepare(\n\t\t\t\t`UPDATE ingest_keys SET revoked_at = ?\n\t\t\t\t WHERE id = ? AND project_id = ? AND revoked_at IS NULL`,\n\t\t\t)\n\t\t\t.bind(new Date().toISOString(), keyId, projectId)\n\t\t\t.run();\n\t\treturn (result.meta?.changes ?? 0) > 0;\n\t}\n\n\tasync findByKeyHash(\n\t\tkeyHash: string,\n\t): Promise<{ projectId: string; keyId: string } | null> {\n\t\tconst row = await this.db\n\t\t\t.prepare(\n\t\t\t\t`SELECT id, project_id FROM ingest_keys WHERE key_hash = ? AND revoked_at IS NULL`,\n\t\t\t)\n\t\t\t.bind(keyHash)\n\t\t\t.first<{ id: string; project_id: string }>();\n\t\treturn row ? { projectId: row.project_id, keyId: row.id } : null;\n\t}\n\n\t/**\n\t * Idempotently register the legacy env INGEST_KEY as a bootstrap key on the\n\t * default project, so existing deployments keep working without manual migration.\n\t */\n\tasync bootstrapEnvKey(plaintext: string): Promise<void> {\n\t\tif (!plaintext) return;\n\t\tawait this.ensureDefaultProject();\n\t\tconst keyHash = await sha256Hex(plaintext);\n\t\tconst existing = await this.db\n\t\t\t.prepare(`SELECT id FROM ingest_keys WHERE key_hash = ?`)\n\t\t\t.bind(keyHash)\n\t\t\t.first<{ id: string }>();\n\t\tif (existing) return;\n\n\t\tconst id = randomHex(16);\n\t\tconst keyPrefix = plaintext.slice(0, Math.min(16, plaintext.length));\n\t\tconst createdAt = new Date().toISOString();\n\t\tawait this.db\n\t\t\t.prepare(\n\t\t\t\t`INSERT INTO ingest_keys (id, project_id, key_hash, key_prefix, name, created_at) VALUES (?, 'default', ?, ?, 'bootstrap', ?)`,\n\t\t\t)\n\t\t\t.bind(id, keyHash, keyPrefix, createdAt)\n\t\t\t.run();\n\t}\n}\n","/**\n * Storage seam for the collector — RFC 0008.\n *\n * Stores and plugins target this interface instead of `D1Database` directly,\n * so a future Node / better-sqlite3 / ClickHouse runtime is one new adapter\n * away rather than a fork-and-rewrite. The shape is deliberately D1's\n * existing `prepare → bind → first|all|run` chain so the migration of\n * existing stores is mechanical (search-and-replace the constructor type).\n *\n * Today there is one implementation, `D1Adapter`. A `MemSqlDb` test double\n * lives next door under `test-utils/`; `BetterSqliteAdapter` and\n * `ClickHouseAdapter` are deferred until they have a real runtime to serve.\n */\n\nexport interface SqlDb {\n\treadonly dialect?: SqlDialect;\n\tprepare(sql: string): SqlStatement;\n\t/**\n\t * Run multiple prepared statements as a batch. On D1 this maps to a\n\t * single network round-trip; on a future better-sqlite3 adapter it\n\t * wraps the calls in a transaction. Returns one result per statement\n\t * in input order, mirroring D1's `batch`.\n\t *\n\t * Statements MUST have been produced by this same `SqlDb` instance —\n\t * mixing adapters is an error and runtime behavior is undefined.\n\t */\n\tbatch(\n\t\tstatements: SqlStatement[],\n\t): Promise<Array<{ meta: { changes: number } }>>;\n}\n\nexport type SqlDialectName = \"sqlite\" | \"postgres\";\n\nexport interface SqlDialect {\n\treadonly name: SqlDialectName;\n\tnow(): string;\n\tsinceHours(param: string): string;\n\tsinceMinutes(param: string): string;\n\tjsonText(expr: string, path: string): string;\n}\n\nexport interface SqlStatement {\n\tbind(...args: unknown[]): SqlStatement;\n\tfirst<T = Record<string, unknown>>(): Promise<T | null>;\n\tall<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n\trun(): Promise<{ meta: { changes: number } }>;\n}\n\n/**\n * D1 already conforms to this shape almost exactly; the adapter normalizes\n * two small differences:\n * - `all()` on D1 returns `results?: T[]` (optional). We coerce to `[]`.\n * - `run()`'s `meta.changes` is optional on D1. We coerce to `0`.\n * Both differences are runtime-undetectable for the queries this codebase\n * issues (every successful all() returns results; every run() against an\n * INSERT/UPDATE/DELETE reports changes), but coercing keeps the typed\n * interface honest.\n */\nexport class D1Adapter implements SqlDb {\n\treadonly dialect = sqliteDialect;\n\n\tconstructor(private readonly d1: D1Database) {}\n\n\tprepare(sql: string): SqlStatement {\n\t\treturn new D1StatementAdapter(this.d1.prepare(sql));\n\t}\n\n\tasync batch(\n\t\tstatements: SqlStatement[],\n\t): Promise<Array<{ meta: { changes: number } }>> {\n\t\t// Unwrap each statement back to its D1 form — D1's batch takes\n\t\t// D1PreparedStatement[], not our typed wrapper.\n\t\tconst unwrapped = statements.map((s) => {\n\t\t\tif (!(s instanceof D1StatementAdapter)) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"D1Adapter.batch: statement was not produced by D1Adapter\",\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn s.unwrap();\n\t\t});\n\t\tconst results = await this.d1.batch(unwrapped);\n\t\treturn results.map((r) => ({\n\t\t\tmeta: { changes: r.meta?.changes ?? 0 },\n\t\t}));\n\t}\n}\n\n/**\n * Convenience wrapper for the common case: take a `CollectorEnv`-shaped\n * object and return an `SqlDb`. Most plugins don't have access to the\n * `CollectorRuntime` (they get it at register time, not handler time)\n * so they call this directly. Plugins that *do* hold the runtime should\n * prefer `runtime.getSqlDb(env)` so a host-supplied `sqlDb` factory in\n * `CollectorConfig` takes effect.\n */\nexport const sqlDbFor = (env: { DB: D1Database | SqlDb }): SqlDb =>\n\tisSqlDb(env.DB) ? env.DB : new D1Adapter(env.DB);\n\n/**\n * SQLite `strftime` format that reproduces `Date#toISOString()` output\n * (`YYYY-MM-DDTHH:MM:SS.mmmZ`). Every stored timestamp column holds that\n * shape, so time windows must be rendered the same way: `datetime('now')`\n * yields `YYYY-MM-DD HH:MM:SS` and in a text comparison the space sorts\n * before the `T`, which made \"last 5 minutes\" match the whole current UTC\n * day on D1 and made `expires_at < now` purges run a day late.\n */\nexport const SQLITE_ISO_FORMAT = \"%Y-%m-%dT%H:%M:%fZ\";\n\n/** Render SQLite `now` (optionally shifted by a modifier expression) as ISO text. */\nexport const sqliteIsoNow = (modifier?: string): string =>\n\tmodifier\n\t\t? `strftime('${SQLITE_ISO_FORMAT}', 'now', ${modifier})`\n\t\t: `strftime('${SQLITE_ISO_FORMAT}', 'now')`;\n\nexport const sqliteDialect: SqlDialect = {\n\tname: \"sqlite\",\n\tnow: () => sqliteIsoNow(),\n\tsinceHours: (param) => sqliteIsoNow(`'-' || ${param} || ' hours'`),\n\tsinceMinutes: (param) => sqliteIsoNow(`'-' || ${param} || ' minutes'`),\n\tjsonText: (expr, path) => `json_extract(${expr}, '${path}')`,\n};\n\n/**\n * The collector stores every timestamp column (`received_at`, `expires_at`,\n * `occurred_at`, …) as TEXT holding `Date#toISOString()` output on both\n * D1 and Postgres. Postgres will not compare TEXT against a\n * `timestamptz`, so time-window expressions have to be rendered as ISO\n * text in the same `YYYY-MM-DDTHH:MM:SS.mmmZ` shape the collector writes.\n */\nconst POSTGRES_ISO_TEXT_FORMAT = `'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'`;\nconst POSTGRES_UTC_NOW = \"(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')\";\n\n/** Render a Postgres `timestamp` expression as collector-style ISO text. */\nexport const postgresIsoText = (expr: string): string =>\n\t`to_char(${expr}, ${POSTGRES_ISO_TEXT_FORMAT})`;\n\nexport const postgresDialect: SqlDialect = {\n\tname: \"postgres\",\n\tnow: () => postgresIsoText(POSTGRES_UTC_NOW),\n\tsinceHours: (param) =>\n\t\tpostgresIsoText(\n\t\t\t`${POSTGRES_UTC_NOW} - (${param}::text || ' hours')::interval`,\n\t\t),\n\tsinceMinutes: (param) =>\n\t\tpostgresIsoText(\n\t\t\t`${POSTGRES_UTC_NOW} - (${param}::text || ' minutes')::interval`,\n\t\t),\n\tjsonText: (expr, path) => {\n\t\tconst key = path\n\t\t\t.replace(/^\\$\\./, \"\")\n\t\t\t.replace(/^\"|\"$/g, \"\")\n\t\t\t.replace(/\\\\u002E/g, \".\");\n\t\treturn `(${expr}::jsonb ->> '${key.replace(/'/g, \"''\")}')`;\n\t},\n};\n\nexport const dialectFor = (db: SqlDb): SqlDialect =>\n\tdb.dialect ?? sqliteDialect;\n\nconst isSqlDb = (db: unknown): db is SqlDb => {\n\tif (!db || typeof db !== \"object\") return false;\n\tconst candidate = db as {\n\t\tprepare?: unknown;\n\t\tbatch?: unknown;\n\t\texec?: unknown;\n\t};\n\treturn (\n\t\ttypeof candidate.prepare === \"function\" &&\n\t\ttypeof candidate.batch === \"function\" &&\n\t\ttypeof candidate.exec !== \"function\"\n\t);\n};\n\nclass D1StatementAdapter implements SqlStatement {\n\tconstructor(private readonly stmt: D1PreparedStatement) {}\n\n\t/** Internal escape hatch for `D1Adapter.batch` to recover the wrapped\n\t * D1 statement. Not part of the public `SqlStatement` shape. */\n\tunwrap(): D1PreparedStatement {\n\t\treturn this.stmt;\n\t}\n\n\tbind(...args: unknown[]): SqlStatement {\n\t\treturn new D1StatementAdapter(this.stmt.bind(...args));\n\t}\n\n\tfirst<T = Record<string, unknown>>(): Promise<T | null> {\n\t\treturn this.stmt.first<T>();\n\t}\n\n\tasync all<T = Record<string, unknown>>(): Promise<{ results: T[] }> {\n\t\tconst r = await this.stmt.all<T>();\n\t\treturn { results: r.results ?? [] };\n\t}\n\n\tasync run(): Promise<{ meta: { changes: number } }> {\n\t\tconst r = await this.stmt.run();\n\t\treturn { meta: { changes: r.meta?.changes ?? 0 } };\n\t}\n}\n","import type { MiddlewareHandler } from \"hono\";\nimport type { CollectorEnv } from \"../framework/env\";\nimport { sha256Hex } from \"../lib/hash\";\nimport { ProjectsStore } from \"../lib/projects-store\";\nimport { sqlDbFor } from \"../lib/sql-db\";\n\nconst CACHE_TTL_MS = 60_000; // 60 seconds\nconst CACHE_MAX_ENTRIES = 500;\n\ninterface CacheEntry {\n\tprojectId: string;\n\texpiresAt: number;\n}\n\nlet keyCaches = new WeakMap<object, Map<string, CacheEntry>>();\nlet bootstrapDone = false;\n\nfunction cacheFor(dbKey: object): Map<string, CacheEntry> {\n\tlet cache = keyCaches.get(dbKey);\n\tif (!cache) {\n\t\tcache = new Map();\n\t\tkeyCaches.set(dbKey, cache);\n\t}\n\treturn cache;\n}\n\nfunction getCached(\n\tcache: Map<string, CacheEntry>,\n\thash: string,\n\tnow: number,\n): string | null {\n\tpruneKeyCache(cache, now);\n\tconst entry = cache.get(hash);\n\tif (!entry) return null;\n\tif (entry.expiresAt <= now) {\n\t\tcache.delete(hash);\n\t\treturn null;\n\t}\n\treturn entry.projectId;\n}\n\nfunction setCached(\n\tcache: Map<string, CacheEntry>,\n\thash: string,\n\tprojectId: string,\n\tnow: number,\n): void {\n\tpruneKeyCache(cache, now);\n\twhile (cache.size >= CACHE_MAX_ENTRIES) {\n\t\tconst oldestKey = cache.keys().next().value;\n\t\tif (!oldestKey) break;\n\t\tcache.delete(oldestKey);\n\t}\n\tcache.set(hash, { projectId, expiresAt: now + CACHE_TTL_MS });\n}\n\nfunction pruneKeyCache(cache: Map<string, CacheEntry>, now: number): void {\n\tfor (const [hash, entry] of cache) {\n\t\tif (entry.expiresAt <= now) cache.delete(hash);\n\t}\n}\n\n/** Test helper — clears the in-memory cache and bootstrap flag. */\nexport function resetIngestAuthCache(): void {\n\tkeyCaches = new WeakMap();\n\tbootstrapDone = false;\n}\n\n/**\n * Creates middleware that validates ingest API keys against the ingest_keys table.\n *\n * Accepts the key from either:\n * - Authorization: Bearer <key>\n * - X-API-Key: <key>\n *\n * On success, sets `projectId` on the Hono context via `c.set('projectId', ...)`.\n *\n * On the first request per isolate, if `env.INGEST_KEY` is set, the middleware\n * registers it as a `bootstrap` key on the default project — so existing\n * single-tenant deployments continue to work without manual migration.\n */\nexport function createIngestAuth(config?: {\n\tallowUnauthenticated?: boolean;\n}): MiddlewareHandler<{\n\tBindings: CollectorEnv;\n\tVariables: { projectId: string };\n}> {\n\treturn async (c, next) => {\n\t\t// Lazy bootstrap of legacy env key (idempotent).\n\t\tif (!bootstrapDone) {\n\t\t\tconst legacy = c.env.INGEST_KEY || c.env.TELEMETRY_INGEST_TOKEN;\n\t\t\tif (legacy) {\n\t\t\t\tconst store = new ProjectsStore(sqlDbFor(c.env));\n\t\t\t\ttry {\n\t\t\t\t\tawait store.bootstrapEnvKey(legacy);\n\t\t\t\t\t// Latch only after success: a transient DB error on the first\n\t\t\t\t\t// request must not permanently disable legacy-key bootstrap for\n\t\t\t\t\t// the isolate. bootstrapEnvKey is idempotent, so concurrent\n\t\t\t\t\t// first-requests racing here are harmless.\n\t\t\t\t\tbootstrapDone = true;\n\t\t\t\t} catch (err) {\n\t\t\t\t\tconsole.error(\"[ingest-auth] bootstrap failed:\", err);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Nothing to bootstrap — don't keep re-checking on every request.\n\t\t\t\tbootstrapDone = true;\n\t\t\t}\n\t\t}\n\n\t\tconst bearer = c.req.header(\"Authorization\")?.replace(/^Bearer\\s+/i, \"\");\n\t\tconst apiKey = c.req.header(\"X-API-Key\");\n\t\tconst token = bearer || apiKey;\n\n\t\tif (!token) {\n\t\t\tif (\n\t\t\t\tconfig?.allowUnauthenticated ||\n\t\t\t\tc.env.ALLOW_UNAUTHENTICATED === \"true\"\n\t\t\t) {\n\t\t\t\tc.set(\"projectId\", \"default\");\n\t\t\t\treturn next();\n\t\t\t}\n\t\t\treturn c.json({ error: \"Unauthorized\" }, 401);\n\t\t}\n\n\t\tconst now = Date.now();\n\t\tconst hash = await sha256Hex(token);\n\t\tconst rawDb =\n\t\t\t(c.env.DB as unknown as Record<string, unknown>)?.__unwrapped_db ||\n\t\t\tc.env.DB;\n\t\tconst cache = cacheFor(rawDb);\n\t\tconst cached = getCached(cache, hash, now);\n\t\tif (cached) {\n\t\t\tc.set(\"projectId\", cached);\n\t\t\treturn next();\n\t\t}\n\n\t\tconst store = new ProjectsStore(sqlDbFor(c.env));\n\t\tconst match = await store.findByKeyHash(hash);\n\t\tif (!match) {\n\t\t\treturn c.json({ error: \"Unauthorized\" }, 401);\n\t\t}\n\n\t\tsetCached(cache, hash, match.projectId, now);\n\t\tc.set(\"projectId\", match.projectId);\n\t\treturn next();\n\t};\n}\n"],"mappings":";AAMA,eAAsB,UAAU,OAAgC;AAC/D,QAAM,UAAU,IAAI,YAAY;AAChC,QAAM,SAAS,MAAM,OAAO,OAAO,OAAO,WAAW,QAAQ,OAAO,KAAK,CAAC;AAC1E,SAAO,WAAW,IAAI,WAAW,MAAM,CAAC;AACzC;AAGO,SAAS,UAAU,OAAuB;AAChD,QAAM,MAAM,IAAI,WAAW,KAAK;AAChC,SAAO,gBAAgB,GAAG;AAC1B,SAAO,WAAW,GAAG;AACtB;AAEA,SAAS,WAAW,OAA2B;AAC9C,MAAI,MAAM;AACV,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACtC,WAAO,MAAM,CAAC,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG;AAAA,EAC7C;AACA,SAAO;AACR;;;ACfA,IAAM,eAAe,CAAC,SAA8B;AAAA,EACnD,IAAI,IAAI;AAAA,EACR,MAAM,IAAI;AAAA,EACV,MAAM,IAAI;AAAA,EACV,WAAW,IAAI;AAAA,EACf,uBACC,IAAI,4BAA4B,QAChC,IAAI,4BAA4B,KAChC,IAAI,4BAA4B,OAChC,IAAI,4BAA4B;AAClC;AAEA,IAAM,WAAW,CAAC,SAAkC;AAAA,EACnD,IAAI,IAAI;AAAA,EACR,WAAW,IAAI;AAAA,EACf,MAAM,IAAI;AAAA,EACV,WAAW,IAAI;AAAA,EACf,WAAW,IAAI;AAAA,EACf,WAAW,IAAI;AAChB;AAEA,IAAM,eAAe;AAEd,IAAM,gBAAN,MAAoB;AAAA,EAC1B,YAA6B,IAAW;AAAX;AAAA,EAAY;AAAA,EAEzC,MAAM,eAAmC;AACxC,UAAM,KAAK,MAAM,KAAK,GACpB,QAAQ,gDAAgD,EACxD,IAAgB;AAClB,YAAQ,GAAG,WAAW,CAAC,GAAG,IAAI,YAAY;AAAA,EAC3C;AAAA,EAEA,MAAM,WAAW,IAAqC;AACrD,UAAM,MAAM,MAAM,KAAK,GACrB,QAAQ,qCAAqC,EAC7C,KAAK,EAAE,EACP,MAAkB;AACpB,WAAO,MAAM,aAAa,GAAG,IAAI;AAAA,EAClC;AAAA,EAEA,MAAM,cAAc,OAAyD;AAC5E,UAAM,OAAO,MAAM,KAAK,KAAK;AAC7B,UAAM,OAAO,MAAM,KAAK,KAAK,EAAE,YAAY;AAC3C,QAAI,CAAC,KAAM,OAAM,IAAI,MAAM,0BAA0B;AACrD,QAAI,CAAC,aAAa,KAAK,IAAI;AAC1B,YAAM,IAAI;AAAA,QACT;AAAA,MACD;AAED,UAAM,KAAK,UAAU,EAAE;AACvB,UAAM,aAAY,oBAAI,KAAK,GAAE,YAAY;AAEzC,UAAM,KAAK,GACT;AAAA,MACA;AAAA,IACD,EACC,KAAK,IAAI,MAAM,MAAM,SAAS,EAC9B,IAAI;AAEN,WAAO,EAAE,IAAI,MAAM,MAAM,WAAW,uBAAuB,MAAM;AAAA,EAClE;AAAA,EAEA,MAAM,cACL,IACA,OAC0B;AAC1B,UAAM,WAAW,MAAM,KAAK,WAAW,EAAE;AACzC,QAAI,CAAC,SAAU,QAAO;AAEtB,UAAM,OAAO,MAAM,MAAM,KAAK;AAC9B,QAAI,OAAO;AACX,QAAI,SAAS,UAAa,SAAS,SAAS,MAAM;AACjD,YAAM,KAAK,GACT,QAAQ,2CAA2C,EACnD,KAAK,MAAM,EAAE,EACb,IAAI;AACN,aAAO,EAAE,GAAG,MAAM,KAAK;AAAA,IACxB;AACA,QACC,MAAM,0BAA0B,UAChC,MAAM,0BAA0B,SAAS,uBACxC;AACD,YAAM,KAAK,GACT,QAAQ,8DAA8D,EACtE,KAAK,MAAM,wBAAwB,IAAI,GAAG,EAAE,EAC5C,IAAI;AACN,aAAO;AAAA,QACN,GAAG;AAAA,QACH,uBAAuB,MAAM;AAAA,MAC9B;AAAA,IACD;AACA,WAAO;AAAA,EACR;AAAA,EAEA,MAAM,uBAAsC;AAC3C,UAAM,KAAK,GACT;AAAA,MACA;AAAA,IACD,EACC,MAAK,oBAAI,KAAK,GAAE,YAAY,CAAC,EAC7B,IAAI;AAAA,EACP;AAAA;AAAA,EAIA,MAAM,SAAS,WAAyC;AACvD,UAAM,KAAK,MAAM,KAAK,GACpB;AAAA,MACA;AAAA,IACD,EACC,KAAK,SAAS,EACd,IAAkB;AACpB,YAAQ,GAAG,WAAW,CAAC,GAAG,IAAI,QAAQ;AAAA,EACvC;AAAA,EAEA,MAAM,UACL,WACA,MACkC;AAClC,UAAM,UAAU,MAAM,KAAK,WAAW,SAAS;AAC/C,QAAI,CAAC,QAAS,OAAM,IAAI,MAAM,mBAAmB;AAEjD,UAAM,cAAc,KAAK,KAAK,KAAK;AACnC,UAAM,KAAK,UAAU,EAAE;AACvB,UAAM,YAAY,OAAO,QAAQ,IAAI,IAAI,UAAU,EAAE,CAAC;AACtD,UAAM,UAAU,MAAM,UAAU,SAAS;AAEzC,UAAM,YAAY,UAAU,MAAM,GAAG,EAAE;AACvC,UAAM,aAAY,oBAAI,KAAK,GAAE,YAAY;AAEzC,UAAM,KAAK,GACT;AAAA,MACA;AAAA,IACD,EACC,KAAK,IAAI,WAAW,SAAS,WAAW,aAAa,SAAS,EAC9D,IAAI;AAEN,WAAO;AAAA,MACN;AAAA,MACA;AAAA,MACA,MAAM;AAAA,MACN;AAAA,MACA;AAAA,MACA,WAAW;AAAA,MACX,KAAK;AAAA,MACL,SAAS;AAAA,IACV;AAAA,EACD;AAAA,EAEA,MAAM,UAAU,OAAe,WAAqC;AACnE,UAAM,SAAS,MAAM,KAAK,GACxB;AAAA,MACA;AAAA;AAAA,IAED,EACC,MAAK,oBAAI,KAAK,GAAE,YAAY,GAAG,OAAO,SAAS,EAC/C,IAAI;AACN,YAAQ,OAAO,MAAM,WAAW,KAAK;AAAA,EACtC;AAAA,EAEA,MAAM,cACL,SACuD;AACvD,UAAM,MAAM,MAAM,KAAK,GACrB;AAAA,MACA;AAAA,IACD,EACC,KAAK,OAAO,EACZ,MAA0C;AAC5C,WAAO,MAAM,EAAE,WAAW,IAAI,YAAY,OAAO,IAAI,GAAG,IAAI;AAAA,EAC7D;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,gBAAgB,WAAkC;AACvD,QAAI,CAAC,UAAW;AAChB,UAAM,KAAK,qBAAqB;AAChC,UAAM,UAAU,MAAM,UAAU,SAAS;AACzC,UAAM,WAAW,MAAM,KAAK,GAC1B,QAAQ,+CAA+C,EACvD,KAAK,OAAO,EACZ,MAAsB;AACxB,QAAI,SAAU;AAEd,UAAM,KAAK,UAAU,EAAE;AACvB,UAAM,YAAY,UAAU,MAAM,GAAG,KAAK,IAAI,IAAI,UAAU,MAAM,CAAC;AACnE,UAAM,aAAY,oBAAI,KAAK,GAAE,YAAY;AACzC,UAAM,KAAK,GACT;AAAA,MACA;AAAA,IACD,EACC,KAAK,IAAI,SAAS,WAAW,SAAS,EACtC,IAAI;AAAA,EACP;AACD;;;ACrJO,IAAM,YAAN,MAAiC;AAAA,EAGvC,YAA6B,IAAgB;AAAhB;AAAA,EAAiB;AAAA,EAFrC,UAAU;AAAA,EAInB,QAAQ,KAA2B;AAClC,WAAO,IAAI,mBAAmB,KAAK,GAAG,QAAQ,GAAG,CAAC;AAAA,EACnD;AAAA,EAEA,MAAM,MACL,YACgD;AAGhD,UAAM,YAAY,WAAW,IAAI,CAAC,MAAM;AACvC,UAAI,EAAE,aAAa,qBAAqB;AACvC,cAAM,IAAI;AAAA,UACT;AAAA,QACD;AAAA,MACD;AACA,aAAO,EAAE,OAAO;AAAA,IACjB,CAAC;AACD,UAAM,UAAU,MAAM,KAAK,GAAG,MAAM,SAAS;AAC7C,WAAO,QAAQ,IAAI,CAAC,OAAO;AAAA,MAC1B,MAAM,EAAE,SAAS,EAAE,MAAM,WAAW,EAAE;AAAA,IACvC,EAAE;AAAA,EACH;AACD;AAUO,IAAM,WAAW,CAAC,QACxB,QAAQ,IAAI,EAAE,IAAI,IAAI,KAAK,IAAI,UAAU,IAAI,EAAE;AAUzC,IAAM,oBAAoB;AAG1B,IAAM,eAAe,CAAC,aAC5B,WACG,aAAa,iBAAiB,aAAa,QAAQ,MACnD,aAAa,iBAAiB;AAE3B,IAAM,gBAA4B;AAAA,EACxC,MAAM;AAAA,EACN,KAAK,MAAM,aAAa;AAAA,EACxB,YAAY,CAAC,UAAU,aAAa,UAAU,KAAK,cAAc;AAAA,EACjE,cAAc,CAAC,UAAU,aAAa,UAAU,KAAK,gBAAgB;AAAA,EACrE,UAAU,CAAC,MAAM,SAAS,gBAAgB,IAAI,MAAM,IAAI;AACzD;AASA,IAAM,2BAA2B;AACjC,IAAM,mBAAmB;AAGlB,IAAM,kBAAkB,CAAC,SAC/B,WAAW,IAAI,KAAK,wBAAwB;AAEtC,IAAM,kBAA8B;AAAA,EAC1C,MAAM;AAAA,EACN,KAAK,MAAM,gBAAgB,gBAAgB;AAAA,EAC3C,YAAY,CAAC,UACZ;AAAA,IACC,GAAG,gBAAgB,OAAO,KAAK;AAAA,EAChC;AAAA,EACD,cAAc,CAAC,UACd;AAAA,IACC,GAAG,gBAAgB,OAAO,KAAK;AAAA,EAChC;AAAA,EACD,UAAU,CAAC,MAAM,SAAS;AACzB,UAAM,MAAM,KACV,QAAQ,SAAS,EAAE,EACnB,QAAQ,UAAU,EAAE,EACpB,QAAQ,YAAY,GAAG;AACzB,WAAO,IAAI,IAAI,gBAAgB,IAAI,QAAQ,MAAM,IAAI,CAAC;AAAA,EACvD;AACD;AAEO,IAAM,aAAa,CAAC,OAC1B,GAAG,WAAW;AAEf,IAAM,UAAU,CAAC,OAA6B;AAC7C,MAAI,CAAC,MAAM,OAAO,OAAO,SAAU,QAAO;AAC1C,QAAM,YAAY;AAKlB,SACC,OAAO,UAAU,YAAY,cAC7B,OAAO,UAAU,UAAU,cAC3B,OAAO,UAAU,SAAS;AAE5B;AAEA,IAAM,qBAAN,MAAM,oBAA2C;AAAA,EAChD,YAA6B,MAA2B;AAA3B;AAAA,EAA4B;AAAA;AAAA;AAAA,EAIzD,SAA8B;AAC7B,WAAO,KAAK;AAAA,EACb;AAAA,EAEA,QAAQ,MAA+B;AACtC,WAAO,IAAI,oBAAmB,KAAK,KAAK,KAAK,GAAG,IAAI,CAAC;AAAA,EACtD;AAAA,EAEA,QAAwD;AACvD,WAAO,KAAK,KAAK,MAAS;AAAA,EAC3B;AAAA,EAEA,MAAM,MAA8D;AACnE,UAAM,IAAI,MAAM,KAAK,KAAK,IAAO;AACjC,WAAO,EAAE,SAAS,EAAE,WAAW,CAAC,EAAE;AAAA,EACnC;AAAA,EAEA,MAAM,MAA8C;AACnD,UAAM,IAAI,MAAM,KAAK,KAAK,IAAI;AAC9B,WAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,WAAW,EAAE,EAAE;AAAA,EAClD;AACD;;;ACjMA,IAAM,eAAe;AACrB,IAAM,oBAAoB;AAO1B,IAAI,YAAY,oBAAI,QAAyC;AAC7D,IAAI,gBAAgB;AAEpB,SAAS,SAAS,OAAwC;AACzD,MAAI,QAAQ,UAAU,IAAI,KAAK;AAC/B,MAAI,CAAC,OAAO;AACX,YAAQ,oBAAI,IAAI;AAChB,cAAU,IAAI,OAAO,KAAK;AAAA,EAC3B;AACA,SAAO;AACR;AAEA,SAAS,UACR,OACA,MACA,KACgB;AAChB,gBAAc,OAAO,GAAG;AACxB,QAAM,QAAQ,MAAM,IAAI,IAAI;AAC5B,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI,MAAM,aAAa,KAAK;AAC3B,UAAM,OAAO,IAAI;AACjB,WAAO;AAAA,EACR;AACA,SAAO,MAAM;AACd;AAEA,SAAS,UACR,OACA,MACA,WACA,KACO;AACP,gBAAc,OAAO,GAAG;AACxB,SAAO,MAAM,QAAQ,mBAAmB;AACvC,UAAM,YAAY,MAAM,KAAK,EAAE,KAAK,EAAE;AACtC,QAAI,CAAC,UAAW;AAChB,UAAM,OAAO,SAAS;AAAA,EACvB;AACA,QAAM,IAAI,MAAM,EAAE,WAAW,WAAW,MAAM,aAAa,CAAC;AAC7D;AAEA,SAAS,cAAc,OAAgC,KAAmB;AACzE,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO;AAClC,QAAI,MAAM,aAAa,IAAK,OAAM,OAAO,IAAI;AAAA,EAC9C;AACD;AAGO,SAAS,uBAA6B;AAC5C,cAAY,oBAAI,QAAQ;AACxB,kBAAgB;AACjB;AAeO,SAAS,iBAAiB,QAK9B;AACF,SAAO,OAAO,GAAG,SAAS;AAEzB,QAAI,CAAC,eAAe;AACnB,YAAM,SAAS,EAAE,IAAI,cAAc,EAAE,IAAI;AACzC,UAAI,QAAQ;AACX,cAAMA,SAAQ,IAAI,cAAc,SAAS,EAAE,GAAG,CAAC;AAC/C,YAAI;AACH,gBAAMA,OAAM,gBAAgB,MAAM;AAKlC,0BAAgB;AAAA,QACjB,SAAS,KAAK;AACb,kBAAQ,MAAM,mCAAmC,GAAG;AAAA,QACrD;AAAA,MACD,OAAO;AAEN,wBAAgB;AAAA,MACjB;AAAA,IACD;AAEA,UAAM,SAAS,EAAE,IAAI,OAAO,eAAe,GAAG,QAAQ,eAAe,EAAE;AACvE,UAAM,SAAS,EAAE,IAAI,OAAO,WAAW;AACvC,UAAM,QAAQ,UAAU;AAExB,QAAI,CAAC,OAAO;AACX,UACC,QAAQ,wBACR,EAAE,IAAI,0BAA0B,QAC/B;AACD,UAAE,IAAI,aAAa,SAAS;AAC5B,eAAO,KAAK;AAAA,MACb;AACA,aAAO,EAAE,KAAK,EAAE,OAAO,eAAe,GAAG,GAAG;AAAA,IAC7C;AAEA,UAAM,MAAM,KAAK,IAAI;AACrB,UAAM,OAAO,MAAM,UAAU,KAAK;AAClC,UAAM,QACJ,EAAE,IAAI,IAA2C,kBAClD,EAAE,IAAI;AACP,UAAM,QAAQ,SAAS,KAAK;AAC5B,UAAM,SAAS,UAAU,OAAO,MAAM,GAAG;AACzC,QAAI,QAAQ;AACX,QAAE,IAAI,aAAa,MAAM;AACzB,aAAO,KAAK;AAAA,IACb;AAEA,UAAM,QAAQ,IAAI,cAAc,SAAS,EAAE,GAAG,CAAC;AAC/C,UAAM,QAAQ,MAAM,MAAM,cAAc,IAAI;AAC5C,QAAI,CAAC,OAAO;AACX,aAAO,EAAE,KAAK,EAAE,OAAO,eAAe,GAAG,GAAG;AAAA,IAC7C;AAEA,cAAU,OAAO,MAAM,MAAM,WAAW,GAAG;AAC3C,MAAE,IAAI,aAAa,MAAM,SAAS;AAClC,WAAO,KAAK;AAAA,EACb;AACD;","names":["store"]}
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import * as hono from 'hono';
2
- import { MiddlewareHandler, Hono } from 'hono';
2
+ import { MiddlewareHandler, Hono, Context } from 'hono';
3
3
  import * as hono_types from 'hono/types';
4
4
  import * as _obsunified_types from '@obsunified/types';
5
5
  import { StoredSpan, TelemetryOverviewOptions, TelemetryOverviewResponse, TelemetryTraceDetailResponse, TelemetryInstrumentationGapsResponse, TelemetryIssueOptions, TelemetryIssueOverviewResponse, TelemetryIssueDetailResponse, UsageEventRecord, UsageOverviewOptions, UsageOverviewResponse, UsageSessionDetailResponse, UsageSessionSummary, AlertRuleInput, AlertRule, AlertStateRow, AlertState, AlertEvaluationsListResponse, AlertComparison, JsonValue, EvidenceReference, EvidenceReferenceContract, Project, IngestKey, IngestKeyWithPlaintext } from '@obsunified/types';
6
- import { b as CollectorEnv, c as CollectorRouteContext, L as Logger, C as ChildSpanRunner } from './ingest-auth-HFVO1zIp.js';
7
- export { a as CollectorApp, d as consoleLogger, e as createIngestAuth } from './ingest-auth-HFVO1zIp.js';
6
+ import { b as CollectorEnv, L as Logger, C as ChildSpanRunner, c as CollectorRouteContext } from './ingest-auth-C5-8Mly5.js';
7
+ export { a as CollectorApp, d as consoleLogger, e as createIngestAuth } from './ingest-auth-C5-8Mly5.js';
8
8
  import { ActionConfidence } from '@obsunified/types/constants';
9
9
  import { Pool } from 'pg';
10
10
 
@@ -81,6 +81,77 @@ declare class D1Adapter implements SqlDb {
81
81
  }>>;
82
82
  }
83
83
 
84
+ /**
85
+ * RFC 0002 Stage 1 — runner that executes SQL-only Analyses on the existing
86
+ * scheduled handler.
87
+ *
88
+ * runSqlAnalysis — execute one definition's `sql` against D1 and shape the
89
+ * row(s) into an AnalysisResult.
90
+ * runAllDueAnalyses — orchestrator: refresh the in-process registry into
91
+ * analysis_definitions, find which ones are due, run them,
92
+ * persist the results. Per-analysis errors are isolated
93
+ * via Promise.allSettled so one bad SQL doesn't kill the
94
+ * batch.
95
+ *
96
+ * Result shape convention: an Analysis SQL is expected to return a single
97
+ * row with the columns:
98
+ *
99
+ * status TEXT | NULL -- 'ok' | 'warn' | 'critical' | 'unknown'
100
+ * primary_value REAL | NULL
101
+ * baseline_value REAL | NULL
102
+ * payload TEXT | NULL -- JSON-serialized object, optional
103
+ *
104
+ * If `status` is NULL we derive it from the magnitude of delta_pct
105
+ * (>=25% → critical, >=10% → warn, else ok). delta_pct itself is computed
106
+ * here from primary/baseline so individual SQL queries don't have to.
107
+ */
108
+
109
+ /**
110
+ * Orchestrator. Refreshes the analysis_definitions registry from the
111
+ * in-process registry (so newly-derived Tier 1 analyses become visible
112
+ * to the dashboard within one tick), then runs whatever's due.
113
+ *
114
+ * Runs once per project in the `projects` table (falling back to the
115
+ * `default` project when the table is empty or unreadable), so analyses and
116
+ * the alert rules bound to them refresh for every tenant, not just the
117
+ * first one.
118
+ */
119
+ declare function runAllDueAnalyses(ctx: {
120
+ env: CollectorEnv;
121
+ retentionHours: number;
122
+ logger?: Logger;
123
+ tracer?: ChildSpanRunner;
124
+ }, _runtime?: unknown): Promise<{
125
+ ran: number;
126
+ failed: number;
127
+ refreshed: number;
128
+ narrated: number;
129
+ skipped?: number;
130
+ }>;
131
+
132
+ /**
133
+ * Retention for the secondary tables the hourly sweep did not cover. Each
134
+ * rule is tied to an existing contract rather than a new policy:
135
+ *
136
+ * - `alert_evaluations`: `/internal/alerts/evaluations` accepts at most a
137
+ * 720-hour window, so rows older than that are unreachable through the API.
138
+ * - `ask_evidence_events`: feeds `getTopAskedAnalyses`, whose trailing window
139
+ * is 7 days; rows older than 30 days cannot influence it.
140
+ * - `metric_series`: identity rows whose points have all expired and that
141
+ * were created before the retention window. They hold no data; the next
142
+ * ingest of that identity recreates the row.
143
+ * - `evidence_retrieval_refs`: rows carry their own `expires_at` (copied from
144
+ * the signal they reference); expansions follow their ref.
145
+ */
146
+
147
+ interface HousekeepingSummary {
148
+ alert_evaluations: number;
149
+ ask_evidence_events: number;
150
+ metric_series: number;
151
+ evidence_refs: number;
152
+ evidence_ref_expansions: number;
153
+ }
154
+
84
155
  interface ServiceMapOptions {
85
156
  projectId: string;
86
157
  hours: number;
@@ -298,6 +369,18 @@ interface CollectorConfig {
298
369
  * a tracing layer at the storage seam.
299
370
  */
300
371
  sqlDb?: SqlDbFactory;
372
+ /**
373
+ * Raw request body cap for `/v1/*` and `/internal/*` in bytes. Defaults
374
+ * to `MAX_BODY_BYTES` from the environment, then 10 MiB. Oversized
375
+ * requests get a 413 before any handler buffers the body.
376
+ */
377
+ maxBodyBytes?: number;
378
+ /**
379
+ * Body cap for the unauthenticated `/auth/*` routes (login/check/logout).
380
+ * Defaults to 16 KiB and is independent of `maxBodyBytes`, so an anonymous
381
+ * client can never make the host buffer an ingest-sized body.
382
+ */
383
+ maxAuthBodyBytes?: number;
301
384
  }
302
385
  declare class CollectorRuntime implements CollectorPluginContext {
303
386
  private readonly spanProcessors;
@@ -327,30 +410,56 @@ declare const createTelemetryCollectorApp: (pluginsOrConfig: CollectorPlugin[] |
327
410
  Bindings: CollectorEnv;
328
411
  }>;
329
412
  /**
330
- * Scheduled handler that purges expired telemetry and usage rows.
331
- * Wire into wrangler.toml: `[triggers] crons = ["0 * * * *"]`
332
- * Source: AgentOwl + DecisionOps
413
+ * Runtime-neutral entry point for a background maintenance task.
414
+ *
415
+ * `run(env)` does the work and needs nothing but a `CollectorEnv`, so any
416
+ * host (Cloudflare cron, a Node `setInterval`, a Kubernetes CronJob hitting
417
+ * a one-shot script) can drive it. `scheduled(event, env, ctx)` is the thin
418
+ * Cloudflare Workers adapter over `run` — the event and context are accepted
419
+ * for signature compatibility with `ExportedHandler.scheduled` but are never
420
+ * consulted, so non-Workers hosts should call `run` directly rather than
421
+ * fabricating placeholder event objects.
333
422
  */
423
+ interface ScheduledTaskHandler<TSummary = void> {
424
+ run(env: CollectorEnv): Promise<TSummary>;
425
+ scheduled(event: ScheduledEvent, env: CollectorEnv, ctx: ExecutionContext): Promise<void>;
426
+ }
427
+ /** Per-table purge counts reported by the retention cleanup task. */
428
+ interface RetentionCleanupSummary {
429
+ spans: number;
430
+ usage: number;
431
+ logs: number;
432
+ ai_calls: number;
433
+ metric_points: number;
434
+ analysis_results: number;
435
+ profile_blobs: number;
436
+ /** RFC 0010 action spine rows removed (trees older than RETENTION_HOURS). */
437
+ actions: number;
438
+ /** Leaf rows removed with their actions: agent_runs, tool_calls,
439
+ * retrieval_events, eval_results and artifacts. */
440
+ action_leaves: number;
441
+ /** Secondary tables (see lib/housekeeping.ts); zeros when that step failed. */
442
+ housekeeping: HousekeepingSummary;
443
+ }
334
444
  declare const createRetentionCleanupHandler: (options?: {
335
445
  logger?: Logger;
336
- }) => {
337
- scheduled(_event: ScheduledEvent, env: CollectorEnv, _ctx: ExecutionContext): Promise<void>;
338
- };
446
+ }) => ScheduledTaskHandler<RetentionCleanupSummary>;
339
447
  /**
340
448
  * RFC 0002 Stage 1 — run any due Analyses. Cheap to call frequently because
341
449
  * `runAllDueAnalyses` filters by `last_run + refreshSeconds < now` before
342
450
  * touching D1.
343
451
  *
344
- * Wire to a per-minute trigger in wrangler.toml: `crons = ["* * * * *"]`.
345
- * Kept distinct from retention so the per-minute tick doesn't sweep every
346
- * retention table every cycle.
452
+ * Wire to a per-minute trigger in wrangler.toml: `crons = ["* * * * *"]`,
453
+ * or call `.run(env)` from a non-Workers host scheduler. Kept distinct from
454
+ * retention so the per-minute tick doesn't sweep every retention table every
455
+ * cycle.
347
456
  */
348
457
  declare const createAnalysesRunHandler: (options?: {
349
458
  logger?: Logger;
350
459
  tracer?: ChildSpanRunner;
351
- }) => {
352
- scheduled(_event: ScheduledEvent, env: CollectorEnv, _ctx: ExecutionContext): Promise<void>;
353
- };
460
+ }) => ScheduledTaskHandler<AnalysesRunSummary | undefined>;
461
+ /** Shape returned by `runAllDueAnalyses` and surfaced via the run handler. */
462
+ type AnalysesRunSummary = Awaited<ReturnType<typeof runAllDueAnalyses>>;
354
463
 
355
464
  declare const actionRoutesPlugin: CollectorPlugin;
356
465
 
@@ -371,15 +480,20 @@ declare const aiReceiverPlugin: CollectorPlugin;
371
480
 
372
481
  declare const aiSpanPayloadsProcessorPlugin: CollectorPlugin;
373
482
 
483
+ /** Resolve a hostname to its addresses (e.g. `dns.lookup(host, { all: true })`). */
484
+ type HostLookup = (hostname: string) => Promise<string[]>;
485
+
374
486
  declare function evaluateAllRules(env: CollectorEnv, options?: {
375
487
  logger?: Logger;
376
488
  tracer?: ChildSpanRunner;
377
489
  concurrency?: number;
378
490
  ruleTimeoutMs?: number;
491
+ hostLookup?: HostLookup;
379
492
  }): Promise<{
380
493
  evaluated: number;
381
494
  fired: number;
382
495
  resolved: number;
496
+ redelivered: number;
383
497
  }>;
384
498
  /**
385
499
  * Scheduled handler suitable for wrangler cron triggers. Dispatches on the
@@ -666,18 +780,61 @@ declare const usersQueryRoutesPlugin: CollectorPlugin;
666
780
  declare const utmEnrichmentPlugin: CollectorPlugin;
667
781
 
668
782
  /**
669
- * Creates auth routes and middleware for dashboard password login.
783
+ * Fixed-window failure limiter for `POST /auth/login`. Only failed attempts
784
+ * count; a successful login clears the client's bucket. State is in-memory,
785
+ * so on Cloudflare Workers it is per isolate and on Node per process — it
786
+ * slows a single-origin brute force without any shared storage, and it can
787
+ * never lock an operator out for longer than one window.
788
+ */
789
+ interface LoginRateLimitOptions {
790
+ /** Failed attempts allowed per window before 429. Default 10. */
791
+ maxFailures?: number;
792
+ /** Window length in milliseconds. Default 15 minutes. */
793
+ windowMs?: number;
794
+ /** Upper bound on tracked clients; the oldest bucket is evicted past it. */
795
+ maxClients?: number;
796
+ /** Clock override for tests. */
797
+ now?: () => number;
798
+ }
799
+
800
+ interface DashboardAuthConfig {
801
+ password?: string;
802
+ token?: string;
803
+ /**
804
+ * Failed-login limiter for POST /auth/login (see login-rate-limit.ts).
805
+ * Pass `false` to disable; defaults to 10 failures per 15 minutes.
806
+ * `clientAddress` lets the host supply the connection's peer address;
807
+ * `trustProxyHeaders` honours forwarding headers from a trusted proxy.
808
+ * Without either, Workers use the edge-set client IP and every other
809
+ * runtime has no trustworthy identity: limiting is then skipped (never a
810
+ * shared bucket, which would let one client lock everyone out) and a
811
+ * warning is logged once via `warn` (default `console.warn`).
812
+ */
813
+ loginRateLimit?: (LoginRateLimitOptions & {
814
+ clientAddress?: (c: Context<{
815
+ Bindings: CollectorEnv;
816
+ }>) => string | undefined;
817
+ trustProxyHeaders?: boolean;
818
+ /** Sink for the one-time "limiter inactive" warning. */
819
+ warn?: (message: string) => void;
820
+ }) | false;
821
+ }
822
+ /**
823
+ * Creates auth routes and middleware for dashboard password login and
824
+ * programmatic read/admin bearer token authentication.
670
825
  *
671
826
  * Routes added:
672
827
  * POST /auth/login — accepts { password }, sets session cookie
673
- * GET /auth/check — returns { authenticated: true/false }
828
+ * GET /auth/check — returns { authenticated: true/false } (supports cookie and Bearer token)
674
829
  * POST /auth/logout — clears session cookie
675
830
  *
676
- * The returned middleware validates the session cookie on protected routes.
831
+ * The returned middleware validates either:
832
+ * 1. Authorization: Bearer <token> (programmatic read/admin token for MCP/API)
833
+ * 2. obs_session cookie (human dashboard user session)
834
+ *
835
+ * Ingest keys are rejected on dashboard/internal routes — they are write-only.
677
836
  */
678
- declare function createDashboardAuth(config: {
679
- password: string;
680
- }): {
837
+ declare function createDashboardAuth(config?: DashboardAuthConfig): {
681
838
  middleware: MiddlewareHandler<{
682
839
  Bindings: CollectorEnv;
683
840
  Variables: {
@@ -718,6 +875,107 @@ declare class TailHub {
718
875
  private broadcast;
719
876
  }
720
877
 
878
+ /**
879
+ * Retention for the RFC 0010 action graph (`actions` spine plus the
880
+ * `agent_runs`, `tool_calls`, `retrieval_events`, `eval_results` and
881
+ * `artifacts` leaves).
882
+ *
883
+ * Unlike the telemetry tables these rows carry no `expires_at`; the sweep
884
+ * derives expiry from `actions.started_at` and the configured
885
+ * `RETENTION_HOURS`. The unit of deletion is the causal tree
886
+ * (`project_id`, `root_action_id`): a tree is selected, rechecked and
887
+ * removed as a whole inside one `SqlDb.batch` (a D1 batch or a Postgres
888
+ * transaction), never action by action. A tree is kept whole while any of
889
+ * its actions:
890
+ *
891
+ * - started inside the retention window,
892
+ * - is still in progress (`ended_at IS NULL`),
893
+ * - belongs to an `agent_runs` row whose `status` is `running`,
894
+ * - is referenced by an `eval_cases` row in the same project via
895
+ * `source_action_id`, `source_agent_run_id`, or (through a `tool_calls`
896
+ * row) `source_tool_call_id`.
897
+ *
898
+ * Work is bounded per invocation: each batch packs whole trees up to
899
+ * `maxActionsPerBatch` actions and `maxTreesPerBatch` trees, and the sweep
900
+ * stops after `maxBatches`. A tree with more than `maxActionsPerBatch`
901
+ * actions can never be deleted atomically within those bounds, so it is
902
+ * retained intact and reported as `oversized_trees` (and logged by the
903
+ * handler) instead of being deleted in pieces. Raise `maxActionsPerBatch`
904
+ * to reclaim such trees.
905
+ *
906
+ * Concurrency: every DELETE repeats the eligibility predicate, so a tree
907
+ * that gained a fresh or unfinished action, a `running` run, or an eval
908
+ * case between selection and deletion is left alone. On D1 the batch is a
909
+ * single serialized SQLite transaction, so that recheck is airtight. On
910
+ * Postgres (READ COMMITTED) each statement sees writes committed before it
911
+ * started, so a commit landing between the leaf deletes and the final spine
912
+ * delete of the same tree could leave that tree's spine without its leaves.
913
+ * That needs an ingest for a tree whose newest action is already older than
914
+ * the retention window, inside a sub-second window; it is documented rather
915
+ * than locked against because `SqlDb` has no row-locking primitive.
916
+ *
917
+ * Intentionally **not** deleted here:
918
+ *
919
+ * - `artifacts.storage_ref` blobs — only the metadata row goes; the blob
920
+ * store is not consulted (the collector never wrote those blobs).
921
+ * - `agent_runs` rows whose `id` is not an action in the tree — nothing
922
+ * proves when they started.
923
+ * - `ai_span_payloads.action_id` and `evidence_retrieval_refs` anchors —
924
+ * those tables have their own `expires_at` retention.
925
+ * - `eval_cases`, `eval_runs`, `eval_case_results` — curated fixtures,
926
+ * never retention-bound.
927
+ */
928
+
929
+ interface ActionGraphRetentionOptions {
930
+ /** Retention window; trees whose newest action is older than this expire. */
931
+ retentionHours: number;
932
+ /** Clock override for tests. Defaults to `Date.now`. */
933
+ now?: () => number;
934
+ /**
935
+ * Upper bound on actions deleted per batch/transaction. Also the largest
936
+ * tree the sweep will delete; bigger trees are retained and reported.
937
+ * Defaults to 500.
938
+ */
939
+ maxActionsPerBatch?: number;
940
+ /** Upper bound on trees per batch (bounds statements per transaction). Defaults to 25. */
941
+ maxTreesPerBatch?: number;
942
+ /** Upper bound on batches per invocation. Defaults to 10. */
943
+ maxBatches?: number;
944
+ }
945
+ /** Per-table delete counts for one `purgeExpiredActionGraph` call. */
946
+ interface ActionGraphPurgeSummary {
947
+ actions: number;
948
+ agent_runs: number;
949
+ tool_calls: number;
950
+ retrieval_events: number;
951
+ eval_results: number;
952
+ artifacts: number;
953
+ /** Whole trees deleted. */
954
+ trees: number;
955
+ /** Batches (transactions) executed. */
956
+ batches: number;
957
+ /** `true` when `maxBatches` was hit while deletable trees remained. */
958
+ truncated: boolean;
959
+ /**
960
+ * Expired, otherwise eligible trees retained because they hold more than
961
+ * `maxActionsPerBatch` actions. Capped at `OVERSIZED_REPORT_LIMIT`.
962
+ */
963
+ oversized_trees: number;
964
+ /** Identities of the reported oversized trees, for operators. */
965
+ oversized: Array<{
966
+ project_id: string;
967
+ root_action_id: string;
968
+ actions: number;
969
+ }>;
970
+ }
971
+ /** Cutoff as the same ISO text shape the collector writes to `started_at`. */
972
+ declare const actionRetentionCutoff: (retentionHours: number, nowMs?: number) => string;
973
+ /**
974
+ * Purge expired action trees. Safe to call repeatedly: a second call right
975
+ * after the first finds no deletable trees and changes nothing.
976
+ */
977
+ declare function purgeExpiredActionGraph(db: SqlDb, options: ActionGraphRetentionOptions): Promise<ActionGraphPurgeSummary>;
978
+
721
979
  type ActionRecord = Omit<ActionRef, "projectId"> & {
722
980
  projectId: string;
723
981
  };
@@ -756,10 +1014,15 @@ declare class ActionStore {
756
1014
  listArtifactsByAction(projectId: string, actionId: string, limit?: number): Promise<ArtifactRef[]>;
757
1015
  }
758
1016
 
1017
+ interface AlertsStoreOptions {
1018
+ /** Permit webhook targets on loopback / private / link-local hosts. */
1019
+ allowPrivateWebhookTargets?: boolean;
1020
+ }
759
1021
  declare function compareValue(value: number, threshold: number, comparison: AlertComparison): boolean;
760
1022
  declare class AlertsStore {
761
1023
  private readonly db;
762
- constructor(db: SqlDb);
1024
+ private readonly options;
1025
+ constructor(db: SqlDb, options?: AlertsStoreOptions);
763
1026
  validateInput(input: AlertRuleInput): void;
764
1027
  listRules(projectId: string): Promise<AlertRule[]>;
765
1028
  getRule(id: string, projectId: string): Promise<AlertRule | null>;
@@ -767,8 +1030,24 @@ declare class AlertsStore {
767
1030
  updateRule(id: string, projectId: string, patch: Partial<AlertRuleInput>): Promise<AlertRule | null>;
768
1031
  deleteRule(id: string, projectId: string): Promise<boolean>;
769
1032
  listEnabledRules(): Promise<AlertRule[]>;
770
- getState(ruleId: string): Promise<AlertStateRow | null>;
1033
+ getState(ruleId: string, projectId: string): Promise<AlertStateRow | null>;
1034
+ /**
1035
+ * Record a state transition with its notification still outstanding
1036
+ * (`notified = 0`); `markNotified` flips it once a channel accepts the
1037
+ * alert. On a database that has not applied migration 049 the column is
1038
+ * missing: the transition is still written (legacy statement) so alerting
1039
+ * keeps working through a rolling deploy, and the evaluator treats a row
1040
+ * without the column as delivered.
1041
+ */
771
1042
  transitionState(ruleId: string, projectId: string, newState: AlertState, now: string): Promise<void>;
1043
+ /**
1044
+ * Mark the transition `(state, lastStateChange)` as delivered. Scoped to
1045
+ * the exact transition the caller notified for: if the rule changed state
1046
+ * again while the webhook was in flight, the newer transition keeps its
1047
+ * own outstanding flag. Returns false when nothing matched (stale or
1048
+ * already marked). On a pre-049 schema there is nothing to mark.
1049
+ */
1050
+ markNotified(ruleId: string, projectId: string, state: AlertState, lastStateChange: string): Promise<boolean>;
772
1051
  recordEvaluation(ruleId: string, projectId: string, value: number, state: AlertState, notified: boolean): Promise<void>;
773
1052
  listEvaluations(params: {
774
1053
  ruleId: string;
@@ -801,7 +1080,7 @@ declare class AlertsStore {
801
1080
  * - `R2BlobStore` — Cloudflare R2 binding (default on Workers).
802
1081
  * - `S3BlobStore` (sibling file) — AWS S3 / MinIO / any S3-compatible.
803
1082
  *
804
- * Replay chunks live under `replays/<session_id>/<chunk_id>`; pprof
1083
+ * Replay chunks live under `replays/<project_id>/<session_id>/<chunk_id>`; pprof
805
1084
  * profiles live under `profiles/<service>/<trace_id>/<span_id>`. The
806
1085
  * adapter never sees these paths — they're constructed by the callers
807
1086
  * (`replay-receiver`, `profile-routes`).
@@ -1152,7 +1431,7 @@ declare class ProjectsStore {
1152
1431
  ensureDefaultProject(): Promise<void>;
1153
1432
  listKeys(projectId: string): Promise<IngestKey[]>;
1154
1433
  createKey(projectId: string, name: string): Promise<IngestKeyWithPlaintext>;
1155
- revokeKey(keyId: string): Promise<boolean>;
1434
+ revokeKey(keyId: string, projectId: string): Promise<boolean>;
1156
1435
  findByKeyHash(keyHash: string): Promise<{
1157
1436
  projectId: string;
1158
1437
  keyId: string;
@@ -1184,6 +1463,23 @@ declare class PostgresAdapter implements SqlDb {
1184
1463
  };
1185
1464
  }>>;
1186
1465
  }
1466
+ /**
1467
+ * Install `pg` result-type parsers so Postgres rows match the shape D1
1468
+ * returns and the shared stores keep working unchanged:
1469
+ *
1470
+ * - BOOLEAN → 0 / 1 (D1 stores flags as INTEGER; stores compare `=== 1`)
1471
+ * - BIGINT → number (`COUNT(*)`, `SUM(bigint)` would otherwise be strings;
1472
+ * integral values above MAX_SAFE_INTEGER throw)
1473
+ * - NUMERIC → number (`SUM`, `EXTRACT(EPOCH …)`; same overflow rule)
1474
+ * - JSON/JSONB → raw text (stores `JSON.parse` the column themselves)
1475
+ *
1476
+ * Call once per process before issuing queries, e.g.
1477
+ * `configurePostgresTypeParsers(pg.types)` in the Node entrypoint.
1478
+ */
1479
+ interface PostgresTypeParserRegistry {
1480
+ setTypeParser(oid: number, parser: (value: string) => unknown): void;
1481
+ }
1482
+ declare const configurePostgresTypeParsers: (types: PostgresTypeParserRegistry) => void;
1187
1483
 
1188
1484
  /** All built-in plugins in recommended registration order */
1189
1485
  declare const allPlugins: CollectorPlugin[];
@@ -1194,4 +1490,4 @@ declare const _default: hono.Hono<{
1194
1490
  Bindings: CollectorEnv;
1195
1491
  }, hono_types.BlankSchema, "/">;
1196
1492
 
1197
- export { type ActionRecord, ActionStore, type AgentRunRecord, AlertsStore, type ArtifactRecord, type BlobListOptions, type BlobListResult, type BlobObject, type BlobPutOptions, type BlobStore, BlobStoreToR2Adapter, type CollectorAuthConfig, type CollectorConfig, CollectorEnv, type CollectorPlugin, CollectorRouteContext, CollectorRuntime, D1Adapter, type EvalCase, type EvalCaseInput, type EvalCaseListOptions, type EvalCaseSourceLinks, EvalCaseSourceNotFoundError, type EvalCaseSourceType, EvalCasesStore, type EvalResultRecord, type EvalRun, type EvalRunCandidate, type EvalRunInput, type EvalRunSourceCase, type EvalRunStatus, type EvalRunsListOptions, Logger, MetricsStore, PostgresAdapter, type PostgresAdapterOptions, ProjectsStore, R2BlobStore, type RetrievalEventRecord, S3BlobStore, type S3BlobStoreOptions, type SpanProcessorPlugin, type SqlDb, type SqlDbFactory, type SqlStatement, type TailEvent, TailHub, type TailKind, type ToolCallRecord, type UsageEventProcessorPlugin, actionRoutesPlugin, aiReceiverPlugin, aiSpanPayloadsProcessorPlugin, alertsRoutesPlugin, allPlugins, analysesRoutesPlugin, askRoutesPlugin, botFilterPlugin, compareValue, connectedRoutesPlugin, createAlertEvaluatorHandler, createAnalysesRunHandler, createDashboardAuth, createDefaultCollectorApp, createRetentionCleanupHandler, createTelemetryCollectorApp, dashboardRoutesPlugin, _default as default, defaultSpanEnrichmentPlugin, evalCasesRoutesPlugin, evaluateAllRules, evidenceRetrievalRoutesPlugin, genAiNormalizerPlugin, identityReceiverPlugin, isEvalCaseSourceType, isEvalRunStatus, issueEnrichmentPlugin, issueInsightsPlugin, logsReceiverPlugin, metricsReceiverPlugin, onboardingRoutesPlugin, otlpReceiverPlugin, platformRoutesPlugin, profileRoutesPlugin, projectsRoutesPlugin, queryRoutesPlugin, redactionProcessorPlugin, replayQueryRoutesPlugin, replayReceiverPlugin, tailRoutesPlugin, timelineRoutesPlugin, uaEnrichmentPlugin, usageNormalizationPlugin, usagePrivacyPlugin, usageQueryRoutesPlugin, usageReceiverPlugin, usersQueryRoutesPlugin, utmEnrichmentPlugin };
1493
+ export { type ActionGraphPurgeSummary, type ActionGraphRetentionOptions, type ActionRecord, ActionStore, type AgentRunRecord, AlertsStore, type AnalysesRunSummary, type ArtifactRecord, type BlobListOptions, type BlobListResult, type BlobObject, type BlobPutOptions, type BlobStore, BlobStoreToR2Adapter, type CollectorAuthConfig, type CollectorConfig, CollectorEnv, type CollectorPlugin, CollectorRouteContext, CollectorRuntime, D1Adapter, type DashboardAuthConfig, type EvalCase, type EvalCaseInput, type EvalCaseListOptions, type EvalCaseSourceLinks, EvalCaseSourceNotFoundError, type EvalCaseSourceType, EvalCasesStore, type EvalResultRecord, type EvalRun, type EvalRunCandidate, type EvalRunInput, type EvalRunSourceCase, type EvalRunStatus, type EvalRunsListOptions, type HostLookup, Logger, MetricsStore, PostgresAdapter, type PostgresAdapterOptions, type PostgresTypeParserRegistry, ProjectsStore, R2BlobStore, type RetentionCleanupSummary, type RetrievalEventRecord, S3BlobStore, type S3BlobStoreOptions, type ScheduledTaskHandler, type SpanProcessorPlugin, type SqlDb, type SqlDbFactory, type SqlStatement, type TailEvent, TailHub, type TailKind, type ToolCallRecord, type UsageEventProcessorPlugin, actionRetentionCutoff, actionRoutesPlugin, aiReceiverPlugin, aiSpanPayloadsProcessorPlugin, alertsRoutesPlugin, allPlugins, analysesRoutesPlugin, askRoutesPlugin, botFilterPlugin, compareValue, configurePostgresTypeParsers, connectedRoutesPlugin, createAlertEvaluatorHandler, createAnalysesRunHandler, createDashboardAuth, createDefaultCollectorApp, createRetentionCleanupHandler, createTelemetryCollectorApp, dashboardRoutesPlugin, _default as default, defaultSpanEnrichmentPlugin, evalCasesRoutesPlugin, evaluateAllRules, evidenceRetrievalRoutesPlugin, genAiNormalizerPlugin, identityReceiverPlugin, isEvalCaseSourceType, isEvalRunStatus, issueEnrichmentPlugin, issueInsightsPlugin, logsReceiverPlugin, metricsReceiverPlugin, onboardingRoutesPlugin, otlpReceiverPlugin, platformRoutesPlugin, profileRoutesPlugin, projectsRoutesPlugin, purgeExpiredActionGraph, queryRoutesPlugin, redactionProcessorPlugin, replayQueryRoutesPlugin, replayReceiverPlugin, tailRoutesPlugin, timelineRoutesPlugin, uaEnrichmentPlugin, usageNormalizationPlugin, usagePrivacyPlugin, usageQueryRoutesPlugin, usageReceiverPlugin, usersQueryRoutesPlugin, utmEnrichmentPlugin };