@neat.is/core 0.7.10 → 0.8.0

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.
@@ -20,7 +20,7 @@ import {
20
20
  startStalenessLoop,
21
21
  touchLastSeen,
22
22
  writeAtomically
23
- } from "./chunk-6T7ZHODF.js";
23
+ } from "./chunk-3PXRQH53.js";
24
24
  import {
25
25
  assertBindAuthority,
26
26
  buildOtelReceiver,
@@ -891,4 +891,4 @@ export {
891
891
  resolveHost,
892
892
  startDaemon
893
893
  };
894
- //# sourceMappingURL=chunk-D4OX6MUX.js.map
894
+ //# sourceMappingURL=chunk-OJ6BW63H.js.map
package/dist/cli.cjs CHANGED
@@ -1445,7 +1445,7 @@ var rootCauseShapes = {
1445
1445
  [import_types.NodeType.FileNode]: fileRootCauseShape,
1446
1446
  [import_types.NodeType.SymbolNode]: symbolRootCauseShape
1447
1447
  };
1448
- function getRootCause(graph, errorNodeId, errorEvent, incidents) {
1448
+ function legacyRootCause(graph, errorNodeId, errorEvent, incidents) {
1449
1449
  if (!graph.hasNode(errorNodeId)) return null;
1450
1450
  const origin = graph.getNodeAttributes(errorNodeId);
1451
1451
  const shape = rootCauseShapes[origin.type];
@@ -1702,7 +1702,9 @@ function getObservedDependencies(graph, nodeId) {
1702
1702
  dependencies: [],
1703
1703
  observed: false,
1704
1704
  inboundObservedCount: 0,
1705
- hasExtractedOutbound: false
1705
+ hasExtractedOutbound: false,
1706
+ inboundVolume: 0,
1707
+ window: "lifetime"
1706
1708
  });
1707
1709
  }
1708
1710
  const attrs = graph.getNodeAttributes(nodeId);
@@ -1733,11 +1735,19 @@ function getObservedDependencies(graph, nodeId) {
1733
1735
  }
1734
1736
  }
1735
1737
  let inboundObservedCount = 0;
1738
+ let inboundVolume = 0;
1739
+ let inboundLastObserved;
1736
1740
  for (const tgt of scope) {
1737
1741
  for (const edgeId of graph.inboundEdges(tgt)) {
1738
1742
  const e = graph.getEdgeAttributes(edgeId);
1739
1743
  if (e.type === import_types.EdgeType.CONTAINS) continue;
1740
- if (e.provenance === import_types.Provenance.OBSERVED) inboundObservedCount += 1;
1744
+ if (e.provenance === import_types.Provenance.OBSERVED) {
1745
+ inboundObservedCount += 1;
1746
+ inboundVolume += e.callCount ?? e.signal?.spanCount ?? 1;
1747
+ if (e.lastObserved && (!inboundLastObserved || e.lastObserved > inboundLastObserved)) {
1748
+ inboundLastObserved = e.lastObserved;
1749
+ }
1750
+ }
1741
1751
  }
1742
1752
  }
1743
1753
  dependencies.sort(
@@ -1748,7 +1758,291 @@ function getObservedDependencies(graph, nodeId) {
1748
1758
  dependencies,
1749
1759
  observed: dependencies.length > 0 || inboundObservedCount > 0,
1750
1760
  inboundObservedCount,
1751
- hasExtractedOutbound
1761
+ hasExtractedOutbound,
1762
+ // The signal is cumulative, so the honest window label is "lifetime" (ADR-190).
1763
+ inboundVolume,
1764
+ window: "lifetime",
1765
+ ...inboundLastObserved ? { inboundLastObserved } : {}
1766
+ });
1767
+ }
1768
+ var SATURATION_P95_MS = 1e3;
1769
+ function nodeScope(graph, nodeId) {
1770
+ const scope = [nodeId];
1771
+ if (!graph.hasNode(nodeId)) return scope;
1772
+ const attrs = graph.getNodeAttributes(nodeId);
1773
+ if (attrs.type === import_types.NodeType.ServiceNode) {
1774
+ for (const edgeId of graph.outboundEdges(nodeId)) {
1775
+ const e = graph.getEdgeAttributes(edgeId);
1776
+ if (e.type !== import_types.EdgeType.CONTAINS) continue;
1777
+ const owned = graph.getNodeAttributes(e.target);
1778
+ if (owned.type === import_types.NodeType.FileNode) scope.push(e.target);
1779
+ }
1780
+ }
1781
+ return scope;
1782
+ }
1783
+ function incidentCountForNode(nodeId, incidents) {
1784
+ if (!incidents || incidents.length === 0) return 0;
1785
+ return incidents.filter((ev) => incidentMatchesNode(ev, nodeId)).length;
1786
+ }
1787
+ function nodeContext(graph, nodeId, incidents, now = Date.now()) {
1788
+ const scope = nodeScope(graph, nodeId);
1789
+ let errorsFromCallers = 0;
1790
+ let inboundVolume = 0;
1791
+ let outboundVolume = 0;
1792
+ let outboundErrors = 0;
1793
+ let latestInboundMs;
1794
+ let latencyP95Ms;
1795
+ let stale = false;
1796
+ for (const n of scope) {
1797
+ if (!graph.hasNode(n)) continue;
1798
+ for (const edgeId of graph.inboundEdges(n)) {
1799
+ const e = graph.getEdgeAttributes(edgeId);
1800
+ if (e.type === import_types.EdgeType.CONTAINS) continue;
1801
+ errorsFromCallers += e.signal?.errorCount ?? 0;
1802
+ inboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
1803
+ if (e.provenance === import_types.Provenance.STALE) stale = true;
1804
+ const p95 = e.signal?.latencyMs?.p95;
1805
+ if (p95 !== void 0) latencyP95Ms = Math.max(latencyP95Ms ?? 0, p95);
1806
+ if (e.lastObserved) {
1807
+ const t = Date.parse(e.lastObserved);
1808
+ if (Number.isFinite(t)) latestInboundMs = Math.max(latestInboundMs ?? 0, t);
1809
+ }
1810
+ }
1811
+ for (const edgeId of graph.outboundEdges(n)) {
1812
+ const e = graph.getEdgeAttributes(edgeId);
1813
+ if (e.type === import_types.EdgeType.CONTAINS) continue;
1814
+ outboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
1815
+ if (e.type === import_types.EdgeType.CALLS) outboundErrors += e.signal?.errorCount ?? 0;
1816
+ if (e.provenance === import_types.Provenance.STALE) stale = true;
1817
+ }
1818
+ }
1819
+ const errorsEmittedHere = incidentCountForNode(nodeId, incidents) + outboundErrors;
1820
+ const lastObservedAgeMs = latestInboundMs !== void 0 ? Math.max(0, now - latestInboundMs) : void 0;
1821
+ return {
1822
+ errorsEmittedHere,
1823
+ errorsFromCallers,
1824
+ callCount: inboundVolume,
1825
+ outboundVolume,
1826
+ ...lastObservedAgeMs !== void 0 ? { lastObservedAgeMs } : {},
1827
+ ...latencyP95Ms !== void 0 ? { latencyP95Ms } : {},
1828
+ stale
1829
+ };
1830
+ }
1831
+ function isSaturated(ctx) {
1832
+ return ctx.latencyP95Ms !== void 0 && ctx.latencyP95Ms >= SATURATION_P95_MS;
1833
+ }
1834
+ function classifyNode(ctx) {
1835
+ if (ctx.errorsEmittedHere > 0) {
1836
+ if ((ctx.stale || isSaturated(ctx)) && ctx.errorsFromCallers >= ctx.errorsEmittedHere) {
1837
+ return "symptom-only";
1838
+ }
1839
+ return "primary-failure";
1840
+ }
1841
+ if (ctx.errorsFromCallers > 0) return "symptom-only";
1842
+ return "unrelated";
1843
+ }
1844
+ function isVictimSeed(ctx) {
1845
+ return ctx.errorsFromCallers > 0 && (ctx.stale || isSaturated(ctx)) && ctx.errorsEmittedHere <= ctx.errorsFromCallers;
1846
+ }
1847
+ function grainOf(graph, nodeId) {
1848
+ if (!graph.hasNode(nodeId)) return "unknown";
1849
+ const t = graph.getNodeAttributes(nodeId).type;
1850
+ if (t === import_types.NodeType.ServiceNode) return "service";
1851
+ if (t === import_types.NodeType.FileNode) return "file";
1852
+ if (t === import_types.NodeType.SymbolNode) return "symbol";
1853
+ return t;
1854
+ }
1855
+ function findPath(graph, from, to, direction, maxDepth) {
1856
+ if (!graph.hasNode(from) || !graph.hasNode(to)) return null;
1857
+ if (from === to) return { nodes: [from], edges: [] };
1858
+ const queue = [{ nodeId: from, depth: 0, nodes: [from], edges: [] }];
1859
+ const enqueued = /* @__PURE__ */ new Set([from]);
1860
+ while (queue.length > 0) {
1861
+ const frame = queue.shift();
1862
+ if (frame.depth >= maxDepth) continue;
1863
+ const best = direction === "up" ? bestEdgeBySource(graph, graph.inboundEdges(frame.nodeId)) : bestEdgeByTarget(graph, graph.outboundEdges(frame.nodeId));
1864
+ const neighbours = [...best.entries()].sort((a, b) => a[0].localeCompare(b[0]));
1865
+ for (const [nid, edge] of neighbours) {
1866
+ if (nid === to) return { nodes: [...frame.nodes, nid], edges: [...frame.edges, edge] };
1867
+ if (enqueued.has(nid)) continue;
1868
+ enqueued.add(nid);
1869
+ queue.push({
1870
+ nodeId: nid,
1871
+ depth: frame.depth + 1,
1872
+ nodes: [...frame.nodes, nid],
1873
+ edges: [...frame.edges, edge]
1874
+ });
1875
+ }
1876
+ }
1877
+ return null;
1878
+ }
1879
+ var EMPTY_CONTEXT = {
1880
+ errorsEmittedHere: 0,
1881
+ errorsFromCallers: 0,
1882
+ callCount: 0,
1883
+ outboundVolume: 0,
1884
+ stale: false
1885
+ };
1886
+ function expandNode(graph, nodeId, direction, incidents, now = Date.now()) {
1887
+ if (!graph.hasNode(nodeId)) {
1888
+ return import_types.ExpandResultSchema.parse({
1889
+ origin: nodeId,
1890
+ direction,
1891
+ node: { id: nodeId, classification: "unrelated", context: EMPTY_CONTEXT },
1892
+ neighbours: []
1893
+ });
1894
+ }
1895
+ const ctx = nodeContext(graph, nodeId, incidents, now);
1896
+ const best = direction === "up" ? bestEdgeBySource(graph, graph.inboundEdges(nodeId)) : bestEdgeByTarget(graph, graph.outboundEdges(nodeId));
1897
+ const neighbours = [];
1898
+ for (const [nid, edge] of best) {
1899
+ if (edge.type === import_types.EdgeType.CONTAINS) continue;
1900
+ const nctx = nodeContext(graph, nid, incidents, now);
1901
+ neighbours.push({
1902
+ node: nid,
1903
+ edgeType: edge.type,
1904
+ provenance: edge.provenance,
1905
+ classification: classifyNode(nctx),
1906
+ context: nctx
1907
+ });
1908
+ }
1909
+ neighbours.sort((a, b) => a.node.localeCompare(b.node));
1910
+ return import_types.ExpandResultSchema.parse({
1911
+ origin: nodeId,
1912
+ direction,
1913
+ node: { id: nodeId, classification: classifyNode(ctx), context: ctx },
1914
+ neighbours
1915
+ });
1916
+ }
1917
+ function relate(graph, a, b, maxDepth = ROOT_CAUSE_MAX_DEPTH) {
1918
+ const buildPath = (fp) => ({
1919
+ nodes: fp.nodes,
1920
+ edgeTypes: fp.edges.map((e) => e.type),
1921
+ provenance: fp.edges.map((e) => e.provenance),
1922
+ grain: fp.nodes.map((n) => grainOf(graph, n)),
1923
+ // The failure runs end to end when every hop carries error / latency / alert
1924
+ // signal — that is what turns reachability into cause-confirmation.
1925
+ carriesSignal: fp.edges.length > 0 && fp.edges.every(
1926
+ (e) => (e.signal?.errorCount ?? 0) > 0 || e.signal?.latencyMs !== void 0 || e.signal?.anomalous !== void 0
1927
+ )
1928
+ });
1929
+ if (!graph.hasNode(a) || !graph.hasNode(b)) {
1930
+ return import_types.RelateResultSchema.parse({
1931
+ a,
1932
+ b,
1933
+ related: false,
1934
+ direction: null,
1935
+ paths: [],
1936
+ note: !graph.hasNode(a) ? `node not found: ${a}` : `node not found: ${b}`
1937
+ });
1938
+ }
1939
+ const down = findPath(graph, a, b, "down", maxDepth);
1940
+ const up = findPath(graph, a, b, "up", maxDepth);
1941
+ if (!down && !up) {
1942
+ return import_types.RelateResultSchema.parse({
1943
+ a,
1944
+ b,
1945
+ related: false,
1946
+ direction: null,
1947
+ paths: [],
1948
+ note: `no path within ${maxDepth} hops`
1949
+ });
1950
+ }
1951
+ const paths = [];
1952
+ const direction = down ? "a->b" : "b->a";
1953
+ if (down) paths.push(buildPath(down));
1954
+ if (up) paths.push(buildPath(up));
1955
+ const endpointsFine = grainOf(graph, a) !== "service" && grainOf(graph, b) !== "service";
1956
+ const grainGap = endpointsFine && paths[0].grain.some((g) => g === "service");
1957
+ return import_types.RelateResultSchema.parse({
1958
+ a,
1959
+ b,
1960
+ related: true,
1961
+ direction,
1962
+ paths,
1963
+ ...grainGap ? { grainGap: true } : {}
1964
+ });
1965
+ }
1966
+ function findLoadOrigin(graph, alertNodeId, incidents, now) {
1967
+ const upstream = getBlastRadius(graph, alertNodeId).affectedNodes.map((n) => n.nodeId);
1968
+ let best = null;
1969
+ for (const nid of upstream) {
1970
+ if (nid === alertNodeId) continue;
1971
+ const ctx = nodeContext(graph, nid, incidents, now);
1972
+ if (ctx.outboundVolume === 0) continue;
1973
+ const isSource = ctx.callCount === 0;
1974
+ const better = !best || (isSource !== best.isSource ? isSource : ctx.outboundVolume !== best.ctx.outboundVolume ? ctx.outboundVolume > best.ctx.outboundVolume : nid < best.node);
1975
+ if (better) best = { node: nid, ctx, isSource };
1976
+ }
1977
+ return best ? { node: best.node, ctx: best.ctx } : null;
1978
+ }
1979
+ function getRootCause(graph, errorNodeId, errorEvent, incidents, opts) {
1980
+ const legacy = legacyRootCause(graph, errorNodeId, errorEvent, incidents);
1981
+ if (!legacy) return null;
1982
+ const navigation = opts?.navigation ?? process.env.NEAT_RCA_NAVIGATION !== "0";
1983
+ if (!navigation) return legacy;
1984
+ return enrichWithNavigation(graph, errorNodeId, legacy, incidents, opts?.now ?? Date.now());
1985
+ }
1986
+ function enrichWithNavigation(graph, errorNodeId, legacy, incidents, now) {
1987
+ const seedNode = legacy.rootCauseNode;
1988
+ const seedCtx = graph.hasNode(seedNode) ? nodeContext(graph, seedNode, incidents, now) : null;
1989
+ const lastProv = legacy.edgeProvenances[legacy.edgeProvenances.length - 1];
1990
+ const candidates = [];
1991
+ if (seedCtx && isVictimSeed(seedCtx)) {
1992
+ const origin = findLoadOrigin(graph, errorNodeId, incidents, now);
1993
+ if (origin) {
1994
+ const path84 = findPath(graph, errorNodeId, origin.node, "up", ROOT_CAUSE_MAX_DEPTH);
1995
+ const originConfidence = confidenceFromMix(path84?.edges ?? [], now);
1996
+ candidates.push({
1997
+ node: origin.node,
1998
+ classification: "primary-failure",
1999
+ reason: `Highest-volume upstream source (${origin.ctx.outboundVolume} observed outbound calls) driving a saturated/stale subgraph; the alerting path decays downstream into a starved victim rather than a fault at the callee.`,
2000
+ context: origin.ctx,
2001
+ confidence: Math.max(0.3, Math.min(0.8, originConfidence || 0.5)),
2002
+ provenance: import_types.Provenance.OBSERVED
2003
+ });
2004
+ }
2005
+ const staleNote = seedCtx.stale ? "; the node has gone STALE" : "";
2006
+ const satNote = isSaturated(seedCtx) ? `; inbound p95 ${Math.round(seedCtx.latencyP95Ms)}ms is saturated` : "";
2007
+ candidates.push({
2008
+ node: seedNode,
2009
+ classification: "symptom-only",
2010
+ reason: `Errors arrive from callers (${seedCtx.errorsFromCallers}) but none originate here${staleNote}${satNote} \u2014 a downstream victim of load, not the fault.`,
2011
+ context: seedCtx,
2012
+ confidence: Math.min(legacy.confidence, 0.4),
2013
+ ...lastProv ? { provenance: lastProv } : {}
2014
+ });
2015
+ } else {
2016
+ candidates.push({
2017
+ node: seedNode,
2018
+ classification: "primary-failure",
2019
+ reason: legacy.rootCauseReason,
2020
+ context: seedCtx ?? EMPTY_CONTEXT,
2021
+ confidence: legacy.confidence,
2022
+ ...lastProv ? { provenance: lastProv } : {}
2023
+ });
2024
+ }
2025
+ const top = candidates[0];
2026
+ let traversalPath = legacy.traversalPath;
2027
+ let edgeProvenances = legacy.edgeProvenances;
2028
+ if (top.node !== seedNode) {
2029
+ const path84 = findPath(graph, errorNodeId, top.node, "up", ROOT_CAUSE_MAX_DEPTH);
2030
+ if (path84) {
2031
+ traversalPath = path84.nodes;
2032
+ edgeProvenances = path84.edges.map((e) => e.provenance);
2033
+ } else {
2034
+ traversalPath = [errorNodeId, top.node];
2035
+ edgeProvenances = [top.provenance ?? import_types.Provenance.OBSERVED];
2036
+ }
2037
+ }
2038
+ return import_types.RootCauseResultSchema.parse({
2039
+ rootCauseNode: top.node,
2040
+ rootCauseReason: top.reason,
2041
+ traversalPath,
2042
+ edgeProvenances,
2043
+ confidence: top.confidence,
2044
+ ...legacy.fixRecommendation ? { fixRecommendation: legacy.fixRecommendation } : {},
2045
+ candidates
1752
2046
  });
1753
2047
  }
1754
2048
 
@@ -4643,6 +4937,63 @@ function columnIsObserved(col) {
4643
4937
  return col.provenances.includes(import_types7.Provenance.OBSERVED);
4644
4938
  }
4645
4939
 
4940
+ // src/latency-digest.ts
4941
+ init_cjs_shims();
4942
+ var SUB = 16;
4943
+ var MIN_EXP = -10;
4944
+ var MAX_EXP = 22;
4945
+ var OCTAVES = MAX_EXP - MIN_EXP + 1;
4946
+ var OVERFLOW_INDEX = OCTAVES * SUB + 1;
4947
+ function latencyBucketIndex(ms) {
4948
+ if (!Number.isFinite(ms) || ms <= 0) return 0;
4949
+ const e = Math.floor(Math.log2(ms));
4950
+ if (e < MIN_EXP) return 0;
4951
+ if (e > MAX_EXP) return OVERFLOW_INDEX;
4952
+ const base = 2 ** e;
4953
+ const raw = Math.floor((ms / base - 1) * SUB);
4954
+ const s = raw < 0 ? 0 : raw >= SUB ? SUB - 1 : raw;
4955
+ return (e - MIN_EXP) * SUB + s + 1;
4956
+ }
4957
+ function bucketRepresentativeMs(index) {
4958
+ if (index <= 0) return 0;
4959
+ if (index >= OVERFLOW_INDEX) return 2 ** (MAX_EXP + 1);
4960
+ const zeroBased = index - 1;
4961
+ const e = MIN_EXP + Math.floor(zeroBased / SUB);
4962
+ const s = zeroBased % SUB;
4963
+ const base = 2 ** e;
4964
+ const lower = base * (1 + s / SUB);
4965
+ const upper = base * (1 + (s + 1) / SUB);
4966
+ return (lower + upper) / 2;
4967
+ }
4968
+ function recordLatency(hist, ms) {
4969
+ const key = String(latencyBucketIndex(ms));
4970
+ hist[key] = (hist[key] ?? 0) + 1;
4971
+ return hist;
4972
+ }
4973
+ function quantile(hist, q) {
4974
+ const entries = Object.entries(hist).map(([k, c]) => [Number(k), c]).filter(([idx, c]) => Number.isFinite(idx) && c > 0).sort((a, b) => a[0] - b[0]);
4975
+ let total = 0;
4976
+ for (const [, c] of entries) total += c;
4977
+ if (total === 0) return 0;
4978
+ const rank = Math.max(1, Math.ceil(q * total));
4979
+ let cumulative = 0;
4980
+ for (const [idx, c] of entries) {
4981
+ cumulative += c;
4982
+ if (cumulative >= rank) return bucketRepresentativeMs(idx);
4983
+ }
4984
+ return bucketRepresentativeMs(entries[entries.length - 1][0]);
4985
+ }
4986
+ function round(ms) {
4987
+ return Math.round(ms * 100) / 100;
4988
+ }
4989
+ function latencyPercentiles(hist) {
4990
+ if (!hist) return void 0;
4991
+ let total = 0;
4992
+ for (const c of Object.values(hist)) total += c;
4993
+ if (total === 0) return void 0;
4994
+ return { p50: round(quantile(hist, 0.5)), p95: round(quantile(hist, 0.95)) };
4995
+ }
4996
+
4646
4997
  // src/ingest.ts
4647
4998
  var HOUR_MS = 60 * 60 * 1e3;
4648
4999
  var DAY_MS = 24 * HOUR_MS;
@@ -5338,7 +5689,7 @@ function ensureFrontierNode(graph, host, ts) {
5338
5689
  graph.addNode(id, node);
5339
5690
  return id;
5340
5691
  }
5341
- function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence) {
5692
+ function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence, durationMs) {
5342
5693
  if (!graph.hasNode(source) || !graph.hasNode(target)) return null;
5343
5694
  const grain = source.startsWith("file:") || source.startsWith("symbol:") ? "file" : "service";
5344
5695
  const id = makeObservedEdgeId(type, source, target);
@@ -5346,10 +5697,15 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false, ev
5346
5697
  const existing = graph.getEdgeAttributes(id);
5347
5698
  const newSpanCount = (existing.signal?.spanCount ?? existing.callCount ?? 0) + 1;
5348
5699
  const newErrorCount = (existing.signal?.errorCount ?? 0) + (isError ? 1 : 0);
5700
+ const latencyHist2 = durationMs !== void 0 ? recordLatency({ ...existing.signal?.latencyHist ?? {} }, durationMs) : existing.signal?.latencyHist;
5701
+ const latencyMs2 = latencyPercentiles(latencyHist2) ?? existing.signal?.latencyMs;
5349
5702
  const newSignal = {
5350
5703
  spanCount: newSpanCount,
5351
5704
  errorCount: newErrorCount,
5352
- lastObservedAgeMs: 0
5705
+ lastObservedAgeMs: 0,
5706
+ ...latencyHist2 ? { latencyHist: latencyHist2 } : {},
5707
+ ...latencyMs2 ? { latencyMs: latencyMs2 } : {},
5708
+ ...existing.signal?.anomalous !== void 0 ? { anomalous: existing.signal.anomalous } : {}
5353
5709
  };
5354
5710
  const updated = {
5355
5711
  ...existing,
@@ -5364,10 +5720,14 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false, ev
5364
5720
  graph.replaceEdgeAttributes(id, updated);
5365
5721
  return { edge: updated, created: false };
5366
5722
  }
5723
+ const latencyHist = durationMs !== void 0 ? recordLatency({}, durationMs) : void 0;
5724
+ const latencyMs = latencyHist ? latencyPercentiles(latencyHist) : void 0;
5367
5725
  const signal = {
5368
5726
  spanCount: 1,
5369
5727
  errorCount: isError ? 1 : 0,
5370
- lastObservedAgeMs: 0
5728
+ lastObservedAgeMs: 0,
5729
+ ...latencyHist ? { latencyHist } : {},
5730
+ ...latencyMs ? { latencyMs } : {}
5371
5731
  };
5372
5732
  const edge = {
5373
5733
  id,
@@ -5592,6 +5952,7 @@ async function handleSpan(ctx, span) {
5592
5952
  }
5593
5953
  const sourceId = ensureServiceNode(ctx.graph, span.service, env);
5594
5954
  const isError = span.statusCode === 2;
5955
+ const durationMs = span.durationNanos > 0n ? Number(span.durationNanos) / 1e6 : void 0;
5595
5956
  const sourceServiceNode = ctx.graph.getNodeAttributes(sourceId);
5596
5957
  const callSite = callSiteFromSpan(span, sourceServiceNode, ctx.scanPath);
5597
5958
  cacheSpanService(span, nowMs, callSite);
@@ -5630,7 +5991,8 @@ async function handleSpan(ctx, span) {
5630
5991
  targetId,
5631
5992
  ts,
5632
5993
  isError,
5633
- callSiteEvidence
5994
+ callSiteEvidence,
5995
+ durationMs
5634
5996
  );
5635
5997
  if (result) affectedNode = targetId;
5636
5998
  if (span.dbSystem === "mongodb" && span.dbCollection) {
@@ -5642,7 +6004,8 @@ async function handleSpan(ctx, span) {
5642
6004
  collectionId,
5643
6005
  ts,
5644
6006
  isError,
5645
- callSiteEvidence
6007
+ callSiteEvidence,
6008
+ durationMs
5646
6009
  );
5647
6010
  }
5648
6011
  if (span.dbTable) {
@@ -5654,7 +6017,8 @@ async function handleSpan(ctx, span) {
5654
6017
  tableId,
5655
6018
  ts,
5656
6019
  isError,
5657
- callSiteEvidence
6020
+ callSiteEvidence,
6021
+ durationMs
5658
6022
  );
5659
6023
  mergeObservedColumns(ctx.graph, tableId, span.dbColumns);
5660
6024
  }
@@ -5673,7 +6037,8 @@ async function handleSpan(ctx, span) {
5673
6037
  targetId,
5674
6038
  ts,
5675
6039
  isError,
5676
- callSiteEvidence
6040
+ callSiteEvidence,
6041
+ durationMs
5677
6042
  );
5678
6043
  if (result) affectedNode = targetId;
5679
6044
  } else if (span.graphqlOperationName && span.graphqlOperationType && spanServesGraphqlOperation(span.kind)) {
@@ -5690,7 +6055,8 @@ async function handleSpan(ctx, span) {
5690
6055
  targetId,
5691
6056
  ts,
5692
6057
  isError,
5693
- callSiteEvidence
6058
+ callSiteEvidence,
6059
+ durationMs
5694
6060
  );
5695
6061
  if (result) affectedNode = targetId;
5696
6062
  } else if (span.rpcSystem === "grpc" && span.rpcService && span.rpcMethod && spanServesGrpcMethod(span.kind)) {
@@ -5702,7 +6068,8 @@ async function handleSpan(ctx, span) {
5702
6068
  targetId,
5703
6069
  ts,
5704
6070
  isError,
5705
- callSiteEvidence
6071
+ callSiteEvidence,
6072
+ durationMs
5706
6073
  );
5707
6074
  if (result) affectedNode = targetId;
5708
6075
  } else if (span.websocketChannel && spanServesWebsocketChannel(span.kind)) {
@@ -5718,7 +6085,8 @@ async function handleSpan(ctx, span) {
5718
6085
  targetId,
5719
6086
  ts,
5720
6087
  isError,
5721
- callSiteEvidence
6088
+ callSiteEvidence,
6089
+ durationMs
5722
6090
  );
5723
6091
  if (result) affectedNode = targetId;
5724
6092
  } else {
@@ -5734,7 +6102,8 @@ async function handleSpan(ctx, span) {
5734
6102
  targetId,
5735
6103
  ts,
5736
6104
  isError,
5737
- callSiteEvidence
6105
+ callSiteEvidence,
6106
+ durationMs
5738
6107
  );
5739
6108
  affectedNode = targetId;
5740
6109
  resolvedViaAddress = true;
@@ -5747,7 +6116,8 @@ async function handleSpan(ctx, span) {
5747
6116
  frontierNodeId,
5748
6117
  ts,
5749
6118
  isError,
5750
- callSiteEvidence
6119
+ callSiteEvidence,
6120
+ durationMs
5751
6121
  );
5752
6122
  affectedNode = frontierNodeId;
5753
6123
  resolvedViaAddress = true;
@@ -5773,7 +6143,8 @@ async function handleSpan(ctx, span) {
5773
6143
  sourceId,
5774
6144
  ts,
5775
6145
  isError,
5776
- fallbackEvidence
6146
+ fallbackEvidence,
6147
+ durationMs
5777
6148
  );
5778
6149
  }
5779
6150
  }
@@ -5787,7 +6158,7 @@ async function handleSpan(ctx, span) {
5787
6158
  );
5788
6159
  if (routeNodeId) {
5789
6160
  const routeSvc = ctx.graph.getNodeAttributes(routeNodeId).service;
5790
- upsertObservedEdge(ctx.graph, import_types8.EdgeType.CONTAINS, (0, import_types8.serviceId)(routeSvc), routeNodeId, ts, isError);
6161
+ upsertObservedEdge(ctx.graph, import_types8.EdgeType.CONTAINS, (0, import_types8.serviceId)(routeSvc), routeNodeId, ts, isError, void 0, durationMs);
5791
6162
  }
5792
6163
  }
5793
6164
  if (span.statusCode === 2) {
@@ -18919,6 +19290,34 @@ function registerRoutes(scope, ctx) {
18919
19290
  }
18920
19291
  return getBlastRadius(proj.graph, nodeId, depth);
18921
19292
  });
19293
+ scope.get("/graph/expand/:nodeId", async (req, reply) => {
19294
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
19295
+ if (!proj) return;
19296
+ const { nodeId } = req.params;
19297
+ if (!proj.graph.hasNode(nodeId)) {
19298
+ return reply.code(404).send({ error: "node not found", id: nodeId });
19299
+ }
19300
+ const direction = req.query.direction;
19301
+ if (direction !== "up" && direction !== "down") {
19302
+ return reply.code(400).send({ error: 'direction must be "up" or "down"' });
19303
+ }
19304
+ const epath = errorsPathFor(proj);
19305
+ const incidents = epath ? await readErrorEvents(epath) : [];
19306
+ return expandNode(proj.graph, nodeId, direction, incidents);
19307
+ });
19308
+ scope.get("/graph/relate", async (req, reply) => {
19309
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
19310
+ if (!proj) return;
19311
+ const { a, b } = req.query;
19312
+ if (!a || !b) {
19313
+ return reply.code(400).send({ error: "both a and b query params are required" });
19314
+ }
19315
+ const maxDepth = req.query.maxDepth ? Number(req.query.maxDepth) : void 0;
19316
+ if (maxDepth !== void 0 && (!Number.isFinite(maxDepth) || maxDepth < 1)) {
19317
+ return reply.code(400).send({ error: "maxDepth must be a positive integer" });
19318
+ }
19319
+ return relate(proj.graph, a, b, maxDepth);
19320
+ });
18922
19321
  scope.get("/search", async (req, reply) => {
18923
19322
  const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18924
19323
  if (!proj) return;