@obsunified/collector 2.2.0 → 2.2.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.
@@ -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
  };
@@ -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
@@ -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;
@@ -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)")
@@ -0,0 +1,31 @@
1
+ -- (PostgreSQL mirror) 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 IF NOT EXISTS consecutive_ok_checks BIGINT NOT NULL DEFAULT 0;
@@ -11,7 +11,10 @@ import {
11
11
  type Logger,
12
12
  } from "../framework/logger";
13
13
  import { AlertsStore, compareValue } from "../lib/alerts-store";
14
- import { openOrDeduplicateIncident } from "../lib/autosre-remediation-gate";
14
+ import {
15
+ openOrDeduplicateIncident,
16
+ resolveServiceIncidentTarget,
17
+ } from "../lib/autosre-remediation-gate";
15
18
  import { sqlDbFor } from "../lib/sql-db";
16
19
  import {
17
20
  assertSafeWebhookUrl,
@@ -439,9 +442,18 @@ export async function evaluateAllRules(
439
442
  allowPrivateWebhookTargets,
440
443
  hostLookup: options?.hostLookup,
441
444
  onAlertFiring: async (rule) => {
445
+ // Target the rule's service when it names one (environment taken from
446
+ // that service's latest spans); otherwise fall back to the rule itself.
447
+ const target = await resolveServiceIncidentTarget(
448
+ db,
449
+ rule.projectId,
450
+ (rule.query as { serviceName?: string }).serviceName,
451
+ `alert:${rule.id}`,
452
+ logger,
453
+ );
442
454
  await openOrDeduplicateIncident(db, rule.projectId, {
443
- environment: "default",
444
- targetNodeId: `alert:${rule.id}`,
455
+ environment: target.environment,
456
+ targetNodeId: target.targetNodeId,
445
457
  symptomAnchorKind: "alert",
446
458
  symptomAnchorId: rule.id,
447
459
  });