@mtharrison/loupe 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -34
- package/dist/client/app.js +12 -10
- package/dist/index.d.ts +6 -8
- package/dist/index.js +81 -66
- package/dist/store.d.ts +6 -7
- package/dist/store.js +203 -45
- 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/README.md
CHANGED
|
@@ -125,9 +125,28 @@ Supported demo environment variables: `OPENAI_MODEL`, `LLM_TRACE_PORT`, `LOUPE_O
|
|
|
125
125
|
|
|
126
126
|
The script tries to open the dashboard automatically and prints the local URL either way. Set `LOUPE_OPEN_BROWSER=0` if you want to suppress the browser launch.
|
|
127
127
|
|
|
128
|
+
### Runnable Nested Tool-Call Demo
|
|
129
|
+
|
|
130
|
+
`examples/nested-tool-call.js` is a credential-free demo that:
|
|
131
|
+
|
|
132
|
+
- starts the Loupe dashboard eagerly
|
|
133
|
+
- wraps a root assistant model and a nested tool model
|
|
134
|
+
- invokes the nested tool model from inside the parent model call
|
|
135
|
+
- shows parent/child spans linked on the same trace
|
|
136
|
+
|
|
137
|
+
Run it with:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
npm install
|
|
141
|
+
export LLM_TRACE_ENABLED=1
|
|
142
|
+
node examples/nested-tool-call.js
|
|
143
|
+
```
|
|
144
|
+
|
|
128
145
|
## Low-Level Lifecycle API
|
|
129
146
|
|
|
130
|
-
If you need full control over trace boundaries, Loupe
|
|
147
|
+
If you need full control over trace boundaries, Loupe exposes a lower-level span lifecycle API modeled on OpenTelemetry concepts: start a span, add events, end it, and record exceptions.
|
|
148
|
+
|
|
149
|
+
Loupe stores GenAI span attributes using the OpenTelemetry semantic convention names where they apply, including `gen_ai.request.model`, `gen_ai.response.model`, `gen_ai.system`, `gen_ai.provider.name`, `gen_ai.operation.name`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, and `gen_ai.conversation.id`.
|
|
131
150
|
|
|
132
151
|
Start the dashboard during app startup, then instrument a model call:
|
|
133
152
|
|
|
@@ -135,9 +154,9 @@ Start the dashboard during app startup, then instrument a model call:
|
|
|
135
154
|
import {
|
|
136
155
|
getLocalLLMTracer,
|
|
137
156
|
isTraceEnabled,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
157
|
+
endSpan,
|
|
158
|
+
recordException,
|
|
159
|
+
startSpan,
|
|
141
160
|
type TraceContext,
|
|
142
161
|
} from '@mtharrison/loupe';
|
|
143
162
|
|
|
@@ -166,51 +185,63 @@ const request = {
|
|
|
166
185
|
options: {},
|
|
167
186
|
};
|
|
168
187
|
|
|
169
|
-
const
|
|
188
|
+
const spanId = startSpan(context, {
|
|
189
|
+
mode: 'invoke',
|
|
190
|
+
name: 'openai.chat.completions',
|
|
191
|
+
request,
|
|
192
|
+
});
|
|
170
193
|
|
|
171
194
|
try {
|
|
172
195
|
const response = await model.invoke(request.input, request.options);
|
|
173
|
-
|
|
196
|
+
endSpan(spanId, response);
|
|
174
197
|
return response;
|
|
175
198
|
} catch (error) {
|
|
176
|
-
|
|
199
|
+
recordException(spanId, error);
|
|
177
200
|
throw error;
|
|
178
201
|
}
|
|
179
202
|
```
|
|
180
203
|
|
|
181
204
|
### Streaming
|
|
182
205
|
|
|
183
|
-
Streaming works the same way. Loupe records each
|
|
206
|
+
Streaming works the same way. Loupe records each span event, first-chunk latency, and the reconstructed final response.
|
|
184
207
|
|
|
185
208
|
```ts
|
|
186
209
|
import {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
210
|
+
addSpanEvent,
|
|
211
|
+
endSpan,
|
|
212
|
+
recordException,
|
|
213
|
+
startSpan,
|
|
191
214
|
} from '@mtharrison/loupe';
|
|
192
215
|
|
|
193
|
-
const
|
|
216
|
+
const spanId = startSpan(context, {
|
|
217
|
+
mode: 'stream',
|
|
218
|
+
name: 'openai.chat.completions',
|
|
219
|
+
request,
|
|
220
|
+
});
|
|
194
221
|
|
|
195
222
|
try {
|
|
196
223
|
for await (const chunk of model.stream(request.input, request.options)) {
|
|
197
224
|
if (chunk?.type === 'finish') {
|
|
198
|
-
|
|
225
|
+
endSpan(spanId, chunk);
|
|
199
226
|
} else {
|
|
200
|
-
|
|
227
|
+
addSpanEvent(spanId, {
|
|
228
|
+
name: `stream.${chunk?.type || 'event'}`,
|
|
229
|
+
attributes: chunk,
|
|
230
|
+
payload: chunk,
|
|
231
|
+
});
|
|
201
232
|
}
|
|
202
233
|
|
|
203
234
|
yield chunk;
|
|
204
235
|
}
|
|
205
236
|
} catch (error) {
|
|
206
|
-
|
|
237
|
+
recordException(spanId, error);
|
|
207
238
|
throw error;
|
|
208
239
|
}
|
|
209
240
|
```
|
|
210
241
|
|
|
211
242
|
## Trace Context
|
|
212
243
|
|
|
213
|
-
Loupe gets its hierarchy and filters from the context you pass to `
|
|
244
|
+
Loupe gets its hierarchy and filters from the context you pass to `startSpan()`.
|
|
214
245
|
|
|
215
246
|
### Generic context fields
|
|
216
247
|
|
|
@@ -322,7 +353,7 @@ Programmatic configuration is also available through `getLocalLLMTracer(config)`
|
|
|
322
353
|
|
|
323
354
|
## API
|
|
324
355
|
|
|
325
|
-
Loupe exposes both low-level lifecycle functions and lightweight wrappers.
|
|
356
|
+
Loupe exposes both low-level span lifecycle functions and lightweight wrappers.
|
|
326
357
|
|
|
327
358
|
### `isTraceEnabled()`
|
|
328
359
|
|
|
@@ -340,29 +371,21 @@ Returns the singleton tracer instance. This is useful if you want to:
|
|
|
340
371
|
|
|
341
372
|
Starts the local dashboard server eagerly instead of waiting for the first trace.
|
|
342
373
|
|
|
343
|
-
### `
|
|
344
|
-
|
|
345
|
-
Creates an `invoke` trace and returns a `traceId`.
|
|
346
|
-
|
|
347
|
-
### `recordInvokeFinish(traceId, response, config?)`
|
|
348
|
-
|
|
349
|
-
Marks an `invoke` trace as complete and stores the response payload.
|
|
350
|
-
|
|
351
|
-
### `recordStreamStart(context, request, config?)`
|
|
374
|
+
### `startSpan(context, options?, config?)`
|
|
352
375
|
|
|
353
|
-
Creates a `
|
|
376
|
+
Creates a span and returns its Loupe `spanId`. Pass `mode`, `name`, and `request` in `options` to describe the operation. Nested spans are linked automatically when wrapped calls invoke other wrapped calls in the same async flow.
|
|
354
377
|
|
|
355
|
-
### `
|
|
378
|
+
### `addSpanEvent(spanId, event, config?)`
|
|
356
379
|
|
|
357
|
-
Appends
|
|
380
|
+
Appends an event to an existing span. For streaming traces, pass the raw chunk as `event.payload` to preserve chunk reconstruction in the UI.
|
|
358
381
|
|
|
359
|
-
### `
|
|
382
|
+
### `endSpan(spanId, response, config?)`
|
|
360
383
|
|
|
361
|
-
|
|
384
|
+
Marks a span as complete and stores the final response payload.
|
|
362
385
|
|
|
363
|
-
### `
|
|
386
|
+
### `recordException(spanId, error, config?)`
|
|
364
387
|
|
|
365
|
-
Marks a
|
|
388
|
+
Marks a span as failed and stores a serialized exception payload.
|
|
366
389
|
|
|
367
390
|
All of these functions forward to the singleton tracer returned by `getLocalLLMTracer()`.
|
|
368
391
|
|
package/dist/client/app.js
CHANGED
|
@@ -21395,34 +21395,36 @@ function App() {
|
|
|
21395
21395
|
});
|
|
21396
21396
|
const handleSseMessage = (0, import_react3.useEffectEvent)((data2) => {
|
|
21397
21397
|
const payload = parseEvent(data2);
|
|
21398
|
+
const nextTrace = payload?.span;
|
|
21399
|
+
const nextTraceId = payload?.spanId;
|
|
21398
21400
|
if (payload?.type === "ui:reload") {
|
|
21399
21401
|
window.location.reload();
|
|
21400
21402
|
return;
|
|
21401
21403
|
}
|
|
21402
|
-
if (
|
|
21403
|
-
(0, import_react3.startTransition)(() => setDetail(
|
|
21404
|
+
if (nextTrace && selectedTraceId && nextTraceId === selectedTraceId) {
|
|
21405
|
+
(0, import_react3.startTransition)(() => setDetail(nextTrace));
|
|
21404
21406
|
}
|
|
21405
21407
|
if (!queryString) {
|
|
21406
|
-
if (payload?.type === "
|
|
21407
|
-
applyIncrementalTraceUpdate(
|
|
21408
|
+
if ((payload?.type === "span:update" || payload?.type === "span:end") && nextTrace) {
|
|
21409
|
+
applyIncrementalTraceUpdate(nextTrace);
|
|
21408
21410
|
return;
|
|
21409
21411
|
}
|
|
21410
|
-
if (payload?.type === "
|
|
21411
|
-
applyIncrementalTraceAdd(
|
|
21412
|
+
if (payload?.type === "span:start" && nextTrace) {
|
|
21413
|
+
applyIncrementalTraceAdd(nextTrace);
|
|
21412
21414
|
scheduleRefresh(180);
|
|
21413
21415
|
return;
|
|
21414
21416
|
}
|
|
21415
|
-
if (payload?.type === "
|
|
21416
|
-
applyIncrementalTraceEvict(
|
|
21417
|
+
if (payload?.type === "span:evict") {
|
|
21418
|
+
applyIncrementalTraceEvict(nextTraceId);
|
|
21417
21419
|
scheduleRefresh(180);
|
|
21418
21420
|
return;
|
|
21419
21421
|
}
|
|
21420
|
-
if (payload?.type === "
|
|
21422
|
+
if (payload?.type === "span:clear") {
|
|
21421
21423
|
clearIncrementalState();
|
|
21422
21424
|
return;
|
|
21423
21425
|
}
|
|
21424
21426
|
}
|
|
21425
|
-
scheduleRefresh(payload?.type === "
|
|
21427
|
+
scheduleRefresh(payload?.type === "span:update" || payload?.type === "span:end" ? 700 : 180);
|
|
21426
21428
|
});
|
|
21427
21429
|
(0, import_react3.useEffect)(() => {
|
|
21428
21430
|
const events = new EventSource("/api/events");
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type ChatModelLike, type LocalLLMTracer, type OpenAIChatCompletionCreateParamsLike, type OpenAIClientLike, type
|
|
2
|
-
export type { ChatModelLike, HierarchyNode, HierarchyResponse, LocalLLMTracer, NormalizedTraceContext, OpenAIChatCompletionCreateParamsLike, OpenAIChatCompletionStreamLike, OpenAIClientLike, TraceConfig, TraceContext, TraceEvent, TraceFilters, TraceHierarchy, TraceListResponse, TraceMode, TraceRecord, TraceRequest, TraceServer, TraceStatus, TraceSummary, TraceTags, UIReloadEvent, } from './types';
|
|
1
|
+
import { type ChatModelLike, type LocalLLMTracer, type OpenAIChatCompletionCreateParamsLike, type OpenAIClientLike, type SpanEventInput, type SpanStartOptions, type TraceConfig, type TraceContext } from './types';
|
|
2
|
+
export type { ChatModelLike, HierarchyNode, HierarchyResponse, LocalLLMTracer, NormalizedTraceContext, OpenAIChatCompletionCreateParamsLike, OpenAIChatCompletionStreamLike, OpenAIClientLike, SpanAttributes, SpanContext, SpanEvent, SpanEventInput, SpanKind, SpanStartOptions, SpanStatus, SpanStatusCode, TraceConfig, TraceContext, TraceEvent, TraceFilters, TraceHierarchy, TraceListResponse, TraceMode, TraceRecord, TraceRequest, TraceServer, TraceStatus, TraceSummary, TraceTags, UIReloadEvent, } from './types';
|
|
3
3
|
export declare function isTraceEnabled(): boolean;
|
|
4
4
|
export declare function getLocalLLMTracer(config?: TraceConfig): LocalLLMTracer;
|
|
5
5
|
export declare function startTraceServer(config?: TraceConfig): Promise<{
|
|
@@ -7,12 +7,10 @@ export declare function startTraceServer(config?: TraceConfig): Promise<{
|
|
|
7
7
|
port: number;
|
|
8
8
|
url: string;
|
|
9
9
|
}>;
|
|
10
|
-
export declare function
|
|
11
|
-
export declare function
|
|
12
|
-
export declare function
|
|
13
|
-
export declare function
|
|
14
|
-
export declare function recordStreamFinish(traceId: string, chunk: unknown, config?: TraceConfig): void;
|
|
15
|
-
export declare function recordError(traceId: string, error: unknown, config?: TraceConfig): void;
|
|
10
|
+
export declare function startSpan(context: TraceContext, options?: SpanStartOptions, config?: TraceConfig): string;
|
|
11
|
+
export declare function endSpan(spanId: string, response: unknown, config?: TraceConfig): void;
|
|
12
|
+
export declare function addSpanEvent(spanId: string, event: SpanEventInput, config?: TraceConfig): void;
|
|
13
|
+
export declare function recordException(spanId: string, error: unknown, config?: TraceConfig): void;
|
|
16
14
|
export declare function __resetLocalLLMTracerForTests(): void;
|
|
17
15
|
export declare function wrapChatModel<TModel extends ChatModelLike<TInput, TOptions, TValue, TChunk>, TInput = any, TOptions = any, TValue = any, TChunk = any>(model: TModel, getContext: () => TraceContext, config?: TraceConfig): TModel;
|
|
18
16
|
export declare function wrapOpenAIClient<TClient extends OpenAIClientLike<TParams, TOptions, TResponse, TChunk>, TParams extends OpenAIChatCompletionCreateParamsLike = OpenAIChatCompletionCreateParamsLike, TOptions = Record<string, any>, TResponse = any, TChunk = any>(client: TClient, getContext: () => TraceContext, config?: TraceConfig): TClient;
|
package/dist/index.js
CHANGED
|
@@ -3,21 +3,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.isTraceEnabled = isTraceEnabled;
|
|
4
4
|
exports.getLocalLLMTracer = getLocalLLMTracer;
|
|
5
5
|
exports.startTraceServer = startTraceServer;
|
|
6
|
-
exports.
|
|
7
|
-
exports.
|
|
8
|
-
exports.
|
|
9
|
-
exports.
|
|
10
|
-
exports.recordStreamFinish = recordStreamFinish;
|
|
11
|
-
exports.recordError = recordError;
|
|
6
|
+
exports.startSpan = startSpan;
|
|
7
|
+
exports.endSpan = endSpan;
|
|
8
|
+
exports.addSpanEvent = addSpanEvent;
|
|
9
|
+
exports.recordException = recordException;
|
|
12
10
|
exports.__resetLocalLLMTracerForTests = __resetLocalLLMTracerForTests;
|
|
13
11
|
exports.wrapChatModel = wrapChatModel;
|
|
14
12
|
exports.wrapOpenAIClient = wrapOpenAIClient;
|
|
13
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
15
14
|
const server_1 = require("./server");
|
|
16
15
|
const store_1 = require("./store");
|
|
17
16
|
const ui_build_1 = require("./ui-build");
|
|
18
17
|
const utils_1 = require("./utils");
|
|
19
18
|
let singleton = null;
|
|
20
19
|
const DEFAULT_TRACE_PORT = 4319;
|
|
20
|
+
const activeSpanStorage = new node_async_hooks_1.AsyncLocalStorage();
|
|
21
21
|
function isTraceEnabled() {
|
|
22
22
|
return (0, utils_1.envFlag)('LLM_TRACE_ENABLED');
|
|
23
23
|
}
|
|
@@ -33,23 +33,17 @@ function getLocalLLMTracer(config = {}) {
|
|
|
33
33
|
function startTraceServer(config = {}) {
|
|
34
34
|
return getLocalLLMTracer(config).startServer();
|
|
35
35
|
}
|
|
36
|
-
function
|
|
37
|
-
return getLocalLLMTracer(config).
|
|
36
|
+
function startSpan(context, options = {}, config = {}) {
|
|
37
|
+
return getLocalLLMTracer(config).startSpan(context, options);
|
|
38
38
|
}
|
|
39
|
-
function
|
|
40
|
-
getLocalLLMTracer(config).
|
|
39
|
+
function endSpan(spanId, response, config = {}) {
|
|
40
|
+
getLocalLLMTracer(config).endSpan(spanId, response);
|
|
41
41
|
}
|
|
42
|
-
function
|
|
43
|
-
|
|
42
|
+
function addSpanEvent(spanId, event, config = {}) {
|
|
43
|
+
getLocalLLMTracer(config).addSpanEvent(spanId, event);
|
|
44
44
|
}
|
|
45
|
-
function
|
|
46
|
-
getLocalLLMTracer(config).
|
|
47
|
-
}
|
|
48
|
-
function recordStreamFinish(traceId, chunk, config = {}) {
|
|
49
|
-
getLocalLLMTracer(config).recordStreamFinish(traceId, chunk);
|
|
50
|
-
}
|
|
51
|
-
function recordError(traceId, error, config = {}) {
|
|
52
|
-
getLocalLLMTracer(config).recordError(traceId, error);
|
|
45
|
+
function recordException(spanId, error, config = {}) {
|
|
46
|
+
getLocalLLMTracer(config).recordException(spanId, error);
|
|
53
47
|
}
|
|
54
48
|
function __resetLocalLLMTracerForTests() {
|
|
55
49
|
if (singleton?.uiWatcher) {
|
|
@@ -70,14 +64,19 @@ function wrapChatModel(model, getContext, config) {
|
|
|
70
64
|
if (!tracer.isEnabled()) {
|
|
71
65
|
return model.invoke(input, options);
|
|
72
66
|
}
|
|
73
|
-
const traceId = tracer.
|
|
67
|
+
const traceId = tracer.startSpan(getContext ? getContext() : {}, {
|
|
68
|
+
attributes: { 'gen_ai.operation.name': 'chat' },
|
|
69
|
+
mode: 'invoke',
|
|
70
|
+
name: 'llm.invoke',
|
|
71
|
+
request: { input: input, options: options },
|
|
72
|
+
});
|
|
74
73
|
try {
|
|
75
|
-
const response = await model.invoke(input, options);
|
|
76
|
-
tracer.
|
|
74
|
+
const response = await tracer.runWithActiveSpan(traceId, () => model.invoke(input, options));
|
|
75
|
+
tracer.endSpan(traceId, response);
|
|
77
76
|
return response;
|
|
78
77
|
}
|
|
79
78
|
catch (error) {
|
|
80
|
-
tracer.
|
|
79
|
+
tracer.recordException(traceId, error);
|
|
81
80
|
throw error;
|
|
82
81
|
}
|
|
83
82
|
},
|
|
@@ -87,21 +86,26 @@ function wrapChatModel(model, getContext, config) {
|
|
|
87
86
|
yield* model.stream(input, options);
|
|
88
87
|
return;
|
|
89
88
|
}
|
|
90
|
-
const traceId = tracer.
|
|
89
|
+
const traceId = tracer.startSpan(getContext ? getContext() : {}, {
|
|
90
|
+
attributes: { 'gen_ai.operation.name': 'chat' },
|
|
91
|
+
mode: 'stream',
|
|
92
|
+
name: 'llm.stream',
|
|
93
|
+
request: { input: input, options: options },
|
|
94
|
+
});
|
|
91
95
|
try {
|
|
92
|
-
const stream = model.stream(input, options);
|
|
96
|
+
const stream = tracer.runWithActiveSpan(traceId, () => model.stream(input, options));
|
|
93
97
|
for await (const chunk of stream) {
|
|
94
98
|
if (chunk?.type === 'finish') {
|
|
95
|
-
tracer.
|
|
99
|
+
tracer.endSpan(traceId, chunk);
|
|
96
100
|
}
|
|
97
101
|
else {
|
|
98
|
-
tracer.
|
|
102
|
+
tracer.addSpanEvent(traceId, (0, utils_1.toSpanEventInputFromChunk)(chunk));
|
|
99
103
|
}
|
|
100
104
|
yield chunk;
|
|
101
105
|
}
|
|
102
106
|
}
|
|
103
107
|
catch (error) {
|
|
104
|
-
tracer.
|
|
108
|
+
tracer.recordException(traceId, error);
|
|
105
109
|
throw error;
|
|
106
110
|
}
|
|
107
111
|
},
|
|
@@ -121,24 +125,34 @@ function wrapOpenAIClient(client, getContext, config) {
|
|
|
121
125
|
}
|
|
122
126
|
const context = withOpenAITraceContext(getContext ? getContext() : {}, params);
|
|
123
127
|
if (params?.stream) {
|
|
124
|
-
const traceId = tracer.
|
|
128
|
+
const traceId = tracer.startSpan(context, {
|
|
129
|
+
attributes: { 'gen_ai.operation.name': 'chat' },
|
|
130
|
+
mode: 'stream',
|
|
131
|
+
name: 'openai.chat.completions',
|
|
132
|
+
request: { input: params, options: options },
|
|
133
|
+
});
|
|
125
134
|
try {
|
|
126
|
-
const stream = await target.create.call(target, params, options);
|
|
135
|
+
const stream = await tracer.runWithActiveSpan(traceId, () => target.create.call(target, params, options));
|
|
127
136
|
return wrapOpenAIChatCompletionsStream(stream, tracer, traceId);
|
|
128
137
|
}
|
|
129
138
|
catch (error) {
|
|
130
|
-
tracer.
|
|
139
|
+
tracer.recordException(traceId, error);
|
|
131
140
|
throw error;
|
|
132
141
|
}
|
|
133
142
|
}
|
|
134
|
-
const traceId = tracer.
|
|
143
|
+
const traceId = tracer.startSpan(context, {
|
|
144
|
+
attributes: { 'gen_ai.operation.name': 'chat' },
|
|
145
|
+
mode: 'invoke',
|
|
146
|
+
name: 'openai.chat.completions',
|
|
147
|
+
request: { input: params, options: options },
|
|
148
|
+
});
|
|
135
149
|
try {
|
|
136
|
-
const response = await target.create.call(target, params, options);
|
|
137
|
-
tracer.
|
|
150
|
+
const response = await tracer.runWithActiveSpan(traceId, () => target.create.call(target, params, options));
|
|
151
|
+
tracer.endSpan(traceId, normalizeOpenAIChatCompletionResponse(response));
|
|
138
152
|
return response;
|
|
139
153
|
}
|
|
140
154
|
catch (error) {
|
|
141
|
-
tracer.
|
|
155
|
+
tracer.recordException(traceId, error);
|
|
142
156
|
throw error;
|
|
143
157
|
}
|
|
144
158
|
};
|
|
@@ -213,6 +227,27 @@ class LocalLLMTracerImpl {
|
|
|
213
227
|
isEnabled() {
|
|
214
228
|
return isTraceEnabled();
|
|
215
229
|
}
|
|
230
|
+
startSpan(context, options = {}) {
|
|
231
|
+
void this.startServer();
|
|
232
|
+
const parentSpanId = options.parentSpanId || activeSpanStorage.getStore() || null;
|
|
233
|
+
return this.store.startSpan(context, {
|
|
234
|
+
...options,
|
|
235
|
+
parentSpanId,
|
|
236
|
+
request: normaliseRequest(options.request || {}),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
runWithActiveSpan(spanId, callback) {
|
|
240
|
+
return activeSpanStorage.run(spanId, callback);
|
|
241
|
+
}
|
|
242
|
+
addSpanEvent(spanId, event) {
|
|
243
|
+
this.store.addSpanEvent(spanId, (0, utils_1.safeClone)(event));
|
|
244
|
+
}
|
|
245
|
+
endSpan(spanId, response) {
|
|
246
|
+
this.store.endSpan(spanId, (0, utils_1.safeClone)(response));
|
|
247
|
+
}
|
|
248
|
+
recordException(spanId, error) {
|
|
249
|
+
this.store.recordException(spanId, error);
|
|
250
|
+
}
|
|
216
251
|
startServer() {
|
|
217
252
|
if (!this.isEnabled() || this.serverFailed) {
|
|
218
253
|
return Promise.resolve(this.serverInfo);
|
|
@@ -234,7 +269,7 @@ class LocalLLMTracerImpl {
|
|
|
234
269
|
this.uiWatcher = await (0, ui_build_1.maybeStartUIWatcher)(() => {
|
|
235
270
|
this.server?.broadcast({
|
|
236
271
|
timestamp: new Date().toISOString(),
|
|
237
|
-
|
|
272
|
+
spanId: null,
|
|
238
273
|
type: 'ui:reload',
|
|
239
274
|
});
|
|
240
275
|
}, this.config.uiHotReload);
|
|
@@ -256,26 +291,6 @@ class LocalLLMTracerImpl {
|
|
|
256
291
|
})();
|
|
257
292
|
return this.serverStartPromise;
|
|
258
293
|
}
|
|
259
|
-
recordInvokeStart(context, request) {
|
|
260
|
-
void this.startServer();
|
|
261
|
-
return this.store.recordInvokeStart(context, normaliseRequest(request));
|
|
262
|
-
}
|
|
263
|
-
recordInvokeFinish(traceId, response) {
|
|
264
|
-
this.store.recordInvokeFinish(traceId, (0, utils_1.safeClone)(response));
|
|
265
|
-
}
|
|
266
|
-
recordStreamStart(context, request) {
|
|
267
|
-
void this.startServer();
|
|
268
|
-
return this.store.recordStreamStart(context, normaliseRequest(request));
|
|
269
|
-
}
|
|
270
|
-
recordStreamChunk(traceId, chunk) {
|
|
271
|
-
this.store.recordStreamChunk(traceId, (0, utils_1.safeClone)(chunk));
|
|
272
|
-
}
|
|
273
|
-
recordStreamFinish(traceId, chunk) {
|
|
274
|
-
this.store.recordStreamFinish(traceId, (0, utils_1.safeClone)(chunk));
|
|
275
|
-
}
|
|
276
|
-
recordError(traceId, error) {
|
|
277
|
-
this.store.recordError(traceId, error);
|
|
278
|
-
}
|
|
279
294
|
}
|
|
280
295
|
function normaliseRequest(request) {
|
|
281
296
|
return {
|
|
@@ -322,7 +337,7 @@ function wrapOpenAIChatCompletionsStream(stream, tracer, traceId) {
|
|
|
322
337
|
state.began = true;
|
|
323
338
|
const nextRole = role || state.role || 'assistant';
|
|
324
339
|
state.role = nextRole;
|
|
325
|
-
tracer.
|
|
340
|
+
tracer.addSpanEvent(traceId, (0, utils_1.toSpanEventInputFromChunk)({ type: 'begin', role: nextRole }));
|
|
326
341
|
};
|
|
327
342
|
const emitFinish = () => {
|
|
328
343
|
if (state.finished) {
|
|
@@ -330,7 +345,7 @@ function wrapOpenAIChatCompletionsStream(stream, tracer, traceId) {
|
|
|
330
345
|
}
|
|
331
346
|
emitBegin(state.role);
|
|
332
347
|
state.finished = true;
|
|
333
|
-
tracer.
|
|
348
|
+
tracer.endSpan(traceId, {
|
|
334
349
|
type: 'finish',
|
|
335
350
|
finish_reasons: state.finishReasons,
|
|
336
351
|
message: {
|
|
@@ -382,23 +397,23 @@ function wrapOpenAIChatCompletionsStream(stream, tracer, traceId) {
|
|
|
382
397
|
emitBegin(state.role);
|
|
383
398
|
}
|
|
384
399
|
if (contentParts.length > 0) {
|
|
385
|
-
tracer.
|
|
400
|
+
tracer.addSpanEvent(traceId, (0, utils_1.toSpanEventInputFromChunk)({
|
|
386
401
|
type: 'chunk',
|
|
387
402
|
content: contentParts.join(''),
|
|
388
403
|
finish_reasons: [...finishReasons],
|
|
389
404
|
raw,
|
|
390
405
|
tool_calls: chunkToolCalls,
|
|
391
406
|
usage,
|
|
392
|
-
});
|
|
407
|
+
}));
|
|
393
408
|
return;
|
|
394
409
|
}
|
|
395
|
-
tracer.
|
|
410
|
+
tracer.addSpanEvent(traceId, (0, utils_1.toSpanEventInputFromChunk)({
|
|
396
411
|
type: 'event',
|
|
397
412
|
finish_reasons: [...finishReasons],
|
|
398
413
|
raw,
|
|
399
414
|
tool_calls: chunkToolCalls,
|
|
400
415
|
usage,
|
|
401
|
-
});
|
|
416
|
+
}));
|
|
402
417
|
};
|
|
403
418
|
const createWrappedIterator = (iterator) => ({
|
|
404
419
|
async next(...args) {
|
|
@@ -412,7 +427,7 @@ function wrapOpenAIChatCompletionsStream(stream, tracer, traceId) {
|
|
|
412
427
|
return result;
|
|
413
428
|
}
|
|
414
429
|
catch (error) {
|
|
415
|
-
tracer.
|
|
430
|
+
tracer.recordException(traceId, error);
|
|
416
431
|
throw error;
|
|
417
432
|
}
|
|
418
433
|
},
|
|
@@ -428,12 +443,12 @@ function wrapOpenAIChatCompletionsStream(stream, tracer, traceId) {
|
|
|
428
443
|
return result;
|
|
429
444
|
}
|
|
430
445
|
catch (error) {
|
|
431
|
-
tracer.
|
|
446
|
+
tracer.recordException(traceId, error);
|
|
432
447
|
throw error;
|
|
433
448
|
}
|
|
434
449
|
},
|
|
435
450
|
async throw(error) {
|
|
436
|
-
tracer.
|
|
451
|
+
tracer.recordException(traceId, error);
|
|
437
452
|
if (typeof iterator.throw === 'function') {
|
|
438
453
|
return iterator.throw(error);
|
|
439
454
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
|
-
import { type HierarchyResponse, type NormalizedTraceContext, type
|
|
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 = {
|
|
@@ -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;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
const { randomUUID } = require('node:crypto');
|
|
5
|
+
const readline = require('node:readline/promises');
|
|
6
|
+
const process = require('node:process');
|
|
7
|
+
|
|
8
|
+
const { getLocalLLMTracer, wrapChatModel } = require('../dist/index.js');
|
|
9
|
+
|
|
10
|
+
const DEMO_TIMEOUT_MS = 15000;
|
|
11
|
+
const PORT = Number(process.env.LLM_TRACE_PORT) || 4319;
|
|
12
|
+
const SESSION_ID = `nested-tool-demo-${randomUUID().slice(0, 8)}`;
|
|
13
|
+
|
|
14
|
+
async function main() {
|
|
15
|
+
process.env.LLM_TRACE_ENABLED ??= '1';
|
|
16
|
+
|
|
17
|
+
const tracer = getLocalLLMTracer({ port: PORT });
|
|
18
|
+
const serverInfo = await tracer.startServer();
|
|
19
|
+
if (!serverInfo?.url) {
|
|
20
|
+
throw new Error('Failed to start the Loupe dashboard.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
log(`[demo] Loupe dashboard: ${serverInfo.url}`);
|
|
24
|
+
openBrowser(serverInfo.url);
|
|
25
|
+
log(`[demo] Session: ${SESSION_ID}`);
|
|
26
|
+
|
|
27
|
+
const nestedResearchModel = wrapChatModel(
|
|
28
|
+
{
|
|
29
|
+
async invoke(input) {
|
|
30
|
+
const question = input?.messages?.[0]?.content || '';
|
|
31
|
+
return {
|
|
32
|
+
message: {
|
|
33
|
+
role: 'assistant',
|
|
34
|
+
content: `Tool research result for "${question}": compare rain gear, walking shoes, and a light sweater.`,
|
|
35
|
+
},
|
|
36
|
+
tool_calls: [],
|
|
37
|
+
usage: {
|
|
38
|
+
tokens: { prompt: 9, completion: 14 },
|
|
39
|
+
pricing: { prompt: 0.000001, completion: 0.000002 },
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
async *stream(input) {
|
|
44
|
+
const content = `Tool research stream for "${input?.messages?.[0]?.content || ''}".`;
|
|
45
|
+
yield { type: 'begin', role: 'assistant' };
|
|
46
|
+
yield { type: 'chunk', content };
|
|
47
|
+
yield {
|
|
48
|
+
type: 'finish',
|
|
49
|
+
message: { role: 'assistant', content },
|
|
50
|
+
tool_calls: [],
|
|
51
|
+
usage: {
|
|
52
|
+
tokens: { prompt: 9, completion: 14 },
|
|
53
|
+
pricing: { prompt: 0.000001, completion: 0.000002 },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
() => ({
|
|
59
|
+
sessionId: SESSION_ID,
|
|
60
|
+
rootSessionId: SESSION_ID,
|
|
61
|
+
rootActorId: 'travel-assistant',
|
|
62
|
+
actorId: 'weather-research-tool',
|
|
63
|
+
provider: 'mock-llm',
|
|
64
|
+
model: 'tool-researcher-v1',
|
|
65
|
+
stage: 'tool:research',
|
|
66
|
+
tags: {
|
|
67
|
+
example: 'nested-tool-call',
|
|
68
|
+
role: 'tool-llm',
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
{ port: PORT },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const rootAssistantModel = wrapChatModel(
|
|
75
|
+
{
|
|
76
|
+
async invoke(input) {
|
|
77
|
+
const question = input?.messages?.[0]?.content || '';
|
|
78
|
+
const toolResult = await nestedResearchModel.invoke(
|
|
79
|
+
{
|
|
80
|
+
messages: [
|
|
81
|
+
{
|
|
82
|
+
role: 'user',
|
|
83
|
+
content: `Research facts needed for: ${question}`,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{},
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
message: {
|
|
92
|
+
role: 'assistant',
|
|
93
|
+
content: [
|
|
94
|
+
`Final answer for "${question}"`,
|
|
95
|
+
'',
|
|
96
|
+
toolResult.message.content,
|
|
97
|
+
'',
|
|
98
|
+
'Pack layers, waterproof gear, and comfortable shoes.',
|
|
99
|
+
].join('\n'),
|
|
100
|
+
},
|
|
101
|
+
tool_calls: [],
|
|
102
|
+
usage: {
|
|
103
|
+
tokens: { prompt: 12, completion: 18 },
|
|
104
|
+
pricing: { prompt: 0.000001, completion: 0.000002 },
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
async *stream(input) {
|
|
109
|
+
const question = input?.messages?.[0]?.content || '';
|
|
110
|
+
yield { type: 'begin', role: 'assistant' };
|
|
111
|
+
yield { type: 'chunk', content: 'Let me research that with the tool model.\n\n' };
|
|
112
|
+
|
|
113
|
+
let toolContent = '';
|
|
114
|
+
for await (const chunk of nestedResearchModel.stream(
|
|
115
|
+
{
|
|
116
|
+
messages: [
|
|
117
|
+
{
|
|
118
|
+
role: 'user',
|
|
119
|
+
content: `Research facts needed for: ${question}`,
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
{},
|
|
124
|
+
)) {
|
|
125
|
+
if (chunk?.type === 'chunk' && typeof chunk.content === 'string') {
|
|
126
|
+
toolContent += chunk.content;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const finalContent = [
|
|
131
|
+
`Final answer for "${question}"`,
|
|
132
|
+
'',
|
|
133
|
+
toolContent,
|
|
134
|
+
'',
|
|
135
|
+
'Pack layers, waterproof gear, and comfortable shoes.',
|
|
136
|
+
].join('\n');
|
|
137
|
+
|
|
138
|
+
yield { type: 'chunk', content: finalContent };
|
|
139
|
+
yield {
|
|
140
|
+
type: 'finish',
|
|
141
|
+
message: { role: 'assistant', content: finalContent },
|
|
142
|
+
tool_calls: [],
|
|
143
|
+
usage: {
|
|
144
|
+
tokens: { prompt: 12, completion: 18 },
|
|
145
|
+
pricing: { prompt: 0.000001, completion: 0.000002 },
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
() => ({
|
|
151
|
+
sessionId: SESSION_ID,
|
|
152
|
+
rootSessionId: SESSION_ID,
|
|
153
|
+
rootActorId: 'travel-assistant',
|
|
154
|
+
actorId: 'travel-assistant',
|
|
155
|
+
provider: 'mock-llm',
|
|
156
|
+
model: 'trip-planner-v1',
|
|
157
|
+
stage: 'assistant',
|
|
158
|
+
tags: {
|
|
159
|
+
example: 'nested-tool-call',
|
|
160
|
+
role: 'root-llm',
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
{ port: PORT },
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const prompt = 'I am taking a rainy weekend trip to London. What should I pack?';
|
|
167
|
+
log(`[demo] User: ${prompt}`);
|
|
168
|
+
const response = await rootAssistantModel.invoke({
|
|
169
|
+
messages: [{ role: 'user', content: prompt }],
|
|
170
|
+
});
|
|
171
|
+
log(`[demo] Assistant:\n${response.message.content}`);
|
|
172
|
+
log('[demo] This run creates a parent span for the assistant call and a child span for the tool LLM call.');
|
|
173
|
+
log(`[demo] Keep this process alive while you inspect ${serverInfo.url}`);
|
|
174
|
+
|
|
175
|
+
await waitForDashboardExit(serverInfo.url);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function openBrowser(url) {
|
|
179
|
+
if (!process.stdout.isTTY || process.env.CI || process.env.LOUPE_OPEN_BROWSER === '0') {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const command =
|
|
184
|
+
process.platform === 'darwin'
|
|
185
|
+
? ['open', [url]]
|
|
186
|
+
: process.platform === 'win32'
|
|
187
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
188
|
+
: process.platform === 'linux'
|
|
189
|
+
? ['xdg-open', [url]]
|
|
190
|
+
: null;
|
|
191
|
+
|
|
192
|
+
if (!command) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const child = spawn(command[0], command[1], {
|
|
198
|
+
detached: true,
|
|
199
|
+
stdio: 'ignore',
|
|
200
|
+
});
|
|
201
|
+
child.on('error', () => {});
|
|
202
|
+
child.unref();
|
|
203
|
+
} catch (_error) {
|
|
204
|
+
// Ignore browser launch failures. The dashboard URL is already printed.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function waitForDashboardExit(url) {
|
|
209
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
210
|
+
log(`[demo] Non-interactive terminal detected. Leaving the dashboard up for ${Math.round(DEMO_TIMEOUT_MS / 1000)} seconds: ${url}`);
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, DEMO_TIMEOUT_MS));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const rl = readline.createInterface({
|
|
216
|
+
input: process.stdin,
|
|
217
|
+
output: process.stdout,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
await rl.question('[demo] Press Enter to stop the demo and close the dashboard.\n');
|
|
222
|
+
} finally {
|
|
223
|
+
rl.close();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function log(message) {
|
|
228
|
+
process.stdout.write(`${message}\n`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
main().catch((error) => {
|
|
232
|
+
process.stderr.write(`[demo] ${error.message}\n`);
|
|
233
|
+
process.exitCode = 1;
|
|
234
|
+
});
|