@mtharrison/loupe 1.3.0 → 1.4.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/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  # @mtharrison/loupe
6
6
 
7
+
8
+
7
9
  Loupe is a lightweight local tracing dashboard for LLM applications and agent systems. It captures full request and response payloads with tags and hierarchy context, then serves an inspector UI on `127.0.0.1` with no database, no containers, and no persistence.
8
10
 
9
11
  This package is for local development. Traces live in memory and are cleared on restart.
@@ -402,7 +402,7 @@ pre {
402
402
  .workspace-grid {
403
403
  display: grid;
404
404
  flex: 1;
405
- grid-template-columns: minmax(30rem, 36rem) minmax(0, 1fr);
405
+ grid-template-columns: clamp(34rem, 38vw, 42rem) minmax(0, 1fr);
406
406
  gap: 0.9rem;
407
407
  align-items: stretch;
408
408
  min-height: 0;
@@ -493,7 +493,7 @@ pre {
493
493
  min-height: 0;
494
494
  flex: 1;
495
495
  overflow: auto;
496
- padding-right: 0.12rem;
496
+ padding-right: 0.4rem;
497
497
  scrollbar-gutter: stable;
498
498
  }
499
499
  .session-sidebar-empty {
@@ -1189,7 +1189,12 @@ pre {
1189
1189
  --timeline-indent: 1rem;
1190
1190
  --timeline-gutter-base: 1.25rem;
1191
1191
  --timeline-connector-base: 0.22rem;
1192
+ --timeline-row-padding-x: 0.55rem;
1193
+ --timeline-card-radius: 12px;
1194
+ --timeline-card-inset-y: 0.14rem;
1192
1195
  --timeline-gutter-width: calc( var(--timeline-depth, 0) * var(--timeline-indent) + var(--timeline-gutter-base) );
1196
+ --timeline-card-left: calc( var(--timeline-row-padding-x) + var(--timeline-time-column) + var(--timeline-column-gap) + var(--timeline-gutter-width) );
1197
+ --timeline-card-right: var(--timeline-row-padding-x);
1193
1198
  --timeline-row-color: rgba(92, 121, 171, 0.9);
1194
1199
  --timeline-bar-stroke: color-mix(in srgb, var(--timeline-row-color) 74%, white);
1195
1200
  --timeline-connector-color: color-mix( in srgb, var(--timeline-row-color) 28%, rgba(84, 100, 125, 0.18) );
@@ -1201,7 +1206,7 @@ pre {
1201
1206
  width: 100%;
1202
1207
  border: 0;
1203
1208
  background: transparent;
1204
- padding: 0.12rem 0.55rem;
1209
+ padding: 0.12rem var(--timeline-row-padding-x);
1205
1210
  color: inherit;
1206
1211
  cursor: default;
1207
1212
  font: inherit;
@@ -1213,11 +1218,11 @@ pre {
1213
1218
  .hierarchy-timeline-row::before {
1214
1219
  content: "";
1215
1220
  position: absolute;
1216
- top: 0.14rem;
1217
- right: 0.55rem;
1218
- bottom: 0.14rem;
1219
- left: calc(0.55rem + var(--timeline-time-column) + var(--timeline-column-gap) + var(--timeline-gutter-width));
1220
- border-radius: 12px;
1221
+ top: var(--timeline-card-inset-y);
1222
+ right: var(--timeline-card-right);
1223
+ bottom: var(--timeline-card-inset-y);
1224
+ left: var(--timeline-card-left);
1225
+ border-radius: var(--timeline-card-radius);
1221
1226
  border: 1px solid transparent;
1222
1227
  background: rgba(255, 255, 255, 0.42);
1223
1228
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 4px 12px rgba(32, 50, 76, 0.03);
@@ -1248,8 +1253,18 @@ pre {
1248
1253
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 4px 12px rgba(32, 50, 76, 0.02);
1249
1254
  }
1250
1255
  .hierarchy-timeline-row:focus-visible {
1251
- outline: 2px solid rgba(40, 93, 168, 0.22);
1252
- outline-offset: 2px;
1256
+ outline: none;
1257
+ }
1258
+ .hierarchy-timeline-row:focus-visible::after {
1259
+ content: "";
1260
+ position: absolute;
1261
+ top: calc(var(--timeline-card-inset-y) - 0.08rem);
1262
+ right: calc(var(--timeline-card-right) - 0.08rem);
1263
+ bottom: calc(var(--timeline-card-inset-y) - 0.08rem);
1264
+ left: calc(var(--timeline-card-left) - 0.08rem);
1265
+ border-radius: calc(var(--timeline-card-radius) + 2px);
1266
+ box-shadow: 0 0 0 2px rgba(40, 93, 168, 0.18);
1267
+ pointer-events: none;
1253
1268
  }
1254
1269
  .hierarchy-timeline-row.is-in-path::before {
1255
1270
  background: rgba(240, 246, 255, 0.54);
@@ -1900,27 +1915,67 @@ pre {
1900
1915
  flex-wrap: wrap;
1901
1916
  }
1902
1917
  .session-tree-timeline-shell {
1903
- padding: 0 0.1rem 0.2rem 2.35rem;
1918
+ padding: 0.08rem 0.1rem 0.24rem 0.5rem;
1904
1919
  }
1905
1920
  .session-tree-timeline-list {
1906
1921
  display: flex;
1907
1922
  flex-direction: column;
1908
- gap: 0.45rem;
1923
+ gap: 0;
1909
1924
  }
1910
1925
  .session-tree-timeline-list .hierarchy-timeline-row {
1911
- grid-template-columns: 4rem minmax(0, 1fr) minmax(6rem, 7.25rem);
1912
- gap: 0.4rem;
1926
+ --timeline-time-column: 4.8rem;
1927
+ --timeline-column-gap: 0.4rem;
1928
+ --timeline-indent: 0.72rem;
1929
+ --timeline-gutter-base: 0.75rem;
1930
+ --embedded-bars-space: clamp(10.75rem, 49%, 15.6rem);
1931
+ --timeline-row-padding-x: 0.18rem;
1932
+ --timeline-card-radius: 18px;
1933
+ --timeline-card-inset-y: 0.24rem;
1934
+ --timeline-card-left: calc( var(--timeline-row-padding-x) + var(--timeline-time-column) + var(--timeline-column-gap) + var(--timeline-gutter-width) - 0.12rem );
1935
+ --timeline-card-right: 0.1rem;
1936
+ display: flex;
1937
+ gap: var(--timeline-column-gap);
1938
+ align-items: stretch;
1939
+ max-width: 100%;
1940
+ overflow: visible;
1941
+ }
1942
+ .session-tree-timeline-list .hierarchy-timeline-row-time {
1943
+ flex: 0 0 var(--timeline-time-column);
1944
+ justify-content: flex-start;
1945
+ text-align: left;
1946
+ padding-right: 0;
1947
+ padding-left: 0.08rem;
1948
+ overflow: visible;
1949
+ font-size: 0.74rem;
1950
+ }
1951
+ .session-tree-timeline-list .hierarchy-timeline-row-branch {
1952
+ flex: 1 1 0;
1953
+ min-width: 0;
1913
1954
  }
1914
1955
  .session-tree-timeline-list .hierarchy-timeline-row-labels {
1915
- padding: 0.72rem 0.5rem 0.72rem 1.05rem;
1956
+ min-width: 0;
1957
+ gap: 0.24rem;
1958
+ padding: 0.84rem calc(var(--embedded-bars-space) + 0.8rem) 0.84rem 0.92rem;
1916
1959
  }
1917
1960
  .session-tree-timeline-list .hierarchy-timeline-row-bars {
1918
- grid-template-columns: minmax(4.8rem, 1fr) auto;
1919
- column-gap: 0.35rem;
1920
- padding: 0.72rem 0.8rem 0.72rem 0.2rem;
1961
+ position: absolute;
1962
+ top: 0.84rem;
1963
+ right: 0.92rem;
1964
+ width: var(--embedded-bars-space);
1965
+ min-width: 0;
1966
+ display: flex;
1967
+ align-items: center;
1968
+ justify-content: flex-end;
1969
+ gap: 0.36rem;
1970
+ padding: 0;
1971
+ overflow: hidden;
1972
+ z-index: 2;
1921
1973
  }
1922
1974
  .session-tree-timeline-list .hierarchy-timeline-row-title {
1923
- flex-wrap: nowrap;
1975
+ position: relative;
1976
+ display: block;
1977
+ min-height: 0;
1978
+ padding-top: 2.05rem;
1924
1979
  }
1925
1980
  .session-tree-timeline-list .hierarchy-timeline-row-title-text,
1926
1981
  .session-tree-timeline-list .hierarchy-timeline-row-meta {
@@ -1929,13 +1984,53 @@ pre {
1929
1984
  white-space: nowrap;
1930
1985
  }
1931
1986
  .session-tree-timeline-list .hierarchy-timeline-row-title-text {
1932
- font-size: 0.84rem;
1987
+ display: block;
1988
+ font-size: 0.86rem;
1989
+ }
1990
+ .session-tree-timeline-list .hierarchy-timeline-pill {
1991
+ position: absolute;
1992
+ top: 0;
1993
+ left: 0;
1994
+ }
1995
+ .session-tree-timeline-list .hierarchy-timeline-row-flag {
1996
+ margin-top: 0.35rem;
1997
+ margin-right: 0.35rem;
1933
1998
  }
1934
1999
  .session-tree-timeline-list .hierarchy-timeline-row-meta,
1935
2000
  .session-tree-timeline-list .hierarchy-timeline-row-time,
1936
2001
  .session-tree-timeline-list .hierarchy-timeline-row-duration {
1937
2002
  font-size: 0.72rem;
1938
2003
  }
2004
+ .session-tree-timeline-list .hierarchy-timeline-row-track {
2005
+ flex: 1 1 auto;
2006
+ min-width: 0;
2007
+ height: 1.3rem;
2008
+ }
2009
+ .session-tree-timeline-list .hierarchy-timeline-row-track::before {
2010
+ height: 0.4rem;
2011
+ }
2012
+ .session-tree-timeline-list .hierarchy-timeline-row-bar {
2013
+ width: max(0.65rem, calc(var(--timeline-span, 0.1) * 100%));
2014
+ height: 0.65rem;
2015
+ }
2016
+ .session-tree-timeline-list .hierarchy-timeline-row-bar::before,
2017
+ .session-tree-timeline-list .hierarchy-timeline-row-bar::after {
2018
+ height: 0.9rem;
2019
+ }
2020
+ .session-tree-timeline-list .hierarchy-timeline-row-duration {
2021
+ flex: 0 0 auto;
2022
+ }
2023
+ .session-tree-timeline-list .hierarchy-timeline-row.is-active::before {
2024
+ border-color: rgba(40, 93, 168, 0.16);
2025
+ background: rgba(236, 244, 255, 0.9);
2026
+ box-shadow: inset 2px 0 0 rgba(40, 93, 168, 0.54), 0 8px 20px rgba(32, 50, 76, 0.06);
2027
+ }
2028
+ .session-tree-timeline-list .hierarchy-timeline-row.is-detail-trace:not(.is-active)::before {
2029
+ box-shadow: inset 2px 0 0 rgba(40, 93, 168, 0.42), 0 6px 18px rgba(32, 50, 76, 0.05);
2030
+ }
2031
+ .session-tree-timeline-list .hierarchy-timeline-row:focus-visible::after {
2032
+ box-shadow: 0 0 0 2px rgba(40, 93, 168, 0.14);
2033
+ }
1939
2034
  .session-tree-timeline {
1940
2035
  display: inline-flex;
1941
2036
  min-width: 0;
@@ -21048,7 +21048,7 @@ function resolveSessionTreeSelection(sessionNodes, selectedNodeId, selectedTrace
21048
21048
  selectedTraceId: nextSelectedTraceId
21049
21049
  };
21050
21050
  }
21051
- function getDefaultExpandedSessionTreeNodeIds(sessionNodes, activeSessionId, selectedNodeId) {
21051
+ function getDefaultExpandedSessionTreeNodeIds(sessionNodes, activeSessionId, selectedNodeId, selectedTraceId = null) {
21052
21052
  const expanded = /* @__PURE__ */ new Set();
21053
21053
  const activeSession = (activeSessionId ? sessionNodes.find((node) => node.id === activeSessionId) ?? null : null) ?? sessionNodes[0] ?? null;
21054
21054
  if (!activeSession) {
@@ -21069,6 +21069,16 @@ function getDefaultExpandedSessionTreeNodeIds(sessionNodes, activeSessionId, sel
21069
21069
  }
21070
21070
  }
21071
21071
  }
21072
+ if (selectedTraceId) {
21073
+ for (const node of findSessionNodePath(
21074
+ [activeSession],
21075
+ `trace:${selectedTraceId}`
21076
+ )) {
21077
+ if (node.children.length) {
21078
+ expanded.add(node.id);
21079
+ }
21080
+ }
21081
+ }
21072
21082
  return expanded;
21073
21083
  }
21074
21084
  function deriveSessionNavItem(node, traceById) {
@@ -21459,7 +21469,7 @@ function App() {
21459
21469
  () => deriveSessionNavItems(sessionNodes, traceById),
21460
21470
  [sessionNodes, traceById]
21461
21471
  );
21462
- const sessionNavById = (0, import_react3.useMemo)(
21472
+ const sessionNavById2 = (0, import_react3.useMemo)(
21463
21473
  () => new Map(sessionNavItems.map((item) => [item.id, item])),
21464
21474
  [sessionNavItems]
21465
21475
  );
@@ -21478,8 +21488,10 @@ function App() {
21478
21488
  [sessionNodes, selectedTraceId]
21479
21489
  );
21480
21490
  const selectedPathIds = (0, import_react3.useMemo)(
21481
- () => new Set(selectedNodePath.map((node) => node.id)),
21482
- [selectedNodePath]
21491
+ () => new Set(
21492
+ [...selectedNodePath, ...selectedTracePath].map((node) => node.id)
21493
+ ),
21494
+ [selectedNodePath, selectedTracePath]
21483
21495
  );
21484
21496
  const selectedSessionNode = (0, import_react3.useMemo)(
21485
21497
  () => (selectedNodePath[0]?.type === "session" ? selectedNodePath[0] : null) ?? (selectedTracePath[0]?.type === "session" ? selectedTracePath[0] : null) ?? sessionNodes[0] ?? null,
@@ -21532,9 +21544,10 @@ function App() {
21532
21544
  () => getDefaultExpandedSessionTreeNodeIds(
21533
21545
  sessionNodes,
21534
21546
  selectedSessionNode?.id ?? null,
21535
- selectedNodeId
21547
+ selectedNodeId,
21548
+ selectedTraceId
21536
21549
  ),
21537
- [selectedNodeId, selectedSessionNode, sessionNodes]
21550
+ [selectedNodeId, selectedSessionNode, selectedTraceId, sessionNodes]
21538
21551
  );
21539
21552
  const activeTabJsonMode = tabModes[detailTab] ?? "formatted";
21540
21553
  const activeTagFilterCount = countTagFilters(filters.tags);
@@ -21627,7 +21640,7 @@ function App() {
21627
21640
  if (!node) {
21628
21641
  return;
21629
21642
  }
21630
- const nextTraceId = selectedTraceId && node.traceIds.includes(selectedTraceId) ? selectedTraceId : getNewestTraceIdForNode(node);
21643
+ const nextTraceId = node.type === "trace" ? node.meta.traceId ?? node.traceIds[0] ?? null : selectedTraceId && node.traceIds.includes(selectedTraceId) ? selectedTraceId : getNewestTraceIdForNode(node);
21631
21644
  (0, import_react3.startTransition)(() => {
21632
21645
  setSelectedNodeId(nodeId);
21633
21646
  if (nextTraceId !== selectedTraceId) {
@@ -21779,7 +21792,7 @@ function App() {
21779
21792
  selectedNodeId,
21780
21793
  selectedPathIds,
21781
21794
  selectedTraceId,
21782
- sessionNavById,
21795
+ sessionNavById: sessionNavById2,
21783
21796
  totalCount: allSessionCount,
21784
21797
  traceById
21785
21798
  }
@@ -21809,6 +21822,7 @@ function App() {
21809
21822
  {
21810
21823
  activeTab,
21811
21824
  detail,
21825
+ detailPath: selectedTracePath.length ? selectedTracePath : selectedNodePath,
21812
21826
  detailTabs,
21813
21827
  fallbackTrace: selectedTraceSummary,
21814
21828
  jsonMode: activeTabJsonMode,
@@ -21821,7 +21835,8 @@ function App() {
21821
21835
  ...current,
21822
21836
  [tabId]: (current[tabId] ?? "formatted") === "formatted" ? "raw" : "formatted"
21823
21837
  }));
21824
- })
21838
+ }),
21839
+ traceById
21825
21840
  }
21826
21841
  ) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CardContent, { className: "content-scroll", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
21827
21842
  EmptyState,
@@ -21892,7 +21907,7 @@ function SessionTreeNavigator({
21892
21907
  selectedNodeId,
21893
21908
  selectedPathIds,
21894
21909
  selectedTraceId,
21895
- sessionNavById,
21910
+ sessionNavById: sessionNavById2,
21896
21911
  totalCount,
21897
21912
  traceById
21898
21913
  }) {
@@ -21921,7 +21936,7 @@ function SessionTreeNavigator({
21921
21936
  selectedNodeId,
21922
21937
  selectedPathIds,
21923
21938
  selectedTraceId,
21924
- sessionNavById,
21939
+ sessionNavById: sessionNavById2,
21925
21940
  traceById
21926
21941
  }
21927
21942
  ) })
@@ -21956,7 +21971,7 @@ function HierarchyTree({
21956
21971
  selectedNodeId,
21957
21972
  selectedPathIds,
21958
21973
  selectedTraceId,
21959
- sessionNavById,
21974
+ sessionNavById: sessionNavById2,
21960
21975
  traceById
21961
21976
  }) {
21962
21977
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "tree-root", children: nodes.map((node) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
@@ -21972,7 +21987,7 @@ function HierarchyTree({
21972
21987
  selectedNodeId,
21973
21988
  selectedPathIds,
21974
21989
  selectedTraceId,
21975
- sessionNavById,
21990
+ sessionNavById: sessionNavById2,
21976
21991
  traceById
21977
21992
  },
21978
21993
  node.id
@@ -21989,7 +22004,7 @@ function HierarchyTreeNode({
21989
22004
  selectedNodeId,
21990
22005
  selectedPathIds,
21991
22006
  selectedTraceId,
21992
- sessionNavById,
22007
+ sessionNavById: sessionNavById2,
21993
22008
  traceById
21994
22009
  }) {
21995
22010
  const isExpandable = node.children.length > 0;
@@ -21999,20 +22014,28 @@ function HierarchyTreeNode({
21999
22014
  const isInPath = selectedPathIds.has(node.id);
22000
22015
  const nodeCopy = getHierarchyNodeCopy(node, traceById);
22001
22016
  const trace = node.meta.traceId ? traceById.get(node.meta.traceId) ?? null : null;
22002
- const sessionNavItem = node.type === "session" ? sessionNavById.get(node.id) ?? null : null;
22017
+ const sessionNavItem = node.type === "session" ? sessionNavById2.get(node.id) ?? null : null;
22003
22018
  if (node.type === "trace" && trace) {
22004
22019
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
22005
- TraceHierarchyLeaf,
22020
+ TraceHierarchyNode,
22006
22021
  {
22022
+ defaultExpandedNodeIds,
22007
22023
  depth,
22024
+ expandedNodeOverrides,
22008
22025
  maxDurationMs,
22009
22026
  node,
22010
22027
  nodeCopy,
22011
22028
  onSelect,
22029
+ onToggle,
22012
22030
  inPath: isInPath,
22031
+ isExpanded,
22013
22032
  selected: selectedNodeId === node.id,
22033
+ selectedNodeId,
22034
+ selectedPathIds,
22014
22035
  selectedTrace: selectedTraceId === trace.id,
22015
- trace
22036
+ selectedTraceId,
22037
+ trace,
22038
+ traceById
22016
22039
  }
22017
22040
  );
22018
22041
  }
@@ -22033,7 +22056,7 @@ function HierarchyTreeNode({
22033
22056
  selectedNodeId,
22034
22057
  selectedPathIds,
22035
22058
  selectedTraceId,
22036
- sessionNavById,
22059
+ sessionNavById: sessionNavById2,
22037
22060
  traceById
22038
22061
  }
22039
22062
  );
@@ -22108,7 +22131,7 @@ function HierarchyTreeNode({
22108
22131
  selectedNodeId,
22109
22132
  selectedPathIds,
22110
22133
  selectedTraceId,
22111
- sessionNavById,
22134
+ sessionNavById: sessionNavById2,
22112
22135
  traceById
22113
22136
  },
22114
22137
  child.id
@@ -22131,7 +22154,7 @@ function SessionHierarchyBranch({
22131
22154
  selectedNodeId,
22132
22155
  selectedPathIds,
22133
22156
  selectedTraceId,
22134
- sessionNavById,
22157
+ sessionNavById: sessionNavById2,
22135
22158
  traceById
22136
22159
  }) {
22137
22160
  const detailLabel = formatList([
@@ -22249,7 +22272,7 @@ function SessionHierarchyBranch({
22249
22272
  selectedNodeId,
22250
22273
  selectedPathIds,
22251
22274
  selectedTraceId,
22252
- sessionNavById,
22275
+ sessionNavById: sessionNavById2,
22253
22276
  traceById
22254
22277
  },
22255
22278
  child.id
@@ -22258,59 +22281,105 @@ function SessionHierarchyBranch({
22258
22281
  }
22259
22282
  );
22260
22283
  }
22261
- function TraceHierarchyLeaf({
22284
+ function TraceHierarchyNode({
22285
+ defaultExpandedNodeIds,
22262
22286
  depth,
22287
+ expandedNodeOverrides,
22263
22288
  inPath,
22289
+ isExpanded,
22264
22290
  maxDurationMs,
22265
22291
  node,
22266
22292
  nodeCopy,
22267
22293
  onSelect,
22294
+ onToggle,
22268
22295
  selected,
22296
+ selectedNodeId,
22297
+ selectedPathIds,
22269
22298
  selectedTrace,
22270
- trace
22299
+ selectedTraceId,
22300
+ trace,
22301
+ traceById
22271
22302
  }) {
22272
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
22303
+ const isExpandable = node.children.length > 0;
22304
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
22273
22305
  "div",
22274
22306
  {
22275
22307
  className: "tree-node-wrap tree-trace-wrap",
22276
22308
  style: { "--depth": String(depth) },
22277
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
22278
- "div",
22279
- {
22280
- className: clsx_default(
22281
- "tree-node-card is-trace",
22282
- inPath && "is-in-path",
22283
- selected && "is-active",
22284
- selectedTrace && "is-detail-trace"
22285
- ),
22286
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
22287
- "button",
22288
- {
22289
- type: "button",
22290
- className: "tree-node-select tree-trace-select",
22291
- onClick: () => onSelect(node),
22292
- children: [
22293
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "tree-node-copy", children: [
22294
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "trace-nav-kicker", children: getTraceActorLabel(trace) }),
22295
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "tree-node-label", children: nodeCopy.label }),
22296
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "tree-node-meta", children: formatList([
22297
- formatTimelineTimestamp(trace.startedAt),
22298
- nodeCopy.meta
22299
- ]) })
22300
- ] }),
22301
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
22302
- TraceElapsedBar,
22303
- {
22304
- compact: true,
22305
- durationMs: trace.durationMs,
22306
- maxDurationMs
22307
- }
22308
- )
22309
- ]
22310
- }
22311
- )
22312
- }
22313
- )
22309
+ children: [
22310
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
22311
+ "div",
22312
+ {
22313
+ className: clsx_default(
22314
+ "tree-node-card is-trace",
22315
+ inPath && "is-in-path",
22316
+ selected && "is-active",
22317
+ selectedTrace && "is-detail-trace"
22318
+ ),
22319
+ children: [
22320
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
22321
+ "button",
22322
+ {
22323
+ type: "button",
22324
+ className: clsx_default("tree-node-toggle", !isExpandable && "is-static"),
22325
+ disabled: !isExpandable,
22326
+ onClick: () => {
22327
+ if (isExpandable) {
22328
+ onToggle(node.id);
22329
+ }
22330
+ },
22331
+ "aria-label": isExpandable ? `${isExpanded ? "Collapse" : "Expand"} ${nodeCopy.label}` : void 0,
22332
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChevronRight, { className: clsx_default(isExpanded && "is-open") })
22333
+ }
22334
+ ),
22335
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
22336
+ "button",
22337
+ {
22338
+ type: "button",
22339
+ className: "tree-node-select tree-trace-select",
22340
+ onClick: () => onSelect(node),
22341
+ children: [
22342
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "tree-node-copy", children: [
22343
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "trace-nav-kicker", children: getTraceActorLabel(trace) }),
22344
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "tree-node-label", children: nodeCopy.label }),
22345
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "tree-node-meta", children: formatList([
22346
+ formatTimelineTimestamp(trace.startedAt),
22347
+ nodeCopy.meta
22348
+ ]) })
22349
+ ] }),
22350
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
22351
+ TraceElapsedBar,
22352
+ {
22353
+ compact: true,
22354
+ durationMs: trace.durationMs,
22355
+ maxDurationMs
22356
+ }
22357
+ )
22358
+ ]
22359
+ }
22360
+ )
22361
+ ]
22362
+ }
22363
+ ),
22364
+ isExpanded ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "tree-node-children", children: node.children.map((child) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
22365
+ HierarchyTreeNode,
22366
+ {
22367
+ defaultExpandedNodeIds,
22368
+ depth: depth + 1,
22369
+ expandedNodeOverrides,
22370
+ maxDurationMs,
22371
+ node: child,
22372
+ onSelect,
22373
+ onToggle,
22374
+ selectedNodeId,
22375
+ selectedPathIds,
22376
+ selectedTraceId,
22377
+ sessionNavById,
22378
+ traceById
22379
+ },
22380
+ child.id
22381
+ )) }) : null
22382
+ ]
22314
22383
  }
22315
22384
  );
22316
22385
  }
@@ -22525,6 +22594,7 @@ function HierarchyTimelineRows({
22525
22594
  function TraceDetailPanel({
22526
22595
  activeTab,
22527
22596
  detail,
22597
+ detailPath,
22528
22598
  detailTabs,
22529
22599
  fallbackTrace,
22530
22600
  jsonMode,
@@ -22533,11 +22603,12 @@ function TraceDetailPanel({
22533
22603
  onBack,
22534
22604
  onNavigateHierarchyNode,
22535
22605
  onTabChange,
22536
- onToggleJsonMode
22606
+ onToggleJsonMode,
22607
+ traceById
22537
22608
  }) {
22538
22609
  const traceDetailPrimaryRef = (0, import_react3.useRef)(null);
22539
22610
  const [showInlineContextRail, setShowInlineContextRail] = (0, import_react3.useState)(false);
22540
- const detailCopy = detail ? getTraceDisplayCopy(detail) : fallbackTrace ? getTraceDisplayCopy(fallbackTrace) : null;
22611
+ const detailCopy = detailPath.length ? getHierarchyPathDisplayCopy(detailPath, traceById) : detail ? getTraceDisplayCopy(detail) : fallbackTrace ? getTraceDisplayCopy(fallbackTrace) : null;
22541
22612
  const detailStatus = detail?.status ?? fallbackTrace?.status ?? null;
22542
22613
  const detailDuration = detail ? formatTraceDuration(detail) : fallbackTrace ? fallbackTrace.durationMs == null ? "Running" : `${fallbackTrace.durationMs} ms` : null;
22543
22614
  const detailSubtitle = formatTraceProviderSummary(detail ?? fallbackTrace);
@@ -22848,6 +22919,24 @@ function renderTabContent(tab, detail, jsonMode, onApplyTagFilter, onApplyTraceF
22848
22919
  }
22849
22920
  ) : null
22850
22921
  ] });
22922
+ case "otel":
22923
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
22924
+ JsonCard,
22925
+ {
22926
+ title: "OTel span",
22927
+ value: {
22928
+ name: detail.name,
22929
+ spanContext: detail.spanContext,
22930
+ parentSpanId: detail.parentSpanId,
22931
+ spanKind: detail.spanKind,
22932
+ spanStatus: detail.spanStatus,
22933
+ startTime: detail.startedAt,
22934
+ endTime: detail.endedAt,
22935
+ attributes: detail.attributes,
22936
+ events: detail.events
22937
+ }
22938
+ }
22939
+ );
22851
22940
  default:
22852
22941
  return null;
22853
22942
  }
@@ -23766,6 +23855,7 @@ function buildDetailTabs(detail) {
23766
23855
  if (detail.stream) {
23767
23856
  tabs.push({ id: "stream", label: "Stream" });
23768
23857
  }
23858
+ tabs.push({ id: "otel", label: "OTel" });
23769
23859
  return tabs;
23770
23860
  }
23771
23861
  function getHierarchyNodeCopy(node, traceById) {
@@ -23824,8 +23914,8 @@ function getHierarchyNodeCopy(node, traceById) {
23824
23914
  trace.provider,
23825
23915
  trace.model,
23826
23916
  formatCostSummaryLabel(
23827
- trace.costUsd ?? getHierarchyNodeCostUsd(node),
23828
- false
23917
+ getHierarchyNodeCostUsd(node) ?? trace.costUsd,
23918
+ node.count > 1
23829
23919
  )
23830
23920
  ]) : formatList([
23831
23921
  formatCountLabel(node.count, "call"),
@@ -23848,55 +23938,24 @@ function getHierarchyNodeCopy(node, traceById) {
23848
23938
  }
23849
23939
  }
23850
23940
  function getTraceDisplayCopy(trace) {
23851
- const actor = trace.hierarchy.childActorId || trace.hierarchy.rootActorId;
23852
- const breadcrumbs = getTraceBreadcrumbs(trace);
23853
- const pathParts = breadcrumbs.map((item) => item.label);
23854
23941
  return {
23855
- breadcrumbs,
23856
- path: pathParts.join(" / "),
23857
- subtitle: formatList([actor, trace.provider, trace.model]),
23942
+ breadcrumbs: [],
23943
+ path: "",
23858
23944
  title: getTraceTitle(trace)
23859
23945
  };
23860
23946
  }
23861
- function getTraceBreadcrumbs(trace) {
23862
- const sessionId = trace.hierarchy.sessionId || "unknown-session";
23863
- const rootActorId = trace.hierarchy.rootActorId || "unknown-actor";
23864
- const breadcrumbs = [
23865
- {
23866
- label: `Session ${shortId2(sessionId)}`,
23867
- nodeId: `session:${sessionId}`
23868
- },
23869
- {
23870
- label: rootActorId,
23871
- nodeId: `actor:${sessionId}:${rootActorId}`
23872
- }
23873
- ];
23874
- if (trace.kind === "guardrail") {
23875
- const label = `${capitalize(trace.hierarchy.guardrailPhase || "guardrail")} guardrail`;
23876
- breadcrumbs.push({
23877
- label,
23878
- nodeId: `guardrail:${sessionId}:${rootActorId}:${trace.hierarchy.guardrailType || label.toLowerCase()}`
23879
- });
23880
- } else if (trace.kind === "child-actor" && trace.hierarchy.childActorId) {
23881
- breadcrumbs.push({
23882
- label: `Child actor: ${trace.hierarchy.childActorId}`,
23883
- nodeId: `child-actor:${sessionId}:${rootActorId}:${trace.hierarchy.childActorId}`
23884
- });
23885
- } else if (trace.kind === "stage") {
23886
- if (trace.hierarchy.childActorId) {
23887
- breadcrumbs.push({
23888
- label: `Child actor: ${trace.hierarchy.childActorId}`,
23889
- nodeId: `child-actor:${sessionId}:${rootActorId}:${trace.hierarchy.childActorId}`
23890
- });
23891
- }
23892
- if (trace.hierarchy.stage) {
23893
- breadcrumbs.push({
23894
- label: `Stage: ${trace.hierarchy.stage}`,
23895
- nodeId: `stage:${sessionId}:${rootActorId}:${trace.hierarchy.childActorId || "root"}:${trace.hierarchy.stage}`
23896
- });
23897
- }
23898
- }
23899
- return breadcrumbs;
23947
+ function getHierarchyPathDisplayCopy(path, traceById) {
23948
+ const breadcrumbs = path.map((node) => ({
23949
+ label: getHierarchyNodeCopy(node, traceById).label,
23950
+ nodeId: node.id
23951
+ }));
23952
+ const lastNode = path[path.length - 1] ?? null;
23953
+ const lastTrace = lastNode?.type === "trace" && lastNode.meta.traceId ? traceById.get(lastNode.meta.traceId) ?? null : null;
23954
+ return {
23955
+ breadcrumbs,
23956
+ path: breadcrumbs.map((item) => item.label).join(" / "),
23957
+ title: lastTrace ? getTraceTitle(lastTrace) : breadcrumbs.at(-1)?.label || "Trace"
23958
+ };
23900
23959
  }
23901
23960
  function formatTraceProviderSummary(trace) {
23902
23961
  if (!trace) {
@@ -24047,12 +24106,12 @@ function patchHierarchyNode(node, traceId, previousSummary, nextSummary, costDel
24047
24106
  let nextLabel = node.label;
24048
24107
  if (containsTrace) {
24049
24108
  if (node.type === "trace" && node.meta.traceId === traceId) {
24050
- nextMeta.costUsd = nextSummary.costUsd;
24051
24109
  nextMeta.model = nextSummary.model;
24052
24110
  nextMeta.provider = nextSummary.provider;
24053
24111
  nextMeta.status = nextSummary.status;
24054
24112
  nextLabel = nextSummary.model ? `${nextSummary.model} ${nextSummary.mode}` : traceId;
24055
- } else if (costDelta !== 0 || previousSummary?.costUsd !== null || nextSummary.costUsd !== null) {
24113
+ }
24114
+ if (costDelta !== 0 || previousSummary?.costUsd !== null || nextSummary.costUsd !== null) {
24056
24115
  const currentCost = typeof nextMeta.costUsd === "number" && Number.isFinite(nextMeta.costUsd) ? nextMeta.costUsd : 0;
24057
24116
  nextMeta.costUsd = roundCostUsd2(currentCost + costDelta);
24058
24117
  }
@@ -41,4 +41,4 @@ export declare function findSessionNodePath(nodes: SessionNavHierarchyNode[], id
41
41
  export declare function findSessionNodeById(nodes: SessionNavHierarchyNode[], id: string): SessionNavHierarchyNode | null;
42
42
  export declare function getNewestTraceIdForNode(node: SessionNavHierarchyNode | null | undefined): string | null;
43
43
  export declare function resolveSessionTreeSelection(sessionNodes: SessionNavHierarchyNode[], selectedNodeId: string | null, selectedTraceId: string | null): SessionTreeSelection;
44
- export declare function getDefaultExpandedSessionTreeNodeIds(sessionNodes: SessionNavHierarchyNode[], activeSessionId: string | null, selectedNodeId: string | null): Set<string>;
44
+ export declare function getDefaultExpandedSessionTreeNodeIds(sessionNodes: SessionNavHierarchyNode[], activeSessionId: string | null, selectedNodeId: string | null, selectedTraceId?: string | null): Set<string>;
@@ -66,7 +66,7 @@ function resolveSessionTreeSelection(sessionNodes, selectedNodeId, selectedTrace
66
66
  selectedTraceId: nextSelectedTraceId,
67
67
  };
68
68
  }
69
- function getDefaultExpandedSessionTreeNodeIds(sessionNodes, activeSessionId, selectedNodeId) {
69
+ function getDefaultExpandedSessionTreeNodeIds(sessionNodes, activeSessionId, selectedNodeId, selectedTraceId = null) {
70
70
  const expanded = new Set();
71
71
  const activeSession = (activeSessionId
72
72
  ? sessionNodes.find((node) => node.id === activeSessionId) ?? null
@@ -89,6 +89,13 @@ function getDefaultExpandedSessionTreeNodeIds(sessionNodes, activeSessionId, sel
89
89
  }
90
90
  }
91
91
  }
92
+ if (selectedTraceId) {
93
+ for (const node of findSessionNodePath([activeSession], `trace:${selectedTraceId}`)) {
94
+ if (node.children.length) {
95
+ expanded.add(node.id);
96
+ }
97
+ }
98
+ }
92
99
  return expanded;
93
100
  }
94
101
  function deriveSessionNavItem(node, traceById) {
package/dist/store.d.ts CHANGED
@@ -17,6 +17,7 @@ export declare class TraceStore extends EventEmitter {
17
17
  clear(): void;
18
18
  hierarchy(filters?: TraceFilters): HierarchyResponse;
19
19
  private recordStart;
20
+ private findTraceBySpanReference;
20
21
  private evictIfNeeded;
21
22
  private cloneTrace;
22
23
  private filteredTraces;
package/dist/store.js CHANGED
@@ -141,51 +141,49 @@ class TraceStore extends node_events_1.EventEmitter {
141
141
  };
142
142
  }
143
143
  const roots = new Map();
144
+ const traceBySpanId = new Map();
145
+ const traceNodeByTraceId = new Map();
146
+ const traceSessionByTraceId = new Map();
147
+ const parentNodeById = new Map();
144
148
  for (const trace of traces) {
145
- const sessionId = trace.hierarchy.sessionId || 'unknown-session';
149
+ const sessionId = getTraceSessionId(trace);
146
150
  const sessionNode = getOrCreateNode(roots, `session:${sessionId}`, 'session', `Session ${sessionId}`, {
147
151
  sessionId,
148
152
  chatId: trace.hierarchy.chatId,
149
- });
150
- const lineage = [sessionNode];
151
- const rootActorId = trace.hierarchy.rootActorId || 'unknown-actor';
152
- const actorNode = getOrCreateNode(sessionNode.children, `actor:${sessionId}:${rootActorId}`, 'actor', rootActorId, {
153
- actorId: rootActorId,
154
- rootActorId,
155
- sessionId,
153
+ rootActorId: trace.hierarchy.rootActorId,
156
154
  topLevelAgentId: trace.hierarchy.topLevelAgentId,
157
155
  });
158
- lineage.push(actorNode);
159
- let currentNode = actorNode;
160
- if (trace.hierarchy.kind === 'guardrail') {
161
- const label = `${trace.hierarchy.guardrailPhase || 'guardrail'} guardrail`;
162
- currentNode = getOrCreateNode(currentNode.children, `guardrail:${sessionId}:${rootActorId}:${trace.context.guardrailType || label}`, 'guardrail', label, {
163
- guardrailPhase: trace.hierarchy.guardrailPhase || null,
164
- guardrailType: trace.context.guardrailType || null,
165
- systemType: trace.context.systemType || null,
166
- watchdogPhase: trace.hierarchy.watchdogPhase || null,
167
- });
168
- lineage.push(currentNode);
169
- }
170
- else if (trace.hierarchy.childActorId) {
171
- currentNode = getOrCreateNode(currentNode.children, `child-actor:${sessionId}:${rootActorId}:${trace.hierarchy.childActorId}`, 'child-actor', trace.hierarchy.childActorId, {
172
- actorId: trace.hierarchy.childActorId,
173
- childActorId: trace.hierarchy.childActorId,
174
- delegatedAgentId: trace.hierarchy.delegatedAgentId,
175
- });
176
- lineage.push(currentNode);
156
+ const traceNode = createTraceNode(trace);
157
+ traceBySpanId.set(trace.spanContext.spanId, trace);
158
+ traceNodeByTraceId.set(trace.id, traceNode);
159
+ traceSessionByTraceId.set(trace.id, sessionId);
160
+ }
161
+ for (const trace of traces) {
162
+ const sessionId = traceSessionByTraceId.get(trace.id) || 'unknown-session';
163
+ const sessionNode = roots.get(`session:${sessionId}`);
164
+ const traceNode = traceNodeByTraceId.get(trace.id);
165
+ if (!sessionNode || !traceNode) {
166
+ continue;
177
167
  }
178
- if (trace.hierarchy.stage) {
179
- currentNode = getOrCreateNode(currentNode.children, `stage:${sessionId}:${rootActorId}:${trace.hierarchy.childActorId || 'root'}:${trace.hierarchy.stage}`, 'stage', trace.hierarchy.stage, {
180
- stage: trace.hierarchy.stage,
181
- workflowState: trace.hierarchy.workflowState,
182
- });
183
- lineage.push(currentNode);
168
+ const parentTrace = trace.parentSpanId ? traceBySpanId.get(trace.parentSpanId) : null;
169
+ const parentTraceNode = parentTrace &&
170
+ traceSessionByTraceId.get(parentTrace.id) === sessionId &&
171
+ parentTrace.id !== trace.id
172
+ ? traceNodeByTraceId.get(parentTrace.id) || null
173
+ : null;
174
+ parentNodeById.set(traceNode.id, parentTraceNode || sessionNode);
175
+ }
176
+ for (const trace of traces) {
177
+ const traceNode = traceNodeByTraceId.get(trace.id);
178
+ const parentNode = traceNode ? parentNodeById.get(traceNode.id) || null : null;
179
+ if (!traceNode || !parentNode) {
180
+ continue;
184
181
  }
185
- const traceNode = createTraceNode(trace);
186
- currentNode.children.set(traceNode.id, traceNode);
187
- for (const node of new Set(lineage)) {
188
- applyTraceRollup(node, trace);
182
+ parentNode.children.set(traceNode.id, traceNode);
183
+ let currentNode = parentNode;
184
+ while (currentNode) {
185
+ applyTraceRollup(currentNode, trace);
186
+ currentNode = parentNodeById.get(currentNode.id) || null;
189
187
  }
190
188
  }
191
189
  return {
@@ -195,9 +193,9 @@ class TraceStore extends node_events_1.EventEmitter {
195
193
  };
196
194
  }
197
195
  recordStart(mode, context, request, options = {}) {
198
- const traceContext = (0, utils_1.normalizeTraceContext)(context, mode);
196
+ const traceContext = applyConversationIdToContext((0, utils_1.normalizeTraceContext)(context, mode), options.attributes);
199
197
  const traceId = randomId();
200
- const parentSpan = options.parentSpanId ? this.traces.get(options.parentSpanId) : null;
198
+ const parentSpan = this.findTraceBySpanReference(options.parentSpanId);
201
199
  const startedAt = new Date().toISOString();
202
200
  const trace = {
203
201
  attributes: buildSpanAttributes(traceContext, mode, request, options.attributes),
@@ -253,6 +251,21 @@ class TraceStore extends node_events_1.EventEmitter {
253
251
  this.publish('span:start', traceId, { trace: this.cloneTrace(trace) });
254
252
  return traceId;
255
253
  }
254
+ findTraceBySpanReference(spanReference) {
255
+ if (!spanReference) {
256
+ return null;
257
+ }
258
+ const byTraceId = this.traces.get(spanReference);
259
+ if (byTraceId) {
260
+ return byTraceId;
261
+ }
262
+ for (const trace of this.traces.values()) {
263
+ if (trace.spanContext.spanId === spanReference) {
264
+ return trace;
265
+ }
266
+ }
267
+ return null;
268
+ }
256
269
  evictIfNeeded() {
257
270
  while (this.order.length > this.maxTraces) {
258
271
  const oldest = this.order.shift();
@@ -420,6 +433,35 @@ function buildGroupHierarchy(traces, groupBy) {
420
433
  }
421
434
  return [...groups.values()].map(serialiseNode);
422
435
  }
436
+ function getTraceSessionId(trace) {
437
+ const conversationId = toNonEmptyString(trace.attributes?.['gen_ai.conversation.id']);
438
+ return conversationId || trace.hierarchy.sessionId || 'unknown-session';
439
+ }
440
+ function applyConversationIdToContext(context, extraAttributes) {
441
+ const conversationId = toNonEmptyString(extraAttributes?.['gen_ai.conversation.id']);
442
+ if (!conversationId || conversationId === context.sessionId) {
443
+ return context;
444
+ }
445
+ return {
446
+ ...context,
447
+ sessionId: conversationId,
448
+ chatId: conversationId,
449
+ rootSessionId: context.rootSessionId || conversationId,
450
+ rootChatId: context.rootChatId || conversationId,
451
+ tags: {
452
+ ...context.tags,
453
+ sessionId: conversationId,
454
+ chatId: conversationId,
455
+ rootSessionId: context.rootSessionId || conversationId,
456
+ rootChatId: context.rootChatId || conversationId,
457
+ },
458
+ hierarchy: {
459
+ ...context.hierarchy,
460
+ sessionId: conversationId,
461
+ chatId: conversationId,
462
+ },
463
+ };
464
+ }
423
465
  function randomId() {
424
466
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
425
467
  }
@@ -469,6 +511,9 @@ function getDefaultSpanName(context, mode) {
469
511
  const prefix = context.provider || 'llm';
470
512
  return `${prefix}.${mode}`;
471
513
  }
514
+ function toNonEmptyString(value) {
515
+ return typeof value === 'string' && value.trim() ? value : null;
516
+ }
472
517
  function buildSpanAttributes(context, mode, request, extraAttributes) {
473
518
  const base = {
474
519
  'gen_ai.conversation.id': context.sessionId || undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mtharrison/loupe",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Lightweight local tracing dashboard for LLM calls",
5
5
  "author": "Matt Harrison",
6
6
  "license": "MIT",