@obsunified/collector 2.1.1 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  2. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  3. package/dist/index.d.ts +359 -2
  4. package/dist/index.js +5000 -334
  5. package/dist/index.js.map +1 -1
  6. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  7. package/package.json +2 -2
  8. package/src/__tests__/autosre-closed-loop.test.ts +2222 -0
  9. package/src/index.ts +43 -0
  10. package/src/lib/analyses-runner.ts +46 -0
  11. package/src/lib/autosre-change-correlation.ts +807 -0
  12. package/src/lib/autosre-findings.ts +763 -0
  13. package/src/lib/autosre-ontology-cache.test.ts +78 -0
  14. package/src/lib/autosre-ontology.ts +938 -0
  15. package/src/lib/autosre-remediation-gate.ts +2127 -0
  16. package/src/lib/eval-cases-store.ts +63 -1
  17. package/src/lib/evidence-references.ts +6 -0
  18. package/src/lib/housekeeping.test.ts +150 -0
  19. package/src/lib/housekeeping.ts +109 -1
  20. package/src/lib/store/ingest.ts +46 -6
  21. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  22. package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
  23. package/src/migrations/alert-state-notified.test.ts +3 -3
  24. package/src/migrations/parity.test.ts +4 -0
  25. package/src/migrations/project-scoped-keys.test.ts +1 -1
  26. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  27. package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
  28. package/src/plugins/alerts-evaluator.ts +29 -1
  29. package/src/plugins/autosre-routes.ts +1134 -0
  30. package/src/plugins/connected-routes/manifest.ts +10 -1
  31. package/src/plugins/connected-routes.ts +241 -0
  32. package/src/plugins/default-span-enrichment.ts +13 -1
  33. package/src/plugins/eval-cases-routes.ts +57 -2
  34. package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
  35. package/src/plugins/evidence-retrieval-routes.ts +643 -5
  36. package/dist/chunk-XKQTVIRE.js.map +0 -1
  37. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -12,6 +12,23 @@ import type {
12
12
  TelemetryTraceDetailResponse,
13
13
  } from "@obsunified/types";
14
14
  import type { CollectorPlugin } from "../framework/collector";
15
+ import {
16
+ computeChangeBlastRadius,
17
+ getChangeEvent,
18
+ listChangeEvents,
19
+ } from "../lib/autosre-change-correlation";
20
+ import {
21
+ getInvestigationFinding,
22
+ searchInvestigationFindings,
23
+ } from "../lib/autosre-findings";
24
+ import {
25
+ getOntologyGraph,
26
+ loadTraceFaultLocusFromDb,
27
+ } from "../lib/autosre-ontology";
28
+ import {
29
+ getIncidentInvestigation,
30
+ listRemediationProposals,
31
+ } from "../lib/autosre-remediation-gate";
15
32
  import type {
16
33
  ActionRef,
17
34
  EntityManifestExtended,
@@ -75,7 +92,7 @@ interface ReplayEvidenceRow {
75
92
 
76
93
  interface AICallEvidenceRow {
77
94
  call_id: string;
78
- trace_id: string | null;
95
+ trace_id: string;
79
96
  span_id?: string | null;
80
97
  service_name?: string | null;
81
98
  model_name: string;
@@ -105,6 +122,7 @@ const DEFAULT_PROFILE_FRAME_LIMIT = 50;
105
122
  const MAX_PROFILE_FRAME_LIMIT = 200;
106
123
  const EVIDENCE_INTENTS = new Set<EvidenceBundleIntent>([
107
124
  "debug_failure",
125
+ "triage_incident",
108
126
  "explain_latency",
109
127
  "explain_cost",
110
128
  "inspect_agent_run",
@@ -1460,6 +1478,280 @@ const buildActionGraphBundle = (
1460
1478
  return bundle;
1461
1479
  };
1462
1480
 
1481
+ /**
1482
+ * Resolves the trace to enrich an anchor with. Prefers the anchor's own
1483
+ * symptom entity (a trace id, or the trace owning a span); only when that
1484
+ * cannot be resolved does it fall back to the most recent error span in the
1485
+ * environment (a heuristic: it may belong to an unrelated request).
1486
+ */
1487
+ const resolveAnchorTraceId = async (
1488
+ db: SqlDb,
1489
+ projectId: string,
1490
+ environment: string,
1491
+ symptom?: { kind: string; id: string } | null,
1492
+ ): Promise<string | null> => {
1493
+ if (symptom?.id) {
1494
+ if (symptom.kind === "trace") {
1495
+ const row = await db
1496
+ .prepare(
1497
+ `SELECT trace_id FROM telemetry_spans
1498
+ WHERE project_id = ? AND trace_id = ? LIMIT 1`,
1499
+ )
1500
+ .bind(projectId, symptom.id)
1501
+ .first<{ trace_id: string }>();
1502
+ if (row?.trace_id) return row.trace_id;
1503
+ } else if (symptom.kind === "span") {
1504
+ const row = await db
1505
+ .prepare(
1506
+ `SELECT trace_id FROM telemetry_spans
1507
+ WHERE project_id = ? AND span_id = ? LIMIT 1`,
1508
+ )
1509
+ .bind(projectId, symptom.id)
1510
+ .first<{ trace_id: string }>();
1511
+ if (row?.trace_id) return row.trace_id;
1512
+ }
1513
+ }
1514
+ const recent = await db
1515
+ .prepare(
1516
+ `SELECT trace_id FROM telemetry_spans
1517
+ WHERE project_id = ? AND environment = ? AND status_code = 2
1518
+ ORDER BY start_time DESC LIMIT 1`,
1519
+ )
1520
+ .bind(projectId, environment)
1521
+ .first<{ trace_id: string }>();
1522
+ return recent?.trace_id ?? null;
1523
+ };
1524
+
1525
+ const attachAutoSreTriageContext = async (
1526
+ db: SqlDb,
1527
+ projectId: string,
1528
+ traceId: string | null,
1529
+ bundle: EvidenceBundle,
1530
+ logger?: { warn: (message: string, meta?: Record<string, unknown>) => void },
1531
+ ): Promise<EvidenceBundle> => {
1532
+ try {
1533
+ const faultLocus = traceId
1534
+ ? await loadTraceFaultLocusFromDb(db, projectId, traceId)
1535
+ : null;
1536
+ const environment = (
1537
+ faultLocus?.faultLocusSpan?.environment ??
1538
+ bundle.environment ??
1539
+ "default"
1540
+ ).toLowerCase();
1541
+ const locusService = faultLocus?.faultLocusSpan?.service_name ?? null;
1542
+ const allRecentChanges = await listChangeEvents(db, {
1543
+ projectId,
1544
+ environment,
1545
+ limit: locusService ? 25 : 5,
1546
+ });
1547
+ // When the fault locus service is known, only changes that target it
1548
+ // (or its node) are candidates; unrelated deploys are noise.
1549
+ const recentChanges = locusService
1550
+ ? allRecentChanges
1551
+ .filter(
1552
+ (ch) =>
1553
+ ch.serviceName === locusService ||
1554
+ (faultLocus?.faultLocusNodeId &&
1555
+ ch.targetNodeId === faultLocus.faultLocusNodeId),
1556
+ )
1557
+ .slice(0, 5)
1558
+ : allRecentChanges;
1559
+ const blastRadii = (
1560
+ await Promise.all(
1561
+ recentChanges
1562
+ .slice(0, 3)
1563
+ .map((ch) =>
1564
+ computeChangeBlastRadius(db, projectId, ch.id, { environment }),
1565
+ ),
1566
+ )
1567
+ ).filter((br): br is NonNullable<typeof br> => Boolean(br));
1568
+
1569
+ const candidateChanges = blastRadii.map((br) => ({
1570
+ changeId: br.change.id,
1571
+ kind: br.change.kind,
1572
+ version: br.change.version,
1573
+ previousVersion: br.change.previousVersion,
1574
+ occurredAt: br.change.occurredAt,
1575
+ cohortVerdict: br.cohortVerdict,
1576
+ causalProofTier: br.causalProofTier,
1577
+ rateNew: br.newCohort.errorRate,
1578
+ rateOld: br.oldCohort.errorRate,
1579
+ sampleNew: br.newCohort.requestCount,
1580
+ sampleOld: br.oldCohort.requestCount,
1581
+ overlappingCodePaths: br.overlappingCodePaths,
1582
+ }));
1583
+ const contentionCandidates = blastRadii
1584
+ .flatMap((br) => br.sharedResourceAggressors)
1585
+ .map((ag) => ({
1586
+ aggressorTraceId: ag.aggressorTraceId,
1587
+ aggressorSpanId: ag.aggressorSpanId,
1588
+ aggressorServiceName: ag.aggressorServiceName,
1589
+ sharedTargetNodeId: ag.sharedTargetNodeId,
1590
+ relationshipCertainty: ag.relationshipCertainty,
1591
+ totalResourceTimeMs: ag.totalResourceTimeMs,
1592
+ }));
1593
+ let priorFindingsList = await searchInvestigationFindings(db, {
1594
+ projectId,
1595
+ environment,
1596
+ rootCauseEntityId:
1597
+ faultLocus?.faultLocusNodeId ??
1598
+ faultLocus?.faultLocusSpan?.span_id ??
1599
+ undefined,
1600
+ limit: 5,
1601
+ });
1602
+ if (priorFindingsList.length === 0 && faultLocus?.faultLocusSpan?.span_id) {
1603
+ priorFindingsList = await searchInvestigationFindings(db, {
1604
+ projectId,
1605
+ environment,
1606
+ rootCauseEntityId: faultLocus.faultLocusSpan.span_id,
1607
+ limit: 5,
1608
+ });
1609
+ }
1610
+ // No unfiltered fallback: unrelated findings would read as relevant
1611
+ // prior art. An empty list is the honest answer.
1612
+ const priorFindings = priorFindingsList.map((f) => ({
1613
+ findingId: f.id,
1614
+ title: f.title,
1615
+ causalProofTier: f.causalProofTier,
1616
+ causalVerificationStatus: f.causalVerificationStatus,
1617
+ recoveryStatus: f.recoveryStatus,
1618
+ contributingChangeId: f.contributingChangeId,
1619
+ occurredAt: f.createdAt,
1620
+ }));
1621
+
1622
+ for (const br of blastRadii) {
1623
+ const changeId = br.change.id;
1624
+ const changeRoute = `#/changes/${encodeURIComponent(changeId)}`;
1625
+ bundle.evidenceReferences.push({
1626
+ evidenceId: `change:${changeId}`,
1627
+ entityKind: "change",
1628
+ entityId: changeId,
1629
+ route: changeRoute,
1630
+ source: br.overlappingCodePaths.length
1631
+ ? "stack_frame_overlap"
1632
+ : "cohort_comparison",
1633
+ confidence: br.cohortVerdict === "isolated_to_new_version" ? 0.9 : 0.65,
1634
+ relationshipCertainty: br.relationshipCertainty,
1635
+ causalProofTier: br.causalProofTier,
1636
+ reason: br.summary,
1637
+ citations: [
1638
+ {
1639
+ label: `change ${changeId} (${br.change.version ?? br.change.kind})`,
1640
+ entityKind: "change",
1641
+ entityId: changeId,
1642
+ route: changeRoute,
1643
+ },
1644
+ ],
1645
+ suggestedNextPivots: [
1646
+ {
1647
+ label: "Inspect change blast radius",
1648
+ entityKind: "change",
1649
+ entityId: changeId,
1650
+ route: changeRoute,
1651
+ reason: "Inspect traffic-normalized cohort lift and contention.",
1652
+ },
1653
+ ],
1654
+ });
1655
+ }
1656
+
1657
+ for (const finding of priorFindingsList) {
1658
+ const findingRoute = `#/findings/${encodeURIComponent(finding.id)}`;
1659
+ bundle.evidenceReferences.push({
1660
+ evidenceId: `finding:${finding.id}`,
1661
+ entityKind: "finding",
1662
+ entityId: finding.id,
1663
+ route: findingRoute,
1664
+ source: "incident_memory_fingerprint",
1665
+ confidence: finding.confidence,
1666
+ relationshipCertainty: "exact",
1667
+ causalProofTier: finding.causalProofTier,
1668
+ reason: `Prior finding (${finding.causalProofTier}): ${finding.title}`,
1669
+ citations: [
1670
+ {
1671
+ label: finding.title,
1672
+ entityKind: "finding",
1673
+ entityId: finding.id,
1674
+ route: findingRoute,
1675
+ },
1676
+ ],
1677
+ suggestedNextPivots: [
1678
+ {
1679
+ label: "Open prior finding",
1680
+ entityKind: "finding",
1681
+ entityId: finding.id,
1682
+ route: findingRoute,
1683
+ reason: "Search incident memory for similar recurring findings.",
1684
+ },
1685
+ ],
1686
+ });
1687
+ }
1688
+
1689
+ let isBlackoutSuspected = false;
1690
+ if (faultLocus?.faultLocusNodeId) {
1691
+ try {
1692
+ const graph = await getOntologyGraph(db, { projectId, environment });
1693
+ isBlackoutSuspected = Boolean(
1694
+ graph.nodes.find((n) => n.id === faultLocus.faultLocusNodeId)
1695
+ ?.isBlackoutSuspected,
1696
+ );
1697
+ } catch (err) {
1698
+ logger?.warn("[evidence-retrieval] blackout check failed", {
1699
+ error: err instanceof Error ? err.message : String(err),
1700
+ });
1701
+ }
1702
+ }
1703
+
1704
+ bundle.autosre = {
1705
+ faultLocus: faultLocus?.faultLocusNodeId
1706
+ ? {
1707
+ nodeId: faultLocus.faultLocusNodeId,
1708
+ spanId: faultLocus.faultLocusSpan?.span_id,
1709
+ relationshipCertainty: faultLocus.relationshipCertainty,
1710
+ isBlackoutSuspected,
1711
+ }
1712
+ : undefined,
1713
+ candidateChanges,
1714
+ contentionCandidates,
1715
+ priorFindings,
1716
+ };
1717
+ if (bundle.budget) {
1718
+ // Keep the autosre section inside the caller's token budget by
1719
+ // dropping the least-relevant tail entries (and their evidence refs).
1720
+ const autosre = bundle.autosre;
1721
+ const target = bundle.budget.targetTokens ?? Number.POSITIVE_INFINITY;
1722
+ const dropRef = (evidenceId: string) => {
1723
+ bundle.evidenceReferences = bundle.evidenceReferences.filter(
1724
+ (ref) => ref.evidenceId !== evidenceId,
1725
+ );
1726
+ };
1727
+ while (estimateTokens(bundle) > target) {
1728
+ if (autosre.priorFindings.length > 0) {
1729
+ const dropped = autosre.priorFindings.pop();
1730
+ if (dropped) dropRef(`finding:${dropped.findingId}`);
1731
+ } else if (autosre.contentionCandidates.length > 0) {
1732
+ autosre.contentionCandidates.pop();
1733
+ } else if (autosre.candidateChanges.length > 0) {
1734
+ const dropped = autosre.candidateChanges.pop();
1735
+ if (dropped) dropRef(`change:${dropped.changeId}`);
1736
+ } else {
1737
+ break;
1738
+ }
1739
+ }
1740
+ bundle.budget = {
1741
+ ...bundle.budget,
1742
+ estimatedTokens: estimateTokens(bundle),
1743
+ };
1744
+ }
1745
+ } catch (err) {
1746
+ // Gracefully degrade if AutoSRE tables are absent in minimal test setups.
1747
+ logger?.warn("[evidence-retrieval] autosre triage enrichment skipped", {
1748
+ trace_id: traceId,
1749
+ error: err instanceof Error ? err.message : String(err),
1750
+ });
1751
+ }
1752
+ return bundle;
1753
+ };
1754
+
1463
1755
  const parseBundleRequest = async (request: Request) => {
1464
1756
  try {
1465
1757
  const body = await request.json();
@@ -1490,11 +1782,17 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1490
1782
  );
1491
1783
  }
1492
1784
 
1785
+ const isAutoSreAnchor =
1786
+ anchor.entityKind === "incident" ||
1787
+ anchor.entityKind === "change" ||
1788
+ anchor.entityKind === "finding";
1493
1789
  const intent =
1494
1790
  typeof body.intent === "string" &&
1495
1791
  EVIDENCE_INTENTS.has(body.intent as EvidenceBundleIntent)
1496
1792
  ? (body.intent as EvidenceBundleIntent)
1497
- : "debug_failure";
1793
+ : isAutoSreAnchor
1794
+ ? "triage_incident"
1795
+ : "debug_failure";
1498
1796
  const budget = body.budget as Record<string, unknown> | undefined;
1499
1797
  const targetTokens = clampInt(
1500
1798
  budget?.targetTokens,
@@ -1550,7 +1848,7 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1550
1848
  all.findIndex((candidate) => candidate.id === profile.id) ===
1551
1849
  index,
1552
1850
  );
1553
- const bundle = buildActionGraphBundle(
1851
+ let bundle = buildActionGraphBundle(
1554
1852
  projectId,
1555
1853
  anchor.entityKind,
1556
1854
  anchor.entityId,
@@ -1560,15 +1858,346 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1560
1858
  targetTokens,
1561
1859
  detailLevel,
1562
1860
  );
1861
+ if (intent === "triage_incident") {
1862
+ bundle = await attachAutoSreTriageContext(
1863
+ db,
1864
+ projectId,
1865
+ traceIds[0] ?? null,
1866
+ bundle,
1867
+ runtime.logger,
1868
+ );
1869
+ }
1563
1870
  return c.json(await materializeBundle(db, projectId, bundle));
1564
1871
  }
1565
1872
 
1873
+ if (anchor.entityKind === "incident") {
1874
+ const incident = await getIncidentInvestigation(
1875
+ db,
1876
+ projectId,
1877
+ anchor.entityId,
1878
+ );
1879
+ if (!incident) {
1880
+ return c.json(
1881
+ { error: "Not Found", message: "Incident not found" },
1882
+ 404,
1883
+ );
1884
+ }
1885
+ const anchorTraceId = await resolveAnchorTraceId(
1886
+ db,
1887
+ projectId,
1888
+ incident.environment,
1889
+ { kind: incident.symptomAnchorKind, id: incident.symptomAnchorId },
1890
+ );
1891
+
1892
+ const incRoute = `#/incidents/${encodeURIComponent(incident.id)}`;
1893
+ const baseBundle: EvidenceBundle = {
1894
+ schemaVersion: BUNDLE_SCHEMA_VERSION,
1895
+ intent,
1896
+ environment: incident.environment,
1897
+ incidentId: incident.id,
1898
+ symptomFingerprint: incident.symptomFingerprint ?? undefined,
1899
+ anchor: { entityKind: "incident", entityId: incident.id },
1900
+ budget: { targetTokens, detailLevel },
1901
+ summary: `Incident ${incident.id} (${incident.status}) on ${incident.targetNodeId} in environment ${incident.environment}.`,
1902
+ derivedSummaries: [
1903
+ {
1904
+ title: `Incident ${incident.id}`,
1905
+ reason: `Trigger ${incident.symptomAnchorKind}:${incident.symptomAnchorId} on ${incident.targetNodeId} (status=${incident.status}).`,
1906
+ confidence: 1.0,
1907
+ evidenceIds: [`incident:${incident.id}`],
1908
+ retrievalRefIds: [],
1909
+ },
1910
+ ],
1911
+ findings: [],
1912
+ compactions: [],
1913
+ evidenceReferences: [
1914
+ {
1915
+ evidenceId: `incident:${incident.id}`,
1916
+ entityKind: "incident",
1917
+ entityId: incident.id,
1918
+ route: incRoute,
1919
+ source: "incident_investigations",
1920
+ confidence: 1.0,
1921
+ relationshipCertainty: "exact",
1922
+ reason: `Incident triggered by ${incident.symptomAnchorKind}:${incident.symptomAnchorId}.`,
1923
+ citations: [
1924
+ {
1925
+ label: incident.targetNodeId,
1926
+ entityKind: "incident",
1927
+ entityId: incident.id,
1928
+ route: incRoute,
1929
+ },
1930
+ ],
1931
+ suggestedNextPivots: [
1932
+ {
1933
+ label: "Open incident",
1934
+ entityKind: "incident",
1935
+ entityId: incident.id,
1936
+ route: incRoute,
1937
+ reason:
1938
+ "Inspect incident investigation and candidate changes.",
1939
+ },
1940
+ ],
1941
+ },
1942
+ ],
1943
+ retrievalRefs: [],
1944
+ suggestedNextPivots: [
1945
+ {
1946
+ label: "Open incident",
1947
+ entityKind: "incident",
1948
+ entityId: incident.id,
1949
+ route: incRoute,
1950
+ reason: "Inspect incident investigation and candidate changes.",
1951
+ },
1952
+ ],
1953
+ };
1954
+ const enriched = await attachAutoSreTriageContext(
1955
+ db,
1956
+ projectId,
1957
+ anchorTraceId,
1958
+ baseBundle,
1959
+ runtime.logger,
1960
+ );
1961
+ return c.json(await materializeBundle(db, projectId, enriched));
1962
+ }
1963
+
1964
+ if (anchor.entityKind === "change") {
1965
+ const change = await getChangeEvent(db, projectId, anchor.entityId);
1966
+ if (!change) {
1967
+ return c.json(
1968
+ { error: "Not Found", message: "Change event not found" },
1969
+ 404,
1970
+ );
1971
+ }
1972
+ const blastRadius = await computeChangeBlastRadius(
1973
+ db,
1974
+ projectId,
1975
+ change.id,
1976
+ { environment: change.environment },
1977
+ );
1978
+ const anchorTraceId = await resolveAnchorTraceId(
1979
+ db,
1980
+ projectId,
1981
+ change.environment,
1982
+ null,
1983
+ );
1984
+
1985
+ const changeRoute = `#/changes/${encodeURIComponent(change.id)}`;
1986
+ const summary =
1987
+ blastRadius?.summary ??
1988
+ `Change ${change.id} (${change.kind} ${change.version ?? ""}) in ${change.environment}: ${change.summary}`;
1989
+ const baseBundle: EvidenceBundle = {
1990
+ schemaVersion: BUNDLE_SCHEMA_VERSION,
1991
+ intent,
1992
+ environment: change.environment,
1993
+ anchor: { entityKind: "change", entityId: change.id },
1994
+ budget: { targetTokens, detailLevel },
1995
+ summary,
1996
+ derivedSummaries: [
1997
+ {
1998
+ title: `Change ${change.id} (${change.kind})`,
1999
+ reason: summary,
2000
+ confidence:
2001
+ blastRadius?.cohortVerdict === "isolated_to_new_version"
2002
+ ? 0.9
2003
+ : 0.7,
2004
+ evidenceIds: [`change:${change.id}`],
2005
+ retrievalRefIds: [],
2006
+ },
2007
+ ],
2008
+ findings: blastRadius
2009
+ ? [
2010
+ {
2011
+ title: `Blast radius (${blastRadius.cohortVerdict})`,
2012
+ reason: blastRadius.summary,
2013
+ confidence:
2014
+ blastRadius.cohortVerdict === "isolated_to_new_version"
2015
+ ? 0.9
2016
+ : 0.65,
2017
+ evidenceIds: [`change:${change.id}`],
2018
+ retrievalRefIds: [],
2019
+ },
2020
+ ]
2021
+ : [],
2022
+ compactions: [],
2023
+ evidenceReferences: [
2024
+ {
2025
+ evidenceId: `change:${change.id}`,
2026
+ entityKind: "change",
2027
+ entityId: change.id,
2028
+ route: changeRoute,
2029
+ source: "change_events",
2030
+ confidence: 1.0,
2031
+ relationshipCertainty:
2032
+ blastRadius?.relationshipCertainty ??
2033
+ change.relationshipCertainty,
2034
+ causalProofTier: blastRadius?.causalProofTier,
2035
+ reason: summary,
2036
+ citations: [
2037
+ {
2038
+ label: `${change.kind} ${change.version ?? change.id}`,
2039
+ entityKind: "change",
2040
+ entityId: change.id,
2041
+ route: changeRoute,
2042
+ },
2043
+ ],
2044
+ suggestedNextPivots: [
2045
+ {
2046
+ label: "Inspect change blast radius",
2047
+ entityKind: "change",
2048
+ entityId: change.id,
2049
+ route: changeRoute,
2050
+ reason:
2051
+ "Inspect traffic-normalized cohort lift and contention.",
2052
+ },
2053
+ ],
2054
+ },
2055
+ ],
2056
+ retrievalRefs: [],
2057
+ suggestedNextPivots: [
2058
+ {
2059
+ label: "Inspect change blast radius",
2060
+ entityKind: "change",
2061
+ entityId: change.id,
2062
+ route: changeRoute,
2063
+ reason: "Inspect traffic-normalized cohort lift and contention.",
2064
+ },
2065
+ ],
2066
+ };
2067
+ const enriched = await attachAutoSreTriageContext(
2068
+ db,
2069
+ projectId,
2070
+ anchorTraceId,
2071
+ baseBundle,
2072
+ runtime.logger,
2073
+ );
2074
+ return c.json(await materializeBundle(db, projectId, enriched));
2075
+ }
2076
+
2077
+ if (anchor.entityKind === "finding") {
2078
+ const finding = await getInvestigationFinding(
2079
+ db,
2080
+ projectId,
2081
+ anchor.entityId,
2082
+ );
2083
+ if (!finding) {
2084
+ return c.json(
2085
+ { error: "Not Found", message: "Finding not found" },
2086
+ 404,
2087
+ );
2088
+ }
2089
+ const proposals = await listRemediationProposals(db, {
2090
+ projectId,
2091
+ environment: finding.environment,
2092
+ findingId: finding.id,
2093
+ limit: 10,
2094
+ });
2095
+ const anchorTraceId = await resolveAnchorTraceId(
2096
+ db,
2097
+ projectId,
2098
+ finding.environment,
2099
+ { kind: finding.symptomEntityKind, id: finding.symptomEntityId },
2100
+ );
2101
+
2102
+ const findingRoute = `#/findings/${encodeURIComponent(finding.id)}`;
2103
+ const summary = `${finding.title} (${finding.causalProofTier}, verification=${finding.causalVerificationStatus}, recovery=${finding.recoveryStatus}): ${finding.summary}`;
2104
+ const baseBundle: EvidenceBundle = {
2105
+ schemaVersion: BUNDLE_SCHEMA_VERSION,
2106
+ intent,
2107
+ environment: finding.environment,
2108
+ incidentId: finding.incidentId ?? undefined,
2109
+ symptomFingerprint: finding.fingerprint,
2110
+ anchor: { entityKind: "finding", entityId: finding.id },
2111
+ budget: { targetTokens, detailLevel },
2112
+ summary,
2113
+ derivedSummaries: [
2114
+ {
2115
+ title: finding.title,
2116
+ reason: summary,
2117
+ confidence: finding.confidence,
2118
+ evidenceIds: [`finding:${finding.id}`],
2119
+ retrievalRefIds: [],
2120
+ },
2121
+ ],
2122
+ findings: [
2123
+ {
2124
+ title: finding.title,
2125
+ reason: finding.summary,
2126
+ confidence: finding.confidence,
2127
+ evidenceIds: [
2128
+ `finding:${finding.id}`,
2129
+ ...finding.citedEvidenceIds,
2130
+ ],
2131
+ retrievalRefIds: [],
2132
+ },
2133
+ ...proposals.map((p) => ({
2134
+ title: `Remediation proposal ${p.id} (${p.actionKind})`,
2135
+ reason: `status=${p.status}, autonomy=${p.effectiveAutonomy} — ${p.policyDecisionReason}`,
2136
+ confidence: finding.confidence,
2137
+ evidenceIds: [`finding:${finding.id}`],
2138
+ retrievalRefIds: [],
2139
+ })),
2140
+ ],
2141
+ compactions: [],
2142
+ evidenceReferences: [
2143
+ {
2144
+ evidenceId: `finding:${finding.id}`,
2145
+ entityKind: "finding",
2146
+ entityId: finding.id,
2147
+ route: findingRoute,
2148
+ source: "investigation_findings",
2149
+ confidence: finding.confidence,
2150
+ relationshipCertainty: "exact",
2151
+ causalProofTier: finding.causalProofTier,
2152
+ reason: summary,
2153
+ citations: [
2154
+ {
2155
+ label: finding.title,
2156
+ entityKind: "finding",
2157
+ entityId: finding.id,
2158
+ route: findingRoute,
2159
+ },
2160
+ ],
2161
+ suggestedNextPivots: [
2162
+ {
2163
+ label: "Open finding",
2164
+ entityKind: "finding",
2165
+ entityId: finding.id,
2166
+ route: findingRoute,
2167
+ reason:
2168
+ "Inspect causal chain, counterfactual verification, and remediation proposals.",
2169
+ },
2170
+ ],
2171
+ },
2172
+ ],
2173
+ retrievalRefs: [],
2174
+ suggestedNextPivots: [
2175
+ {
2176
+ label: "Open finding",
2177
+ entityKind: "finding",
2178
+ entityId: finding.id,
2179
+ route: findingRoute,
2180
+ reason:
2181
+ "Inspect causal chain, counterfactual verification, and remediation proposals.",
2182
+ },
2183
+ ],
2184
+ };
2185
+ const enriched = await attachAutoSreTriageContext(
2186
+ db,
2187
+ projectId,
2188
+ anchorTraceId,
2189
+ baseBundle,
2190
+ runtime.logger,
2191
+ );
2192
+ return c.json(await materializeBundle(db, projectId, enriched));
2193
+ }
2194
+
1566
2195
  if (anchor.entityKind !== "trace") {
1567
2196
  return c.json(
1568
2197
  {
1569
2198
  error: "Bad Request",
1570
2199
  message:
1571
- "Supported evidence bundle anchors are trace, action, agent_run, and tool_call.",
2200
+ "Supported evidence bundle anchors are trace, action, agent_run, tool_call, change, finding, and incident.",
1572
2201
  },
1573
2202
  400,
1574
2203
  );
@@ -1588,7 +2217,7 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1588
2217
  limit: MAX_LOGS_FOR_BUNDLE,
1589
2218
  });
1590
2219
  const profiles = await profilesForTrace(db, projectId, traceId);
1591
- const bundle = await buildTraceBundle(
2220
+ let bundle = await buildTraceBundle(
1592
2221
  projectId,
1593
2222
  detail,
1594
2223
  logs.logs,
@@ -1598,6 +2227,15 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1598
2227
  detailLevel,
1599
2228
  hours,
1600
2229
  );
2230
+ if (intent === "triage_incident") {
2231
+ bundle = await attachAutoSreTriageContext(
2232
+ db,
2233
+ projectId,
2234
+ traceId,
2235
+ bundle,
2236
+ runtime.logger,
2237
+ );
2238
+ }
1601
2239
  return c.json(await materializeBundle(db, projectId, bundle));
1602
2240
  });
1603
2241