@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.
- package/CHANGELOG.md +3 -2
- package/dist/browser/neurolink.min.js +439 -439
- package/dist/core/toolExecutionGuards.js +9 -1
- package/dist/providers/googleVertex/client.js +52 -12
- package/dist/services/server/ai/observability/instrumentation.d.ts +2 -0
- package/dist/services/server/ai/observability/instrumentation.js +4 -0
- package/dist/telemetry/attributes.d.ts +32 -0
- package/dist/telemetry/attributes.js +289 -0
- package/dist/telemetry/index.d.ts +1 -1
- package/dist/telemetry/index.js +1 -1
- package/dist/types/loopEngine.d.ts +12 -1
- package/dist/types/observability.d.ts +8 -0
- package/package.json +2 -1
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
3583
|
-
toolSpan.setAttribute(
|
|
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]:
|
|
3803
|
+
[LANGFUSE_ATTR.OBSERVATION_INPUT]: spanModelRequestAttribute({
|
|
3786
3804
|
system: systemPromptWithSchema,
|
|
3787
|
-
messages: sanitizeAnthropicMessagesForTrace(
|
|
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,
|
|
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 =
|
|
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 =
|
|
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,
|
|
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,
|
|
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,
|
package/dist/telemetry/index.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|