@obsunified/collector 2.2.0 → 2.2.3

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 (45) hide show
  1. package/dist/auth.js +1 -1
  2. package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
  3. package/dist/chunk-2FALTDCE.js.map +1 -0
  4. package/dist/index.d.ts +61 -3
  5. package/dist/index.js +10933 -9769
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -3
  8. package/src/__tests__/autosre-closed-loop.test.ts +1554 -17
  9. package/src/auth/dashboard-auth.test.ts +61 -0
  10. package/src/auth/dashboard-auth.ts +17 -2
  11. package/src/lib/ai-store/calls.ts +43 -23
  12. package/src/lib/ai-store.ts +12 -1
  13. package/src/lib/analyses-runner.ts +44 -7
  14. package/src/lib/autosre-change-correlation.ts +39 -12
  15. package/src/lib/autosre-findings.ts +237 -44
  16. package/src/lib/autosre-ontology-cache.test.ts +78 -0
  17. package/src/lib/autosre-ontology.ts +52 -28
  18. package/src/lib/autosre-remediation-gate.ts +942 -348
  19. package/src/lib/housekeeping.test.ts +70 -0
  20. package/src/lib/housekeeping.ts +91 -83
  21. package/src/lib/projects-store.test.ts +38 -1
  22. package/src/lib/projects-store.ts +23 -4
  23. package/src/lib/sql-db-postgres.integration.test.ts +75 -2
  24. package/src/lib/store/ingest.ts +8 -56
  25. package/src/lib/usage-store.ts +9 -3
  26. package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
  27. package/src/migrations/052_remediation_callback.sql +10 -0
  28. package/src/migrations/053_remediation_callback_delivery.sql +7 -0
  29. package/src/migrations/alert-state-notified.test.ts +3 -3
  30. package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
  31. package/src/migrations-postgres/052_remediation_callback.sql +10 -0
  32. package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
  33. package/src/plugins/ai-call-by-id.test.ts +73 -0
  34. package/src/plugins/ai-receiver.ts +10 -0
  35. package/src/plugins/alerts-evaluator.ts +15 -3
  36. package/src/plugins/autosre-routes.ts +374 -108
  37. package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
  38. package/src/plugins/connected-routes/manifest.test.ts +142 -0
  39. package/src/plugins/connected-routes/manifest.ts +123 -6
  40. package/src/plugins/connected-routes-links.test.ts +96 -0
  41. package/src/plugins/connected-routes.test.ts +3 -3
  42. package/src/plugins/connected-routes.ts +56 -83
  43. package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
  44. package/src/plugins/evidence-retrieval-routes.ts +397 -25
  45. package/dist/chunk-KMEZKGSO.js.map +0 -1
@@ -62,6 +62,7 @@ describe("purgeSecondaryRows", () => {
62
62
  ontology_nodes: 0,
63
63
  change_events: 0,
64
64
  incident_investigations: 0,
65
+ incident_leases_reaped: 0,
65
66
  remediation_proposals: 0,
66
67
  investigation_findings: 0,
67
68
  });
@@ -150,4 +151,73 @@ describe("purgeSecondaryRows", () => {
150
151
  ]);
151
152
  db.close();
152
153
  });
154
+
155
+ it("reaps expired leases back to open without touching live leases or non-leased incidents (RFC 0013 § 11)", async () => {
156
+ const db = createMigratedSqliteDb();
157
+ const ins = db.raw.prepare(
158
+ `INSERT INTO incident_investigations (project_id, environment, id, dedup_key, status, target_node_id, symptom_anchor_kind, symptom_anchor_id, lease_owner_id, lease_expires_at, opened_at, updated_at, expires_at)
159
+ VALUES ('alpha', 'prod', ?, ?, ?, 'n', 'trace', 't', ?, ?, ?, ?, ?)`,
160
+ );
161
+ const farFuture = iso(-24 * 365);
162
+ ins.run(
163
+ "lease-expired",
164
+ "k1",
165
+ "leased",
166
+ "runner-a",
167
+ iso(1),
168
+ iso(5),
169
+ iso(5),
170
+ farFuture,
171
+ );
172
+ ins.run(
173
+ "lease-live",
174
+ "k2",
175
+ "leased",
176
+ "runner-b",
177
+ iso(-1),
178
+ iso(5),
179
+ iso(5),
180
+ farFuture,
181
+ );
182
+ ins.run(
183
+ "remediating",
184
+ "k3",
185
+ "remediating",
186
+ "runner-c",
187
+ iso(1),
188
+ iso(5),
189
+ iso(5),
190
+ farFuture,
191
+ );
192
+
193
+ const summary = await purgeSecondaryRows(db, {
194
+ retentionHours: 72,
195
+ now: () => NOW,
196
+ });
197
+ expect(summary.incident_leases_reaped).toBe(1);
198
+ const rows = db.raw
199
+ .prepare(
200
+ `SELECT id, status, lease_owner_id FROM incident_investigations ORDER BY id`,
201
+ )
202
+ .all() as Array<{
203
+ id: string;
204
+ status: string;
205
+ lease_owner_id: string | null;
206
+ }>;
207
+ expect(rows).toEqual([
208
+ { id: "lease-expired", status: "open", lease_owner_id: null },
209
+ { id: "lease-live", status: "leased", lease_owner_id: "runner-b" },
210
+ { id: "remediating", status: "remediating", lease_owner_id: "runner-c" },
211
+ ]);
212
+ db.close();
213
+ });
214
+
215
+ it("propagates storage errors instead of silently swallowing them", async () => {
216
+ const db = createMigratedSqliteDb();
217
+ db.raw.exec("DROP TABLE remediation_proposals");
218
+ await expect(
219
+ purgeSecondaryRows(db, { retentionHours: 72, now: () => NOW }),
220
+ ).rejects.toThrow(/remediation_proposals/);
221
+ db.close();
222
+ });
153
223
  });
@@ -13,7 +13,6 @@
13
13
  * the signal they reference); expansions follow their ref.
14
14
  */
15
15
 
16
- import { evaluateRemediationWatchdogs } from "./autosre-remediation-gate";
17
16
  import type { SqlDb } from "./sql-db";
18
17
 
19
18
  export const ALERT_EVALUATIONS_MAX_AGE_HOURS = 720;
@@ -29,6 +28,8 @@ export interface HousekeepingSummary {
29
28
  ontology_nodes?: number;
30
29
  change_events?: number;
31
30
  incident_investigations?: number;
31
+ /** `leased` incidents whose lease expired and were returned to `open` (RFC 0013 § 11). */
32
+ incident_leases_reaped?: number;
32
33
  investigation_findings?: number;
33
34
  remediation_proposals?: number;
34
35
  }
@@ -92,90 +93,96 @@ export async function purgeSecondaryRows(
92
93
  .run();
93
94
 
94
95
  // RFC 0013 — AutoSRE ontology, change events, incident investigations,
95
- // investigation findings, and remediation proposals retention + watchdog.
96
+ // investigation findings, and remediation proposals retention, plus lease
97
+ // reaping. Errors propagate (the retention caller logs them); the
98
+ // post-remediation watchdog runs on the 1-minute analyses tick instead.
96
99
  const thirtyDaysAgoIso = isoBefore(30 * 24);
97
100
  const ninetyDaysAgoIso = isoBefore(90 * 24);
98
- let ontologyEdgesPurged = 0;
99
- let ontologyNodesPurged = 0;
100
- let changeEventsPurged = 0;
101
- let incidentInvestigationsPurged = 0;
102
- let investigationFindingsPurged = 0;
103
- let remediationProposalsPurged = 0;
104
- try {
105
- const edgesRes = await db
106
- .prepare(`DELETE FROM ontology_edges WHERE last_seen_at < ?`)
107
- .bind(thirtyDaysAgoIso)
108
- .run();
109
- ontologyEdgesPurged = changes(edgesRes);
110
-
111
- const nodesRes = await db
112
- .prepare(
113
- `DELETE FROM ontology_nodes
114
- WHERE last_seen_at < ?
115
- AND NOT EXISTS (
116
- SELECT 1 FROM ontology_edges e
117
- WHERE e.project_id = ontology_nodes.project_id
118
- AND e.environment = ontology_nodes.environment
119
- AND (e.source_node_id = ontology_nodes.id OR e.target_node_id = ontology_nodes.id)
120
- )`,
121
- )
122
- .bind(thirtyDaysAgoIso)
123
- .run();
124
- ontologyNodesPurged = changes(nodesRes);
125
-
126
- const proposalsRes = await db
127
- .prepare(
128
- `DELETE FROM remediation_proposals
129
- WHERE (expires_at IS NOT NULL AND expires_at < ?)
130
- OR (expires_at IS NULL AND created_at < ?)`,
131
- )
132
- .bind(nowIso, ninetyDaysAgoIso)
133
- .run();
134
- remediationProposalsPurged = changes(proposalsRes);
135
-
136
- const findingsRes = await db
137
- .prepare(
138
- `DELETE FROM investigation_findings
139
- WHERE causal_verification_status NOT IN ('counterfactual_verified', 'operationally_corroborated')
140
- AND ((expires_at IS NOT NULL AND expires_at < ?)
141
- OR (expires_at IS NULL AND created_at < ?))`,
142
- )
143
- .bind(nowIso, ninetyDaysAgoIso)
144
- .run();
145
- investigationFindingsPurged = changes(findingsRes);
146
-
147
- // Preserve change_events referenced by active investigation_findings
148
- const changesRes = await db
149
- .prepare(
150
- `DELETE FROM change_events
151
- WHERE ((expires_at IS NOT NULL AND expires_at < ?)
152
- OR (expires_at IS NULL AND occurred_at < ?))
153
- AND NOT EXISTS (
154
- SELECT 1 FROM investigation_findings f
155
- WHERE f.project_id = change_events.project_id
156
- AND f.environment = change_events.environment
157
- AND f.contributing_change_id = change_events.id
158
- )`,
159
- )
160
- .bind(nowIso, ninetyDaysAgoIso)
161
- .run();
162
- changeEventsPurged = changes(changesRes);
163
-
164
- const incidentsRes = await db
165
- .prepare(
166
- `DELETE FROM incident_investigations
167
- WHERE status IN ('resolved', 'escalated')
168
- AND ((expires_at IS NOT NULL AND expires_at < ?)
169
- OR (expires_at IS NULL AND updated_at < ?))`,
170
- )
171
- .bind(nowIso, ninetyDaysAgoIso)
172
- .run();
173
- incidentInvestigationsPurged = changes(incidentsRes);
174
-
175
- await evaluateRemediationWatchdogs(db, { nowIso });
176
- } catch {
177
- // Gracefully ignore when running against partial test schemas without 050
178
- }
101
+ const edgesRes = await db
102
+ .prepare(`DELETE FROM ontology_edges WHERE last_seen_at < ?`)
103
+ .bind(thirtyDaysAgoIso)
104
+ .run();
105
+ const ontologyEdgesPurged = changes(edgesRes);
106
+
107
+ const nodesRes = await db
108
+ .prepare(
109
+ `DELETE FROM ontology_nodes
110
+ WHERE last_seen_at < ?
111
+ AND NOT EXISTS (
112
+ SELECT 1 FROM ontology_edges e
113
+ WHERE e.project_id = ontology_nodes.project_id
114
+ AND e.environment = ontology_nodes.environment
115
+ AND (e.source_node_id = ontology_nodes.id OR e.target_node_id = ontology_nodes.id)
116
+ )`,
117
+ )
118
+ .bind(thirtyDaysAgoIso)
119
+ .run();
120
+ const ontologyNodesPurged = changes(nodesRes);
121
+
122
+ const proposalsRes = await db
123
+ .prepare(
124
+ `DELETE FROM remediation_proposals
125
+ WHERE (expires_at IS NOT NULL AND expires_at < ?)
126
+ OR (expires_at IS NULL AND created_at < ?)`,
127
+ )
128
+ .bind(nowIso, ninetyDaysAgoIso)
129
+ .run();
130
+ const remediationProposalsPurged = changes(proposalsRes);
131
+
132
+ const findingsRes = await db
133
+ .prepare(
134
+ `DELETE FROM investigation_findings
135
+ WHERE causal_verification_status NOT IN ('counterfactual_verified', 'operationally_corroborated')
136
+ AND ((expires_at IS NOT NULL AND expires_at < ?)
137
+ OR (expires_at IS NULL AND created_at < ?))`,
138
+ )
139
+ .bind(nowIso, ninetyDaysAgoIso)
140
+ .run();
141
+ const investigationFindingsPurged = changes(findingsRes);
142
+
143
+ // Preserve change_events referenced by active investigation_findings
144
+ const changesRes = await db
145
+ .prepare(
146
+ `DELETE FROM change_events
147
+ WHERE ((expires_at IS NOT NULL AND expires_at < ?)
148
+ OR (expires_at IS NULL AND occurred_at < ?))
149
+ AND NOT EXISTS (
150
+ SELECT 1 FROM investigation_findings f
151
+ WHERE f.project_id = change_events.project_id
152
+ AND f.environment = change_events.environment
153
+ AND f.contributing_change_id = change_events.id
154
+ )`,
155
+ )
156
+ .bind(nowIso, ninetyDaysAgoIso)
157
+ .run();
158
+ const changeEventsPurged = changes(changesRes);
159
+
160
+ const incidentsRes = await db
161
+ .prepare(
162
+ `DELETE FROM incident_investigations
163
+ WHERE status IN ('resolved', 'escalated')
164
+ AND ((expires_at IS NOT NULL AND expires_at < ?)
165
+ OR (expires_at IS NULL AND updated_at < ?))`,
166
+ )
167
+ .bind(nowIso, ninetyDaysAgoIso)
168
+ .run();
169
+ const incidentInvestigationsPurged = changes(incidentsRes);
170
+
171
+ // Lease reaping (RFC 0013 § 11): a runner that died holding a lease must not
172
+ // strand the incident. Expired `leased` incidents go back to `open` so the
173
+ // next runner can pick them up. `remediating`/`verifying` are owned by the
174
+ // proposal claim/watchdog timeouts, not by this reaper.
175
+ const leasesRes = await db
176
+ .prepare(
177
+ `UPDATE incident_investigations
178
+ SET status = 'open', lease_owner_id = NULL, lease_expires_at = NULL, updated_at = ?
179
+ WHERE status = 'leased'
180
+ AND lease_expires_at IS NOT NULL
181
+ AND lease_expires_at < ?`,
182
+ )
183
+ .bind(nowIso, nowIso)
184
+ .run();
185
+ const incidentLeasesReaped = changes(leasesRes);
179
186
 
180
187
  return {
181
188
  alert_evaluations: changes(alertEvaluations),
@@ -187,6 +194,7 @@ export async function purgeSecondaryRows(
187
194
  ontology_nodes: ontologyNodesPurged,
188
195
  change_events: changeEventsPurged,
189
196
  incident_investigations: incidentInvestigationsPurged,
197
+ incident_leases_reaped: incidentLeasesReaped,
190
198
  investigation_findings: investigationFindingsPurged,
191
199
  remediation_proposals: remediationProposalsPurged,
192
200
  };
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { sha256Hex } from "./hash";
3
- import { ProjectsStore } from "./projects-store";
3
+ import { displayPrefix, ProjectsStore } from "./projects-store";
4
4
  import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
5
5
 
6
6
  describe("ProjectsStore.revokeKey", () => {
@@ -23,3 +23,40 @@ describe("ProjectsStore.revokeKey", () => {
23
23
  db.close();
24
24
  });
25
25
  });
26
+
27
+ describe("bootstrap key display prefix", () => {
28
+ it("never exposes a short bootstrap key in full", async () => {
29
+ const db = createMigratedSqliteDb();
30
+ const store = new ProjectsStore(db);
31
+ await store.bootstrapEnvKey("short-key-12345"); // 15 chars
32
+ const [key] = (await store.listKeys("default")).filter(
33
+ (k) => k.name === "bootstrap",
34
+ );
35
+ expect(key?.keyPrefix).toBe("short");
36
+ expect("short-key-12345".startsWith(key?.keyPrefix ?? "")).toBe(true);
37
+ expect(key?.keyPrefix.length).toBeLessThan("short-key-12345".length);
38
+ db.close();
39
+ });
40
+
41
+ it("keeps 16 chars for long keys, matching minted keys", () => {
42
+ expect(displayPrefix("a".repeat(64))).toHaveLength(16);
43
+ expect(displayPrefix("abc")).toBe("a");
44
+ expect(displayPrefix("")).toBe("");
45
+ });
46
+
47
+ it("shrinks a legacy full-length prefix on the next bootstrap", async () => {
48
+ const db = createMigratedSqliteDb();
49
+ const store = new ProjectsStore(db);
50
+ await store.bootstrapEnvKey("legacy-key-1234");
51
+ await db
52
+ .prepare(`UPDATE ingest_keys SET key_prefix = ? WHERE name = 'bootstrap'`)
53
+ .bind("legacy-key-1234")
54
+ .run();
55
+ await store.bootstrapEnvKey("legacy-key-1234");
56
+ const [key] = (await store.listKeys("default")).filter(
57
+ (k) => k.name === "bootstrap",
58
+ );
59
+ expect(key?.keyPrefix).toBe("legac");
60
+ db.close();
61
+ });
62
+ });
@@ -31,6 +31,15 @@ const rowToKey = (row: IngestKeyRow): IngestKey => ({
31
31
 
32
32
  const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$/;
33
33
 
34
+ /**
35
+ * Display identifier for a secret: at most 16 chars (what minted keys show),
36
+ * but never more than a third of the secret, so a short operator-chosen
37
+ * INGEST_KEY can never be returned in full by `keys list`.
38
+ */
39
+ export function displayPrefix(plaintext: string): string {
40
+ return plaintext.slice(0, Math.min(16, Math.floor(plaintext.length / 3)));
41
+ }
42
+
34
43
  export class ProjectsStore {
35
44
  constructor(private readonly db: SqlDb) {}
36
45
 
@@ -189,14 +198,24 @@ export class ProjectsStore {
189
198
  if (!plaintext) return;
190
199
  await this.ensureDefaultProject();
191
200
  const keyHash = await sha256Hex(plaintext);
201
+ const keyPrefix = displayPrefix(plaintext);
192
202
  const existing = await this.db
193
- .prepare(`SELECT id FROM ingest_keys WHERE key_hash = ?`)
203
+ .prepare(`SELECT id, key_prefix FROM ingest_keys WHERE key_hash = ?`)
194
204
  .bind(keyHash)
195
- .first<{ id: string }>();
196
- if (existing) return;
205
+ .first<{ id: string; key_prefix: string }>();
206
+ if (existing) {
207
+ // Rows written before the prefix was length-capped may hold a short
208
+ // bootstrap key in full; shrink them to the safe display prefix.
209
+ if (existing.key_prefix.length > keyPrefix.length) {
210
+ await this.db
211
+ .prepare(`UPDATE ingest_keys SET key_prefix = ? WHERE id = ?`)
212
+ .bind(keyPrefix, existing.id)
213
+ .run();
214
+ }
215
+ return;
216
+ }
197
217
 
198
218
  const id = randomHex(16);
199
- const keyPrefix = plaintext.slice(0, Math.min(16, plaintext.length));
200
219
  const createdAt = new Date().toISOString();
201
220
  await this.db
202
221
  .prepare(
@@ -28,20 +28,22 @@ import type {
28
28
  StoredSpan,
29
29
  UsageEventRecord,
30
30
  } from "@obsunified/types";
31
- import type { MiddlewareHandler } from "hono";
31
+ import { Hono, type MiddlewareHandler } from "hono";
32
32
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
33
33
  import { deriveAnalysesForProject } from "../analyses/derive";
34
34
  import { INVESTIGATION_ANALYSES } from "../analyses/investigations";
35
35
  import { TIER0_ANALYSES } from "../analyses/tier0";
36
36
  import {
37
- type CollectorRuntime,
37
+ CollectorRuntime,
38
38
  createTelemetryCollectorApp,
39
39
  type SpanProcessorPlugin,
40
40
  } from "../framework/collector";
41
41
  import type { CollectorEnv, CollectorRouteContext } from "../framework/env";
42
42
  import type { DecodedMetricPoint } from "../otlp/decode";
43
43
  import { aiSpanPayloadsProcessorPlugin } from "../plugins/ai-span-payloads-processor";
44
+ import { connectedRoutesPlugin } from "../plugins/connected-routes";
44
45
  import { identityReceiverPlugin } from "../plugins/identity-receiver";
46
+ import { usageQueryRoutesPlugin } from "../plugins/usage-query-routes";
45
47
  import { purgeExpiredActionGraph } from "./action-retention";
46
48
  import { ActionStore } from "./actions-store";
47
49
  import { getAICallsOverview, ingestAICallBatch } from "./ai-store/calls";
@@ -1222,4 +1224,75 @@ describe.skipIf(!databaseUrl)("PostgresAdapter against a live Postgres", () => {
1222
1224
  expect(errorRate.primaryValue).toBe(0.5);
1223
1225
  expect(Array.isArray(errorRate.payload.sparkline)).toBe(true);
1224
1226
  });
1227
+
1228
+ it("serves the change connected-rail and usage sessions routes without Postgres query errors", async () => {
1229
+ const now = isoNow();
1230
+ const later = isoIn(60 * 60 * 1000);
1231
+ const insertSpan = (spanId: string, startTime: string) =>
1232
+ db
1233
+ .prepare(
1234
+ `INSERT INTO telemetry_spans (
1235
+ project_id, environment, trace_id, span_id, service_name, service_version,
1236
+ span_name, status_code, start_time, end_time, duration_ms, received_at, expires_at
1237
+ ) VALUES ('default', 'default', 'trace-pg-1', ?, 'checkout', 'v9.9.9', 'POST /pay', 2, ?, ?, 5, ?, ?)`,
1238
+ )
1239
+ .bind(spanId, startTime, startTime, now, later)
1240
+ .run();
1241
+ await insertSpan("span-pg-a", "2026-01-01T00:00:00.000Z");
1242
+ await insertSpan("span-pg-b", "2026-01-01T00:00:01.000Z");
1243
+ await db
1244
+ .prepare(
1245
+ `INSERT INTO change_events (
1246
+ project_id, environment, id, target_node_id, kind, source, summary,
1247
+ service_name, version, occurred_at, received_at
1248
+ ) VALUES ('default', 'default', 'chg-pg-1', 'service:checkout', 'deploy', 'explicit', 'deploy v9.9.9', 'checkout', 'v9.9.9', ?, ?)`,
1249
+ )
1250
+ .bind(now, now)
1251
+ .run();
1252
+ await new UsageStore(db).ingest([
1253
+ usageEvent({
1254
+ eventId: "pg-s1-a",
1255
+ sessionId: "pg-sess-1",
1256
+ pagePath: "/a",
1257
+ }),
1258
+ usageEvent({
1259
+ eventId: "pg-s1-b",
1260
+ sessionId: "pg-sess-1",
1261
+ pagePath: "/b",
1262
+ eventType: "interaction",
1263
+ }),
1264
+ ]);
1265
+
1266
+ const app = new Hono<{ Bindings: CollectorEnv }>();
1267
+ const runtime = new CollectorRuntime();
1268
+ connectedRoutesPlugin.register(app, runtime);
1269
+ usageQueryRoutesPlugin.register(app, runtime);
1270
+ const env = { DB: db as unknown as D1Database } as CollectorEnv;
1271
+
1272
+ const change = await app.request(
1273
+ "/internal/connected/change/chg-pg-1",
1274
+ {},
1275
+ env,
1276
+ );
1277
+ expect(change.status).toBe(200);
1278
+ const manifest = (await change.json()) as {
1279
+ across: Array<{ links: Array<{ entityId: string }> }>;
1280
+ };
1281
+ const spanIds = manifest.across.flatMap((s) =>
1282
+ s.links.map((l) => l.entityId),
1283
+ );
1284
+ // Most recent first, one link per span.
1285
+ expect(spanIds).toEqual(["trace-pg-1:span-pg-b", "trace-pg-1:span-pg-a"]);
1286
+
1287
+ const sessions = await app.request(
1288
+ "/internal/usage/sessions?hours=24",
1289
+ {},
1290
+ env,
1291
+ );
1292
+ expect(sessions.status).toBe(200);
1293
+ const body = (await sessions.json()) as {
1294
+ sessions: Array<{ sessionId: string; lastPath: string | null }>;
1295
+ };
1296
+ expect(body.sessions.map((r) => r.sessionId)).toContain("pg-sess-1");
1297
+ });
1225
1298
  });
@@ -8,6 +8,7 @@ import type { SqlDb } from "../sql-db";
8
8
  export async function ingestTelemetrySpans(
9
9
  db: SqlDb,
10
10
  spans: StoredSpan[],
11
+ logger: { warn: (message: string) => void } = console,
11
12
  ): Promise<{ inserted: number; traceCount: number }> {
12
13
  if (spans.length === 0) return { inserted: 0, traceCount: 0 };
13
14
 
@@ -82,65 +83,16 @@ export async function ingestTelemetrySpans(
82
83
  ),
83
84
  );
84
85
 
85
- try {
86
- await db.batch(statements);
87
- } catch (err) {
88
- const msg = err instanceof Error ? err.message : String(err);
89
- if (msg.includes("no such column: environment")) {
90
- // Fallback for unit tests running against pre-050 partial schemas
91
- const legacyStatements = enrichedSpans.map((span) =>
92
- db
93
- .prepare(`
94
- INSERT OR IGNORE INTO telemetry_spans (
95
- project_id, trace_id, span_id, parent_span_id, trace_state,
96
- service_name, scope_name, scope_version, span_name, span_kind,
97
- status_code, status_message, start_time, end_time, duration_ms,
98
- attributes_json, dropped_attributes_count,
99
- resource_attributes_json, events_json, dropped_events_count,
100
- links_json, dropped_links_count, received_at, expires_at,
101
- session_id, interaction_id, telemetry_sdk_name
102
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
103
- `)
104
- .bind(
105
- span.projectId,
106
- span.traceId,
107
- span.spanId,
108
- span.parentSpanId,
109
- span.traceState,
110
- span.serviceName,
111
- span.scopeName,
112
- span.scopeVersion,
113
- span.spanName,
114
- span.spanKind,
115
- span.statusCode,
116
- span.statusMessage,
117
- span.startTime,
118
- span.endTime,
119
- span.durationMs,
120
- span.attributesJson,
121
- span.droppedAttributesCount,
122
- span.resourceAttributesJson,
123
- span.eventsJson,
124
- span.droppedEventsCount,
125
- span.linksJson,
126
- span.droppedLinksCount,
127
- span.receivedAt,
128
- span.expiresAt,
129
- span.sessionId ?? null,
130
- span.interactionId ?? null,
131
- span.telemetrySdkName ?? null,
132
- ),
133
- );
134
- await db.batch(legacyStatements);
135
- } else {
136
- throw err;
137
- }
138
- }
86
+ await db.batch(statements);
139
87
 
140
88
  try {
141
89
  await upsertOntologyFromSpans(db, enrichedSpans);
142
- } catch {
143
- // Non-fatal on partial-schema unit test DBs
90
+ } catch (err) {
91
+ // Ontology enrichment must never block span ingest, but a failure here
92
+ // means the causal graph is silently going stale, so surface it.
93
+ logger.warn(
94
+ `upsertOntologyFromSpans failed: ${err instanceof Error ? err.message : String(err)}`,
95
+ );
144
96
  }
145
97
 
146
98
  // Trace instrumentation gaps are computed lazily at read time (see
@@ -503,15 +503,21 @@ export class UsageStore {
503
503
  SUM(CASE WHEN event_type = 'interaction' THEN 1 ELSE 0 END) AS interaction_count,
504
504
  SUM(CASE WHEN event_type = 'frontend_error' OR severity = 'error' THEN 1 ELSE 0 END) AS error_count,
505
505
  MAX(CASE WHEN event_type = 'page_view' THEN CAST(COALESCE(${loadMsExpr}, ${loadTimeMsExpr}, ${durationMsExpr}) AS REAL) ELSE NULL END) AS max_load_ms,
506
- (SELECT referrer FROM usage_events ref WHERE ref.project_id = ue.project_id AND ref.session_id = ue.session_id ORDER BY occurred_at ASC LIMIT 1) AS referrer,
507
- (SELECT page_path FROM usage_events lp WHERE lp.project_id = ue.project_id AND lp.session_id = ue.session_id ORDER BY occurred_at DESC LIMIT 1) AS last_path
506
+ (SELECT referrer FROM usage_events ref WHERE ref.project_id = ? AND ref.session_id = ue.session_id ORDER BY occurred_at ASC LIMIT 1) AS referrer,
507
+ (SELECT page_path FROM usage_events lp WHERE lp.project_id = ? AND lp.session_id = ue.session_id ORDER BY occurred_at DESC LIMIT 1) AS last_path
508
508
  FROM usage_events ue
509
509
  WHERE project_id = ? AND received_at >= ?
510
510
  GROUP BY session_id
511
511
  ORDER BY last_seen DESC
512
512
  LIMIT ?`,
513
513
  )
514
- .bind(options.projectId, cutoff, limit * 3)
514
+ .bind(
515
+ options.projectId,
516
+ options.projectId,
517
+ options.projectId,
518
+ cutoff,
519
+ limit * 3,
520
+ )
515
521
  .all<{
516
522
  session_id: string;
517
523
  visitor_id: string;
@@ -0,0 +1,31 @@
1
+ -- Make active-incident deduplication race-free (RFC 0013 § 6.A, governance review B4).
2
+ --
3
+ -- `openOrDeduplicateIncident` used to SELECT-then-INSERT, so two concurrent
4
+ -- alerts for the same symptom could each open an incident. A partial UNIQUE
5
+ -- index lets the database arbitrate; the application inserts with
6
+ -- ON CONFLICT DO NOTHING and re-reads the winner.
7
+
8
+ -- Collapse any pre-existing duplicate active rows (keep the newest per key) so
9
+ -- the index can be built. Older duplicates are closed as escalated, not deleted.
10
+ UPDATE incident_investigations
11
+ SET status = 'escalated'
12
+ WHERE status IN ('open', 'dispatched', 'leased', 'remediating', 'verifying')
13
+ AND EXISTS (
14
+ SELECT 1 FROM incident_investigations o
15
+ WHERE o.project_id = incident_investigations.project_id
16
+ AND o.environment = incident_investigations.environment
17
+ AND o.dedup_key = incident_investigations.dedup_key
18
+ AND o.status IN ('open', 'dispatched', 'leased', 'remediating', 'verifying')
19
+ AND (
20
+ o.opened_at > incident_investigations.opened_at
21
+ OR (o.opened_at = incident_investigations.opened_at AND o.id > incident_investigations.id)
22
+ )
23
+ );
24
+
25
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_incidents_active_dedup
26
+ ON incident_investigations (project_id, environment, dedup_key)
27
+ WHERE status IN ('open', 'dispatched', 'leased', 'remediating', 'verifying');
28
+
29
+ -- Watchdog (governance review B11): a finding is only `recovered` after two
30
+ -- consecutive checkpoints with sufficient traffic and a healthy error rate.
31
+ ALTER TABLE investigation_findings ADD COLUMN consecutive_ok_checks INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,10 @@
1
+ -- Persisted grant-handoff callback (RFC 0013 § 6.B, journey review J17).
2
+ --
3
+ -- A proposal parked for human approval may carry a webhook that receives the
4
+ -- one-time grant token when the approval happens later. `callback_url` is the
5
+ -- target (SSRF-guarded again at delivery time); `callback_secret` is the HMAC
6
+ -- signing secret, stored as supplied (the collector has no at-rest encryption
7
+ -- helper) and never returned by any GET. Both are cleared once the grant is
8
+ -- delivered, rejected or expired.
9
+ ALTER TABLE remediation_proposals ADD COLUMN callback_url TEXT;
10
+ ALTER TABLE remediation_proposals ADD COLUMN callback_secret TEXT;
@@ -0,0 +1,7 @@
1
+ -- Last grant-handoff webhook delivery outcome (RFC 0013 § 6.B).
2
+ --
3
+ -- JSON `{status: 'delivered'|'failed', attempts, lastError, at}` recorded after
4
+ -- the grant webhook is attempted (up to 3 attempts). It never contains the
5
+ -- callback URL or secret, so unlike callback_url it is kept after approval so
6
+ -- the dashboard can flag a failed handoff.
7
+ ALTER TABLE remediation_proposals ADD COLUMN callback_delivery TEXT;
@@ -44,9 +44,9 @@ describe("migration 049 (alert_state.notified)", () => {
44
44
  .map((c) => (c as { name: string }).name);
45
45
  expect(columnsBefore).not.toContain("notified");
46
46
 
47
- expect(
48
- applySqliteMigrations(db.raw, { from: "049", upTo: "049" }),
49
- ).toEqual(["049_alert_state_notified.sql"]);
47
+ expect(applySqliteMigrations(db.raw, { from: "049", upTo: "049" })).toEqual(
48
+ ["049_alert_state_notified.sql"],
49
+ );
50
50
 
51
51
  const notified = db.raw
52
52
  .prepare("PRAGMA table_info(alert_state)")