@mtharrison/loupe 1.1.1 → 1.3.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
@@ -20,6 +20,16 @@ Most tracing tools assume hosted infrastructure, persistent storage, or producti
20
20
  - cost rollups when token usage and pricing are available
21
21
  - zero external services
22
22
 
23
+ ## Screenshots
24
+
25
+ Conversation view with tool calls, staged traces, and session navigation:
26
+
27
+ ![Loupe conversation view](./assets/screenshot1.png)
28
+
29
+ Request view showing the captured OpenAI payload for a multi-turn tool call:
30
+
31
+ ![Loupe request view](./assets/screenshot2.png)
32
+
23
33
  ## Installation
24
34
 
25
35
  ```sh
@@ -38,15 +48,115 @@ Enable tracing:
38
48
  export LLM_TRACE_ENABLED=1
39
49
  ```
40
50
 
51
+ If your app already uses a higher-level model interface or the official OpenAI client, Loupe can wrap that directly instead of requiring manual `record*` calls.
52
+
53
+ ### `wrapOpenAIClient(client, getContext, config?)`
54
+
55
+ Wraps `client.chat.completions.create(...)` on an OpenAI-compatible client and records either an `invoke` trace or a `stream` trace based on `params.stream`.
56
+
57
+ ```ts
58
+ import {
59
+ wrapOpenAIClient,
60
+ } from '@mtharrison/loupe';
61
+ import OpenAI from 'openai';
62
+
63
+ const client = wrapOpenAIClient(
64
+ new OpenAI(),
65
+ () => ({
66
+ sessionId: 'session-123',
67
+ rootActorId: 'support-assistant',
68
+ actorId: 'support-assistant',
69
+ }),
70
+ );
71
+
72
+ const completion = await client.chat.completions.create({
73
+ model: 'gpt-4.1',
74
+ messages: [{ role: 'user', content: 'Summarize the latest notes.' }],
75
+ });
76
+
77
+ const stream = await client.chat.completions.create({
78
+ model: 'gpt-4.1',
79
+ messages: [{ role: 'user', content: 'Stream the same summary.' }],
80
+ stream: true,
81
+ });
82
+
83
+ for await (const chunk of stream) {
84
+ process.stdout.write(chunk.choices?.[0]?.delta?.content || '');
85
+ }
86
+ ```
87
+
88
+ If you do not call `startServer()` yourself, the dashboard starts lazily on the first recorded trace.
89
+
90
+ When the server starts, Loupe prints the local URL:
91
+
92
+ ```text
93
+ [llm-trace] dashboard: http://127.0.0.1:4319
94
+ ```
95
+
96
+ If `4319` is already in use and you did not explicitly configure a port, Loupe falls back to another free local port and prints that URL instead.
97
+
98
+ `wrapOpenAIClient()` is structurally typed, so Loupe's runtime API does not require the OpenAI SDK for normal library usage. The repo includes `openai` as a dev dependency for the bundled demo; if your own app instantiates `new OpenAI()` or runs the published example from a consumer install, install `openai` there too.
99
+
100
+ ### `wrapChatModel(model, getContext, config?)`
101
+
102
+ Wraps any object with `invoke()` and `stream()` methods.
103
+
104
+ ### Runnable OpenAI Tools Demo
105
+
106
+ There is also a runnable example at `examples/openai-multiturn-tools.js` that:
107
+
108
+ - starts the Loupe dashboard eagerly
109
+ - wraps an OpenAI client with `wrapOpenAIClient()`
110
+ - runs a multi-turn conversation with tool calls
111
+ - keeps the process alive so the in-memory traces stay visible in the dashboard
112
+
113
+ From this repo, after installing this package's dev dependencies, run:
114
+
115
+ ```bash
116
+ npm install
117
+ export OPENAI_API_KEY=your-key
118
+ export LLM_TRACE_ENABLED=1
119
+ node examples/openai-multiturn-tools.js
120
+ ```
121
+
122
+ If you copy this example pattern into another app, install `openai` in that app before using `new OpenAI()`.
123
+
124
+ Supported demo environment variables: `OPENAI_MODEL`, `LLM_TRACE_PORT`, `LOUPE_OPEN_BROWSER`.
125
+
126
+ 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
+
128
+ ### Runnable Nested Tool-Call Demo
129
+
130
+ `examples/nested-tool-call.js` is a credential-free demo that:
131
+
132
+ - starts the Loupe dashboard eagerly
133
+ - wraps a root assistant model and a nested tool model
134
+ - invokes the nested tool model from inside the parent model call
135
+ - shows parent/child spans linked on the same trace
136
+
137
+ Run it with:
138
+
139
+ ```bash
140
+ npm install
141
+ export LLM_TRACE_ENABLED=1
142
+ node examples/nested-tool-call.js
143
+ ```
144
+
145
+ ## Low-Level Lifecycle API
146
+
147
+ 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.
148
+
149
+ 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`.
150
+
41
151
  Start the dashboard during app startup, then instrument a model call:
42
152
 
43
153
  ```ts
44
154
  import {
45
155
  getLocalLLMTracer,
46
156
  isTraceEnabled,
47
- recordError,
48
- recordInvokeFinish,
49
- recordInvokeStart,
157
+ endSpan,
158
+ recordException,
159
+ startSpan,
50
160
  type TraceContext,
51
161
  } from '@mtharrison/loupe';
52
162
 
@@ -75,59 +185,63 @@ const request = {
75
185
  options: {},
76
186
  };
77
187
 
78
- const traceId = recordInvokeStart(context, request);
188
+ const spanId = startSpan(context, {
189
+ mode: 'invoke',
190
+ name: 'openai.chat.completions',
191
+ request,
192
+ });
79
193
 
80
194
  try {
81
195
  const response = await model.invoke(request.input, request.options);
82
- recordInvokeFinish(traceId, response);
196
+ endSpan(spanId, response);
83
197
  return response;
84
198
  } catch (error) {
85
- recordError(traceId, error);
199
+ recordException(spanId, error);
86
200
  throw error;
87
201
  }
88
202
  ```
89
203
 
90
- If you do not call `startServer()` yourself, the dashboard starts lazily on the first recorded trace.
91
-
92
- When the server starts, Loupe prints the local URL:
93
-
94
- ```text
95
- [llm-trace] dashboard: http://127.0.0.1:4319
96
- ```
97
-
98
- ## Streaming
204
+ ### Streaming
99
205
 
100
- Streaming works the same way. Loupe records each chunk event, first-chunk latency, and the reconstructed final response.
206
+ Streaming works the same way. Loupe records each span event, first-chunk latency, and the reconstructed final response.
101
207
 
102
208
  ```ts
103
209
  import {
104
- recordError,
105
- recordStreamChunk,
106
- recordStreamFinish,
107
- recordStreamStart,
210
+ addSpanEvent,
211
+ endSpan,
212
+ recordException,
213
+ startSpan,
108
214
  } from '@mtharrison/loupe';
109
215
 
110
- const traceId = recordStreamStart(context, request);
216
+ const spanId = startSpan(context, {
217
+ mode: 'stream',
218
+ name: 'openai.chat.completions',
219
+ request,
220
+ });
111
221
 
112
222
  try {
113
223
  for await (const chunk of model.stream(request.input, request.options)) {
114
224
  if (chunk?.type === 'finish') {
115
- recordStreamFinish(traceId, chunk);
225
+ endSpan(spanId, chunk);
116
226
  } else {
117
- recordStreamChunk(traceId, chunk);
227
+ addSpanEvent(spanId, {
228
+ name: `stream.${chunk?.type || 'event'}`,
229
+ attributes: chunk,
230
+ payload: chunk,
231
+ });
118
232
  }
119
233
 
120
234
  yield chunk;
121
235
  }
122
236
  } catch (error) {
123
- recordError(traceId, error);
237
+ recordException(spanId, error);
124
238
  throw error;
125
239
  }
126
240
  ```
127
241
 
128
242
  ## Trace Context
129
243
 
130
- Loupe gets its hierarchy and filters from the context you pass to `recordInvokeStart()` and `recordStreamStart()`.
244
+ Loupe gets its hierarchy and filters from the context you pass to `startSpan()`.
131
245
 
132
246
  ### Generic context fields
133
247
 
@@ -213,7 +327,7 @@ If usage or pricing is missing, Loupe still records the trace, but cost will sho
213
327
 
214
328
  The local dashboard includes:
215
329
 
216
- - `Traces` and `Sessions` navigation
330
+ - session-first tree navigation
217
331
  - hierarchy-aware browsing
218
332
  - conversation, request, response, context, and stream views
219
333
  - formatted and raw JSON modes
@@ -231,7 +345,7 @@ Environment variables:
231
345
  | --- | --- | --- |
232
346
  | `LLM_TRACE_ENABLED` | `false` | Enables Loupe. |
233
347
  | `LLM_TRACE_HOST` | `127.0.0.1` | Host for the local dashboard server. |
234
- | `LLM_TRACE_PORT` | `4319` | Port for the local dashboard server. |
348
+ | `LLM_TRACE_PORT` | `4319` | Port for the local dashboard server. If unset, Loupe tries `4319` first and falls back to a free local port if it is already in use. |
235
349
  | `LLM_TRACE_MAX_TRACES` | `1000` | Maximum number of traces kept in memory. |
236
350
  | `LLM_TRACE_UI_HOT_RELOAD` | auto in local interactive dev | Enables UI rebuild + reload while developing the dashboard itself. |
237
351
 
@@ -239,7 +353,7 @@ Programmatic configuration is also available through `getLocalLLMTracer(config)`
239
353
 
240
354
  ## API
241
355
 
242
- The supported public API is the low-level tracer lifecycle API.
356
+ Loupe exposes both low-level span lifecycle functions and lightweight wrappers.
243
357
 
244
358
  ### `isTraceEnabled()`
245
359
 
@@ -257,31 +371,31 @@ Returns the singleton tracer instance. This is useful if you want to:
257
371
 
258
372
  Starts the local dashboard server eagerly instead of waiting for the first trace.
259
373
 
260
- ### `recordInvokeStart(context, request, config?)`
374
+ ### `startSpan(context, options?, config?)`
261
375
 
262
- Creates an `invoke` trace and returns a `traceId`.
376
+ 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.
263
377
 
264
- ### `recordInvokeFinish(traceId, response, config?)`
378
+ ### `addSpanEvent(spanId, event, config?)`
265
379
 
266
- Marks an `invoke` trace as complete and stores the response payload.
380
+ Appends an event to an existing span. For streaming traces, pass the raw chunk as `event.payload` to preserve chunk reconstruction in the UI.
267
381
 
268
- ### `recordStreamStart(context, request, config?)`
382
+ ### `endSpan(spanId, response, config?)`
269
383
 
270
- Creates a `stream` trace and returns a `traceId`.
384
+ Marks a span as complete and stores the final response payload.
271
385
 
272
- ### `recordStreamChunk(traceId, chunk, config?)`
386
+ ### `recordException(spanId, error, config?)`
273
387
 
274
- Appends a non-final stream chunk to an existing trace.
388
+ Marks a span as failed and stores a serialized exception payload.
275
389
 
276
- ### `recordStreamFinish(traceId, chunk, config?)`
390
+ All of these functions forward to the singleton tracer returned by `getLocalLLMTracer()`.
277
391
 
278
- Stores the final stream payload and marks the trace complete.
392
+ ### `wrapChatModel(model, getContext, config?)`
279
393
 
280
- ### `recordError(traceId, error, config?)`
394
+ Returns a traced model wrapper for `invoke()` and `stream()`.
281
395
 
282
- Marks a trace as failed and stores a serialized error payload.
396
+ ### `wrapOpenAIClient(client, getContext, config?)`
283
397
 
284
- All of these functions forward to the singleton tracer returned by `getLocalLLMTracer()`.
398
+ Returns a traced OpenAI client wrapper for `chat.completions.create(...)`.
285
399
 
286
400
  ## HTTP Endpoints
287
401
 
Binary file
Binary file