@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/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 = full request; output, token usage and finish reason collected
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
- * verbatim loop-built request and the collected chunk stream (the rawest
10
- * request/response a plugin can observe — the provider HTTP body is
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().default('https://cloud.langfuse.com'),
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: verbatim (minus the AbortSignal,
92
- * which is not JSON-safe) when content capture is on, counts-only otherwise.
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 || !config.publicKey || !config.secretKey)
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
- let id = sessionId;
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 = reporter.openTrace({ name: config.traceName, sessionId: id });
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
- reporter.updateSpan(child.span, { input: messageText(event.data) });
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 = reporter.openTrace({
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
- reporter.updateSpan(state.trace, { input: messageText(event.data) });
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
- metadata: { turn: event.data.turn, endReason: event.data.reason.kind },
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: config.captureContent
395
- ? { messages: options.messages, system: options.system, tools: options.tools }
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: requestBodyOf(options, config.captureContent),
553
+ input,
408
554
  metadata: { provider: options.provider },
409
555
  });
410
556
  const rawChunks = [];
411
- const closeRequest = (level, statusMessage) => {
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: rawChunks.length },
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
- const statusMessage = contentMessage(config, messageOf(error));
425
- closeRequest('ERROR', statusMessage);
426
- reporter.endGeneration(generation, { level: 'ERROR', statusMessage });
427
- reporter.endSpan(oneOffRoot);
637
+ thrownMessage = messageOf(error);
638
+ closeCall();
428
639
  throw error;
429
640
  }
430
- // Tee the chunk stream: replay every chunk unchanged, then close the
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
- rawChunks.push(chunk);
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
- text += chunk.text;
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
- toolCalls.push({ name: chunk.block.name, arguments: chunk.block.arguments });
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
- const failure = finish && (finish.kind === 'error' || finish.kind === 'aborted') ? finish : undefined;
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 argSummary = config.captureContent ? toolCallSummary(exec.arguments) : undefined;
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
- input: config.captureContent ? exec.arguments : undefined,
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
- reporter.endSpan(span, {
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
- reporter.endSpan(span, {
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', () => reporter.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
- // End every tracked observation still open before shutting the exporter
581
- // down — v5 spans only export on end, so unloading mid-turn (profile
582
- // reload) would otherwise silently drop them. (An in-flight generation
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();