@obsunified/collector 2.2.0 → 2.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/auth.js +1 -1
  2. package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
  3. package/dist/chunk-2FALTDCE.js.map +1 -0
  4. package/dist/index.d.ts +61 -3
  5. package/dist/index.js +10933 -9769
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -3
  8. package/src/__tests__/autosre-closed-loop.test.ts +1554 -17
  9. package/src/auth/dashboard-auth.test.ts +61 -0
  10. package/src/auth/dashboard-auth.ts +17 -2
  11. package/src/lib/ai-store/calls.ts +43 -23
  12. package/src/lib/ai-store.ts +12 -1
  13. package/src/lib/analyses-runner.ts +44 -7
  14. package/src/lib/autosre-change-correlation.ts +39 -12
  15. package/src/lib/autosre-findings.ts +237 -44
  16. package/src/lib/autosre-ontology-cache.test.ts +78 -0
  17. package/src/lib/autosre-ontology.ts +52 -28
  18. package/src/lib/autosre-remediation-gate.ts +942 -348
  19. package/src/lib/housekeeping.test.ts +70 -0
  20. package/src/lib/housekeeping.ts +91 -83
  21. package/src/lib/projects-store.test.ts +38 -1
  22. package/src/lib/projects-store.ts +23 -4
  23. package/src/lib/sql-db-postgres.integration.test.ts +75 -2
  24. package/src/lib/store/ingest.ts +8 -56
  25. package/src/lib/usage-store.ts +9 -3
  26. package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
  27. package/src/migrations/052_remediation_callback.sql +10 -0
  28. package/src/migrations/053_remediation_callback_delivery.sql +7 -0
  29. package/src/migrations/alert-state-notified.test.ts +3 -3
  30. package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
  31. package/src/migrations-postgres/052_remediation_callback.sql +10 -0
  32. package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
  33. package/src/plugins/ai-call-by-id.test.ts +73 -0
  34. package/src/plugins/ai-receiver.ts +10 -0
  35. package/src/plugins/alerts-evaluator.ts +15 -3
  36. package/src/plugins/autosre-routes.ts +374 -108
  37. package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
  38. package/src/plugins/connected-routes/manifest.test.ts +142 -0
  39. package/src/plugins/connected-routes/manifest.ts +123 -6
  40. package/src/plugins/connected-routes-links.test.ts +96 -0
  41. package/src/plugins/connected-routes.test.ts +3 -3
  42. package/src/plugins/connected-routes.ts +56 -83
  43. package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
  44. package/src/plugins/evidence-retrieval-routes.ts +397 -25
  45. package/dist/chunk-KMEZKGSO.js.map +0 -1
@@ -12,8 +12,14 @@ import type {
12
12
  RecoveryStatus,
13
13
  TelemetryCodeReference,
14
14
  } from "@obsunified/types";
15
- import { buildCanonicalNodeId, buildFindingFingerprint } from "@obsunified/types";
16
- import { randomHex } from "./hash";
15
+ import {
16
+ buildCanonicalNodeId,
17
+ buildFindingFingerprint,
18
+ OntologyNodeKindSchema,
19
+ } from "@obsunified/types";
20
+ import { consoleLogger, type Logger } from "../framework/logger";
21
+ import { computeChangeBlastRadius } from "./autosre-change-correlation";
22
+ import { randomHex, sha256Hex } from "./hash";
17
23
  import { parseJsonArray, parseJsonRecord } from "./json";
18
24
  import type { SqlDb } from "./sql-db";
19
25
 
@@ -148,14 +154,15 @@ export const mapFindingRow = (row: FindingRow): InvestigationFinding => {
148
154
  cohortVerdict:
149
155
  (row.cohort_verdict as CohortAttributionVerdict | null) ?? null,
150
156
  contendingTraceId: row.contending_trace_id,
151
- causalChain: parseJsonArray(row.causal_chain_json) as unknown as CausalTriple[],
157
+ causalChain: parseJsonArray(
158
+ row.causal_chain_json,
159
+ ) as unknown as CausalTriple[],
152
160
  citedEvidenceIds: parseJsonArray(row.cited_evidence_ids_json).map(String),
153
161
  codeReferences: parseJsonArray(
154
162
  row.code_references_json ?? "[]",
155
163
  ) as unknown as TelemetryCodeReference[],
156
164
  counterfactualProof,
157
- recoveryStatus: (row.recovery_status ||
158
- "not_applicable") as RecoveryStatus,
165
+ recoveryStatus: (row.recovery_status || "not_applicable") as RecoveryStatus,
159
166
  causalVerificationStatus: (row.causal_verification_status ||
160
167
  "unverified") as CausalVerificationStatus,
161
168
  watchdogStep: Number(row.watchdog_step ?? 0),
@@ -174,17 +181,24 @@ export const mapFindingRow = (row: FindingRow): InvestigationFinding => {
174
181
  };
175
182
 
176
183
  /**
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.
184
+ * Enforces P1 #1: the causal proof tier is server-derived. Nothing the client
185
+ * asserts (`cohortVerdict`, `causalProofTier`, an `isolated_to_version_cohort`
186
+ * triple) can raise the tier above `corroborated_candidate`. Only a verdict the
187
+ * collector recomputed itself from telemetry (`serverCohortVerdict`, produced by
188
+ * `computeChangeBlastRadius`) may yield `cohort_rate_isolated`, and only
189
+ * `verifyPairedCounterfactualProof` can reach `counterfactual_verified`.
180
190
  */
181
191
  function resolveSafeCausalProofTier(
182
192
  input: InvestigationFindingInput,
193
+ serverCohortVerdict: CohortAttributionVerdict | null,
183
194
  ): CausalProofTier {
184
- if (input.cohortVerdict === "isolated_to_new_version") {
195
+ if (serverCohortVerdict === "isolated_to_new_version") {
185
196
  return "cohort_rate_isolated";
186
197
  }
187
198
  if (
199
+ serverCohortVerdict === "sequential_post_cutover_regression" ||
200
+ serverCohortVerdict === "shared_degradation_new_version_aggressor" ||
201
+ input.cohortVerdict === "isolated_to_new_version" ||
188
202
  input.cohortVerdict === "sequential_post_cutover_regression" ||
189
203
  input.cohortVerdict === "shared_degradation_new_version_aggressor"
190
204
  ) {
@@ -195,17 +209,12 @@ function resolveSafeCausalProofTier(
195
209
  t.predicate === "touched_same_code_path" ||
196
210
  t.predicate === "contended_on_shared_resource" ||
197
211
  t.predicate === "induced_shared_resource_contention" ||
198
- t.predicate === "degraded_after_change",
212
+ t.predicate === "degraded_after_change" ||
213
+ t.predicate === "isolated_to_version_cohort",
199
214
  );
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
215
  if (
208
216
  hasCodeOverlapOrContention ||
217
+ input.causalProofTier === "cohort_rate_isolated" ||
209
218
  input.causalProofTier === "corroborated_candidate"
210
219
  ) {
211
220
  return "corroborated_candidate";
@@ -213,11 +222,104 @@ function resolveSafeCausalProofTier(
213
222
  return "unverified_locus";
214
223
  }
215
224
 
225
+ export interface FaultLocusSpanRow {
226
+ service_name: string | null;
227
+ span_name: string;
228
+ status_message: string | null;
229
+ error_signature?: string | null;
230
+ }
231
+
232
+ /** Looks up the span a finding's fault locus refers to (newest match wins). */
233
+ export async function loadFaultLocusSpan(
234
+ db: SqlDb,
235
+ projectId: string,
236
+ environment: string,
237
+ spanId: string,
238
+ traceId?: string,
239
+ ): Promise<FaultLocusSpanRow | null> {
240
+ if (!spanId) return null;
241
+ return db
242
+ .prepare(
243
+ `SELECT service_name, span_name, status_message, error_signature
244
+ FROM telemetry_spans
245
+ WHERE project_id = ? AND environment = ? AND span_id = ?
246
+ ${traceId ? "AND trace_id = ?" : ""}
247
+ ORDER BY start_time DESC
248
+ LIMIT 1`,
249
+ )
250
+ .bind(
251
+ ...(traceId
252
+ ? [projectId, environment, spanId, traceId]
253
+ : [projectId, environment, spanId]),
254
+ )
255
+ .first<FaultLocusSpanRow>();
256
+ }
257
+
258
+ /** Fingerprint inputs derivable from a span; empty fields are left undefined. */
259
+ export type SpanFingerprintInput = Pick<
260
+ FindingFingerprintInput,
261
+ "environment" | "originatingNodeId" | "downstreamTargetNodeId"
262
+ > &
263
+ Partial<
264
+ Pick<FindingFingerprintInput, "normalizedOperation" | "errorSignature">
265
+ >;
266
+
267
+ /** True for `kind:env:ns:name...` ids built by buildCanonicalNodeId. */
268
+ function looksLikeCanonicalNodeId(id: string): boolean {
269
+ const parts = id.split(":");
270
+ return (
271
+ parts.length >= 4 &&
272
+ (OntologyNodeKindSchema.options as readonly string[]).includes(parts[0])
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Splits a `<traceId>:<spanId>` span locus. Returns null for anything that
278
+ * is a canonical node id or does not have exactly two non-empty parts.
279
+ */
280
+ function splitTraceSpanLocus(
281
+ id: string,
282
+ ): { traceId: string; spanId: string } | null {
283
+ if (looksLikeCanonicalNodeId(id)) return null;
284
+ const parts = id.split(":");
285
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
286
+ return { traceId: parts[0], spanId: parts[1] };
287
+ }
288
+
289
+ /**
290
+ * Stable recurrence-fingerprint inputs for a fault-locus span: the service node,
291
+ * the span name as the operation and the span's error signature (falling back
292
+ * to its status message). No instance ids participate, so two traces failing
293
+ * the same way on the same service/route share a fingerprint. Used by both
294
+ * finding creation and the evidence bundle's prior-finding lookup.
295
+ */
296
+ export function fingerprintInputFromSpan(
297
+ span: FaultLocusSpanRow,
298
+ environment: string,
299
+ ): SpanFingerprintInput {
300
+ return {
301
+ environment,
302
+ originatingNodeId: buildCanonicalNodeId({
303
+ kind: "service",
304
+ environment,
305
+ serviceName: span.service_name || "unknown-service",
306
+ }).id,
307
+ // Empty strings are "absent": returning undefined lets the caller's
308
+ // `??` chain reach its title/summary fallbacks.
309
+ normalizedOperation: span.span_name?.trim() || undefined,
310
+ errorSignature:
311
+ span.error_signature?.trim() || span.status_message?.trim() || undefined,
312
+ downstreamTargetNodeId: null,
313
+ };
314
+ }
315
+
216
316
  export async function recordInvestigationFinding(
217
317
  db: SqlDb,
218
318
  projectId: string,
219
319
  input: InvestigationFindingInput,
320
+ options: { logger?: Logger } = {},
220
321
  ): Promise<InvestigationFinding> {
322
+ const logger = options.logger ?? consoleLogger;
221
323
  const nowIso = new Date().toISOString();
222
324
  const environment = (
223
325
  input.environment ??
@@ -227,23 +329,86 @@ export async function recordInvestigationFinding(
227
329
  .trim()
228
330
  .toLowerCase();
229
331
 
230
- const defaultOriginNode = input.faultLocusEntityId.includes(":")
231
- ? input.faultLocusEntityId
332
+ // Idempotent retries: one key maps to one finding id per project and
333
+ // environment, and a replay returns the stored finding untouched.
334
+ const idempotencyKey = input.idempotencyKey?.trim();
335
+ let idempotencyId: string | undefined;
336
+ if (idempotencyKey) {
337
+ idempotencyId = `fnd_k${(await sha256Hex(`${projectId}\u0000${environment}\u0000${idempotencyKey}`)).slice(0, 20)}`;
338
+ const replay = await getInvestigationFinding(
339
+ db,
340
+ projectId,
341
+ idempotencyId,
342
+ environment,
343
+ );
344
+ if (replay) return replay;
345
+ }
346
+
347
+ // Findings pointing at a span id (no colon) must not mint a per-incident
348
+ // fingerprint. Resolve the span to its service/operation/error signature so
349
+ // every incident on the same service + route + error shares one identity.
350
+ // Caller-supplied fingerprint fields always win; unknown spans fall back to
351
+ // treating the locus id as a service name.
352
+ const supplied: Partial<FindingFingerprintInput> =
353
+ input.fingerprintInput ?? {};
354
+ const locusId = input.faultLocusEntityId.trim();
355
+ let spanDerived: Partial<FindingFingerprintInput> = {};
356
+ const traceSpan =
357
+ input.faultLocusEntityKind === "span" ? splitTraceSpanLocus(locusId) : null;
358
+ if (traceSpan) {
359
+ const spanRow = await loadFaultLocusSpan(
360
+ db,
361
+ projectId,
362
+ environment,
363
+ traceSpan.spanId,
364
+ traceSpan.traceId,
365
+ );
366
+ if (spanRow) spanDerived = fingerprintInputFromSpan(spanRow, environment);
367
+ } else if (
368
+ !locusId.includes(":") &&
369
+ input.faultLocusEntityKind !== "service"
370
+ ) {
371
+ const spanRow = await loadFaultLocusSpan(
372
+ db,
373
+ projectId,
374
+ environment,
375
+ locusId,
376
+ );
377
+ if (spanRow) spanDerived = fingerprintInputFromSpan(spanRow, environment);
378
+ }
379
+ const defaultOriginNode = locusId.includes(":")
380
+ ? traceSpan
381
+ ? buildCanonicalNodeId({
382
+ kind: "service",
383
+ environment,
384
+ serviceName: locusId,
385
+ }).id
386
+ : locusId
232
387
  : buildCanonicalNodeId({
233
388
  kind: "service",
234
389
  environment,
235
- serviceName: input.faultLocusEntityId,
390
+ serviceName: locusId,
236
391
  }).id;
237
392
 
238
- const fpResult = buildFindingFingerprint(
239
- input.fingerprintInput ?? {
240
- environment,
241
- originatingNodeId: defaultOriginNode,
242
- normalizedOperation: input.title,
243
- errorSignature: input.summary.slice(0, 120),
244
- downstreamTargetNodeId: null,
245
- },
246
- );
393
+ const fpResult = buildFindingFingerprint({
394
+ environment: supplied.environment ?? environment,
395
+ originatingNodeId:
396
+ supplied.originatingNodeId ??
397
+ spanDerived.originatingNodeId ??
398
+ defaultOriginNode,
399
+ normalizedOperation:
400
+ supplied.normalizedOperation ??
401
+ spanDerived.normalizedOperation ??
402
+ input.title,
403
+ errorSignature:
404
+ supplied.errorSignature ??
405
+ spanDerived.errorSignature ??
406
+ input.summary.slice(0, 120),
407
+ downstreamTargetNodeId:
408
+ supplied.downstreamTargetNodeId ??
409
+ spanDerived.downstreamTargetNodeId ??
410
+ null,
411
+ });
247
412
 
248
413
  // Check for prior verified findings with the same stable fingerprint
249
414
  const priorRow = await db
@@ -256,7 +421,40 @@ export async function recordInvestigationFinding(
256
421
  .bind(projectId, environment, fpResult.fingerprint)
257
422
  .first<{ id: string; causal_verification_status: string }>();
258
423
 
259
- const causalProofTier = resolveSafeCausalProofTier(input);
424
+ // Recompute the cohort verdict server-side; never trust the client's claim.
425
+ let serverCohortVerdict: CohortAttributionVerdict | null = null;
426
+ if (input.contributingChangeId) {
427
+ const blastRadius = await computeChangeBlastRadius(
428
+ db,
429
+ projectId,
430
+ input.contributingChangeId,
431
+ { environment },
432
+ );
433
+ serverCohortVerdict = blastRadius?.cohortVerdict ?? null;
434
+ }
435
+ const causalProofTier = resolveSafeCausalProofTier(
436
+ input,
437
+ serverCohortVerdict,
438
+ );
439
+ if (
440
+ input.cohortVerdict === "isolated_to_new_version" &&
441
+ serverCohortVerdict !== "isolated_to_new_version"
442
+ ) {
443
+ logger.warn(
444
+ "[autosre] client-asserted cohortVerdict=isolated_to_new_version was not corroborated server-side; capping proof tier",
445
+ {
446
+ projectId,
447
+ contributingChangeId: input.contributingChangeId ?? null,
448
+ serverCohortVerdict,
449
+ },
450
+ );
451
+ }
452
+ // An unverified client claim of isolation is not persisted as a verdict.
453
+ const storedCohortVerdict =
454
+ serverCohortVerdict ??
455
+ (input.cohortVerdict === "isolated_to_new_version"
456
+ ? null
457
+ : (input.cohortVerdict ?? null));
260
458
 
261
459
  const relationshipScore = input.causalChain.some(
262
460
  (c) => c.relationshipCertainty === "exact",
@@ -303,10 +501,8 @@ export async function recordInvestigationFinding(
303
501
  finalConfidence: confidence,
304
502
  };
305
503
 
306
- const id = input.id?.trim() || `fnd_${randomHex(10)}`;
307
- const expiresAt = new Date(
308
- Date.now() + 90 * 24 * 3600_000,
309
- ).toISOString();
504
+ const id = idempotencyId ?? (input.id?.trim() || `fnd_${randomHex(10)}`);
505
+ const expiresAt = new Date(Date.now() + 90 * 24 * 3600_000).toISOString();
310
506
 
311
507
  await db
312
508
  .prepare(
@@ -352,7 +548,7 @@ export async function recordInvestigationFinding(
352
548
  input.rootCauseEntityKind ?? null,
353
549
  input.rootCauseEntityId ?? null,
354
550
  input.contributingChangeId ?? null,
355
- input.cohortVerdict ?? null,
551
+ storedCohortVerdict,
356
552
  input.contendingTraceId ?? null,
357
553
  JSON.stringify(input.causalChain),
358
554
  JSON.stringify(input.citedEvidenceIds),
@@ -429,9 +625,7 @@ export async function searchInvestigationFindings(
429
625
  binds.push(options.symptomEntityId);
430
626
  }
431
627
  if (options.rootCauseEntityId) {
432
- conditions.push(
433
- "(root_cause_entity_id = ? OR fault_locus_entity_id = ?)",
434
- );
628
+ conditions.push("(root_cause_entity_id = ? OR fault_locus_entity_id = ?)");
435
629
  binds.push(options.rootCauseEntityId, options.rootCauseEntityId);
436
630
  }
437
631
  if (options.causalVerificationStatus) {
@@ -443,7 +637,7 @@ export async function searchInvestigationFindings(
443
637
  binds.push(options.incidentId);
444
638
  }
445
639
 
446
- const limit = Math.max(1, Math.min(100, options.limit ?? 25));
640
+ const limit = Math.max(1, Math.min(200, options.limit ?? 25));
447
641
  binds.push(limit);
448
642
 
449
643
  const rows = await db
@@ -575,7 +769,8 @@ export async function verifyPairedCounterfactualProof(
575
769
  const failingPassed =
576
770
  failingRow.result_passed !== null
577
771
  ? Number(failingRow.result_passed) === 1
578
- : Number(failingRow.fail_count) === 0 && Number(failingRow.pass_count) > 0;
772
+ : Number(failingRow.fail_count) === 0 &&
773
+ Number(failingRow.pass_count) > 0;
579
774
  if (failingPassed) {
580
775
  throw new CounterfactualVerificationError(
581
776
  `Baseline run ${input.failingRunId} did not fail (passed=1)`,
@@ -590,8 +785,7 @@ export async function verifyPairedCounterfactualProof(
590
785
  );
591
786
  }
592
787
 
593
- const failingFp =
594
- failingRow.result_failure_fp ?? failingRow.run_failure_fp;
788
+ const failingFp = failingRow.result_failure_fp ?? failingRow.run_failure_fp;
595
789
  if (failingFp !== finding.fingerprint) {
596
790
  throw new CounterfactualVerificationError(
597
791
  `Failing run fingerprint (${failingFp ?? "null"}) does not match finding fingerprint (${finding.fingerprint})`,
@@ -627,8 +821,7 @@ export async function verifyPairedCounterfactualProof(
627
821
  `Candidate run ${passRunId} did not pass`,
628
822
  );
629
823
  }
630
- const passInputHash =
631
- passRow.result_input_hash ?? passRow.run_input_hash;
824
+ const passInputHash = passRow.result_input_hash ?? passRow.run_input_hash;
632
825
  if (passInputHash !== failingInputHash) {
633
826
  throw new CounterfactualVerificationError(
634
827
  `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;