@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
@@ -21,6 +21,12 @@ import { EVIDENCE_REFERENCE_CONTRACT } from "@obsunified/types";
21
21
  import { alertEvidenceReferences } from "./evidence-references";
22
22
  import { randomHex } from "./hash";
23
23
  import type { SqlDb } from "./sql-db";
24
+ import { assertSafeWebhookUrl, WebhookUrlError } from "./webhook-url";
25
+
26
+ export interface AlertsStoreOptions {
27
+ /** Permit webhook targets on loopback / private / link-local hosts. */
28
+ allowPrivateWebhookTargets?: boolean;
29
+ }
24
30
 
25
31
  const parseJsonField = <T>(value: unknown): T => {
26
32
  if (typeof value === "string") return JSON.parse(value) as T;
@@ -79,7 +85,10 @@ export function compareValue(
79
85
  }
80
86
 
81
87
  export class AlertsStore {
82
- constructor(private readonly db: SqlDb) {}
88
+ constructor(
89
+ private readonly db: SqlDb,
90
+ private readonly options: AlertsStoreOptions = {},
91
+ ) {}
83
92
 
84
93
  validateInput(input: AlertRuleInput): void {
85
94
  if (!input.name?.trim()) throw new Error("name is required");
@@ -96,8 +105,16 @@ export class AlertsStore {
96
105
  for (const ch of input.channels) {
97
106
  if (ch.type !== "webhook")
98
107
  throw new Error(`unknown channel type: ${ch.type}`);
99
- if (!ch.url || !/^https?:\/\//.test(ch.url))
108
+ if (!ch.url || typeof ch.url !== "string")
100
109
  throw new Error("channel.url must be http(s)://");
110
+ try {
111
+ assertSafeWebhookUrl(ch.url, {
112
+ allowPrivateNetworks: this.options.allowPrivateWebhookTargets,
113
+ });
114
+ } catch (err) {
115
+ if (err instanceof WebhookUrlError) throw new Error(err.message);
116
+ throw err;
117
+ }
101
118
  }
102
119
  }
103
120
 
@@ -217,18 +234,30 @@ export class AlertsStore {
217
234
  }
218
235
 
219
236
  async deleteRule(id: string, projectId: string): Promise<boolean> {
220
- const result = await this.db
221
- .prepare(`DELETE FROM alert_rules WHERE id = ? AND project_id = ?`)
237
+ // Only a rule the caller's project owns takes its state and
238
+ // evaluations with it; a miss must not touch another project's rows.
239
+ // Children go first because alert_state / alert_evaluations reference
240
+ // alert_rules(id) and SQLite enforces that on D1 and node:sqlite.
241
+ const owned = await this.db
242
+ .prepare(
243
+ `SELECT 1 AS ok FROM alert_rules WHERE id = ? AND project_id = ?`,
244
+ )
222
245
  .bind(id, projectId)
223
- .run();
224
- // Also clean up state and evaluations for this rule.
246
+ .first<{ ok: number }>();
247
+ if (!owned) return false;
225
248
  await this.db
226
- .prepare(`DELETE FROM alert_state WHERE rule_id = ?`)
227
- .bind(id)
249
+ .prepare(`DELETE FROM alert_state WHERE rule_id = ? AND project_id = ?`)
250
+ .bind(id, projectId)
228
251
  .run();
229
252
  await this.db
230
- .prepare(`DELETE FROM alert_evaluations WHERE rule_id = ?`)
231
- .bind(id)
253
+ .prepare(
254
+ `DELETE FROM alert_evaluations WHERE rule_id = ? AND project_id = ?`,
255
+ )
256
+ .bind(id, projectId)
257
+ .run();
258
+ const result = await this.db
259
+ .prepare(`DELETE FROM alert_rules WHERE id = ? AND project_id = ?`)
260
+ .bind(id, projectId)
232
261
  .run();
233
262
  return (result.meta?.changes ?? 0) > 0;
234
263
  }
@@ -242,28 +271,85 @@ export class AlertsStore {
242
271
 
243
272
  // ── State ──
244
273
 
245
- async getState(ruleId: string): Promise<AlertStateRow | null> {
274
+ async getState(
275
+ ruleId: string,
276
+ projectId: string,
277
+ ): Promise<AlertStateRow | null> {
246
278
  const row = await this.db
247
- .prepare(`SELECT * FROM alert_state WHERE rule_id = ?`)
248
- .bind(ruleId)
279
+ .prepare(`SELECT * FROM alert_state WHERE rule_id = ? AND project_id = ?`)
280
+ .bind(ruleId, projectId)
249
281
  .first<AlertStateRow>();
250
282
  return row ?? null;
251
283
  }
252
284
 
285
+ /**
286
+ * Record a state transition with its notification still outstanding
287
+ * (`notified = 0`); `markNotified` flips it once a channel accepts the
288
+ * alert. On a database that has not applied migration 049 the column is
289
+ * missing: the transition is still written (legacy statement) so alerting
290
+ * keeps working through a rolling deploy, and the evaluator treats a row
291
+ * without the column as delivered.
292
+ */
253
293
  async transitionState(
254
294
  ruleId: string,
255
295
  projectId: string,
256
296
  newState: AlertState,
257
297
  now: string,
258
298
  ): Promise<void> {
259
- await this.db
260
- .prepare(
261
- `INSERT INTO alert_state (rule_id, project_id, current_state, last_state_change)
299
+ try {
300
+ await this.db
301
+ .prepare(
302
+ `INSERT INTO alert_state (rule_id, project_id, current_state, last_state_change, notified)
303
+ VALUES (?, ?, ?, ?, 0)
304
+ ON CONFLICT(rule_id) DO UPDATE SET
305
+ current_state = excluded.current_state,
306
+ last_state_change = excluded.last_state_change,
307
+ notified = 0`,
308
+ )
309
+ .bind(ruleId, projectId, newState, now)
310
+ .run();
311
+ } catch (err) {
312
+ if (!isMissingNotifiedColumn(err)) throw err;
313
+ await this.db
314
+ .prepare(
315
+ `INSERT INTO alert_state (rule_id, project_id, current_state, last_state_change)
262
316
  VALUES (?, ?, ?, ?)
263
- ON CONFLICT(rule_id) DO UPDATE SET current_state = excluded.current_state, last_state_change = excluded.last_state_change`,
264
- )
265
- .bind(ruleId, projectId, newState, now)
266
- .run();
317
+ ON CONFLICT(rule_id) DO UPDATE SET
318
+ current_state = excluded.current_state,
319
+ last_state_change = excluded.last_state_change`,
320
+ )
321
+ .bind(ruleId, projectId, newState, now)
322
+ .run();
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Mark the transition `(state, lastStateChange)` as delivered. Scoped to
328
+ * the exact transition the caller notified for: if the rule changed state
329
+ * again while the webhook was in flight, the newer transition keeps its
330
+ * own outstanding flag. Returns false when nothing matched (stale or
331
+ * already marked). On a pre-049 schema there is nothing to mark.
332
+ */
333
+ async markNotified(
334
+ ruleId: string,
335
+ projectId: string,
336
+ state: AlertState,
337
+ lastStateChange: string,
338
+ ): Promise<boolean> {
339
+ try {
340
+ const result = await this.db
341
+ .prepare(
342
+ `UPDATE alert_state SET notified = 1
343
+ WHERE rule_id = ? AND project_id = ? AND current_state = ?
344
+ AND last_state_change = ? AND notified = 0`,
345
+ )
346
+ .bind(ruleId, projectId, state, lastStateChange)
347
+ .run();
348
+ return (result.meta?.changes ?? 0) > 0;
349
+ } catch (err) {
350
+ if (isMissingNotifiedColumn(err)) return false;
351
+ throw err;
352
+ }
267
353
  }
268
354
 
269
355
  async recordEvaluation(
@@ -448,7 +534,9 @@ export class AlertsStore {
448
534
  sql += ` AND model_name = ?`;
449
535
  binds.push(q.model);
450
536
  }
451
- if (q.isError === true) sql += ` AND is_error = 1`;
537
+ // Bare boolean test: INTEGER 0/1 on D1, BOOLEAN on Postgres (where
538
+ // `is_error = 1` is a type error).
539
+ if (q.isError === true) sql += ` AND is_error`;
452
540
  const row = await this.db
453
541
  .prepare(sql)
454
542
  .bind(...binds)
@@ -456,3 +544,16 @@ export class AlertsStore {
456
544
  return row?.c ?? 0;
457
545
  }
458
546
  }
547
+
548
+ /**
549
+ * SQLite: "table alert_state has no column named notified" / "no such column:
550
+ * notified"; Postgres: `column "notified" of relation "alert_state" does not
551
+ * exist` (42703). Anything else is a real failure.
552
+ */
553
+ const isMissingNotifiedColumn = (err: unknown): boolean => {
554
+ const message = err instanceof Error ? err.message : String(err);
555
+ return (
556
+ /notified/i.test(message) &&
557
+ /no such column|has no column|does not exist/i.test(message)
558
+ );
559
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `runAllDueAnalyses` used to hard-code project `default`, so analyses for
3
+ * every other project never refreshed and alert rules bound to them always
4
+ * evaluated against nothing. These tests run the orchestrator against the
5
+ * real migrated schema and against a database whose projects table fails.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import type { CollectorEnv } from "../framework/env";
10
+ import { runAllDueAnalyses } from "./analyses-runner";
11
+ import { ProjectsStore } from "./projects-store";
12
+ import { MemSqlDb } from "./test-utils/mem-sql-db";
13
+ import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
14
+
15
+ const silent = { debug() {}, info() {}, warn() {}, error() {} };
16
+
17
+ describe("runAllDueAnalyses", () => {
18
+ it("refreshes and runs analyses for every project, not just default", async () => {
19
+ const db = createMigratedSqliteDb();
20
+ const beta = await new ProjectsStore(db).createProject({
21
+ name: "Beta",
22
+ slug: "beta",
23
+ });
24
+
25
+ const summary = await runAllDueAnalyses({
26
+ env: { DB: db } as unknown as CollectorEnv,
27
+ retentionHours: 24,
28
+ logger: silent,
29
+ });
30
+
31
+ expect(summary.failed).toBe(0);
32
+ expect(summary.ran).toBeGreaterThan(0);
33
+ const projects = await db
34
+ .prepare(
35
+ `SELECT DISTINCT project_id FROM analysis_results ORDER BY project_id`,
36
+ )
37
+ .all<{ project_id: string }>();
38
+ const ids = projects.results.map((r) => r.project_id);
39
+ // 011 seeds `default`, 026 seeds `obs-dashboard`.
40
+ expect(ids).toEqual(
41
+ expect.arrayContaining(["default", "obs-dashboard", beta.id]),
42
+ );
43
+
44
+ const perProject = await db
45
+ .prepare(
46
+ `SELECT project_id, COUNT(*) AS n FROM analysis_results GROUP BY project_id`,
47
+ )
48
+ .all<{ project_id: string; n: number }>();
49
+ const counts = new Map(perProject.results.map((r) => [r.project_id, r.n]));
50
+ expect(counts.get(beta.id)).toBe(counts.get("default"));
51
+ db.close();
52
+ });
53
+
54
+ it("falls back to the default project when projects cannot be listed", async () => {
55
+ const db = new MemSqlDb({
56
+ all: (sql) => {
57
+ if (sql.includes("FROM projects")) throw new Error("no projects table");
58
+ return [];
59
+ },
60
+ first: () => null,
61
+ });
62
+
63
+ const summary = await runAllDueAnalyses({
64
+ env: { DB: db } as unknown as CollectorEnv,
65
+ retentionHours: 24,
66
+ logger: silent,
67
+ });
68
+
69
+ expect(summary).toMatchObject({ ran: 0, failed: 0 });
70
+ expect(summary.refreshed).toBeGreaterThan(0);
71
+ const due = db.callsMatching("FROM analysis_definitions");
72
+ expect(due.length).toBeGreaterThan(0);
73
+ expect(due.every((c) => c.binds[0] === "default")).toBe(true);
74
+ });
75
+ });
@@ -36,12 +36,14 @@ import {
36
36
  type Logger,
37
37
  } from "../framework/logger";
38
38
  import { AnalysesStore } from "./analyses-store";
39
+ import { openOrDeduplicateIncident } from "./autosre-remediation-gate";
39
40
  import { generateNarrative, LlmCallError, type LlmConfig } from "./llm";
40
41
  import {
41
42
  computeSignature,
42
43
  evaluateGate,
43
44
  type NarrativeIntent,
44
45
  } from "./narrate-gate";
46
+ import { ProjectsStore } from "./projects-store";
45
47
  import { type SqlDb, sqlDbFor } from "./sql-db";
46
48
 
47
49
  export interface AnalysisRunContext {
@@ -253,8 +255,10 @@ async function narratePass(
253
255
  * in-process registry (so newly-derived Tier 1 analyses become visible
254
256
  * to the dashboard within one tick), then runs whatever's due.
255
257
  *
256
- * Stage 1 has a single project ("default"). When a projects table is
257
- * available we'll iterate it here.
258
+ * Runs once per project in the `projects` table (falling back to the
259
+ * `default` project when the table is empty or unreadable), so analyses and
260
+ * the alert rules bound to them refresh for every tenant, not just the
261
+ * first one.
258
262
  */
259
263
  export async function runAllDueAnalyses(
260
264
  ctx: {
@@ -274,7 +278,61 @@ export async function runAllDueAnalyses(
274
278
  const logger = ctx.logger ?? consoleLogger;
275
279
  const db = sqlDbFor(ctx.env);
276
280
  const store = new AnalysesStore(db);
277
- const projectId = "default";
281
+ const totals = { ran: 0, failed: 0, refreshed: 0, narrated: 0, skipped: 0 };
282
+ for (const projectId of await listAnalysisProjects(db, logger)) {
283
+ const summary = await runDueAnalysesForProject(projectId, {
284
+ ctx,
285
+ db,
286
+ store,
287
+ logger,
288
+ });
289
+ totals.ran += summary.ran;
290
+ totals.failed += summary.failed;
291
+ totals.refreshed += summary.refreshed;
292
+ totals.narrated += summary.narrated;
293
+ totals.skipped += summary.skipped ?? 0;
294
+ }
295
+ return totals;
296
+ }
297
+
298
+ /** Project ids to run analyses for; `default` when none can be listed. */
299
+ async function listAnalysisProjects(
300
+ db: SqlDb,
301
+ logger: Logger,
302
+ ): Promise<string[]> {
303
+ try {
304
+ const ids = (await new ProjectsStore(db).listProjects())
305
+ .map((p) => p.id)
306
+ .filter((id) => typeof id === "string" && id.length > 0);
307
+ if (ids.length > 0) return ids;
308
+ } catch (error) {
309
+ logger.error("[analyses] failed to list projects; running default only", {
310
+ error: error instanceof Error ? error.message : String(error),
311
+ });
312
+ }
313
+ return ["default"];
314
+ }
315
+
316
+ async function runDueAnalysesForProject(
317
+ projectId: string,
318
+ deps: {
319
+ ctx: {
320
+ env: CollectorEnv;
321
+ retentionHours: number;
322
+ tracer?: ChildSpanRunner;
323
+ };
324
+ db: SqlDb;
325
+ store: AnalysesStore;
326
+ logger: Logger;
327
+ },
328
+ ): Promise<{
329
+ ran: number;
330
+ failed: number;
331
+ refreshed: number;
332
+ narrated: number;
333
+ skipped?: number;
334
+ }> {
335
+ const { ctx, db, store, logger } = deps;
278
336
  const now = Date.now();
279
337
  const expiresAt = now + ctx.retentionHours * 3600 * 1000;
280
338
 
@@ -418,6 +476,14 @@ export async function runAllDueAnalyses(
418
476
  if (result.narrative) narrated += 1;
419
477
  }
420
478
  await store.insertResult(result, expiresAt);
479
+ if (result.status === "critical") {
480
+ await openOrDeduplicateIncident(db, projectId, {
481
+ environment: "default",
482
+ targetNodeId: `analysis:${def.id}`,
483
+ symptomAnchorKind: "analysis",
484
+ symptomAnchorId: def.id,
485
+ }).catch(() => undefined);
486
+ }
421
487
  // markRan releases the claim as a side-effect.
422
488
  await store.markRan(projectId, def.id, result.generatedAt);
423
489
  ran += 1;
@@ -0,0 +1,61 @@
1
+ import type { AnalysisResult } from "@obsunified/types";
2
+ import { describe, expect, it } from "vitest";
3
+ import { AnalysesStore } from "./analyses-store";
4
+ import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
5
+
6
+ const result = (
7
+ analysisId: string,
8
+ generatedAt: string,
9
+ status: AnalysisResult["status"],
10
+ ): AnalysisResult => ({
11
+ analysisId,
12
+ projectId: "alpha",
13
+ generatedAt,
14
+ paramsHash: null,
15
+ status,
16
+ primaryValue: 1,
17
+ baselineValue: 0,
18
+ deltaPct: null,
19
+ payload: {},
20
+ narrative: null,
21
+ narrativeSignature: null,
22
+ durationMs: 1,
23
+ });
24
+
25
+ describe("AnalysesStore.getLatestResultsBulk", () => {
26
+ it("returns the newest result per analysis from a single bounded query", async () => {
27
+ const db = createMigratedSqliteDb();
28
+ const store = new AnalysesStore(db);
29
+ const expires = Date.now() + 3_600_000;
30
+ for (const [id, at, status] of [
31
+ ["a", "2026-10-13T01:00:00.000Z", "ok"],
32
+ ["a", "2026-10-13T03:00:00.000Z", "critical"],
33
+ ["a", "2026-10-13T02:00:00.000Z", "warn"],
34
+ ["b", "2026-10-13T02:30:00.000Z", "warn"],
35
+ ["c", "2026-10-13T02:30:00.000Z", "ok"],
36
+ ] as const) {
37
+ await store.insertResult(result(id, at, status), expires);
38
+ }
39
+ // Another project with the same ids must not bleed through.
40
+ await store.insertResult(
41
+ { ...result("a", "2026-10-13T09:00:00.000Z", "ok"), projectId: "beta" },
42
+ expires,
43
+ );
44
+
45
+ const latest = await store.getLatestResultsBulk("alpha", ["a", "b", "zz"]);
46
+ expect([...latest.keys()].sort()).toEqual(["a", "b"]);
47
+ expect(latest.get("a")?.status).toBe("critical");
48
+ expect(latest.get("a")?.generatedAt).toBe("2026-10-13T03:00:00.000Z");
49
+ expect(latest.get("b")?.status).toBe("warn");
50
+
51
+ // The query itself must not return every retained row.
52
+ const rows = await db
53
+ .prepare(
54
+ `SELECT COUNT(*) AS n FROM analysis_results WHERE project_id = ? AND analysis_id = ?`,
55
+ )
56
+ .bind("alpha", "a")
57
+ .first<{ n: number }>();
58
+ expect(rows?.n).toBe(3);
59
+ db.close();
60
+ });
61
+ });
@@ -293,20 +293,27 @@ export class AnalysesStore {
293
293
  );
294
294
  if (analysisIds.length === 0) return new Map();
295
295
 
296
- // Pull a recent window of rows for the requested analyses then group by
297
- // analysis_id, taking the row with the largest generated_at per group.
298
- // One query, no per-analysis round-trips.
296
+ // One row per analysis: join against the per-analysis MAX(generated_at)
297
+ // so the database returns only the newest result for each id instead
298
+ // of every retained row (a 60 s refresh over a 72 h window is ~4k rows
299
+ // per analysis). Ties on generated_at are broken in JS below.
299
300
  const placeholders = analysisIds.map(() => "?").join(", ");
300
301
  const result = await this.db
301
302
  .prepare(
302
- `SELECT id, project_id, analysis_id, generated_at, params_hash,
303
- status, primary_value, baseline_value, delta_pct, payload_json,
304
- narrative, narrative_signature, duration_ms, expires_at
305
- FROM analysis_results
306
- WHERE project_id = ? AND analysis_id IN (${placeholders})
307
- ORDER BY generated_at DESC`,
303
+ `SELECT r.id, r.project_id, r.analysis_id, r.generated_at, r.params_hash,
304
+ r.status, r.primary_value, r.baseline_value, r.delta_pct, r.payload_json,
305
+ r.narrative, r.narrative_signature, r.duration_ms, r.expires_at
306
+ FROM analysis_results r
307
+ JOIN (
308
+ SELECT analysis_id, MAX(generated_at) AS latest_at
309
+ FROM analysis_results
310
+ WHERE project_id = ? AND analysis_id IN (${placeholders})
311
+ GROUP BY analysis_id
312
+ ) m ON m.analysis_id = r.analysis_id AND m.latest_at = r.generated_at
313
+ WHERE r.project_id = ?
314
+ ORDER BY r.generated_at DESC, r.id DESC`,
308
315
  )
309
- .bind(projectId, ...analysisIds)
316
+ .bind(projectId, ...analysisIds, projectId)
310
317
  .all<ResultRow>();
311
318
 
312
319
  const latest = new Map<string, AnalysisResult>();