@obsunified/collector 2.1.1 → 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.
Files changed (37) hide show
  1. package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  2. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  3. package/dist/index.d.ts +359 -2
  4. package/dist/index.js +5000 -334
  5. package/dist/index.js.map +1 -1
  6. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  7. package/package.json +2 -2
  8. package/src/__tests__/autosre-closed-loop.test.ts +2222 -0
  9. package/src/index.ts +43 -0
  10. package/src/lib/analyses-runner.ts +46 -0
  11. package/src/lib/autosre-change-correlation.ts +807 -0
  12. package/src/lib/autosre-findings.ts +763 -0
  13. package/src/lib/autosre-ontology-cache.test.ts +78 -0
  14. package/src/lib/autosre-ontology.ts +938 -0
  15. package/src/lib/autosre-remediation-gate.ts +2127 -0
  16. package/src/lib/eval-cases-store.ts +63 -1
  17. package/src/lib/evidence-references.ts +6 -0
  18. package/src/lib/housekeeping.test.ts +150 -0
  19. package/src/lib/housekeeping.ts +109 -1
  20. package/src/lib/store/ingest.ts +46 -6
  21. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  22. package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
  23. package/src/migrations/alert-state-notified.test.ts +3 -3
  24. package/src/migrations/parity.test.ts +4 -0
  25. package/src/migrations/project-scoped-keys.test.ts +1 -1
  26. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  27. package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
  28. package/src/plugins/alerts-evaluator.ts +29 -1
  29. package/src/plugins/autosre-routes.ts +1134 -0
  30. package/src/plugins/connected-routes/manifest.ts +10 -1
  31. package/src/plugins/connected-routes.ts +241 -0
  32. package/src/plugins/default-span-enrichment.ts +13 -1
  33. package/src/plugins/eval-cases-routes.ts +57 -2
  34. package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
  35. package/src/plugins/evidence-retrieval-routes.ts +643 -5
  36. package/dist/chunk-XKQTVIRE.js.map +0 -1
  37. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -76,6 +76,10 @@ export interface EvalRunCandidate {
76
76
  modelProvider: string | null;
77
77
  model: string | null;
78
78
  modelVersion: string | null;
79
+ environment?: string | null;
80
+ serviceVersion?: string | null;
81
+ commitSha?: string | null;
82
+ configDigest?: string | null;
79
83
  }
80
84
 
81
85
  export interface EvalRunSourceCase {
@@ -99,6 +103,12 @@ export interface EvalRun {
99
103
  evalCaseId: string | null;
100
104
  status: EvalRunStatus;
101
105
  candidate: EvalRunCandidate;
106
+ environment?: string;
107
+ serviceVersion?: string | null;
108
+ commitSha?: string | null;
109
+ configDigest?: string | null;
110
+ inputPayloadHash?: string | null;
111
+ failureFingerprint?: string | null;
102
112
  startedAt: string | null;
103
113
  endedAt: string | null;
104
114
  totalCount: number;
@@ -117,6 +127,12 @@ export interface EvalRunInput {
117
127
  evalCaseId?: string | null;
118
128
  status?: EvalRunStatus;
119
129
  candidate?: Partial<EvalRunCandidate>;
130
+ environment?: string | null;
131
+ serviceVersion?: string | null;
132
+ commitSha?: string | null;
133
+ configDigest?: string | null;
134
+ inputPayloadHash?: string | null;
135
+ failureFingerprint?: string | null;
120
136
  startedAt?: string | null;
121
137
  endedAt?: string | null;
122
138
  totalCount?: number;
@@ -144,6 +160,12 @@ interface EvalRunRow {
144
160
  candidate_model_provider: string | null;
145
161
  candidate_model: string | null;
146
162
  candidate_model_version: string | null;
163
+ environment?: string | null;
164
+ service_version?: string | null;
165
+ commit_sha?: string | null;
166
+ config_digest?: string | null;
167
+ input_payload_hash?: string | null;
168
+ failure_fingerprint?: string | null;
147
169
  status: string;
148
170
  started_at: string | null;
149
171
  ended_at: string | null;
@@ -412,7 +434,17 @@ const rowToEvalRun = (row: EvalRunRow): EvalRun => {
412
434
  modelProvider: row.candidate_model_provider ?? null,
413
435
  model: row.candidate_model ?? null,
414
436
  modelVersion: row.candidate_model_version ?? null,
437
+ environment: row.environment ?? "default",
438
+ serviceVersion: row.service_version ?? null,
439
+ commitSha: row.commit_sha ?? null,
440
+ configDigest: row.config_digest ?? null,
415
441
  },
442
+ environment: row.environment ?? "default",
443
+ serviceVersion: row.service_version ?? null,
444
+ commitSha: row.commit_sha ?? null,
445
+ configDigest: row.config_digest ?? null,
446
+ inputPayloadHash: row.input_payload_hash ?? null,
447
+ failureFingerprint: row.failure_fingerprint ?? null,
416
448
  startedAt: row.started_at ?? null,
417
449
  endedAt: row.ended_at ?? null,
418
450
  totalCount: row.total_count ?? 0,
@@ -560,6 +592,16 @@ export class EvalCasesStore {
560
592
  }
561
593
 
562
594
  const candidate = input.candidate ?? {};
595
+ const environment =
596
+ input.environment?.trim() || candidate.environment?.trim() || "default";
597
+ const serviceVersion =
598
+ input.serviceVersion?.trim() || candidate.serviceVersion?.trim() || null;
599
+ const commitSha =
600
+ input.commitSha?.trim() || candidate.commitSha?.trim() || null;
601
+ const configDigest =
602
+ input.configDigest?.trim() || candidate.configDigest?.trim() || null;
603
+ const inputPayloadHash = input.inputPayloadHash?.trim() || null;
604
+ const failureFingerprint = input.failureFingerprint?.trim() || null;
563
605
  const totalCount = nonNegativeInteger(input.totalCount);
564
606
  const passCount = nonNegativeInteger(input.passCount);
565
607
  const failCount = nonNegativeInteger(input.failCount);
@@ -580,10 +622,12 @@ export class EvalCasesStore {
580
622
  candidate_agent_id, candidate_agent_version,
581
623
  candidate_prompt_id, candidate_prompt_version,
582
624
  candidate_model_provider, candidate_model, candidate_model_version,
625
+ environment, service_version, commit_sha, config_digest,
626
+ input_payload_hash, failure_fingerprint,
583
627
  status, started_at, ended_at,
584
628
  total_count, pass_count, fail_count, average_score,
585
629
  metadata_json, created_at
586
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
630
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
587
631
  )
588
632
  .bind(
589
633
  id,
@@ -596,6 +640,12 @@ export class EvalCasesStore {
596
640
  candidate.modelProvider ?? null,
597
641
  candidate.model ?? null,
598
642
  candidate.modelVersion ?? null,
643
+ environment,
644
+ serviceVersion,
645
+ commitSha,
646
+ configDigest,
647
+ inputPayloadHash,
648
+ failureFingerprint,
599
649
  status,
600
650
  startedAt,
601
651
  endedAt,
@@ -679,6 +729,14 @@ export class EvalCasesStore {
679
729
  average_score = (
680
730
  SELECT AVG(score) FROM eval_case_results
681
731
  WHERE project_id = ? AND run_id = ? AND score IS NOT NULL
732
+ ),
733
+ input_payload_hash = COALESCE(
734
+ input_payload_hash,
735
+ (SELECT input_payload_hash FROM eval_case_results WHERE project_id = ? AND run_id = ? AND input_payload_hash IS NOT NULL LIMIT 1)
736
+ ),
737
+ failure_fingerprint = COALESCE(
738
+ failure_fingerprint,
739
+ (SELECT failure_fingerprint FROM eval_case_results WHERE project_id = ? AND run_id = ? AND failure_fingerprint IS NOT NULL LIMIT 1)
682
740
  )
683
741
  WHERE project_id = ? AND id = ?`,
684
742
  )
@@ -693,6 +751,10 @@ export class EvalCasesStore {
693
751
  id,
694
752
  projectId,
695
753
  id,
754
+ projectId,
755
+ id,
756
+ projectId,
757
+ id,
696
758
  )
697
759
  .run();
698
760
  }
@@ -146,6 +146,12 @@ export const evidenceRouteFor = (
146
146
  return traceRoute(entityId);
147
147
  case "docs":
148
148
  return entityId.startsWith("#/") ? entityId : `#/docs/${entityId}`;
149
+ case "change":
150
+ return `#/changes/${encodeURIComponent(entityId)}`;
151
+ case "finding":
152
+ return `#/findings/${encodeURIComponent(entityId)}`;
153
+ case "incident":
154
+ return `#/incidents/${encodeURIComponent(entityId)}`;
149
155
  }
150
156
  const exhaustive: never = entityKind;
151
157
  return exhaustive;
@@ -58,6 +58,13 @@ describe("purgeSecondaryRows", () => {
58
58
  metric_series: 1,
59
59
  evidence_refs: 1,
60
60
  evidence_ref_expansions: 1,
61
+ ontology_edges: 0,
62
+ ontology_nodes: 0,
63
+ change_events: 0,
64
+ incident_investigations: 0,
65
+ incident_leases_reaped: 0,
66
+ remediation_proposals: 0,
67
+ investigation_findings: 0,
61
68
  });
62
69
  const ids = (sql: string) =>
63
70
  (raw.prepare(sql).all() as Array<{ id: string }>).map((r) => r.id).sort();
@@ -70,4 +77,147 @@ describe("purgeSecondaryRows", () => {
70
77
  expect(ids("SELECT id FROM evidence_ref_expansions")).toEqual(["e2"]);
71
78
  db.close();
72
79
  });
80
+
81
+ it("enforces RFC 0013 AutoSRE retention windows while preserving counterfactual_verified and operationally_corroborated findings", async () => {
82
+ const db = createMigratedSqliteDb();
83
+ const raw = db.raw;
84
+ raw.exec(`
85
+ INSERT INTO projects (id, name, slug, created_at) VALUES ('alpha', 'A', 'a', '${iso(3000)}');
86
+ `);
87
+ // Ontology edges/nodes (30d = 720h)
88
+ raw
89
+ .prepare(
90
+ `INSERT INTO ontology_nodes (id, project_id, environment, kind, name, first_seen_at, last_seen_at) VALUES (?, 'alpha', 'prod', 'service', ?, ?, ?)`,
91
+ )
92
+ .run("n-old", "old-svc", iso(800), iso(750));
93
+ raw
94
+ .prepare(
95
+ `INSERT INTO ontology_nodes (id, project_id, environment, kind, name, first_seen_at, last_seen_at) VALUES (?, 'alpha', 'prod', 'service', ?, ?, ?)`,
96
+ )
97
+ .run("n-fresh", "fresh-svc", iso(100), iso(10));
98
+ raw
99
+ .prepare(
100
+ `INSERT INTO ontology_edges (id, project_id, environment, source_node_id, predicate, target_node_id, relationship_certainty, first_seen_at, last_seen_at) VALUES (?, 'alpha', 'prod', 'n-old', 'calls', 'n-fresh', 'exact', ?, ?)`,
101
+ )
102
+ .run("edge-old", iso(800), iso(750));
103
+
104
+ // Change events (90d = 2160h)
105
+ raw
106
+ .prepare(
107
+ `INSERT INTO change_events (id, project_id, environment, kind, source, target_node_id, summary, occurred_at, received_at) VALUES (?, 'alpha', 'prod', 'deploy', 'ci_webhook', 'n-fresh', 'old deploy', ?, ?)`,
108
+ )
109
+ .run("chg-old", iso(2200), iso(2200));
110
+ raw
111
+ .prepare(
112
+ `INSERT INTO change_events (id, project_id, environment, kind, source, target_node_id, summary, occurred_at, received_at) VALUES (?, 'alpha', 'prod', 'deploy', 'ci_webhook', 'n-fresh', 'recent deploy', ?, ?)`,
113
+ )
114
+ .run("chg-recent", iso(100), iso(100));
115
+
116
+ // Findings (90d = 2160h, unless counterfactual_verified or operationally_corroborated)
117
+ const insFinding = raw.prepare(
118
+ `INSERT INTO investigation_findings (id, project_id, environment, fingerprint, fingerprint_input_json, title, summary, causal_proof_tier, causal_verification_status, confidence, confidence_breakdown_json, symptom_entity_kind, symptom_entity_id, fault_locus_entity_kind, fault_locus_entity_id, causal_chain_json, cited_evidence_ids_json, actor_type, actor_id, created_at, updated_at) VALUES (?, 'alpha', 'prod', 'fp_1', '{}', 't', 's', 'corroborated_candidate', ?, 0.8, '{}', 'trace', 't1', 'span', 's1', '[]', '["t1"]', 'agent', 'autosre', ?, ?)`,
119
+ );
120
+ insFinding.run("fnd-old-unverified", "unverified", iso(2200), iso(2200));
121
+ insFinding.run(
122
+ "fnd-old-verified",
123
+ "counterfactual_verified",
124
+ iso(2200),
125
+ iso(2200),
126
+ );
127
+ insFinding.run(
128
+ "fnd-old-corroborated",
129
+ "operationally_corroborated",
130
+ iso(2200),
131
+ iso(2200),
132
+ );
133
+
134
+ const summary = await purgeSecondaryRows(db, {
135
+ retentionHours: 72,
136
+ now: () => NOW,
137
+ });
138
+ expect(summary.ontology_edges).toBe(1);
139
+ expect(summary.ontology_nodes).toBe(1);
140
+ expect(summary.change_events).toBe(1);
141
+ expect(summary.investigation_findings).toBe(1);
142
+
143
+ const remainingFindings = (
144
+ raw
145
+ .prepare(`SELECT id FROM investigation_findings ORDER BY id ASC`)
146
+ .all() as Array<{ id: string }>
147
+ ).map((r) => r.id);
148
+ expect(remainingFindings).toEqual([
149
+ "fnd-old-corroborated",
150
+ "fnd-old-verified",
151
+ ]);
152
+ db.close();
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
+ });
73
223
  });
@@ -24,6 +24,14 @@ export interface HousekeepingSummary {
24
24
  metric_series: number;
25
25
  evidence_refs: number;
26
26
  evidence_ref_expansions: number;
27
+ ontology_edges?: number;
28
+ ontology_nodes?: number;
29
+ change_events?: number;
30
+ incident_investigations?: number;
31
+ /** `leased` incidents whose lease expired and were returned to `open` (RFC 0013 § 11). */
32
+ incident_leases_reaped?: number;
33
+ investigation_findings?: number;
34
+ remediation_proposals?: number;
27
35
  }
28
36
 
29
37
  export interface HousekeepingOptions {
@@ -42,6 +50,7 @@ export async function purgeSecondaryRows(
42
50
  const nowMs = options.now?.() ?? Date.now();
43
51
  const isoBefore = (hours: number) =>
44
52
  new Date(nowMs - hours * 3_600_000).toISOString();
53
+ const nowIso = isoBefore(0);
45
54
 
46
55
  const alertEvaluations = await db
47
56
  .prepare(`DELETE FROM alert_evaluations WHERE evaluated_at < ?`)
@@ -71,7 +80,7 @@ export async function purgeSecondaryRows(
71
80
  `DELETE FROM evidence_retrieval_refs
72
81
  WHERE expires_at IS NOT NULL AND expires_at < ?`,
73
82
  )
74
- .bind(isoBefore(0))
83
+ .bind(nowIso)
75
84
  .run();
76
85
  const expansions = await db
77
86
  .prepare(
@@ -83,11 +92,110 @@ export async function purgeSecondaryRows(
83
92
  )
84
93
  .run();
85
94
 
95
+ // RFC 0013 — AutoSRE ontology, change events, incident investigations,
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.
99
+ const thirtyDaysAgoIso = isoBefore(30 * 24);
100
+ const ninetyDaysAgoIso = isoBefore(90 * 24);
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);
186
+
86
187
  return {
87
188
  alert_evaluations: changes(alertEvaluations),
88
189
  ask_evidence_events: changes(askEvents),
89
190
  metric_series: changes(series),
90
191
  evidence_refs: changes(refs),
91
192
  evidence_ref_expansions: changes(expansions),
193
+ ontology_edges: ontologyEdgesPurged,
194
+ ontology_nodes: ontologyNodesPurged,
195
+ change_events: changeEventsPurged,
196
+ incident_investigations: incidentInvestigationsPurged,
197
+ incident_leases_reaped: incidentLeasesReaped,
198
+ investigation_findings: investigationFindingsPurged,
199
+ remediation_proposals: remediationProposalsPurged,
92
200
  };
93
201
  }
@@ -1,16 +1,40 @@
1
1
  import type { StoredSpan } from "@obsunified/types";
2
+ import {
3
+ extractSpanLineageAndTopology,
4
+ upsertOntologyFromSpans,
5
+ } from "../autosre-ontology";
2
6
  import type { SqlDb } from "../sql-db";
3
7
 
4
8
  export async function ingestTelemetrySpans(
5
9
  db: SqlDb,
6
10
  spans: StoredSpan[],
11
+ logger: { warn: (message: string) => void } = console,
7
12
  ): Promise<{ inserted: number; traceCount: number }> {
8
13
  if (spans.length === 0) return { inserted: 0, traceCount: 0 };
9
14
 
10
- const statements = spans.map((span) => {
15
+ const enrichedSpans = spans.map((span) => {
11
16
  if (!span.projectId)
12
17
  throw new Error("TelemetryStore.ingest: span.projectId is required");
13
- return db
18
+ if (
19
+ span.environment !== undefined &&
20
+ span.serviceVersion !== undefined &&
21
+ span.targetNodeId !== undefined
22
+ ) {
23
+ return span;
24
+ }
25
+ const lineage = extractSpanLineageAndTopology(span);
26
+ return {
27
+ ...span,
28
+ environment: span.environment ?? lineage.environment,
29
+ serviceVersion: span.serviceVersion ?? lineage.serviceVersion,
30
+ commitSha: span.commitSha ?? lineage.commitSha,
31
+ targetNodeId: span.targetNodeId ?? lineage.targetNode?.id ?? null,
32
+ errorSignature: span.errorSignature ?? lineage.errorSignature,
33
+ };
34
+ });
35
+
36
+ const statements = enrichedSpans.map((span) =>
37
+ db
14
38
  .prepare(`
15
39
  INSERT OR IGNORE INTO telemetry_spans (
16
40
  project_id, trace_id, span_id, parent_span_id, trace_state,
@@ -19,8 +43,9 @@ export async function ingestTelemetrySpans(
19
43
  attributes_json, dropped_attributes_count,
20
44
  resource_attributes_json, events_json, dropped_events_count,
21
45
  links_json, dropped_links_count, received_at, expires_at,
22
- session_id, interaction_id, telemetry_sdk_name
23
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
46
+ session_id, interaction_id, telemetry_sdk_name,
47
+ environment, service_version, commit_sha, target_node_id, error_signature
48
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
24
49
  `)
25
50
  .bind(
26
51
  span.projectId,
@@ -50,11 +75,26 @@ export async function ingestTelemetrySpans(
50
75
  span.sessionId ?? null,
51
76
  span.interactionId ?? null,
52
77
  span.telemetrySdkName ?? null,
53
- );
54
- });
78
+ span.environment ?? "default",
79
+ span.serviceVersion ?? null,
80
+ span.commitSha ?? null,
81
+ span.targetNodeId ?? null,
82
+ span.errorSignature ?? null,
83
+ ),
84
+ );
55
85
 
56
86
  await db.batch(statements);
57
87
 
88
+ try {
89
+ await upsertOntologyFromSpans(db, enrichedSpans);
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
+ );
96
+ }
97
+
58
98
  // Trace instrumentation gaps are computed lazily at read time (see
59
99
  // `getTelemetryTraceGaps`), not materialized on ingest. This keeps the
60
100
  // ingest hot path to a single batched span write and avoids recomputing