@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.
- package/dist/index.d.ts +53 -3
- package/dist/index.js +10459 -9696
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/autosre-closed-loop.test.ts +964 -17
- package/src/lib/analyses-runner.ts +44 -7
- package/src/lib/autosre-change-correlation.ts +39 -12
- package/src/lib/autosre-findings.ts +68 -30
- package/src/lib/autosre-ontology-cache.test.ts +78 -0
- package/src/lib/autosre-ontology.ts +52 -28
- package/src/lib/autosre-remediation-gate.ts +799 -335
- package/src/lib/housekeeping.test.ts +70 -0
- package/src/lib/housekeeping.ts +91 -83
- package/src/lib/store/ingest.ts +8 -56
- package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
- package/src/migrations/alert-state-notified.test.ts +3 -3
- package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
- package/src/plugins/alerts-evaluator.ts +15 -3
- package/src/plugins/autosre-routes.ts +223 -108
- package/src/plugins/connected-routes/manifest.ts +1 -4
- package/src/plugins/connected-routes.ts +48 -78
- package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
- package/src/plugins/evidence-retrieval-routes.ts +372 -30
|
@@ -36,7 +36,11 @@ import {
|
|
|
36
36
|
type Logger,
|
|
37
37
|
} from "../framework/logger";
|
|
38
38
|
import { AnalysesStore } from "./analyses-store";
|
|
39
|
-
import {
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
|
83
|
-
|
|
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(
|
|
350
|
-
|
|
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
|
|
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 [
|
|
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 (
|
|
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 {
|
|
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(
|
|
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:
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 &&
|
|
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 {
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
)
|
|
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
|
-
|
|
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 (
|
|
297
|
-
|
|
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 (
|
|
317
|
-
|
|
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;
|