@mtharrison/loupe 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -41
- package/assets/screenshot1.png +0 -0
- package/assets/screenshot2.png +0 -0
- package/dist/client/app.css +365 -263
- package/dist/client/app.js +815 -658
- package/dist/index.d.ts +7 -8
- package/dist/index.js +392 -49
- package/dist/server.d.ts +1 -0
- package/dist/server.js +42 -11
- package/dist/session-nav.d.ts +10 -0
- package/dist/session-nav.js +91 -0
- package/dist/store.d.ts +6 -7
- package/dist/store.js +203 -45
- package/dist/types.d.ts +62 -9
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +14 -0
- package/examples/nested-tool-call.js +234 -0
- package/examples/openai-multiturn-tools.js +399 -0
- package/package.json +3 -1
package/dist/session-nav.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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);
|
|
21
26
|
if (!trace) {
|
|
22
27
|
return;
|
|
23
28
|
}
|
|
24
|
-
|
|
25
|
-
trace.
|
|
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('
|
|
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();
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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;
|