@obsunified/collector 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
  4. package/dist/chunk-KMEZKGSO.js.map +1 -0
  5. package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  6. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  7. package/dist/index.d.ts +630 -27
  8. package/dist/index.js +5819 -853
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  11. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  12. package/package.json +5 -5
  13. package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
  14. package/src/analyses/derive.ts +26 -25
  15. package/src/analyses/investigations.ts +8 -8
  16. package/src/analyses/tier0.ts +22 -22
  17. package/src/auth/dashboard-auth.test.ts +791 -0
  18. package/src/auth/dashboard-auth.ts +187 -38
  19. package/src/auth/login-rate-limit.test.ts +129 -0
  20. package/src/auth/login-rate-limit.ts +143 -0
  21. package/src/framework/body-limit.test.ts +165 -0
  22. package/src/framework/collector.ts +355 -115
  23. package/src/framework/env.ts +9 -0
  24. package/src/framework/limits.ts +31 -0
  25. package/src/framework/scheduled-handlers.test.ts +342 -0
  26. package/src/index.ts +59 -1
  27. package/src/lib/action-retention.test.ts +472 -0
  28. package/src/lib/action-retention.ts +420 -0
  29. package/src/lib/actions-store.ts +6 -12
  30. package/src/lib/ai-store/calls.ts +2 -2
  31. package/src/lib/ai-store/sessions.ts +3 -3
  32. package/src/lib/ai-store/spans.ts +1 -1
  33. package/src/lib/ai-store/types.ts +2 -1
  34. package/src/lib/alerts-store.test.ts +153 -0
  35. package/src/lib/alerts-store.ts +122 -21
  36. package/src/lib/analyses-runner.test.ts +75 -0
  37. package/src/lib/analyses-runner.ts +69 -3
  38. package/src/lib/analyses-store-latest.test.ts +61 -0
  39. package/src/lib/analyses-store.ts +17 -10
  40. package/src/lib/autosre-change-correlation.ts +780 -0
  41. package/src/lib/autosre-findings.ts +725 -0
  42. package/src/lib/autosre-ontology.ts +914 -0
  43. package/src/lib/autosre-remediation-gate.ts +1663 -0
  44. package/src/lib/blob-store.ts +1 -1
  45. package/src/lib/eval-cases-store.ts +63 -1
  46. package/src/lib/evidence-references.ts +6 -0
  47. package/src/lib/housekeeping.test.ts +153 -0
  48. package/src/lib/housekeeping.ts +193 -0
  49. package/src/lib/identity-index/action-lookups.ts +6 -6
  50. package/src/lib/identity-index/key-lookups.ts +2 -2
  51. package/src/lib/identity-index/user-lookup.ts +8 -5
  52. package/src/lib/identity-index.test.ts +2 -3
  53. package/src/lib/metrics-store-series.test.ts +99 -0
  54. package/src/lib/metrics-store.test.ts +23 -0
  55. package/src/lib/metrics-store.ts +27 -3
  56. package/src/lib/projects-store.test.ts +25 -0
  57. package/src/lib/projects-store.ts +4 -3
  58. package/src/lib/sql-db-dialect.test.ts +12 -3
  59. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  60. package/src/lib/sql-db-postgres.test.ts +314 -0
  61. package/src/lib/sql-db-postgres.ts +392 -54
  62. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  63. package/src/lib/sql-db.ts +40 -6
  64. package/src/lib/store/ingest.ts +94 -6
  65. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  66. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  67. package/src/lib/webhook-url.test.ts +122 -0
  68. package/src/lib/webhook-url.ts +155 -0
  69. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  70. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  71. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  72. package/src/migrations/044_actions_started_at_index.sql +10 -0
  73. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  74. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  75. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  76. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  77. package/src/migrations/049_alert_state_notified.sql +17 -0
  78. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  79. package/src/migrations/alert-state-notified.test.ts +111 -0
  80. package/src/migrations/parity.test.ts +68 -0
  81. package/src/migrations/project-scoped-keys.test.ts +432 -0
  82. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  83. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  84. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  85. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  86. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  87. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  88. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  89. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  90. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  91. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  92. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  93. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  94. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  95. package/src/migrations-postgres/README.md +58 -5
  96. package/src/otlp/decode/body.test.ts +76 -0
  97. package/src/otlp/decode/body.ts +67 -6
  98. package/src/plugins/_json.ts +21 -0
  99. package/src/plugins/action-graph-processor.ts +6 -12
  100. package/src/plugins/ai-receiver.ts +5 -12
  101. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  102. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  103. package/src/plugins/alerts-evaluator.test.ts +464 -3
  104. package/src/plugins/alerts-evaluator.ts +181 -41
  105. package/src/plugins/alerts-routes.ts +10 -6
  106. package/src/plugins/ask-routes.ts +4 -5
  107. package/src/plugins/autosre-routes.ts +1019 -0
  108. package/src/plugins/connected-routes/manifest.ts +13 -1
  109. package/src/plugins/connected-routes.test.ts +106 -0
  110. package/src/plugins/connected-routes.ts +285 -5
  111. package/src/plugins/dashboard-routes.test.ts +120 -0
  112. package/src/plugins/dashboard-routes.ts +11 -2
  113. package/src/plugins/default-span-enrichment.ts +13 -1
  114. package/src/plugins/eval-cases-routes.ts +65 -19
  115. package/src/plugins/evidence-retrieval-routes.ts +300 -4
  116. package/src/plugins/identity-receiver.ts +9 -11
  117. package/src/plugins/json-body.test.ts +91 -0
  118. package/src/plugins/onboarding-routes.test.ts +105 -0
  119. package/src/plugins/onboarding-routes.ts +26 -11
  120. package/src/plugins/projects-routes.ts +2 -1
  121. package/src/plugins/replay-isolation.test.ts +922 -0
  122. package/src/plugins/replay-receiver.ts +8 -8
  123. package/src/plugins/usage-receiver.ts +3 -6
  124. package/dist/chunk-UGWDOGGH.js.map +0 -1
  125. package/dist/chunk-XKQTVIRE.js.map +0 -1
  126. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -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
+ });