@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.
@@ -36,7 +36,11 @@ import {
36
36
  type Logger,
37
37
  } from "../framework/logger";
38
38
  import { AnalysesStore } from "./analyses-store";
39
- import { openOrDeduplicateIncident } from "./autosre-remediation-gate";
39
+ import {
40
+ evaluateRemediationWatchdogs,
41
+ openOrDeduplicateIncident,
42
+ resolveServiceIncidentTarget,
43
+ } from "./autosre-remediation-gate";
40
44
  import { generateNarrative, LlmCallError, type LlmConfig } from "./llm";
41
45
  import {
42
46
  computeSignature,
@@ -292,6 +296,20 @@ export async function runAllDueAnalyses(
292
296
  totals.narrated += summary.narrated;
293
297
  totals.skipped += summary.skipped ?? 0;
294
298
  }
299
+
300
+ // RFC 0013 § 5.B: the post-remediation watchdog (grant/claim timeouts and the
301
+ // +2m/+5m/+15m recovery checkpoints) rides the 1-minute tick so checkpoints
302
+ // are evaluated close to when they come due. Cross-project by design.
303
+ try {
304
+ const wd = await evaluateRemediationWatchdogs(db, { logger });
305
+ if (wd.evaluated + wd.timedOutClaims + wd.expiredGrants + wd.failed > 0) {
306
+ logger.info("[analyses] remediation watchdog summary", { ...wd });
307
+ }
308
+ } catch (error) {
309
+ logger.error("[analyses] remediation watchdog failed", {
310
+ error: error instanceof Error ? error.message : String(error),
311
+ });
312
+ }
295
313
  return totals;
296
314
  }
297
315
 
@@ -477,12 +495,31 @@ async function runDueAnalysesForProject(
477
495
  }
478
496
  await store.insertResult(result, expiresAt);
479
497
  if (result.status === "critical") {
480
- await openOrDeduplicateIncident(db, projectId, {
481
- environment: "default",
482
- targetNodeId: `analysis:${def.id}`,
483
- symptomAnchorKind: "analysis",
484
- symptomAnchorId: def.id,
485
- }).catch(() => undefined);
498
+ try {
499
+ const scopeService = def.scope?.service;
500
+ const target = await resolveServiceIncidentTarget(
501
+ db,
502
+ projectId,
503
+ typeof scopeService === "string" ? scopeService : null,
504
+ `analysis:${def.id}`,
505
+ logger,
506
+ );
507
+ await openOrDeduplicateIncident(db, projectId, {
508
+ environment: target.environment,
509
+ targetNodeId: target.targetNodeId,
510
+ symptomAnchorKind: "analysis",
511
+ symptomAnchorId: def.id,
512
+ });
513
+ } catch (incidentError) {
514
+ // Incident bookkeeping must not fail the analysis run.
515
+ logger.error("[analyses] failed to open incident", {
516
+ analysis_id: def.id,
517
+ error:
518
+ incidentError instanceof Error
519
+ ? incidentError.message
520
+ : String(incidentError),
521
+ });
522
+ }
486
523
  }
487
524
  // markRan releases the claim as a side-effect.
488
525
  await store.markRan(projectId, def.id, result.generatedAt);
@@ -12,6 +12,7 @@ import type {
12
12
  RelationshipCertainty,
13
13
  } from "@obsunified/types";
14
14
  import { buildCanonicalNodeId } from "@obsunified/types";
15
+ import { normalizeEnvironment } from "@obsunified/types/lineage";
15
16
  import { randomHex } from "./hash";
16
17
  import { parseJsonArray, parseJsonRecord } from "./json";
17
18
  import type { SqlDb } from "./sql-db";
@@ -73,14 +74,30 @@ export const mapChangeEventRow = (row: ChangeEventRow): ChangeEvent => ({
73
74
  expiresAt: row.expires_at,
74
75
  });
75
76
 
77
+ export class InvalidTimestampError extends Error {
78
+ constructor(field: string, value: string) {
79
+ super(`${field} must be a valid ISO-8601 timestamp, got "${value}"`);
80
+ this.name = "InvalidTimestampError";
81
+ }
82
+ }
83
+
84
+ /** Canonicalizes a client timestamp to `toISOString()` form so string comparison in SQL is sound. */
85
+ const normalizeTimestamp = (field: string, value: string): string => {
86
+ const ms = new Date(value).getTime();
87
+ if (!Number.isFinite(ms)) throw new InvalidTimestampError(field, value);
88
+ return new Date(ms).toISOString();
89
+ };
90
+
76
91
  export async function recordChangeEvent(
77
92
  db: SqlDb,
78
93
  projectId: string,
79
94
  input: ChangeEventInput,
80
95
  ): Promise<ChangeEvent> {
81
96
  const nowIso = new Date().toISOString();
82
- const occurredAt = input.occurredAt || nowIso;
83
- const environment = (input.environment || "default").trim().toLowerCase();
97
+ const occurredAt = input.occurredAt
98
+ ? normalizeTimestamp("occurredAt", input.occurredAt)
99
+ : nowIso;
100
+ const environment = normalizeEnvironment(input.environment);
84
101
  const namespace = (input.namespace || "default").trim();
85
102
  const serviceName = input.serviceName?.trim() || null;
86
103
 
@@ -257,7 +274,7 @@ export async function listChangeEvents(
257
274
  }
258
275
  if (options.since) {
259
276
  conditions.push("occurred_at >= ?");
260
- binds.push(options.since);
277
+ binds.push(normalizeTimestamp("since", options.since));
261
278
  }
262
279
 
263
280
  const limit = Math.max(1, Math.min(200, options.limit ?? 50));
@@ -346,8 +363,10 @@ const summarizeCohort = (
346
363
  requestCount > 0 ? downstreamCount / requestCount : 0;
347
364
  const avgDownstreamDurationMs =
348
365
  downstreamCount > 0
349
- ? downstreamSpans.reduce((acc, s) => acc + Number(s.duration_ms ?? 0), 0) /
350
- downstreamCount
366
+ ? downstreamSpans.reduce(
367
+ (acc, s) => acc + Number(s.duration_ms ?? 0),
368
+ 0,
369
+ ) / downstreamCount
351
370
  : null;
352
371
 
353
372
  return {
@@ -433,7 +452,9 @@ export async function findCrossTraceContention(
433
452
  }>(),
434
453
  ]);
435
454
 
436
- const isScoped = nodeRow ? nodeRow.is_scoped_identity === 1 : !options.targetNodeId.includes(":unscoped/");
455
+ const isScoped = nodeRow
456
+ ? nodeRow.is_scoped_identity === 1
457
+ : !options.targetNodeId.includes(":unscoped/");
437
458
  const relationshipCertainty: RelationshipCertainty = isScoped
438
459
  ? "exact"
439
460
  : "inferred";
@@ -455,7 +476,11 @@ export async function findCrossTraceContention(
455
476
  const extractFilePathsFromSpan = (span: SpanCohortSampleRow): string[] => {
456
477
  const found: string[] = [];
457
478
  const attrs = parseJsonRecord(span.attributes_json ?? "{}");
458
- for (const key of ["code.filepath", "obs.code.filepath", "exception.stacktrace"]) {
479
+ for (const key of [
480
+ "code.filepath",
481
+ "obs.code.filepath",
482
+ "exception.stacktrace",
483
+ ]) {
459
484
  const val = attrs[key];
460
485
  if (typeof val === "string") {
461
486
  found.push(val);
@@ -556,9 +581,9 @@ export async function computeChangeBlastRadius(
556
581
 
557
582
  const flagKey =
558
583
  change.kind === "feature_flag"
559
- ? (typeof change.attributes.flagKey === "string"
584
+ ? ((typeof change.attributes.flagKey === "string"
560
585
  ? change.attributes.flagKey
561
- : change.serviceName) ?? "flag"
586
+ : change.serviceName) ?? "flag")
562
587
  : null;
563
588
 
564
589
  const newCohortKey =
@@ -577,7 +602,10 @@ export async function computeChangeBlastRadius(
577
602
  const attrs = parseJsonRecord(s.attributes_json ?? "{}");
578
603
  const direct = attrs[`obs.feature_flags.${flagKey}`];
579
604
  if (direct !== undefined && direct !== null) return String(direct);
580
- if (attrs["feature_flag.key"] === flagKey && attrs["feature_flag.variant"] !== undefined) {
605
+ if (
606
+ attrs["feature_flag.key"] === flagKey &&
607
+ attrs["feature_flag.variant"] !== undefined
608
+ ) {
581
609
  return String(attrs["feature_flag.variant"]);
582
610
  }
583
611
  const nested = attrs["obs.feature_flags"];
@@ -732,8 +760,7 @@ export async function computeChangeBlastRadius(
732
760
  } else if (preBaseline.requestCount >= minSample) {
733
761
  // Instant 100% cutover: compare post-change v_new vs pre-change baseline
734
762
  rateDifference = newCohort.errorRate - preBaseline.errorRate;
735
- relativeRisk =
736
- newCohort.errorRate / Math.max(preBaseline.errorRate, 0.001);
763
+ relativeRisk = newCohort.errorRate / Math.max(preBaseline.errorRate, 0.001);
737
764
  const p95Ratio =
738
765
  newCohort.p95DurationMs !== null &&
739
766
  preBaseline.p95DurationMs !== null &&
@@ -12,7 +12,12 @@ import type {
12
12
  RecoveryStatus,
13
13
  TelemetryCodeReference,
14
14
  } from "@obsunified/types";
15
- import { buildCanonicalNodeId, buildFindingFingerprint } from "@obsunified/types";
15
+ import {
16
+ buildCanonicalNodeId,
17
+ buildFindingFingerprint,
18
+ } from "@obsunified/types";
19
+ import { consoleLogger, type Logger } from "../framework/logger";
20
+ import { computeChangeBlastRadius } from "./autosre-change-correlation";
16
21
  import { randomHex } from "./hash";
17
22
  import { parseJsonArray, parseJsonRecord } from "./json";
18
23
  import type { SqlDb } from "./sql-db";
@@ -148,14 +153,15 @@ export const mapFindingRow = (row: FindingRow): InvestigationFinding => {
148
153
  cohortVerdict:
149
154
  (row.cohort_verdict as CohortAttributionVerdict | null) ?? null,
150
155
  contendingTraceId: row.contending_trace_id,
151
- causalChain: parseJsonArray(row.causal_chain_json) as unknown as CausalTriple[],
156
+ causalChain: parseJsonArray(
157
+ row.causal_chain_json,
158
+ ) as unknown as CausalTriple[],
152
159
  citedEvidenceIds: parseJsonArray(row.cited_evidence_ids_json).map(String),
153
160
  codeReferences: parseJsonArray(
154
161
  row.code_references_json ?? "[]",
155
162
  ) as unknown as TelemetryCodeReference[],
156
163
  counterfactualProof,
157
- recoveryStatus: (row.recovery_status ||
158
- "not_applicable") as RecoveryStatus,
164
+ recoveryStatus: (row.recovery_status || "not_applicable") as RecoveryStatus,
159
165
  causalVerificationStatus: (row.causal_verification_status ||
160
166
  "unverified") as CausalVerificationStatus,
161
167
  watchdogStep: Number(row.watchdog_step ?? 0),
@@ -174,17 +180,24 @@ export const mapFindingRow = (row: FindingRow): InvestigationFinding => {
174
180
  };
175
181
 
176
182
  /**
177
- * Enforces P1 #1: Structural overlap (`touched_same_code_path` or `originated_fault_in_span`)
178
- * can never be promoted to `cohort_rate_isolated` or `counterfactual_verified` without
179
- * actual cohort isolation or SQL-verified counterfactual proof.
183
+ * Enforces P1 #1: the causal proof tier is server-derived. Nothing the client
184
+ * asserts (`cohortVerdict`, `causalProofTier`, an `isolated_to_version_cohort`
185
+ * triple) can raise the tier above `corroborated_candidate`. Only a verdict the
186
+ * collector recomputed itself from telemetry (`serverCohortVerdict`, produced by
187
+ * `computeChangeBlastRadius`) may yield `cohort_rate_isolated`, and only
188
+ * `verifyPairedCounterfactualProof` can reach `counterfactual_verified`.
180
189
  */
181
190
  function resolveSafeCausalProofTier(
182
191
  input: InvestigationFindingInput,
192
+ serverCohortVerdict: CohortAttributionVerdict | null,
183
193
  ): CausalProofTier {
184
- if (input.cohortVerdict === "isolated_to_new_version") {
194
+ if (serverCohortVerdict === "isolated_to_new_version") {
185
195
  return "cohort_rate_isolated";
186
196
  }
187
197
  if (
198
+ serverCohortVerdict === "sequential_post_cutover_regression" ||
199
+ serverCohortVerdict === "shared_degradation_new_version_aggressor" ||
200
+ input.cohortVerdict === "isolated_to_new_version" ||
188
201
  input.cohortVerdict === "sequential_post_cutover_regression" ||
189
202
  input.cohortVerdict === "shared_degradation_new_version_aggressor"
190
203
  ) {
@@ -195,17 +208,12 @@ function resolveSafeCausalProofTier(
195
208
  t.predicate === "touched_same_code_path" ||
196
209
  t.predicate === "contended_on_shared_resource" ||
197
210
  t.predicate === "induced_shared_resource_contention" ||
198
- t.predicate === "degraded_after_change",
211
+ t.predicate === "degraded_after_change" ||
212
+ t.predicate === "isolated_to_version_cohort",
199
213
  );
200
- if (input.causalProofTier === "cohort_rate_isolated") {
201
- // Only allow cohort_rate_isolated if a change or isolated_to_version_cohort triple is present
202
- const hasIsolatedTriple = input.causalChain.some(
203
- (t) => t.predicate === "isolated_to_version_cohort",
204
- );
205
- if (hasIsolatedTriple) return "cohort_rate_isolated";
206
- }
207
214
  if (
208
215
  hasCodeOverlapOrContention ||
216
+ input.causalProofTier === "cohort_rate_isolated" ||
209
217
  input.causalProofTier === "corroborated_candidate"
210
218
  ) {
211
219
  return "corroborated_candidate";
@@ -217,7 +225,9 @@ export async function recordInvestigationFinding(
217
225
  db: SqlDb,
218
226
  projectId: string,
219
227
  input: InvestigationFindingInput,
228
+ options: { logger?: Logger } = {},
220
229
  ): Promise<InvestigationFinding> {
230
+ const logger = options.logger ?? consoleLogger;
221
231
  const nowIso = new Date().toISOString();
222
232
  const environment = (
223
233
  input.environment ??
@@ -256,7 +266,40 @@ export async function recordInvestigationFinding(
256
266
  .bind(projectId, environment, fpResult.fingerprint)
257
267
  .first<{ id: string; causal_verification_status: string }>();
258
268
 
259
- const causalProofTier = resolveSafeCausalProofTier(input);
269
+ // Recompute the cohort verdict server-side; never trust the client's claim.
270
+ let serverCohortVerdict: CohortAttributionVerdict | null = null;
271
+ if (input.contributingChangeId) {
272
+ const blastRadius = await computeChangeBlastRadius(
273
+ db,
274
+ projectId,
275
+ input.contributingChangeId,
276
+ { environment },
277
+ );
278
+ serverCohortVerdict = blastRadius?.cohortVerdict ?? null;
279
+ }
280
+ const causalProofTier = resolveSafeCausalProofTier(
281
+ input,
282
+ serverCohortVerdict,
283
+ );
284
+ if (
285
+ input.cohortVerdict === "isolated_to_new_version" &&
286
+ serverCohortVerdict !== "isolated_to_new_version"
287
+ ) {
288
+ logger.warn(
289
+ "[autosre] client-asserted cohortVerdict=isolated_to_new_version was not corroborated server-side; capping proof tier",
290
+ {
291
+ projectId,
292
+ contributingChangeId: input.contributingChangeId ?? null,
293
+ serverCohortVerdict,
294
+ },
295
+ );
296
+ }
297
+ // An unverified client claim of isolation is not persisted as a verdict.
298
+ const storedCohortVerdict =
299
+ serverCohortVerdict ??
300
+ (input.cohortVerdict === "isolated_to_new_version"
301
+ ? null
302
+ : (input.cohortVerdict ?? null));
260
303
 
261
304
  const relationshipScore = input.causalChain.some(
262
305
  (c) => c.relationshipCertainty === "exact",
@@ -304,9 +347,7 @@ export async function recordInvestigationFinding(
304
347
  };
305
348
 
306
349
  const id = input.id?.trim() || `fnd_${randomHex(10)}`;
307
- const expiresAt = new Date(
308
- Date.now() + 90 * 24 * 3600_000,
309
- ).toISOString();
350
+ const expiresAt = new Date(Date.now() + 90 * 24 * 3600_000).toISOString();
310
351
 
311
352
  await db
312
353
  .prepare(
@@ -352,7 +393,7 @@ export async function recordInvestigationFinding(
352
393
  input.rootCauseEntityKind ?? null,
353
394
  input.rootCauseEntityId ?? null,
354
395
  input.contributingChangeId ?? null,
355
- input.cohortVerdict ?? null,
396
+ storedCohortVerdict,
356
397
  input.contendingTraceId ?? null,
357
398
  JSON.stringify(input.causalChain),
358
399
  JSON.stringify(input.citedEvidenceIds),
@@ -429,9 +470,7 @@ export async function searchInvestigationFindings(
429
470
  binds.push(options.symptomEntityId);
430
471
  }
431
472
  if (options.rootCauseEntityId) {
432
- conditions.push(
433
- "(root_cause_entity_id = ? OR fault_locus_entity_id = ?)",
434
- );
473
+ conditions.push("(root_cause_entity_id = ? OR fault_locus_entity_id = ?)");
435
474
  binds.push(options.rootCauseEntityId, options.rootCauseEntityId);
436
475
  }
437
476
  if (options.causalVerificationStatus) {
@@ -443,7 +482,7 @@ export async function searchInvestigationFindings(
443
482
  binds.push(options.incidentId);
444
483
  }
445
484
 
446
- const limit = Math.max(1, Math.min(100, options.limit ?? 25));
485
+ const limit = Math.max(1, Math.min(200, options.limit ?? 25));
447
486
  binds.push(limit);
448
487
 
449
488
  const rows = await db
@@ -575,7 +614,8 @@ export async function verifyPairedCounterfactualProof(
575
614
  const failingPassed =
576
615
  failingRow.result_passed !== null
577
616
  ? Number(failingRow.result_passed) === 1
578
- : Number(failingRow.fail_count) === 0 && Number(failingRow.pass_count) > 0;
617
+ : Number(failingRow.fail_count) === 0 &&
618
+ Number(failingRow.pass_count) > 0;
579
619
  if (failingPassed) {
580
620
  throw new CounterfactualVerificationError(
581
621
  `Baseline run ${input.failingRunId} did not fail (passed=1)`,
@@ -590,8 +630,7 @@ export async function verifyPairedCounterfactualProof(
590
630
  );
591
631
  }
592
632
 
593
- const failingFp =
594
- failingRow.result_failure_fp ?? failingRow.run_failure_fp;
633
+ const failingFp = failingRow.result_failure_fp ?? failingRow.run_failure_fp;
595
634
  if (failingFp !== finding.fingerprint) {
596
635
  throw new CounterfactualVerificationError(
597
636
  `Failing run fingerprint (${failingFp ?? "null"}) does not match finding fingerprint (${finding.fingerprint})`,
@@ -627,8 +666,7 @@ export async function verifyPairedCounterfactualProof(
627
666
  `Candidate run ${passRunId} did not pass`,
628
667
  );
629
668
  }
630
- const passInputHash =
631
- passRow.result_input_hash ?? passRow.run_input_hash;
669
+ const passInputHash = passRow.result_input_hash ?? passRow.run_input_hash;
632
670
  if (passInputHash !== failingInputHash) {
633
671
  throw new CounterfactualVerificationError(
634
672
  `Candidate run ${passRunId} input_payload_hash (${passInputHash ?? "null"}) does not match failing run (${failingInputHash})`,
@@ -0,0 +1,78 @@
1
+ import type { StoredSpan } from "@obsunified/types";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ clearOntologyUpsertCache,
5
+ extractSpanLineageAndTopology,
6
+ upsertOntologyFromSpans,
7
+ } from "./autosre-ontology";
8
+ import type { SqlDb } from "./sql-db";
9
+
10
+ const dbSpan = (i: number, environment = "prod"): StoredSpan =>
11
+ ({
12
+ projectId: "p",
13
+ traceId: `t${i}`,
14
+ spanId: `s${i}`,
15
+ environment,
16
+ startTime: "2026-10-15T00:00:00.000Z",
17
+ receivedAt: "2026-10-15T00:00:00.000Z",
18
+ attributesJson: JSON.stringify({
19
+ "db.system": "postgresql",
20
+ "db.name": `db${i}`,
21
+ "server.address": "pg.internal",
22
+ "server.port": 5432,
23
+ }),
24
+ resourceAttributesJson: "{}",
25
+ }) as unknown as StoredSpan;
26
+
27
+ const fakeDb = (batch: SqlDb["batch"]): SqlDb => {
28
+ const stmt = { bind: () => stmt };
29
+ return { prepare: () => stmt, batch } as unknown as SqlDb;
30
+ };
31
+
32
+ describe("ontology env normalization", () => {
33
+ afterEach(() => clearOntologyUpsertCache());
34
+
35
+ it("maps production-tagged spans to the prod environment", () => {
36
+ const span = {
37
+ ...dbSpan(1, ""),
38
+ environment: undefined,
39
+ resourceAttributesJson: JSON.stringify({
40
+ "deployment.environment.name": "Production",
41
+ }),
42
+ } as unknown as StoredSpan;
43
+ expect(extractSpanLineageAndTopology(span).environment).toBe("prod");
44
+ });
45
+ });
46
+
47
+ describe("NODE_UPSERT_CACHE", () => {
48
+ afterEach(() => clearOntologyUpsertCache());
49
+
50
+ it("does not remember keys when the batch fails", async () => {
51
+ const failing = vi.fn(async () => {
52
+ throw new Error("boom");
53
+ });
54
+ await expect(
55
+ upsertOntologyFromSpans(fakeDb(failing as never), [dbSpan(1)]),
56
+ ).rejects.toThrow("boom");
57
+ const ok = vi.fn(async () => []);
58
+ await upsertOntologyFromSpans(fakeDb(ok as never), [dbSpan(1)]);
59
+ expect(ok).toHaveBeenCalledTimes(1);
60
+ // Now cached: a third call writes nothing.
61
+ const again = vi.fn(async () => []);
62
+ await upsertOntologyFromSpans(fakeDb(again as never), [dbSpan(1)]);
63
+ expect(again).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it("caps the cache and evicts the oldest entries", async () => {
67
+ const spans = Array.from({ length: 5_002 }, (_, i) => dbSpan(i));
68
+ const batch = vi.fn(async () => []);
69
+ await upsertOntologyFromSpans(fakeDb(batch as never), spans);
70
+ // Oldest keys were evicted so they are written again; newest stay cached.
71
+ const second = vi.fn(async () => []);
72
+ await upsertOntologyFromSpans(fakeDb(second as never), [dbSpan(0)]);
73
+ expect(second).toHaveBeenCalledTimes(1);
74
+ const third = vi.fn(async () => []);
75
+ await upsertOntologyFromSpans(fakeDb(third as never), [dbSpan(5_001)]);
76
+ expect(third).not.toHaveBeenCalled();
77
+ });
78
+ });
@@ -9,7 +9,11 @@ import type {
9
9
  RelationshipCertainty,
10
10
  StoredSpan,
11
11
  } from "@obsunified/types";
12
- import { buildCanonicalNodeId, normalizeErrorSignature } from "@obsunified/types";
12
+ import {
13
+ buildCanonicalNodeId,
14
+ normalizeErrorSignature,
15
+ } from "@obsunified/types";
16
+ import { normalizeEnvironment } from "@obsunified/types/lineage";
13
17
  import { randomHex } from "./hash";
14
18
  import { parseJsonArray, parseJsonRecord } from "./json";
15
19
  import type { SqlDb, SqlStatement } from "./sql-db";
@@ -53,22 +57,22 @@ export function extractSpanLineageAndTopology(
53
57
  const attrs = parseJsonRecord(span.attributesJson);
54
58
  const resource = parseJsonRecord(span.resourceAttributesJson);
55
59
 
56
- const environment = (
60
+ const environment = normalizeEnvironment(
57
61
  span.environment ??
58
- pickStr(
59
- resource,
60
- "deployment.environment.name",
61
- "deployment.environment",
62
- "obs.environment",
63
- ) ??
64
- pickStr(
65
- attrs,
66
- "deployment.environment.name",
67
- "deployment.environment",
68
- "obs.environment",
69
- ) ??
70
- "default"
71
- ).toLowerCase();
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
+ );
72
76
 
73
77
  const namespace =
74
78
  pickStr(resource, "service.namespace") ??
@@ -144,11 +148,8 @@ export function extractSpanLineageAndTopology(
144
148
  environment,
145
149
  messagingSystem,
146
150
  messagingDestination:
147
- pickStr(
148
- attrs,
149
- "messaging.destination.name",
150
- "messaging.destination",
151
- ) ?? "default",
151
+ pickStr(attrs, "messaging.destination.name", "messaging.destination") ??
152
+ "default",
152
153
  serverAddress,
153
154
  });
154
155
  edgePredicate =
@@ -207,8 +208,7 @@ export function extractSpanLineageAndTopology(
207
208
  ? (evObj.attributes as Record<string, JsonValue>)
208
209
  : {};
209
210
  rawErr =
210
- pickStr(evAttrs, "exception.type", "exception.message") ??
211
- rawErr;
211
+ pickStr(evAttrs, "exception.type", "exception.message") ?? rawErr;
212
212
  }
213
213
  }
214
214
  }
@@ -236,6 +236,20 @@ export function extractSpanLineageAndTopology(
236
236
  // 60-second isolate cache to avoid repeating identical ontology node upserts on every span
237
237
  const NODE_UPSERT_CACHE = new Map<string, number>();
238
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
+ }
239
253
 
240
254
  export function clearOntologyUpsertCache(): void {
241
255
  NODE_UPSERT_CACHE.clear();
@@ -254,6 +268,8 @@ export async function upsertOntologyFromSpans(
254
268
 
255
269
  const nowMs = Date.now();
256
270
  const statements: SqlStatement[] = [];
271
+ // Cache keys are only remembered once the batch has been written successfully.
272
+ const pendingCacheKeys = new Set<string>();
257
273
  const edgeAggregates = new Map<
258
274
  string,
259
275
  {
@@ -293,8 +309,11 @@ export async function upsertOntologyFromSpans(
293
309
  if (lineage.sourceNode) {
294
310
  const cacheKey = `${span.projectId}::${lineage.environment}::${lineage.sourceNode.id}::${lineage.serviceVersion ?? ""}::${lineage.commitSha ?? ""}::${lineage.configDigest ?? ""}`;
295
311
  const cachedAt = NODE_UPSERT_CACHE.get(cacheKey);
296
- if (options.bypassCache || !cachedAt || nowMs - cachedAt > CACHE_TTL_MS) {
297
- NODE_UPSERT_CACHE.set(cacheKey, nowMs);
312
+ if (
313
+ !pendingCacheKeys.has(cacheKey) &&
314
+ (options.bypassCache || !cachedAt || nowMs - cachedAt > CACHE_TTL_MS)
315
+ ) {
316
+ pendingCacheKeys.add(cacheKey);
298
317
  serviceNodesToCheck.set(
299
318
  `${span.projectId}::${lineage.environment}::${lineage.sourceNode.id}`,
300
319
  {
@@ -313,8 +332,11 @@ export async function upsertOntologyFromSpans(
313
332
  if (lineage.targetNode) {
314
333
  const targetCacheKey = `${span.projectId}::${lineage.environment}::${lineage.targetNode.id}`;
315
334
  const cachedAt = NODE_UPSERT_CACHE.get(targetCacheKey);
316
- if (options.bypassCache || !cachedAt || nowMs - cachedAt > CACHE_TTL_MS) {
317
- NODE_UPSERT_CACHE.set(targetCacheKey, nowMs);
335
+ if (
336
+ !pendingCacheKeys.has(targetCacheKey) &&
337
+ (options.bypassCache || !cachedAt || nowMs - cachedAt > CACHE_TTL_MS)
338
+ ) {
339
+ pendingCacheKeys.add(targetCacheKey);
318
340
  statements.push(
319
341
  db
320
342
  .prepare(
@@ -511,6 +533,7 @@ export async function upsertOntologyFromSpans(
511
533
  if (statements.length > 0) {
512
534
  await db.batch(statements);
513
535
  }
536
+ rememberUpserts(pendingCacheKeys, nowMs);
514
537
  }
515
538
 
516
539
  /**
@@ -577,7 +600,8 @@ export function computeTarjanScc(
577
600
  if (lowlink.get(nodeId) === indices.get(nodeId)) {
578
601
  const component: string[] = [];
579
602
  while (stack.length > 0) {
580
- const w = stack.pop()!;
603
+ const w = stack.pop();
604
+ if (w === undefined) break;
581
605
  onStack.delete(w);
582
606
  component.push(w);
583
607
  if (w === nodeId) break;