@juspay/neurolink 12.53.0 → 12.54.0

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.
@@ -33,7 +33,15 @@ export function guardToolExecutor(name, execute, guards) {
33
33
  // The span wraps the CALL, not the guard: a timeout or an abort is a fact
34
34
  // about this tool invocation and belongs inside its observation.
35
35
  const wrapInSpan = guards.withToolSpan;
36
- const call = wrapInSpan ? () => wrapInSpan(name, invoke) : invoke;
36
+ const toolCallId = typeof opts === "object" &&
37
+ opts !== null &&
38
+ "toolCallId" in opts &&
39
+ typeof opts.toolCallId === "string"
40
+ ? opts.toolCallId
41
+ : undefined;
42
+ const call = wrapInSpan
43
+ ? () => wrapInSpan(name, invoke, { args, toolCallId })
44
+ : invoke;
37
45
  guards.onProgress?.();
38
46
  try {
39
47
  const raced = guards.abortSignal
@@ -43,7 +43,7 @@ import { createStreamChannel } from "../../core/streamChannel.js";
43
43
  import { toNativeToolDeclarations } from "../../core/nativeToolFormat.js";
44
44
  import { getAvailableInputTokens, getContextWindowSize, } from "../../constants/contextWindows.js";
45
45
  import { resolveLiveTool } from "../../tools/toolDiscovery.js";
46
- import { ATTR, LANGFUSE_ATTR, spanJsonAttribute, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "../../telemetry/index.js";
46
+ import { ATTR, LANGFUSE_ATTR, spanContentAttribute, spanJsonAttribute, spanModelRequestAttribute, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "../../telemetry/index.js";
47
47
  import { SpanKind, SpanStatusCode, context as otelContext, trace as otelTrace, } from "@opentelemetry/api";
48
48
  import { calculateCost } from "../../utils/pricing.js";
49
49
  import { transformToolExecutions } from "../../utils/transformationUtils.js";
@@ -3459,7 +3459,7 @@ export class GoogleVertexProvider extends BaseProvider {
3459
3459
  // Usage lives ONLY on the generation spans (Langfuse sums usage across
3460
3460
  // observations for trace totals, so repeating it on the turn double-counts).
3461
3461
  const offeredToolNames = (tools ?? []).map((anthropicTool) => anthropicTool.name);
3462
- const turnInputAttribute = spanJsonAttribute({
3462
+ const turnInputAttribute = spanModelRequestAttribute({
3463
3463
  system: systemPromptWithSchema,
3464
3464
  messages: sanitizeAnthropicMessagesForTrace(messages),
3465
3465
  });
@@ -3543,6 +3543,7 @@ export class GoogleVertexProvider extends BaseProvider {
3543
3543
  const loopPromise = (async () => {
3544
3544
  let step = 0;
3545
3545
  const currentMessages = [...messages];
3546
+ let stepConversation = currentMessages;
3546
3547
  // Step-cap / abort bookkeeping (mirrors the generate twin): read by the
3547
3548
  // terminal recovery block after the loop and by the finishReason
3548
3549
  // mapping written into finishReasonRef.
@@ -3569,18 +3570,34 @@ export class GoogleVertexProvider extends BaseProvider {
3569
3570
  // migration. Shared with resolveToolOnMiss below: the old dispatch
3570
3571
  // opened the span before the executor lookup, so a tool hydrated
3571
3572
  // mid-turn was observed exactly like one declared up front.
3572
- const withToolSpan = (toolCallName, run) => {
3573
+ const withToolSpan = (toolCallName, run, call) => {
3574
+ // Arguments are inputs: honour the same opt-out the AI SDK path uses.
3575
+ const argsAttribute = call?.args !== undefined &&
3576
+ process.env.NEUROLINK_RECORD_INPUTS?.trim().toLowerCase() !==
3577
+ "false"
3578
+ ? spanContentAttribute(call.args)
3579
+ : undefined;
3573
3580
  const toolSpan = tracers.mcp.startSpan("ai.toolCall", {
3574
3581
  kind: SpanKind.INTERNAL,
3575
3582
  attributes: {
3576
3583
  [LANGFUSE_ATTR.OBSERVATION_TYPE]: "tool",
3577
3584
  [ATTR.GEN_AI_TOOL_NAME]: toolCallName,
3578
3585
  "ai.toolCall.name": toolCallName,
3586
+ ...(call?.toolCallId
3587
+ ? { "ai.toolCall.id": call.toolCallId }
3588
+ : {}),
3589
+ ...(argsAttribute !== undefined
3590
+ ? {
3591
+ "ai.toolCall.args": argsAttribute,
3592
+ [LANGFUSE_ATTR.OBSERVATION_INPUT]: argsAttribute,
3593
+ }
3594
+ : {}),
3579
3595
  },
3580
3596
  }, turnContext);
3581
3597
  const finish = (output, errorMessage) => {
3582
- toolSpan.setAttribute("ai.toolCall.result", spanJsonAttribute(output));
3583
- toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanJsonAttribute(output));
3598
+ const outputAttribute = spanContentAttribute(output);
3599
+ toolSpan.setAttribute("ai.toolCall.result", outputAttribute);
3600
+ toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, outputAttribute);
3584
3601
  if (errorMessage) {
3585
3602
  toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_LEVEL, "ERROR");
3586
3603
  toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_STATUS_MESSAGE, errorMessage);
@@ -3742,6 +3759,7 @@ export class GoogleVertexProvider extends BaseProvider {
3742
3759
  ...baseAdapter,
3743
3760
  buildStepRequest: (conversation, engineStep) => {
3744
3761
  step = engineStep + 1;
3762
+ stepConversation = conversation;
3745
3763
  turnClock.noteProgress();
3746
3764
  return baseAdapter.buildStepRequest(conversation, engineStep);
3747
3765
  },
@@ -3782,9 +3800,9 @@ export class GoogleVertexProvider extends BaseProvider {
3782
3800
  temperature: requestParams.temperature,
3783
3801
  top_p: requestParams.top_p,
3784
3802
  }),
3785
- [LANGFUSE_ATTR.OBSERVATION_INPUT]: spanJsonAttribute({
3803
+ [LANGFUSE_ATTR.OBSERVATION_INPUT]: spanModelRequestAttribute({
3786
3804
  system: systemPromptWithSchema,
3787
- messages: sanitizeAnthropicMessagesForTrace(currentMessages),
3805
+ messages: sanitizeAnthropicMessagesForTrace(stepConversation),
3788
3806
  }),
3789
3807
  [LANGFUSE_ATTR.OBSERVATION_METADATA]: spanJsonAttribute({
3790
3808
  step,
@@ -3812,7 +3830,13 @@ export class GoogleVertexProvider extends BaseProvider {
3812
3830
  turnCacheUsage.creation += result.usage.cacheWriteTokens ?? 0;
3813
3831
  turnCacheUsage.creation5m += result.usage.cacheWrite5mTokens ?? 0;
3814
3832
  turnCacheUsage.creation1h += result.usage.cacheWrite1hTokens ?? 0;
3815
- generationSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanJsonAttribute({ text: result.text }));
3833
+ generationSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanContentAttribute({
3834
+ text: result.text,
3835
+ ...(result.toolCalls.length > 0
3836
+ ? { toolCalls: result.toolCalls }
3837
+ : {}),
3838
+ }));
3839
+ generationSpan.setAttribute(`${LANGFUSE_ATTR.OBSERVATION_METADATA}.finishReason`, result.rawStopReason ?? "unknown");
3816
3840
  return result;
3817
3841
  }
3818
3842
  catch (error) {
@@ -4339,7 +4363,7 @@ export class GoogleVertexProvider extends BaseProvider {
4339
4363
  if (turnCacheUsage.creation > 0) {
4340
4364
  usage.cacheCreationTokens = turnCacheUsage.creation;
4341
4365
  }
4342
- const turnOutputAttribute = spanJsonAttribute({
4366
+ const turnOutputAttribute = spanContentAttribute({
4343
4367
  text: aggregatedTurnText,
4344
4368
  ...(structuredOutputRef.value
4345
4369
  ? { structuredOutput: structuredOutputRef.value }
@@ -4357,6 +4381,7 @@ export class GoogleVertexProvider extends BaseProvider {
4357
4381
  toolCallCount: metadata.totalToolExecutions,
4358
4382
  toolsCalled: toolsUsedRef.filter((name) => name !== "final_result"),
4359
4383
  structuredOutput: useFinalResultTool,
4384
+ ...(stopReasonRef.value ? { stopReason: stopReasonRef.value } : {}),
4360
4385
  usage: {
4361
4386
  input: usage.input,
4362
4387
  output: usage.output,
@@ -4366,10 +4391,25 @@ export class GoogleVertexProvider extends BaseProvider {
4366
4391
  input_cache_creation_1h: turnCacheUsage.creation1h,
4367
4392
  },
4368
4393
  }));
4394
+ // How the turn ended, on the trace itself, so an evaluator can select
4395
+ // only turns that finished ("completed") and skip capped, timed-out,
4396
+ // aborted and failed ones. A trace holding several turns keeps the last
4397
+ // one's; each turn's own is in its observation metadata above.
4398
+ if (stopReasonRef.value) {
4399
+ turnSpan.setAttribute(`${LANGFUSE_ATTR.TRACE_METADATA}.stopReason`, stopReasonRef.value);
4400
+ }
4369
4401
  turnSpan.setStatus({ code: SpanStatusCode.OK });
4370
4402
  channel.close();
4371
4403
  }
4372
4404
  catch (err) {
4405
+ turnSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_METADATA, spanJsonAttribute({
4406
+ toolsOffered: offeredToolNames,
4407
+ toolCount: offeredToolNames.length,
4408
+ maxSteps,
4409
+ structuredOutput: useFinalResultTool,
4410
+ stopReason: "provider-error",
4411
+ }));
4412
+ turnSpan.setAttribute(`${LANGFUSE_ATTR.TRACE_METADATA}.stopReason`, "provider-error");
4373
4413
  turnSpan.setStatus({
4374
4414
  code: SpanStatusCode.ERROR,
4375
4415
  message: err instanceof Error ? err.message : String(err),
@@ -5635,7 +5675,7 @@ export class GoogleVertexProvider extends BaseProvider {
5635
5675
  "";
5636
5676
  // Set generation input before the call so error paths still carry the
5637
5677
  // request; output is set after the native call resolves.
5638
- const generationInputAttribute = spanJsonAttribute({
5678
+ const generationInputAttribute = spanModelRequestAttribute({
5639
5679
  ...(mergedOptions.systemPrompt
5640
5680
  ? { system: mergedOptions.systemPrompt }
5641
5681
  : {}),
@@ -5676,7 +5716,7 @@ export class GoogleVertexProvider extends BaseProvider {
5676
5716
  });
5677
5717
  nativeResult = await this.runNativeGenerateWithMiddleware(mergedOptions, modelName, (effectiveOptions) => this.executeNativeGemini3GenerateWithSchemaFallback(effectiveOptions, modelName));
5678
5718
  }
5679
- executionSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanJsonAttribute(nativeResult?.content ?? ""));
5719
+ executionSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanContentAttribute(nativeResult?.content ?? ""));
5680
5720
  return nativeResult;
5681
5721
  });
5682
5722
  this.attachUsageAndCostAttributes(generateSpan, modelName, result?.usage);
@@ -5685,7 +5725,7 @@ export class GoogleVertexProvider extends BaseProvider {
5685
5725
  // enabled / useAiResponse is false, so the cost is zero on
5686
5726
  // non-TTS paths.
5687
5727
  result = await this.synthesizeAIResponseIfNeeded(result, options);
5688
- generateSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanJsonAttribute(result?.content ?? ""));
5728
+ generateSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanContentAttribute(result?.content ?? ""));
5689
5729
  // Fire onFinish lifecycle callback for the native generate path.
5690
5730
  // Pipeline A providers get this for free via the AI SDK middleware
5691
5731
  // wrapper (LifecycleMiddleware); native @google/genai bypasses
@@ -124,6 +124,8 @@ export declare function setLangfuseContext<T = void>(context: {
124
124
  requestId?: string | null;
125
125
  traceName?: string | null;
126
126
  metadata?: Record<string, unknown> | null;
127
+ /** Langfuse trace tags, set on the trace's root span */
128
+ tags?: string[] | null;
127
129
  /** Explicit operation name (overrides auto-detection) */
128
130
  operationName?: string | null;
129
131
  /** Override global autoDetectOperationName for this context */
@@ -298,6 +298,9 @@ class ContextEnricher {
298
298
  span.setAttribute("langfuse.trace.name", traceName);
299
299
  span.setAttribute("trace.name", traceName);
300
300
  }
301
+ if (isRootSpan && context?.tags && context.tags.length > 0) {
302
+ span.setAttribute("langfuse.trace.tags", [...context.tags]);
303
+ }
301
304
  // Set operation name as separate attribute for filtering/analytics
302
305
  if (operationName) {
303
306
  span.setAttribute("gen_ai.operation.name", operationName);
@@ -1163,6 +1166,7 @@ export async function setLangfuseContext(context, callback) {
1163
1166
  metadata: context.metadata !== undefined
1164
1167
  ? context.metadata
1165
1168
  : currentContext.metadata,
1169
+ tags: context.tags !== undefined ? context.tags : currentContext.tags,
1166
1170
  // Operation name support
1167
1171
  operationName: context.operationName !== undefined
1168
1172
  ? context.operationName
@@ -1,3 +1,4 @@
1
+ import type { SpanModelRequest } from "../types/index.js";
1
2
  export declare const ATTR: {
2
3
  readonly GEN_AI_SYSTEM: "gen_ai.system";
3
4
  readonly GEN_AI_MODEL: "gen_ai.request.model";
@@ -114,6 +115,8 @@ export declare const LANGFUSE_ATTR: {
114
115
  readonly TRACE_NAME: "langfuse.trace.name";
115
116
  readonly TRACE_INPUT: "langfuse.trace.input";
116
117
  readonly TRACE_OUTPUT: "langfuse.trace.output";
118
+ readonly TRACE_TAGS: "langfuse.trace.tags";
119
+ readonly TRACE_METADATA: "langfuse.trace.metadata";
117
120
  readonly OBSERVATION_TYPE: "langfuse.observation.type";
118
121
  readonly OBSERVATION_INPUT: "langfuse.observation.input";
119
122
  readonly OBSERVATION_OUTPUT: "langfuse.observation.output";
@@ -127,6 +130,24 @@ export declare const LANGFUSE_ATTR: {
127
130
  };
128
131
  /** Default ceiling for serialized span attribute values. */
129
132
  export declare const SPAN_ATTRIBUTE_MAX_CHARS = 40000;
133
+ /**
134
+ * Returns a copy of `value` with credentials masked: values under keys such as
135
+ * `access_token`, `password`, `authorization` or `set-cookie`, bearer tokens
136
+ * and `name=value` credentials inside text. Content with nothing sensitive in
137
+ * it serializes exactly as it did before. Throws only if the value cannot be
138
+ * walked; callers on the tracing path use `spanContentAttribute`, which turns
139
+ * that into an omitted value instead of a leak or a failed turn.
140
+ */
141
+ export declare function redactSpanContent(value: unknown): unknown;
142
+ /**
143
+ * `spanJsonAttribute` for content a person or model wrote or a tool returned
144
+ * (arguments, results, answers), with credentials masked first. Use the plain
145
+ * form for metadata such as usage counts and server ids.
146
+ *
147
+ * A structured value over the cap is shortened by trimming its longest
148
+ * strings, so the attribute stays valid JSON instead of ending mid-token.
149
+ */
150
+ export declare function spanContentAttribute(value: unknown, maxChars?: number): string;
130
151
  /**
131
152
  * Serialize an arbitrary value for a span attribute, hard-capped at
132
153
  * `maxChars` so a pathological prompt or tool result can't put megabytes
@@ -134,3 +155,14 @@ export declare const SPAN_ATTRIBUTE_MAX_CHARS = 40000;
134
155
  * JSON-stringified with a String() fallback for circular structures.
135
156
  */
136
157
  export declare function spanJsonAttribute(value: unknown, maxChars?: number): string;
158
+ /**
159
+ * Serialize a model request for a span attribute without losing the
160
+ * conversation to the cap.
161
+ *
162
+ * A head cut keeps the system prompt and drops everything after it, so a long
163
+ * system prompt hides every message, the user's question included. Here the
164
+ * prompt and the newest messages are budgeted first, older messages are
165
+ * dropped whole and counted, and the system prompt gets whatever room is left.
166
+ * The result is always valid JSON.
167
+ */
168
+ export declare function spanModelRequestAttribute(rawRequest: SpanModelRequest, maxChars?: number): string;
@@ -125,6 +125,8 @@ export const LANGFUSE_ATTR = {
125
125
  TRACE_NAME: "langfuse.trace.name",
126
126
  TRACE_INPUT: "langfuse.trace.input",
127
127
  TRACE_OUTPUT: "langfuse.trace.output",
128
+ TRACE_TAGS: "langfuse.trace.tags",
129
+ TRACE_METADATA: "langfuse.trace.metadata",
128
130
  OBSERVATION_TYPE: "langfuse.observation.type",
129
131
  OBSERVATION_INPUT: "langfuse.observation.input",
130
132
  OBSERVATION_OUTPUT: "langfuse.observation.output",
@@ -138,6 +140,174 @@ export const LANGFUSE_ATTR = {
138
140
  };
139
141
  /** Default ceiling for serialized span attribute values. */
140
142
  export const SPAN_ATTRIBUTE_MAX_CHARS = 40_000;
143
+ /** Opening of the system prompt a model-request attribute always keeps. */
144
+ const SYSTEM_PROMPT_FLOOR_CHARS = 2_000;
145
+ /** Room for the "...[truncated N chars]" note, kept outside that opening. */
146
+ const TRUNCATION_NOTE_ALLOWANCE_CHARS = 48;
147
+ const MIN_SHRINKABLE_STRING_CHARS = 64;
148
+ const REDACTED_VALUE = "[REDACTED]";
149
+ const REDACTION_FAILED_VALUE = "[content omitted: redaction failed]";
150
+ const SENSITIVE_KEY_PATTERN = /(authorization|password|passwd|secret|apikey|privatekey|token|credential|signature)/;
151
+ const COOKIE_KEY_PATTERN = /^(set)?cookies?$/;
152
+ // Cheap pre-check: text without any of these is returned untouched, so the
153
+ // common case costs one regex scan and stays byte-identical.
154
+ const SENSITIVE_HINT_PATTERN = /authorization|password|passwd|secret|api[_-]?key|private[_-]?key|token|credential|signature|cookie|bearer/i;
155
+ function isSensitiveKey(key) {
156
+ const normalized = key.replace(/[_-]/g, "").toLowerCase();
157
+ return (SENSITIVE_KEY_PATTERN.test(normalized) ||
158
+ COOKIE_KEY_PATTERN.test(normalized));
159
+ }
160
+ // JSON that a tool returned as text, carried inside JSON the model sees as a
161
+ // string, has its quotes escaped once per level of nesting, and each level
162
+ // doubles the backslashes before the quote: one, three, seven.
163
+ const ESCAPED_QUOTE_BACKSLASHES = [1, 3, 7];
164
+ const ESCAPED_PAIR_PATTERNS = ESCAPED_QUOTE_BACKSLASHES.map((backslashes) => {
165
+ const quote = `${"\\\\".repeat(backslashes)}"`;
166
+ return new RegExp(`${quote}([^"\\\\]{1,80})${quote}(\\s*:\\s*)${quote}((?:(?!${quote})[\\s\\S]){0,8192})${quote}`, "g");
167
+ });
168
+ /**
169
+ * Masks credentials in text that is about to be written to a span: bearer
170
+ * tokens, `name=value` credentials in URLs and form bodies, and the values of
171
+ * sensitive keys in JSON text (tool results arrive as JSON strings).
172
+ */
173
+ function redactText(text) {
174
+ if (!SENSITIVE_HINT_PATTERN.test(text)) {
175
+ return text;
176
+ }
177
+ let redacted = text
178
+ .replace(/\bBearer\s+[\w.\-+/=]{16,}/gi, `Bearer ${REDACTED_VALUE}`)
179
+ .replace(/\b(access_token|refresh_token|id_token|api_key|apikey|client_secret|password|signature|credential)=([^&\s"]+)/gi, `$1=${REDACTED_VALUE}`)
180
+ .replace(/"([^"\\]{1,80})"(\s*:\s*)"(?:[^"\\]|\\.)*"/g, (match, key, separator) => isSensitiveKey(key) ? `"${key}"${separator}"${REDACTED_VALUE}"` : match);
181
+ ESCAPED_QUOTE_BACKSLASHES.forEach((backslashes, index) => {
182
+ const quote = `${"\\".repeat(backslashes)}"`;
183
+ redacted = redacted.replace(ESCAPED_PAIR_PATTERNS[index], (match, key, separator) => isSensitiveKey(key)
184
+ ? `${quote}${key}${quote}${separator}${quote}${REDACTED_VALUE}${quote}`
185
+ : match);
186
+ });
187
+ return redacted;
188
+ }
189
+ function redactNode(value, ancestors) {
190
+ if (typeof value === "string") {
191
+ return redactText(value);
192
+ }
193
+ if (value === null || typeof value !== "object") {
194
+ return value;
195
+ }
196
+ if (ancestors.has(value)) {
197
+ return "[circular]";
198
+ }
199
+ ancestors.add(value);
200
+ try {
201
+ if ("toJSON" in value && typeof value.toJSON === "function") {
202
+ return redactNode(value.toJSON(), ancestors);
203
+ }
204
+ if (Array.isArray(value)) {
205
+ return value.map((entry) => redactNode(entry, ancestors));
206
+ }
207
+ // No prototype, so a key named __proto__ is data rather than an assignment.
208
+ const redacted = Object.create(null);
209
+ for (const [key, entry] of Object.entries(value)) {
210
+ // Counts and flags under a sensitive-looking name (input_tokens: 5) are
211
+ // not credentials; only text and structures are masked.
212
+ redacted[key] =
213
+ isSensitiveKey(key) &&
214
+ (typeof entry === "string" ||
215
+ (typeof entry === "object" && entry !== null))
216
+ ? REDACTED_VALUE
217
+ : redactNode(entry, ancestors);
218
+ }
219
+ return redacted;
220
+ }
221
+ finally {
222
+ ancestors.delete(value);
223
+ }
224
+ }
225
+ /**
226
+ * Returns a copy of `value` with credentials masked: values under keys such as
227
+ * `access_token`, `password`, `authorization` or `set-cookie`, bearer tokens
228
+ * and `name=value` credentials inside text. Content with nothing sensitive in
229
+ * it serializes exactly as it did before. Throws only if the value cannot be
230
+ * walked; callers on the tracing path use `spanContentAttribute`, which turns
231
+ * that into an omitted value instead of a leak or a failed turn.
232
+ */
233
+ export function redactSpanContent(value) {
234
+ return redactNode(value, new WeakSet());
235
+ }
236
+ function redactSafely(value) {
237
+ try {
238
+ return redactSpanContent(value);
239
+ }
240
+ catch {
241
+ return REDACTION_FAILED_VALUE;
242
+ }
243
+ }
244
+ const MAX_SHRINK_ATTEMPTS = 200;
245
+ /**
246
+ * Cuts the longest string inside `root` down by at least `excess` characters,
247
+ * in place, and says whether it found one worth cutting. Only ever called on
248
+ * the copy `redactSpanContent` made, never on the caller's own data.
249
+ */
250
+ function shrinkLongestString(root, excess) {
251
+ let owner;
252
+ let ownerKey = "";
253
+ let longestText = "";
254
+ const visited = new Set();
255
+ const pending = [root];
256
+ while (pending.length > 0) {
257
+ const node = pending.pop();
258
+ if (node === undefined || visited.has(node)) {
259
+ continue;
260
+ }
261
+ visited.add(node);
262
+ for (const [key, entry] of Object.entries(node)) {
263
+ if (typeof entry === "string") {
264
+ if (entry.length > longestText.length &&
265
+ entry.length > MIN_SHRINKABLE_STRING_CHARS) {
266
+ longestText = entry;
267
+ owner = node;
268
+ ownerKey = key;
269
+ }
270
+ }
271
+ else if (entry !== null && typeof entry === "object") {
272
+ pending.push(entry);
273
+ }
274
+ }
275
+ }
276
+ if (owner === undefined) {
277
+ return false;
278
+ }
279
+ const keep = Math.max(MIN_SHRINKABLE_STRING_CHARS / 2, longestText.length - excess - TRUNCATION_NOTE_ALLOWANCE_CHARS);
280
+ Reflect.set(owner, ownerKey, `${longestText.slice(0, keep)}...[truncated ${longestText.length - keep} chars]`);
281
+ return true;
282
+ }
283
+ /**
284
+ * `spanJsonAttribute` for content a person or model wrote or a tool returned
285
+ * (arguments, results, answers), with credentials masked first. Use the plain
286
+ * form for metadata such as usage counts and server ids.
287
+ *
288
+ * A structured value over the cap is shortened by trimming its longest
289
+ * strings, so the attribute stays valid JSON instead of ending mid-token.
290
+ */
291
+ export function spanContentAttribute(value, maxChars = SPAN_ATTRIBUTE_MAX_CHARS) {
292
+ const redacted = redactSafely(value);
293
+ if (redacted === null || typeof redacted !== "object") {
294
+ return spanJsonAttribute(redacted, maxChars);
295
+ }
296
+ let serialized;
297
+ try {
298
+ serialized = JSON.stringify(redacted) ?? String(redacted);
299
+ }
300
+ catch {
301
+ return spanJsonAttribute(redacted, maxChars);
302
+ }
303
+ for (let attempt = 0; serialized.length > maxChars && attempt < MAX_SHRINK_ATTEMPTS; attempt++) {
304
+ if (!shrinkLongestString(redacted, serialized.length - maxChars)) {
305
+ break;
306
+ }
307
+ serialized = JSON.stringify(redacted);
308
+ }
309
+ return spanJsonAttribute(serialized, maxChars);
310
+ }
141
311
  /**
142
312
  * Serialize an arbitrary value for a span attribute, hard-capped at
143
313
  * `maxChars` so a pathological prompt or tool result can't put megabytes
@@ -162,3 +332,122 @@ export function spanJsonAttribute(value, maxChars = SPAN_ATTRIBUTE_MAX_CHARS) {
162
332
  }
163
333
  return serialized;
164
334
  }
335
+ /**
336
+ * Serialize a model request for a span attribute without losing the
337
+ * conversation to the cap.
338
+ *
339
+ * A head cut keeps the system prompt and drops everything after it, so a long
340
+ * system prompt hides every message, the user's question included. Here the
341
+ * prompt and the newest messages are budgeted first, older messages are
342
+ * dropped whole and counted, and the system prompt gets whatever room is left.
343
+ * The result is always valid JSON.
344
+ */
345
+ export function spanModelRequestAttribute(rawRequest, maxChars = SPAN_ATTRIBUTE_MAX_CHARS) {
346
+ // Credentials are masked before anything is measured, so budgets apply to
347
+ // what is actually written. Each message is masked on its own: one that
348
+ // cannot be walked is omitted without losing the rest of the conversation.
349
+ const request = {
350
+ ...(rawRequest.system !== undefined
351
+ ? { system: redactSafely(rawRequest.system) }
352
+ : {}),
353
+ ...(rawRequest.messages !== undefined
354
+ ? {
355
+ messages: rawRequest.messages.map((message) => redactSafely(message)),
356
+ }
357
+ : {}),
358
+ ...(rawRequest.prompt !== undefined
359
+ ? { prompt: redactText(rawRequest.prompt) }
360
+ : {}),
361
+ };
362
+ const serialize = (value) => {
363
+ try {
364
+ return typeof value === "string"
365
+ ? value
366
+ : (JSON.stringify(value) ?? String(value));
367
+ }
368
+ catch {
369
+ return String(value);
370
+ }
371
+ };
372
+ // Sizes are measured as JSON, which is what the attribute carries: escaping
373
+ // makes a JSON string longer than its text.
374
+ const jsonLength = (value) => {
375
+ try {
376
+ return (JSON.stringify(value) ?? "null").length;
377
+ }
378
+ catch {
379
+ return Number.POSITIVE_INFINITY;
380
+ }
381
+ };
382
+ // Never returns an empty string for non-empty text: when nothing fits, the
383
+ // truncation note alone says the value existed and how long it was.
384
+ const fitText = (text, budget) => {
385
+ if (jsonLength(text) <= budget) {
386
+ return text;
387
+ }
388
+ let keep = budget;
389
+ for (let attempt = 0; attempt < 12 && keep > 0; attempt++) {
390
+ const candidate = `${text.slice(0, keep)}...[truncated ${text.length - keep} chars]`;
391
+ const size = jsonLength(candidate);
392
+ if (size <= budget) {
393
+ return candidate;
394
+ }
395
+ keep = Math.floor((keep * budget) / size) - 1;
396
+ }
397
+ return `[truncated ${text.length} chars]`;
398
+ };
399
+ // Room for keys, brackets and the omitted-message count.
400
+ let remaining = maxChars - 128;
401
+ // The system prompt always keeps its opening, so a trace still shows which
402
+ // prompt was in use. Reserved before the messages are budgeted and returned
403
+ // to the system prompt afterwards; a request that fits is unaffected.
404
+ const systemFloor = request.system !== undefined
405
+ ? Math.min(jsonLength(request.system), SYSTEM_PROMPT_FLOOR_CHARS + TRUNCATION_NOTE_ALLOWANCE_CHARS)
406
+ : 0;
407
+ remaining -= systemFloor;
408
+ let prompt;
409
+ if (request.prompt !== undefined) {
410
+ prompt = fitText(request.prompt, remaining);
411
+ remaining -= jsonLength(prompt);
412
+ }
413
+ const messages = request.messages ?? [];
414
+ const kept = [];
415
+ for (let index = messages.length - 1; index >= 0; index--) {
416
+ const size = jsonLength(messages[index]) + 1;
417
+ if (size <= remaining) {
418
+ kept.unshift(messages[index]);
419
+ remaining -= size;
420
+ continue;
421
+ }
422
+ if (kept.length === 0 && remaining > 64) {
423
+ const excerpt = fitText(serialize(messages[index]), remaining - 32);
424
+ kept.unshift({ truncatedMessage: excerpt });
425
+ remaining -= jsonLength(excerpt) + 32;
426
+ }
427
+ break;
428
+ }
429
+ const omittedMessages = messages.length - kept.length;
430
+ remaining += systemFloor;
431
+ const body = {};
432
+ if (request.system !== undefined) {
433
+ body.system =
434
+ jsonLength(request.system) <= remaining
435
+ ? request.system
436
+ : fitText(serialize(request.system), Math.max(0, remaining));
437
+ }
438
+ if (request.messages !== undefined) {
439
+ body.messages = kept;
440
+ }
441
+ if (omittedMessages > 0) {
442
+ body.omittedMessages = omittedMessages;
443
+ }
444
+ if (prompt !== undefined) {
445
+ body.prompt = prompt;
446
+ }
447
+ try {
448
+ return JSON.stringify(body);
449
+ }
450
+ catch {
451
+ return spanJsonAttribute(body, maxChars);
452
+ }
453
+ }
@@ -1,7 +1,7 @@
1
1
  export { TelemetryService } from "./telemetryService.js";
2
2
  export { tracers } from "./tracers.js";
3
3
  export { withSpan, withClientSpan, withStreamSpan, withClientStreamSpan, } from "./withSpan.js";
4
- export { ATTR, LANGFUSE_ATTR, SPAN_ATTRIBUTE_MAX_CHARS, spanJsonAttribute, } from "./attributes.js";
4
+ export { ATTR, LANGFUSE_ATTR, SPAN_ATTRIBUTE_MAX_CHARS, redactSpanContent, spanContentAttribute, spanJsonAttribute, spanModelRequestAttribute, } from "./attributes.js";
5
5
  /**
6
6
  * Initialize telemetry for NeuroLink
7
7
  * Reuses an existing global TracerProvider when one is already registered,
@@ -2,7 +2,7 @@
2
2
  export { TelemetryService } from "./telemetryService.js";
3
3
  export { tracers } from "./tracers.js";
4
4
  export { withSpan, withClientSpan, withStreamSpan, withClientStreamSpan, } from "./withSpan.js";
5
- export { ATTR, LANGFUSE_ATTR, SPAN_ATTRIBUTE_MAX_CHARS, spanJsonAttribute, } from "./attributes.js";
5
+ export { ATTR, LANGFUSE_ATTR, SPAN_ATTRIBUTE_MAX_CHARS, redactSpanContent, spanContentAttribute, spanJsonAttribute, spanModelRequestAttribute, } from "./attributes.js";
6
6
  import { logger } from "../utils/logger.js";
7
7
  /**
8
8
  * Initialize telemetry for NeuroLink
@@ -346,6 +346,13 @@ export type AnthropicLoopAdapterConfig<TMessage = Anthropic.Messages.MessagePara
346
346
  * it, so this cannot quietly give a provider behaviour its hand-rolled loops
347
347
  * never had.
348
348
  */
349
+ /** One model-requested tool call, as recorded on the tool's observability span. */
350
+ export type ToolSpanCall = {
351
+ /** Arguments the model passed to the tool. */
352
+ args?: Record<string, unknown>;
353
+ /** The model's id for this call, shared with the tool_use block that requested it. */
354
+ toolCallId?: string;
355
+ };
349
356
  export type ToolExecutionGuards = {
350
357
  /**
351
358
  * Upper bound on a single execute(). Omit, or pass `null`, for no bound.
@@ -380,8 +387,12 @@ export type ToolExecutionGuards = {
380
387
  * mark a FAILURE THAT WAS RETURNED rather than thrown: MCP tools report
381
388
  * errors in their payload, and an observation that only watches for
382
389
  * exceptions records those calls as successful.
390
+ *
391
+ * `call` carries the arguments the model passed and the model's id for the
392
+ * call, so the observation can show what was asked and be paired with the
393
+ * request that asked it.
383
394
  */
384
- withToolSpan?: <T>(name: string, run: () => Promise<T>) => Promise<T>;
395
+ withToolSpan?: <T>(name: string, run: () => Promise<T>, call?: ToolSpanCall) => Promise<T>;
385
396
  };
386
397
  /**
387
398
  * @deprecated Renamed to `ToolExecutionGuards` — the guards were never
@@ -229,6 +229,12 @@ export type TraceView = {
229
229
  * Extended context for Langfuse spans.
230
230
  * Supports all Langfuse trace attributes for rich observability.
231
231
  */
232
+ /** A model request serialized onto a span: the system prompt plus messages, or a bare prompt. */
233
+ export type SpanModelRequest = {
234
+ system?: unknown;
235
+ messages?: readonly unknown[];
236
+ prompt?: string;
237
+ };
232
238
  export type LangfuseContext = {
233
239
  userId?: string | null;
234
240
  sessionId?: string | null;
@@ -240,6 +246,8 @@ export type LangfuseContext = {
240
246
  traceName?: string | null;
241
247
  /** Custom metadata to attach to spans */
242
248
  metadata?: Record<string, unknown> | null;
249
+ /** Langfuse trace tags, set on the trace's root span (e.g. to label what a trace is for) */
250
+ tags?: string[] | null;
243
251
  /**
244
252
  * Explicit operation name (e.g., "ai.streamText", "chat", "embeddings").
245
253
  * If set, overrides auto-detection from the span name.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.53.0",
3
+ "version": "12.54.0",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
6
6
  "author": {
@@ -113,6 +113,7 @@
113
113
  "test:adjust-body-after-400": "pnpm exec tsx test/continuous-test-suite-adjust-body-after-400.ts",
114
114
  "test:zod3-schema-native-path": "pnpm exec tsx test/continuous-test-suite-zod3-schema-native-path.ts",
115
115
  "test:forced-tool-declared": "pnpm exec tsx test/continuous-test-suite-forced-tool-declared.ts",
116
+ "test:span-content-redaction": "pnpm exec tsx test/continuous-test-suite-span-content-redaction.ts",
116
117
  "test:voice-turn-memory": "pnpm exec tsx test/continuous-test-suite-voice-turn-memory.ts",
117
118
  "test:error-classification-e2e": "pnpm exec tsx test/continuous-test-suite-error-classification-e2e.ts",
118
119
  "test:error-classifier-contract": "pnpm exec tsx test/continuous-test-suite-error-classifier-contract.ts",