@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
@@ -0,0 +1,938 @@
1
+ import type {
2
+ CanonicalNodeIdentity,
3
+ CausalTriple,
4
+ JsonValue,
5
+ OntologyEdge,
6
+ OntologyGraphResponse,
7
+ OntologyNode,
8
+ OntologyNodeKind,
9
+ RelationshipCertainty,
10
+ StoredSpan,
11
+ } from "@obsunified/types";
12
+ import {
13
+ buildCanonicalNodeId,
14
+ normalizeErrorSignature,
15
+ } from "@obsunified/types";
16
+ import { normalizeEnvironment } from "@obsunified/types/lineage";
17
+ import { randomHex } from "./hash";
18
+ import { parseJsonArray, parseJsonRecord } from "./json";
19
+ import type { SqlDb, SqlStatement } from "./sql-db";
20
+
21
+ export interface ExtractedSpanLineage {
22
+ environment: string;
23
+ namespace: string;
24
+ serviceName: string | null;
25
+ serviceVersion: string | null;
26
+ commitSha: string | null;
27
+ configDigest: string | null;
28
+ sourceNode: CanonicalNodeIdentity | null;
29
+ targetNode: CanonicalNodeIdentity | null;
30
+ edgePredicate: string | null;
31
+ errorSignature: string | null;
32
+ }
33
+
34
+ const pickStr = (
35
+ record: Record<string, JsonValue>,
36
+ ...keys: string[]
37
+ ): string | null => {
38
+ for (const key of keys) {
39
+ const val = record[key];
40
+ if (typeof val === "string" && val.trim().length > 0) {
41
+ return val.trim();
42
+ }
43
+ if (typeof val === "number") {
44
+ return String(val);
45
+ }
46
+ }
47
+ return null;
48
+ };
49
+
50
+ /**
51
+ * Extracts runtime lineage (`environment`, `serviceVersion`, `commitSha`, `configDigest`),
52
+ * canonical source/target `OntologyNode` identities, and `errorSignature` from a span.
53
+ */
54
+ export function extractSpanLineageAndTopology(
55
+ span: StoredSpan,
56
+ ): ExtractedSpanLineage {
57
+ const attrs = parseJsonRecord(span.attributesJson);
58
+ const resource = parseJsonRecord(span.resourceAttributesJson);
59
+
60
+ const environment = normalizeEnvironment(
61
+ span.environment ??
62
+ pickStr(
63
+ resource,
64
+ "deployment.environment.name",
65
+ "deployment.environment",
66
+ "obs.environment",
67
+ ) ??
68
+ pickStr(
69
+ attrs,
70
+ "deployment.environment.name",
71
+ "deployment.environment",
72
+ "obs.environment",
73
+ ) ??
74
+ undefined,
75
+ );
76
+
77
+ const namespace =
78
+ pickStr(resource, "service.namespace") ??
79
+ pickStr(attrs, "service.namespace") ??
80
+ "default";
81
+
82
+ const serviceName =
83
+ span.serviceName ??
84
+ pickStr(resource, "service.name") ??
85
+ pickStr(attrs, "service.name");
86
+
87
+ const serviceVersion =
88
+ span.serviceVersion ??
89
+ pickStr(resource, "service.version", "obs.service.version") ??
90
+ pickStr(attrs, "service.version", "obs.service.version");
91
+
92
+ const commitSha =
93
+ span.commitSha ??
94
+ pickStr(
95
+ resource,
96
+ "vcs.commit.id",
97
+ "git.commit.sha",
98
+ "obs.commit.sha",
99
+ "service.commit.sha",
100
+ ) ??
101
+ pickStr(attrs, "vcs.commit.id", "git.commit.sha", "obs.commit.sha");
102
+
103
+ const configDigest =
104
+ pickStr(resource, "obs.config.digest") ??
105
+ pickStr(attrs, "obs.config.digest");
106
+
107
+ const sourceNode = serviceName
108
+ ? buildCanonicalNodeId({
109
+ kind: "service",
110
+ environment,
111
+ namespace,
112
+ serviceName,
113
+ })
114
+ : null;
115
+
116
+ // Determine downstream target node for DB, cache, queue, external API, or peer service calls
117
+ let targetNode: CanonicalNodeIdentity | null = null;
118
+ let edgePredicate: string | null = null;
119
+
120
+ const dbSystem = pickStr(attrs, "db.system");
121
+ const messagingSystem = pickStr(attrs, "messaging.system");
122
+ const peerService = pickStr(attrs, "peer.service", "obs.target.service");
123
+ const serverAddress = pickStr(
124
+ attrs,
125
+ "server.address",
126
+ "net.peer.name",
127
+ "db.connection_string",
128
+ );
129
+ const serverPort = pickStr(attrs, "server.port", "net.peer.port");
130
+ const toolName = pickStr(attrs, "obs.tool.name", "tool.name");
131
+
132
+ if (dbSystem) {
133
+ const isCache =
134
+ dbSystem.toLowerCase() === "redis" ||
135
+ dbSystem.toLowerCase() === "memcached";
136
+ targetNode = buildCanonicalNodeId({
137
+ kind: isCache ? "cache" : "database",
138
+ environment,
139
+ dbSystem,
140
+ dbName: pickStr(attrs, "db.namespace", "db.name") ?? dbSystem,
141
+ serverAddress,
142
+ serverPort,
143
+ });
144
+ edgePredicate = isCache ? "queries_cache" : "queries_database";
145
+ } else if (messagingSystem) {
146
+ targetNode = buildCanonicalNodeId({
147
+ kind: "queue_topic",
148
+ environment,
149
+ messagingSystem,
150
+ messagingDestination:
151
+ pickStr(attrs, "messaging.destination.name", "messaging.destination") ??
152
+ "default",
153
+ serverAddress,
154
+ });
155
+ edgePredicate =
156
+ span.spanKind === 4 ? "publishes_to_queue" : "consumes_from_queue";
157
+ } else if (peerService) {
158
+ targetNode = buildCanonicalNodeId({
159
+ kind: "service",
160
+ environment,
161
+ namespace: pickStr(attrs, "peer.namespace") ?? namespace,
162
+ serviceName: peerService,
163
+ });
164
+ edgePredicate = "calls_service";
165
+ } else if (span.spanKind === 2 && serverAddress) {
166
+ // Outbound client span to external host or internal service
167
+ targetNode = buildCanonicalNodeId({
168
+ kind: "external_api",
169
+ environment,
170
+ serverAddress,
171
+ serverPort: serverPort ?? "443",
172
+ });
173
+ edgePredicate = "calls_external_api";
174
+ } else if (toolName) {
175
+ targetNode = buildCanonicalNodeId({
176
+ kind: "tool",
177
+ environment,
178
+ serviceName:
179
+ pickStr(attrs, "obs.agent.id", "agent.id") ?? serviceName ?? "default",
180
+ toolName,
181
+ });
182
+ edgePredicate = "invokes_tool";
183
+ }
184
+
185
+ // Extract normalized error signature when status_code === 2 (ERROR)
186
+ let errorSignature: string | null = span.errorSignature ?? null;
187
+ if (!errorSignature && span.statusCode === 2) {
188
+ let rawErr =
189
+ pickStr(
190
+ attrs,
191
+ "exception.type",
192
+ "error.type",
193
+ "obs.tool.error_type",
194
+ "http.response.status_code",
195
+ "http.status_code",
196
+ "rpc.grpc.status_code",
197
+ ) ?? span.statusMessage;
198
+
199
+ if (!rawErr && span.eventsJson) {
200
+ const events = parseJsonArray(span.eventsJson);
201
+ for (const ev of events) {
202
+ if (ev && typeof ev === "object" && !Array.isArray(ev)) {
203
+ const evObj = ev as Record<string, JsonValue>;
204
+ if (evObj.name === "exception" && evObj.attributes) {
205
+ const evAttrs =
206
+ typeof evObj.attributes === "object" &&
207
+ !Array.isArray(evObj.attributes)
208
+ ? (evObj.attributes as Record<string, JsonValue>)
209
+ : {};
210
+ rawErr =
211
+ pickStr(evAttrs, "exception.type", "exception.message") ?? rawErr;
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ errorSignature = normalizeErrorSignature(
218
+ rawErr ?? `ERROR:${span.spanName}`,
219
+ );
220
+ }
221
+
222
+ return {
223
+ environment,
224
+ namespace,
225
+ serviceName,
226
+ serviceVersion,
227
+ commitSha,
228
+ configDigest,
229
+ sourceNode,
230
+ targetNode,
231
+ edgePredicate,
232
+ errorSignature,
233
+ };
234
+ }
235
+
236
+ // 60-second isolate cache to avoid repeating identical ontology node upserts on every span
237
+ const NODE_UPSERT_CACHE = new Map<string, number>();
238
+ const CACHE_TTL_MS = 60_000;
239
+ const NODE_UPSERT_CACHE_MAX = 5_000;
240
+
241
+ function rememberUpserts(keys: Iterable<string>, nowMs: number): void {
242
+ for (const key of keys) {
243
+ // Re-insert so Map iteration order tracks recency; evict oldest past the cap.
244
+ NODE_UPSERT_CACHE.delete(key);
245
+ NODE_UPSERT_CACHE.set(key, nowMs);
246
+ }
247
+ while (NODE_UPSERT_CACHE.size > NODE_UPSERT_CACHE_MAX) {
248
+ const oldest = NODE_UPSERT_CACHE.keys().next().value;
249
+ if (oldest === undefined) break;
250
+ NODE_UPSERT_CACHE.delete(oldest);
251
+ }
252
+ }
253
+
254
+ export function clearOntologyUpsertCache(): void {
255
+ NODE_UPSERT_CACHE.clear();
256
+ }
257
+
258
+ /**
259
+ * Upserts environment-scoped `ontology_nodes`, `ontology_edges`, and inferred version-transition
260
+ * `change_events` from ingested spans.
261
+ */
262
+ export async function upsertOntologyFromSpans(
263
+ db: SqlDb,
264
+ spans: StoredSpan[],
265
+ options: { bypassCache?: boolean } = {},
266
+ ): Promise<void> {
267
+ if (spans.length === 0) return;
268
+
269
+ const nowMs = Date.now();
270
+ const statements: SqlStatement[] = [];
271
+ // Cache keys are only remembered once the batch has been written successfully.
272
+ const pendingCacheKeys = new Set<string>();
273
+ const edgeAggregates = new Map<
274
+ string,
275
+ {
276
+ projectId: string;
277
+ environment: string;
278
+ id: string;
279
+ sourceNodeId: string;
280
+ predicate: string;
281
+ targetNodeId: string;
282
+ certainty: RelationshipCertainty;
283
+ callCount: number;
284
+ errorCount: number;
285
+ lastDurationMs: number;
286
+ firstSeenAt: string;
287
+ lastSeenAt: string;
288
+ }
289
+ >();
290
+
291
+ // Track version transitions per service node
292
+ const serviceNodesToCheck = new Map<
293
+ string,
294
+ {
295
+ projectId: string;
296
+ environment: string;
297
+ node: CanonicalNodeIdentity;
298
+ version: string | null;
299
+ commitSha: string | null;
300
+ configDigest: string | null;
301
+ timestamp: string;
302
+ }
303
+ >();
304
+
305
+ for (const span of spans) {
306
+ const lineage = extractSpanLineageAndTopology(span);
307
+ const ts = span.receivedAt || span.startTime || new Date().toISOString();
308
+
309
+ if (lineage.sourceNode) {
310
+ const cacheKey = `${span.projectId}::${lineage.environment}::${lineage.sourceNode.id}::${lineage.serviceVersion ?? ""}::${lineage.commitSha ?? ""}::${lineage.configDigest ?? ""}`;
311
+ const cachedAt = NODE_UPSERT_CACHE.get(cacheKey);
312
+ if (
313
+ !pendingCacheKeys.has(cacheKey) &&
314
+ (options.bypassCache || !cachedAt || nowMs - cachedAt > CACHE_TTL_MS)
315
+ ) {
316
+ pendingCacheKeys.add(cacheKey);
317
+ serviceNodesToCheck.set(
318
+ `${span.projectId}::${lineage.environment}::${lineage.sourceNode.id}`,
319
+ {
320
+ projectId: span.projectId,
321
+ environment: lineage.environment,
322
+ node: lineage.sourceNode,
323
+ version: lineage.serviceVersion,
324
+ commitSha: lineage.commitSha,
325
+ configDigest: lineage.configDigest,
326
+ timestamp: ts,
327
+ },
328
+ );
329
+ }
330
+ }
331
+
332
+ if (lineage.targetNode) {
333
+ const targetCacheKey = `${span.projectId}::${lineage.environment}::${lineage.targetNode.id}`;
334
+ const cachedAt = NODE_UPSERT_CACHE.get(targetCacheKey);
335
+ if (
336
+ !pendingCacheKeys.has(targetCacheKey) &&
337
+ (options.bypassCache || !cachedAt || nowMs - cachedAt > CACHE_TTL_MS)
338
+ ) {
339
+ pendingCacheKeys.add(targetCacheKey);
340
+ statements.push(
341
+ db
342
+ .prepare(
343
+ `INSERT INTO ontology_nodes (
344
+ project_id, environment, id, kind, name, namespace_or_endpoint,
345
+ is_scoped_identity, attributes_json, first_seen_at, last_seen_at
346
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)
347
+ ON CONFLICT (project_id, environment, id) DO UPDATE SET
348
+ last_seen_at = excluded.last_seen_at`,
349
+ )
350
+ .bind(
351
+ span.projectId,
352
+ lineage.environment,
353
+ lineage.targetNode.id,
354
+ lineage.targetNode.kind,
355
+ lineage.targetNode.name,
356
+ lineage.targetNode.namespaceOrEndpoint,
357
+ lineage.targetNode.isScopedIdentity ? 1 : 0,
358
+ ts,
359
+ ts,
360
+ ),
361
+ );
362
+ }
363
+ }
364
+
365
+ if (lineage.sourceNode && lineage.targetNode && lineage.edgePredicate) {
366
+ const edgeId = `${lineage.sourceNode.id}--${lineage.edgePredicate}-->${lineage.targetNode.id}`;
367
+ const aggKey = `${span.projectId}::${lineage.environment}::${edgeId}`;
368
+ const certainty: RelationshipCertainty =
369
+ lineage.sourceNode.isScopedIdentity &&
370
+ lineage.targetNode.isScopedIdentity
371
+ ? "exact"
372
+ : "inferred";
373
+ const existing = edgeAggregates.get(aggKey);
374
+ const isErr = span.statusCode === 2 ? 1 : 0;
375
+ if (existing) {
376
+ existing.callCount += 1;
377
+ existing.errorCount += isErr;
378
+ existing.lastDurationMs = span.durationMs;
379
+ if (ts > existing.lastSeenAt) existing.lastSeenAt = ts;
380
+ } else {
381
+ edgeAggregates.set(aggKey, {
382
+ projectId: span.projectId,
383
+ environment: lineage.environment,
384
+ id: edgeId,
385
+ sourceNodeId: lineage.sourceNode.id,
386
+ predicate: lineage.edgePredicate,
387
+ targetNodeId: lineage.targetNode.id,
388
+ certainty,
389
+ callCount: 1,
390
+ errorCount: isErr,
391
+ lastDurationMs: span.durationMs,
392
+ firstSeenAt: ts,
393
+ lastSeenAt: ts,
394
+ });
395
+ }
396
+ }
397
+ }
398
+
399
+ // Check version transitions for service nodes before upserting them
400
+ for (const entry of serviceNodesToCheck.values()) {
401
+ if (entry.version) {
402
+ const existingNode = await db
403
+ .prepare(
404
+ `SELECT current_version, current_commit_sha
405
+ FROM ontology_nodes
406
+ WHERE project_id = ? AND environment = ? AND id = ?`,
407
+ )
408
+ .bind(entry.projectId, entry.environment, entry.node.id)
409
+ .first<{
410
+ current_version: string | null;
411
+ current_commit_sha: string | null;
412
+ }>();
413
+
414
+ if (
415
+ existingNode?.current_version &&
416
+ existingNode.current_version !== entry.version
417
+ ) {
418
+ // Check if an explicit change_event already recorded this transition
419
+ const existingChange = await db
420
+ .prepare(
421
+ `SELECT id FROM change_events
422
+ WHERE project_id = ? AND environment = ? AND target_node_id = ? AND version = ?
423
+ LIMIT 1`,
424
+ )
425
+ .bind(
426
+ entry.projectId,
427
+ entry.environment,
428
+ entry.node.id,
429
+ entry.version,
430
+ )
431
+ .first<{ id: string }>();
432
+
433
+ if (!existingChange) {
434
+ const changeId = `chg_inf_${randomHex(8)}`;
435
+ const expiresAt = new Date(
436
+ Date.now() + 30 * 24 * 3600_000,
437
+ ).toISOString();
438
+ statements.push(
439
+ db
440
+ .prepare(
441
+ `INSERT INTO change_events (
442
+ project_id, environment, id, target_node_id, kind, source,
443
+ relationship_certainty, summary, service_name, version,
444
+ previous_version, commit_sha, previous_commit_sha, config_digest,
445
+ touched_paths_json, attributes_json, occurred_at, received_at, expires_at
446
+ ) VALUES (?, ?, ?, ?, 'deploy', 'inferred_telemetry', 'inferred', ?, ?, ?, ?, ?, ?, ?, '[]', '{}', ?, ?, ?)
447
+ ON CONFLICT (project_id, environment, id) DO NOTHING`,
448
+ )
449
+ .bind(
450
+ entry.projectId,
451
+ entry.environment,
452
+ changeId,
453
+ entry.node.id,
454
+ `Inferred version transition ${existingNode.current_version} -> ${entry.version} on ${entry.node.name}`,
455
+ entry.node.name,
456
+ entry.version,
457
+ existingNode.current_version,
458
+ entry.commitSha,
459
+ existingNode.current_commit_sha,
460
+ entry.configDigest,
461
+ entry.timestamp,
462
+ entry.timestamp,
463
+ expiresAt,
464
+ ),
465
+ );
466
+ }
467
+ }
468
+ }
469
+
470
+ statements.push(
471
+ db
472
+ .prepare(
473
+ `INSERT INTO ontology_nodes (
474
+ project_id, environment, id, kind, name, namespace_or_endpoint,
475
+ is_scoped_identity, current_version, current_commit_sha,
476
+ current_config_digest, attributes_json, first_seen_at, last_seen_at
477
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)
478
+ ON CONFLICT (project_id, environment, id) DO UPDATE SET
479
+ current_version = COALESCE(excluded.current_version, ontology_nodes.current_version),
480
+ current_commit_sha = COALESCE(excluded.current_commit_sha, ontology_nodes.current_commit_sha),
481
+ current_config_digest = COALESCE(excluded.current_config_digest, ontology_nodes.current_config_digest),
482
+ last_seen_at = excluded.last_seen_at`,
483
+ )
484
+ .bind(
485
+ entry.projectId,
486
+ entry.environment,
487
+ entry.node.id,
488
+ entry.node.kind,
489
+ entry.node.name,
490
+ entry.node.namespaceOrEndpoint,
491
+ entry.node.isScopedIdentity ? 1 : 0,
492
+ entry.version,
493
+ entry.commitSha,
494
+ entry.configDigest,
495
+ entry.timestamp,
496
+ entry.timestamp,
497
+ ),
498
+ );
499
+ }
500
+
501
+ for (const edge of edgeAggregates.values()) {
502
+ statements.push(
503
+ db
504
+ .prepare(
505
+ `INSERT INTO ontology_edges (
506
+ project_id, environment, id, source_node_id, predicate, target_node_id,
507
+ relationship_certainty, call_count, error_count, last_duration_ms,
508
+ first_seen_at, last_seen_at
509
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
510
+ ON CONFLICT (project_id, environment, id) DO UPDATE SET
511
+ call_count = ontology_edges.call_count + excluded.call_count,
512
+ error_count = ontology_edges.error_count + excluded.error_count,
513
+ last_duration_ms = excluded.last_duration_ms,
514
+ last_seen_at = excluded.last_seen_at`,
515
+ )
516
+ .bind(
517
+ edge.projectId,
518
+ edge.environment,
519
+ edge.id,
520
+ edge.sourceNodeId,
521
+ edge.predicate,
522
+ edge.targetNodeId,
523
+ edge.certainty,
524
+ edge.callCount,
525
+ edge.errorCount,
526
+ edge.lastDurationMs,
527
+ edge.firstSeenAt,
528
+ edge.lastSeenAt,
529
+ ),
530
+ );
531
+ }
532
+
533
+ if (statements.length > 0) {
534
+ await db.batch(statements);
535
+ }
536
+ rememberUpserts(pendingCacheKeys, nowMs);
537
+ }
538
+
539
+ /**
540
+ * Tarjan's O(V + E) Strongly Connected Components (SCC) condensation algorithm
541
+ * (RFC 0013 § 1.B & P2 #6). Ensures cyclic service/queue topologies never cause
542
+ * infinite loops during dependency traversal.
543
+ */
544
+ export function computeTarjanScc(
545
+ nodes: OntologyNode[],
546
+ edges: OntologyEdge[],
547
+ ): {
548
+ annotatedNodes: OntologyNode[];
549
+ components: Array<{ sccId: string; nodeIds: string[]; isCyclic: boolean }>;
550
+ } {
551
+ const adjacency = new Map<string, string[]>();
552
+ const selfLoops = new Set<string>();
553
+
554
+ for (const node of nodes) {
555
+ adjacency.set(node.id, []);
556
+ }
557
+ for (const edge of edges) {
558
+ if (!adjacency.has(edge.sourceNodeId)) {
559
+ adjacency.set(edge.sourceNodeId, []);
560
+ }
561
+ if (!adjacency.has(edge.targetNodeId)) {
562
+ adjacency.set(edge.targetNodeId, []);
563
+ }
564
+ adjacency.get(edge.sourceNodeId)?.push(edge.targetNodeId);
565
+ if (edge.sourceNodeId === edge.targetNodeId) {
566
+ selfLoops.add(edge.sourceNodeId);
567
+ }
568
+ }
569
+
570
+ let index = 0;
571
+ const indices = new Map<string, number>();
572
+ const lowlink = new Map<string, number>();
573
+ const onStack = new Set<string>();
574
+ const stack: string[] = [];
575
+ const rawComponents: string[][] = [];
576
+
577
+ const strongConnect = (nodeId: string) => {
578
+ indices.set(nodeId, index);
579
+ lowlink.set(nodeId, index);
580
+ index += 1;
581
+ stack.push(nodeId);
582
+ onStack.add(nodeId);
583
+
584
+ const neighbors = adjacency.get(nodeId) ?? [];
585
+ for (const targetId of neighbors) {
586
+ if (!indices.has(targetId)) {
587
+ strongConnect(targetId);
588
+ lowlink.set(
589
+ nodeId,
590
+ Math.min(lowlink.get(nodeId) ?? 0, lowlink.get(targetId) ?? 0),
591
+ );
592
+ } else if (onStack.has(targetId)) {
593
+ lowlink.set(
594
+ nodeId,
595
+ Math.min(lowlink.get(nodeId) ?? 0, indices.get(targetId) ?? 0),
596
+ );
597
+ }
598
+ }
599
+
600
+ if (lowlink.get(nodeId) === indices.get(nodeId)) {
601
+ const component: string[] = [];
602
+ while (stack.length > 0) {
603
+ const w = stack.pop();
604
+ if (w === undefined) break;
605
+ onStack.delete(w);
606
+ component.push(w);
607
+ if (w === nodeId) break;
608
+ }
609
+ component.sort();
610
+ rawComponents.push(component);
611
+ }
612
+ };
613
+
614
+ for (const nodeId of adjacency.keys()) {
615
+ if (!indices.has(nodeId)) {
616
+ strongConnect(nodeId);
617
+ }
618
+ }
619
+
620
+ const nodeSccMap = new Map<string, { sccId: string; isCyclic: boolean }>();
621
+ const components = rawComponents.map((nodeIds, idx) => {
622
+ const sccId = `scc_${idx + 1}`;
623
+ const isCyclic =
624
+ nodeIds.length > 1 || (nodeIds.length === 1 && selfLoops.has(nodeIds[0]));
625
+ for (const id of nodeIds) {
626
+ nodeSccMap.set(id, { sccId, isCyclic });
627
+ }
628
+ return { sccId, nodeIds, isCyclic };
629
+ });
630
+
631
+ const annotatedNodes = nodes.map((n) => {
632
+ const scc = nodeSccMap.get(n.id);
633
+ return {
634
+ ...n,
635
+ sccId: scc?.sccId ?? "scc_0",
636
+ inCycle: scc?.isCyclic ?? false,
637
+ };
638
+ });
639
+
640
+ return { annotatedNodes, components };
641
+ }
642
+
643
+ export interface TraceSpanLocusInput {
644
+ span_id: string;
645
+ parent_span_id: string | null;
646
+ service_name: string | null;
647
+ span_name: string;
648
+ span_kind: number;
649
+ status_code: number;
650
+ status_message: string | null;
651
+ start_time: string;
652
+ end_time: string;
653
+ duration_ms: number;
654
+ environment?: string | null;
655
+ target_node_id?: string | null;
656
+ error_signature?: string | null;
657
+ }
658
+
659
+ export interface TraceFaultLocusResult {
660
+ faultLocusSpan: TraceSpanLocusInput | null;
661
+ faultLocusNodeId: string | null;
662
+ relationshipCertainty: RelationshipCertainty;
663
+ causalProofTier: "unverified_locus";
664
+ causalTriples: CausalTriple[];
665
+ }
666
+
667
+ /**
668
+ * Per-trace cycle-guarded DFS fault locus detector (RFC 0013 § 1.B & P1 #1).
669
+ * Identifies where an error surfaced (`originated_fault_in_span`) while keeping
670
+ * `causalProofTier: "unverified_locus"` separate from `relationshipCertainty: "exact"`.
671
+ */
672
+ export function localizeTraceFaultLocus(
673
+ traceId: string,
674
+ spans: TraceSpanLocusInput[],
675
+ ): TraceFaultLocusResult {
676
+ if (spans.length === 0) {
677
+ return {
678
+ faultLocusSpan: null,
679
+ faultLocusNodeId: null,
680
+ relationshipCertainty: "exact",
681
+ causalProofTier: "unverified_locus",
682
+ causalTriples: [],
683
+ };
684
+ }
685
+
686
+ const spanById = new Map<string, TraceSpanLocusInput>();
687
+ const childrenByParent = new Map<string, TraceSpanLocusInput[]>();
688
+ for (const s of spans) {
689
+ spanById.set(s.span_id, s);
690
+ if (s.parent_span_id) {
691
+ const list = childrenByParent.get(s.parent_span_id) ?? [];
692
+ list.push(s);
693
+ childrenByParent.set(s.parent_span_id, list);
694
+ }
695
+ }
696
+
697
+ for (const list of childrenByParent.values()) {
698
+ list.sort((a, b) => a.start_time.localeCompare(b.start_time));
699
+ }
700
+
701
+ const roots = spans
702
+ .filter((s) => !s.parent_span_id || !spanById.has(s.parent_span_id))
703
+ .sort((a, b) => a.start_time.localeCompare(b.start_time));
704
+
705
+ const visited = new Set<string>();
706
+ const causalTriples: CausalTriple[] = [];
707
+ const leafErrorSpans: TraceSpanLocusInput[] = [];
708
+ const MAX_DEPTH = 256;
709
+
710
+ const walk = (span: TraceSpanLocusInput, depth: number) => {
711
+ if (visited.has(span.span_id) || depth > MAX_DEPTH) return;
712
+ visited.add(span.span_id);
713
+
714
+ const children = (childrenByParent.get(span.span_id) ?? []).filter(
715
+ (c) => !visited.has(c.span_id),
716
+ );
717
+ const errorChildren = children.filter((c) => c.status_code === 2);
718
+
719
+ if (span.status_code === 2) {
720
+ if (errorChildren.length === 0) {
721
+ leafErrorSpans.push(span);
722
+ } else {
723
+ for (const errChild of errorChildren) {
724
+ causalTriples.push({
725
+ subjectKind: "span",
726
+ subjectId: span.span_id,
727
+ predicate: "failed_due_to_child_span",
728
+ objectKind: "span",
729
+ objectId: errChild.span_id,
730
+ relationshipCertainty: "exact",
731
+ causalProofTier: "unverified_locus",
732
+ confidence: 0.85,
733
+ reason: `Parent span ${span.span_name} failed while child span ${errChild.span_name} returned ERROR`,
734
+ });
735
+ }
736
+ }
737
+ }
738
+
739
+ for (const child of children) {
740
+ walk(child, depth + 1);
741
+ }
742
+ };
743
+
744
+ for (const root of roots) {
745
+ walk(root, 0);
746
+ }
747
+ // Catch any disconnected cycle islands
748
+ for (const s of spans) {
749
+ if (!visited.has(s.span_id)) {
750
+ walk(s, 0);
751
+ }
752
+ }
753
+
754
+ leafErrorSpans.sort((a, b) => a.start_time.localeCompare(b.start_time));
755
+ const faultLocusSpan =
756
+ leafErrorSpans[0] ?? spans.find((s) => s.status_code === 2) ?? null;
757
+
758
+ let faultLocusNodeId: string | null = null;
759
+ if (faultLocusSpan) {
760
+ const env = (faultLocusSpan.environment ?? "default").toLowerCase();
761
+ faultLocusNodeId =
762
+ faultLocusSpan.target_node_id ??
763
+ (faultLocusSpan.service_name
764
+ ? buildCanonicalNodeId({
765
+ kind: "service",
766
+ environment: env,
767
+ serviceName: faultLocusSpan.service_name,
768
+ }).id
769
+ : null);
770
+
771
+ causalTriples.unshift({
772
+ subjectKind: "trace",
773
+ subjectId: traceId,
774
+ predicate: "originated_fault_in_span",
775
+ objectKind: "span",
776
+ objectId: faultLocusSpan.span_id,
777
+ relationshipCertainty: "exact",
778
+ causalProofTier: "unverified_locus",
779
+ confidence: 0.9,
780
+ reason: `Deepest/earliest failing span ${faultLocusSpan.span_name} (${faultLocusSpan.service_name ?? "unknown"}) is the trace fault locus`,
781
+ });
782
+ }
783
+
784
+ return {
785
+ faultLocusSpan,
786
+ faultLocusNodeId,
787
+ relationshipCertainty: "exact",
788
+ causalProofTier: "unverified_locus",
789
+ causalTriples,
790
+ };
791
+ }
792
+
793
+ /**
794
+ * Queries `telemetry_spans` for a trace and computes its fault locus.
795
+ */
796
+ export async function loadTraceFaultLocusFromDb(
797
+ db: SqlDb,
798
+ projectId: string,
799
+ traceId: string,
800
+ ): Promise<TraceFaultLocusResult> {
801
+ const rows = await db
802
+ .prepare(
803
+ `SELECT
804
+ span_id, parent_span_id, service_name, span_name, span_kind,
805
+ status_code, status_message, start_time, end_time, duration_ms,
806
+ environment, target_node_id, error_signature
807
+ FROM telemetry_spans
808
+ WHERE project_id = ? AND trace_id = ?
809
+ ORDER BY start_time ASC
810
+ LIMIT 500`,
811
+ )
812
+ .bind(projectId, traceId)
813
+ .all<TraceSpanLocusInput>();
814
+ return localizeTraceFaultLocus(traceId, rows.results);
815
+ }
816
+
817
+ interface OntologyNodeRow {
818
+ project_id: string;
819
+ environment: string;
820
+ id: string;
821
+ kind: string;
822
+ name: string;
823
+ namespace_or_endpoint: string;
824
+ is_scoped_identity: number;
825
+ current_version: string | null;
826
+ current_commit_sha: string | null;
827
+ current_config_digest: string | null;
828
+ attributes_json: string | null;
829
+ first_seen_at: string;
830
+ last_seen_at: string;
831
+ }
832
+
833
+ interface OntologyEdgeRow {
834
+ project_id: string;
835
+ environment: string;
836
+ id: string;
837
+ source_node_id: string;
838
+ predicate: string;
839
+ target_node_id: string;
840
+ relationship_certainty: string;
841
+ call_count: number;
842
+ error_count: number;
843
+ last_duration_ms: number | null;
844
+ first_seen_at: string;
845
+ last_seen_at: string;
846
+ }
847
+
848
+ /**
849
+ * Loads the environment-scoped operational ontology graph with Tarjan SCC
850
+ * cycle metadata and dependency blackout indicators.
851
+ */
852
+ export async function getOntologyGraph(
853
+ db: SqlDb,
854
+ options: { projectId: string; environment?: string },
855
+ ): Promise<OntologyGraphResponse> {
856
+ const env = (options.environment ?? "default").toLowerCase();
857
+
858
+ const [nodeRows, edgeRows] = await Promise.all([
859
+ db
860
+ .prepare(
861
+ `SELECT * FROM ontology_nodes
862
+ WHERE project_id = ? AND environment = ?
863
+ ORDER BY id ASC
864
+ LIMIT 500`,
865
+ )
866
+ .bind(options.projectId, env)
867
+ .all<OntologyNodeRow>(),
868
+ db
869
+ .prepare(
870
+ `SELECT * FROM ontology_edges
871
+ WHERE project_id = ? AND environment = ?
872
+ ORDER BY call_count DESC
873
+ LIMIT 1000`,
874
+ )
875
+ .bind(options.projectId, env)
876
+ .all<OntologyEdgeRow>(),
877
+ ]);
878
+
879
+ const rawNodes: OntologyNode[] = nodeRows.results.map((r) => ({
880
+ projectId: r.project_id,
881
+ environment: r.environment,
882
+ id: r.id,
883
+ kind: r.kind as OntologyNodeKind,
884
+ name: r.name,
885
+ namespaceOrEndpoint: r.namespace_or_endpoint,
886
+ isScopedIdentity: r.is_scoped_identity === 1,
887
+ currentVersion: r.current_version,
888
+ currentCommitSha: r.current_commit_sha,
889
+ currentConfigDigest: r.current_config_digest,
890
+ attributes: parseJsonRecord(r.attributes_json ?? "{}"),
891
+ firstSeenAt: r.first_seen_at,
892
+ lastSeenAt: r.last_seen_at,
893
+ }));
894
+
895
+ const edges: OntologyEdge[] = edgeRows.results.map((r) => ({
896
+ projectId: r.project_id,
897
+ environment: r.environment,
898
+ id: r.id,
899
+ sourceNodeId: r.source_node_id,
900
+ predicate: r.predicate,
901
+ targetNodeId: r.target_node_id,
902
+ relationshipCertainty: (r.relationship_certainty ||
903
+ "exact") as RelationshipCertainty,
904
+ callCount: Number(r.call_count ?? 0),
905
+ errorCount: Number(r.error_count ?? 0),
906
+ lastDurationMs:
907
+ r.last_duration_ms === null ? null : Number(r.last_duration_ms),
908
+ firstSeenAt: r.first_seen_at,
909
+ lastSeenAt: r.last_seen_at,
910
+ }));
911
+
912
+ const { annotatedNodes, components } = computeTarjanScc(rawNodes, edges);
913
+
914
+ // Detect suspected dependency blackouts: targets with high inbound error edges
915
+ const inboundErrorsByTarget = new Map<string, number>();
916
+ for (const e of edges) {
917
+ if (e.errorCount > 0) {
918
+ inboundErrorsByTarget.set(
919
+ e.targetNodeId,
920
+ (inboundErrorsByTarget.get(e.targetNodeId) ?? 0) + e.errorCount,
921
+ );
922
+ }
923
+ }
924
+
925
+ const nodes = annotatedNodes.map((n) => ({
926
+ ...n,
927
+ isBlackoutSuspected:
928
+ (inboundErrorsByTarget.get(n.id) ?? 0) >= 3 &&
929
+ edges.filter((e) => e.sourceNodeId === n.id).length === 0,
930
+ }));
931
+
932
+ return {
933
+ environment: env,
934
+ nodes,
935
+ edges,
936
+ stronglyConnectedComponents: components,
937
+ };
938
+ }