@mtharrison/loupe 1.2.0 → 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
@@ -125,9 +125,28 @@ Supported demo environment variables: `OPENAI_MODEL`, `LLM_TRACE_PORT`, `LOUPE_O
125
125
 
126
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
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
+
128
145
  ## Low-Level Lifecycle API
129
146
 
130
- If you need full control over trace boundaries, Loupe also exposes the lower-level `record*` lifecycle functions.
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`.
131
150
 
132
151
  Start the dashboard during app startup, then instrument a model call:
133
152
 
@@ -135,9 +154,9 @@ Start the dashboard during app startup, then instrument a model call:
135
154
  import {
136
155
  getLocalLLMTracer,
137
156
  isTraceEnabled,
138
- recordError,
139
- recordInvokeFinish,
140
- recordInvokeStart,
157
+ endSpan,
158
+ recordException,
159
+ startSpan,
141
160
  type TraceContext,
142
161
  } from '@mtharrison/loupe';
143
162
 
@@ -166,51 +185,63 @@ const request = {
166
185
  options: {},
167
186
  };
168
187
 
169
- const traceId = recordInvokeStart(context, request);
188
+ const spanId = startSpan(context, {
189
+ mode: 'invoke',
190
+ name: 'openai.chat.completions',
191
+ request,
192
+ });
170
193
 
171
194
  try {
172
195
  const response = await model.invoke(request.input, request.options);
173
- recordInvokeFinish(traceId, response);
196
+ endSpan(spanId, response);
174
197
  return response;
175
198
  } catch (error) {
176
- recordError(traceId, error);
199
+ recordException(spanId, error);
177
200
  throw error;
178
201
  }
179
202
  ```
180
203
 
181
204
  ### Streaming
182
205
 
183
- 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.
184
207
 
185
208
  ```ts
186
209
  import {
187
- recordError,
188
- recordStreamChunk,
189
- recordStreamFinish,
190
- recordStreamStart,
210
+ addSpanEvent,
211
+ endSpan,
212
+ recordException,
213
+ startSpan,
191
214
  } from '@mtharrison/loupe';
192
215
 
193
- const traceId = recordStreamStart(context, request);
216
+ const spanId = startSpan(context, {
217
+ mode: 'stream',
218
+ name: 'openai.chat.completions',
219
+ request,
220
+ });
194
221
 
195
222
  try {
196
223
  for await (const chunk of model.stream(request.input, request.options)) {
197
224
  if (chunk?.type === 'finish') {
198
- recordStreamFinish(traceId, chunk);
225
+ endSpan(spanId, chunk);
199
226
  } else {
200
- recordStreamChunk(traceId, chunk);
227
+ addSpanEvent(spanId, {
228
+ name: `stream.${chunk?.type || 'event'}`,
229
+ attributes: chunk,
230
+ payload: chunk,
231
+ });
201
232
  }
202
233
 
203
234
  yield chunk;
204
235
  }
205
236
  } catch (error) {
206
- recordError(traceId, error);
237
+ recordException(spanId, error);
207
238
  throw error;
208
239
  }
209
240
  ```
210
241
 
211
242
  ## Trace Context
212
243
 
213
- 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()`.
214
245
 
215
246
  ### Generic context fields
216
247
 
@@ -322,7 +353,7 @@ Programmatic configuration is also available through `getLocalLLMTracer(config)`
322
353
 
323
354
  ## API
324
355
 
325
- Loupe exposes both low-level lifecycle functions and lightweight wrappers.
356
+ Loupe exposes both low-level span lifecycle functions and lightweight wrappers.
326
357
 
327
358
  ### `isTraceEnabled()`
328
359
 
@@ -340,29 +371,21 @@ Returns the singleton tracer instance. This is useful if you want to:
340
371
 
341
372
  Starts the local dashboard server eagerly instead of waiting for the first trace.
342
373
 
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?)`
374
+ ### `startSpan(context, options?, config?)`
352
375
 
353
- Creates a `stream` 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.
354
377
 
355
- ### `recordStreamChunk(traceId, chunk, config?)`
378
+ ### `addSpanEvent(spanId, event, config?)`
356
379
 
357
- Appends a non-final stream chunk to an existing trace.
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.
358
381
 
359
- ### `recordStreamFinish(traceId, chunk, config?)`
382
+ ### `endSpan(spanId, response, config?)`
360
383
 
361
- Stores the final stream payload and marks the trace complete.
384
+ Marks a span as complete and stores the final response payload.
362
385
 
363
- ### `recordError(traceId, error, config?)`
386
+ ### `recordException(spanId, error, config?)`
364
387
 
365
- Marks a trace as failed and stores a serialized error payload.
388
+ Marks a span as failed and stores a serialized exception payload.
366
389
 
367
390
  All of these functions forward to the singleton tracer returned by `getLocalLLMTracer()`.
368
391
 
@@ -21395,34 +21395,36 @@ function App() {
21395
21395
  });
21396
21396
  const handleSseMessage = (0, import_react3.useEffectEvent)((data2) => {
21397
21397
  const payload = parseEvent(data2);
21398
+ const nextTrace = payload?.span;
21399
+ const nextTraceId = payload?.spanId;
21398
21400
  if (payload?.type === "ui:reload") {
21399
21401
  window.location.reload();
21400
21402
  return;
21401
21403
  }
21402
- if (payload?.trace && selectedTraceId && payload.traceId === selectedTraceId) {
21403
- (0, import_react3.startTransition)(() => setDetail(payload.trace));
21404
+ if (nextTrace && selectedTraceId && nextTraceId === selectedTraceId) {
21405
+ (0, import_react3.startTransition)(() => setDetail(nextTrace));
21404
21406
  }
21405
21407
  if (!queryString) {
21406
- if (payload?.type === "trace:update" && payload.trace) {
21407
- applyIncrementalTraceUpdate(payload.trace);
21408
+ if ((payload?.type === "span:update" || payload?.type === "span:end") && nextTrace) {
21409
+ applyIncrementalTraceUpdate(nextTrace);
21408
21410
  return;
21409
21411
  }
21410
- if (payload?.type === "trace:add" && payload.trace) {
21411
- applyIncrementalTraceAdd(payload.trace);
21412
+ if (payload?.type === "span:start" && nextTrace) {
21413
+ applyIncrementalTraceAdd(nextTrace);
21412
21414
  scheduleRefresh(180);
21413
21415
  return;
21414
21416
  }
21415
- if (payload?.type === "trace:evict") {
21416
- applyIncrementalTraceEvict(payload.traceId);
21417
+ if (payload?.type === "span:evict") {
21418
+ applyIncrementalTraceEvict(nextTraceId);
21417
21419
  scheduleRefresh(180);
21418
21420
  return;
21419
21421
  }
21420
- if (payload?.type === "trace:clear") {
21422
+ if (payload?.type === "span:clear") {
21421
21423
  clearIncrementalState();
21422
21424
  return;
21423
21425
  }
21424
21426
  }
21425
- scheduleRefresh(payload?.type === "trace:update" ? 700 : 180);
21427
+ scheduleRefresh(payload?.type === "span:update" || payload?.type === "span:end" ? 700 : 180);
21426
21428
  });
21427
21429
  (0, import_react3.useEffect)(() => {
21428
21430
  const events = new EventSource("/api/events");
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { type ChatModelLike, type LocalLLMTracer, type OpenAIChatCompletionCreateParamsLike, type OpenAIClientLike, type TraceConfig, type TraceContext, type TraceRequest } from './types';
2
- export type { ChatModelLike, HierarchyNode, HierarchyResponse, LocalLLMTracer, NormalizedTraceContext, OpenAIChatCompletionCreateParamsLike, OpenAIChatCompletionStreamLike, OpenAIClientLike, TraceConfig, TraceContext, TraceEvent, TraceFilters, TraceHierarchy, TraceListResponse, TraceMode, TraceRecord, TraceRequest, TraceServer, TraceStatus, TraceSummary, TraceTags, UIReloadEvent, } from './types';
1
+ import { type ChatModelLike, type LocalLLMTracer, type OpenAIChatCompletionCreateParamsLike, type OpenAIClientLike, type SpanEventInput, type SpanStartOptions, type TraceConfig, type TraceContext } from './types';
2
+ export type { ChatModelLike, HierarchyNode, HierarchyResponse, LocalLLMTracer, NormalizedTraceContext, OpenAIChatCompletionCreateParamsLike, OpenAIChatCompletionStreamLike, OpenAIClientLike, SpanAttributes, SpanContext, SpanEvent, SpanEventInput, SpanKind, SpanStartOptions, SpanStatus, SpanStatusCode, TraceConfig, TraceContext, TraceEvent, TraceFilters, TraceHierarchy, TraceListResponse, TraceMode, TraceRecord, TraceRequest, TraceServer, TraceStatus, TraceSummary, TraceTags, UIReloadEvent, } from './types';
3
3
  export declare function isTraceEnabled(): boolean;
4
4
  export declare function getLocalLLMTracer(config?: TraceConfig): LocalLLMTracer;
5
5
  export declare function startTraceServer(config?: TraceConfig): Promise<{
@@ -7,12 +7,10 @@ export declare function startTraceServer(config?: TraceConfig): Promise<{
7
7
  port: number;
8
8
  url: string;
9
9
  }>;
10
- export declare function recordInvokeStart(context: TraceContext, request: TraceRequest, config?: TraceConfig): string;
11
- export declare function recordInvokeFinish(traceId: string, response: unknown, config?: TraceConfig): void;
12
- export declare function recordStreamStart(context: TraceContext, request: TraceRequest, config?: TraceConfig): string;
13
- export declare function recordStreamChunk(traceId: string, chunk: unknown, config?: TraceConfig): void;
14
- export declare function recordStreamFinish(traceId: string, chunk: unknown, config?: TraceConfig): void;
15
- export declare function recordError(traceId: string, error: unknown, config?: TraceConfig): void;
10
+ export declare function startSpan(context: TraceContext, options?: SpanStartOptions, config?: TraceConfig): string;
11
+ export declare function endSpan(spanId: string, response: unknown, config?: TraceConfig): void;
12
+ export declare function addSpanEvent(spanId: string, event: SpanEventInput, config?: TraceConfig): void;
13
+ export declare function recordException(spanId: string, error: unknown, config?: TraceConfig): void;
16
14
  export declare function __resetLocalLLMTracerForTests(): void;
17
15
  export declare function wrapChatModel<TModel extends ChatModelLike<TInput, TOptions, TValue, TChunk>, TInput = any, TOptions = any, TValue = any, TChunk = any>(model: TModel, getContext: () => TraceContext, config?: TraceConfig): TModel;
18
16
  export declare function wrapOpenAIClient<TClient extends OpenAIClientLike<TParams, TOptions, TResponse, TChunk>, TParams extends OpenAIChatCompletionCreateParamsLike = OpenAIChatCompletionCreateParamsLike, TOptions = Record<string, any>, TResponse = any, TChunk = any>(client: TClient, getContext: () => TraceContext, config?: TraceConfig): TClient;
package/dist/index.js CHANGED
@@ -3,21 +3,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isTraceEnabled = isTraceEnabled;
4
4
  exports.getLocalLLMTracer = getLocalLLMTracer;
5
5
  exports.startTraceServer = startTraceServer;
6
- exports.recordInvokeStart = recordInvokeStart;
7
- exports.recordInvokeFinish = recordInvokeFinish;
8
- exports.recordStreamStart = recordStreamStart;
9
- exports.recordStreamChunk = recordStreamChunk;
10
- exports.recordStreamFinish = recordStreamFinish;
11
- exports.recordError = recordError;
6
+ exports.startSpan = startSpan;
7
+ exports.endSpan = endSpan;
8
+ exports.addSpanEvent = addSpanEvent;
9
+ exports.recordException = recordException;
12
10
  exports.__resetLocalLLMTracerForTests = __resetLocalLLMTracerForTests;
13
11
  exports.wrapChatModel = wrapChatModel;
14
12
  exports.wrapOpenAIClient = wrapOpenAIClient;
13
+ const node_async_hooks_1 = require("node:async_hooks");
15
14
  const server_1 = require("./server");
16
15
  const store_1 = require("./store");
17
16
  const ui_build_1 = require("./ui-build");
18
17
  const utils_1 = require("./utils");
19
18
  let singleton = null;
20
19
  const DEFAULT_TRACE_PORT = 4319;
20
+ const activeSpanStorage = new node_async_hooks_1.AsyncLocalStorage();
21
21
  function isTraceEnabled() {
22
22
  return (0, utils_1.envFlag)('LLM_TRACE_ENABLED');
23
23
  }
@@ -33,23 +33,17 @@ function getLocalLLMTracer(config = {}) {
33
33
  function startTraceServer(config = {}) {
34
34
  return getLocalLLMTracer(config).startServer();
35
35
  }
36
- function recordInvokeStart(context, request, config = {}) {
37
- return getLocalLLMTracer(config).recordInvokeStart(context, request);
36
+ function startSpan(context, options = {}, config = {}) {
37
+ return getLocalLLMTracer(config).startSpan(context, options);
38
38
  }
39
- function recordInvokeFinish(traceId, response, config = {}) {
40
- getLocalLLMTracer(config).recordInvokeFinish(traceId, response);
39
+ function endSpan(spanId, response, config = {}) {
40
+ getLocalLLMTracer(config).endSpan(spanId, response);
41
41
  }
42
- function recordStreamStart(context, request, config = {}) {
43
- return getLocalLLMTracer(config).recordStreamStart(context, request);
42
+ function addSpanEvent(spanId, event, config = {}) {
43
+ getLocalLLMTracer(config).addSpanEvent(spanId, event);
44
44
  }
45
- function recordStreamChunk(traceId, chunk, config = {}) {
46
- getLocalLLMTracer(config).recordStreamChunk(traceId, chunk);
47
- }
48
- function recordStreamFinish(traceId, chunk, config = {}) {
49
- getLocalLLMTracer(config).recordStreamFinish(traceId, chunk);
50
- }
51
- function recordError(traceId, error, config = {}) {
52
- getLocalLLMTracer(config).recordError(traceId, error);
45
+ function recordException(spanId, error, config = {}) {
46
+ getLocalLLMTracer(config).recordException(spanId, error);
53
47
  }
54
48
  function __resetLocalLLMTracerForTests() {
55
49
  if (singleton?.uiWatcher) {
@@ -70,14 +64,19 @@ function wrapChatModel(model, getContext, config) {
70
64
  if (!tracer.isEnabled()) {
71
65
  return model.invoke(input, options);
72
66
  }
73
- const traceId = tracer.recordInvokeStart(getContext ? getContext() : {}, { input: input, options: options });
67
+ const traceId = tracer.startSpan(getContext ? getContext() : {}, {
68
+ attributes: { 'gen_ai.operation.name': 'chat' },
69
+ mode: 'invoke',
70
+ name: 'llm.invoke',
71
+ request: { input: input, options: options },
72
+ });
74
73
  try {
75
- const response = await model.invoke(input, options);
76
- tracer.recordInvokeFinish(traceId, response);
74
+ const response = await tracer.runWithActiveSpan(traceId, () => model.invoke(input, options));
75
+ tracer.endSpan(traceId, response);
77
76
  return response;
78
77
  }
79
78
  catch (error) {
80
- tracer.recordError(traceId, error);
79
+ tracer.recordException(traceId, error);
81
80
  throw error;
82
81
  }
83
82
  },
@@ -87,21 +86,26 @@ function wrapChatModel(model, getContext, config) {
87
86
  yield* model.stream(input, options);
88
87
  return;
89
88
  }
90
- const traceId = tracer.recordStreamStart(getContext ? getContext() : {}, { input: input, options: options });
89
+ const traceId = tracer.startSpan(getContext ? getContext() : {}, {
90
+ attributes: { 'gen_ai.operation.name': 'chat' },
91
+ mode: 'stream',
92
+ name: 'llm.stream',
93
+ request: { input: input, options: options },
94
+ });
91
95
  try {
92
- const stream = model.stream(input, options);
96
+ const stream = tracer.runWithActiveSpan(traceId, () => model.stream(input, options));
93
97
  for await (const chunk of stream) {
94
98
  if (chunk?.type === 'finish') {
95
- tracer.recordStreamFinish(traceId, chunk);
99
+ tracer.endSpan(traceId, chunk);
96
100
  }
97
101
  else {
98
- tracer.recordStreamChunk(traceId, chunk);
102
+ tracer.addSpanEvent(traceId, (0, utils_1.toSpanEventInputFromChunk)(chunk));
99
103
  }
100
104
  yield chunk;
101
105
  }
102
106
  }
103
107
  catch (error) {
104
- tracer.recordError(traceId, error);
108
+ tracer.recordException(traceId, error);
105
109
  throw error;
106
110
  }
107
111
  },
@@ -121,24 +125,34 @@ function wrapOpenAIClient(client, getContext, config) {
121
125
  }
122
126
  const context = withOpenAITraceContext(getContext ? getContext() : {}, params);
123
127
  if (params?.stream) {
124
- const traceId = tracer.recordStreamStart(context, { input: params, options: options });
128
+ const traceId = tracer.startSpan(context, {
129
+ attributes: { 'gen_ai.operation.name': 'chat' },
130
+ mode: 'stream',
131
+ name: 'openai.chat.completions',
132
+ request: { input: params, options: options },
133
+ });
125
134
  try {
126
- const stream = await target.create.call(target, params, options);
135
+ const stream = await tracer.runWithActiveSpan(traceId, () => target.create.call(target, params, options));
127
136
  return wrapOpenAIChatCompletionsStream(stream, tracer, traceId);
128
137
  }
129
138
  catch (error) {
130
- tracer.recordError(traceId, error);
139
+ tracer.recordException(traceId, error);
131
140
  throw error;
132
141
  }
133
142
  }
134
- const traceId = tracer.recordInvokeStart(context, { input: params, options: options });
143
+ const traceId = tracer.startSpan(context, {
144
+ attributes: { 'gen_ai.operation.name': 'chat' },
145
+ mode: 'invoke',
146
+ name: 'openai.chat.completions',
147
+ request: { input: params, options: options },
148
+ });
135
149
  try {
136
- const response = await target.create.call(target, params, options);
137
- tracer.recordInvokeFinish(traceId, normalizeOpenAIChatCompletionResponse(response));
150
+ const response = await tracer.runWithActiveSpan(traceId, () => target.create.call(target, params, options));
151
+ tracer.endSpan(traceId, normalizeOpenAIChatCompletionResponse(response));
138
152
  return response;
139
153
  }
140
154
  catch (error) {
141
- tracer.recordError(traceId, error);
155
+ tracer.recordException(traceId, error);
142
156
  throw error;
143
157
  }
144
158
  };
@@ -213,6 +227,27 @@ class LocalLLMTracerImpl {
213
227
  isEnabled() {
214
228
  return isTraceEnabled();
215
229
  }
230
+ startSpan(context, options = {}) {
231
+ void this.startServer();
232
+ const parentSpanId = options.parentSpanId || activeSpanStorage.getStore() || null;
233
+ return this.store.startSpan(context, {
234
+ ...options,
235
+ parentSpanId,
236
+ request: normaliseRequest(options.request || {}),
237
+ });
238
+ }
239
+ runWithActiveSpan(spanId, callback) {
240
+ return activeSpanStorage.run(spanId, callback);
241
+ }
242
+ addSpanEvent(spanId, event) {
243
+ this.store.addSpanEvent(spanId, (0, utils_1.safeClone)(event));
244
+ }
245
+ endSpan(spanId, response) {
246
+ this.store.endSpan(spanId, (0, utils_1.safeClone)(response));
247
+ }
248
+ recordException(spanId, error) {
249
+ this.store.recordException(spanId, error);
250
+ }
216
251
  startServer() {
217
252
  if (!this.isEnabled() || this.serverFailed) {
218
253
  return Promise.resolve(this.serverInfo);
@@ -234,7 +269,7 @@ class LocalLLMTracerImpl {
234
269
  this.uiWatcher = await (0, ui_build_1.maybeStartUIWatcher)(() => {
235
270
  this.server?.broadcast({
236
271
  timestamp: new Date().toISOString(),
237
- traceId: null,
272
+ spanId: null,
238
273
  type: 'ui:reload',
239
274
  });
240
275
  }, this.config.uiHotReload);
@@ -256,26 +291,6 @@ class LocalLLMTracerImpl {
256
291
  })();
257
292
  return this.serverStartPromise;
258
293
  }
259
- recordInvokeStart(context, request) {
260
- void this.startServer();
261
- return this.store.recordInvokeStart(context, normaliseRequest(request));
262
- }
263
- recordInvokeFinish(traceId, response) {
264
- this.store.recordInvokeFinish(traceId, (0, utils_1.safeClone)(response));
265
- }
266
- recordStreamStart(context, request) {
267
- void this.startServer();
268
- return this.store.recordStreamStart(context, normaliseRequest(request));
269
- }
270
- recordStreamChunk(traceId, chunk) {
271
- this.store.recordStreamChunk(traceId, (0, utils_1.safeClone)(chunk));
272
- }
273
- recordStreamFinish(traceId, chunk) {
274
- this.store.recordStreamFinish(traceId, (0, utils_1.safeClone)(chunk));
275
- }
276
- recordError(traceId, error) {
277
- this.store.recordError(traceId, error);
278
- }
279
294
  }
280
295
  function normaliseRequest(request) {
281
296
  return {
@@ -322,7 +337,7 @@ function wrapOpenAIChatCompletionsStream(stream, tracer, traceId) {
322
337
  state.began = true;
323
338
  const nextRole = role || state.role || 'assistant';
324
339
  state.role = nextRole;
325
- tracer.recordStreamChunk(traceId, { type: 'begin', role: nextRole });
340
+ tracer.addSpanEvent(traceId, (0, utils_1.toSpanEventInputFromChunk)({ type: 'begin', role: nextRole }));
326
341
  };
327
342
  const emitFinish = () => {
328
343
  if (state.finished) {
@@ -330,7 +345,7 @@ function wrapOpenAIChatCompletionsStream(stream, tracer, traceId) {
330
345
  }
331
346
  emitBegin(state.role);
332
347
  state.finished = true;
333
- tracer.recordStreamFinish(traceId, {
348
+ tracer.endSpan(traceId, {
334
349
  type: 'finish',
335
350
  finish_reasons: state.finishReasons,
336
351
  message: {
@@ -382,23 +397,23 @@ function wrapOpenAIChatCompletionsStream(stream, tracer, traceId) {
382
397
  emitBegin(state.role);
383
398
  }
384
399
  if (contentParts.length > 0) {
385
- tracer.recordStreamChunk(traceId, {
400
+ tracer.addSpanEvent(traceId, (0, utils_1.toSpanEventInputFromChunk)({
386
401
  type: 'chunk',
387
402
  content: contentParts.join(''),
388
403
  finish_reasons: [...finishReasons],
389
404
  raw,
390
405
  tool_calls: chunkToolCalls,
391
406
  usage,
392
- });
407
+ }));
393
408
  return;
394
409
  }
395
- tracer.recordStreamChunk(traceId, {
410
+ tracer.addSpanEvent(traceId, (0, utils_1.toSpanEventInputFromChunk)({
396
411
  type: 'event',
397
412
  finish_reasons: [...finishReasons],
398
413
  raw,
399
414
  tool_calls: chunkToolCalls,
400
415
  usage,
401
- });
416
+ }));
402
417
  };
403
418
  const createWrappedIterator = (iterator) => ({
404
419
  async next(...args) {
@@ -412,7 +427,7 @@ function wrapOpenAIChatCompletionsStream(stream, tracer, traceId) {
412
427
  return result;
413
428
  }
414
429
  catch (error) {
415
- tracer.recordError(traceId, error);
430
+ tracer.recordException(traceId, error);
416
431
  throw error;
417
432
  }
418
433
  },
@@ -428,12 +443,12 @@ function wrapOpenAIChatCompletionsStream(stream, tracer, traceId) {
428
443
  return result;
429
444
  }
430
445
  catch (error) {
431
- tracer.recordError(traceId, error);
446
+ tracer.recordException(traceId, error);
432
447
  throw error;
433
448
  }
434
449
  },
435
450
  async throw(error) {
436
- tracer.recordError(traceId, error);
451
+ tracer.recordException(traceId, error);
437
452
  if (typeof iterator.throw === 'function') {
438
453
  return iterator.throw(error);
439
454
  }
package/dist/store.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import { type HierarchyResponse, type NormalizedTraceContext, type TraceFilters, type TraceListResponse, type TraceRecord, type TraceRequest } from './types';
2
+ import { type HierarchyResponse, type NormalizedTraceContext, type SpanEventInput, type SpanStartOptions, type TraceFilters, type TraceListResponse, type TraceRecord } from './types';
3
3
  export declare class TraceStore extends EventEmitter {
4
4
  maxTraces: number;
5
5
  order: string[];
@@ -7,12 +7,11 @@ export declare class TraceStore extends EventEmitter {
7
7
  constructor(options?: {
8
8
  maxTraces?: number;
9
9
  });
10
- recordInvokeStart(context: NormalizedTraceContext | undefined, request: TraceRequest): string;
11
- recordInvokeFinish(traceId: string, response: any): void;
12
- recordStreamStart(context: NormalizedTraceContext | undefined, request: TraceRequest): string;
13
- recordStreamChunk(traceId: string, chunk: any): void;
14
- recordStreamFinish(traceId: string, chunk: any): void;
15
- recordError(traceId: string, error: unknown): void;
10
+ startSpan(context: NormalizedTraceContext | undefined, options?: SpanStartOptions): string;
11
+ addSpanEvent(spanId: string, event: SpanEventInput): void;
12
+ endSpan(spanId: string, response: any): void;
13
+ recordException(spanId: string, error: unknown): void;
14
+ private applyStreamPayload;
16
15
  list(filters?: TraceFilters): TraceListResponse;
17
16
  get(traceId: string): TraceRecord | null;
18
17
  clear(): void;
package/dist/store.js CHANGED
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TraceStore = void 0;
4
+ const node_crypto_1 = require("node:crypto");
4
5
  const node_events_1 = require("node:events");
5
6
  const utils_1 = require("./utils");
7
+ const STREAM_EVENT_NAME_PREFIX = 'stream.';
6
8
  class TraceStore extends node_events_1.EventEmitter {
7
9
  maxTraces;
8
10
  order;
@@ -13,72 +15,100 @@ class TraceStore extends node_events_1.EventEmitter {
13
15
  this.order = [];
14
16
  this.traces = new Map();
15
17
  }
16
- recordInvokeStart(context, request) {
17
- return this.recordStart('invoke', context, request);
18
+ startSpan(context, options = {}) {
19
+ return this.recordStart(options.mode || 'invoke', context, options.request || {}, options);
18
20
  }
19
- recordInvokeFinish(traceId, response) {
20
- const trace = this.traces.get(traceId);
21
+ addSpanEvent(spanId, event) {
22
+ // Loupe returns its own stable span handle from startSpan(). That handle is used to
23
+ // look up mutable in-memory records here, while trace.spanContext.spanId stores the
24
+ // OpenTelemetry span ID exposed on the resulting span data.
25
+ const trace = this.traces.get(spanId);
21
26
  if (!trace) {
22
27
  return;
23
28
  }
24
- trace.response = (0, utils_1.safeClone)(response);
25
- trace.usage = (0, utils_1.safeClone)(response?.usage);
29
+ const spanEvent = normalizeSpanEvent(event);
30
+ trace.events.push(spanEvent);
31
+ this.applyStreamPayload(trace, event.payload, spanEvent);
32
+ this.publish('span:update', spanId, { trace: this.cloneTrace(trace) });
33
+ }
34
+ endSpan(spanId, response) {
35
+ const trace = this.traces.get(spanId);
36
+ if (!trace) {
37
+ return;
38
+ }
39
+ const clone = (0, utils_1.safeClone)(response);
40
+ if (trace.mode === 'stream') {
41
+ const finishEvent = normalizeSpanEvent((0, utils_1.toSpanEventInputFromChunk)(clone));
42
+ trace.events.push(finishEvent);
43
+ this.applyStreamPayload(trace, clone, finishEvent);
44
+ trace.response = clone;
45
+ trace.usage = (0, utils_1.safeClone)(clone?.usage);
46
+ }
47
+ else {
48
+ trace.response = clone;
49
+ trace.usage = (0, utils_1.safeClone)(clone?.usage);
50
+ }
51
+ applyResponseAttributes(trace, clone);
26
52
  trace.status = 'ok';
53
+ trace.spanStatus = { code: 'OK' };
27
54
  trace.endedAt = new Date().toISOString();
28
- this.publish('trace:update', traceId, { trace: this.cloneTrace(trace) });
55
+ this.publish('span:end', spanId, { trace: this.cloneTrace(trace) });
29
56
  }
30
- recordStreamStart(context, request) {
31
- return this.recordStart('stream', context, request);
57
+ recordException(spanId, error) {
58
+ const trace = this.traces.get(spanId);
59
+ if (!trace) {
60
+ return;
61
+ }
62
+ const payload = (0, utils_1.toErrorPayload)(error);
63
+ trace.error = payload;
64
+ trace.events.push({
65
+ attributes: payload || {},
66
+ name: 'exception',
67
+ timestamp: new Date().toISOString(),
68
+ });
69
+ trace.status = 'error';
70
+ trace.spanStatus = {
71
+ code: 'ERROR',
72
+ message: payload?.message,
73
+ };
74
+ if (payload?.name || payload?.type || payload?.code || payload?.status) {
75
+ trace.attributes['error.type'] = String(payload.name || payload.type || payload.code || payload.status);
76
+ }
77
+ trace.endedAt = new Date().toISOString();
78
+ this.publish('span:end', spanId, { trace: this.cloneTrace(trace) });
32
79
  }
33
- recordStreamChunk(traceId, chunk) {
34
- const trace = this.traces.get(traceId);
35
- if (!trace || !trace.stream) {
80
+ applyStreamPayload(trace, payload, spanEvent) {
81
+ if (!trace.stream) {
36
82
  return;
37
83
  }
38
- const clone = (0, utils_1.safeClone)(chunk);
84
+ const clone = toStreamPayload(payload, spanEvent);
39
85
  if (clone && typeof clone === 'object') {
40
86
  clone.offsetMs = Math.max(0, Date.now() - Date.parse(trace.startedAt));
41
87
  }
42
88
  trace.stream.events.push(clone);
43
- if (chunk?.type === 'chunk') {
89
+ if (clone?.type === 'chunk') {
44
90
  trace.stream.chunkCount += 1;
45
91
  if (trace.stream.firstChunkMs === null) {
46
92
  trace.stream.firstChunkMs = Date.now() - Date.parse(trace.startedAt);
47
93
  }
48
- if (typeof chunk.content === 'string') {
49
- trace.stream.reconstructed.message.content = `${trace.stream.reconstructed.message.content || ''}${chunk.content}`;
94
+ if (typeof clone.content === 'string') {
95
+ trace.stream.reconstructed.message.content = `${trace.stream.reconstructed.message.content || ''}${clone.content}`;
50
96
  }
51
97
  }
52
- if (chunk?.type === 'begin') {
53
- trace.stream.reconstructed.message.role = chunk.role;
98
+ if (clone?.type === 'begin') {
99
+ trace.stream.reconstructed.message.role = clone.role;
54
100
  }
55
- if (chunk?.type === 'finish') {
101
+ if (clone?.type === 'finish') {
56
102
  trace.response = clone;
57
- trace.usage = (0, utils_1.safeClone)(chunk.usage);
103
+ trace.usage = (0, utils_1.safeClone)(clone.usage);
58
104
  trace.stream.reconstructed.message = {
59
- ...((0, utils_1.safeClone)(chunk.message) || {}),
105
+ ...((0, utils_1.safeClone)(clone.message) || {}),
60
106
  content: trace.stream.reconstructed.message.content ||
61
- (typeof chunk.message?.content === 'string' ? chunk.message.content : chunk.message?.content ?? null),
107
+ (typeof clone.message?.content === 'string' ? clone.message.content : clone.message?.content ?? null),
62
108
  };
63
- trace.stream.reconstructed.tool_calls = (0, utils_1.safeClone)(chunk.tool_calls || []);
64
- trace.stream.reconstructed.usage = (0, utils_1.safeClone)(chunk.usage || null);
65
- trace.status = 'ok';
66
- trace.endedAt = new Date().toISOString();
67
- }
68
- this.publish('trace:update', traceId, { trace: this.cloneTrace(trace) });
69
- }
70
- recordStreamFinish(traceId, chunk) {
71
- this.recordStreamChunk(traceId, chunk);
72
- }
73
- recordError(traceId, error) {
74
- const trace = this.traces.get(traceId);
75
- if (!trace) {
76
- return;
109
+ trace.stream.reconstructed.tool_calls = (0, utils_1.safeClone)(clone.tool_calls || []);
110
+ trace.stream.reconstructed.usage = (0, utils_1.safeClone)(clone.usage || null);
77
111
  }
78
- trace.error = (0, utils_1.toErrorPayload)(error);
79
- trace.status = 'error';
80
- trace.endedAt = new Date().toISOString();
81
- this.publish('trace:update', traceId, { trace: this.cloneTrace(trace) });
82
112
  }
83
113
  list(filters = {}) {
84
114
  const items = this.filteredTraces(filters).map(utils_1.toSummary);
@@ -99,7 +129,7 @@ class TraceStore extends node_events_1.EventEmitter {
99
129
  clear() {
100
130
  this.order = [];
101
131
  this.traces.clear();
102
- this.publish('trace:clear', null, {});
132
+ this.publish('span:clear', null, {});
103
133
  }
104
134
  hierarchy(filters = {}) {
105
135
  const traces = this.filteredTraces(filters);
@@ -164,19 +194,24 @@ class TraceStore extends node_events_1.EventEmitter {
164
194
  rootNodes: [...roots.values()].map(serialiseNode),
165
195
  };
166
196
  }
167
- recordStart(mode, context, request) {
197
+ recordStart(mode, context, request, options = {}) {
168
198
  const traceContext = (0, utils_1.normalizeTraceContext)(context, mode);
169
199
  const traceId = randomId();
200
+ const parentSpan = options.parentSpanId ? this.traces.get(options.parentSpanId) : null;
170
201
  const startedAt = new Date().toISOString();
171
202
  const trace = {
203
+ attributes: buildSpanAttributes(traceContext, mode, request, options.attributes),
172
204
  context: traceContext,
173
205
  endedAt: null,
174
206
  error: null,
207
+ events: [],
175
208
  hierarchy: traceContext.hierarchy,
176
209
  id: traceId,
177
210
  kind: traceContext.kind,
178
211
  mode,
179
212
  model: traceContext.model,
213
+ name: options.name || getDefaultSpanName(traceContext, mode),
214
+ parentSpanId: parentSpan?.spanContext.spanId || options.parentSpanId || null,
180
215
  provider: traceContext.provider,
181
216
  request: {
182
217
  input: (0, utils_1.safeClone)(request?.input),
@@ -187,6 +222,15 @@ class TraceStore extends node_events_1.EventEmitter {
187
222
  },
188
223
  response: null,
189
224
  startedAt,
225
+ spanContext: {
226
+ // The returned Loupe span handle (trace.id) is used for local mutation and SSE updates.
227
+ // spanContext contains the OpenTelemetry trace/span identifiers that are attached to
228
+ // the exported span payload and inherited by child spans.
229
+ spanId: randomHexId(16),
230
+ traceId: parentSpan?.spanContext.traceId || randomHexId(32),
231
+ },
232
+ spanKind: options.kind || 'CLIENT',
233
+ spanStatus: { code: 'UNSET' },
190
234
  status: 'pending',
191
235
  stream: mode === 'stream'
192
236
  ? {
@@ -206,7 +250,7 @@ class TraceStore extends node_events_1.EventEmitter {
206
250
  this.order.push(traceId);
207
251
  this.traces.set(traceId, trace);
208
252
  this.evictIfNeeded();
209
- this.publish('trace:add', traceId, { trace: this.cloneTrace(trace) });
253
+ this.publish('span:start', traceId, { trace: this.cloneTrace(trace) });
210
254
  return traceId;
211
255
  }
212
256
  evictIfNeeded() {
@@ -216,7 +260,7 @@ class TraceStore extends node_events_1.EventEmitter {
216
260
  if (oldest) {
217
261
  this.traces.delete(oldest);
218
262
  }
219
- this.publish('trace:evict', oldest || null, removed ? { trace: this.cloneTrace(removed) } : {});
263
+ this.publish('span:evict', oldest || null, removed ? { trace: this.cloneTrace(removed) } : {});
220
264
  }
221
265
  }
222
266
  cloneTrace(trace) {
@@ -273,8 +317,9 @@ class TraceStore extends node_events_1.EventEmitter {
273
317
  }
274
318
  publish(type, traceId, payload) {
275
319
  const event = {
320
+ span: payload.trace,
321
+ spanId: traceId,
276
322
  type,
277
- traceId,
278
323
  timestamp: new Date().toISOString(),
279
324
  ...payload,
280
325
  };
@@ -378,3 +423,116 @@ function buildGroupHierarchy(traces, groupBy) {
378
423
  function randomId() {
379
424
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
380
425
  }
426
+ function randomHexId(length) {
427
+ if (length % 2 !== 0) {
428
+ throw new RangeError(`OpenTelemetry hex IDs must have an even length. Received: ${length}`);
429
+ }
430
+ return (0, node_crypto_1.randomBytes)(Math.ceil(length / 2)).toString('hex').slice(0, length);
431
+ }
432
+ function normalizeSpanEvent(event) {
433
+ const attributes = (0, utils_1.safeClone)(event.attributes || {});
434
+ if (event.name === 'stream.finish') {
435
+ attributes['gen_ai.message.status'] = attributes['gen_ai.message.status'] || 'completed';
436
+ }
437
+ else if (event.name.startsWith(STREAM_EVENT_NAME_PREFIX)) {
438
+ attributes['gen_ai.message.status'] = attributes['gen_ai.message.status'] || 'in_progress';
439
+ }
440
+ if (attributes.finish_reasons && attributes['gen_ai.response.finish_reasons'] === undefined) {
441
+ attributes['gen_ai.response.finish_reasons'] = (0, utils_1.safeClone)(attributes.finish_reasons);
442
+ }
443
+ if (attributes.message && attributes['gen_ai.output.messages'] === undefined) {
444
+ attributes['gen_ai.output.messages'] = [(0, utils_1.safeClone)(attributes.message)];
445
+ }
446
+ return {
447
+ attributes,
448
+ name: event.name,
449
+ timestamp: new Date().toISOString(),
450
+ };
451
+ }
452
+ function toStreamPayload(payload, spanEvent) {
453
+ if (payload && typeof payload === 'object') {
454
+ return (0, utils_1.safeClone)(payload);
455
+ }
456
+ // Generic addSpanEvent() callers may only provide an OpenTelemetry-style event name
457
+ // plus attributes. Reconstruct the minimal legacy stream payload shape from that data
458
+ // so the existing dashboard stream timeline can continue to render incrementally.
459
+ const suffix = spanEvent.name.startsWith(STREAM_EVENT_NAME_PREFIX)
460
+ ? spanEvent.name.slice(STREAM_EVENT_NAME_PREFIX.length)
461
+ : spanEvent.name;
462
+ const eventType = suffix || 'event';
463
+ return {
464
+ ...(0, utils_1.safeClone)(spanEvent.attributes || {}),
465
+ type: eventType,
466
+ };
467
+ }
468
+ function getDefaultSpanName(context, mode) {
469
+ const prefix = context.provider || 'llm';
470
+ return `${prefix}.${mode}`;
471
+ }
472
+ function buildSpanAttributes(context, mode, request, extraAttributes) {
473
+ const base = {
474
+ 'gen_ai.conversation.id': context.sessionId || undefined,
475
+ 'gen_ai.input.messages': Array.isArray(request?.input?.messages) ? (0, utils_1.safeClone)(request.input.messages) : undefined,
476
+ 'gen_ai.operation.name': inferGenAIOperationName(request, mode),
477
+ 'gen_ai.provider.name': context.provider || undefined,
478
+ 'gen_ai.request.choice.count': typeof request?.input?.n === 'number' ? request.input.n : undefined,
479
+ 'gen_ai.request.model': (typeof request?.input?.model === 'string' && request.input.model) || context.model || undefined,
480
+ 'gen_ai.system': context.provider || undefined,
481
+ 'loupe.actor.id': context.actorId || undefined,
482
+ 'loupe.actor.type': context.actorType || undefined,
483
+ 'loupe.guardrail.phase': context.guardrailPhase || undefined,
484
+ 'loupe.guardrail.type': context.guardrailType || undefined,
485
+ 'loupe.root_actor.id': context.rootActorId || undefined,
486
+ 'loupe.root_session.id': context.rootSessionId || undefined,
487
+ 'loupe.session.id': context.sessionId || undefined,
488
+ 'loupe.stage': context.stage || undefined,
489
+ 'loupe.tenant.id': context.tenantId || undefined,
490
+ 'loupe.user.id': context.userId || undefined,
491
+ };
492
+ for (const [key, value] of Object.entries(context.tags || {})) {
493
+ base[`loupe.tag.${key}`] = value;
494
+ }
495
+ return Object.fromEntries(Object.entries({
496
+ ...base,
497
+ ...(extraAttributes || {}),
498
+ }).filter(([, value]) => value !== undefined && value !== null));
499
+ }
500
+ function applyResponseAttributes(trace, response) {
501
+ const finishReasons = Array.isArray(response?.finish_reasons)
502
+ ? response.finish_reasons
503
+ : response?.finish_reason
504
+ ? [response.finish_reason]
505
+ : [];
506
+ const usage = response?.usage;
507
+ if (typeof response?.model === 'string' && response.model) {
508
+ trace.attributes['gen_ai.response.model'] = response.model;
509
+ }
510
+ if (usage?.tokens?.prompt !== undefined) {
511
+ trace.attributes['gen_ai.usage.input_tokens'] = usage.tokens.prompt;
512
+ }
513
+ if (usage?.tokens?.completion !== undefined) {
514
+ trace.attributes['gen_ai.usage.output_tokens'] = usage.tokens.completion;
515
+ }
516
+ if (finishReasons.length > 0) {
517
+ trace.attributes['gen_ai.response.finish_reasons'] = (0, utils_1.safeClone)(finishReasons);
518
+ }
519
+ if (response?.message) {
520
+ trace.attributes['gen_ai.output.messages'] = [(0, utils_1.safeClone)(response.message)];
521
+ trace.attributes['gen_ai.output.type'] = inferGenAIOutputType(response.message.content);
522
+ }
523
+ }
524
+ function inferGenAIOperationName(request, mode) {
525
+ if (Array.isArray(request?.input?.messages) && request.input.messages.length > 0) {
526
+ return 'chat';
527
+ }
528
+ return mode;
529
+ }
530
+ function inferGenAIOutputType(content) {
531
+ if (typeof content === 'string' || Array.isArray(content)) {
532
+ return 'text';
533
+ }
534
+ if (content && typeof content === 'object') {
535
+ return 'json';
536
+ }
537
+ return 'unknown';
538
+ }
package/dist/types.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export type TraceMode = 'invoke' | 'stream';
2
2
  export type TraceStatus = 'pending' | 'ok' | 'error';
3
+ export type SpanKind = 'INTERNAL' | 'SERVER' | 'CLIENT' | 'PRODUCER' | 'CONSUMER';
4
+ export type SpanStatusCode = 'UNSET' | 'OK' | 'ERROR';
3
5
  export type TraceConfig = {
4
6
  host?: string;
5
7
  maxTraces?: number;
@@ -7,6 +9,20 @@ export type TraceConfig = {
7
9
  uiHotReload?: boolean;
8
10
  };
9
11
  export type TraceTags = Record<string, string>;
12
+ export type SpanAttributes = Record<string, any>;
13
+ export type SpanContext = {
14
+ spanId: string;
15
+ traceId: string;
16
+ };
17
+ export type SpanEvent = {
18
+ attributes: SpanAttributes;
19
+ name: string;
20
+ timestamp: string;
21
+ };
22
+ export type SpanStatus = {
23
+ code: SpanStatusCode;
24
+ message?: string;
25
+ };
10
26
  export type TraceContext = {
11
27
  actorId?: string | null;
12
28
  actorType?: string | null;
@@ -79,6 +95,19 @@ export type TraceRequest = {
79
95
  input?: Record<string, any>;
80
96
  options?: Record<string, any>;
81
97
  };
98
+ export type SpanStartOptions = {
99
+ attributes?: SpanAttributes;
100
+ kind?: SpanKind;
101
+ mode?: TraceMode;
102
+ name?: string;
103
+ parentSpanId?: string | null;
104
+ request?: TraceRequest;
105
+ };
106
+ export type SpanEventInput = {
107
+ attributes?: SpanAttributes;
108
+ name: string;
109
+ payload?: unknown;
110
+ };
82
111
  export type TraceStructuredInputInsight = {
83
112
  format: 'xml';
84
113
  role: string;
@@ -101,14 +130,18 @@ export type TraceSummaryFlags = {
101
130
  hasStructuredInput: boolean;
102
131
  };
103
132
  export type TraceRecord = {
133
+ attributes: SpanAttributes;
104
134
  context: NormalizedTraceContext;
105
135
  endedAt: string | null;
106
136
  error: Record<string, any> | null;
137
+ events: SpanEvent[];
107
138
  hierarchy: TraceHierarchy;
108
139
  id: string;
109
140
  kind: string;
110
141
  mode: TraceMode;
111
142
  model: string | null;
143
+ name: string;
144
+ parentSpanId: string | null;
112
145
  provider: string | null;
113
146
  request: {
114
147
  input?: Record<string, any>;
@@ -116,6 +149,9 @@ export type TraceRecord = {
116
149
  };
117
150
  response: Record<string, any> | null;
118
151
  startedAt: string;
152
+ spanContext: SpanContext;
153
+ spanKind: SpanKind;
154
+ spanStatus: SpanStatus;
119
155
  status: TraceStatus;
120
156
  stream: null | {
121
157
  chunkCount: number;
@@ -153,14 +189,14 @@ export type TraceSummary = {
153
189
  tags: TraceTags;
154
190
  };
155
191
  export type TraceEvent = {
192
+ span?: TraceRecord;
193
+ spanId: string | null;
156
194
  timestamp: string;
157
- trace?: TraceRecord;
158
- traceId: string | null;
159
195
  type: string;
160
196
  };
161
197
  export type UIReloadEvent = {
162
198
  timestamp: string;
163
- traceId: null;
199
+ spanId: null;
164
200
  type: 'ui:reload';
165
201
  };
166
202
  export type TraceListResponse = {
@@ -233,14 +269,13 @@ export type UIWatchController = {
233
269
  stop(): Promise<void>;
234
270
  };
235
271
  export type LocalLLMTracer = {
272
+ addSpanEvent(spanId: string, event: SpanEventInput): void;
236
273
  configure(config?: TraceConfig): void;
274
+ endSpan(spanId: string, response?: unknown): void;
237
275
  isEnabled(): boolean;
238
- recordError(traceId: string, error: unknown): void;
239
- recordInvokeFinish(traceId: string, response: unknown): void;
240
- recordInvokeStart(context: TraceContext, request: TraceRequest): string;
241
- recordStreamChunk(traceId: string, chunk: unknown): void;
242
- recordStreamFinish(traceId: string, chunk: unknown): void;
243
- recordStreamStart(context: TraceContext, request: TraceRequest): string;
276
+ recordException(spanId: string, error: unknown): void;
277
+ runWithActiveSpan<T>(spanId: string, callback: () => T): T;
278
+ startSpan(context: TraceContext, options?: SpanStartOptions): string;
244
279
  startServer(): Promise<{
245
280
  host: string;
246
281
  port: number;
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { type NormalizedTraceContext, type TraceContext, type TraceInsights, type TraceMode, type TraceRecord, type TraceSummary } from './types';
1
+ import { type NormalizedTraceContext, type SpanEventInput, type TraceContext, type TraceInsights, type TraceMode, type TraceRecord, type TraceSummary } from './types';
2
2
  export declare function safeClone<T>(value: T): T;
3
+ export declare function toSpanEventInputFromChunk(chunk: unknown): SpanEventInput;
3
4
  export declare function toErrorPayload(error: any): Record<string, any> | null;
4
5
  export declare function sanitizeHeaders(headers: Record<string, any> | undefined): Record<string, any>;
5
6
  export declare function envFlag(name: string): boolean;
package/dist/utils.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.safeClone = safeClone;
4
+ exports.toSpanEventInputFromChunk = toSpanEventInputFromChunk;
4
5
  exports.toErrorPayload = toErrorPayload;
5
6
  exports.sanitizeHeaders = sanitizeHeaders;
6
7
  exports.envFlag = envFlag;
@@ -30,6 +31,19 @@ function safeClone(value) {
30
31
  return value;
31
32
  }
32
33
  }
34
+ function toSpanEventInputFromChunk(chunk) {
35
+ const payload = safeClone(chunk);
36
+ const eventType = typeof payload?.type === 'string' && payload.type ? payload.type : 'event';
37
+ const attributes = payload !== null && typeof payload === 'object' ? { ...payload } : {};
38
+ if ('type' in attributes) {
39
+ delete attributes.type;
40
+ }
41
+ return {
42
+ attributes,
43
+ name: `stream.${eventType}`,
44
+ payload: payload ?? undefined,
45
+ };
46
+ }
33
47
  function toErrorPayload(error) {
34
48
  if (!error) {
35
49
  return null;
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('node:child_process');
4
+ const { randomUUID } = require('node:crypto');
5
+ const readline = require('node:readline/promises');
6
+ const process = require('node:process');
7
+
8
+ const { getLocalLLMTracer, wrapChatModel } = require('../dist/index.js');
9
+
10
+ const DEMO_TIMEOUT_MS = 15000;
11
+ const PORT = Number(process.env.LLM_TRACE_PORT) || 4319;
12
+ const SESSION_ID = `nested-tool-demo-${randomUUID().slice(0, 8)}`;
13
+
14
+ async function main() {
15
+ process.env.LLM_TRACE_ENABLED ??= '1';
16
+
17
+ const tracer = getLocalLLMTracer({ port: PORT });
18
+ const serverInfo = await tracer.startServer();
19
+ if (!serverInfo?.url) {
20
+ throw new Error('Failed to start the Loupe dashboard.');
21
+ }
22
+
23
+ log(`[demo] Loupe dashboard: ${serverInfo.url}`);
24
+ openBrowser(serverInfo.url);
25
+ log(`[demo] Session: ${SESSION_ID}`);
26
+
27
+ const nestedResearchModel = wrapChatModel(
28
+ {
29
+ async invoke(input) {
30
+ const question = input?.messages?.[0]?.content || '';
31
+ return {
32
+ message: {
33
+ role: 'assistant',
34
+ content: `Tool research result for "${question}": compare rain gear, walking shoes, and a light sweater.`,
35
+ },
36
+ tool_calls: [],
37
+ usage: {
38
+ tokens: { prompt: 9, completion: 14 },
39
+ pricing: { prompt: 0.000001, completion: 0.000002 },
40
+ },
41
+ };
42
+ },
43
+ async *stream(input) {
44
+ const content = `Tool research stream for "${input?.messages?.[0]?.content || ''}".`;
45
+ yield { type: 'begin', role: 'assistant' };
46
+ yield { type: 'chunk', content };
47
+ yield {
48
+ type: 'finish',
49
+ message: { role: 'assistant', content },
50
+ tool_calls: [],
51
+ usage: {
52
+ tokens: { prompt: 9, completion: 14 },
53
+ pricing: { prompt: 0.000001, completion: 0.000002 },
54
+ },
55
+ };
56
+ },
57
+ },
58
+ () => ({
59
+ sessionId: SESSION_ID,
60
+ rootSessionId: SESSION_ID,
61
+ rootActorId: 'travel-assistant',
62
+ actorId: 'weather-research-tool',
63
+ provider: 'mock-llm',
64
+ model: 'tool-researcher-v1',
65
+ stage: 'tool:research',
66
+ tags: {
67
+ example: 'nested-tool-call',
68
+ role: 'tool-llm',
69
+ },
70
+ }),
71
+ { port: PORT },
72
+ );
73
+
74
+ const rootAssistantModel = wrapChatModel(
75
+ {
76
+ async invoke(input) {
77
+ const question = input?.messages?.[0]?.content || '';
78
+ const toolResult = await nestedResearchModel.invoke(
79
+ {
80
+ messages: [
81
+ {
82
+ role: 'user',
83
+ content: `Research facts needed for: ${question}`,
84
+ },
85
+ ],
86
+ },
87
+ {},
88
+ );
89
+
90
+ return {
91
+ message: {
92
+ role: 'assistant',
93
+ content: [
94
+ `Final answer for "${question}"`,
95
+ '',
96
+ toolResult.message.content,
97
+ '',
98
+ 'Pack layers, waterproof gear, and comfortable shoes.',
99
+ ].join('\n'),
100
+ },
101
+ tool_calls: [],
102
+ usage: {
103
+ tokens: { prompt: 12, completion: 18 },
104
+ pricing: { prompt: 0.000001, completion: 0.000002 },
105
+ },
106
+ };
107
+ },
108
+ async *stream(input) {
109
+ const question = input?.messages?.[0]?.content || '';
110
+ yield { type: 'begin', role: 'assistant' };
111
+ yield { type: 'chunk', content: 'Let me research that with the tool model.\n\n' };
112
+
113
+ let toolContent = '';
114
+ for await (const chunk of nestedResearchModel.stream(
115
+ {
116
+ messages: [
117
+ {
118
+ role: 'user',
119
+ content: `Research facts needed for: ${question}`,
120
+ },
121
+ ],
122
+ },
123
+ {},
124
+ )) {
125
+ if (chunk?.type === 'chunk' && typeof chunk.content === 'string') {
126
+ toolContent += chunk.content;
127
+ }
128
+ }
129
+
130
+ const finalContent = [
131
+ `Final answer for "${question}"`,
132
+ '',
133
+ toolContent,
134
+ '',
135
+ 'Pack layers, waterproof gear, and comfortable shoes.',
136
+ ].join('\n');
137
+
138
+ yield { type: 'chunk', content: finalContent };
139
+ yield {
140
+ type: 'finish',
141
+ message: { role: 'assistant', content: finalContent },
142
+ tool_calls: [],
143
+ usage: {
144
+ tokens: { prompt: 12, completion: 18 },
145
+ pricing: { prompt: 0.000001, completion: 0.000002 },
146
+ },
147
+ };
148
+ },
149
+ },
150
+ () => ({
151
+ sessionId: SESSION_ID,
152
+ rootSessionId: SESSION_ID,
153
+ rootActorId: 'travel-assistant',
154
+ actorId: 'travel-assistant',
155
+ provider: 'mock-llm',
156
+ model: 'trip-planner-v1',
157
+ stage: 'assistant',
158
+ tags: {
159
+ example: 'nested-tool-call',
160
+ role: 'root-llm',
161
+ },
162
+ }),
163
+ { port: PORT },
164
+ );
165
+
166
+ const prompt = 'I am taking a rainy weekend trip to London. What should I pack?';
167
+ log(`[demo] User: ${prompt}`);
168
+ const response = await rootAssistantModel.invoke({
169
+ messages: [{ role: 'user', content: prompt }],
170
+ });
171
+ log(`[demo] Assistant:\n${response.message.content}`);
172
+ log('[demo] This run creates a parent span for the assistant call and a child span for the tool LLM call.');
173
+ log(`[demo] Keep this process alive while you inspect ${serverInfo.url}`);
174
+
175
+ await waitForDashboardExit(serverInfo.url);
176
+ }
177
+
178
+ function openBrowser(url) {
179
+ if (!process.stdout.isTTY || process.env.CI || process.env.LOUPE_OPEN_BROWSER === '0') {
180
+ return;
181
+ }
182
+
183
+ const command =
184
+ process.platform === 'darwin'
185
+ ? ['open', [url]]
186
+ : process.platform === 'win32'
187
+ ? ['cmd', ['/c', 'start', '', url]]
188
+ : process.platform === 'linux'
189
+ ? ['xdg-open', [url]]
190
+ : null;
191
+
192
+ if (!command) {
193
+ return;
194
+ }
195
+
196
+ try {
197
+ const child = spawn(command[0], command[1], {
198
+ detached: true,
199
+ stdio: 'ignore',
200
+ });
201
+ child.on('error', () => {});
202
+ child.unref();
203
+ } catch (_error) {
204
+ // Ignore browser launch failures. The dashboard URL is already printed.
205
+ }
206
+ }
207
+
208
+ async function waitForDashboardExit(url) {
209
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
210
+ log(`[demo] Non-interactive terminal detected. Leaving the dashboard up for ${Math.round(DEMO_TIMEOUT_MS / 1000)} seconds: ${url}`);
211
+ await new Promise((resolve) => setTimeout(resolve, DEMO_TIMEOUT_MS));
212
+ return;
213
+ }
214
+
215
+ const rl = readline.createInterface({
216
+ input: process.stdin,
217
+ output: process.stdout,
218
+ });
219
+
220
+ try {
221
+ await rl.question('[demo] Press Enter to stop the demo and close the dashboard.\n');
222
+ } finally {
223
+ rl.close();
224
+ }
225
+ }
226
+
227
+ function log(message) {
228
+ process.stdout.write(`${message}\n`);
229
+ }
230
+
231
+ main().catch((error) => {
232
+ process.stderr.write(`[demo] ${error.message}\n`);
233
+ process.exitCode = 1;
234
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mtharrison/loupe",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Lightweight local tracing dashboard for LLM calls",
5
5
  "author": "Matt Harrison",
6
6
  "license": "MIT",