@amaster.ai/dsh-langfuse 0.1.38 → 0.1.40
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 +4 -4
- package/lib/client.js +15 -2
- package/lib/index.js +61 -209
- package/lib/payload.js +189 -0
- package/lib/types/client.d.ts +1 -0
- package/lib/types/payload.d.ts +15 -0
- package/package.json +19 -18
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 #
|
|
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.
|
|
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
|
|
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
|
|
|
@@ -62,7 +62,7 @@ Configured Langfuse credentials and `pk-lf-*` / `sk-lf-*` values are redacted ac
|
|
|
62
62
|
|
|
63
63
|
Text-only generation output remains a string. When reasoning or tool calls are present, output uses the Langfuse chat format: `role: "assistant"`, optional `content`, `thinking` blocks, and `tool_calls` with the original call ID and function arguments. This renders thinking and invoked tools in the formatted preview. The original reasoning deltas remain in the nested `llm-request` output; no extra generation is created.
|
|
64
64
|
|
|
65
|
-
`captureMedia: true` requires content capture and an available `ctx.attachments` service (`@deepseek-ai/dsh-attachment >=0.2.0-rc.1`). Prompt, generation-input, and tool-output image references are read through that service and converted to data URIs for the SDK's media uploader. Install the attachment bundle appropriate to the deployment; the plugin does not instantiate a storage backend. Missing/failed reads, offloaded images, unsupported MIME types, oversized images, and disabled media fall back to MIME/size/dimension metadata. A generation's byte budget covers all messages in that request. Observation end times are captured when the model, tool, or turn completes; delayed image reads do not extend business latency. `LANGFUSE_MEDIA_UPLOAD_ENABLED=false` (or `0`) overrides media capture. Verbatim file references remain metadata; their bytes are not uploaded.
|
|
65
|
+
`captureMedia: true` requires content capture and an available `ctx.attachments` service (`@deepseek-ai/dsh-attachment >=0.2.0-rc.1 || >=0.2.2-rc.1`). Prompt, generation-input, and tool-output image references are read through that service and converted to data URIs for the SDK's media uploader. Install the attachment bundle appropriate to the deployment; the plugin does not instantiate a storage backend. Missing/failed reads, offloaded images, unsupported MIME types, oversized images, and disabled media fall back to MIME/size/dimension metadata. A generation's byte budget covers all messages in that request. Observation end times are captured when the model, tool, or turn completes; delayed image reads do not extend business latency. `LANGFUSE_MEDIA_UPLOAD_ENABLED=false` (or `0`) overrides media capture. Verbatim file references remain metadata; their bytes are not uploaded.
|
|
66
66
|
|
|
67
67
|
Root severity follows the final turn reason: errors are `ERROR`; cancellation, blocking, token limits, and interruptions are `WARNING`. A recovered intermediate generation/tool/hook/subagent error sets `hadErrors` metadata while a successful final turn remains `DEFAULT`. This keeps retry failures visible without treating recovery as a failed turn. Outstanding tools also close as `WARNING` at unload, including calls without an agent/session; late results still reach their original callers without repeated telemetry or media reads.
|
|
68
68
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
571
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/types/client.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.1.40",
|
|
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",
|
|
@@ -36,11 +36,11 @@
|
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
39
|
-
"@deepseek-ai/dsh-attachment": ">=0.2.0-rc.1 || >=0.2.1-rc.1",
|
|
40
|
-
"@deepseek-ai/dsh-llm": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1",
|
|
41
|
-
"@deepseek-ai/dsh-session": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1",
|
|
42
|
-
"@deepseek-ai/dsh-subagent": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1",
|
|
43
|
-
"@deepseek-ai/dsh-tools": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1",
|
|
39
|
+
"@deepseek-ai/dsh-attachment": ">=0.2.0-rc.1 || >=0.2.1-rc.1 || >=0.2.2-rc.1",
|
|
40
|
+
"@deepseek-ai/dsh-llm": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1 || >=0.2.2-rc.1",
|
|
41
|
+
"@deepseek-ai/dsh-session": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1 || >=0.2.2-rc.1",
|
|
42
|
+
"@deepseek-ai/dsh-subagent": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1 || >=0.2.2-rc.1",
|
|
43
|
+
"@deepseek-ai/dsh-tools": ">=0.1.5-rc.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.2.0-rc.1 || >=0.2.1-rc.1 || >=0.2.2-rc.1",
|
|
44
44
|
"@langfuse/otel": "^5.10.1",
|
|
45
45
|
"@langfuse/tracing": "^5.10.1",
|
|
46
46
|
"@opentelemetry/api": "^1.9.0",
|
|
@@ -64,17 +64,17 @@
|
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@deepseek-ai/cordis": "4.0
|
|
68
|
-
"@deepseek-ai/dsh-agent": "0.2.
|
|
69
|
-
"@deepseek-ai/dsh-attachment": "0.2.
|
|
70
|
-
"@deepseek-ai/dsh-hook-protocol": "0.2.
|
|
71
|
-
"@deepseek-ai/dsh-llm": "0.2.
|
|
72
|
-
"@deepseek-ai/dsh-ptc-runtime": "0.2.
|
|
73
|
-
"@deepseek-ai/dsh-session": "0.2.
|
|
74
|
-
"@deepseek-ai/dsh-session-projection": "0.2.
|
|
75
|
-
"@deepseek-ai/dsh-subagent": "0.2.
|
|
76
|
-
"@deepseek-ai/dsh-tools": "0.2.
|
|
77
|
-
"@deepseek-ai/dsh-util-time": "0.2.
|
|
67
|
+
"@deepseek-ai/cordis": "4.1.0",
|
|
68
|
+
"@deepseek-ai/dsh-agent": "0.2.2-rc.1",
|
|
69
|
+
"@deepseek-ai/dsh-attachment": "0.2.2-rc.1",
|
|
70
|
+
"@deepseek-ai/dsh-hook-protocol": "0.2.2-rc.1",
|
|
71
|
+
"@deepseek-ai/dsh-llm": "0.2.2-rc.1",
|
|
72
|
+
"@deepseek-ai/dsh-ptc-runtime": "0.2.2-rc.1",
|
|
73
|
+
"@deepseek-ai/dsh-session": "0.2.2-rc.1",
|
|
74
|
+
"@deepseek-ai/dsh-session-projection": "0.2.2-rc.1",
|
|
75
|
+
"@deepseek-ai/dsh-subagent": "0.2.2-rc.1",
|
|
76
|
+
"@deepseek-ai/dsh-tools": "0.2.2-rc.1",
|
|
77
|
+
"@deepseek-ai/dsh-util-time": "0.2.2-rc.1",
|
|
78
78
|
"@langfuse/otel": "5.10.1",
|
|
79
79
|
"@langfuse/tracing": "5.10.1",
|
|
80
80
|
"@opentelemetry/api": "^1.9.1",
|
|
@@ -82,7 +82,8 @@
|
|
|
82
82
|
"@opentelemetry/sdk-trace-node": "^2.10.0",
|
|
83
83
|
"@types/node": "^22.0.0",
|
|
84
84
|
"typescript": "^5.6.3",
|
|
85
|
-
"vitest": "^4.1.11"
|
|
85
|
+
"vitest": "^4.1.11",
|
|
86
|
+
"@deepseek-ai/dsh-tool-executor-native": "0.2.2-rc.1"
|
|
86
87
|
},
|
|
87
88
|
"engines": {
|
|
88
89
|
"node": "^20.19 || ^22.12 || >=24.0"
|