@mtharrison/loupe 1.2.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.
@@ -125,9 +127,28 @@ Supported demo environment variables: `OPENAI_MODEL`, `LLM_TRACE_PORT`, `LOUPE_O
125
127
 
126
128
  The script tries to open the dashboard automatically and prints the local URL either way. Set `LOUPE_OPEN_BROWSER=0` if you want to suppress the browser launch.
127
129
 
130
+ ### Runnable Nested Tool-Call Demo
131
+
132
+ `examples/nested-tool-call.js` is a credential-free demo that:
133
+
134
+ - starts the Loupe dashboard eagerly
135
+ - wraps a root assistant model and a nested tool model
136
+ - invokes the nested tool model from inside the parent model call
137
+ - shows parent/child spans linked on the same trace
138
+
139
+ Run it with:
140
+
141
+ ```bash
142
+ npm install
143
+ export LLM_TRACE_ENABLED=1
144
+ node examples/nested-tool-call.js
145
+ ```
146
+
128
147
  ## Low-Level Lifecycle API
129
148
 
130
- If you need full control over trace boundaries, Loupe also exposes the lower-level `record*` lifecycle functions.
149
+ If you need full control over trace boundaries, Loupe exposes a lower-level span lifecycle API modeled on OpenTelemetry concepts: start a span, add events, end it, and record exceptions.
150
+
151
+ Loupe stores GenAI span attributes using the OpenTelemetry semantic convention names where they apply, including `gen_ai.request.model`, `gen_ai.response.model`, `gen_ai.system`, `gen_ai.provider.name`, `gen_ai.operation.name`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, and `gen_ai.conversation.id`.
131
152
 
132
153
  Start the dashboard during app startup, then instrument a model call:
133
154
 
@@ -135,9 +156,9 @@ Start the dashboard during app startup, then instrument a model call:
135
156
  import {
136
157
  getLocalLLMTracer,
137
158
  isTraceEnabled,
138
- recordError,
139
- recordInvokeFinish,
140
- recordInvokeStart,
159
+ endSpan,
160
+ recordException,
161
+ startSpan,
141
162
  type TraceContext,
142
163
  } from '@mtharrison/loupe';
143
164
 
@@ -166,51 +187,63 @@ const request = {
166
187
  options: {},
167
188
  };
168
189
 
169
- const traceId = recordInvokeStart(context, request);
190
+ const spanId = startSpan(context, {
191
+ mode: 'invoke',
192
+ name: 'openai.chat.completions',
193
+ request,
194
+ });
170
195
 
171
196
  try {
172
197
  const response = await model.invoke(request.input, request.options);
173
- recordInvokeFinish(traceId, response);
198
+ endSpan(spanId, response);
174
199
  return response;
175
200
  } catch (error) {
176
- recordError(traceId, error);
201
+ recordException(spanId, error);
177
202
  throw error;
178
203
  }
179
204
  ```
180
205
 
181
206
  ### Streaming
182
207
 
183
- Streaming works the same way. Loupe records each chunk event, first-chunk latency, and the reconstructed final response.
208
+ Streaming works the same way. Loupe records each span event, first-chunk latency, and the reconstructed final response.
184
209
 
185
210
  ```ts
186
211
  import {
187
- recordError,
188
- recordStreamChunk,
189
- recordStreamFinish,
190
- recordStreamStart,
212
+ addSpanEvent,
213
+ endSpan,
214
+ recordException,
215
+ startSpan,
191
216
  } from '@mtharrison/loupe';
192
217
 
193
- const traceId = recordStreamStart(context, request);
218
+ const spanId = startSpan(context, {
219
+ mode: 'stream',
220
+ name: 'openai.chat.completions',
221
+ request,
222
+ });
194
223
 
195
224
  try {
196
225
  for await (const chunk of model.stream(request.input, request.options)) {
197
226
  if (chunk?.type === 'finish') {
198
- recordStreamFinish(traceId, chunk);
227
+ endSpan(spanId, chunk);
199
228
  } else {
200
- recordStreamChunk(traceId, chunk);
229
+ addSpanEvent(spanId, {
230
+ name: `stream.${chunk?.type || 'event'}`,
231
+ attributes: chunk,
232
+ payload: chunk,
233
+ });
201
234
  }
202
235
 
203
236
  yield chunk;
204
237
  }
205
238
  } catch (error) {
206
- recordError(traceId, error);
239
+ recordException(spanId, error);
207
240
  throw error;
208
241
  }
209
242
  ```
210
243
 
211
244
  ## Trace Context
212
245
 
213
- Loupe gets its hierarchy and filters from the context you pass to `recordInvokeStart()` and `recordStreamStart()`.
246
+ Loupe gets its hierarchy and filters from the context you pass to `startSpan()`.
214
247
 
215
248
  ### Generic context fields
216
249
 
@@ -322,7 +355,7 @@ Programmatic configuration is also available through `getLocalLLMTracer(config)`
322
355
 
323
356
  ## API
324
357
 
325
- Loupe exposes both low-level lifecycle functions and lightweight wrappers.
358
+ Loupe exposes both low-level span lifecycle functions and lightweight wrappers.
326
359
 
327
360
  ### `isTraceEnabled()`
328
361
 
@@ -340,29 +373,21 @@ Returns the singleton tracer instance. This is useful if you want to:
340
373
 
341
374
  Starts the local dashboard server eagerly instead of waiting for the first trace.
342
375
 
343
- ### `recordInvokeStart(context, request, config?)`
344
-
345
- Creates an `invoke` trace and returns a `traceId`.
346
-
347
- ### `recordInvokeFinish(traceId, response, config?)`
348
-
349
- Marks an `invoke` trace as complete and stores the response payload.
350
-
351
- ### `recordStreamStart(context, request, config?)`
376
+ ### `startSpan(context, options?, config?)`
352
377
 
353
- Creates a `stream` trace and returns a `traceId`.
378
+ Creates a span and returns its Loupe `spanId`. Pass `mode`, `name`, and `request` in `options` to describe the operation. Nested spans are linked automatically when wrapped calls invoke other wrapped calls in the same async flow.
354
379
 
355
- ### `recordStreamChunk(traceId, chunk, config?)`
380
+ ### `addSpanEvent(spanId, event, config?)`
356
381
 
357
- Appends a non-final stream chunk to an existing trace.
382
+ Appends an event to an existing span. For streaming traces, pass the raw chunk as `event.payload` to preserve chunk reconstruction in the UI.
358
383
 
359
- ### `recordStreamFinish(traceId, chunk, config?)`
384
+ ### `endSpan(spanId, response, config?)`
360
385
 
361
- Stores the final stream payload and marks the trace complete.
386
+ Marks a span as complete and stores the final response payload.
362
387
 
363
- ### `recordError(traceId, error, config?)`
388
+ ### `recordException(spanId, error, config?)`
364
389
 
365
- Marks a trace as failed and stores a serialized error payload.
390
+ Marks a span as failed and stores a serialized exception payload.
366
391
 
367
392
  All of these functions forward to the singleton tracer returned by `getLocalLLMTracer()`.
368
393
 
@@ -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;