@amaster.ai/dsh-langfuse 0.1.16 → 0.1.18

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
@@ -5,10 +5,12 @@
5
5
  Langfuse observability for [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness):
6
6
 
7
7
  - one **generation** per LLM call (`llm/stream` waterfall), plus a nested `llm-request` span carrying the verbatim loop-built request
8
- - one **span** per tool call (`tools/execute` waterfall)
8
+ - one **tool observation** per tool call (`tools/execute` waterfall)
9
9
  - one **span** per `hook/invoked` / `hook/result` pair, with hook point, decision, exit code, and duration (stderr summary follows `captureContent`)
10
10
  - one **trace** per session turn (`session/event`) — in the v5 SDK the trace IS its root span, ended (and thereby exported) at `turn/end`
11
11
  - subagent child sessions nested under the parent's tree (`session/created` header link + `subagent/start` / `subagent/end`)
12
+ - structured reasoning in generation output, optional image display, inherited user/tags, and final turn severity
13
+ - optional W3C parent context supplied per session, without adopting the host's ambient OTEL context
12
14
 
13
15
  ## Install
14
16
 
@@ -18,7 +20,7 @@ dsh plugin --profile my-agent add @amaster.ai/dsh-langfuse \
18
20
  @opentelemetry/api @opentelemetry/exporter-trace-otlp-http
19
21
  ```
20
22
 
21
- The Langfuse JS SDK v5 peers (`@langfuse/tracing`, `@langfuse/otel`, `@opentelemetry/sdk-trace-node`) are loaded via lazy dynamic `import()`, so an unused install costs nothing — but they must all be present at runtime (`dsh plugin add` does not auto-install peer trees, so the OTEL api/exporter packages `@langfuse/otel` itself peers on are listed explicitly).
23
+ The SDK peers (`@langfuse/tracing`, `@langfuse/otel`, `@opentelemetry/sdk-trace-node`, `@opentelemetry/api`) are loaded via lazy dynamic `import()`, so an unused install costs nothing — but they must all be present at runtime (`dsh plugin add` does not auto-install peer trees, so the exporter that `@langfuse/otel` itself peers on is listed explicitly).
22
24
 
23
25
  ## Configuration
24
26
 
@@ -35,9 +37,55 @@ Disabled by default. Configure via the profile's `cordis.patch.yml`:
35
37
  baseUrl: https://cloud.langfuse.com
36
38
  traceName: dsh-turn
37
39
  captureContent: true # set false to record metadata only
40
+ userId: developer
41
+ tags: [coding, experiment-a]
42
+ environment: development
43
+ release: build-42
44
+ redactFields: [password, authorization]
45
+ captureMedia: false # opt in to uploading images
46
+ maxMediaBytes: 5242880 # total image bytes per display payload
47
+ mediaTimeoutMs: 5000 # attachment read deadline
38
48
  ```
39
49
 
40
- Buffered spans drain on `session/flush(session)`; the exporter shuts down with the plugin fiber.
50
+ 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.
51
+
52
+ Buffered spans drain on `session/flush(session)`; the exporter shuts down with the plugin fiber. Both checkpoints await pending display projections. Unload also closes outstanding model calls with their collected partial content/usage and marks incomplete calls `WARNING`, including calls whose stream was never consumed; the original stream can still settle without ending those observations twice. Attachment reads run in the background and never delay the agent's chunk stream or tool return value; a read that exceeds its deadline degrades to image metadata, even if the backend ignores cancellation. The SDK's `LANGFUSE_FLUSH_AT`, `LANGFUSE_FLUSH_INTERVAL`, and `LANGFUSE_TIMEOUT` control exporter batching and network timeouts.
53
+
54
+ 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
+ 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
+
58
+ ## Reasoning, images, and turn results
59
+
60
+ 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.
61
+
62
+ `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.
63
+
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
+
66
+ ## Per-request labels and distributed tracing
67
+
68
+ Hosts can supply standard context before a session's next trace starts:
69
+
70
+ ```ts
71
+ ctx.emit('langfuse/context', session.id, {
72
+ userId: 'user-123',
73
+ tags: ['support', 'experiment-b'],
74
+ traceparent: '00-1234567890abcdef1234567890abcdef-1234567890abcdef-01',
75
+ });
76
+ ```
77
+
78
+ 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
+
80
+ 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
+
82
+ ## Token usage and cost
83
+
84
+ Generation `usageDetails` has disjoint `input`, `cache_read_input_tokens`, `cache_creation_input_tokens`, `output`, and `output_reasoning_tokens` buckets plus `total`. Reasoning is subtracted from provider output before becoming its own bucket; cached input is counted separately. These keys match the official Pi integration. Custom definitions using the previous `input_cache_read`, `input_cache_creation`, or `output_reasoning` names must update their price keys. This avoids double-counting both usage and inferred cost. On Langfuse v3, the compact token badge may display only the base `input`/`output` buckets. Open its usage breakdown for aggregate input (including cache) and output (including reasoning); `total` already includes every bucket. Do not add cached tokens to `input` again to change that badge.
85
+
86
+ [Langfuse infers USD cost](https://langfuse.com/docs/observability/features/token-and-cost-tracking) when the generation model matches a model definition with prices for the **exact usage keys**. In Project Settings → Models, configure these buckets for private routes, model aliases, or custom prices; reasoning output generally uses the model's output rate. A token total does not prove that a matching price definition exists. If a generation shows “Create model definition”, its model has no matching definition. For gateway aliases, use the actual routed model and your gateway’s rates when defining prices; the plugin keeps the requested model name rather than guessing the upstream model. Definition changes apply to new generations only. dsh's current `TokenUsage` does not carry billed cost, so the plugin does not claim provider-invoiced costs or maintain its own price table.
87
+
88
+ Compaction and session-title calls passing through `llm/stream` already receive their own generation and `purpose` metadata, including out-of-turn calls. Tools using the same LLM service are observed there too; adding a second generation from tool-result usage would duplicate accounting.
41
89
 
42
90
  Hook spans appear when a dsh hook bridge emits the paired session records. The current `hook/result` record does not contain stdout; successful script output can appear in the LLM request when the bridge injects it as context, while the hook span reports decision, exit code, duration, and bounded stderr. `SessionStart` runs before the first turn and does not emit these records, so it has no hook span.
43
91
 
@@ -45,8 +93,12 @@ Observability is a no-throw seam: backend errors are logged with a `[dsh-langfus
45
93
 
46
94
  ## Compatibility
47
95
 
96
+ Run `pnpm test:sdk` at the repository root for a secrets-free check using the real Langfuse SDK and the existing local OTLP receiver. It verifies export, native tool type, SDK label redaction, ambient-baggage isolation, external trace ownership, and agent-less tool unload. It is separate from the mocked unit suite and the gateway/real-Langfuse E2E legs.
97
+
48
98
  Pinned dsh/cordis versions live in the [root compat matrix](../../README.md#compatibility). Event payloads ride pre-release dsh APIs — check the `TODO(verify)` markers in `src/` before upgrading dsh.
49
99
 
50
100
  ## License
51
101
 
52
102
  MIT
103
+
104
+ The Langfuse integration scenarios label observations with environment `ci`, release `GITHUB_SHA`, and tags for scenario, run ID, and workflow run attempt. Select environment `ci` in Langfuse to find new E2E traces. Read-back uses a bounded time window for each model run and queries a discovered trace directly while ingestion settles.
package/lib/client.js CHANGED
@@ -40,6 +40,21 @@
40
40
  * parent instead of adopting the ambient context's active span.
41
41
  */
42
42
  const NO_PARENT = { traceId: '0'.repeat(32), spanId: '0'.repeat(16), traceFlags: 0 };
43
+ function parentContext(traceparent) {
44
+ if (traceparent === undefined)
45
+ return NO_PARENT;
46
+ const match = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(traceparent);
47
+ if (!match || /^0+$/.test(match[1]) || /^0+$/.test(match[2])) {
48
+ console.error('[dsh-langfuse] invalid traceparent; starting an independent trace');
49
+ return NO_PARENT;
50
+ }
51
+ return {
52
+ traceId: match[1],
53
+ spanId: match[2],
54
+ traceFlags: Number.parseInt(match[3], 16) & 1,
55
+ isRemote: true,
56
+ };
57
+ }
43
58
  /**
44
59
  * Map dsh token accounting onto Langfuse's `usageDetails`, keeping every
45
60
  * bucket mutually exclusive (Langfuse's flat-bucket rule: `input` excludes
@@ -49,7 +64,7 @@ const NO_PARENT = { traceId: '0'.repeat(32), spanId: '0'.repeat(16), traceFlags:
49
64
  * INCLUDES reasoning (verified in dsh-llm-deepseek@0.1.6-alpha.2:
50
65
  * `outputTokens: usage.completion_tokens`, with reasoning split out of
51
66
  * `completion_tokens_details`), so the `output` bucket subtracts
52
- * `reasoningTokens` into `output_reasoning`. Only `usageDetails` is sent.
67
+ * `reasoningTokens` into `output_reasoning_tokens`. Only `usageDetails` is sent.
53
68
  */
54
69
  export function usageOf(usage) {
55
70
  // The dsh type marks the two primary fields required, but a non-conformant
@@ -68,16 +83,17 @@ export function usageOf(usage) {
68
83
  total,
69
84
  };
70
85
  if (usage.cacheReadTokens)
71
- usageDetails.input_cache_read = usage.cacheReadTokens;
86
+ usageDetails.cache_read_input_tokens = usage.cacheReadTokens;
72
87
  if (usage.cacheWriteTokens)
73
- usageDetails.input_cache_creation = usage.cacheWriteTokens;
88
+ usageDetails.cache_creation_input_tokens = usage.cacheWriteTokens;
74
89
  if (reasoning)
75
- usageDetails.output_reasoning = reasoning;
90
+ usageDetails.output_reasoning_tokens = reasoning;
76
91
  return usageDetails;
77
92
  }
78
93
  export class LangfuseReporter {
79
94
  config;
80
95
  tracing = null;
96
+ api = null;
81
97
  provider = null;
82
98
  /**
83
99
  * Observation → its trace's correlating attributes; children inherit their
@@ -99,24 +115,112 @@ export class LangfuseReporter {
99
115
  this.config = config;
100
116
  this.ready = this.init();
101
117
  }
118
+ /** Clone telemetry only; never mutate harness-owned messages or tool results. */
119
+ mask(data) {
120
+ const secrets = [this.config.publicKey, this.config.secretKey].filter(Boolean);
121
+ const fields = new Set(this.config.redactFields?.map((key) => key.toLowerCase()));
122
+ const seen = new WeakSet();
123
+ const redactText = (text) => {
124
+ const ranges = Array.from(text.matchAll(/(?:pk|sk)-lf-[\w-]+/g), (match) => [match.index, match.index + match[0].length]);
125
+ for (const secret of secrets) {
126
+ for (let index = text.indexOf(secret); index !== -1; index = text.indexOf(secret, index + 1)) {
127
+ ranges.push([index, index + secret.length]);
128
+ }
129
+ }
130
+ let output = '';
131
+ let end = 0;
132
+ // Match the original text once, merging overlap before changing any bytes.
133
+ for (const [start, stop] of ranges.sort((a, b) => a[0] - b[0])) {
134
+ if (start >= end)
135
+ output += `${text.slice(end, start)}[REDACTED]`;
136
+ end = Math.max(end, stop);
137
+ }
138
+ return output + text.slice(end);
139
+ };
140
+ const redact = (value) => {
141
+ if (typeof value === 'string') {
142
+ // Tool-call arguments and SDK attributes may themselves be JSON text.
143
+ if (fields.size && /^\s*[[{]/.test(value)) {
144
+ let parsed;
145
+ try {
146
+ parsed = JSON.parse(value);
147
+ }
148
+ catch {
149
+ /* not JSON */
150
+ }
151
+ if (parsed !== undefined) {
152
+ const masked = JSON.stringify(redact(parsed));
153
+ if (masked !== JSON.stringify(parsed))
154
+ return masked;
155
+ }
156
+ }
157
+ return redactText(value);
158
+ }
159
+ if (!value || typeof value !== 'object' || value instanceof Date)
160
+ return value;
161
+ if (seen.has(value))
162
+ return '[Circular]';
163
+ seen.add(value);
164
+ const result = Array.isArray(value)
165
+ ? value.map(redact)
166
+ : Object.fromEntries(Object.entries(value).map(([key, child]) => [
167
+ redact(key),
168
+ fields.has(key.toLowerCase()) ? '[REDACTED]' : redact(child),
169
+ ]));
170
+ seen.delete(value);
171
+ return result;
172
+ };
173
+ try {
174
+ return redact(data);
175
+ }
176
+ catch (error) {
177
+ console.error('[dsh-langfuse] redaction failed:', error);
178
+ return '[REDACTED]';
179
+ }
180
+ }
102
181
  /** Dynamically import the SDK and wire the isolated export pipeline. */
103
182
  async init() {
104
183
  try {
105
- const [tracing, otel, sdkNode] = await Promise.all([
184
+ const [tracing, otel, sdkNode, api] = await Promise.all([
106
185
  import('@langfuse/tracing'),
107
186
  import('@langfuse/otel'),
108
187
  import('@opentelemetry/sdk-trace-node'),
188
+ import('@opentelemetry/api'),
109
189
  ]);
190
+ const environment = this.config.environment || process.env.LANGFUSE_TRACING_ENVIRONMENT || undefined;
191
+ const maskedEnvironment = this.mask(environment);
110
192
  const processor = new otel.LangfuseSpanProcessor({
111
193
  publicKey: this.config.publicKey,
112
194
  secretKey: this.config.secretKey,
113
195
  baseUrl: this.config.baseUrl,
196
+ environment: maskedEnvironment === environment ? environment : 'redacted',
197
+ release: this.mask(this.config.release || process.env.LANGFUSE_RELEASE || undefined),
198
+ mediaUploadEnabled: !!this.config.captureMedia &&
199
+ this.config.captureContent !== false &&
200
+ !/^(false|0)$/i.test(process.env.LANGFUSE_MEDIA_UPLOAD_ENABLED ?? ''),
201
+ mask: ({ data }) => {
202
+ // OTEL attributes arrive as serialized JSON; configured field names
203
+ // still need structural redaction at the final exporter boundary.
204
+ if (typeof data === 'string') {
205
+ let parsed;
206
+ try {
207
+ parsed = JSON.parse(data);
208
+ }
209
+ catch {
210
+ /* plain text */
211
+ }
212
+ if (parsed !== undefined)
213
+ return JSON.stringify(this.mask(parsed));
214
+ }
215
+ return this.mask(data);
216
+ },
114
217
  });
115
218
  this.provider = new sdkNode.NodeTracerProvider({ spanProcessors: [processor] });
116
219
  // Isolated provider, not provider.register(): the process-global OTEL
117
220
  // provider stays untouched. Set `tracing` last — observation methods
118
221
  // gate on it, and it must never be visible before the provider is wired.
119
222
  tracing.setLangfuseTracerProvider(this.provider);
223
+ this.api = api;
120
224
  this.tracing = tracing;
121
225
  }
122
226
  catch (error) {
@@ -131,19 +235,37 @@ export class LangfuseReporter {
131
235
  stampTraceContext(observation, context) {
132
236
  if (!context)
133
237
  return;
134
- observation.otelSpan.setAttribute('langfuse.trace.name', context.traceName);
238
+ if (context.traceName)
239
+ observation.otelSpan.setAttribute('langfuse.trace.name', this.mask(context.traceName));
135
240
  if (context.sessionId)
136
- observation.otelSpan.setAttribute('session.id', context.sessionId);
241
+ observation.otelSpan.setAttribute('session.id', this.mask(context.sessionId));
242
+ if (context.userId)
243
+ observation.otelSpan.setAttribute('user.id', this.mask(context.userId));
244
+ if (context.tags?.length)
245
+ observation.otelSpan.setAttribute('langfuse.trace.tags', this.mask(context.tags));
137
246
  this.traceContext.set(observation, context);
138
247
  }
139
248
  /** Open a trace (one per agent turn, or one-off for session-less calls). */
140
249
  openTrace(input) {
141
- if (!this.tracing)
250
+ const tracing = this.tracing;
251
+ const api = this.api;
252
+ if (!tracing || !api)
142
253
  return null;
143
254
  try {
144
- const root = this.tracing.startObservation(input.name, { metadata: input.metadata }, { parentSpanContext: NO_PARENT });
145
- this.stampTraceContext(root, { sessionId: input.sessionId, traceName: input.name });
146
- this.roots.add(root);
255
+ const parent = parentContext(input.context?.traceparent);
256
+ input = this.mask(input);
257
+ const root = api.context.with(api.ROOT_CONTEXT, () => tracing.startObservation(input.name, { metadata: input.metadata }, { parentSpanContext: parent }));
258
+ this.stampTraceContext(root, {
259
+ sessionId: input.sessionId,
260
+ traceName: parent === NO_PARENT ? input.name : undefined,
261
+ userId: input.context?.userId ?? this.config.userId,
262
+ tags: input.context?.tags ?? this.config.tags,
263
+ });
264
+ // External traces retain their owner's trace name and trace-level IO.
265
+ if (parent === NO_PARENT)
266
+ this.roots.add(root);
267
+ else
268
+ root.otelSpan.setAttribute(tracing.LangfuseOtelSpanAttributes.IS_APP_ROOT, false);
147
269
  return root;
148
270
  }
149
271
  catch (error) {
@@ -153,15 +275,17 @@ export class LangfuseReporter {
153
275
  }
154
276
  /** Open a generation (one per LLM call) under any observation parent. */
155
277
  startGeneration(parent, input) {
156
- if (!parent)
278
+ const api = this.api;
279
+ if (!parent || !api)
157
280
  return null;
158
281
  try {
159
- const generation = parent.startObservation(input.name, {
282
+ input = this.mask(input);
283
+ const generation = api.context.with(api.ROOT_CONTEXT, () => parent.startObservation(input.name, {
160
284
  model: input.model,
161
285
  input: input.input,
162
286
  modelParameters: input.modelParameters,
163
287
  metadata: input.metadata,
164
- }, { asType: 'generation' });
288
+ }, { asType: 'generation' }));
165
289
  this.stampTraceContext(generation, this.traceContext.get(parent));
166
290
  return generation;
167
291
  }
@@ -170,10 +294,11 @@ export class LangfuseReporter {
170
294
  return null;
171
295
  }
172
296
  }
173
- endGeneration(generation, update) {
297
+ endGeneration(generation, update, endedAt) {
174
298
  if (!generation)
175
299
  return;
176
300
  try {
301
+ update = this.mask(update);
177
302
  generation.update({
178
303
  output: update.output,
179
304
  usageDetails: update.usage ? usageOf(update.usage) : undefined,
@@ -185,7 +310,7 @@ export class LangfuseReporter {
185
310
  // v5 observation attributes have no `name` — renames ride the OTEL span.
186
311
  if (update.name)
187
312
  generation.otelSpan.updateName(update.name);
188
- generation.end();
313
+ generation.end(endedAt);
189
314
  }
190
315
  catch (error) {
191
316
  console.error('[dsh-langfuse] generation end failed:', error);
@@ -193,13 +318,18 @@ export class LangfuseReporter {
193
318
  }
194
319
  /** Open a span (one per tool dispatch, or a nested detail span) under any observation parent. */
195
320
  startSpan(parent, input) {
196
- if (!parent)
321
+ const api = this.api;
322
+ if (!parent || !api)
197
323
  return null;
198
324
  try {
199
- const span = parent.startObservation(input.name, {
325
+ input = this.mask(input);
326
+ const attributes = {
200
327
  input: input.input,
201
328
  metadata: input.metadata,
202
- });
329
+ };
330
+ const span = api.context.with(api.ROOT_CONTEXT, () => input.asType === 'tool'
331
+ ? parent.startObservation(input.name, attributes, { asType: 'tool' })
332
+ : parent.startObservation(input.name, attributes));
203
333
  this.stampTraceContext(span, this.traceContext.get(parent));
204
334
  return span;
205
335
  }
@@ -220,6 +350,7 @@ export class LangfuseReporter {
220
350
  if (!span)
221
351
  return;
222
352
  try {
353
+ update = this.mask(update);
223
354
  span.update({ input: update.input, metadata: update.metadata });
224
355
  if (update.name)
225
356
  span.otelSpan.updateName(update.name);
@@ -237,15 +368,16 @@ export class LangfuseReporter {
237
368
  * On trace roots the output also rides the deprecated `langfuse.trace.*`
238
369
  * attributes (setTraceIO) for older servers — same rule as updateSpan.
239
370
  */
240
- endSpan(span, update = {}) {
371
+ endSpan(span, update = {}, endedAt) {
241
372
  if (!span)
242
373
  return;
243
374
  try {
375
+ update = this.mask(update);
244
376
  span.update(update);
245
377
  if (update.output !== undefined && this.roots.has(span)) {
246
378
  span.setTraceIO({ output: update.output });
247
379
  }
248
- span.end();
380
+ span.end(endedAt);
249
381
  }
250
382
  catch (error) {
251
383
  console.error('[dsh-langfuse] span end failed:', error);
@@ -270,6 +402,7 @@ export class LangfuseReporter {
270
402
  const tracing = this.tracing;
271
403
  this.provider = null;
272
404
  this.tracing = null;
405
+ this.api = null;
273
406
  if (!provider || !tracing)
274
407
  return;
275
408
  try {