@amaster.ai/dsh-langfuse 0.1.16 → 0.1.17
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 +55 -3
- package/lib/client.js +154 -21
- package/lib/index.js +332 -123
- package/lib/media.js +57 -0
- package/lib/types/client.d.ts +22 -4
- package/lib/types/index.d.ts +35 -4
- package/lib/types/media.d.ts +11 -0
- package/package.json +11 -1
package/lib/index.js
CHANGED
|
@@ -4,11 +4,10 @@
|
|
|
4
4
|
* Instruments the documented seams, with zero core patches:
|
|
5
5
|
*
|
|
6
6
|
* - `llm/stream` waterfall → one Langfuse **generation** per LLM call
|
|
7
|
-
* (input =
|
|
7
|
+
* (input = loop-built options; output, token usage and finish reason collected
|
|
8
8
|
* from the chunk stream), plus a nested `llm-request` **span** holding the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* assembled inside the adapter).
|
|
9
|
+
* same options and collected chunk stream (the rawest request/response a
|
|
10
|
+
* plugin can observe — the provider HTTP body is assembled inside the adapter).
|
|
12
11
|
* - `tools/execute` waterfall → one Langfuse **span** per tool dispatch
|
|
13
12
|
* (routed to the session's trace via `exec.agent.id` — the agent/session
|
|
14
13
|
* shared identity).
|
|
@@ -31,6 +30,7 @@
|
|
|
31
30
|
*/
|
|
32
31
|
import Schema from '@deepseek-ai/schemastery';
|
|
33
32
|
import { LangfuseReporter } from './client.js';
|
|
33
|
+
import { displayBlocks } from './media.js';
|
|
34
34
|
export const name = 'dsh-langfuse';
|
|
35
35
|
/**
|
|
36
36
|
* Waterfall listeners only need the events to exist; no hard service
|
|
@@ -43,10 +43,28 @@ export const Config = Schema.object({
|
|
|
43
43
|
enabled: Schema.boolean().default(false).description('master switch'),
|
|
44
44
|
publicKey: Schema.string().default('').description('Langfuse public key'),
|
|
45
45
|
secretKey: Schema.string().role('secret').default('').description('Langfuse secret key'),
|
|
46
|
-
baseUrl: Schema.string()
|
|
46
|
+
baseUrl: Schema.string()
|
|
47
|
+
.default('')
|
|
48
|
+
.description('profile override for LANGFUSE_BASE_URL; defaults to EU cloud'),
|
|
47
49
|
traceName: Schema.string().default('dsh-turn'),
|
|
48
50
|
/** Include full message/tool payloads in Langfuse IO. Disable to log metadata only. */
|
|
49
51
|
captureContent: Schema.boolean().default(true),
|
|
52
|
+
redactFields: Schema.array(Schema.string())
|
|
53
|
+
.default([])
|
|
54
|
+
.description('case-insensitive payload fields to redact'),
|
|
55
|
+
userId: Schema.string().default(''),
|
|
56
|
+
tags: Schema.array(Schema.string()).default([]),
|
|
57
|
+
environment: Schema.string().default(''),
|
|
58
|
+
release: Schema.string().default(''),
|
|
59
|
+
captureMedia: Schema.boolean()
|
|
60
|
+
.default(false)
|
|
61
|
+
.description('upload attachment images for display; requires captureContent'),
|
|
62
|
+
maxMediaBytes: Schema.number()
|
|
63
|
+
.min(1)
|
|
64
|
+
.step(1)
|
|
65
|
+
.default(5 * 1024 * 1024)
|
|
66
|
+
.description('image byte budget per display payload'),
|
|
67
|
+
mediaTimeoutMs: Schema.number().min(1).max(60_000).step(1).default(5000),
|
|
50
68
|
});
|
|
51
69
|
const messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
52
70
|
/**
|
|
@@ -88,8 +106,8 @@ function modelParametersOf(options, captureContent) {
|
|
|
88
106
|
return Object.keys(params).length > 0 ? params : undefined;
|
|
89
107
|
}
|
|
90
108
|
/**
|
|
91
|
-
* The loop-built request as Langfuse input:
|
|
92
|
-
*
|
|
109
|
+
* The loop-built request as Langfuse input: every current/future option except
|
|
110
|
+
* the non-JSON-safe AbortSignal when content capture is on, counts otherwise.
|
|
93
111
|
*/
|
|
94
112
|
function requestBodyOf(options, captureContent) {
|
|
95
113
|
if (!captureContent) {
|
|
@@ -138,8 +156,48 @@ function toolCallSummary(args) {
|
|
|
138
156
|
}
|
|
139
157
|
return undefined;
|
|
140
158
|
}
|
|
159
|
+
/** Redact each complete block before exporting fragments that could reconstruct a secret. */
|
|
160
|
+
function redactChunks(chunks, reporter) {
|
|
161
|
+
const groups = new Map();
|
|
162
|
+
for (const [position, chunk] of chunks.entries()) {
|
|
163
|
+
if (chunk.type !== 'text-delta' &&
|
|
164
|
+
chunk.type !== 'reasoning-delta' &&
|
|
165
|
+
chunk.type !== 'tool-call-delta')
|
|
166
|
+
continue;
|
|
167
|
+
const key = `${chunk.type}:${chunk.index}`;
|
|
168
|
+
const group = groups.get(key) ?? { text: '', positions: [] };
|
|
169
|
+
group.text += chunk.type === 'tool-call-delta' ? chunk.argumentsDelta : chunk.text;
|
|
170
|
+
group.positions.push(position);
|
|
171
|
+
groups.set(key, group);
|
|
172
|
+
}
|
|
173
|
+
const output = [...chunks];
|
|
174
|
+
for (const group of groups.values()) {
|
|
175
|
+
const masked = reporter.mask(group.text);
|
|
176
|
+
if (masked === group.text)
|
|
177
|
+
continue;
|
|
178
|
+
// Sensitive content changes, but the record count/type/index remain.
|
|
179
|
+
for (const [index, position] of group.positions.entries()) {
|
|
180
|
+
const chunk = chunks[position];
|
|
181
|
+
const text = index === 0 ? masked : '';
|
|
182
|
+
if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta')
|
|
183
|
+
output[position] = { ...chunk, text };
|
|
184
|
+
else if (chunk.type === 'tool-call-delta')
|
|
185
|
+
output[position] = { ...chunk, argumentsDelta: text };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return output;
|
|
189
|
+
}
|
|
141
190
|
export function apply(ctx, config) {
|
|
142
|
-
if (!config.enabled ||
|
|
191
|
+
if (!config.enabled || /^(false|0)$/i.test(process.env.LANGFUSE_TRACING_ENABLED ?? ''))
|
|
192
|
+
return;
|
|
193
|
+
config = {
|
|
194
|
+
...config,
|
|
195
|
+
publicKey: config.publicKey || process.env.LANGFUSE_PUBLIC_KEY || '',
|
|
196
|
+
secretKey: config.secretKey || process.env.LANGFUSE_SECRET_KEY || '',
|
|
197
|
+
baseUrl: config.baseUrl || process.env.LANGFUSE_BASE_URL || 'https://cloud.langfuse.com',
|
|
198
|
+
userId: config.userId || process.env.LANGFUSE_USER_ID,
|
|
199
|
+
};
|
|
200
|
+
if (!config.publicKey || !config.secretKey)
|
|
143
201
|
return;
|
|
144
202
|
const reporter = new LangfuseReporter(config);
|
|
145
203
|
const sessions = new Map();
|
|
@@ -147,6 +205,71 @@ export function apply(ctx, config) {
|
|
|
147
205
|
/** Per-session stack of currently open tools/execute spans (for subagent parenting). */
|
|
148
206
|
const openToolSpans = new Map();
|
|
149
207
|
const openHooks = new Map();
|
|
208
|
+
const openOperations = new Set();
|
|
209
|
+
const traceContexts = new Map();
|
|
210
|
+
const pendingMedia = new Set();
|
|
211
|
+
const mediaFor = new WeakMap();
|
|
212
|
+
const mediaOptions = {
|
|
213
|
+
enabled: config.captureContent &&
|
|
214
|
+
!!config.captureMedia &&
|
|
215
|
+
!/^(false|0)$/i.test(process.env.LANGFUSE_MEDIA_UPLOAD_ENABLED ?? ''),
|
|
216
|
+
maxBytes: config.maxMediaBytes ?? 5 * 1024 * 1024,
|
|
217
|
+
timeoutMs: config.mediaTimeoutMs ?? 5000,
|
|
218
|
+
};
|
|
219
|
+
function trackMedia(task) {
|
|
220
|
+
const pending = task.catch((error) => {
|
|
221
|
+
console.error('[dsh-langfuse] media finalization failed:', error);
|
|
222
|
+
});
|
|
223
|
+
pendingMedia.add(pending);
|
|
224
|
+
void pending.then(() => pendingMedia.delete(pending));
|
|
225
|
+
return pending;
|
|
226
|
+
}
|
|
227
|
+
function enrichInput(handle, input) {
|
|
228
|
+
if (!handle)
|
|
229
|
+
return;
|
|
230
|
+
mediaFor.set(handle, trackMedia(input.then((value) => reporter.updateSpan(handle, { input: value }))));
|
|
231
|
+
}
|
|
232
|
+
function afterMedia(handle, close, endedAt = new Date()) {
|
|
233
|
+
const task = handle ? mediaFor.get(handle) : undefined;
|
|
234
|
+
if (task)
|
|
235
|
+
trackMedia(task.then(() => close(endedAt)));
|
|
236
|
+
else
|
|
237
|
+
close(endedAt);
|
|
238
|
+
}
|
|
239
|
+
function setMessageInput(handle, content) {
|
|
240
|
+
if (content.some((block) => block.type !== 'text')) {
|
|
241
|
+
enrichInput(handle, displayBlocks(content, ctx.get('attachments'), mediaOptions));
|
|
242
|
+
}
|
|
243
|
+
else
|
|
244
|
+
reporter.updateSpan(handle, { input: messageText({ content }) });
|
|
245
|
+
}
|
|
246
|
+
async function drainMedia() {
|
|
247
|
+
while (pendingMedia.size)
|
|
248
|
+
await Promise.all([...pendingMedia]);
|
|
249
|
+
}
|
|
250
|
+
ctx.on('langfuse/context', (sessionId, context) => {
|
|
251
|
+
traceContexts.set(sessionId, {
|
|
252
|
+
...context,
|
|
253
|
+
tags: context.tags ? [...context.tags] : undefined,
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
function openTrace(sessionId, metadata) {
|
|
257
|
+
const context = sessionId ? traceContexts.get(sessionId) : undefined;
|
|
258
|
+
if (sessionId)
|
|
259
|
+
traceContexts.delete(sessionId);
|
|
260
|
+
return reporter.openTrace({ name: config.traceName, sessionId, metadata, context });
|
|
261
|
+
}
|
|
262
|
+
function rootSessionId(sessionId) {
|
|
263
|
+
while (sessionId && children.has(sessionId))
|
|
264
|
+
sessionId = children.get(sessionId)?.parentSessionId;
|
|
265
|
+
return sessionId;
|
|
266
|
+
}
|
|
267
|
+
function noteError(sessionId) {
|
|
268
|
+
const id = rootSessionId(sessionId);
|
|
269
|
+
const state = id ? sessions.get(id) : undefined;
|
|
270
|
+
if (state)
|
|
271
|
+
state.hadErrors = true;
|
|
272
|
+
}
|
|
150
273
|
function closeHooks(sessionId) {
|
|
151
274
|
for (const span of openHooks.get(sessionId)?.values() ?? []) {
|
|
152
275
|
reporter.endSpan(span, { level: 'WARNING', statusMessage: 'hook result not observed' });
|
|
@@ -164,13 +287,11 @@ export function apply(ctx, config) {
|
|
|
164
287
|
* (a tree by construction) to the root trace.
|
|
165
288
|
*/
|
|
166
289
|
function traceFor(sessionId) {
|
|
167
|
-
|
|
168
|
-
while (id && children.has(id))
|
|
169
|
-
id = children.get(id)?.parentSessionId;
|
|
290
|
+
const id = rootSessionId(sessionId);
|
|
170
291
|
const existing = id ? sessions.get(id) : undefined;
|
|
171
292
|
if (existing)
|
|
172
293
|
return existing.trace;
|
|
173
|
-
const trace =
|
|
294
|
+
const trace = openTrace(id);
|
|
174
295
|
// hasInput starts true: one-offs never take a turn's first-message input.
|
|
175
296
|
if (trace && id)
|
|
176
297
|
sessions.set(id, { trace, hasInput: true });
|
|
@@ -219,8 +340,10 @@ export function apply(ctx, config) {
|
|
|
219
340
|
const child = children.get(info.id);
|
|
220
341
|
if (!child)
|
|
221
342
|
return;
|
|
343
|
+
if (info.stopReason === 'error')
|
|
344
|
+
noteError(info.id);
|
|
222
345
|
children.delete(info.id);
|
|
223
|
-
reporter.endSpan(child.span, {
|
|
346
|
+
afterMedia(child.span, (endedAt) => reporter.endSpan(child.span, {
|
|
224
347
|
output: {
|
|
225
348
|
stopReason: info.stopReason,
|
|
226
349
|
...(config.captureContent ? { lastAssistantMessage: info.lastAssistantMessage } : {}),
|
|
@@ -230,7 +353,7 @@ export function apply(ctx, config) {
|
|
|
230
353
|
: info.stopReason === 'aborted'
|
|
231
354
|
? 'WARNING'
|
|
232
355
|
: 'DEFAULT',
|
|
233
|
-
});
|
|
356
|
+
}, endedAt));
|
|
234
357
|
});
|
|
235
358
|
// ------------------------------------------------------------------
|
|
236
359
|
// Trace lifecycle: one trace per turn (top-level sessions only — child
|
|
@@ -270,6 +393,8 @@ export function apply(ctx, config) {
|
|
|
270
393
|
const pending = openHooks.get(sessionId);
|
|
271
394
|
const span = pending?.get(data.handlerId);
|
|
272
395
|
if (span) {
|
|
396
|
+
if (data.exitCode === undefined || (data.exitCode !== 0 && data.exitCode !== 2))
|
|
397
|
+
noteError(sessionId);
|
|
273
398
|
reporter.endSpan(span, {
|
|
274
399
|
output: {
|
|
275
400
|
decision: data.decision,
|
|
@@ -296,7 +421,7 @@ export function apply(ctx, config) {
|
|
|
296
421
|
// The delegated prompt (the child's first user/message) is the
|
|
297
422
|
// subagent span's input; the descriptor enriches the span name.
|
|
298
423
|
if (event.type === 'user/message' && !child.hasInput && config.captureContent) {
|
|
299
|
-
|
|
424
|
+
setMessageInput(child.span, event.data.content);
|
|
300
425
|
child.hasInput = true;
|
|
301
426
|
}
|
|
302
427
|
else if (event.type === 'subagent/descriptor') {
|
|
@@ -319,12 +444,8 @@ export function apply(ctx, config) {
|
|
|
319
444
|
closeHooks(sessionId);
|
|
320
445
|
const stale = sessions.get(sessionId);
|
|
321
446
|
if (stale)
|
|
322
|
-
reporter.endSpan(stale.trace);
|
|
323
|
-
const trace =
|
|
324
|
-
name: config.traceName,
|
|
325
|
-
sessionId,
|
|
326
|
-
metadata: { turn: event.data.turn },
|
|
327
|
-
});
|
|
447
|
+
afterMedia(stale.trace, (endedAt) => reporter.endSpan(stale.trace, {}, endedAt));
|
|
448
|
+
const trace = openTrace(sessionId, { turn: event.data.turn });
|
|
328
449
|
if (trace)
|
|
329
450
|
sessions.set(sessionId, { trace, hasInput: false });
|
|
330
451
|
break;
|
|
@@ -332,7 +453,7 @@ export function apply(ctx, config) {
|
|
|
332
453
|
case 'user/message': {
|
|
333
454
|
const state = sessions.get(sessionId);
|
|
334
455
|
if (state && !state.hasInput && config.captureContent) {
|
|
335
|
-
|
|
456
|
+
setMessageInput(state.trace, event.data.content);
|
|
336
457
|
state.hasInput = true;
|
|
337
458
|
}
|
|
338
459
|
break;
|
|
@@ -350,10 +471,25 @@ export function apply(ctx, config) {
|
|
|
350
471
|
sessions.delete(sessionId);
|
|
351
472
|
// The turn's final answer becomes the trace's output; ending the
|
|
352
473
|
// root span is what exports the trace (v5 exports spans on end).
|
|
353
|
-
reporter.endSpan(state.trace, {
|
|
474
|
+
afterMedia(state.trace, (endedAt) => reporter.endSpan(state.trace, {
|
|
354
475
|
output: state.lastAssistantText,
|
|
355
|
-
|
|
356
|
-
|
|
476
|
+
level: event.data.reason.kind === 'error'
|
|
477
|
+
? 'ERROR'
|
|
478
|
+
: ['aborted', 'blocked', 'max-tokens', 'interrupted'].includes(event.data.reason.kind)
|
|
479
|
+
? 'WARNING'
|
|
480
|
+
: 'DEFAULT',
|
|
481
|
+
statusMessage: event.data.reason.kind === 'error'
|
|
482
|
+
? contentMessage(config, event.data.reason.error.message)
|
|
483
|
+
: undefined,
|
|
484
|
+
metadata: {
|
|
485
|
+
turn: event.data.turn,
|
|
486
|
+
endReason: event.data.reason.kind,
|
|
487
|
+
...(state.hadErrors ? { hadErrors: true } : {}),
|
|
488
|
+
...(event.data.reason.kind === 'error'
|
|
489
|
+
? { errorCode: event.data.reason.error.code }
|
|
490
|
+
: {}),
|
|
491
|
+
},
|
|
492
|
+
}, endedAt));
|
|
357
493
|
}
|
|
358
494
|
break;
|
|
359
495
|
}
|
|
@@ -364,12 +500,13 @@ export function apply(ctx, config) {
|
|
|
364
500
|
closeHooks(sessionId);
|
|
365
501
|
const state = sessions.get(sessionId);
|
|
366
502
|
sessions.delete(sessionId);
|
|
503
|
+
traceContexts.delete(sessionId);
|
|
367
504
|
openToolSpans.delete(sessionId);
|
|
368
505
|
// End an abandoned root (crash-orphaned turn or between-turns one-off
|
|
369
506
|
// trace) — v5 spans only export on end, so dropping it open would lose
|
|
370
507
|
// everything it recorded.
|
|
371
508
|
if (state)
|
|
372
|
-
reporter.endSpan(state.trace);
|
|
509
|
+
afterMedia(state.trace, (endedAt) => reporter.endSpan(state.trace, {}, endedAt));
|
|
373
510
|
// Children: keep the entry — background/continuable subagents dispose
|
|
374
511
|
// their session BEFORE subagent/end fires, and the span's close must ride
|
|
375
512
|
// subagent/end (disposing first would either leak the span or close it
|
|
@@ -388,15 +525,24 @@ export function apply(ctx, config) {
|
|
|
388
525
|
// spans never export in v5 — end it when the call settles.
|
|
389
526
|
const oneOffRoot = options.sessionId ? null : parent;
|
|
390
527
|
const generationName = options.purpose ? `llm-call [${options.purpose}]` : 'llm-call';
|
|
528
|
+
const modelParameters = modelParametersOf(options, config.captureContent);
|
|
529
|
+
const input = requestBodyOf(options, config.captureContent);
|
|
391
530
|
const generation = reporter.startGeneration(parent, {
|
|
392
531
|
name: generationName,
|
|
393
532
|
model: options.model,
|
|
394
|
-
input
|
|
395
|
-
|
|
396
|
-
: { messageCount: options.messages.length },
|
|
397
|
-
modelParameters: modelParametersOf(options, config.captureContent),
|
|
533
|
+
input,
|
|
534
|
+
modelParameters,
|
|
398
535
|
metadata: { purpose: options.purpose },
|
|
399
536
|
});
|
|
537
|
+
if (generation &&
|
|
538
|
+
config.captureContent &&
|
|
539
|
+
options.messages.some((message) => message.content.some((block) => block.type === 'image'))) {
|
|
540
|
+
const budget = { remaining: mediaOptions.maxBytes };
|
|
541
|
+
enrichInput(generation, Promise.all(options.messages.map(async (message) => ({
|
|
542
|
+
...message,
|
|
543
|
+
content: await displayBlocks(message.content, ctx.get('attachments'), mediaOptions, options.signal, budget),
|
|
544
|
+
}))).then((messages) => ({ ...input, messages })));
|
|
545
|
+
}
|
|
400
546
|
// The rawest request a plugin can observe: the loop-built GenerateOptions
|
|
401
547
|
// verbatim, pre-adapter (the provider HTTP body is assembled inside the
|
|
402
548
|
// adapter — dsh exposes no seam for it). Nested under the generation,
|
|
@@ -404,49 +550,105 @@ export function apply(ctx, config) {
|
|
|
404
550
|
// chunk stream (the raw response at this seam).
|
|
405
551
|
const requestSpan = reporter.startSpan(generation, {
|
|
406
552
|
name: 'llm-request',
|
|
407
|
-
input
|
|
553
|
+
input,
|
|
408
554
|
metadata: { provider: options.provider },
|
|
409
555
|
});
|
|
410
556
|
const rawChunks = [];
|
|
411
|
-
|
|
557
|
+
let chunkCount = 0;
|
|
558
|
+
let toolCallCount = 0;
|
|
559
|
+
let text = '';
|
|
560
|
+
let reasoning = '';
|
|
561
|
+
const toolCalls = [];
|
|
562
|
+
let usage;
|
|
563
|
+
let finish;
|
|
564
|
+
let completionStartTime;
|
|
565
|
+
let thrownMessage;
|
|
566
|
+
const outputBody = () => config.captureContent
|
|
567
|
+
? toolCalls.length > 0 || reasoning
|
|
568
|
+
? {
|
|
569
|
+
role: 'assistant',
|
|
570
|
+
...(text ? { content: text } : {}),
|
|
571
|
+
...(reasoning ? { thinking: [{ type: 'thinking', content: reasoning }] } : {}),
|
|
572
|
+
...(toolCalls.length ? { tool_calls: toolCalls } : {}),
|
|
573
|
+
}
|
|
574
|
+
: text || undefined
|
|
575
|
+
: undefined;
|
|
576
|
+
let closed = false;
|
|
577
|
+
const closeCall = () => {
|
|
578
|
+
if (closed)
|
|
579
|
+
return;
|
|
580
|
+
closed = true;
|
|
581
|
+
const endedAt = new Date();
|
|
582
|
+
openOperations.delete(closeCall);
|
|
583
|
+
const failure = finish && (finish.kind === 'error' || finish.kind === 'aborted') ? finish : undefined;
|
|
584
|
+
// No terminal finish chunk (the consumer abandoned the stream or
|
|
585
|
+
// the adapter cut it short): the call's outcome is unknown — mark
|
|
586
|
+
// it instead of posing as a normal completion.
|
|
587
|
+
let level = 'DEFAULT';
|
|
588
|
+
let statusMessage;
|
|
589
|
+
if (thrownMessage !== undefined) {
|
|
590
|
+
level = 'ERROR';
|
|
591
|
+
statusMessage = contentMessage(config, thrownMessage);
|
|
592
|
+
}
|
|
593
|
+
else if (failure) {
|
|
594
|
+
level = failure.kind === 'aborted' ? 'WARNING' : 'ERROR';
|
|
595
|
+
// `failure` is contractually required on error/aborted finishes,
|
|
596
|
+
// but a non-conformant adapter must never turn observability into
|
|
597
|
+
// a generator-level TypeError escaping into the agent loop.
|
|
598
|
+
statusMessage = contentMessage(config, failure.failure?.message ?? 'unknown failure');
|
|
599
|
+
}
|
|
600
|
+
else if (!finish) {
|
|
601
|
+
level = 'WARNING';
|
|
602
|
+
statusMessage = 'stream closed before the terminal finish chunk';
|
|
603
|
+
}
|
|
412
604
|
reporter.endSpan(requestSpan, {
|
|
413
|
-
output: config.captureContent ? rawChunks : undefined,
|
|
605
|
+
output: config.captureContent ? redactChunks(rawChunks, reporter) : undefined,
|
|
414
606
|
level,
|
|
415
607
|
statusMessage,
|
|
416
|
-
metadata: { chunkCount
|
|
417
|
-
});
|
|
608
|
+
metadata: { chunkCount },
|
|
609
|
+
}, endedAt);
|
|
610
|
+
if (level === 'ERROR')
|
|
611
|
+
noteError(options.sessionId);
|
|
612
|
+
// Partial progress counts on every path: whatever streamed is kept.
|
|
613
|
+
afterMedia(generation, (endedAt) => reporter.endGeneration(generation, {
|
|
614
|
+
name: config.captureContent
|
|
615
|
+
? enhanceWithFirstLine(generationName, reporter.mask(text))
|
|
616
|
+
: undefined,
|
|
617
|
+
output: outputBody(),
|
|
618
|
+
usage,
|
|
619
|
+
completionStartTime,
|
|
620
|
+
level,
|
|
621
|
+
statusMessage,
|
|
622
|
+
metadata: {
|
|
623
|
+
finishReason: finish?.kind,
|
|
624
|
+
errorCode: failure?.failure?.code,
|
|
625
|
+
incomplete: finish ? undefined : true,
|
|
626
|
+
toolCallCount: toolCallCount > 0 ? toolCallCount : undefined,
|
|
627
|
+
},
|
|
628
|
+
}, endedAt), endedAt);
|
|
629
|
+
reporter.endSpan(oneOffRoot, {}, endedAt);
|
|
418
630
|
};
|
|
631
|
+
openOperations.add(closeCall);
|
|
419
632
|
let stream;
|
|
420
633
|
try {
|
|
421
634
|
stream = next();
|
|
422
635
|
}
|
|
423
636
|
catch (error) {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
reporter.endGeneration(generation, { level: 'ERROR', statusMessage });
|
|
427
|
-
reporter.endSpan(oneOffRoot);
|
|
637
|
+
thrownMessage = messageOf(error);
|
|
638
|
+
closeCall();
|
|
428
639
|
throw error;
|
|
429
640
|
}
|
|
430
|
-
//
|
|
431
|
-
// generation from the terminal `finish` chunk and accumulated content.
|
|
432
|
-
// Stream failures surface as a `finish` chunk with an error/aborted
|
|
433
|
-
// reason rather than throwing; the single close in `finally` also covers
|
|
434
|
-
// consumer abandonment and mid-stream throws.
|
|
641
|
+
// Replay unchanged; unload can finish the observation before this iterator settles.
|
|
435
642
|
return (async function* () {
|
|
436
|
-
let text = '';
|
|
437
|
-
const toolCalls = [];
|
|
438
|
-
let usage;
|
|
439
|
-
let finish;
|
|
440
|
-
let completionStartTime;
|
|
441
|
-
let thrownMessage;
|
|
442
|
-
const outputBody = () => config.captureContent
|
|
443
|
-
? toolCalls.length > 0
|
|
444
|
-
? { text, toolCalls }
|
|
445
|
-
: text || undefined
|
|
446
|
-
: undefined;
|
|
447
643
|
try {
|
|
448
644
|
for await (const chunk of stream) {
|
|
449
|
-
|
|
645
|
+
if (closed) {
|
|
646
|
+
yield chunk;
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
chunkCount += 1;
|
|
650
|
+
if (config.captureContent)
|
|
651
|
+
rawChunks.push(chunk);
|
|
450
652
|
// TTFT is the first real token — block boundaries, usage frames and
|
|
451
653
|
// empty heartbeat deltas are not completions (the predicate above).
|
|
452
654
|
if (completionStartTime === undefined && isTokenDelta(chunk)) {
|
|
@@ -454,11 +656,22 @@ export function apply(ctx, config) {
|
|
|
454
656
|
}
|
|
455
657
|
switch (chunk.type) {
|
|
456
658
|
case 'text-delta':
|
|
457
|
-
|
|
659
|
+
if (config.captureContent)
|
|
660
|
+
text += chunk.text;
|
|
661
|
+
break;
|
|
662
|
+
case 'reasoning-delta':
|
|
663
|
+
if (config.captureContent)
|
|
664
|
+
reasoning += chunk.text;
|
|
458
665
|
break;
|
|
459
666
|
case 'block-end':
|
|
460
667
|
if (chunk.block.type === 'tool-call') {
|
|
461
|
-
|
|
668
|
+
toolCallCount += 1;
|
|
669
|
+
if (config.captureContent)
|
|
670
|
+
toolCalls.push({
|
|
671
|
+
id: chunk.block.id,
|
|
672
|
+
type: 'function',
|
|
673
|
+
function: { name: chunk.block.name, arguments: chunk.block.arguments },
|
|
674
|
+
});
|
|
462
675
|
}
|
|
463
676
|
break;
|
|
464
677
|
case 'usage':
|
|
@@ -479,44 +692,7 @@ export function apply(ctx, config) {
|
|
|
479
692
|
throw error;
|
|
480
693
|
}
|
|
481
694
|
finally {
|
|
482
|
-
|
|
483
|
-
// No terminal finish chunk (the consumer abandoned the stream or
|
|
484
|
-
// the adapter cut it short): the call's outcome is unknown — mark
|
|
485
|
-
// it instead of posing as a normal completion.
|
|
486
|
-
let level = 'DEFAULT';
|
|
487
|
-
let statusMessage;
|
|
488
|
-
if (thrownMessage !== undefined) {
|
|
489
|
-
level = 'ERROR';
|
|
490
|
-
statusMessage = contentMessage(config, thrownMessage);
|
|
491
|
-
}
|
|
492
|
-
else if (failure) {
|
|
493
|
-
level = failure.kind === 'aborted' ? 'WARNING' : 'ERROR';
|
|
494
|
-
// `failure` is contractually required on error/aborted finishes,
|
|
495
|
-
// but a non-conformant adapter must never turn observability into
|
|
496
|
-
// a generator-level TypeError escaping into the agent loop.
|
|
497
|
-
statusMessage = contentMessage(config, failure.failure?.message ?? 'unknown failure');
|
|
498
|
-
}
|
|
499
|
-
else if (!finish) {
|
|
500
|
-
level = 'WARNING';
|
|
501
|
-
statusMessage = 'stream closed before the terminal finish chunk';
|
|
502
|
-
}
|
|
503
|
-
closeRequest(level, statusMessage);
|
|
504
|
-
// Partial progress counts on every path: whatever streamed is kept.
|
|
505
|
-
reporter.endGeneration(generation, {
|
|
506
|
-
name: enhanceWithFirstLine(generationName, text),
|
|
507
|
-
output: outputBody(),
|
|
508
|
-
usage,
|
|
509
|
-
completionStartTime,
|
|
510
|
-
level,
|
|
511
|
-
statusMessage,
|
|
512
|
-
metadata: {
|
|
513
|
-
finishReason: finish?.kind,
|
|
514
|
-
errorCode: failure?.failure?.code,
|
|
515
|
-
incomplete: finish ? undefined : true,
|
|
516
|
-
toolCallCount: toolCalls.length > 0 ? toolCalls.length : undefined,
|
|
517
|
-
},
|
|
518
|
-
});
|
|
519
|
-
reporter.endSpan(oneOffRoot);
|
|
695
|
+
closeCall();
|
|
520
696
|
}
|
|
521
697
|
})();
|
|
522
698
|
});
|
|
@@ -533,65 +709,98 @@ export function apply(ctx, config) {
|
|
|
533
709
|
// it — it rides this dispatch's (same one-off rule as llm/stream).
|
|
534
710
|
const oneOffRoot = sessionId ? null : parent;
|
|
535
711
|
// Argument summaries are argument content — they ride captureContent.
|
|
536
|
-
const
|
|
712
|
+
const args = config.captureContent ? reporter.mask(exec.arguments) : undefined;
|
|
713
|
+
const argSummary = config.captureContent ? toolCallSummary(args) : undefined;
|
|
537
714
|
const span = reporter.startSpan(parent, {
|
|
538
715
|
name: `tool:${exec.name}${argSummary ? ` [${argSummary}]` : ''}`,
|
|
539
|
-
|
|
716
|
+
asType: 'tool',
|
|
717
|
+
input: args,
|
|
540
718
|
metadata: { callId: exec.callId, toolName: exec.name },
|
|
541
719
|
});
|
|
542
720
|
if (sessionId && span) {
|
|
543
721
|
openToolSpans.set(sessionId, [...(openToolSpans.get(sessionId) ?? []), span]);
|
|
544
722
|
}
|
|
723
|
+
let closed = false;
|
|
724
|
+
const closeTool = () => finishTool({ level: 'WARNING', statusMessage: 'plugin unloaded during tool call' });
|
|
725
|
+
function finishTool(update, display) {
|
|
726
|
+
if (closed)
|
|
727
|
+
return;
|
|
728
|
+
closed = true;
|
|
729
|
+
const endedAt = new Date();
|
|
730
|
+
openOperations.delete(closeTool);
|
|
731
|
+
if (sessionId && span) {
|
|
732
|
+
const remaining = (openToolSpans.get(sessionId) ?? []).filter((s) => s !== span);
|
|
733
|
+
if (remaining.length)
|
|
734
|
+
openToolSpans.set(sessionId, remaining);
|
|
735
|
+
else
|
|
736
|
+
openToolSpans.delete(sessionId);
|
|
737
|
+
}
|
|
738
|
+
const end = (output = update.output) => {
|
|
739
|
+
reporter.endSpan(span, { ...update, output }, endedAt);
|
|
740
|
+
reporter.endSpan(oneOffRoot, {}, endedAt);
|
|
741
|
+
};
|
|
742
|
+
if (display)
|
|
743
|
+
trackMedia(display.then(end).catch((error) => {
|
|
744
|
+
console.error('[dsh-langfuse] media projection failed:', error);
|
|
745
|
+
end();
|
|
746
|
+
}));
|
|
747
|
+
else
|
|
748
|
+
end();
|
|
749
|
+
}
|
|
750
|
+
openOperations.add(closeTool);
|
|
545
751
|
try {
|
|
546
752
|
const result = await next();
|
|
547
|
-
|
|
753
|
+
if (closed)
|
|
754
|
+
return result;
|
|
755
|
+
if (result.isError)
|
|
756
|
+
noteError(sessionId);
|
|
757
|
+
const update = {
|
|
548
758
|
output: config.captureContent ? result.content : undefined,
|
|
549
|
-
level: result.isError ? 'ERROR' : 'DEFAULT',
|
|
759
|
+
level: (result.isError ? 'ERROR' : 'DEFAULT'),
|
|
550
760
|
statusMessage: result.isError ? contentMessage(config, result.error.message) : undefined,
|
|
551
761
|
metadata: { errorCode: result.isError ? result.error.info?.code : undefined },
|
|
552
|
-
}
|
|
762
|
+
};
|
|
763
|
+
if (span && config.captureContent && result.content.some((block) => block.type === 'image')) {
|
|
764
|
+
finishTool(update, displayBlocks(result.content, ctx.get('attachments'), mediaOptions, exec.signal));
|
|
765
|
+
}
|
|
766
|
+
else
|
|
767
|
+
finishTool(update);
|
|
553
768
|
return result;
|
|
554
769
|
}
|
|
555
770
|
catch (error) {
|
|
556
|
-
|
|
771
|
+
noteError(sessionId);
|
|
772
|
+
finishTool({
|
|
557
773
|
level: 'ERROR',
|
|
558
774
|
statusMessage: contentMessage(config, messageOf(error)),
|
|
559
775
|
});
|
|
560
776
|
throw error;
|
|
561
777
|
}
|
|
562
|
-
finally {
|
|
563
|
-
reporter.endSpan(oneOffRoot);
|
|
564
|
-
if (sessionId && span) {
|
|
565
|
-
const remaining = (openToolSpans.get(sessionId) ?? []).filter((s) => s !== span);
|
|
566
|
-
if (remaining.length > 0)
|
|
567
|
-
openToolSpans.set(sessionId, remaining);
|
|
568
|
-
else
|
|
569
|
-
openToolSpans.delete(sessionId);
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
778
|
});
|
|
573
779
|
// Durability checkpoint: the store awaits every session/flush listener.
|
|
574
|
-
ctx.on('session/flush', () =>
|
|
780
|
+
ctx.on('session/flush', async () => {
|
|
781
|
+
await drainMedia();
|
|
782
|
+
await reporter.flush();
|
|
783
|
+
});
|
|
575
784
|
// Lifecycle: this cordis fork has no ready/dispose events — cleanup rides
|
|
576
785
|
// the fiber unload via ctx.effect() (shutdown() chains behind the lazy
|
|
577
786
|
// import, so a still-importing SDK is never shut down mid-construction).
|
|
578
787
|
ctx.effect(() => {
|
|
579
788
|
return async () => {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
// isn't tracked; its own tee finally closes it if the stream settles.)
|
|
789
|
+
const endedAt = new Date();
|
|
790
|
+
for (const close of openOperations)
|
|
791
|
+
close();
|
|
584
792
|
for (const sessionId of openHooks.keys())
|
|
585
793
|
closeHooks(sessionId);
|
|
794
|
+
await drainMedia();
|
|
795
|
+
// End every tracked observation still open before shutting the exporter
|
|
796
|
+
// down — v5 spans only export on end, so unloading mid-turn (profile
|
|
797
|
+
// reload) would otherwise silently drop them.
|
|
586
798
|
for (const state of sessions.values())
|
|
587
|
-
reporter.endSpan(state.trace);
|
|
799
|
+
reporter.endSpan(state.trace, {}, endedAt);
|
|
588
800
|
for (const child of children.values())
|
|
589
|
-
reporter.endSpan(child.span);
|
|
590
|
-
for (const spans of openToolSpans.values()) {
|
|
591
|
-
for (const span of spans)
|
|
592
|
-
reporter.endSpan(span);
|
|
593
|
-
}
|
|
801
|
+
reporter.endSpan(child.span, {}, endedAt);
|
|
594
802
|
sessions.clear();
|
|
803
|
+
traceContexts.clear();
|
|
595
804
|
children.clear();
|
|
596
805
|
openToolSpans.clear();
|
|
597
806
|
await reporter.shutdown();
|