@mtharrison/loupe 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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);
26
+ if (!trace) {
27
+ return;
28
+ }
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);
21
36
  if (!trace) {
22
37
  return;
23
38
  }
24
- trace.response = (0, utils_1.safeClone)(response);
25
- trace.usage = (0, utils_1.safeClone)(response?.usage);
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();
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);
67
111
  }
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;
77
- }
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);
@@ -111,51 +141,49 @@ class TraceStore extends node_events_1.EventEmitter {
111
141
  };
112
142
  }
113
143
  const roots = new Map();
144
+ const traceBySpanId = new Map();
145
+ const traceNodeByTraceId = new Map();
146
+ const traceSessionByTraceId = new Map();
147
+ const parentNodeById = new Map();
114
148
  for (const trace of traces) {
115
- const sessionId = trace.hierarchy.sessionId || 'unknown-session';
149
+ const sessionId = getTraceSessionId(trace);
116
150
  const sessionNode = getOrCreateNode(roots, `session:${sessionId}`, 'session', `Session ${sessionId}`, {
117
151
  sessionId,
118
152
  chatId: trace.hierarchy.chatId,
119
- });
120
- const lineage = [sessionNode];
121
- const rootActorId = trace.hierarchy.rootActorId || 'unknown-actor';
122
- const actorNode = getOrCreateNode(sessionNode.children, `actor:${sessionId}:${rootActorId}`, 'actor', rootActorId, {
123
- actorId: rootActorId,
124
- rootActorId,
125
- sessionId,
153
+ rootActorId: trace.hierarchy.rootActorId,
126
154
  topLevelAgentId: trace.hierarchy.topLevelAgentId,
127
155
  });
128
- lineage.push(actorNode);
129
- let currentNode = actorNode;
130
- if (trace.hierarchy.kind === 'guardrail') {
131
- const label = `${trace.hierarchy.guardrailPhase || 'guardrail'} guardrail`;
132
- currentNode = getOrCreateNode(currentNode.children, `guardrail:${sessionId}:${rootActorId}:${trace.context.guardrailType || label}`, 'guardrail', label, {
133
- guardrailPhase: trace.hierarchy.guardrailPhase || null,
134
- guardrailType: trace.context.guardrailType || null,
135
- systemType: trace.context.systemType || null,
136
- watchdogPhase: trace.hierarchy.watchdogPhase || null,
137
- });
138
- lineage.push(currentNode);
139
- }
140
- else if (trace.hierarchy.childActorId) {
141
- currentNode = getOrCreateNode(currentNode.children, `child-actor:${sessionId}:${rootActorId}:${trace.hierarchy.childActorId}`, 'child-actor', trace.hierarchy.childActorId, {
142
- actorId: trace.hierarchy.childActorId,
143
- childActorId: trace.hierarchy.childActorId,
144
- delegatedAgentId: trace.hierarchy.delegatedAgentId,
145
- });
146
- lineage.push(currentNode);
156
+ const traceNode = createTraceNode(trace);
157
+ traceBySpanId.set(trace.spanContext.spanId, trace);
158
+ traceNodeByTraceId.set(trace.id, traceNode);
159
+ traceSessionByTraceId.set(trace.id, sessionId);
160
+ }
161
+ for (const trace of traces) {
162
+ const sessionId = traceSessionByTraceId.get(trace.id) || 'unknown-session';
163
+ const sessionNode = roots.get(`session:${sessionId}`);
164
+ const traceNode = traceNodeByTraceId.get(trace.id);
165
+ if (!sessionNode || !traceNode) {
166
+ continue;
147
167
  }
148
- if (trace.hierarchy.stage) {
149
- currentNode = getOrCreateNode(currentNode.children, `stage:${sessionId}:${rootActorId}:${trace.hierarchy.childActorId || 'root'}:${trace.hierarchy.stage}`, 'stage', trace.hierarchy.stage, {
150
- stage: trace.hierarchy.stage,
151
- workflowState: trace.hierarchy.workflowState,
152
- });
153
- lineage.push(currentNode);
168
+ const parentTrace = trace.parentSpanId ? traceBySpanId.get(trace.parentSpanId) : null;
169
+ const parentTraceNode = parentTrace &&
170
+ traceSessionByTraceId.get(parentTrace.id) === sessionId &&
171
+ parentTrace.id !== trace.id
172
+ ? traceNodeByTraceId.get(parentTrace.id) || null
173
+ : null;
174
+ parentNodeById.set(traceNode.id, parentTraceNode || sessionNode);
175
+ }
176
+ for (const trace of traces) {
177
+ const traceNode = traceNodeByTraceId.get(trace.id);
178
+ const parentNode = traceNode ? parentNodeById.get(traceNode.id) || null : null;
179
+ if (!traceNode || !parentNode) {
180
+ continue;
154
181
  }
155
- const traceNode = createTraceNode(trace);
156
- currentNode.children.set(traceNode.id, traceNode);
157
- for (const node of new Set(lineage)) {
158
- applyTraceRollup(node, trace);
182
+ parentNode.children.set(traceNode.id, traceNode);
183
+ let currentNode = parentNode;
184
+ while (currentNode) {
185
+ applyTraceRollup(currentNode, trace);
186
+ currentNode = parentNodeById.get(currentNode.id) || null;
159
187
  }
160
188
  }
161
189
  return {
@@ -164,19 +192,24 @@ class TraceStore extends node_events_1.EventEmitter {
164
192
  rootNodes: [...roots.values()].map(serialiseNode),
165
193
  };
166
194
  }
167
- recordStart(mode, context, request) {
168
- const traceContext = (0, utils_1.normalizeTraceContext)(context, mode);
195
+ recordStart(mode, context, request, options = {}) {
196
+ const traceContext = applyConversationIdToContext((0, utils_1.normalizeTraceContext)(context, mode), options.attributes);
169
197
  const traceId = randomId();
198
+ const parentSpan = this.findTraceBySpanReference(options.parentSpanId);
170
199
  const startedAt = new Date().toISOString();
171
200
  const trace = {
201
+ attributes: buildSpanAttributes(traceContext, mode, request, options.attributes),
172
202
  context: traceContext,
173
203
  endedAt: null,
174
204
  error: null,
205
+ events: [],
175
206
  hierarchy: traceContext.hierarchy,
176
207
  id: traceId,
177
208
  kind: traceContext.kind,
178
209
  mode,
179
210
  model: traceContext.model,
211
+ name: options.name || getDefaultSpanName(traceContext, mode),
212
+ parentSpanId: parentSpan?.spanContext.spanId || options.parentSpanId || null,
180
213
  provider: traceContext.provider,
181
214
  request: {
182
215
  input: (0, utils_1.safeClone)(request?.input),
@@ -187,6 +220,15 @@ class TraceStore extends node_events_1.EventEmitter {
187
220
  },
188
221
  response: null,
189
222
  startedAt,
223
+ spanContext: {
224
+ // The returned Loupe span handle (trace.id) is used for local mutation and SSE updates.
225
+ // spanContext contains the OpenTelemetry trace/span identifiers that are attached to
226
+ // the exported span payload and inherited by child spans.
227
+ spanId: randomHexId(16),
228
+ traceId: parentSpan?.spanContext.traceId || randomHexId(32),
229
+ },
230
+ spanKind: options.kind || 'CLIENT',
231
+ spanStatus: { code: 'UNSET' },
190
232
  status: 'pending',
191
233
  stream: mode === 'stream'
192
234
  ? {
@@ -206,9 +248,24 @@ class TraceStore extends node_events_1.EventEmitter {
206
248
  this.order.push(traceId);
207
249
  this.traces.set(traceId, trace);
208
250
  this.evictIfNeeded();
209
- this.publish('trace:add', traceId, { trace: this.cloneTrace(trace) });
251
+ this.publish('span:start', traceId, { trace: this.cloneTrace(trace) });
210
252
  return traceId;
211
253
  }
254
+ findTraceBySpanReference(spanReference) {
255
+ if (!spanReference) {
256
+ return null;
257
+ }
258
+ const byTraceId = this.traces.get(spanReference);
259
+ if (byTraceId) {
260
+ return byTraceId;
261
+ }
262
+ for (const trace of this.traces.values()) {
263
+ if (trace.spanContext.spanId === spanReference) {
264
+ return trace;
265
+ }
266
+ }
267
+ return null;
268
+ }
212
269
  evictIfNeeded() {
213
270
  while (this.order.length > this.maxTraces) {
214
271
  const oldest = this.order.shift();
@@ -216,7 +273,7 @@ class TraceStore extends node_events_1.EventEmitter {
216
273
  if (oldest) {
217
274
  this.traces.delete(oldest);
218
275
  }
219
- this.publish('trace:evict', oldest || null, removed ? { trace: this.cloneTrace(removed) } : {});
276
+ this.publish('span:evict', oldest || null, removed ? { trace: this.cloneTrace(removed) } : {});
220
277
  }
221
278
  }
222
279
  cloneTrace(trace) {
@@ -273,8 +330,9 @@ class TraceStore extends node_events_1.EventEmitter {
273
330
  }
274
331
  publish(type, traceId, payload) {
275
332
  const event = {
333
+ span: payload.trace,
334
+ spanId: traceId,
276
335
  type,
277
- traceId,
278
336
  timestamp: new Date().toISOString(),
279
337
  ...payload,
280
338
  };
@@ -375,6 +433,151 @@ function buildGroupHierarchy(traces, groupBy) {
375
433
  }
376
434
  return [...groups.values()].map(serialiseNode);
377
435
  }
436
+ function getTraceSessionId(trace) {
437
+ const conversationId = toNonEmptyString(trace.attributes?.['gen_ai.conversation.id']);
438
+ return conversationId || trace.hierarchy.sessionId || 'unknown-session';
439
+ }
440
+ function applyConversationIdToContext(context, extraAttributes) {
441
+ const conversationId = toNonEmptyString(extraAttributes?.['gen_ai.conversation.id']);
442
+ if (!conversationId || conversationId === context.sessionId) {
443
+ return context;
444
+ }
445
+ return {
446
+ ...context,
447
+ sessionId: conversationId,
448
+ chatId: conversationId,
449
+ rootSessionId: context.rootSessionId || conversationId,
450
+ rootChatId: context.rootChatId || conversationId,
451
+ tags: {
452
+ ...context.tags,
453
+ sessionId: conversationId,
454
+ chatId: conversationId,
455
+ rootSessionId: context.rootSessionId || conversationId,
456
+ rootChatId: context.rootChatId || conversationId,
457
+ },
458
+ hierarchy: {
459
+ ...context.hierarchy,
460
+ sessionId: conversationId,
461
+ chatId: conversationId,
462
+ },
463
+ };
464
+ }
378
465
  function randomId() {
379
466
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
380
467
  }
468
+ function randomHexId(length) {
469
+ if (length % 2 !== 0) {
470
+ throw new RangeError(`OpenTelemetry hex IDs must have an even length. Received: ${length}`);
471
+ }
472
+ return (0, node_crypto_1.randomBytes)(Math.ceil(length / 2)).toString('hex').slice(0, length);
473
+ }
474
+ function normalizeSpanEvent(event) {
475
+ const attributes = (0, utils_1.safeClone)(event.attributes || {});
476
+ if (event.name === 'stream.finish') {
477
+ attributes['gen_ai.message.status'] = attributes['gen_ai.message.status'] || 'completed';
478
+ }
479
+ else if (event.name.startsWith(STREAM_EVENT_NAME_PREFIX)) {
480
+ attributes['gen_ai.message.status'] = attributes['gen_ai.message.status'] || 'in_progress';
481
+ }
482
+ if (attributes.finish_reasons && attributes['gen_ai.response.finish_reasons'] === undefined) {
483
+ attributes['gen_ai.response.finish_reasons'] = (0, utils_1.safeClone)(attributes.finish_reasons);
484
+ }
485
+ if (attributes.message && attributes['gen_ai.output.messages'] === undefined) {
486
+ attributes['gen_ai.output.messages'] = [(0, utils_1.safeClone)(attributes.message)];
487
+ }
488
+ return {
489
+ attributes,
490
+ name: event.name,
491
+ timestamp: new Date().toISOString(),
492
+ };
493
+ }
494
+ function toStreamPayload(payload, spanEvent) {
495
+ if (payload && typeof payload === 'object') {
496
+ return (0, utils_1.safeClone)(payload);
497
+ }
498
+ // Generic addSpanEvent() callers may only provide an OpenTelemetry-style event name
499
+ // plus attributes. Reconstruct the minimal legacy stream payload shape from that data
500
+ // so the existing dashboard stream timeline can continue to render incrementally.
501
+ const suffix = spanEvent.name.startsWith(STREAM_EVENT_NAME_PREFIX)
502
+ ? spanEvent.name.slice(STREAM_EVENT_NAME_PREFIX.length)
503
+ : spanEvent.name;
504
+ const eventType = suffix || 'event';
505
+ return {
506
+ ...(0, utils_1.safeClone)(spanEvent.attributes || {}),
507
+ type: eventType,
508
+ };
509
+ }
510
+ function getDefaultSpanName(context, mode) {
511
+ const prefix = context.provider || 'llm';
512
+ return `${prefix}.${mode}`;
513
+ }
514
+ function toNonEmptyString(value) {
515
+ return typeof value === 'string' && value.trim() ? value : null;
516
+ }
517
+ function buildSpanAttributes(context, mode, request, extraAttributes) {
518
+ const base = {
519
+ 'gen_ai.conversation.id': context.sessionId || undefined,
520
+ 'gen_ai.input.messages': Array.isArray(request?.input?.messages) ? (0, utils_1.safeClone)(request.input.messages) : undefined,
521
+ 'gen_ai.operation.name': inferGenAIOperationName(request, mode),
522
+ 'gen_ai.provider.name': context.provider || undefined,
523
+ 'gen_ai.request.choice.count': typeof request?.input?.n === 'number' ? request.input.n : undefined,
524
+ 'gen_ai.request.model': (typeof request?.input?.model === 'string' && request.input.model) || context.model || undefined,
525
+ 'gen_ai.system': context.provider || undefined,
526
+ 'loupe.actor.id': context.actorId || undefined,
527
+ 'loupe.actor.type': context.actorType || undefined,
528
+ 'loupe.guardrail.phase': context.guardrailPhase || undefined,
529
+ 'loupe.guardrail.type': context.guardrailType || undefined,
530
+ 'loupe.root_actor.id': context.rootActorId || undefined,
531
+ 'loupe.root_session.id': context.rootSessionId || undefined,
532
+ 'loupe.session.id': context.sessionId || undefined,
533
+ 'loupe.stage': context.stage || undefined,
534
+ 'loupe.tenant.id': context.tenantId || undefined,
535
+ 'loupe.user.id': context.userId || undefined,
536
+ };
537
+ for (const [key, value] of Object.entries(context.tags || {})) {
538
+ base[`loupe.tag.${key}`] = value;
539
+ }
540
+ return Object.fromEntries(Object.entries({
541
+ ...base,
542
+ ...(extraAttributes || {}),
543
+ }).filter(([, value]) => value !== undefined && value !== null));
544
+ }
545
+ function applyResponseAttributes(trace, response) {
546
+ const finishReasons = Array.isArray(response?.finish_reasons)
547
+ ? response.finish_reasons
548
+ : response?.finish_reason
549
+ ? [response.finish_reason]
550
+ : [];
551
+ const usage = response?.usage;
552
+ if (typeof response?.model === 'string' && response.model) {
553
+ trace.attributes['gen_ai.response.model'] = response.model;
554
+ }
555
+ if (usage?.tokens?.prompt !== undefined) {
556
+ trace.attributes['gen_ai.usage.input_tokens'] = usage.tokens.prompt;
557
+ }
558
+ if (usage?.tokens?.completion !== undefined) {
559
+ trace.attributes['gen_ai.usage.output_tokens'] = usage.tokens.completion;
560
+ }
561
+ if (finishReasons.length > 0) {
562
+ trace.attributes['gen_ai.response.finish_reasons'] = (0, utils_1.safeClone)(finishReasons);
563
+ }
564
+ if (response?.message) {
565
+ trace.attributes['gen_ai.output.messages'] = [(0, utils_1.safeClone)(response.message)];
566
+ trace.attributes['gen_ai.output.type'] = inferGenAIOutputType(response.message.content);
567
+ }
568
+ }
569
+ function inferGenAIOperationName(request, mode) {
570
+ if (Array.isArray(request?.input?.messages) && request.input.messages.length > 0) {
571
+ return 'chat';
572
+ }
573
+ return mode;
574
+ }
575
+ function inferGenAIOutputType(content) {
576
+ if (typeof content === 'string' || Array.isArray(content)) {
577
+ return 'text';
578
+ }
579
+ if (content && typeof content === 'object') {
580
+ return 'json';
581
+ }
582
+ return 'unknown';
583
+ }
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;