@amaster.ai/dsh-langfuse 0.1.38 → 0.1.39

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 CHANGED
@@ -37,7 +37,7 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
37
37
  baseUrl: https://cloud.langfuse.com
38
38
  traceName: dsh-turn
39
39
  captureContent: true # set false to record metadata only
40
- maxPayloadBytes: 262144 # per-LLM-call request/raw/output cap (256 KiB)
40
+ maxPayloadBytes: 262144 # LLM payload and tool IO cap (256 KiB)
41
41
  userId: developer
42
42
  tags: [coding, experiment-a]
43
43
  environment: development
@@ -50,11 +50,11 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
50
50
 
51
51
  Explicit profile values take precedence over environment variables. Empty credentials/base URL fall back to `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, and `LANGFUSE_BASE_URL` (then the EU cloud URL). `userId` falls back to `LANGFUSE_USER_ID`. Empty environment/release values use the SDK's `LANGFUSE_TRACING_ENVIRONMENT` / `LANGFUSE_RELEASE` defaults. `LANGFUSE_TRACING_ENABLED=false` (or `0`) overrides the profile master switch; environment keys alone do not enable the plugin.
52
52
 
53
- Buffered spans drain on `session/flush(session)`; the exporter shuts down with the plugin fiber. Both checkpoints await pending display projections. Unload also closes outstanding model calls with their collected partial content/usage and marks incomplete calls `WARNING`, including calls whose stream was never consumed; the original stream can still settle without ending those observations twice. Attachment reads run in the background and never delay the agent's chunk stream or tool return value; a read that exceeds its deadline degrades to image metadata, even if the backend ignores cancellation. The SDK's `LANGFUSE_FLUSH_AT`, `LANGFUSE_FLUSH_INTERVAL`, and `LANGFUSE_TIMEOUT` control exporter batching and network timeouts.
53
+ Buffered spans drain on `session/flush(session)`; the exporter shuts down with the plugin fiber. A session checkpoint awaits its own pending display projections, including descendant-session work already attached to its trace; unrelated sessions do not delay it. Session-less projections are included at any checkpoint. Unload drains all pending projections. The SDK exporter remains shared by the plugin. Unload also closes outstanding model calls with their collected partial content/usage and marks incomplete calls `WARNING`, including calls whose stream was never consumed; the original stream can still settle without ending those observations twice. Attachment reads run in the background and never delay the agent's chunk stream or tool return value; a read that exceeds its deadline degrades to image metadata, even if the backend ignores cancellation. The SDK's `LANGFUSE_FLUSH_AT`, `LANGFUSE_FLUSH_INTERVAL`, and `LANGFUSE_TIMEOUT` control exporter batching and network timeouts.
54
54
 
55
55
  With `captureContent: true`, both `llm-call` and `llm-request` inputs contain every `llm/stream` request option except the non-serializable `AbortSignal`. New request fields pass through without a plugin update. For image-bearing requests, generation input is updated to a display projection; `llm-request` retains the original attachment references. Langfuse's separate generation `modelParameters` attribute summarizes the known sampling fields; it is not part of either request input. These are dsh-side values, not exact provider HTTP payloads: adapters may add defaults, project messages/tools, or rename fields after this seam. With `captureContent: false`, the collector keeps chunk/tool-call counts and usage without buffering text, reasoning, tool arguments, or raw chunks; inputs keep structural counts while the separate `modelParameters` attribute keeps safe parameter metadata; reply excerpts, reasoning, tool arguments/results, error text, and media are withheld from observation names and IO.
56
56
 
57
- `maxPayloadBytes` independently bounds each captured request, raw chunk stream, and assembled output per LLM call (default 256 KiB). Oversized requests become a bounded preview; oversized streams retain only fitting chunks/prefixes and set `metadata.truncated`. These limits apply only to telemetry copies: forwarded chunks and original messages/results stay unchanged. Session disposal closes that session's active LLM/tool observations; a child whose activation rolls back before `subagent/start` closes immediately, while an already-started child still closes at `subagent/end`.
57
+ `maxPayloadBytes` independently bounds each captured request, raw chunk stream, and assembled output per LLM call, and each tool input, output, and error status message (default 256 KiB). Span updates and endings also bound IO at the reporter boundary, including ordinary turn roots and A2A task roots; the legacy trace IO attributes receive the same bounded values. Oversized structured payloads become a bounded preview; oversized streams retain only fitting chunks/prefixes and set `metadata.truncated`. Oversized tool error status messages are omitted while severity and `metadata.truncated` remain available. These limits apply only to telemetry copies: forwarded chunks and original messages/results stay unchanged. Session disposal closes that session's active LLM/tool observations; a child whose activation rolls back before `subagent/start` closes immediately, while an already-started child still closes at `subagent/end`.
58
58
 
59
59
  Configured Langfuse credentials and `pk-lf-*` / `sk-lf-*` values are redacted across reporter payloads, names, status messages, and labels. `redactFields` replaces matching payload fields recursively, including JSON-encoded arguments, ignoring case. Name excerpts are derived after redaction. Delta blocks are also checked as assembled text so fragmented credentials cannot bypass the mask: affected blocks keep their record count/type/index, with sanitized text in the first delta and later deltas cleared. Redaction only changes telemetry copies; original messages/results and the forwarded stream stay intact. The exporter applies the same mask to IO and metadata as a final safeguard. User/tags are explicit metadata and remain when content capture is off.
60
60
 
package/lib/client.js CHANGED
@@ -33,6 +33,7 @@
33
33
  * before readiness are still dropped — a window that cannot exist once the
34
34
  * plugin fiber has been awaited.
35
35
  */
36
+ import { boundPayload, newPayloadBudget } from './payload.js';
36
37
  /**
37
38
  * OTEL's canonical invalid span context as a literal (a static
38
39
  * `INVALID_SPAN_CONTEXT` import would load @opentelemetry/api eagerly, even
@@ -419,7 +420,13 @@ export class LangfuseReporter {
419
420
  if (!span)
420
421
  return;
421
422
  try {
422
- update = this.mask(update);
423
+ const budget = newPayloadBudget(this.config.maxPayloadBytes ?? 256 * 1024);
424
+ const input = update.input === undefined ? undefined : boundPayload(update.input, budget, this);
425
+ update = this.mask({
426
+ ...update,
427
+ input,
428
+ ...(budget.truncated ? { metadata: { ...update.metadata, truncated: true } } : {}),
429
+ });
423
430
  span.update({ input: update.input, metadata: update.metadata });
424
431
  if (update.name)
425
432
  span.otelSpan.updateName(update.name);
@@ -441,7 +448,13 @@ export class LangfuseReporter {
441
448
  if (!span)
442
449
  return;
443
450
  try {
444
- update = this.mask(update);
451
+ const budget = newPayloadBudget(this.config.maxPayloadBytes ?? 256 * 1024);
452
+ const output = update.output === undefined ? undefined : boundPayload(update.output, budget, this);
453
+ update = this.mask({
454
+ ...update,
455
+ output,
456
+ ...(budget.truncated ? { metadata: { ...update.metadata, truncated: true } } : {}),
457
+ });
445
458
  span.update(update);
446
459
  if (update.output !== undefined && this.roots.has(span)) {
447
460
  span.setTraceIO({ output: update.output });
package/lib/index.js CHANGED
@@ -35,6 +35,7 @@ import Schema from '@deepseek-ai/schemastery';
35
35
  import { LangfuseReporter } from './client.js';
36
36
  import { isHookInvoked, isHookResult } from './hooks.js';
37
37
  import { displayBlocks } from './media.js';
38
+ import { boundPayload, newPayloadBudget, takePayloadText } from './payload.js';
38
39
  export { appendHookEvent } from './hooks.js';
39
40
  export const name = 'dsh-langfuse';
40
41
  /**
@@ -58,7 +59,7 @@ export const Config = Schema.object({
58
59
  .min(1)
59
60
  .step(1)
60
61
  .default(256 * 1024)
61
- .description('maximum captured request, raw-stream, and assembled-output bytes per LLM call'),
62
+ .description('maximum captured request, raw-stream, assembled-output, or tool input/output/status bytes'),
62
63
  redactFields: Schema.array(Schema.string())
63
64
  .default([])
64
65
  .description('case-insensitive payload fields to redact'),
@@ -130,195 +131,6 @@ function requestBodyOf(options, captureContent) {
130
131
  const { signal: _, ...body } = options;
131
132
  return body;
132
133
  }
133
- const newPayloadBudget = (maxBytes) => ({
134
- remaining: maxBytes,
135
- truncated: false,
136
- });
137
- /** Take a UTF-8 prefix without ever exceeding the remaining content-byte budget. */
138
- function takePayloadText(value, budget) {
139
- const bytes = Buffer.byteLength(value);
140
- if (bytes <= budget.remaining) {
141
- budget.remaining -= bytes;
142
- return value;
143
- }
144
- budget.truncated = true;
145
- if (budget.remaining <= 0)
146
- return '';
147
- let low = 0;
148
- let high = value.length;
149
- while (low < high) {
150
- const middle = Math.ceil((low + high) / 2);
151
- if (Buffer.byteLength(value.slice(0, middle)) <= budget.remaining)
152
- low = middle;
153
- else
154
- high = middle - 1;
155
- }
156
- budget.remaining = 0;
157
- return value.slice(0, low);
158
- }
159
- /** Build a preview whose complete JSON value, including its wrapper, fits the remaining cap. */
160
- function previewPayload(serialized, budget) {
161
- const remaining = budget.remaining;
162
- const marker = { truncated: true };
163
- const withPreview = { ...marker, preview: '' };
164
- if (Buffer.byteLength(JSON.stringify(withPreview)) <= remaining) {
165
- let low = 0;
166
- let high = serialized.length;
167
- while (low < high) {
168
- const middle = Math.ceil((low + high) / 2);
169
- const candidate = { ...marker, preview: serialized.slice(0, middle) };
170
- if (Buffer.byteLength(JSON.stringify(candidate)) <= remaining)
171
- low = middle;
172
- else
173
- high = middle - 1;
174
- }
175
- budget.remaining = 0;
176
- return { ...marker, preview: serialized.slice(0, low) };
177
- }
178
- if (Buffer.byteLength(JSON.stringify(marker)) <= remaining) {
179
- budget.remaining = 0;
180
- return marker;
181
- }
182
- // Even the marker is too large: retain a plain string prefix, still capped
183
- // after JSON quoting/escaping rather than by source-string bytes.
184
- let low = 0;
185
- let high = serialized.length;
186
- while (low < high) {
187
- const middle = Math.ceil((low + high) / 2);
188
- if (Buffer.byteLength(JSON.stringify(serialized.slice(0, middle))) <= remaining)
189
- low = middle;
190
- else
191
- high = middle - 1;
192
- }
193
- budget.remaining = 0;
194
- return low > 0 ? serialized.slice(0, low) : remaining >= 2 ? '' : 0;
195
- }
196
- const MAX_ATOMIC_MASK_BYTES = 64 * 1024;
197
- /** Redact leaves while serializing, so an oversized payload never needs a full mask or stringify. */
198
- function boundMaskedPayload(input, budget, reporter) {
199
- const seen = new WeakSet();
200
- const parts = [];
201
- let remaining = budget.remaining;
202
- let overflow = false;
203
- const append = (text) => {
204
- if (overflow || text === undefined)
205
- return;
206
- const bytes = Buffer.byteLength(text);
207
- if (bytes > remaining) {
208
- // The node was already masked atomically; a serialized prefix is safe.
209
- parts.push(takePayloadText(text, { remaining, truncated: true }));
210
- remaining = 0;
211
- overflow = true;
212
- return;
213
- }
214
- parts.push(text);
215
- remaining -= bytes;
216
- };
217
- const write = (value) => {
218
- if (overflow || value === undefined)
219
- return undefined;
220
- if (value === null || typeof value !== 'object' || value instanceof Date) {
221
- if (typeof value === 'string' &&
222
- Buffer.byteLength(value) > MAX_ATOMIC_MASK_BYTES &&
223
- Buffer.byteLength(value) > remaining) {
224
- overflow = true;
225
- return undefined;
226
- }
227
- const masked = reporter.mask(value);
228
- append(JSON.stringify(masked));
229
- return masked;
230
- }
231
- if (seen.has(value)) {
232
- append(JSON.stringify('[Circular]'));
233
- return '[Circular]';
234
- }
235
- seen.add(value);
236
- if (Array.isArray(value)) {
237
- append('[');
238
- const result = [];
239
- for (const [index, item] of value.entries()) {
240
- if (overflow)
241
- break;
242
- if (index > 0)
243
- append(',');
244
- if (item === undefined) {
245
- append('null');
246
- result.push(null);
247
- }
248
- else {
249
- const masked = write(item);
250
- if (!overflow)
251
- result.push(masked);
252
- }
253
- }
254
- append(']');
255
- seen.delete(value);
256
- return result;
257
- }
258
- append('{');
259
- const result = Object.create(null);
260
- let first = true;
261
- for (const key in value) {
262
- if (overflow)
263
- break;
264
- if (!Object.hasOwn(value, key))
265
- continue;
266
- const child = value[key];
267
- if (child === undefined && !reporter.shouldRedactField(key))
268
- continue;
269
- if (!first)
270
- append(',');
271
- first = false;
272
- append(JSON.stringify(reporter.mask(key)));
273
- append(':');
274
- if (reporter.shouldRedactField(key)) {
275
- append(JSON.stringify('[REDACTED]'));
276
- result[key] = '[REDACTED]';
277
- }
278
- else {
279
- const masked = write(child);
280
- if (!overflow)
281
- result[key] = masked;
282
- }
283
- }
284
- append('}');
285
- seen.delete(value);
286
- return result;
287
- };
288
- try {
289
- const masked = write(input);
290
- if (!overflow) {
291
- budget.remaining = remaining;
292
- return masked;
293
- }
294
- }
295
- catch {
296
- budget.truncated = true;
297
- return previewPayload('', budget);
298
- }
299
- budget.truncated = true;
300
- return previewPayload(parts.join(''), budget);
301
- }
302
- /** Bound a structured payload without changing its shape when it fits. */
303
- function boundPayload(input, budget, reporter) {
304
- if (reporter)
305
- return boundMaskedPayload(input, budget, reporter);
306
- let serialized;
307
- try {
308
- serialized = JSON.stringify(input) ?? '';
309
- }
310
- catch {
311
- budget.truncated = true;
312
- return previewPayload('', budget);
313
- }
314
- const bytes = Buffer.byteLength(serialized);
315
- if (bytes <= budget.remaining) {
316
- budget.remaining -= bytes;
317
- return input;
318
- }
319
- budget.truncated = true;
320
- return previewPayload(serialized, budget);
321
- }
322
134
  /** Keep only whole chunks that fit; stream accounting still processes every original chunk. */
323
135
  function capturePayloadChunk(chunk, chunks, budget) {
324
136
  if (budget.remaining <= 0) {
@@ -530,7 +342,7 @@ export function apply(ctx, config) {
530
342
  const openOperations = new Set();
531
343
  const operationSessions = new Map();
532
344
  const traceContexts = new Map();
533
- const pendingMedia = new Set();
345
+ const pendingMedia = new Map();
534
346
  const mediaFor = new WeakMap();
535
347
  const mediaOptions = {
536
348
  enabled: config.captureContent &&
@@ -539,36 +351,55 @@ export function apply(ctx, config) {
539
351
  maxBytes: config.maxMediaBytes ?? 5 * 1024 * 1024,
540
352
  timeoutMs: config.mediaTimeoutMs ?? 5000,
541
353
  };
542
- function trackMedia(task) {
354
+ function mediaSessions(sessionId) {
355
+ const owners = [];
356
+ while (sessionId && !owners.includes(sessionId)) {
357
+ owners.push(sessionId);
358
+ sessionId = children.get(sessionId)?.parentSessionId;
359
+ }
360
+ return owners;
361
+ }
362
+ function trackMedia(task, owners) {
543
363
  const pending = task.catch((error) => {
544
364
  console.error('[dsh-langfuse] media finalization failed:', error);
545
365
  });
546
- pendingMedia.add(pending);
366
+ pendingMedia.set(pending, owners);
547
367
  void pending.then(() => pendingMedia.delete(pending));
548
368
  return pending;
549
369
  }
550
- function enrichInput(handle, input) {
370
+ function enrichInput(handle, input, sessionId) {
551
371
  if (!handle)
552
372
  return;
553
- mediaFor.set(handle, trackMedia(input.then((value) => reporter.updateSpan(handle, { input: value }))));
373
+ const owners = mediaSessions(sessionId);
374
+ mediaFor.set(handle, {
375
+ pending: trackMedia(input.then((value) => reporter.updateSpan(handle, { input: value })), owners),
376
+ sessions: owners,
377
+ });
554
378
  }
555
379
  function afterMedia(handle, close, endedAt = new Date()) {
556
380
  const task = handle ? mediaFor.get(handle) : undefined;
557
381
  if (task)
558
- trackMedia(task.then(() => close(endedAt)));
382
+ trackMedia(task.pending.then(() => close(endedAt)), task.sessions);
559
383
  else
560
384
  close(endedAt);
561
385
  }
562
- function setMessageInput(handle, content) {
386
+ function setMessageInput(handle, content, sessionId) {
563
387
  if (content.some((block) => block.type !== 'text')) {
564
- enrichInput(handle, displayBlocks(content, ctx.get('attachments'), mediaOptions));
388
+ enrichInput(handle, displayBlocks(content, ctx.get('attachments'), mediaOptions), sessionId);
565
389
  }
566
390
  else
567
391
  reporter.updateSpan(handle, { input: messageText({ content }) });
568
392
  }
569
- async function drainMedia() {
570
- while (pendingMedia.size)
571
- await Promise.all([...pendingMedia]);
393
+ async function drainMedia(sessionId) {
394
+ for (;;) {
395
+ // Session-less observations have no narrower durability checkpoint.
396
+ const pending = [...pendingMedia]
397
+ .filter(([, owners]) => !sessionId || !owners.length || owners.includes(sessionId))
398
+ .map(([task]) => task);
399
+ if (!pending.length)
400
+ return;
401
+ await Promise.all(pending);
402
+ }
572
403
  }
573
404
  ctx.on('langfuse/context', (sessionId, context) => {
574
405
  traceContexts.delete(sessionId);
@@ -1100,7 +931,7 @@ export function apply(ctx, config) {
1100
931
  // The delegated prompt (the child's first user/message) is the
1101
932
  // subagent span's input; the descriptor enriches the span name.
1102
933
  if (event.type === 'user/message' && !child.hasInput && config.captureContent) {
1103
- setMessageInput(child.span, event.data.content);
934
+ setMessageInput(child.span, event.data.content, sessionId);
1104
935
  child.hasInput = true;
1105
936
  }
1106
937
  else if (event.type === 'subagent/descriptor') {
@@ -1141,7 +972,7 @@ export function apply(ctx, config) {
1141
972
  traceFor(sessionId);
1142
973
  const state = sessions.get(sessionId);
1143
974
  if (state && !state.hasInput && config.captureContent) {
1144
- setMessageInput(state.trace, event.data.content);
975
+ setMessageInput(state.trace, event.data.content, sessionId);
1145
976
  state.hasInput = true;
1146
977
  }
1147
978
  break;
@@ -1264,7 +1095,7 @@ export function apply(ctx, config) {
1264
1095
  const enriched = boundPayload({ ...requestBody, messages }, enrichmentBudget, reporter);
1265
1096
  requestBudget.truncated ||= enrichmentBudget.truncated;
1266
1097
  return enriched;
1267
- }));
1098
+ }), options.sessionId);
1268
1099
  }
1269
1100
  // The rawest request a plugin can observe: the loop-built GenerateOptions
1270
1101
  // verbatim, pre-adapter (the provider HTTP body is assembled inside the
@@ -1469,7 +1300,11 @@ export function apply(ctx, config) {
1469
1300
  // it — it rides this dispatch's (same one-off rule as llm/stream).
1470
1301
  const oneOffRoot = sessionId ? null : parent;
1471
1302
  // Argument summaries are argument content — they ride captureContent.
1472
- const args = config.captureContent ? reporter.mask(exec.arguments) : undefined;
1303
+ const maxPayloadBytes = config.maxPayloadBytes ?? 256 * 1024;
1304
+ const inputBudget = newPayloadBudget(maxPayloadBytes);
1305
+ const args = config.captureContent
1306
+ ? boundPayload(exec.arguments, inputBudget, reporter)
1307
+ : undefined;
1473
1308
  const argSummary = config.captureContent ? toolCallSummary(args) : undefined;
1474
1309
  const span = reporter.startSpan(parent, {
1475
1310
  name: `tool:${exec.name}${argSummary ? ` [${argSummary}]` : ''}`,
@@ -1510,14 +1345,31 @@ export function apply(ctx, config) {
1510
1345
  openToolSpans.delete(sessionId);
1511
1346
  }
1512
1347
  const end = (output = update.output) => {
1513
- reporter.endSpan(span, { ...update, output }, endedAt);
1348
+ const outputBudget = newPayloadBudget(maxPayloadBytes);
1349
+ const bounded = output === undefined ? undefined : boundPayload(output, outputBudget, reporter);
1350
+ const statusBudget = newPayloadBudget(maxPayloadBytes);
1351
+ const status = update.statusMessage === undefined
1352
+ ? undefined
1353
+ : boundPayload(update.statusMessage, statusBudget, reporter);
1354
+ reporter.endSpan(span, {
1355
+ ...update,
1356
+ output: bounded,
1357
+ statusMessage: typeof status === 'string' ? status : undefined,
1358
+ metadata: {
1359
+ ...update.metadata,
1360
+ truncated: inputBudget.truncated ||
1361
+ outputBudget.truncated ||
1362
+ statusBudget.truncated ||
1363
+ undefined,
1364
+ },
1365
+ }, endedAt);
1514
1366
  reporter.endSpan(oneOffRoot, {}, endedAt);
1515
1367
  };
1516
1368
  if (display)
1517
1369
  trackMedia(display.then(end).catch((error) => {
1518
1370
  console.error('[dsh-langfuse] media projection failed:', error);
1519
1371
  end();
1520
- }));
1372
+ }), mediaSessions(sessionId));
1521
1373
  else
1522
1374
  end();
1523
1375
  }
@@ -1548,8 +1400,8 @@ export function apply(ctx, config) {
1548
1400
  toolResults.get(exec)?.(result);
1549
1401
  });
1550
1402
  // Durability checkpoint: the store awaits every session/flush listener.
1551
- ctx.on('session/flush', async () => {
1552
- await drainMedia();
1403
+ ctx.on('session/flush', async (session) => {
1404
+ await drainMedia(session.id);
1553
1405
  await reporter.flush();
1554
1406
  });
1555
1407
  // Lifecycle: this cordis fork has no ready/dispose events — cleanup rides
package/lib/payload.js ADDED
@@ -0,0 +1,189 @@
1
+ export const newPayloadBudget = (maxBytes) => ({
2
+ remaining: maxBytes,
3
+ truncated: false,
4
+ });
5
+ /** Take a UTF-8 prefix without ever exceeding the remaining content-byte budget. */
6
+ export function takePayloadText(value, budget) {
7
+ const bytes = Buffer.byteLength(value);
8
+ if (bytes <= budget.remaining) {
9
+ budget.remaining -= bytes;
10
+ return value;
11
+ }
12
+ budget.truncated = true;
13
+ if (budget.remaining <= 0)
14
+ return '';
15
+ let low = 0;
16
+ let high = value.length;
17
+ while (low < high) {
18
+ const middle = Math.ceil((low + high) / 2);
19
+ if (Buffer.byteLength(value.slice(0, middle)) <= budget.remaining)
20
+ low = middle;
21
+ else
22
+ high = middle - 1;
23
+ }
24
+ budget.remaining = 0;
25
+ return value.slice(0, low);
26
+ }
27
+ /** Build a preview whose complete JSON value, including its wrapper, fits the remaining cap. */
28
+ function previewPayload(serialized, budget) {
29
+ const remaining = budget.remaining;
30
+ const marker = { truncated: true };
31
+ const withPreview = { ...marker, preview: '' };
32
+ if (Buffer.byteLength(JSON.stringify(withPreview)) <= remaining) {
33
+ let low = 0;
34
+ let high = serialized.length;
35
+ while (low < high) {
36
+ const middle = Math.ceil((low + high) / 2);
37
+ const candidate = { ...marker, preview: serialized.slice(0, middle) };
38
+ if (Buffer.byteLength(JSON.stringify(candidate)) <= remaining)
39
+ low = middle;
40
+ else
41
+ high = middle - 1;
42
+ }
43
+ budget.remaining = 0;
44
+ return { ...marker, preview: serialized.slice(0, low) };
45
+ }
46
+ if (Buffer.byteLength(JSON.stringify(marker)) <= remaining) {
47
+ budget.remaining = 0;
48
+ return marker;
49
+ }
50
+ // Even the marker is too large: retain a plain string prefix, still capped
51
+ // after JSON quoting/escaping rather than by source-string bytes.
52
+ let low = 0;
53
+ let high = serialized.length;
54
+ while (low < high) {
55
+ const middle = Math.ceil((low + high) / 2);
56
+ if (Buffer.byteLength(JSON.stringify(serialized.slice(0, middle))) <= remaining)
57
+ low = middle;
58
+ else
59
+ high = middle - 1;
60
+ }
61
+ budget.remaining = 0;
62
+ return low > 0 ? serialized.slice(0, low) : remaining >= 2 ? '' : 0;
63
+ }
64
+ const MAX_ATOMIC_MASK_BYTES = 64 * 1024;
65
+ /** Redact leaves while serializing, so an oversized payload never needs a full mask or stringify. */
66
+ function boundMaskedPayload(input, budget, reporter) {
67
+ const seen = new WeakSet();
68
+ const parts = [];
69
+ let remaining = budget.remaining;
70
+ let overflow = false;
71
+ const append = (text) => {
72
+ if (overflow || text === undefined)
73
+ return;
74
+ const bytes = Buffer.byteLength(text);
75
+ if (bytes > remaining) {
76
+ // The node was already masked atomically; a serialized prefix is safe.
77
+ parts.push(takePayloadText(text, { remaining, truncated: true }));
78
+ remaining = 0;
79
+ overflow = true;
80
+ return;
81
+ }
82
+ parts.push(text);
83
+ remaining -= bytes;
84
+ };
85
+ const write = (value) => {
86
+ if (overflow || value === undefined)
87
+ return undefined;
88
+ if (value === null || typeof value !== 'object' || value instanceof Date) {
89
+ if (typeof value === 'string' &&
90
+ Buffer.byteLength(value) > MAX_ATOMIC_MASK_BYTES &&
91
+ Buffer.byteLength(value) > remaining) {
92
+ overflow = true;
93
+ return undefined;
94
+ }
95
+ const masked = reporter.mask(value);
96
+ append(JSON.stringify(masked));
97
+ return masked;
98
+ }
99
+ if (seen.has(value)) {
100
+ append(JSON.stringify('[Circular]'));
101
+ return '[Circular]';
102
+ }
103
+ seen.add(value);
104
+ if (Array.isArray(value)) {
105
+ append('[');
106
+ const result = [];
107
+ for (const [index, item] of value.entries()) {
108
+ if (overflow)
109
+ break;
110
+ if (index > 0)
111
+ append(',');
112
+ if (item === undefined) {
113
+ append('null');
114
+ result.push(null);
115
+ }
116
+ else {
117
+ const masked = write(item);
118
+ if (!overflow)
119
+ result.push(masked);
120
+ }
121
+ }
122
+ append(']');
123
+ seen.delete(value);
124
+ return result;
125
+ }
126
+ append('{');
127
+ const result = Object.create(null);
128
+ let first = true;
129
+ for (const key in value) {
130
+ if (overflow)
131
+ break;
132
+ if (!Object.hasOwn(value, key))
133
+ continue;
134
+ const child = value[key];
135
+ if (child === undefined && !reporter.shouldRedactField(key))
136
+ continue;
137
+ if (!first)
138
+ append(',');
139
+ first = false;
140
+ append(JSON.stringify(reporter.mask(key)));
141
+ append(':');
142
+ if (reporter.shouldRedactField(key)) {
143
+ append(JSON.stringify('[REDACTED]'));
144
+ result[key] = '[REDACTED]';
145
+ }
146
+ else {
147
+ const masked = write(child);
148
+ if (!overflow)
149
+ result[key] = masked;
150
+ }
151
+ }
152
+ append('}');
153
+ seen.delete(value);
154
+ return result;
155
+ };
156
+ try {
157
+ const masked = write(input);
158
+ if (!overflow) {
159
+ budget.remaining = remaining;
160
+ return masked;
161
+ }
162
+ }
163
+ catch {
164
+ budget.truncated = true;
165
+ return previewPayload('', budget);
166
+ }
167
+ budget.truncated = true;
168
+ return previewPayload(parts.join(''), budget);
169
+ }
170
+ /** Bound a structured payload without changing its shape when it fits. */
171
+ export function boundPayload(input, budget, reporter) {
172
+ if (reporter)
173
+ return boundMaskedPayload(input, budget, reporter);
174
+ let serialized;
175
+ try {
176
+ serialized = JSON.stringify(input) ?? '';
177
+ }
178
+ catch {
179
+ budget.truncated = true;
180
+ return previewPayload('', budget);
181
+ }
182
+ const bytes = Buffer.byteLength(serialized);
183
+ if (bytes <= budget.remaining) {
184
+ budget.remaining -= bytes;
185
+ return input;
186
+ }
187
+ budget.truncated = true;
188
+ return previewPayload(serialized, budget);
189
+ }
@@ -46,6 +46,7 @@ export interface LangfuseConnectionConfig {
46
46
  tags?: string[];
47
47
  captureContent?: boolean;
48
48
  captureMedia?: boolean;
49
+ maxPayloadBytes?: number;
49
50
  }
50
51
  export type ObservationLevel = 'DEFAULT' | 'WARNING' | 'ERROR';
51
52
  /** Standard request context supplied by a host for one session's next trace. */
@@ -0,0 +1,15 @@
1
+ import type { LangfuseReporter } from './client.js';
2
+ export interface PayloadBudget {
3
+ remaining: number;
4
+ truncated: boolean;
5
+ }
6
+ export declare const newPayloadBudget: (maxBytes: number) => PayloadBudget;
7
+ /** Take a UTF-8 prefix without ever exceeding the remaining content-byte budget. */
8
+ export declare function takePayloadText(value: string, budget: PayloadBudget): string;
9
+ type BoundedPayload<T> = T | {
10
+ truncated: true;
11
+ preview?: string;
12
+ } | string | number;
13
+ /** Bound a structured payload without changing its shape when it fits. */
14
+ export declare function boundPayload<T>(input: T, budget: PayloadBudget, reporter?: LangfuseReporter): BoundedPayload<T>;
15
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-langfuse",
3
- "version": "0.1.38",
3
+ "version": "0.1.39",
4
4
  "description": "Langfuse observability plugin for DeepSeek Harness: one generation per LLM call with a nested llm-request span, one span per tool call, one trace per session turn, subagent child sessions nested under the parent's tree.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",