@mtharrison/loupe 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -34
- package/dist/client/app.css +115 -20
- package/dist/client/app.js +183 -122
- package/dist/index.d.ts +6 -8
- package/dist/index.js +81 -66
- package/dist/session-nav.d.ts +1 -1
- package/dist/session-nav.js +8 -1
- package/dist/store.d.ts +7 -7
- package/dist/store.js +286 -83
- package/dist/types.d.ts +44 -9
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +14 -0
- package/examples/nested-tool-call.js +234 -0
- package/package.json +1 -1
package/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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
trace.
|
|
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('
|
|
55
|
+
this.publish('span:end', spanId, { trace: this.cloneTrace(trace) });
|
|
29
56
|
}
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
if (!trace || !trace.stream) {
|
|
80
|
+
applyStreamPayload(trace, payload, spanEvent) {
|
|
81
|
+
if (!trace.stream) {
|
|
36
82
|
return;
|
|
37
83
|
}
|
|
38
|
-
const clone = (
|
|
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 (
|
|
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
|
|
49
|
-
trace.stream.reconstructed.message.content = `${trace.stream.reconstructed.message.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 (
|
|
53
|
-
trace.stream.reconstructed.message.role =
|
|
98
|
+
if (clone?.type === 'begin') {
|
|
99
|
+
trace.stream.reconstructed.message.role = clone.role;
|
|
54
100
|
}
|
|
55
|
-
if (
|
|
101
|
+
if (clone?.type === 'finish') {
|
|
56
102
|
trace.response = clone;
|
|
57
|
-
trace.usage = (0, utils_1.safeClone)(
|
|
103
|
+
trace.usage = (0, utils_1.safeClone)(clone.usage);
|
|
58
104
|
trace.stream.reconstructed.message = {
|
|
59
|
-
...((0, utils_1.safeClone)(
|
|
105
|
+
...((0, utils_1.safeClone)(clone.message) || {}),
|
|
60
106
|
content: trace.stream.reconstructed.message.content ||
|
|
61
|
-
(typeof
|
|
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)(
|
|
64
|
-
trace.stream.reconstructed.usage = (0, utils_1.safeClone)(
|
|
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('
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
currentNode
|
|
157
|
-
|
|
158
|
-
applyTraceRollup(
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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;
|