@obsunified/collector 2.3.0 → 2.4.4

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 (59) hide show
  1. package/README.md +7 -3
  2. package/dist/auth.d.ts +2 -1
  3. package/dist/auth.js +1 -1
  4. package/dist/{chunk-2FALTDCE.js → chunk-VSOZJO6Y.js} +12 -1
  5. package/dist/chunk-VSOZJO6Y.js.map +1 -0
  6. package/dist/index.d.ts +271 -62
  7. package/dist/index.js +1077 -137
  8. package/dist/index.js.map +1 -1
  9. package/dist/{ingest-auth-BmP5-f96.d.ts → ingest-auth-BTowieER.d.ts} +66 -2
  10. package/package.json +3 -3
  11. package/src/framework/collector.ts +22 -0
  12. package/src/framework/env.ts +9 -1
  13. package/src/framework/ingest-auth-warning.test.ts +71 -0
  14. package/src/index.ts +15 -0
  15. package/src/lib/alert-email.ts +104 -0
  16. package/src/lib/alerts-store.test.ts +147 -2
  17. package/src/lib/alerts-store.ts +438 -63
  18. package/src/lib/metric-alerts.test.ts +769 -0
  19. package/src/lib/metric-alerts.ts +640 -0
  20. package/src/lib/metrics-store.ts +9 -2
  21. package/src/lib/projects-store.test.ts +44 -1
  22. package/src/lib/projects-store.ts +21 -0
  23. package/src/lib/sql-db-postgres.integration.test.ts +247 -0
  24. package/src/lib/store/trace-queries.ts +1 -1
  25. package/src/lib/webhook-url.test.ts +49 -0
  26. package/src/lib/webhook-url.ts +72 -14
  27. package/src/migrations/054_alert_rules_pending_secs.sql +11 -0
  28. package/src/migrations/055_alert_state_pending_since.sql +12 -0
  29. package/src/migrations/056_metric_point_series_received.sql +9 -0
  30. package/src/migrations/057_alert_evaluations_outcome.sql +11 -0
  31. package/src/migrations/058_alert_state_pending_seen_at.sql +12 -0
  32. package/src/migrations/059_alert_evaluations_error.sql +11 -0
  33. package/src/migrations-postgres/054_alert_rules_pending_secs.sql +7 -0
  34. package/src/migrations-postgres/055_alert_state_pending_since.sql +6 -0
  35. package/src/migrations-postgres/056_metric_point_series_received.sql +7 -0
  36. package/src/migrations-postgres/057_alert_evaluations_outcome.sql +6 -0
  37. package/src/migrations-postgres/058_alert_state_pending_seen_at.sql +6 -0
  38. package/src/migrations-postgres/059_alert_evaluations_error.sql +6 -0
  39. package/src/migrations-postgres/README.md +13 -0
  40. package/src/otlp/decode/logs.ts +30 -2
  41. package/src/otlp/decode/metrics.ts +67 -2
  42. package/src/otlp/decode/traces.ts +57 -4
  43. package/src/otlp/decode/values.ts +64 -10
  44. package/src/otlp/decode.test.ts +305 -0
  45. package/src/otlp/decode.ts +9 -3
  46. package/src/plugins/alerts-evaluator-pending.test.ts +322 -0
  47. package/src/plugins/alerts-evaluator-retention.test.ts +2 -0
  48. package/src/plugins/alerts-evaluator.test.ts +10 -2
  49. package/src/plugins/alerts-evaluator.ts +239 -40
  50. package/src/plugins/alerts-routes.ts +17 -2
  51. package/src/plugins/autosre-routes.ts +9 -0
  52. package/src/plugins/logs-receiver.ts +8 -8
  53. package/src/plugins/metrics-receiver.ts +10 -9
  54. package/src/plugins/otlp-receiver.ts +10 -26
  55. package/src/plugins/profile-routes.test.ts +22 -0
  56. package/src/plugins/profile-routes.ts +11 -2
  57. package/src/plugins/replay-query-routes.test.ts +63 -1
  58. package/src/plugins/replay-query-routes.ts +40 -9
  59. package/dist/chunk-2FALTDCE.js.map +0 -1
package/README.md CHANGED
@@ -20,10 +20,14 @@ import {
20
20
  createRetentionCleanupHandler,
21
21
  } from "@obsunified/collector";
22
22
 
23
+ // Ingest keys are validated against the `ingest_keys` table. If the
24
+ // `INGEST_KEY` env binding is set, it is registered as a `bootstrap` key on
25
+ // the first request, so no option is needed for it. Dashboard auth reads
26
+ // `DASHBOARD_PASSWORD` / `DASHBOARD_TOKEN`, and CORS falls back to
27
+ // `ALLOWED_ORIGINS`, from the request env in the same way.
23
28
  const app = createDefaultCollectorApp({
24
- auth: { middleware: createIngestAuth({ secret: env.INGEST_KEY }) },
25
- dashboardAuth: createDashboardAuth({ password: env.DASHBOARD_PASSWORD }),
26
- allowedOrigins: env.ALLOWED_ORIGINS,
29
+ auth: { middleware: createIngestAuth() },
30
+ dashboardAuth: createDashboardAuth(),
27
31
  });
28
32
 
29
33
  export default {
package/dist/auth.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  import 'hono';
2
- export { n as createIngestAuth, r as resetIngestAuthCache } from './ingest-auth-BmP5-f96.js';
2
+ export { p as createIngestAuth, s as resetIngestAuthCache } from './ingest-auth-BTowieER.js';
3
+ import '@obsunified/types';
package/dist/auth.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createIngestAuth,
3
3
  resetIngestAuthCache
4
- } from "./chunk-2FALTDCE.js";
4
+ } from "./chunk-VSOZJO6Y.js";
5
5
  export {
6
6
  createIngestAuth,
7
7
  resetIngestAuthCache
@@ -146,11 +146,22 @@ var ProjectsStore = class {
146
146
  }
147
147
  return;
148
148
  }
149
+ const previous = await this.db.prepare(
150
+ `SELECT id, created_at FROM ingest_keys WHERE project_id = 'default' AND name = 'bootstrap' AND revoked_at IS NULL`
151
+ ).all();
149
152
  const id = randomHex(16);
150
153
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
151
154
  await this.db.prepare(
152
155
  `INSERT INTO ingest_keys (id, project_id, key_hash, key_prefix, name, created_at) VALUES (?, 'default', ?, ?, 'bootstrap', ?)`
153
156
  ).bind(id, keyHash, keyPrefix, createdAt).run();
157
+ const stale = previous.results ?? [];
158
+ if (stale.length > 0) {
159
+ console.warn(
160
+ `[projects-store] INGEST_KEY changed: registered a new bootstrap ingest key (${keyPrefix}\u2026) but ${stale.length} earlier bootstrap key(s) are still active and keep accepting ingest: ${stale.map((row) => `id ${row.id} (created ${row.created_at})`).join(
161
+ ", "
162
+ )}. Once every client uses the new key, revoke the old one in the dashboard (Projects \u2192 default \u2192 Keys) or with DELETE /internal/projects/default/keys/<keyId>.`
163
+ );
164
+ }
154
165
  }
155
166
  };
156
167
 
@@ -336,4 +347,4 @@ export {
336
347
  resetIngestAuthCache,
337
348
  createIngestAuth
338
349
  };
339
- //# sourceMappingURL=chunk-2FALTDCE.js.map
350
+ //# sourceMappingURL=chunk-VSOZJO6Y.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\n/**\n * Display identifier for a secret: at most 16 chars (what minted keys show),\n * but never more than a third of the secret, so a short operator-chosen\n * INGEST_KEY can never be returned in full by `keys list`.\n */\nexport function displayPrefix(plaintext: string): string {\n\treturn plaintext.slice(0, Math.min(16, Math.floor(plaintext.length / 3)));\n}\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 keyPrefix = displayPrefix(plaintext);\n\t\tconst existing = await this.db\n\t\t\t.prepare(`SELECT id, key_prefix FROM ingest_keys WHERE key_hash = ?`)\n\t\t\t.bind(keyHash)\n\t\t\t.first<{ id: string; key_prefix: string }>();\n\t\tif (existing) {\n\t\t\t// Rows written before the prefix was length-capped may hold a short\n\t\t\t// bootstrap key in full; shrink them to the safe display prefix.\n\t\t\tif (existing.key_prefix.length > keyPrefix.length) {\n\t\t\t\tawait this.db\n\t\t\t\t\t.prepare(`UPDATE ingest_keys SET key_prefix = ? WHERE id = ?`)\n\t\t\t\t\t.bind(keyPrefix, existing.id)\n\t\t\t\t\t.run();\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\t// A different, still-active bootstrap key means INGEST_KEY was rotated.\n\t\t// The old key is deliberately NOT revoked here: other instances (or\n\t\t// clients mid-rollout) may still be using it. Tell the operator instead.\n\t\tconst previous = await this.db\n\t\t\t.prepare(\n\t\t\t\t`SELECT id, created_at FROM ingest_keys WHERE project_id = 'default' AND name = 'bootstrap' AND revoked_at IS NULL`,\n\t\t\t)\n\t\t\t.all<{ id: string; created_at: string }>();\n\n\t\tconst id = randomHex(16);\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\n\t\tconst stale = previous.results ?? [];\n\t\tif (stale.length > 0) {\n\t\t\tconsole.warn(\n\t\t\t\t`[projects-store] INGEST_KEY changed: registered a new bootstrap ingest key (${keyPrefix}…) but ${stale.length} earlier bootstrap key(s) are still active and keep accepting ingest: ${stale\n\t\t\t\t\t// Ids only: legacy rows may still hold a long key_prefix.\n\t\t\t\t\t.map((row) => `id ${row.id} (created ${row.created_at})`)\n\t\t\t\t\t.join(\n\t\t\t\t\t\t\", \",\n\t\t\t\t\t)}. Once every client uses the new key, revoke the old one in the dashboard (Projects → default → Keys) or with DELETE /internal/projects/default/keys/<keyId>.`,\n\t\t\t);\n\t\t}\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":"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","names":["store"]}
package/dist/index.d.ts CHANGED
@@ -2,9 +2,9 @@ import * as hono from 'hono';
2
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
- import { StoredSpan, TelemetryOverviewOptions, TelemetryOverviewResponse, TelemetryTraceDetailResponse, TelemetryInstrumentationGapsResponse, TelemetryIssueOptions, TelemetryIssueOverviewResponse, TelemetryIssueDetailResponse, UsageEventRecord, UsageOverviewOptions, UsageOverviewResponse, UsageSessionDetailResponse, UsageSessionSummary, AlertRuleInput, AlertRule, AlertStateRow, AlertState, AlertEvaluationsListResponse, AlertComparison, ChangeBlastRadiusResult, ContentionCandidate, ChangeEvent, ChangeEventInput, InvestigationFinding, InvestigationFindingInput, OntologyNode, OntologyEdge, CanonicalNodeIdentity, OntologyGraphResponse, RelationshipCertainty, CausalTriple, IncidentInvestigation, RemediationProposal, EvidenceEntityKind, RemediationProposalInput, JsonValue, EvidenceReference, EvidenceReferenceContract, Project, IngestKey, IngestKeyWithPlaintext } from '@obsunified/types';
6
- import { e as CollectorEnv, L as Logger, c as ChildSpanRunner, j as CollectorRouteContext } from './ingest-auth-BmP5-f96.js';
7
- export { C as COLLECTOR_HOST_MANAGED_ENV_KEYS, a as COLLECTOR_OPTIONAL_STRING_ENV_KEYS, b as COLLECTOR_STRING_ENV_MANIFEST, d as CollectorApp, f as CollectorHostManagedEnvKey, g as CollectorManifestStringEnvKey, h as CollectorObjectBindingKey, i as CollectorOptionalStringEnvKey, k as CollectorStringEnvKey, l as assertCollectorEnvManifestCompleteness, m as consoleLogger, n as createIngestAuth } from './ingest-auth-BmP5-f96.js';
5
+ import { StoredSpan, TelemetryOverviewOptions, TelemetryOverviewResponse, TelemetryTraceDetailResponse, TelemetryInstrumentationGapsResponse, TelemetryIssueOptions, TelemetryIssueOverviewResponse, TelemetryIssueDetailResponse, UsageEventRecord, UsageOverviewOptions, UsageOverviewResponse, UsageSessionDetailResponse, UsageSessionSummary, AlertRuleInput, AlertRule, AlertStateRow, AlertState, AlertEvaluationOutcome, AlertEvaluationsListResponse, AlertComparison, ChangeBlastRadiusResult, ContentionCandidate, ChangeEvent, ChangeEventInput, InvestigationFinding, InvestigationFindingInput, OntologyNode, OntologyEdge, CanonicalNodeIdentity, OntologyGraphResponse, RelationshipCertainty, CausalTriple, IncidentInvestigation, RemediationProposal, EvidenceEntityKind, RemediationProposalInput, JsonValue, EvidenceReference, EvidenceReferenceContract, AlertQueryMetrics, AlertMetricAggregation, AlertMetricField, Project, IngestKey, IngestKeyWithPlaintext } from '@obsunified/types';
6
+ import { g as CollectorEnv, L as Logger, e as ChildSpanRunner, l as CollectorRouteContext, a as AlertEmailTransport } from './ingest-auth-BTowieER.js';
7
+ export { A as AlertEmailMessage, b as AlertNotificationPayload, C as COLLECTOR_HOST_MANAGED_ENV_KEYS, c as COLLECTOR_OPTIONAL_STRING_ENV_KEYS, d as COLLECTOR_STRING_ENV_MANIFEST, f as CollectorApp, h as CollectorHostManagedEnvKey, i as CollectorManifestStringEnvKey, j as CollectorObjectBindingKey, k as CollectorOptionalStringEnvKey, m as CollectorStringEnvKey, n as assertCollectorEnvManifestCompleteness, o as consoleLogger, p as createIngestAuth, q as formatAlertEmail, r as isValidEmailAddress } from './ingest-auth-BTowieER.js';
8
8
  import { ActionConfidence } from '@obsunified/types/constants';
9
9
  import { Pool } from 'pg';
10
10
 
@@ -389,6 +389,13 @@ interface CollectorConfig {
389
389
  * client can never make the host buffer an ingest-sized body.
390
390
  */
391
391
  maxAuthBodyBytes?: number;
392
+ /**
393
+ * Acknowledge that this app runs without `auth` on `/v1/*` (e.g. a local
394
+ * sandbox behind a trusted proxy). It changes nothing about request
395
+ * handling; it only silences the one-time warning logged when the first
396
+ * ingest request reaches an app that has no ingest auth configured.
397
+ */
398
+ allowUnauthenticatedIngest?: boolean;
392
399
  }
393
400
  declare class CollectorRuntime implements CollectorPluginContext {
394
401
  private readonly spanProcessors;
@@ -488,15 +495,178 @@ declare const aiReceiverPlugin: CollectorPlugin;
488
495
 
489
496
  declare const aiSpanPayloadsProcessorPlugin: CollectorPlugin;
490
497
 
498
+ /** A rule's value, and whether a metrics rule found no fresh data. */
499
+ interface AlertRuleEvaluation {
500
+ value: number;
501
+ noData: boolean;
502
+ }
503
+
504
+ interface AlertsStoreOptions {
505
+ /** Permit webhook targets on loopback / private / link-local hosts. */
506
+ allowPrivateWebhookTargets?: boolean;
507
+ /**
508
+ * Whether this collector can send email (a transport is bound as
509
+ * `env.ALERT_EMAIL`). Rules with an email channel are refused otherwise.
510
+ */
511
+ emailDeliveryAvailable?: boolean;
512
+ }
513
+ declare function compareValue(value: number, threshold: number, comparison: AlertComparison): boolean;
514
+ declare class AlertsStore {
515
+ private readonly db;
516
+ private readonly options;
517
+ constructor(db: SqlDb, options?: AlertsStoreOptions);
518
+ validateInput(input: AlertRuleInput): void;
519
+ listRules(projectId: string): Promise<AlertRule[]>;
520
+ /**
521
+ * The newest evaluation of every rule in the project, one query. Falls
522
+ * back to the pre-057/059 column set on older databases.
523
+ */
524
+ private lastEvaluations;
525
+ getRule(id: string, projectId: string): Promise<AlertRule | null>;
526
+ createRule(projectId: string, input: AlertRuleInput): Promise<AlertRule>;
527
+ updateRule(id: string, projectId: string, patch: Partial<AlertRuleInput>): Promise<AlertRule | null>;
528
+ /**
529
+ * Clears a rule's pending period. Tolerates databases that have not
530
+ * applied 055/058 (there is nothing to clear on them).
531
+ */
532
+ resetPending(ruleId: string, projectId: string): Promise<void>;
533
+ /**
534
+ * Runs `current`; on a database that has not applied migration 054 (no
535
+ * `alert_rules.pending_secs`) a rule without a pending duration is
536
+ * written with the legacy statement so rolling deploys keep working. A
537
+ * rule that needs the column is refused with a clear message instead of
538
+ * silently losing its pending duration.
539
+ */
540
+ private withPendingColumnFallback;
541
+ deleteRule(id: string, projectId: string): Promise<boolean>;
542
+ listEnabledRules(): Promise<AlertRule[]>;
543
+ getState(ruleId: string, projectId: string): Promise<AlertStateRow | null>;
544
+ /**
545
+ * Record a state transition with its notification still outstanding
546
+ * (`notified = 0`); `markNotified` flips it once a channel accepts the
547
+ * alert. On a database that has not applied migration 049 the column is
548
+ * missing: the transition is still written (legacy statement) so alerting
549
+ * keeps working through a rolling deploy, and the evaluator treats a row
550
+ * without the column as delivered.
551
+ */
552
+ transitionState(ruleId: string, projectId: string, newState: AlertState, now: string): Promise<void>;
553
+ /**
554
+ * Mark the transition `(state, lastStateChange)` as delivered. Scoped to
555
+ * the exact transition the caller notified for: if the rule changed state
556
+ * again while the webhook was in flight, the newer transition keeps its
557
+ * own outstanding flag. Returns false when nothing matched (stale or
558
+ * already marked). On a pre-049 schema there is nothing to mark.
559
+ */
560
+ markNotified(ruleId: string, projectId: string, state: AlertState, lastStateChange: string): Promise<boolean>;
561
+ /**
562
+ * Record (`{ since, seenAt }`) or clear (`null`) the pending period of a
563
+ * rule that has a pending duration (migrations 055, 058): when the
564
+ * condition started holding, and the last evaluation that saw it hold.
565
+ * A rule without a state row gets one in state `ok` since its creation,
566
+ * already delivered (nothing is owed for a rule that never changed
567
+ * state).
568
+ */
569
+ setPending(ruleId: string, projectId: string, pending: {
570
+ since: string;
571
+ seenAt: string;
572
+ } | null, ruleCreatedAt: string): Promise<void>;
573
+ recordEvaluation(ruleId: string, projectId: string, value: number, state: AlertState, notified: boolean, outcome?: AlertEvaluationOutcome, errorMessage?: string): Promise<void>;
574
+ listEvaluations(params: {
575
+ ruleId: string;
576
+ projectId?: string;
577
+ hours: number;
578
+ limit?: number;
579
+ }): Promise<AlertEvaluationsListResponse>;
580
+ evaluateRule(rule: AlertRule, now?: Date): Promise<number>;
581
+ /** Like `evaluateRule`, plus whether a metrics rule found no fresh data. */
582
+ evaluateRuleWithStatus(rule: AlertRule, now?: Date): Promise<AlertRuleEvaluation>;
583
+ private evaluateCountRule;
584
+ /**
585
+ * Stage 6 — fetch the latest narrative associated with this rule's
586
+ * bound analysis. Used by the evaluator when firing an analysis-bound
587
+ * alert so the webhook payload includes the human-readable
588
+ * narrative, not just a threshold crossing.
589
+ */
590
+ getAnalysisNarrative(projectId: string, analysisId: string): Promise<{
591
+ narrative: string | null;
592
+ status: string | null;
593
+ } | null>;
594
+ private evaluateSpanRule;
595
+ private evaluateLogRule;
596
+ private evaluateUsageRule;
597
+ private evaluateAIRule;
598
+ }
599
+
491
600
  /** Resolve a hostname to its addresses (e.g. `dns.lookup(host, { all: true })`). */
492
601
  type HostLookup = (hostname: string) => Promise<string[]>;
493
602
 
603
+ interface AlertEvaluatorStore {
604
+ listEnabledRules(): Promise<AlertRule[]>;
605
+ evaluateRule(rule: AlertRule): Promise<number>;
606
+ /** Preferred when present: also says whether a metrics rule had no data. */
607
+ evaluateRuleWithStatus?(rule: AlertRule): Promise<AlertRuleEvaluation>;
608
+ getState(ruleId: string, projectId: string): Promise<{
609
+ current_state: AlertState;
610
+ last_state_change: string;
611
+ /** 0 while the transition's notification is owed; absent pre-049 (= delivered). */
612
+ notified?: number;
613
+ /** Start of the current pending period (migration 055); null/absent when not pending. */
614
+ pending_since?: string | null;
615
+ /** Last evaluation that saw the pending condition hold (migration 058). */
616
+ pending_seen_at?: string | null;
617
+ } | null>;
618
+ /** Record (`{ since, seenAt }`) or clear (null) the rule's pending period. */
619
+ setPending(ruleId: string, projectId: string, pending: {
620
+ since: string;
621
+ seenAt: string;
622
+ } | null, ruleCreatedAt: string): Promise<void>;
623
+ /** Flip the exact transition `(state, lastStateChange)` to delivered. */
624
+ markNotified(ruleId: string, projectId: string, state: AlertState, lastStateChange: string): Promise<boolean>;
625
+ getAnalysisNarrative(projectId: string, analysisId: string): Promise<{
626
+ narrative: string | null;
627
+ status: string | null;
628
+ } | null>;
629
+ transitionState(ruleId: string, projectId: string, newState: AlertState, now: string): Promise<void>;
630
+ recordEvaluation(ruleId: string, projectId: string, value: number, state: AlertState, notified: boolean, outcome?: AlertEvaluationOutcome,
631
+ /** With outcome `error`: why (the store sanitizes it). */
632
+ errorMessage?: string): Promise<void>;
633
+ }
634
+ declare function evaluateRuleBatch(store: AlertEvaluatorStore, rules: AlertRule[], options?: {
635
+ logger?: Logger;
636
+ tracer?: ChildSpanRunner;
637
+ concurrency?: number;
638
+ ruleTimeoutMs?: number;
639
+ allowPrivateWebhookTargets?: boolean;
640
+ hostLookup?: HostLookup;
641
+ email?: AlertEmailTransport;
642
+ onAlertFiring?: (rule: AlertRule) => Promise<void>;
643
+ /**
644
+ * Longest gap between two evaluations that still continues a pending
645
+ * hold. Default `DEFAULT_MAX_EVALUATION_GAP_MS` (two 5-minute ticks);
646
+ * pass twice the host's evaluation interval.
647
+ */
648
+ maxEvaluationGapMs?: number;
649
+ /**
650
+ * ISO start of this collector process. A hold last seen before it
651
+ * starts over, as Prometheus resets `for` on restart. Unset on hosts
652
+ * without a long-lived process (Workers), where the gap rule applies.
653
+ */
654
+ holdStartedAt?: string;
655
+ }): Promise<{
656
+ evaluated: number;
657
+ fired: number;
658
+ resolved: number;
659
+ /** Notifications for an earlier transition that were delivered on this tick. */
660
+ redelivered: number;
661
+ }>;
494
662
  declare function evaluateAllRules(env: CollectorEnv, options?: {
495
663
  logger?: Logger;
496
664
  tracer?: ChildSpanRunner;
497
665
  concurrency?: number;
498
666
  ruleTimeoutMs?: number;
499
667
  hostLookup?: HostLookup;
668
+ maxEvaluationGapMs?: number;
669
+ holdStartedAt?: string;
500
670
  }): Promise<{
501
671
  evaluated: number;
502
672
  fired: number;
@@ -1024,64 +1194,6 @@ declare class ActionStore {
1024
1194
  listArtifactsByAction(projectId: string, actionId: string, limit?: number): Promise<ArtifactRef[]>;
1025
1195
  }
1026
1196
 
1027
- interface AlertsStoreOptions {
1028
- /** Permit webhook targets on loopback / private / link-local hosts. */
1029
- allowPrivateWebhookTargets?: boolean;
1030
- }
1031
- declare function compareValue(value: number, threshold: number, comparison: AlertComparison): boolean;
1032
- declare class AlertsStore {
1033
- private readonly db;
1034
- private readonly options;
1035
- constructor(db: SqlDb, options?: AlertsStoreOptions);
1036
- validateInput(input: AlertRuleInput): void;
1037
- listRules(projectId: string): Promise<AlertRule[]>;
1038
- getRule(id: string, projectId: string): Promise<AlertRule | null>;
1039
- createRule(projectId: string, input: AlertRuleInput): Promise<AlertRule>;
1040
- updateRule(id: string, projectId: string, patch: Partial<AlertRuleInput>): Promise<AlertRule | null>;
1041
- deleteRule(id: string, projectId: string): Promise<boolean>;
1042
- listEnabledRules(): Promise<AlertRule[]>;
1043
- getState(ruleId: string, projectId: string): Promise<AlertStateRow | null>;
1044
- /**
1045
- * Record a state transition with its notification still outstanding
1046
- * (`notified = 0`); `markNotified` flips it once a channel accepts the
1047
- * alert. On a database that has not applied migration 049 the column is
1048
- * missing: the transition is still written (legacy statement) so alerting
1049
- * keeps working through a rolling deploy, and the evaluator treats a row
1050
- * without the column as delivered.
1051
- */
1052
- transitionState(ruleId: string, projectId: string, newState: AlertState, now: string): Promise<void>;
1053
- /**
1054
- * Mark the transition `(state, lastStateChange)` as delivered. Scoped to
1055
- * the exact transition the caller notified for: if the rule changed state
1056
- * again while the webhook was in flight, the newer transition keeps its
1057
- * own outstanding flag. Returns false when nothing matched (stale or
1058
- * already marked). On a pre-049 schema there is nothing to mark.
1059
- */
1060
- markNotified(ruleId: string, projectId: string, state: AlertState, lastStateChange: string): Promise<boolean>;
1061
- recordEvaluation(ruleId: string, projectId: string, value: number, state: AlertState, notified: boolean): Promise<void>;
1062
- listEvaluations(params: {
1063
- ruleId: string;
1064
- projectId?: string;
1065
- hours: number;
1066
- limit?: number;
1067
- }): Promise<AlertEvaluationsListResponse>;
1068
- evaluateRule(rule: AlertRule): Promise<number>;
1069
- /**
1070
- * Stage 6 — fetch the latest narrative associated with this rule's
1071
- * bound analysis. Used by the evaluator when firing an analysis-bound
1072
- * alert so the webhook payload includes the human-readable
1073
- * narrative, not just a threshold crossing.
1074
- */
1075
- getAnalysisNarrative(projectId: string, analysisId: string): Promise<{
1076
- narrative: string | null;
1077
- status: string | null;
1078
- } | null>;
1079
- private evaluateSpanRule;
1080
- private evaluateLogRule;
1081
- private evaluateUsageRule;
1082
- private evaluateAIRule;
1083
- }
1084
-
1085
1197
  declare function recordChangeEvent(db: SqlDb, projectId: string, input: ChangeEventInput): Promise<ChangeEvent>;
1086
1198
  declare function listChangeEvents(db: SqlDb, options: {
1087
1199
  projectId: string;
@@ -1698,6 +1810,103 @@ declare class EvalCasesStore {
1698
1810
  private buildMetadata;
1699
1811
  }
1700
1812
 
1813
+ /**
1814
+ * `metrics` alert signal: validation and evaluation of an
1815
+ * `AlertQueryMetrics` against `metric_series` / `metric_point`.
1816
+ *
1817
+ * Evaluation, per selector (numerator, then the optional denominator):
1818
+ *
1819
+ * 1. Series: `metric_series` rows of the project whose name is one of the
1820
+ * selector's names (and whose `service_name` matches, when given), kept
1821
+ * when every listed data-point / resource attribute equals the rule's
1822
+ * value. Attribute values are compared as strings, so `200` matches a
1823
+ * stored `"200"` and vice versa.
1824
+ * 2. Points: the series' points whose `received_at` (the collector's clock,
1825
+ * as for every other alert signal) is inside the rule window, ordered by
1826
+ * producer time (`ts_ns`). The number read is `field` (`value`, or a
1827
+ * histogram/summary `count` / `sum`); points without it are skipped.
1828
+ * 3. Staleness: for `last`, `min`, `max`, `avg` and `sum` a series takes
1829
+ * part only when its newest point is at most `staleSecs` (default 300)
1830
+ * old, so a series that stopped reporting (a renamed host, a replaced
1831
+ * pod) no longer adds its last value. `increase` / `rate` keep every
1832
+ * series with points in the window: growth that happened is real even
1833
+ * if the series stopped afterwards.
1834
+ * 4. Each series is reduced on its own (`reduceSeries`), and the per-series
1835
+ * results are summed. A series with no usable point adds nothing.
1836
+ *
1837
+ * When no series takes part (in the numerator or the denominator) the
1838
+ * result is "no data": the value is 0 and `noData` is true. The rule's
1839
+ * `noData` policy decides what that means (`zero`: evaluate 0 as usual;
1840
+ * `keep`: leave the alert state as it is).
1841
+ *
1842
+ * `age_seconds` ignores the window: it is the seconds since the newest
1843
+ * `received_at` of any matching series. When no matching series has any
1844
+ * stored point (never reported, or everything aged out of retention) it is
1845
+ * the seconds since the rule was created, a finite number that grows until
1846
+ * the threshold is crossed, so "never reported" fires after the same grace
1847
+ * period as "stopped reporting".
1848
+ *
1849
+ * `age_seconds` is never "no data".
1850
+ *
1851
+ * Ratio: with a `denominator`, value = numerator / denominator when the
1852
+ * denominator is > 0 and >= minDenominator (default 0), else 0.
1853
+ *
1854
+ * Cost bounds: at most `MAX_MATCHED_SERIES` series and `MAX_LOADED_POINTS`
1855
+ * points per selector; past either the evaluation fails with an error (the
1856
+ * evaluator records it as an `error` evaluation) rather than returning a
1857
+ * value computed from part of the data. Metric rule windows are at most
1858
+ * `MAX_METRIC_WINDOW_MINS`.
1859
+ */
1860
+
1861
+ /** Throws an Error with a user-facing message when `query` is malformed. */
1862
+ declare function validateMetricQuery(query: unknown): void;
1863
+ interface MetricPointSample {
1864
+ tsNs: string;
1865
+ startTsNs: string | null;
1866
+ value: number | null;
1867
+ count: number | null;
1868
+ sum: number | null;
1869
+ receivedAt: string;
1870
+ }
1871
+ interface SeriesReduceOptions {
1872
+ /**
1873
+ * The series has no history before the window (its `metric_series` row
1874
+ * was created inside it): a cumulative counter's first point then counts
1875
+ * as growth from 0 instead of being the baseline.
1876
+ */
1877
+ bornInWindow?: boolean;
1878
+ aggregation: Exclude<AlertMetricAggregation, "age_seconds">;
1879
+ field: AlertMetricField;
1880
+ /** `metric_series.temporality`: 1 delta, 2 cumulative, null for gauges. */
1881
+ temporality: number | null;
1882
+ /** Start of the window in Unix nanoseconds. */
1883
+ windowStartNs: bigint;
1884
+ windowSecs: number;
1885
+ }
1886
+ /**
1887
+ * Reduces one series' window points to a number, or null when the series
1888
+ * has no point carrying `field` in the window.
1889
+ */
1890
+ declare function reduceSeries(points: MetricPointSample[], opts: SeriesReduceOptions): number | null;
1891
+ interface MetricEvalContext {
1892
+ projectId: string;
1893
+ windowMins: number;
1894
+ /** The rule's `createdAt`; the reference for "never reported". */
1895
+ ruleCreatedAt: string;
1896
+ now: Date;
1897
+ /** Staleness bound for gauge-style aggregations; default 300. */
1898
+ staleSecs?: number;
1899
+ /** Point cap per selector; default MAX_LOADED_POINTS (tests lower it). */
1900
+ maxLoadedPoints?: number;
1901
+ }
1902
+ interface MetricEvaluation {
1903
+ value: number;
1904
+ /** No series took part; see the module comment. Never true for age_seconds. */
1905
+ noData: boolean;
1906
+ }
1907
+ /** Evaluates a validated metrics query to a finite number. */
1908
+ declare function evaluateMetricQuery(db: SqlDb, query: AlertQueryMetrics, ctx: MetricEvalContext): Promise<MetricEvaluation>;
1909
+
1701
1910
  type MetricType = "gauge" | "sum" | "histogram" | "exp_histogram" | "summary";
1702
1911
  /**
1703
1912
  * A metric data point normalized for storage. Series-identifying fields
@@ -1855,4 +2064,4 @@ declare const _default: hono.Hono<{
1855
2064
  Bindings: CollectorEnv;
1856
2065
  }, hono_types.BlankSchema, "/">;
1857
2066
 
1858
- 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, CounterfactualVerificationError, 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, RemediationGateError, 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, type WatchdogEvaluationSummary, acquireIncidentLease, actionRetentionCutoff, actionRoutesPlugin, aiReceiverPlugin, aiSpanPayloadsProcessorPlugin, alertsRoutesPlugin, allPlugins, analysesRoutesPlugin, approveRemediationProposal, askRoutesPlugin, autosreRoutesPlugin, botFilterPlugin, claimRemediationGrant, compareValue, completeRemediationProposal, computeChangeBlastRadius, computeTarjanScc, configurePostgresTypeParsers, connectedRoutesPlugin, createAlertEvaluatorHandler, createAnalysesRunHandler, createDashboardAuth, createDefaultCollectorApp, createRetentionCleanupHandler, createTelemetryCollectorApp, dashboardRoutesPlugin, _default as default, defaultSpanEnrichmentPlugin, dispatchIncidentWebhook, evalCasesRoutesPlugin, evaluateAllRules, evaluateRemediationWatchdogs, evidenceRetrievalRoutesPlugin, extractSpanLineageAndTopology, failRemediationProposal, findCrossTraceContention, genAiNormalizerPlugin, getChangeEvent, getIncidentInvestigation, getInvestigationFinding, getOntologyGraph, getRemediationProposal, identityReceiverPlugin, isEvalCaseSourceType, isEvalRunStatus, issueEnrichmentPlugin, issueInsightsPlugin, listChangeEvents, listIncidentInvestigations, listRemediationProposals, loadTraceFaultLocusFromDb, localizeTraceFaultLocus, logsReceiverPlugin, metricsReceiverPlugin, onboardingRoutesPlugin, openOrDeduplicateIncident, otlpReceiverPlugin, platformRoutesPlugin, profileRoutesPlugin, projectsRoutesPlugin, proposeRemediationAction, purgeExpiredActionGraph, queryRoutesPlugin, recordChangeEvent, recordInvestigationFinding, redactionProcessorPlugin, rejectRemediationProposal, replayQueryRoutesPlugin, replayReceiverPlugin, searchInvestigationFindings, tailRoutesPlugin, timelineRoutesPlugin, uaEnrichmentPlugin, upsertOntologyFromSpans, usageNormalizationPlugin, usagePrivacyPlugin, usageQueryRoutesPlugin, usageReceiverPlugin, usersQueryRoutesPlugin, utmEnrichmentPlugin, verifyPairedCounterfactualProof };
2067
+ export { type ActionGraphPurgeSummary, type ActionGraphRetentionOptions, type ActionRecord, ActionStore, type AgentRunRecord, AlertEmailTransport, 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, CounterfactualVerificationError, 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, RemediationGateError, 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, type WatchdogEvaluationSummary, acquireIncidentLease, actionRetentionCutoff, actionRoutesPlugin, aiReceiverPlugin, aiSpanPayloadsProcessorPlugin, alertsRoutesPlugin, allPlugins, analysesRoutesPlugin, approveRemediationProposal, askRoutesPlugin, autosreRoutesPlugin, botFilterPlugin, claimRemediationGrant, compareValue, completeRemediationProposal, computeChangeBlastRadius, computeTarjanScc, configurePostgresTypeParsers, connectedRoutesPlugin, createAlertEvaluatorHandler, createAnalysesRunHandler, createDashboardAuth, createDefaultCollectorApp, createRetentionCleanupHandler, createTelemetryCollectorApp, dashboardRoutesPlugin, _default as default, defaultSpanEnrichmentPlugin, dispatchIncidentWebhook, evalCasesRoutesPlugin, evaluateAllRules, evaluateMetricQuery, evaluateRemediationWatchdogs, evaluateRuleBatch, evidenceRetrievalRoutesPlugin, extractSpanLineageAndTopology, failRemediationProposal, findCrossTraceContention, genAiNormalizerPlugin, getChangeEvent, getIncidentInvestigation, getInvestigationFinding, getOntologyGraph, getRemediationProposal, identityReceiverPlugin, isEvalCaseSourceType, isEvalRunStatus, issueEnrichmentPlugin, issueInsightsPlugin, listChangeEvents, listIncidentInvestigations, listRemediationProposals, loadTraceFaultLocusFromDb, localizeTraceFaultLocus, logsReceiverPlugin, metricsReceiverPlugin, onboardingRoutesPlugin, openOrDeduplicateIncident, otlpReceiverPlugin, platformRoutesPlugin, profileRoutesPlugin, projectsRoutesPlugin, proposeRemediationAction, purgeExpiredActionGraph, queryRoutesPlugin, recordChangeEvent, recordInvestigationFinding, redactionProcessorPlugin, reduceSeries, rejectRemediationProposal, replayQueryRoutesPlugin, replayReceiverPlugin, searchInvestigationFindings, tailRoutesPlugin, timelineRoutesPlugin, uaEnrichmentPlugin, upsertOntologyFromSpans, usageNormalizationPlugin, usagePrivacyPlugin, usageQueryRoutesPlugin, usageReceiverPlugin, usersQueryRoutesPlugin, utmEnrichmentPlugin, validateMetricQuery, verifyPairedCounterfactualProof };