@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.
- package/dist/auth.js +1 -1
- package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
- package/dist/chunk-2FALTDCE.js.map +1 -0
- package/dist/index.d.ts +61 -3
- package/dist/index.js +10933 -9769
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
- package/src/__tests__/autosre-closed-loop.test.ts +1554 -17
- package/src/auth/dashboard-auth.test.ts +61 -0
- package/src/auth/dashboard-auth.ts +17 -2
- package/src/lib/ai-store/calls.ts +43 -23
- package/src/lib/ai-store.ts +12 -1
- package/src/lib/analyses-runner.ts +44 -7
- package/src/lib/autosre-change-correlation.ts +39 -12
- package/src/lib/autosre-findings.ts +237 -44
- 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 +942 -348
- package/src/lib/housekeeping.test.ts +70 -0
- package/src/lib/housekeeping.ts +91 -83
- package/src/lib/projects-store.test.ts +38 -1
- package/src/lib/projects-store.ts +23 -4
- package/src/lib/sql-db-postgres.integration.test.ts +75 -2
- package/src/lib/store/ingest.ts +8 -56
- package/src/lib/usage-store.ts +9 -3
- package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
- package/src/migrations/052_remediation_callback.sql +10 -0
- package/src/migrations/053_remediation_callback_delivery.sql +7 -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/migrations-postgres/052_remediation_callback.sql +10 -0
- package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
- package/src/plugins/ai-call-by-id.test.ts +73 -0
- package/src/plugins/ai-receiver.ts +10 -0
- package/src/plugins/alerts-evaluator.ts +15 -3
- package/src/plugins/autosre-routes.ts +374 -108
- package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
- package/src/plugins/connected-routes/manifest.test.ts +142 -0
- package/src/plugins/connected-routes/manifest.ts +123 -6
- package/src/plugins/connected-routes-links.test.ts +96 -0
- package/src/plugins/connected-routes.test.ts +3 -3
- package/src/plugins/connected-routes.ts +56 -83
- package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
- package/src/plugins/evidence-retrieval-routes.ts +397 -25
- 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 {
|
|
16
|
-
|
|
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(
|
|
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:
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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 (
|
|
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
|
-
|
|
231
|
-
|
|
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:
|
|
390
|
+
serviceName: locusId,
|
|
236
391
|
}).id;
|
|
237
392
|
|
|
238
|
-
const fpResult = buildFindingFingerprint(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
originatingNodeId
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 &&
|
|
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 {
|
|
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;
|