@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 +3 -0
- package/lib/client.js +36 -2
- package/lib/index.js +342 -17
- package/lib/types/client.d.ts +2 -0
- package/lib/types/index.d.ts +34 -31
- package/package.json +12 -12
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
|
|
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
|
-
|
|
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
|
|
639
|
-
//
|
|
640
|
-
// parent
|
|
641
|
-
const
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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 ?
|
|
1322
|
+
output: config.captureContent ? rawOutput : undefined,
|
|
1031
1323
|
level,
|
|
1032
1324
|
statusMessage,
|
|
1033
|
-
metadata: {
|
|
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
|
|
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
|
-
|
|
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
|
|
1390
|
+
text = appendAssembledText(text, chunk.text, outputBudget);
|
|
1086
1391
|
break;
|
|
1087
1392
|
case 'reasoning-delta':
|
|
1088
1393
|
if (config.captureContent)
|
|
1089
|
-
reasoning
|
|
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
|
-
|
|
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: {
|
|
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;
|
package/lib/types/client.d.ts
CHANGED
|
@@ -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. */
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
69
|
-
"@deepseek-ai/dsh-attachment": "0.2.1-rc.
|
|
70
|
-
"@deepseek-ai/dsh-hook-protocol": "0.2.1-rc.
|
|
71
|
-
"@deepseek-ai/dsh-llm": "0.2.1-rc.
|
|
72
|
-
"@deepseek-ai/dsh-ptc-runtime": "0.2.1-rc.
|
|
73
|
-
"@deepseek-ai/dsh-session": "0.2.1-rc.
|
|
74
|
-
"@deepseek-ai/dsh-session-projection": "0.2.1-rc.
|
|
75
|
-
"@deepseek-ai/dsh-subagent": "0.2.1-rc.
|
|
76
|
-
"@deepseek-ai/dsh-tools": "0.2.1-rc.
|
|
77
|
-
"@deepseek-ai/dsh-util-time": "0.2.1-rc.
|
|
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",
|