@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.
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  resolveHost,
7
7
  resolveNeatVersion,
8
8
  writeDaemonRecord
9
- } from "./chunk-D4OX6MUX.js";
9
+ } from "./chunk-OJ6BW63H.js";
10
10
  import {
11
11
  buildSearchIndex
12
12
  } from "./chunk-BC53SCT7.js";
@@ -74,7 +74,7 @@ import {
74
74
  startStalenessLoop,
75
75
  upsertConnectorEntry,
76
76
  validateConnectorEntry
77
- } from "./chunk-6T7ZHODF.js";
77
+ } from "./chunk-3PXRQH53.js";
78
78
  import {
79
79
  startOtelGrpcReceiver
80
80
  } from "./chunk-FCO5Z3RW.js";
package/dist/index.cjs CHANGED
@@ -1437,7 +1437,7 @@ var rootCauseShapes = {
1437
1437
  [import_types.NodeType.FileNode]: fileRootCauseShape,
1438
1438
  [import_types.NodeType.SymbolNode]: symbolRootCauseShape
1439
1439
  };
1440
- function getRootCause(graph, errorNodeId, errorEvent, incidents) {
1440
+ function legacyRootCause(graph, errorNodeId, errorEvent, incidents) {
1441
1441
  if (!graph.hasNode(errorNodeId)) return null;
1442
1442
  const origin = graph.getNodeAttributes(errorNodeId);
1443
1443
  const shape = rootCauseShapes[origin.type];
@@ -1694,7 +1694,9 @@ function getObservedDependencies(graph, nodeId) {
1694
1694
  dependencies: [],
1695
1695
  observed: false,
1696
1696
  inboundObservedCount: 0,
1697
- hasExtractedOutbound: false
1697
+ hasExtractedOutbound: false,
1698
+ inboundVolume: 0,
1699
+ window: "lifetime"
1698
1700
  });
1699
1701
  }
1700
1702
  const attrs = graph.getNodeAttributes(nodeId);
@@ -1725,11 +1727,19 @@ function getObservedDependencies(graph, nodeId) {
1725
1727
  }
1726
1728
  }
1727
1729
  let inboundObservedCount = 0;
1730
+ let inboundVolume = 0;
1731
+ let inboundLastObserved;
1728
1732
  for (const tgt of scope) {
1729
1733
  for (const edgeId of graph.inboundEdges(tgt)) {
1730
1734
  const e = graph.getEdgeAttributes(edgeId);
1731
1735
  if (e.type === import_types.EdgeType.CONTAINS) continue;
1732
- if (e.provenance === import_types.Provenance.OBSERVED) inboundObservedCount += 1;
1736
+ if (e.provenance === import_types.Provenance.OBSERVED) {
1737
+ inboundObservedCount += 1;
1738
+ inboundVolume += e.callCount ?? e.signal?.spanCount ?? 1;
1739
+ if (e.lastObserved && (!inboundLastObserved || e.lastObserved > inboundLastObserved)) {
1740
+ inboundLastObserved = e.lastObserved;
1741
+ }
1742
+ }
1733
1743
  }
1734
1744
  }
1735
1745
  dependencies.sort(
@@ -1740,7 +1750,291 @@ function getObservedDependencies(graph, nodeId) {
1740
1750
  dependencies,
1741
1751
  observed: dependencies.length > 0 || inboundObservedCount > 0,
1742
1752
  inboundObservedCount,
1743
- hasExtractedOutbound
1753
+ hasExtractedOutbound,
1754
+ // The signal is cumulative, so the honest window label is "lifetime" (ADR-190).
1755
+ inboundVolume,
1756
+ window: "lifetime",
1757
+ ...inboundLastObserved ? { inboundLastObserved } : {}
1758
+ });
1759
+ }
1760
+ var SATURATION_P95_MS = 1e3;
1761
+ function nodeScope(graph, nodeId) {
1762
+ const scope = [nodeId];
1763
+ if (!graph.hasNode(nodeId)) return scope;
1764
+ const attrs = graph.getNodeAttributes(nodeId);
1765
+ if (attrs.type === import_types.NodeType.ServiceNode) {
1766
+ for (const edgeId of graph.outboundEdges(nodeId)) {
1767
+ const e = graph.getEdgeAttributes(edgeId);
1768
+ if (e.type !== import_types.EdgeType.CONTAINS) continue;
1769
+ const owned = graph.getNodeAttributes(e.target);
1770
+ if (owned.type === import_types.NodeType.FileNode) scope.push(e.target);
1771
+ }
1772
+ }
1773
+ return scope;
1774
+ }
1775
+ function incidentCountForNode(nodeId, incidents) {
1776
+ if (!incidents || incidents.length === 0) return 0;
1777
+ return incidents.filter((ev) => incidentMatchesNode(ev, nodeId)).length;
1778
+ }
1779
+ function nodeContext(graph, nodeId, incidents, now = Date.now()) {
1780
+ const scope = nodeScope(graph, nodeId);
1781
+ let errorsFromCallers = 0;
1782
+ let inboundVolume = 0;
1783
+ let outboundVolume = 0;
1784
+ let outboundErrors = 0;
1785
+ let latestInboundMs;
1786
+ let latencyP95Ms;
1787
+ let stale = false;
1788
+ for (const n of scope) {
1789
+ if (!graph.hasNode(n)) continue;
1790
+ for (const edgeId of graph.inboundEdges(n)) {
1791
+ const e = graph.getEdgeAttributes(edgeId);
1792
+ if (e.type === import_types.EdgeType.CONTAINS) continue;
1793
+ errorsFromCallers += e.signal?.errorCount ?? 0;
1794
+ inboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
1795
+ if (e.provenance === import_types.Provenance.STALE) stale = true;
1796
+ const p95 = e.signal?.latencyMs?.p95;
1797
+ if (p95 !== void 0) latencyP95Ms = Math.max(latencyP95Ms ?? 0, p95);
1798
+ if (e.lastObserved) {
1799
+ const t = Date.parse(e.lastObserved);
1800
+ if (Number.isFinite(t)) latestInboundMs = Math.max(latestInboundMs ?? 0, t);
1801
+ }
1802
+ }
1803
+ for (const edgeId of graph.outboundEdges(n)) {
1804
+ const e = graph.getEdgeAttributes(edgeId);
1805
+ if (e.type === import_types.EdgeType.CONTAINS) continue;
1806
+ outboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
1807
+ if (e.type === import_types.EdgeType.CALLS) outboundErrors += e.signal?.errorCount ?? 0;
1808
+ if (e.provenance === import_types.Provenance.STALE) stale = true;
1809
+ }
1810
+ }
1811
+ const errorsEmittedHere = incidentCountForNode(nodeId, incidents) + outboundErrors;
1812
+ const lastObservedAgeMs = latestInboundMs !== void 0 ? Math.max(0, now - latestInboundMs) : void 0;
1813
+ return {
1814
+ errorsEmittedHere,
1815
+ errorsFromCallers,
1816
+ callCount: inboundVolume,
1817
+ outboundVolume,
1818
+ ...lastObservedAgeMs !== void 0 ? { lastObservedAgeMs } : {},
1819
+ ...latencyP95Ms !== void 0 ? { latencyP95Ms } : {},
1820
+ stale
1821
+ };
1822
+ }
1823
+ function isSaturated(ctx) {
1824
+ return ctx.latencyP95Ms !== void 0 && ctx.latencyP95Ms >= SATURATION_P95_MS;
1825
+ }
1826
+ function classifyNode(ctx) {
1827
+ if (ctx.errorsEmittedHere > 0) {
1828
+ if ((ctx.stale || isSaturated(ctx)) && ctx.errorsFromCallers >= ctx.errorsEmittedHere) {
1829
+ return "symptom-only";
1830
+ }
1831
+ return "primary-failure";
1832
+ }
1833
+ if (ctx.errorsFromCallers > 0) return "symptom-only";
1834
+ return "unrelated";
1835
+ }
1836
+ function isVictimSeed(ctx) {
1837
+ return ctx.errorsFromCallers > 0 && (ctx.stale || isSaturated(ctx)) && ctx.errorsEmittedHere <= ctx.errorsFromCallers;
1838
+ }
1839
+ function grainOf(graph, nodeId) {
1840
+ if (!graph.hasNode(nodeId)) return "unknown";
1841
+ const t = graph.getNodeAttributes(nodeId).type;
1842
+ if (t === import_types.NodeType.ServiceNode) return "service";
1843
+ if (t === import_types.NodeType.FileNode) return "file";
1844
+ if (t === import_types.NodeType.SymbolNode) return "symbol";
1845
+ return t;
1846
+ }
1847
+ function findPath(graph, from, to, direction, maxDepth) {
1848
+ if (!graph.hasNode(from) || !graph.hasNode(to)) return null;
1849
+ if (from === to) return { nodes: [from], edges: [] };
1850
+ const queue = [{ nodeId: from, depth: 0, nodes: [from], edges: [] }];
1851
+ const enqueued = /* @__PURE__ */ new Set([from]);
1852
+ while (queue.length > 0) {
1853
+ const frame = queue.shift();
1854
+ if (frame.depth >= maxDepth) continue;
1855
+ const best = direction === "up" ? bestEdgeBySource(graph, graph.inboundEdges(frame.nodeId)) : bestEdgeByTarget(graph, graph.outboundEdges(frame.nodeId));
1856
+ const neighbours = [...best.entries()].sort((a, b) => a[0].localeCompare(b[0]));
1857
+ for (const [nid, edge] of neighbours) {
1858
+ if (nid === to) return { nodes: [...frame.nodes, nid], edges: [...frame.edges, edge] };
1859
+ if (enqueued.has(nid)) continue;
1860
+ enqueued.add(nid);
1861
+ queue.push({
1862
+ nodeId: nid,
1863
+ depth: frame.depth + 1,
1864
+ nodes: [...frame.nodes, nid],
1865
+ edges: [...frame.edges, edge]
1866
+ });
1867
+ }
1868
+ }
1869
+ return null;
1870
+ }
1871
+ var EMPTY_CONTEXT = {
1872
+ errorsEmittedHere: 0,
1873
+ errorsFromCallers: 0,
1874
+ callCount: 0,
1875
+ outboundVolume: 0,
1876
+ stale: false
1877
+ };
1878
+ function expandNode(graph, nodeId, direction, incidents, now = Date.now()) {
1879
+ if (!graph.hasNode(nodeId)) {
1880
+ return import_types.ExpandResultSchema.parse({
1881
+ origin: nodeId,
1882
+ direction,
1883
+ node: { id: nodeId, classification: "unrelated", context: EMPTY_CONTEXT },
1884
+ neighbours: []
1885
+ });
1886
+ }
1887
+ const ctx = nodeContext(graph, nodeId, incidents, now);
1888
+ const best = direction === "up" ? bestEdgeBySource(graph, graph.inboundEdges(nodeId)) : bestEdgeByTarget(graph, graph.outboundEdges(nodeId));
1889
+ const neighbours = [];
1890
+ for (const [nid, edge] of best) {
1891
+ if (edge.type === import_types.EdgeType.CONTAINS) continue;
1892
+ const nctx = nodeContext(graph, nid, incidents, now);
1893
+ neighbours.push({
1894
+ node: nid,
1895
+ edgeType: edge.type,
1896
+ provenance: edge.provenance,
1897
+ classification: classifyNode(nctx),
1898
+ context: nctx
1899
+ });
1900
+ }
1901
+ neighbours.sort((a, b) => a.node.localeCompare(b.node));
1902
+ return import_types.ExpandResultSchema.parse({
1903
+ origin: nodeId,
1904
+ direction,
1905
+ node: { id: nodeId, classification: classifyNode(ctx), context: ctx },
1906
+ neighbours
1907
+ });
1908
+ }
1909
+ function relate(graph, a, b, maxDepth = ROOT_CAUSE_MAX_DEPTH) {
1910
+ const buildPath = (fp) => ({
1911
+ nodes: fp.nodes,
1912
+ edgeTypes: fp.edges.map((e) => e.type),
1913
+ provenance: fp.edges.map((e) => e.provenance),
1914
+ grain: fp.nodes.map((n) => grainOf(graph, n)),
1915
+ // The failure runs end to end when every hop carries error / latency / alert
1916
+ // signal — that is what turns reachability into cause-confirmation.
1917
+ carriesSignal: fp.edges.length > 0 && fp.edges.every(
1918
+ (e) => (e.signal?.errorCount ?? 0) > 0 || e.signal?.latencyMs !== void 0 || e.signal?.anomalous !== void 0
1919
+ )
1920
+ });
1921
+ if (!graph.hasNode(a) || !graph.hasNode(b)) {
1922
+ return import_types.RelateResultSchema.parse({
1923
+ a,
1924
+ b,
1925
+ related: false,
1926
+ direction: null,
1927
+ paths: [],
1928
+ note: !graph.hasNode(a) ? `node not found: ${a}` : `node not found: ${b}`
1929
+ });
1930
+ }
1931
+ const down = findPath(graph, a, b, "down", maxDepth);
1932
+ const up = findPath(graph, a, b, "up", maxDepth);
1933
+ if (!down && !up) {
1934
+ return import_types.RelateResultSchema.parse({
1935
+ a,
1936
+ b,
1937
+ related: false,
1938
+ direction: null,
1939
+ paths: [],
1940
+ note: `no path within ${maxDepth} hops`
1941
+ });
1942
+ }
1943
+ const paths = [];
1944
+ const direction = down ? "a->b" : "b->a";
1945
+ if (down) paths.push(buildPath(down));
1946
+ if (up) paths.push(buildPath(up));
1947
+ const endpointsFine = grainOf(graph, a) !== "service" && grainOf(graph, b) !== "service";
1948
+ const grainGap = endpointsFine && paths[0].grain.some((g) => g === "service");
1949
+ return import_types.RelateResultSchema.parse({
1950
+ a,
1951
+ b,
1952
+ related: true,
1953
+ direction,
1954
+ paths,
1955
+ ...grainGap ? { grainGap: true } : {}
1956
+ });
1957
+ }
1958
+ function findLoadOrigin(graph, alertNodeId, incidents, now) {
1959
+ const upstream = getBlastRadius(graph, alertNodeId).affectedNodes.map((n) => n.nodeId);
1960
+ let best = null;
1961
+ for (const nid of upstream) {
1962
+ if (nid === alertNodeId) continue;
1963
+ const ctx = nodeContext(graph, nid, incidents, now);
1964
+ if (ctx.outboundVolume === 0) continue;
1965
+ const isSource = ctx.callCount === 0;
1966
+ const better = !best || (isSource !== best.isSource ? isSource : ctx.outboundVolume !== best.ctx.outboundVolume ? ctx.outboundVolume > best.ctx.outboundVolume : nid < best.node);
1967
+ if (better) best = { node: nid, ctx, isSource };
1968
+ }
1969
+ return best ? { node: best.node, ctx: best.ctx } : null;
1970
+ }
1971
+ function getRootCause(graph, errorNodeId, errorEvent, incidents, opts) {
1972
+ const legacy = legacyRootCause(graph, errorNodeId, errorEvent, incidents);
1973
+ if (!legacy) return null;
1974
+ const navigation = opts?.navigation ?? process.env.NEAT_RCA_NAVIGATION !== "0";
1975
+ if (!navigation) return legacy;
1976
+ return enrichWithNavigation(graph, errorNodeId, legacy, incidents, opts?.now ?? Date.now());
1977
+ }
1978
+ function enrichWithNavigation(graph, errorNodeId, legacy, incidents, now) {
1979
+ const seedNode = legacy.rootCauseNode;
1980
+ const seedCtx = graph.hasNode(seedNode) ? nodeContext(graph, seedNode, incidents, now) : null;
1981
+ const lastProv = legacy.edgeProvenances[legacy.edgeProvenances.length - 1];
1982
+ const candidates = [];
1983
+ if (seedCtx && isVictimSeed(seedCtx)) {
1984
+ const origin = findLoadOrigin(graph, errorNodeId, incidents, now);
1985
+ if (origin) {
1986
+ const path68 = findPath(graph, errorNodeId, origin.node, "up", ROOT_CAUSE_MAX_DEPTH);
1987
+ const originConfidence = confidenceFromMix(path68?.edges ?? [], now);
1988
+ candidates.push({
1989
+ node: origin.node,
1990
+ classification: "primary-failure",
1991
+ 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.`,
1992
+ context: origin.ctx,
1993
+ confidence: Math.max(0.3, Math.min(0.8, originConfidence || 0.5)),
1994
+ provenance: import_types.Provenance.OBSERVED
1995
+ });
1996
+ }
1997
+ const staleNote = seedCtx.stale ? "; the node has gone STALE" : "";
1998
+ const satNote = isSaturated(seedCtx) ? `; inbound p95 ${Math.round(seedCtx.latencyP95Ms)}ms is saturated` : "";
1999
+ candidates.push({
2000
+ node: seedNode,
2001
+ classification: "symptom-only",
2002
+ reason: `Errors arrive from callers (${seedCtx.errorsFromCallers}) but none originate here${staleNote}${satNote} \u2014 a downstream victim of load, not the fault.`,
2003
+ context: seedCtx,
2004
+ confidence: Math.min(legacy.confidence, 0.4),
2005
+ ...lastProv ? { provenance: lastProv } : {}
2006
+ });
2007
+ } else {
2008
+ candidates.push({
2009
+ node: seedNode,
2010
+ classification: "primary-failure",
2011
+ reason: legacy.rootCauseReason,
2012
+ context: seedCtx ?? EMPTY_CONTEXT,
2013
+ confidence: legacy.confidence,
2014
+ ...lastProv ? { provenance: lastProv } : {}
2015
+ });
2016
+ }
2017
+ const top = candidates[0];
2018
+ let traversalPath = legacy.traversalPath;
2019
+ let edgeProvenances = legacy.edgeProvenances;
2020
+ if (top.node !== seedNode) {
2021
+ const path68 = findPath(graph, errorNodeId, top.node, "up", ROOT_CAUSE_MAX_DEPTH);
2022
+ if (path68) {
2023
+ traversalPath = path68.nodes;
2024
+ edgeProvenances = path68.edges.map((e) => e.provenance);
2025
+ } else {
2026
+ traversalPath = [errorNodeId, top.node];
2027
+ edgeProvenances = [top.provenance ?? import_types.Provenance.OBSERVED];
2028
+ }
2029
+ }
2030
+ return import_types.RootCauseResultSchema.parse({
2031
+ rootCauseNode: top.node,
2032
+ rootCauseReason: top.reason,
2033
+ traversalPath,
2034
+ edgeProvenances,
2035
+ confidence: top.confidence,
2036
+ ...legacy.fixRecommendation ? { fixRecommendation: legacy.fixRecommendation } : {},
2037
+ candidates
1744
2038
  });
1745
2039
  }
1746
2040
 
@@ -4623,6 +4917,63 @@ function columnIsObserved(col) {
4623
4917
  return col.provenances.includes(import_types7.Provenance.OBSERVED);
4624
4918
  }
4625
4919
 
4920
+ // src/latency-digest.ts
4921
+ init_cjs_shims();
4922
+ var SUB = 16;
4923
+ var MIN_EXP = -10;
4924
+ var MAX_EXP = 22;
4925
+ var OCTAVES = MAX_EXP - MIN_EXP + 1;
4926
+ var OVERFLOW_INDEX = OCTAVES * SUB + 1;
4927
+ function latencyBucketIndex(ms) {
4928
+ if (!Number.isFinite(ms) || ms <= 0) return 0;
4929
+ const e = Math.floor(Math.log2(ms));
4930
+ if (e < MIN_EXP) return 0;
4931
+ if (e > MAX_EXP) return OVERFLOW_INDEX;
4932
+ const base = 2 ** e;
4933
+ const raw = Math.floor((ms / base - 1) * SUB);
4934
+ const s = raw < 0 ? 0 : raw >= SUB ? SUB - 1 : raw;
4935
+ return (e - MIN_EXP) * SUB + s + 1;
4936
+ }
4937
+ function bucketRepresentativeMs(index) {
4938
+ if (index <= 0) return 0;
4939
+ if (index >= OVERFLOW_INDEX) return 2 ** (MAX_EXP + 1);
4940
+ const zeroBased = index - 1;
4941
+ const e = MIN_EXP + Math.floor(zeroBased / SUB);
4942
+ const s = zeroBased % SUB;
4943
+ const base = 2 ** e;
4944
+ const lower = base * (1 + s / SUB);
4945
+ const upper = base * (1 + (s + 1) / SUB);
4946
+ return (lower + upper) / 2;
4947
+ }
4948
+ function recordLatency(hist, ms) {
4949
+ const key = String(latencyBucketIndex(ms));
4950
+ hist[key] = (hist[key] ?? 0) + 1;
4951
+ return hist;
4952
+ }
4953
+ function quantile(hist, q) {
4954
+ 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]);
4955
+ let total = 0;
4956
+ for (const [, c] of entries) total += c;
4957
+ if (total === 0) return 0;
4958
+ const rank = Math.max(1, Math.ceil(q * total));
4959
+ let cumulative = 0;
4960
+ for (const [idx, c] of entries) {
4961
+ cumulative += c;
4962
+ if (cumulative >= rank) return bucketRepresentativeMs(idx);
4963
+ }
4964
+ return bucketRepresentativeMs(entries[entries.length - 1][0]);
4965
+ }
4966
+ function round(ms) {
4967
+ return Math.round(ms * 100) / 100;
4968
+ }
4969
+ function latencyPercentiles(hist) {
4970
+ if (!hist) return void 0;
4971
+ let total = 0;
4972
+ for (const c of Object.values(hist)) total += c;
4973
+ if (total === 0) return void 0;
4974
+ return { p50: round(quantile(hist, 0.5)), p95: round(quantile(hist, 0.95)) };
4975
+ }
4976
+
4626
4977
  // src/ingest.ts
4627
4978
  var HOUR_MS = 60 * 60 * 1e3;
4628
4979
  var DAY_MS = 24 * HOUR_MS;
@@ -5318,7 +5669,7 @@ function ensureFrontierNode(graph, host, ts) {
5318
5669
  graph.addNode(id, node);
5319
5670
  return id;
5320
5671
  }
5321
- function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence) {
5672
+ function upsertObservedEdge(graph, type, source, target, ts, isError = false, evidence, durationMs) {
5322
5673
  if (!graph.hasNode(source) || !graph.hasNode(target)) return null;
5323
5674
  const grain = source.startsWith("file:") || source.startsWith("symbol:") ? "file" : "service";
5324
5675
  const id = makeObservedEdgeId(type, source, target);
@@ -5326,10 +5677,15 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false, ev
5326
5677
  const existing = graph.getEdgeAttributes(id);
5327
5678
  const newSpanCount = (existing.signal?.spanCount ?? existing.callCount ?? 0) + 1;
5328
5679
  const newErrorCount = (existing.signal?.errorCount ?? 0) + (isError ? 1 : 0);
5680
+ const latencyHist2 = durationMs !== void 0 ? recordLatency({ ...existing.signal?.latencyHist ?? {} }, durationMs) : existing.signal?.latencyHist;
5681
+ const latencyMs2 = latencyPercentiles(latencyHist2) ?? existing.signal?.latencyMs;
5329
5682
  const newSignal = {
5330
5683
  spanCount: newSpanCount,
5331
5684
  errorCount: newErrorCount,
5332
- lastObservedAgeMs: 0
5685
+ lastObservedAgeMs: 0,
5686
+ ...latencyHist2 ? { latencyHist: latencyHist2 } : {},
5687
+ ...latencyMs2 ? { latencyMs: latencyMs2 } : {},
5688
+ ...existing.signal?.anomalous !== void 0 ? { anomalous: existing.signal.anomalous } : {}
5333
5689
  };
5334
5690
  const updated = {
5335
5691
  ...existing,
@@ -5344,10 +5700,14 @@ function upsertObservedEdge(graph, type, source, target, ts, isError = false, ev
5344
5700
  graph.replaceEdgeAttributes(id, updated);
5345
5701
  return { edge: updated, created: false };
5346
5702
  }
5703
+ const latencyHist = durationMs !== void 0 ? recordLatency({}, durationMs) : void 0;
5704
+ const latencyMs = latencyHist ? latencyPercentiles(latencyHist) : void 0;
5347
5705
  const signal = {
5348
5706
  spanCount: 1,
5349
5707
  errorCount: isError ? 1 : 0,
5350
- lastObservedAgeMs: 0
5708
+ lastObservedAgeMs: 0,
5709
+ ...latencyHist ? { latencyHist } : {},
5710
+ ...latencyMs ? { latencyMs } : {}
5351
5711
  };
5352
5712
  const edge = {
5353
5713
  id,
@@ -5572,6 +5932,7 @@ async function handleSpan(ctx, span) {
5572
5932
  }
5573
5933
  const sourceId = ensureServiceNode(ctx.graph, span.service, env);
5574
5934
  const isError = span.statusCode === 2;
5935
+ const durationMs = span.durationNanos > 0n ? Number(span.durationNanos) / 1e6 : void 0;
5575
5936
  const sourceServiceNode = ctx.graph.getNodeAttributes(sourceId);
5576
5937
  const callSite = callSiteFromSpan(span, sourceServiceNode, ctx.scanPath);
5577
5938
  cacheSpanService(span, nowMs, callSite);
@@ -5610,7 +5971,8 @@ async function handleSpan(ctx, span) {
5610
5971
  targetId,
5611
5972
  ts,
5612
5973
  isError,
5613
- callSiteEvidence
5974
+ callSiteEvidence,
5975
+ durationMs
5614
5976
  );
5615
5977
  if (result) affectedNode = targetId;
5616
5978
  if (span.dbSystem === "mongodb" && span.dbCollection) {
@@ -5622,7 +5984,8 @@ async function handleSpan(ctx, span) {
5622
5984
  collectionId,
5623
5985
  ts,
5624
5986
  isError,
5625
- callSiteEvidence
5987
+ callSiteEvidence,
5988
+ durationMs
5626
5989
  );
5627
5990
  }
5628
5991
  if (span.dbTable) {
@@ -5634,7 +5997,8 @@ async function handleSpan(ctx, span) {
5634
5997
  tableId,
5635
5998
  ts,
5636
5999
  isError,
5637
- callSiteEvidence
6000
+ callSiteEvidence,
6001
+ durationMs
5638
6002
  );
5639
6003
  mergeObservedColumns(ctx.graph, tableId, span.dbColumns);
5640
6004
  }
@@ -5653,7 +6017,8 @@ async function handleSpan(ctx, span) {
5653
6017
  targetId,
5654
6018
  ts,
5655
6019
  isError,
5656
- callSiteEvidence
6020
+ callSiteEvidence,
6021
+ durationMs
5657
6022
  );
5658
6023
  if (result) affectedNode = targetId;
5659
6024
  } else if (span.graphqlOperationName && span.graphqlOperationType && spanServesGraphqlOperation(span.kind)) {
@@ -5670,7 +6035,8 @@ async function handleSpan(ctx, span) {
5670
6035
  targetId,
5671
6036
  ts,
5672
6037
  isError,
5673
- callSiteEvidence
6038
+ callSiteEvidence,
6039
+ durationMs
5674
6040
  );
5675
6041
  if (result) affectedNode = targetId;
5676
6042
  } else if (span.rpcSystem === "grpc" && span.rpcService && span.rpcMethod && spanServesGrpcMethod(span.kind)) {
@@ -5682,7 +6048,8 @@ async function handleSpan(ctx, span) {
5682
6048
  targetId,
5683
6049
  ts,
5684
6050
  isError,
5685
- callSiteEvidence
6051
+ callSiteEvidence,
6052
+ durationMs
5686
6053
  );
5687
6054
  if (result) affectedNode = targetId;
5688
6055
  } else if (span.websocketChannel && spanServesWebsocketChannel(span.kind)) {
@@ -5698,7 +6065,8 @@ async function handleSpan(ctx, span) {
5698
6065
  targetId,
5699
6066
  ts,
5700
6067
  isError,
5701
- callSiteEvidence
6068
+ callSiteEvidence,
6069
+ durationMs
5702
6070
  );
5703
6071
  if (result) affectedNode = targetId;
5704
6072
  } else {
@@ -5714,7 +6082,8 @@ async function handleSpan(ctx, span) {
5714
6082
  targetId,
5715
6083
  ts,
5716
6084
  isError,
5717
- callSiteEvidence
6085
+ callSiteEvidence,
6086
+ durationMs
5718
6087
  );
5719
6088
  affectedNode = targetId;
5720
6089
  resolvedViaAddress = true;
@@ -5727,7 +6096,8 @@ async function handleSpan(ctx, span) {
5727
6096
  frontierNodeId,
5728
6097
  ts,
5729
6098
  isError,
5730
- callSiteEvidence
6099
+ callSiteEvidence,
6100
+ durationMs
5731
6101
  );
5732
6102
  affectedNode = frontierNodeId;
5733
6103
  resolvedViaAddress = true;
@@ -5753,7 +6123,8 @@ async function handleSpan(ctx, span) {
5753
6123
  sourceId,
5754
6124
  ts,
5755
6125
  isError,
5756
- fallbackEvidence
6126
+ fallbackEvidence,
6127
+ durationMs
5757
6128
  );
5758
6129
  }
5759
6130
  }
@@ -5767,7 +6138,7 @@ async function handleSpan(ctx, span) {
5767
6138
  );
5768
6139
  if (routeNodeId) {
5769
6140
  const routeSvc = ctx.graph.getNodeAttributes(routeNodeId).service;
5770
- upsertObservedEdge(ctx.graph, import_types8.EdgeType.CONTAINS, (0, import_types8.serviceId)(routeSvc), routeNodeId, ts, isError);
6141
+ upsertObservedEdge(ctx.graph, import_types8.EdgeType.CONTAINS, (0, import_types8.serviceId)(routeSvc), routeNodeId, ts, isError, void 0, durationMs);
5771
6142
  }
5772
6143
  }
5773
6144
  if (span.statusCode === 2) {
@@ -18513,6 +18884,34 @@ function registerRoutes(scope, ctx) {
18513
18884
  }
18514
18885
  return getBlastRadius(proj.graph, nodeId, depth);
18515
18886
  });
18887
+ scope.get("/graph/expand/:nodeId", async (req, reply) => {
18888
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18889
+ if (!proj) return;
18890
+ const { nodeId } = req.params;
18891
+ if (!proj.graph.hasNode(nodeId)) {
18892
+ return reply.code(404).send({ error: "node not found", id: nodeId });
18893
+ }
18894
+ const direction = req.query.direction;
18895
+ if (direction !== "up" && direction !== "down") {
18896
+ return reply.code(400).send({ error: 'direction must be "up" or "down"' });
18897
+ }
18898
+ const epath = errorsPathFor(proj);
18899
+ const incidents = epath ? await readErrorEvents(epath) : [];
18900
+ return expandNode(proj.graph, nodeId, direction, incidents);
18901
+ });
18902
+ scope.get("/graph/relate", async (req, reply) => {
18903
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18904
+ if (!proj) return;
18905
+ const { a, b } = req.query;
18906
+ if (!a || !b) {
18907
+ return reply.code(400).send({ error: "both a and b query params are required" });
18908
+ }
18909
+ const maxDepth = req.query.maxDepth ? Number(req.query.maxDepth) : void 0;
18910
+ if (maxDepth !== void 0 && (!Number.isFinite(maxDepth) || maxDepth < 1)) {
18911
+ return reply.code(400).send({ error: "maxDepth must be a positive integer" });
18912
+ }
18913
+ return relate(proj.graph, a, b, maxDepth);
18914
+ });
18516
18915
  scope.get("/search", async (req, reply) => {
18517
18916
  const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
18518
18917
  if (!proj) return;