@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.
@@ -1,11 +1,96 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.deriveSessionNavItems = deriveSessionNavItems;
4
+ exports.sortSessionNodesForNav = sortSessionNodesForNav;
5
+ exports.findSessionNodePath = findSessionNodePath;
6
+ exports.findSessionNodeById = findSessionNodeById;
7
+ exports.getNewestTraceIdForNode = getNewestTraceIdForNode;
8
+ exports.resolveSessionTreeSelection = resolveSessionTreeSelection;
9
+ exports.getDefaultExpandedSessionTreeNodeIds = getDefaultExpandedSessionTreeNodeIds;
4
10
  function deriveSessionNavItems(sessionNodes, traceById) {
5
11
  return sessionNodes
6
12
  .map((node) => deriveSessionNavItem(node, traceById))
7
13
  .sort(compareSessionNavItems);
8
14
  }
15
+ function sortSessionNodesForNav(sessionNodes, traceById) {
16
+ const itemById = new Map(sessionNodes.map((node) => [node.id, deriveSessionNavItem(node, traceById)]));
17
+ return sessionNodes
18
+ .slice()
19
+ .sort((left, right) => compareSessionNavItems(itemById.get(left.id), itemById.get(right.id)));
20
+ }
21
+ function findSessionNodePath(nodes, id, trail = []) {
22
+ for (const node of nodes) {
23
+ const nextTrail = [...trail, node];
24
+ if (node.id === id) {
25
+ return nextTrail;
26
+ }
27
+ const childTrail = findSessionNodePath(node.children, id, nextTrail);
28
+ if (childTrail.length) {
29
+ return childTrail;
30
+ }
31
+ }
32
+ return [];
33
+ }
34
+ function findSessionNodeById(nodes, id) {
35
+ return findSessionNodePath(nodes, id).at(-1) ?? null;
36
+ }
37
+ function getNewestTraceIdForNode(node) {
38
+ if (!node?.traceIds.length) {
39
+ return null;
40
+ }
41
+ if (typeof node.meta?.traceId === "string" && node.meta.traceId) {
42
+ return node.meta.traceId;
43
+ }
44
+ return node.traceIds[0] || null;
45
+ }
46
+ function resolveSessionTreeSelection(sessionNodes, selectedNodeId, selectedTraceId) {
47
+ const selectedNode = selectedNodeId
48
+ ? findSessionNodeById(sessionNodes, selectedNodeId)
49
+ : null;
50
+ const selectedTraceNode = selectedTraceId
51
+ ? findSessionNodeById(sessionNodes, `trace:${selectedTraceId}`)
52
+ : null;
53
+ const fallbackNode = selectedNode ?? selectedTraceNode ?? sessionNodes[0] ?? null;
54
+ if (!fallbackNode) {
55
+ return {
56
+ selectedNodeId: null,
57
+ selectedTraceId: null,
58
+ };
59
+ }
60
+ const nextSelectedNodeId = selectedNode?.id ?? fallbackNode.id;
61
+ const nextSelectedTraceId = selectedTraceId && fallbackNode.traceIds.includes(selectedTraceId)
62
+ ? selectedTraceId
63
+ : getNewestTraceIdForNode(fallbackNode);
64
+ return {
65
+ selectedNodeId: nextSelectedNodeId,
66
+ selectedTraceId: nextSelectedTraceId,
67
+ };
68
+ }
69
+ function getDefaultExpandedSessionTreeNodeIds(sessionNodes, activeSessionId, selectedNodeId) {
70
+ const expanded = new Set();
71
+ const activeSession = (activeSessionId
72
+ ? sessionNodes.find((node) => node.id === activeSessionId) ?? null
73
+ : null) ?? sessionNodes[0] ?? null;
74
+ if (!activeSession) {
75
+ return expanded;
76
+ }
77
+ if (activeSession.children.length) {
78
+ expanded.add(activeSession.id);
79
+ }
80
+ visitSessionTree(activeSession.children, (node) => {
81
+ if (node.children.length && node.type === "actor") {
82
+ expanded.add(node.id);
83
+ }
84
+ });
85
+ if (selectedNodeId) {
86
+ for (const node of findSessionNodePath([activeSession], selectedNodeId)) {
87
+ if (node.children.length) {
88
+ expanded.add(node.id);
89
+ }
90
+ }
91
+ }
92
+ return expanded;
93
+ }
9
94
  function deriveSessionNavItem(node, traceById) {
10
95
  const traces = node.traceIds
11
96
  .map((traceId) => traceById.get(traceId))
@@ -130,3 +215,9 @@ function formatCompactTimestamp(value) {
130
215
  minute: "2-digit",
131
216
  });
132
217
  }
218
+ function visitSessionTree(nodes, visitor) {
219
+ for (const node of nodes) {
220
+ visitor(node);
221
+ visitSessionTree(node.children, visitor);
222
+ }
223
+ }
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 = {
@@ -202,6 +238,24 @@ export interface ChatModelLike<TInput = any, TOptions = any, TValue = any, TChun
202
238
  invoke(input: TInput, options?: TOptions): Promise<TValue>;
203
239
  stream(input: TInput, options?: TOptions): AsyncGenerator<TChunk>;
204
240
  }
241
+ export type OpenAIChatCompletionCreateParamsLike = Record<string, any> & {
242
+ messages?: Record<string, any>[];
243
+ model?: string | null;
244
+ stream?: boolean | null;
245
+ };
246
+ export interface OpenAIChatCompletionStreamLike<TChunk = any> extends AsyncIterable<TChunk> {
247
+ [Symbol.asyncIterator](): AsyncIterator<TChunk>;
248
+ }
249
+ export interface OpenAIChatCompletionsLike<TParams = OpenAIChatCompletionCreateParamsLike, TOptions = Record<string, any>, TResponse = any, TChunk = any> {
250
+ create(params: TParams, options?: TOptions): Promise<TResponse> | Promise<OpenAIChatCompletionStreamLike<TChunk>> | OpenAIChatCompletionStreamLike<TChunk>;
251
+ }
252
+ export interface OpenAIClientLike<TParams = OpenAIChatCompletionCreateParamsLike, TOptions = Record<string, any>, TResponse = any, TChunk = any> {
253
+ chat: {
254
+ completions: OpenAIChatCompletionsLike<TParams, TOptions, TResponse, TChunk>;
255
+ [key: string]: any;
256
+ };
257
+ [key: string]: any;
258
+ }
205
259
  export type TraceServer = {
206
260
  broadcast(event: TraceEvent | UIReloadEvent): void;
207
261
  close(): void;
@@ -215,14 +269,13 @@ export type UIWatchController = {
215
269
  stop(): Promise<void>;
216
270
  };
217
271
  export type LocalLLMTracer = {
272
+ addSpanEvent(spanId: string, event: SpanEventInput): void;
218
273
  configure(config?: TraceConfig): void;
274
+ endSpan(spanId: string, response?: unknown): void;
219
275
  isEnabled(): boolean;
220
- recordError(traceId: string, error: unknown): void;
221
- recordInvokeFinish(traceId: string, response: unknown): void;
222
- recordInvokeStart(context: TraceContext, request: TraceRequest): string;
223
- recordStreamChunk(traceId: string, chunk: unknown): void;
224
- recordStreamFinish(traceId: string, chunk: unknown): void;
225
- 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;
226
279
  startServer(): Promise<{
227
280
  host: string;
228
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;