@amaster.ai/dsh-langfuse 0.1.34 → 0.1.35

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,6 +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
41
  userId: developer
41
42
  tags: [coding, experiment-a]
42
43
  environment: development
@@ -53,6 +54,8 @@ Buffered spans drain on `session/flush(session)`; the exporter shuts down with t
53
54
 
54
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.
55
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`.
58
+
56
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.
57
60
 
58
61
  ## Reasoning, images, and turn results
package/lib/client.js CHANGED
@@ -115,16 +115,38 @@ export class LangfuseReporter {
115
115
  this.config = config;
116
116
  this.ready = this.init();
117
117
  }
118
+ /** Whether a payload property should be replaced without inspecting its value. */
119
+ shouldRedactField(key) {
120
+ return !!this.config.redactFields?.some((field) => field.toLowerCase() === key.toLowerCase());
121
+ }
118
122
  /** Clone telemetry only; never mutate harness-owned messages or tool results. */
119
123
  mask(data) {
120
124
  const secrets = [this.config.publicKey, this.config.secretKey].filter(Boolean);
121
125
  const fields = new Set(this.config.redactFields?.map((key) => key.toLowerCase()));
122
126
  const seen = new WeakSet();
123
127
  const redactText = (text) => {
124
- const ranges = Array.from(text.matchAll(/(?:pk|sk)-lf-[\w-]+/g), (match) => [match.index, match.index + match[0].length]);
128
+ const markedRanges = Array.from(text.matchAll(/\[REDACTED\]/g), (match) => [match.index, match.index + match[0].length]);
129
+ const ranges = [];
130
+ let markerIndex = 0;
131
+ const addRange = (start, stop) => {
132
+ // Existing markers are atomic: a second mask must never expand them.
133
+ // Walk the sorted marker list once instead of rescanning it per match.
134
+ while (markerIndex < markedRanges.length && markedRanges[markerIndex][1] <= start)
135
+ markerIndex += 1;
136
+ if (markerIndex < markedRanges.length &&
137
+ start < markedRanges[markerIndex][1] &&
138
+ stop > markedRanges[markerIndex][0])
139
+ return;
140
+ ranges.push([start, stop]);
141
+ };
142
+ for (const match of text.matchAll(/(?:pk|sk)-lf-[\w-]+/g))
143
+ addRange(match.index, match.index + match[0].length);
125
144
  for (const secret of secrets) {
145
+ // Each independent scan starts at the first marker. Do not reset for
146
+ // every match, or a large marker list becomes quadratic again.
147
+ markerIndex = 0;
126
148
  for (let index = text.indexOf(secret); index !== -1; index = text.indexOf(secret, index + 1)) {
127
- ranges.push([index, index + secret.length]);
149
+ addRange(index, index + secret.length);
128
150
  }
129
151
  }
130
152
  let output = '';
@@ -346,6 +368,12 @@ export class LangfuseReporter {
346
368
  // v5 observation attributes have no `name` — renames ride the OTEL span.
347
369
  if (update.name)
348
370
  generation.otelSpan.updateName(update.name);
371
+ }
372
+ catch (error) {
373
+ console.error('[dsh-langfuse] generation end failed:', error);
374
+ }
375
+ // An update failure must not strand the observation: v5 exports only ended spans.
376
+ try {
349
377
  generation.end(endedAt);
350
378
  }
351
379
  catch (error) {
@@ -418,6 +446,12 @@ export class LangfuseReporter {
418
446
  if (update.output !== undefined && this.roots.has(span)) {
419
447
  span.setTraceIO({ output: update.output });
420
448
  }
449
+ }
450
+ catch (error) {
451
+ console.error('[dsh-langfuse] span end failed:', error);
452
+ }
453
+ // Always close the span, even when its final attributes could not be written.
454
+ try {
421
455
  span.end(endedAt);
422
456
  }
423
457
  catch (error) {
package/lib/index.js CHANGED
@@ -54,6 +54,11 @@ export const Config = Schema.object({
54
54
  traceName: Schema.string().default('dsh-turn'),
55
55
  /** Include full message/tool payloads in Langfuse IO. Disable to log metadata only. */
56
56
  captureContent: Schema.boolean().default(true),
57
+ maxPayloadBytes: Schema.number()
58
+ .min(1)
59
+ .step(1)
60
+ .default(256 * 1024)
61
+ .description('maximum captured request, raw-stream, and assembled-output bytes per LLM call'),
57
62
  redactFields: Schema.array(Schema.string())
58
63
  .default([])
59
64
  .description('case-insensitive payload fields to redact'),
@@ -125,6 +130,260 @@ function requestBodyOf(options, captureContent) {
125
130
  const { signal: _, ...body } = options;
126
131
  return body;
127
132
  }
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
+ /** Keep only whole chunks that fit; stream accounting still processes every original chunk. */
323
+ function capturePayloadChunk(chunk, chunks, budget) {
324
+ if (budget.remaining <= 0) {
325
+ budget.truncated = true;
326
+ return;
327
+ }
328
+ let serialized;
329
+ try {
330
+ serialized = JSON.stringify(chunk) ?? '';
331
+ }
332
+ catch {
333
+ chunks.length = 0;
334
+ budget.truncated = true;
335
+ budget.remaining = 0;
336
+ return;
337
+ }
338
+ // Account for the final raw array too: [] around the first chunk and one
339
+ // comma per additional retained chunk, after redaction has changed lengths.
340
+ const bytes = Buffer.byteLength(serialized) + (chunks.length === 0 ? 2 : 1);
341
+ if (bytes > budget.remaining) {
342
+ // Partial groups are unsafe: a retained prefix may be a secret fragment.
343
+ chunks.length = 0;
344
+ budget.truncated = true;
345
+ budget.remaining = 0;
346
+ return;
347
+ }
348
+ chunks.push(chunk);
349
+ budget.remaining -= bytes;
350
+ }
351
+ /** Append only while the full assembled text fits; otherwise drop the whole channel. */
352
+ function appendAssembledText(current, delta, budget) {
353
+ const bytes = Buffer.byteLength(delta);
354
+ if (bytes > budget.remaining) {
355
+ budget.truncated = true;
356
+ budget.remaining = 0;
357
+ return '';
358
+ }
359
+ budget.remaining -= bytes;
360
+ return current + delta;
361
+ }
362
+ /** Charge the whole tool-call envelope, not only its argument text. */
363
+ function captureToolCall(call, calls, budget) {
364
+ if (budget.remaining <= 0) {
365
+ budget.truncated = true;
366
+ return;
367
+ }
368
+ const envelope = {
369
+ ...call,
370
+ function: { ...call.function, arguments: '' },
371
+ };
372
+ const bytes = Buffer.byteLength(JSON.stringify(envelope));
373
+ if (bytes > budget.remaining) {
374
+ budget.truncated = true;
375
+ budget.remaining = 0;
376
+ return;
377
+ }
378
+ budget.remaining -= bytes;
379
+ calls.push({
380
+ ...call,
381
+ function: {
382
+ ...call.function,
383
+ arguments: takePayloadText(call.function.arguments, budget),
384
+ },
385
+ });
386
+ }
128
387
  /** Generation names carry the reply's first non-empty line (capped) for list readability. */
129
388
  function enhanceWithFirstLine(base, text) {
130
389
  const firstLine = text
@@ -269,6 +528,7 @@ export function apply(ctx, config) {
269
528
  const toolResults = new WeakMap();
270
529
  const openHooks = new Map();
271
530
  const openOperations = new Set();
531
+ const operationSessions = new Map();
272
532
  const traceContexts = new Map();
273
533
  const pendingMedia = new Set();
274
534
  const mediaFor = new WeakMap();
@@ -635,10 +895,11 @@ export function apply(ctx, config) {
635
895
  if (!parentSessionId)
636
896
  return;
637
897
  const childId = session.id;
638
- // Parent under the enclosing delegation tool span when one is open
639
- // (foreground runs execute inside the parent's tools/execute), else the
640
- // parent's own subagent span (nested delegation), else the root trace.
641
- const parentObservation = openToolSpans.get(parentSessionId)?.at(-1) ??
898
+ // Parent under the enclosing delegation tool span only when it is unique;
899
+ // concurrent tools have no reliable stack-top ordering, so use the known
900
+ // parent subagent/root instead of attributing the child to an arbitrary tool.
901
+ const openTools = openToolSpans.get(parentSessionId) ?? [];
902
+ const parentObservation = (openTools.length === 1 ? openTools[0] : undefined) ??
642
903
  children.get(parentSessionId)?.span ??
643
904
  traceFor(parentSessionId);
644
905
  const span = reporter.startSpan(parentObservation, {
@@ -651,6 +912,7 @@ export function apply(ctx, config) {
651
912
  children.set(childId, {
652
913
  parentSessionId,
653
914
  span,
915
+ started: false,
654
916
  hasInput: false,
655
917
  turnTrace: children.get(parentSessionId)?.turnTrace ??
656
918
  sessions.get(rootSessionId(parentSessionId) ?? '')?.trace,
@@ -660,13 +922,17 @@ export function apply(ctx, config) {
660
922
  const child = children.get(info.id);
661
923
  if (!child)
662
924
  return; // remote (ACP) runs have no local session — nothing to nest
925
+ child.started = true;
663
926
  reporter.updateSpan(child.span, { metadata: { provider: info.provider, local: info.local } });
664
927
  });
665
928
  ctx.on('subagent/end', (info) => {
666
929
  const child = children.get(info.id);
667
930
  if (!child)
668
931
  return;
669
- if (info.stopReason === 'error')
932
+ // A background child can finish after the parent moved to another turn.
933
+ // Only mark the root that was active when this child was created.
934
+ if (info.stopReason === 'error' &&
935
+ sessions.get(rootSessionId(info.id) ?? '')?.trace === child.turnTrace)
670
936
  noteError(info.id);
671
937
  children.delete(info.id);
672
938
  afterMedia(child.span, (endedAt) => reporter.endSpan(child.span, {
@@ -913,6 +1179,16 @@ export function apply(ctx, config) {
913
1179
  });
914
1180
  ctx.on('session/disposed', (session) => {
915
1181
  const sessionId = session.id;
1182
+ for (const close of [...openOperations]) {
1183
+ if (operationSessions.get(close) === sessionId)
1184
+ close();
1185
+ }
1186
+ const child = children.get(sessionId);
1187
+ if (child && !child.started) {
1188
+ // Activation rollback disposes the session without subagent/start/end.
1189
+ children.delete(sessionId);
1190
+ afterMedia(child.span, (endedAt) => reporter.endSpan(child.span, { level: 'WARNING', statusMessage: 'subagent activation rolled back' }, endedAt));
1191
+ }
916
1192
  closeHooks(sessionId);
917
1193
  const state = sessions.get(sessionId);
918
1194
  sessions.delete(sessionId);
@@ -951,7 +1227,12 @@ export function apply(ctx, config) {
951
1227
  const oneOffRoot = options.sessionId ? null : parent;
952
1228
  const generationName = options.purpose ? `llm-call [${options.purpose}]` : 'llm-call';
953
1229
  const modelParameters = modelParametersOf(options, config.captureContent);
954
- const input = requestBodyOf(options, config.captureContent);
1230
+ const maxPayloadBytes = config.maxPayloadBytes ?? 256 * 1024;
1231
+ const requestBudget = newPayloadBudget(maxPayloadBytes);
1232
+ const rawBudget = newPayloadBudget(maxPayloadBytes);
1233
+ const outputBudget = newPayloadBudget(maxPayloadBytes);
1234
+ const requestBody = requestBodyOf(options, config.captureContent);
1235
+ const input = boundPayload(requestBody, requestBudget, reporter);
955
1236
  const generation = reporter.startGeneration(parent, {
956
1237
  name: generationName,
957
1238
  model: options.model,
@@ -966,7 +1247,12 @@ export function apply(ctx, config) {
966
1247
  enrichInput(generation, Promise.all(options.messages.map(async (message) => ({
967
1248
  ...message,
968
1249
  content: await displayBlocks(message.content, ctx.get('attachments'), mediaOptions, options.signal, budget),
969
- }))).then((messages) => ({ ...input, messages })));
1250
+ }))).then((messages) => {
1251
+ const enrichmentBudget = newPayloadBudget(maxPayloadBytes);
1252
+ const enriched = boundPayload({ ...requestBody, messages }, enrichmentBudget, reporter);
1253
+ requestBudget.truncated ||= enrichmentBudget.truncated;
1254
+ return enriched;
1255
+ }));
970
1256
  }
971
1257
  // The rawest request a plugin can observe: the loop-built GenerateOptions
972
1258
  // verbatim, pre-adapter (the provider HTTP body is assembled inside the
@@ -1005,6 +1291,7 @@ export function apply(ctx, config) {
1005
1291
  closed = true;
1006
1292
  const endedAt = new Date();
1007
1293
  openOperations.delete(closeCall);
1294
+ operationSessions.delete(closeCall);
1008
1295
  const failure = finish && (finish.kind === 'error' || finish.kind === 'aborted') ? finish : undefined;
1009
1296
  // No terminal finish chunk (the consumer abandoned the stream or
1010
1297
  // the adapter cut it short): the call's outcome is unknown — mark
@@ -1026,20 +1313,33 @@ export function apply(ctx, config) {
1026
1313
  level = 'WARNING';
1027
1314
  statusMessage = 'stream closed before the terminal finish chunk';
1028
1315
  }
1316
+ // Redact complete grouped deltas before the final serialized bound;
1317
+ // streaming capture has already bounded memory and dropped overflow whole.
1318
+ const finalRawBudget = newPayloadBudget(maxPayloadBytes);
1319
+ const rawOutput = boundPayload(reporter.mask(redactChunks(rawChunks, reporter)), finalRawBudget);
1320
+ rawBudget.truncated ||= finalRawBudget.truncated;
1029
1321
  reporter.endSpan(requestSpan, {
1030
- output: config.captureContent ? redactChunks(rawChunks, reporter) : undefined,
1322
+ output: config.captureContent ? rawOutput : undefined,
1031
1323
  level,
1032
1324
  statusMessage,
1033
- metadata: { chunkCount },
1325
+ metadata: {
1326
+ chunkCount,
1327
+ truncated: requestBudget.truncated || rawBudget.truncated || undefined,
1328
+ },
1034
1329
  }, endedAt);
1035
1330
  if (level === 'ERROR')
1036
1331
  noteError(options.sessionId);
1332
+ // A final independent safety bound covers the complete output envelope;
1333
+ // an exhausted streaming budget must not erase an otherwise valid prefix.
1334
+ const finalOutputBudget = newPayloadBudget(maxPayloadBytes);
1335
+ const output = boundPayload(outputBody(), finalOutputBudget, reporter);
1336
+ outputBudget.truncated ||= finalOutputBudget.truncated;
1037
1337
  // Partial progress counts on every path: whatever streamed is kept.
1038
1338
  afterMedia(generation, (endedAt) => reporter.endGeneration(generation, {
1039
1339
  name: config.captureContent
1040
1340
  ? enhanceWithFirstLine(generationName, reporter.mask(text))
1041
1341
  : undefined,
1042
- output: outputBody(),
1342
+ output,
1043
1343
  usage,
1044
1344
  completionStartTime,
1045
1345
  level,
@@ -1049,11 +1349,16 @@ export function apply(ctx, config) {
1049
1349
  errorCode: failure?.failure?.code,
1050
1350
  incomplete: finish ? undefined : true,
1051
1351
  toolCallCount: toolCallCount > 0 ? toolCallCount : undefined,
1352
+ truncated: requestBudget.truncated ||
1353
+ rawBudget.truncated ||
1354
+ outputBudget.truncated ||
1355
+ undefined,
1052
1356
  },
1053
1357
  }, endedAt), endedAt);
1054
1358
  reporter.endSpan(oneOffRoot, {}, endedAt);
1055
1359
  };
1056
1360
  openOperations.add(closeCall);
1361
+ operationSessions.set(closeCall, options.sessionId);
1057
1362
  let stream;
1058
1363
  try {
1059
1364
  stream = next();
@@ -1073,7 +1378,7 @@ export function apply(ctx, config) {
1073
1378
  }
1074
1379
  chunkCount += 1;
1075
1380
  if (config.captureContent)
1076
- rawChunks.push(chunk);
1381
+ capturePayloadChunk(chunk, rawChunks, rawBudget);
1077
1382
  // TTFT is the first real token — block boundaries, usage frames and
1078
1383
  // empty heartbeat deltas are not completions (the predicate above).
1079
1384
  if (completionStartTime === undefined && isTokenDelta(chunk)) {
@@ -1082,21 +1387,39 @@ export function apply(ctx, config) {
1082
1387
  switch (chunk.type) {
1083
1388
  case 'text-delta':
1084
1389
  if (config.captureContent)
1085
- text += chunk.text;
1390
+ text = appendAssembledText(text, chunk.text, outputBudget);
1086
1391
  break;
1087
1392
  case 'reasoning-delta':
1088
1393
  if (config.captureContent)
1089
- reasoning += chunk.text;
1394
+ reasoning = appendAssembledText(reasoning, chunk.text, outputBudget);
1090
1395
  break;
1091
1396
  case 'block-end':
1092
1397
  if (chunk.block.type === 'tool-call') {
1093
1398
  toolCallCount += 1;
1094
- if (config.captureContent)
1095
- toolCalls.push({
1399
+ if (config.captureContent) {
1400
+ if (outputBudget.remaining <= 0) {
1401
+ outputBudget.truncated = true;
1402
+ break;
1403
+ }
1404
+ const envelopeBytes = Buffer.byteLength(JSON.stringify({
1405
+ id: chunk.block.id,
1406
+ type: 'function',
1407
+ function: { name: chunk.block.name, arguments: '' },
1408
+ }));
1409
+ if (envelopeBytes > outputBudget.remaining) {
1410
+ outputBudget.truncated = true;
1411
+ outputBudget.remaining = 0;
1412
+ break;
1413
+ }
1414
+ captureToolCall({
1096
1415
  id: chunk.block.id,
1097
1416
  type: 'function',
1098
- function: { name: chunk.block.name, arguments: chunk.block.arguments },
1099
- });
1417
+ function: {
1418
+ name: chunk.block.name,
1419
+ arguments: reporter.mask(chunk.block.arguments),
1420
+ },
1421
+ }, toolCalls, outputBudget);
1422
+ }
1100
1423
  }
1101
1424
  break;
1102
1425
  case 'usage':
@@ -1159,6 +1482,7 @@ export function apply(ctx, config) {
1159
1482
  closed = true;
1160
1483
  const endedAt = new Date();
1161
1484
  openOperations.delete(closeTool);
1485
+ operationSessions.delete(closeTool);
1162
1486
  toolResults.delete(exec);
1163
1487
  if (sessionId && toolCalls.get(sessionId)?.get(exec.callId)?.span === span) {
1164
1488
  const calls = toolCalls.get(sessionId);
@@ -1186,6 +1510,7 @@ export function apply(ctx, config) {
1186
1510
  end();
1187
1511
  }
1188
1512
  openOperations.add(closeTool);
1513
+ operationSessions.set(closeTool, sessionId);
1189
1514
  toolResults.set(exec, (result) => {
1190
1515
  if (closed)
1191
1516
  return;
@@ -92,6 +92,8 @@ export declare class LangfuseReporter {
92
92
  /** Settles (never rejects) once the lazy SDK import+wiring finished. */
93
93
  readonly ready: Promise<void>;
94
94
  constructor(config: LangfuseConnectionConfig);
95
+ /** Whether a payload property should be replaced without inspecting its value. */
96
+ shouldRedactField(key: string): boolean;
95
97
  /** Clone telemetry only; never mutate harness-owned messages or tool results. */
96
98
  mask<T>(data: T): T;
97
99
  /** Dynamically import the SDK and wire the isolated export pipeline. */
@@ -70,39 +70,41 @@ export declare const name = "dsh-langfuse";
70
70
  * prefer fail-fast wiring.
71
71
  */
72
72
  export declare const inject: string[];
73
- export declare const Config: Schema<Schemastery.ObjectS<{
74
- enabled: Schema<boolean, boolean>;
75
- publicKey: Schema<string, string>;
76
- secretKey: Schema<string, string>;
77
- baseUrl: Schema<string, string>;
78
- traceName: Schema<string, string>;
73
+ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
74
+ enabled: Schema<boolean, boolean, "defined">;
75
+ publicKey: Schema<string, string, "defined">;
76
+ secretKey: Schema<string, string, "defined">;
77
+ baseUrl: Schema<string, string, "defined">;
78
+ traceName: Schema<string, string, "defined">;
79
79
  /** Include full message/tool payloads in Langfuse IO. Disable to log metadata only. */
80
- captureContent: Schema<boolean, boolean>;
81
- redactFields: Schema<string[], string[]>;
82
- userId: Schema<string, string>;
83
- tags: Schema<string[], string[]>;
84
- environment: Schema<string, string>;
85
- release: Schema<string, string>;
86
- captureMedia: Schema<boolean, boolean>;
87
- maxMediaBytes: Schema<number, number>;
88
- mediaTimeoutMs: Schema<number, number>;
89
- }>, Schemastery.ObjectT<{
90
- enabled: Schema<boolean, boolean>;
91
- publicKey: Schema<string, string>;
92
- secretKey: Schema<string, string>;
93
- baseUrl: Schema<string, string>;
94
- traceName: Schema<string, string>;
80
+ captureContent: Schema<boolean, boolean, "defined">;
81
+ maxPayloadBytes: Schema<number, number, "defined">;
82
+ redactFields: Schema<string[], string[], "defined">;
83
+ userId: Schema<string, string, "defined">;
84
+ tags: Schema<string[], string[], "defined">;
85
+ environment: Schema<string, string, "defined">;
86
+ release: Schema<string, string, "defined">;
87
+ captureMedia: Schema<boolean, boolean, "defined">;
88
+ maxMediaBytes: Schema<number, number, "defined">;
89
+ mediaTimeoutMs: Schema<number, number, "defined">;
90
+ }>>, Schemastery.ObjectT<NoInfer<{
91
+ enabled: Schema<boolean, boolean, "defined">;
92
+ publicKey: Schema<string, string, "defined">;
93
+ secretKey: Schema<string, string, "defined">;
94
+ baseUrl: Schema<string, string, "defined">;
95
+ traceName: Schema<string, string, "defined">;
95
96
  /** Include full message/tool payloads in Langfuse IO. Disable to log metadata only. */
96
- captureContent: Schema<boolean, boolean>;
97
- redactFields: Schema<string[], string[]>;
98
- userId: Schema<string, string>;
99
- tags: Schema<string[], string[]>;
100
- environment: Schema<string, string>;
101
- release: Schema<string, string>;
102
- captureMedia: Schema<boolean, boolean>;
103
- maxMediaBytes: Schema<number, number>;
104
- mediaTimeoutMs: Schema<number, number>;
105
- }>>;
97
+ captureContent: Schema<boolean, boolean, "defined">;
98
+ maxPayloadBytes: Schema<number, number, "defined">;
99
+ redactFields: Schema<string[], string[], "defined">;
100
+ userId: Schema<string, string, "defined">;
101
+ tags: Schema<string[], string[], "defined">;
102
+ environment: Schema<string, string, "defined">;
103
+ release: Schema<string, string, "defined">;
104
+ captureMedia: Schema<boolean, boolean, "defined">;
105
+ maxMediaBytes: Schema<number, number, "defined">;
106
+ mediaTimeoutMs: Schema<number, number, "defined">;
107
+ }>>, "plain">;
106
108
  export interface LangfusePluginConfig {
107
109
  enabled: boolean;
108
110
  publicKey: string;
@@ -110,6 +112,7 @@ export interface LangfusePluginConfig {
110
112
  baseUrl: string;
111
113
  traceName: string;
112
114
  captureContent: boolean;
115
+ maxPayloadBytes?: number;
113
116
  redactFields?: string[];
114
117
  userId?: string;
115
118
  tags?: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amaster.ai/dsh-langfuse",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
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",
@@ -32,7 +32,7 @@
32
32
  }
33
33
  },
34
34
  "dependencies": {
35
- "@deepseek-ai/schemastery": "^3.18.1"
35
+ "@deepseek-ai/schemastery": "^3.18.5"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@deepseek-ai/cordis": "^4.0.1",
@@ -65,16 +65,16 @@
65
65
  },
66
66
  "devDependencies": {
67
67
  "@deepseek-ai/cordis": "4.0.5",
68
- "@deepseek-ai/dsh-agent": "0.2.1-rc.4",
69
- "@deepseek-ai/dsh-attachment": "0.2.1-rc.4",
70
- "@deepseek-ai/dsh-hook-protocol": "0.2.1-rc.4",
71
- "@deepseek-ai/dsh-llm": "0.2.1-rc.4",
72
- "@deepseek-ai/dsh-ptc-runtime": "0.2.1-rc.4",
73
- "@deepseek-ai/dsh-session": "0.2.1-rc.4",
74
- "@deepseek-ai/dsh-session-projection": "0.2.1-rc.4",
75
- "@deepseek-ai/dsh-subagent": "0.2.1-rc.4",
76
- "@deepseek-ai/dsh-tools": "0.2.1-rc.4",
77
- "@deepseek-ai/dsh-util-time": "0.2.1-rc.4",
68
+ "@deepseek-ai/dsh-agent": "0.2.1-rc.5",
69
+ "@deepseek-ai/dsh-attachment": "0.2.1-rc.5",
70
+ "@deepseek-ai/dsh-hook-protocol": "0.2.1-rc.5",
71
+ "@deepseek-ai/dsh-llm": "0.2.1-rc.5",
72
+ "@deepseek-ai/dsh-ptc-runtime": "0.2.1-rc.5",
73
+ "@deepseek-ai/dsh-session": "0.2.1-rc.5",
74
+ "@deepseek-ai/dsh-session-projection": "0.2.1-rc.5",
75
+ "@deepseek-ai/dsh-subagent": "0.2.1-rc.5",
76
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.5",
77
+ "@deepseek-ai/dsh-util-time": "0.2.1-rc.5",
78
78
  "@langfuse/otel": "5.10.1",
79
79
  "@langfuse/tracing": "5.10.1",
80
80
  "@opentelemetry/api": "^1.9.1",