@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 +59 -34
- package/dist/client/app.css +115 -20
- package/dist/client/app.js +183 -122
- package/dist/index.d.ts +6 -8
- package/dist/index.js +81 -66
- package/dist/session-nav.d.ts +1 -1
- package/dist/session-nav.js +8 -1
- package/dist/store.d.ts +7 -7
- package/dist/store.js +286 -83
- package/dist/types.d.ts +44 -9
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +14 -0
- package/examples/nested-tool-call.js +234 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
198
|
+
endSpan(spanId, response);
|
|
174
199
|
return response;
|
|
175
200
|
} catch (error) {
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
212
|
+
addSpanEvent,
|
|
213
|
+
endSpan,
|
|
214
|
+
recordException,
|
|
215
|
+
startSpan,
|
|
191
216
|
} from '@mtharrison/loupe';
|
|
192
217
|
|
|
193
|
-
const
|
|
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
|
-
|
|
227
|
+
endSpan(spanId, chunk);
|
|
199
228
|
} else {
|
|
200
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
### `
|
|
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 `
|
|
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
|
-
### `
|
|
380
|
+
### `addSpanEvent(spanId, event, config?)`
|
|
356
381
|
|
|
357
|
-
Appends
|
|
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
|
-
### `
|
|
384
|
+
### `endSpan(spanId, response, config?)`
|
|
360
385
|
|
|
361
|
-
|
|
386
|
+
Marks a span as complete and stores the final response payload.
|
|
362
387
|
|
|
363
|
-
### `
|
|
388
|
+
### `recordException(spanId, error, config?)`
|
|
364
389
|
|
|
365
|
-
Marks a
|
|
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
|
|
package/dist/client/app.css
CHANGED
|
@@ -402,7 +402,7 @@ pre {
|
|
|
402
402
|
.workspace-grid {
|
|
403
403
|
display: grid;
|
|
404
404
|
flex: 1;
|
|
405
|
-
grid-template-columns:
|
|
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.
|
|
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
|
|
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:
|
|
1217
|
-
right:
|
|
1218
|
-
bottom:
|
|
1219
|
-
left:
|
|
1220
|
-
border-radius:
|
|
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:
|
|
1252
|
-
|
|
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.
|
|
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
|
|
1923
|
+
gap: 0;
|
|
1909
1924
|
}
|
|
1910
1925
|
.session-tree-timeline-list .hierarchy-timeline-row {
|
|
1911
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|