@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
@@ -0,0 +1,37 @@
1
+ -- Migration 048: telemetry_spans composite primary key
2
+ -- (project_id, trace_id, span_id)
3
+ --
4
+ -- Mirrors ../migrations/048_telemetry_spans_project_composite_pk.sql. 001
5
+ -- keyed spans by (trace_id, span_id); 010 added project_id outside the key,
6
+ -- so ingest's INSERT OR IGNORE (ON CONFLICT DO NOTHING here) silently
7
+ -- dropped a span whose ids already existed under another project. Swaps the
8
+ -- key for (project_id, trace_id, span_id) in place, keeping every row and
9
+ -- index. The old key was unique, so the composite key cannot collide.
10
+ --
11
+ -- Idempotent: does nothing when the key is already composite.
12
+ --
13
+ -- Operational note: ADD PRIMARY KEY builds a new unique index under an
14
+ -- ACCESS EXCLUSIVE lock. Retention keeps the table bounded, but on a busy
15
+ -- database apply it in a quiet window.
16
+
17
+ UPDATE telemetry_spans SET project_id = 'default' WHERE project_id IS NULL;
18
+ ALTER TABLE telemetry_spans ALTER COLUMN project_id SET NOT NULL;
19
+
20
+ DO $$
21
+ DECLARE
22
+ pk_name TEXT;
23
+ pk_def TEXT;
24
+ BEGIN
25
+ SELECT c.conname, pg_get_constraintdef(c.oid)
26
+ INTO pk_name, pk_def
27
+ FROM pg_constraint c
28
+ WHERE c.conrelid = 'telemetry_spans'::regclass AND c.contype = 'p';
29
+
30
+ IF pk_def IS DISTINCT FROM 'PRIMARY KEY (project_id, trace_id, span_id)' THEN
31
+ IF pk_name IS NOT NULL THEN
32
+ EXECUTE format('ALTER TABLE telemetry_spans DROP CONSTRAINT %I', pk_name);
33
+ END IF;
34
+ ALTER TABLE telemetry_spans ADD PRIMARY KEY (project_id, trace_id, span_id);
35
+ END IF;
36
+ END
37
+ $$;
@@ -0,0 +1,10 @@
1
+ -- Migration 049: alert_state.notified
2
+ --
3
+ -- Mirrors ../migrations/049_alert_state_notified.sql. INTEGER 0/1 like the
4
+ -- sibling alert_evaluations.notified (not BOOLEAN), so the shared store SQL
5
+ -- can test and set it the same way on both backends.
6
+ --
7
+ -- Backfill: existing rows default to 1 (delivered); the pre-049 evaluator
8
+ -- only stored a transition after the channels accepted it. Idempotent.
9
+
10
+ ALTER TABLE alert_state ADD COLUMN IF NOT EXISTS notified INTEGER NOT NULL DEFAULT 1;
@@ -32,9 +32,62 @@ remaining migrations are tracked in
32
32
  [`docs/postgres-migration.md`](../../../../docs/postgres-migration.md); they'll
33
33
  land alongside the first hosted-Postgres deployment.
34
34
 
35
- ## What's NOT translated
35
+ ## Column types the stores depend on
36
36
 
37
- `009_projects_and_keys.sql` uses SQLite's `BLOB` type for the hashed API key.
38
- Postgres uses `BYTEA` and the column-name rewrite is in
39
- `009_projects_and_keys.sql` here (a non-mechanical translation; review carefully
40
- when porting forward).
37
+ - `ingest_keys.key_hash` is `TEXT` on both backends. `ProjectsStore` binds the
38
+ SHA-256 hex digest as a string; an earlier Postgres version of `009` declared
39
+ the column `BYTEA`, which silently stored the ASCII bytes of the hex text and
40
+ made the `026` seed key unreachable. `041_ingest_key_hash_text.sql` converts
41
+ databases migrated with that version back to `TEXT` (fresh installs get
42
+ `TEXT` from `009` directly and `041` is a no-op). The D1 `041` file is an
43
+ intentional no-op that only keeps the numbering in parity.
44
+ - Timestamp columns (`received_at`, `expires_at`, …) are `TEXT` holding
45
+ `Date#toISOString()` output. Postgres will not compare `text` to
46
+ `timestamptz`, so `postgresDialect` and `translateD1Sql` render every time
47
+ window as ISO text (`to_char(…, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`).
48
+ `metric_exemplars` originally declared `received_at`/`expires_at` as
49
+ `TIMESTAMPTZ`, which made `MetricsStore.purgeExpired` fail with
50
+ `operator does not exist: timestamp with time zone < text`;
51
+ `043_metric_exemplars_text_timestamps.sql` converts those columns to UTC
52
+ ISO `TEXT` in place (fresh installs get `TEXT` from `035` and `043` is a
53
+ no-op; the D1 `043` is a numbering no-op).
54
+ - `actions.started_at` is ISO `TEXT` and drives action-graph retention (the
55
+ spine has no `expires_at`). `044_actions_started_at_index.sql` adds
56
+ `idx_actions_started_at` on both backends so the hourly sweep reads expired
57
+ trees in index order. Deletes run per tree inside `PostgresAdapter.batch`
58
+ (one transaction) and re-check eligibility in every statement, but under
59
+ READ COMMITTED a concurrent insert into an already-expired tree can still
60
+ land between that tree's leaf deletes and its spine delete; see the header
61
+ of `src/lib/action-retention.ts`.
62
+ - Row keys are project-scoped. `045`–`048` swap the single-column primary
63
+ keys for composite ones in place (`user_profiles (project_id, user_id)`;
64
+ `actions`, `agent_runs`, `tool_calls`, `retrieval_events`, `eval_results`,
65
+ `artifacts (project_id, id)`; `ai_span_payloads` and
66
+ `telemetry_spans (project_id, trace_id, span_id)`). Each file looks up the
67
+ current primary key by definition and is a no-op once the key is
68
+ composite; the D1 siblings rebuild the tables through `*_new` copies. The
69
+ stores upsert with `ON CONFLICT (project_id, …)`, so both backends must
70
+ carry these migrations before deploying collector 2.1.
71
+ - `049` adds `alert_state.notified INTEGER` (0/1, like
72
+ `alert_evaluations.notified`): whether the current state's transition has
73
+ been delivered to the rule's channels. Existing rows backfill to 1. The
74
+ store falls back to the pre-049 statement when the column is missing, so
75
+ the collector keeps evaluating during a rolling deploy, but it cannot
76
+ retry undelivered notifications until 049 is applied.
77
+ - Flags (`is_error`, `is_bot`, `payload_capture_enabled`) are `BOOLEAN` here
78
+ and `INTEGER 0/1` on D1. Shared SQL must test them as bare booleans
79
+ (`CASE WHEN is_error THEN 1 ELSE 0 END`, never `is_error = 1` or
80
+ `SUM(is_error)`). Hosts call `configurePostgresTypeParsers(pg.types)` so
81
+ BOOLEAN reads back as `0/1`, BIGINT/NUMERIC as numbers and JSON/JSONB as
82
+ text, matching what D1 returns.
83
+
84
+ ## Verifying against a real database
85
+
86
+ `src/lib/sql-db-postgres.integration.test.ts` applies this directory into an
87
+ empty scratch database, then exercises ingest-key auth, AI/usage ingest, the
88
+ retention purge and every built-in analysis through `PostgresAdapter`:
89
+
90
+ ```bash
91
+ OBS_PG_TEST_URL=postgres://obs:obs@127.0.0.1:5432/obs_scratch \
92
+ pnpm --filter @obsunified/collector exec vitest run src/lib/sql-db-postgres.integration.test.ts
93
+ ```
@@ -0,0 +1,76 @@
1
+ import { gzipSync } from "node:zlib";
2
+ import { Hono } from "hono";
3
+ import { describe, expect, it } from "vitest";
4
+ import { OtlpDecodeError, type ReadBodyLimits, readOtlpBody } from "./body";
5
+
6
+ const MIB = 1024 * 1024;
7
+
8
+ const read = async (
9
+ body: BodyInit,
10
+ headers: Record<string, string>,
11
+ limits: ReadBodyLimits = {},
12
+ ) => {
13
+ const app = new Hono().post("/t", async (c) => {
14
+ try {
15
+ const result = await readOtlpBody(c, limits);
16
+ return c.json({
17
+ bytes: result.bytes.byteLength,
18
+ wire: result.wireFormat,
19
+ });
20
+ } catch (err) {
21
+ if (err instanceof OtlpDecodeError)
22
+ return c.json({ error: err.message }, err.status);
23
+ throw err;
24
+ }
25
+ });
26
+ const res = await app.request("/t", { method: "POST", body, headers });
27
+ return {
28
+ status: res.status,
29
+ body: (await res.json()) as Record<string, unknown>,
30
+ };
31
+ };
32
+
33
+ describe("readOtlpBody limits", () => {
34
+ it("decompresses a normal gzip payload", async () => {
35
+ const json = JSON.stringify({ resourceSpans: [] });
36
+ const res = await read(gzipSync(json), {
37
+ "content-type": "application/json",
38
+ "content-encoding": "gzip",
39
+ });
40
+ expect(res).toEqual({
41
+ status: 200,
42
+ body: { bytes: json.length, wire: "json" },
43
+ });
44
+ });
45
+
46
+ it("refuses a gzip bomb once the decompressed cap is crossed", async () => {
47
+ // 8 MiB of zeros gzips to a few KiB; the reader must stop at 1 MiB.
48
+ const bomb = gzipSync(Buffer.alloc(8 * MIB));
49
+ expect(bomb.byteLength).toBeLessThan(64 * 1024);
50
+ const res = await read(
51
+ bomb,
52
+ { "content-type": "application/x-protobuf", "content-encoding": "gzip" },
53
+ { maxDecompressedBytes: MIB },
54
+ );
55
+ expect(res.status).toBe(413);
56
+ expect(res.body.error).toMatch(/Decompressed payload too large/);
57
+ });
58
+
59
+ it("refuses a raw body over the configured cap", async () => {
60
+ const res = await read(
61
+ "x".repeat(2048),
62
+ { "content-type": "application/json" },
63
+ { maxBodyBytes: 1024 },
64
+ );
65
+ expect(res.status).toBe(413);
66
+ expect(res.body.error).toMatch(/Payload too large \(max 1024 bytes\)/);
67
+ });
68
+
69
+ it("reports malformed gzip as 400, not 500", async () => {
70
+ const res = await read("definitely not gzip", {
71
+ "content-type": "application/json",
72
+ "content-encoding": "gzip",
73
+ });
74
+ expect(res.status).toBe(400);
75
+ });
76
+ });
@@ -1,5 +1,10 @@
1
1
  import type { JsonValue } from "@bufbuild/protobuf";
2
2
  import type { Context } from "hono";
3
+ import {
4
+ DEFAULT_MAX_BODY_BYTES,
5
+ DEFAULT_MAX_DECOMPRESSED_BYTES,
6
+ parseByteLimit,
7
+ } from "../../framework/limits";
3
8
 
4
9
  export type OtlpWireFormat = "json" | "protobuf";
5
10
 
@@ -11,25 +16,50 @@ export interface ReadBodyResult {
11
16
  export class OtlpDecodeError extends Error {
12
17
  constructor(
13
18
  message: string,
14
- public readonly status: 400 | 415,
19
+ public readonly status: 400 | 413 | 415,
15
20
  ) {
16
21
  super(message);
17
22
  }
18
23
  }
19
24
 
25
+ export interface ReadBodyLimits {
26
+ /** Raw (compressed) body cap; defaults to `MAX_BODY_BYTES` / 10 MiB. */
27
+ maxBodyBytes?: number;
28
+ /** Gunzipped payload cap; defaults to 32 MiB. */
29
+ maxDecompressedBytes?: number;
30
+ }
31
+
20
32
  /**
21
33
  * Reads and decompresses an OTLP request body, returning the raw bytes plus
22
34
  * the inferred wire format. Throws `OtlpDecodeError` for unsupported content-
23
- * types or malformed gzip.
35
+ * types, malformed gzip, or bodies over the raw / decompressed caps (413).
36
+ * The framework's body-limit middleware normally refuses oversized raw
37
+ * bodies first; the raw check here keeps the reader safe on its own, and
38
+ * the decompressed cap is what stops a gzip bomb.
24
39
  */
25
- export const readOtlpBody = async (c: Context): Promise<ReadBodyResult> => {
40
+ export const readOtlpBody = async (
41
+ c: Context,
42
+ limits: ReadBodyLimits = {},
43
+ ): Promise<ReadBodyResult> => {
26
44
  const contentType = (c.req.header("content-type") ?? "").toLowerCase();
27
45
  const wireFormat = detectWireFormat(contentType);
46
+ const env = (c.env ?? {}) as { MAX_BODY_BYTES?: string };
47
+ const maxBodyBytes =
48
+ limits.maxBodyBytes ??
49
+ parseByteLimit(env.MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
50
+ const maxDecompressedBytes =
51
+ limits.maxDecompressedBytes ?? DEFAULT_MAX_DECOMPRESSED_BYTES;
28
52
 
29
53
  let body = await c.req.arrayBuffer();
54
+ if (body.byteLength > maxBodyBytes) {
55
+ throw new OtlpDecodeError(
56
+ `Payload too large (max ${maxBodyBytes} bytes)`,
57
+ 413,
58
+ );
59
+ }
30
60
  const encoding = (c.req.header("content-encoding") ?? "").toLowerCase();
31
61
  if (encoding === "gzip") {
32
- body = await gunzip(body);
62
+ body = await gunzip(body, maxDecompressedBytes);
33
63
  } else if (encoding && encoding !== "identity") {
34
64
  throw new OtlpDecodeError(`Unsupported content-encoding: ${encoding}`, 415);
35
65
  }
@@ -46,12 +76,43 @@ const detectWireFormat = (contentType: string): OtlpWireFormat => {
46
76
  );
47
77
  };
48
78
 
49
- const gunzip = async (input: ArrayBuffer): Promise<ArrayBuffer> => {
79
+ /** Gunzip with a hard cap on output size; stops reading once exceeded. */
80
+ const gunzip = async (
81
+ input: ArrayBuffer,
82
+ maxBytes: number,
83
+ ): Promise<ArrayBuffer> => {
50
84
  const stream = new Response(input).body?.pipeThrough(
51
85
  new DecompressionStream("gzip"),
52
86
  );
53
87
  if (!stream) throw new OtlpDecodeError("Empty gzip body", 400);
54
- return new Response(stream).arrayBuffer();
88
+ const reader = stream.getReader();
89
+ const chunks: Uint8Array[] = [];
90
+ let total = 0;
91
+ try {
92
+ for (;;) {
93
+ const { done, value } = await reader.read();
94
+ if (done) break;
95
+ total += value.byteLength;
96
+ if (total > maxBytes) {
97
+ await reader.cancel().catch(() => {});
98
+ throw new OtlpDecodeError(
99
+ `Decompressed payload too large (max ${maxBytes} bytes)`,
100
+ 413,
101
+ );
102
+ }
103
+ chunks.push(value);
104
+ }
105
+ } catch (err) {
106
+ if (err instanceof OtlpDecodeError) throw err;
107
+ throw new OtlpDecodeError("Malformed gzip body", 400);
108
+ }
109
+ const out = new Uint8Array(total);
110
+ let offset = 0;
111
+ for (const chunk of chunks) {
112
+ out.set(chunk, offset);
113
+ offset += chunk.byteLength;
114
+ }
115
+ return out.buffer;
55
116
  };
56
117
 
57
118
  export const decodeJsonBody = (bytes: Uint8Array): JsonValue => {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Parse a request body that must be a JSON object. `c.req.json()` happily
3
+ * returns `null`, arrays and primitives for bodies like `null` or `"x"`, and
4
+ * the first property access in the handler then threw a TypeError that
5
+ * surfaced as a 500. Returns `null` for anything that is not a plain object
6
+ * so routes can answer 400 uniformly.
7
+ */
8
+ export async function readJsonObject<T extends object>(c: {
9
+ req: { json(): Promise<unknown> };
10
+ }): Promise<T | null> {
11
+ let parsed: unknown;
12
+ try {
13
+ parsed = await c.req.json();
14
+ } catch {
15
+ return null;
16
+ }
17
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
18
+ return null;
19
+ }
20
+ return parsed as T;
21
+ }
@@ -907,8 +907,7 @@ export const actionGraphProcessorPlugin: CollectorPlugin = {
907
907
  span_id, session_id, interaction_id, user_id, agent_run_id, step_id,
908
908
  tool_call_id, prompt_version, model_name, provider, total_cost_usd, attrs_json
909
909
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
910
- ON CONFLICT (id) DO UPDATE SET
911
- project_id = excluded.project_id,
910
+ ON CONFLICT (project_id, id) DO UPDATE SET
912
911
  root_action_id = excluded.root_action_id,
913
912
  caused_by_action_id = excluded.caused_by_action_id,
914
913
  actor_type = excluded.actor_type,
@@ -972,8 +971,7 @@ export const actionGraphProcessorPlugin: CollectorPlugin = {
972
971
  id, project_id, agent_id, agent_name, agent_version, goal, outcome,
973
972
  autonomy_level, status, error_message, total_cost_usd, total_duration_ms, metadata_json
974
973
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
975
- ON CONFLICT (id) DO UPDATE SET
976
- project_id = excluded.project_id,
974
+ ON CONFLICT (project_id, id) DO UPDATE SET
977
975
  agent_id = excluded.agent_id,
978
976
  agent_name = excluded.agent_name,
979
977
  agent_version = excluded.agent_version,
@@ -1015,9 +1013,8 @@ export const actionGraphProcessorPlugin: CollectorPlugin = {
1015
1013
  mcp_audit_json, mutation_before_json, mutation_after_json,
1016
1014
  mutation_diff_json, mutation_artifact_id
1017
1015
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1018
- ON CONFLICT (id) DO UPDATE SET
1016
+ ON CONFLICT (project_id, id) DO UPDATE SET
1019
1017
  action_id = excluded.action_id,
1020
- project_id = excluded.project_id,
1021
1018
  tool_name = excluded.tool_name,
1022
1019
  args_hash = excluded.args_hash,
1023
1020
  result_hash = excluded.result_hash,
@@ -1062,9 +1059,8 @@ export const actionGraphProcessorPlugin: CollectorPlugin = {
1062
1059
  id, action_id, project_id, retriever_name, query_hash, documents_json,
1063
1060
  total_results, max_relevance_score, duration_ms
1064
1061
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1065
- ON CONFLICT (id) DO UPDATE SET
1062
+ ON CONFLICT (project_id, id) DO UPDATE SET
1066
1063
  action_id = excluded.action_id,
1067
- project_id = excluded.project_id,
1068
1064
  retriever_name = excluded.retriever_name,
1069
1065
  query_hash = excluded.query_hash,
1070
1066
  documents_json = excluded.documents_json,
@@ -1095,9 +1091,8 @@ export const actionGraphProcessorPlugin: CollectorPlugin = {
1095
1091
  id, action_id, project_id, evaluator_name, evaluator_version,
1096
1092
  score, passed, reasoning, rubric_json
1097
1093
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1098
- ON CONFLICT (id) DO UPDATE SET
1094
+ ON CONFLICT (project_id, id) DO UPDATE SET
1099
1095
  action_id = excluded.action_id,
1100
- project_id = excluded.project_id,
1101
1096
  evaluator_name = excluded.evaluator_name,
1102
1097
  evaluator_version = excluded.evaluator_version,
1103
1098
  score = excluded.score,
@@ -1128,9 +1123,8 @@ export const actionGraphProcessorPlugin: CollectorPlugin = {
1128
1123
  id, action_id, project_id, artifact_name, artifact_type,
1129
1124
  storage_ref, size_bytes, sha256_hash, content_preview
1130
1125
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1131
- ON CONFLICT (id) DO UPDATE SET
1126
+ ON CONFLICT (project_id, id) DO UPDATE SET
1132
1127
  action_id = excluded.action_id,
1133
- project_id = excluded.project_id,
1134
1128
  artifact_name = excluded.artifact_name,
1135
1129
  artifact_type = excluded.artifact_type,
1136
1130
  storage_ref = excluded.storage_ref,
@@ -15,18 +15,15 @@ import type { CollectorPlugin } from "../framework/collector";
15
15
  import { AIStore, type IngestEvaluation } from "../lib/ai-store";
16
16
  import { sqlDbFor } from "../lib/sql-db";
17
17
  import { getProjectId } from "./_context";
18
+ import { readJsonObject } from "./_json";
18
19
 
19
20
  export const aiReceiverPlugin: CollectorPlugin = {
20
21
  name: "ai-receiver",
21
22
  register(app, _runtime) {
22
23
  app.post("/v1/ai", async (c) => {
23
24
  const projectId = getProjectId(c);
24
- let payload: AICallPayload;
25
- try {
26
- payload = await c.req.json<AICallPayload>();
27
- } catch {
28
- return c.json({ error: "Invalid JSON body" }, 400);
29
- }
25
+ const payload = await readJsonObject<AICallPayload>(c);
26
+ if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
30
27
  if (!payload.calls || !Array.isArray(payload.calls)) {
31
28
  return c.json({ error: "Missing calls array" }, 400);
32
29
  }
@@ -126,12 +123,8 @@ export const aiReceiverPlugin: CollectorPlugin = {
126
123
  // one or both of score/label, and optional explanation + metadata.
127
124
  app.post("/v1/ai/evaluations", async (c) => {
128
125
  const projectId = getProjectId(c);
129
- let payload: AIEvaluationPayload;
130
- try {
131
- payload = await c.req.json<AIEvaluationPayload>();
132
- } catch {
133
- return c.json({ error: "Invalid JSON body" }, 400);
134
- }
126
+ const payload = await readJsonObject<AIEvaluationPayload>(c);
127
+ if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
135
128
  if (!payload.evaluations || !Array.isArray(payload.evaluations)) {
136
129
  return c.json({ error: "Missing evaluations array" }, 400);
137
130
  }
@@ -130,8 +130,7 @@ export const aiSpanPayloadsProcessorPlugin: CollectorPlugin = {
130
130
  input_json, output_json, input_hash, output_hash, session_id, user_id,
131
131
  received_at, expires_at, interaction_id, action_id
132
132
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
133
- ON CONFLICT(trace_id, span_id) DO UPDATE SET
134
- project_id = excluded.project_id,
133
+ ON CONFLICT(project_id, trace_id, span_id) DO UPDATE SET
135
134
  span_kind = excluded.span_kind,
136
135
  input_json = excluded.input_json,
137
136
  output_json = excluded.output_json,
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Regression for the alert notification retry: the "still owed" decision
3
+ * lives on `alert_state.notified` (migration 049), not in alert_evaluations,
4
+ * which housekeeping purges after 720 hours. A long-lived firing rule whose
5
+ * evaluation history has been purged must not be re-notified every tick.
6
+ * Runs the real AlertsStore against the migrated SQLite schema; only the
7
+ * signal query and `fetch` are substituted.
8
+ */
9
+
10
+ import type { AlertRule } from "@obsunified/types";
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+ import { AlertsStore } from "../lib/alerts-store";
13
+ import {
14
+ ALERT_EVALUATIONS_MAX_AGE_HOURS,
15
+ purgeSecondaryRows,
16
+ } from "../lib/housekeeping";
17
+ import { ProjectsStore } from "../lib/projects-store";
18
+ import { createMigratedSqliteDb } from "../lib/test-utils/node-sqlite-db";
19
+ import { evaluateRuleBatch } from "./alerts-evaluator";
20
+
21
+ type EvaluatorStore = Parameters<typeof evaluateRuleBatch>[0];
22
+
23
+ const logger = { debug() {}, info() {}, warn() {}, error() {} };
24
+ const HOUR = 3_600_000;
25
+
26
+ /** Real store for state and evaluations; the signal value is injected. */
27
+ const evaluatorOver = (
28
+ store: AlertsStore,
29
+ value: () => number,
30
+ ): EvaluatorStore => ({
31
+ listEnabledRules: () => store.listEnabledRules(),
32
+ evaluateRule: async () => value(),
33
+ getState: (id, project) => store.getState(id, project),
34
+ getAnalysisNarrative: (project, analysis) =>
35
+ store.getAnalysisNarrative(project, analysis),
36
+ transitionState: (id, project, next, now) =>
37
+ store.transitionState(id, project, next, now),
38
+ markNotified: (id, project, state, since) =>
39
+ store.markNotified(id, project, state, since),
40
+ recordEvaluation: (id, project, v, state, notified) =>
41
+ store.recordEvaluation(id, project, v, state, notified),
42
+ });
43
+
44
+ const setup = async () => {
45
+ const db = createMigratedSqliteDb();
46
+ const project = await new ProjectsStore(db).createProject({
47
+ name: "A",
48
+ slug: "a",
49
+ });
50
+ const store = new AlertsStore(db);
51
+ const rule = await store.createRule(project.id, {
52
+ name: "errors",
53
+ signal: "spans",
54
+ query: {},
55
+ threshold: 10,
56
+ windowMins: 5,
57
+ comparison: ">",
58
+ channels: [{ type: "webhook", url: "https://hooks.example.com/alert/x" }],
59
+ enabled: true,
60
+ });
61
+ const evaluations = () =>
62
+ db.count("alert_evaluations", "rule_id = ?", rule.id);
63
+ return { db, project, store, rule, evaluations };
64
+ };
65
+
66
+ const purgeAllEvaluations = async (
67
+ db: ReturnType<typeof createMigratedSqliteDb>,
68
+ ) =>
69
+ purgeSecondaryRows(db, {
70
+ retentionHours: 72,
71
+ now: () => Date.now() + (ALERT_EVALUATIONS_MAX_AGE_HOURS + 1) * HOUR,
72
+ });
73
+
74
+ describe("alert notification retry survives evaluation retention", () => {
75
+ afterEach(() => {
76
+ vi.unstubAllGlobals();
77
+ });
78
+
79
+ it("does not re-send a delivered firing notification after its evaluations are purged", async () => {
80
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
81
+ vi.stubGlobal("fetch", fetchMock);
82
+ const { db, project, store, rule, evaluations } = await setup();
83
+ const evaluator = evaluatorOver(store, () => 42);
84
+ const rules: AlertRule[] = [rule];
85
+
86
+ // Breach: state recorded, webhook accepted, flag set.
87
+ expect(await evaluateRuleBatch(evaluator, rules, { logger })).toEqual({
88
+ evaluated: 1,
89
+ fired: 1,
90
+ resolved: 0,
91
+ redelivered: 0,
92
+ });
93
+ expect(await store.getState(rule.id, project.id)).toMatchObject({
94
+ current_state: "firing",
95
+ notified: 1,
96
+ });
97
+ expect(fetchMock).toHaveBeenCalledTimes(1);
98
+
99
+ // Still breaching for longer than evaluation retention: history goes.
100
+ await evaluateRuleBatch(evaluator, rules, { logger });
101
+ expect(await evaluations()).toBe(2);
102
+ const purged = await purgeAllEvaluations(db);
103
+ expect(purged.alert_evaluations).toBe(2);
104
+ expect(await evaluations()).toBe(0);
105
+
106
+ // The flag on the state row is the source of truth: no resend.
107
+ for (let tick = 0; tick < 3; tick += 1) {
108
+ expect(await evaluateRuleBatch(evaluator, rules, { logger })).toEqual({
109
+ evaluated: 1,
110
+ fired: 0,
111
+ resolved: 0,
112
+ redelivered: 0,
113
+ });
114
+ }
115
+ expect(fetchMock).toHaveBeenCalledTimes(1);
116
+ expect(await store.getState(rule.id, project.id)).toMatchObject({
117
+ current_state: "firing",
118
+ notified: 1,
119
+ });
120
+ });
121
+
122
+ it("keeps retrying an undelivered notification across the purge until a channel accepts it", async () => {
123
+ const statuses = [503, 503, 200];
124
+ const fetchMock = vi.fn(
125
+ async () => new Response(null, { status: statuses.shift() ?? 200 }),
126
+ );
127
+ vi.stubGlobal("fetch", fetchMock);
128
+ const { db, project, store, rule } = await setup();
129
+ const evaluator = evaluatorOver(store, () => 42);
130
+ const rules: AlertRule[] = [rule];
131
+
132
+ await evaluateRuleBatch(evaluator, rules, { logger }); // 503
133
+ expect(await store.getState(rule.id, project.id)).toMatchObject({
134
+ current_state: "firing",
135
+ notified: 0,
136
+ });
137
+ await purgeAllEvaluations(db);
138
+
139
+ await evaluateRuleBatch(evaluator, rules, { logger }); // 503 again
140
+ expect(await evaluateRuleBatch(evaluator, rules, { logger })).toEqual({
141
+ evaluated: 1,
142
+ fired: 0,
143
+ resolved: 0,
144
+ redelivered: 1,
145
+ }); // 200
146
+ expect(fetchMock).toHaveBeenCalledTimes(3);
147
+ expect((await store.getState(rule.id, project.id))?.notified).toBe(1);
148
+
149
+ await evaluateRuleBatch(evaluator, rules, { logger });
150
+ expect(fetchMock).toHaveBeenCalledTimes(3);
151
+ });
152
+
153
+ it("listRules and getRule still expose the current state for the dashboard", async () => {
154
+ vi.stubGlobal(
155
+ "fetch",
156
+ vi.fn(async () => new Response(null, { status: 500 })),
157
+ );
158
+ const { project, store, rule } = await setup();
159
+ await evaluateRuleBatch(
160
+ evaluatorOver(store, () => 42),
161
+ [rule],
162
+ { logger },
163
+ );
164
+ // Delivery failed, but the breach is visible.
165
+ expect((await store.getRule(rule.id, project.id))?.currentState).toBe(
166
+ "firing",
167
+ );
168
+ expect((await store.listRules(project.id))[0]?.currentState).toBe("firing");
169
+ });
170
+ });