@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.
@@ -14,11 +14,21 @@ import type {
14
14
  import type { CollectorPlugin } from "../framework/collector";
15
15
  import {
16
16
  computeChangeBlastRadius,
17
+ getChangeEvent,
17
18
  listChangeEvents,
18
19
  } from "../lib/autosre-change-correlation";
19
- import { searchInvestigationFindings } from "../lib/autosre-findings";
20
- import { loadTraceFaultLocusFromDb } from "../lib/autosre-ontology";
21
- import { getIncidentInvestigation } from "../lib/autosre-remediation-gate";
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";
22
32
  import type {
23
33
  ActionRef,
24
34
  EntityManifestExtended,
@@ -1468,11 +1478,56 @@ const buildActionGraphBundle = (
1468
1478
  return bundle;
1469
1479
  };
1470
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
+
1471
1525
  const attachAutoSreTriageContext = async (
1472
1526
  db: SqlDb,
1473
1527
  projectId: string,
1474
1528
  traceId: string | null,
1475
1529
  bundle: EvidenceBundle,
1530
+ logger?: { warn: (message: string, meta?: Record<string, unknown>) => void },
1476
1531
  ): Promise<EvidenceBundle> => {
1477
1532
  try {
1478
1533
  const faultLocus = traceId
@@ -1483,11 +1538,24 @@ const attachAutoSreTriageContext = async (
1483
1538
  bundle.environment ??
1484
1539
  "default"
1485
1540
  ).toLowerCase();
1486
- const recentChanges = await listChangeEvents(db, {
1541
+ const locusService = faultLocus?.faultLocusSpan?.service_name ?? null;
1542
+ const allRecentChanges = await listChangeEvents(db, {
1487
1543
  projectId,
1488
1544
  environment,
1489
- limit: 5,
1545
+ limit: locusService ? 25 : 5,
1490
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;
1491
1559
  const blastRadii = (
1492
1560
  await Promise.all(
1493
1561
  recentChanges
@@ -1539,13 +1607,8 @@ const attachAutoSreTriageContext = async (
1539
1607
  limit: 5,
1540
1608
  });
1541
1609
  }
1542
- if (priorFindingsList.length === 0) {
1543
- priorFindingsList = await searchInvestigationFindings(db, {
1544
- projectId,
1545
- environment,
1546
- limit: 5,
1547
- });
1548
- }
1610
+ // No unfiltered fallback: unrelated findings would read as relevant
1611
+ // prior art. An empty list is the honest answer.
1549
1612
  const priorFindings = priorFindingsList.map((f) => ({
1550
1613
  findingId: f.id,
1551
1614
  title: f.title,
@@ -1567,8 +1630,7 @@ const attachAutoSreTriageContext = async (
1567
1630
  source: br.overlappingCodePaths.length
1568
1631
  ? "stack_frame_overlap"
1569
1632
  : "cohort_comparison",
1570
- confidence:
1571
- br.cohortVerdict === "isolated_to_new_version" ? 0.9 : 0.65,
1633
+ confidence: br.cohortVerdict === "isolated_to_new_version" ? 0.9 : 0.65,
1572
1634
  relationshipCertainty: br.relationshipCertainty,
1573
1635
  causalProofTier: br.causalProofTier,
1574
1636
  reason: br.summary,
@@ -1624,13 +1686,28 @@ const attachAutoSreTriageContext = async (
1624
1686
  });
1625
1687
  }
1626
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
+
1627
1704
  bundle.autosre = {
1628
1705
  faultLocus: faultLocus?.faultLocusNodeId
1629
1706
  ? {
1630
1707
  nodeId: faultLocus.faultLocusNodeId,
1631
1708
  spanId: faultLocus.faultLocusSpan?.span_id,
1632
1709
  relationshipCertainty: faultLocus.relationshipCertainty,
1633
- isBlackoutSuspected: false,
1710
+ isBlackoutSuspected,
1634
1711
  }
1635
1712
  : undefined,
1636
1713
  candidateChanges,
@@ -1638,13 +1715,39 @@ const attachAutoSreTriageContext = async (
1638
1715
  priorFindings,
1639
1716
  };
1640
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
+ }
1641
1740
  bundle.budget = {
1642
1741
  ...bundle.budget,
1643
1742
  estimatedTokens: estimateTokens(bundle),
1644
1743
  };
1645
1744
  }
1646
- } catch {
1647
- // Gracefully degrade if AutoSRE tables are absent in minimal test setups
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
+ });
1648
1751
  }
1649
1752
  return bundle;
1650
1753
  };
@@ -1679,11 +1782,17 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1679
1782
  );
1680
1783
  }
1681
1784
 
1785
+ const isAutoSreAnchor =
1786
+ anchor.entityKind === "incident" ||
1787
+ anchor.entityKind === "change" ||
1788
+ anchor.entityKind === "finding";
1682
1789
  const intent =
1683
1790
  typeof body.intent === "string" &&
1684
1791
  EVIDENCE_INTENTS.has(body.intent as EvidenceBundleIntent)
1685
1792
  ? (body.intent as EvidenceBundleIntent)
1686
- : "debug_failure";
1793
+ : isAutoSreAnchor
1794
+ ? "triage_incident"
1795
+ : "debug_failure";
1687
1796
  const budget = body.budget as Record<string, unknown> | undefined;
1688
1797
  const targetTokens = clampInt(
1689
1798
  budget?.targetTokens,
@@ -1755,6 +1864,7 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1755
1864
  projectId,
1756
1865
  traceIds[0] ?? null,
1757
1866
  bundle,
1867
+ runtime.logger,
1758
1868
  );
1759
1869
  }
1760
1870
  return c.json(await materializeBundle(db, projectId, bundle));
@@ -1772,14 +1882,12 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1772
1882
  404,
1773
1883
  );
1774
1884
  }
1775
- const recentErrSpan = await db
1776
- .prepare(
1777
- `SELECT trace_id FROM telemetry_spans
1778
- WHERE project_id = ? AND environment = ? AND status_code = 2
1779
- ORDER BY start_time DESC LIMIT 1`,
1780
- )
1781
- .bind(projectId, incident.environment)
1782
- .first<{ trace_id: string }>();
1885
+ const anchorTraceId = await resolveAnchorTraceId(
1886
+ db,
1887
+ projectId,
1888
+ incident.environment,
1889
+ { kind: incident.symptomAnchorKind, id: incident.symptomAnchorId },
1890
+ );
1783
1891
 
1784
1892
  const incRoute = `#/incidents/${encodeURIComponent(incident.id)}`;
1785
1893
  const baseBundle: EvidenceBundle = {
@@ -1826,7 +1934,8 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1826
1934
  entityKind: "incident",
1827
1935
  entityId: incident.id,
1828
1936
  route: incRoute,
1829
- reason: "Inspect incident investigation and candidate changes.",
1937
+ reason:
1938
+ "Inspect incident investigation and candidate changes.",
1830
1939
  },
1831
1940
  ],
1832
1941
  },
@@ -1845,8 +1954,240 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1845
1954
  const enriched = await attachAutoSreTriageContext(
1846
1955
  db,
1847
1956
  projectId,
1848
- recentErrSpan?.trace_id ?? null,
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,
1849
2189
  baseBundle,
2190
+ runtime.logger,
1850
2191
  );
1851
2192
  return c.json(await materializeBundle(db, projectId, enriched));
1852
2193
  }
@@ -1856,7 +2197,7 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1856
2197
  {
1857
2198
  error: "Bad Request",
1858
2199
  message:
1859
- "Supported evidence bundle anchors are trace, action, agent_run, tool_call, and incident.",
2200
+ "Supported evidence bundle anchors are trace, action, agent_run, tool_call, change, finding, and incident.",
1860
2201
  },
1861
2202
  400,
1862
2203
  );
@@ -1886,12 +2227,13 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
1886
2227
  detailLevel,
1887
2228
  hours,
1888
2229
  );
1889
- if (intent === "triage_incident" || intent === "debug_failure") {
2230
+ if (intent === "triage_incident") {
1890
2231
  bundle = await attachAutoSreTriageContext(
1891
2232
  db,
1892
2233
  projectId,
1893
2234
  traceId,
1894
2235
  bundle,
2236
+ runtime.logger,
1895
2237
  );
1896
2238
  }
1897
2239
  return c.json(await materializeBundle(db, projectId, bundle));