@amaster.ai/dsh-langfuse 0.1.30 → 0.1.31
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 +14 -2
- package/lib/client.js +12 -0
- package/lib/index.js +58 -4
- package/lib/types/client.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,20 +63,32 @@ Text-only generation output remains a string. When reasoning or tool calls are p
|
|
|
63
63
|
|
|
64
64
|
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.
|
|
65
65
|
|
|
66
|
-
## Per-request labels and distributed tracing
|
|
66
|
+
## Per-request metadata, labels and distributed tracing
|
|
67
67
|
|
|
68
|
-
Hosts can supply
|
|
68
|
+
Hosts can supply context before a session's next trace starts:
|
|
69
69
|
|
|
70
70
|
```ts
|
|
71
71
|
ctx.emit('langfuse/context', session.id, {
|
|
72
72
|
userId: 'user-123',
|
|
73
73
|
tags: ['support', 'experiment-b'],
|
|
74
|
+
metadata: {
|
|
75
|
+
projectId: 'project-a',
|
|
76
|
+
requestId: 'request-123',
|
|
77
|
+
experiment: { name: 'retrieval', variant: 2 },
|
|
78
|
+
labels: ['interactive'],
|
|
79
|
+
},
|
|
74
80
|
traceparent: '00-1234567890abcdef1234567890abcdef-1234567890abcdef-01',
|
|
75
81
|
});
|
|
76
82
|
```
|
|
77
83
|
|
|
78
84
|
The typed event is exported through this package's Cordis augmentation. Context is consumed once by the next turn trace (or an out-of-turn trace created first); send it again for another request. Labels override profile defaults for that trace and propagate through generations, tools, hooks, and nested subagents. Queued context is discarded when the session is disposed or the plugin unloads. Concurrent sessions have independent context; no process-global request environment is modified.
|
|
79
85
|
|
|
86
|
+
`metadata` accepts a plain JSON object with arbitrary string keys and nested values: plain objects, dense arrays, finite numbers, booleans, null and strings (including empty strings). The event validates, snapshots and masks the context immediately, so later caller mutations cannot change it. Request metadata is carried by every observation; plugin-generated fields such as `turn`, `provider`, `toolName` and `taskId` take precedence on that observation. Existing credential masking and `redactFields` apply, and explicit metadata remains available when `captureContent: false`.
|
|
87
|
+
|
|
88
|
+
Non-JSON metadata rejects the entire submitted context with a diagnostic, without interrupting the agent or retaining an earlier pending context. This includes non-finite numbers, undefined, functions, symbols, BigInt, circular references, throwing getters, class instances (including Date, Map and Set), sparse arrays, extra array properties, and symbol-keyed or non-enumerable object properties. Values are never silently converted or dropped, and custom `toJSON` functions are not invoked. Omitted optional context fields remain supported. For an existing A2A task, supplying new metadata replaces its request metadata for the task root and subsequently created turns and their descendants; already-created observations retain their context.
|
|
89
|
+
|
|
90
|
+
The plugin preserves JSON types in its OTLP payload. Langfuse server APIs may normalize the stored values: for example, v3's read API parses a top-level metadata string `"123"` as the number `123`.
|
|
91
|
+
|
|
80
92
|
Only explicit W3C version-00 parents with non-zero trace/span IDs are joined. Invalid headers log a prefixed diagnostic and start an independent trace. Without a header the plugin always starts an independent trace. Each observation is created inside native OTEL `ROOT_CONTEXT`: foreign spans and Langfuse baggage (user/tags/metadata) are excluded, while explicit parents and plugin labels are preserved. This scoped context does not replace the host's global provider or context manager. When joining an external trace, dsh's turn is an observation under the supplied parent; the SDK's automatic app-root claim is disabled and the external owner's trace name and trace-level IO stay intact.
|
|
81
93
|
|
|
82
94
|
## Token usage and cost
|
package/lib/client.js
CHANGED
|
@@ -243,6 +243,16 @@ export class LangfuseReporter {
|
|
|
243
243
|
observation.otelSpan.setAttribute('user.id', this.mask(context.userId));
|
|
244
244
|
if (context.tags?.length)
|
|
245
245
|
observation.otelSpan.setAttribute('langfuse.trace.tags', this.mask(context.tags));
|
|
246
|
+
if (context.metadata) {
|
|
247
|
+
// Langfuse merges the JSON object with observation.metadata.<key>
|
|
248
|
+
// attributes, giving the latter (our runtime facts) precedence. The
|
|
249
|
+
// object form also preserves null/empty strings that SDK v5's
|
|
250
|
+
// per-key serializer omits, without inheriting a parent's runtime facts.
|
|
251
|
+
// Do not also set trace.metadata: v4 gives it precedence over even
|
|
252
|
+
// observation-specific fields, which would undo that collision policy.
|
|
253
|
+
const metadata = JSON.stringify(this.mask(context.metadata));
|
|
254
|
+
observation.otelSpan.setAttribute('langfuse.observation.metadata', metadata);
|
|
255
|
+
}
|
|
246
256
|
this.traceContext.set(observation, context);
|
|
247
257
|
}
|
|
248
258
|
/** Open a trace (one per agent turn, or one-off for session-less calls). */
|
|
@@ -263,6 +273,7 @@ export class LangfuseReporter {
|
|
|
263
273
|
traceName: externalParent === NO_PARENT ? input.name : undefined,
|
|
264
274
|
userId: input.context?.userId ?? this.config.userId,
|
|
265
275
|
tags: input.context?.tags ?? this.config.tags,
|
|
276
|
+
metadata: input.context?.metadata,
|
|
266
277
|
});
|
|
267
278
|
// External traces retain their owner's trace name and trace-level IO.
|
|
268
279
|
if (externalParent === NO_PARENT) {
|
|
@@ -288,6 +299,7 @@ export class LangfuseReporter {
|
|
|
288
299
|
...previous,
|
|
289
300
|
userId: context.userId ?? previous?.userId,
|
|
290
301
|
tags: context.tags ?? previous?.tags,
|
|
302
|
+
metadata: context.metadata ?? previous?.metadata,
|
|
291
303
|
});
|
|
292
304
|
const parent = parentContext(context.traceparent);
|
|
293
305
|
if (parent !== NO_PARENT)
|
package/lib/index.js
CHANGED
|
@@ -192,6 +192,47 @@ function redactChunks(chunks, reporter) {
|
|
|
192
192
|
}
|
|
193
193
|
return output;
|
|
194
194
|
}
|
|
195
|
+
/** Validate and copy together: JSON.stringify silently rewrites non-JSON values. */
|
|
196
|
+
function snapshotMetadata(metadata) {
|
|
197
|
+
const ancestors = new Set();
|
|
198
|
+
function snapshot(value) {
|
|
199
|
+
if (value === null ||
|
|
200
|
+
typeof value === 'string' ||
|
|
201
|
+
typeof value === 'boolean' ||
|
|
202
|
+
(typeof value === 'number' && Number.isFinite(value)))
|
|
203
|
+
return value;
|
|
204
|
+
if (typeof value !== 'object' || ancestors.has(value))
|
|
205
|
+
throw new TypeError('Invalid metadata');
|
|
206
|
+
const array = Array.isArray(value);
|
|
207
|
+
const prototype = Object.getPrototypeOf(value);
|
|
208
|
+
if (prototype !== null && prototype !== (array ? Array.prototype : Object.prototype))
|
|
209
|
+
throw new TypeError('Invalid metadata');
|
|
210
|
+
ancestors.add(value);
|
|
211
|
+
let copy;
|
|
212
|
+
if (array) {
|
|
213
|
+
// Reject holes and extra properties instead of turning them into null or dropping them.
|
|
214
|
+
if (Reflect.ownKeys(value).length !== value.length + 1)
|
|
215
|
+
throw new TypeError('Invalid metadata');
|
|
216
|
+
copy = Array.from({ length: value.length }, (_, index) => {
|
|
217
|
+
if (!Object.hasOwn(value, index))
|
|
218
|
+
throw new TypeError('Invalid metadata');
|
|
219
|
+
return snapshot(value[index]);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
copy = Object.fromEntries(Reflect.ownKeys(value).map((key) => {
|
|
224
|
+
if (typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))
|
|
225
|
+
throw new TypeError('Invalid metadata');
|
|
226
|
+
return [key, snapshot(value[key])];
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
ancestors.delete(value);
|
|
230
|
+
return copy;
|
|
231
|
+
}
|
|
232
|
+
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata))
|
|
233
|
+
throw new TypeError('Invalid metadata');
|
|
234
|
+
return snapshot(metadata);
|
|
235
|
+
}
|
|
195
236
|
export function apply(ctx, config) {
|
|
196
237
|
if (!config.enabled || /^(false|0)$/i.test(process.env.LANGFUSE_TRACING_ENABLED ?? ''))
|
|
197
238
|
return;
|
|
@@ -270,10 +311,23 @@ export function apply(ctx, config) {
|
|
|
270
311
|
await Promise.all([...pendingMedia]);
|
|
271
312
|
}
|
|
272
313
|
ctx.on('langfuse/context', (sessionId, context) => {
|
|
273
|
-
traceContexts.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
314
|
+
traceContexts.delete(sessionId);
|
|
315
|
+
try {
|
|
316
|
+
// Snapshot at submission, not trace creation: the host may reuse or
|
|
317
|
+
// mutate its request object before the next event opens the trace.
|
|
318
|
+
const { userId, tags, traceparent, metadata } = context;
|
|
319
|
+
const snapshot = {
|
|
320
|
+
userId,
|
|
321
|
+
tags,
|
|
322
|
+
traceparent,
|
|
323
|
+
metadata: metadata === undefined ? undefined : snapshotMetadata(metadata),
|
|
324
|
+
};
|
|
325
|
+
traceContexts.set(sessionId, reporter.mask(JSON.parse(JSON.stringify(snapshot))));
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// Never include caller data or serialization errors in diagnostics.
|
|
329
|
+
console.error('[dsh-langfuse] invalid request context; ignoring context');
|
|
330
|
+
}
|
|
277
331
|
});
|
|
278
332
|
function openTrace(sessionId, metadata, startTime) {
|
|
279
333
|
const context = sessionId ? traceContexts.get(sessionId) : undefined;
|
package/lib/types/client.d.ts
CHANGED
|
@@ -53,6 +53,8 @@ export interface LangfuseTraceContext {
|
|
|
53
53
|
userId?: string;
|
|
54
54
|
tags?: string[];
|
|
55
55
|
traceparent?: string;
|
|
56
|
+
/** Strict JSON request dimensions inherited by every observation; non-JSON values reject context. */
|
|
57
|
+
metadata?: Record<string, unknown>;
|
|
56
58
|
}
|
|
57
59
|
/** Any observation that can parent a span (trace root span or nested span/generation). */
|
|
58
60
|
type Observation = LangfuseSpan | LangfuseGeneration;
|
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.31",
|
|
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",
|