@juspay/neurolink 9.55.10 → 9.56.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.
@@ -113,6 +113,18 @@ export class StreamHandler {
113
113
  // rather than crashing the process with an unhandled rejection.
114
114
  if (NoOutputGeneratedError.isInstance(error)) {
115
115
  logger.warn(`${providerName}: Stream produced no output (NoOutputGeneratedError), returning empty stream`);
116
+ // Curator P2-5: stamp the active OTel span so ContextEnricher.onEnd()
117
+ // surfaces a WARNING-level Langfuse observation instead of defaulting
118
+ // to DEFAULT with no status message.
119
+ try {
120
+ const activeSpan = trace.getSpan(otelContext.active());
121
+ if (activeSpan) {
122
+ activeSpan.setAttribute("neurolink.no_output", true);
123
+ }
124
+ }
125
+ catch {
126
+ // Tracing not initialized — ignore.
127
+ }
116
128
  // S4 fix: yield a sentinel chunk so Pipeline B can detect the empty stream
117
129
  // and set the span to WARNING status instead of OK
118
130
  yield {
@@ -399,6 +399,10 @@ export class ToolsManager {
399
399
  attributes: {
400
400
  "tool.name": toolName,
401
401
  "tool.type": "custom",
402
+ // Curator P1-3: pure wrapper — duplicates the AI SDK's
403
+ // ai.toolCall observation in Langfuse. Keep the OTel span
404
+ // for internal metrics; filter from Langfuse export.
405
+ "langfuse.internal": true,
402
406
  },
403
407
  });
404
408
  const startTime = Date.now();
@@ -631,6 +631,11 @@ export class RedisConversationMemoryManager {
631
631
  * Applies sendToolPreview toggle and hydrates result.result for backward compat
632
632
  */
633
633
  async buildContextMessages(sessionId, userId, enableSummarization, requestId) {
634
+ logger.debug("[RedisConversationMemoryManager] Building context messages", {
635
+ sessionId,
636
+ userId,
637
+ enableSummarization,
638
+ });
634
639
  await this.ensureInitialized();
635
640
  if (!this.redisClient) {
636
641
  logger.warn("[RedisConversationMemoryManager] Redis client not available in buildContextMessages");
@@ -654,6 +659,11 @@ export class RedisConversationMemoryManager {
654
659
  const redisKey = getSessionKey(this.redisConfig, sessionId, userId);
655
660
  const conversationData = await withTimeout(redisClient.get(redisKey), REDIS_TIMEOUT_MS);
656
661
  const conversation = deserializeConversation(conversationData || null);
662
+ logger.debug("[RedisConversationMemoryManager] Retrieved conversation for context building", {
663
+ sessionId,
664
+ userId,
665
+ conversationFound: !!conversation,
666
+ });
657
667
  if (!conversation) {
658
668
  span.setAttribute("session.found", false);
659
669
  span.setStatus({ code: SpanStatusCode.OK });
@@ -672,6 +682,12 @@ export class RedisConversationMemoryManager {
672
682
  lastActivity: new Date(conversation.updatedAt).getTime(),
673
683
  };
674
684
  const contextMessages = buildContextFromPointer(session, requestId);
685
+ logger.debug("[RedisConversationMemoryManager] Built context messages from pointer", {
686
+ sessionId,
687
+ userId,
688
+ contextMessageCount: contextMessages.length,
689
+ pointerMessageId: session.summarizedUpToMessageId || "none",
690
+ });
675
691
  const sendToolPreview = this.config?.contextCompaction?.sendToolPreview === true;
676
692
  // Map tool_result messages: apply preview toggle + hydrate result.result
677
693
  const finalMessages = contextMessages.map((msg) => {
@@ -695,6 +711,15 @@ export class RedisConversationMemoryManager {
695
711
  }
696
712
  hydratedResult = { ...msg.result, result: parsedResult };
697
713
  }
714
+ logger.debug("[RedisConversationMemoryManager] Processing tool_result message for context", {
715
+ sessionId,
716
+ userId,
717
+ messageId: msg.id,
718
+ sendToolPreview,
719
+ hasPreview: !!msg.metadata?.toolOutputPreview,
720
+ contentLength: content ? String(content).length : 0,
721
+ resultHydrated: hydratedResult !== msg.result,
722
+ });
698
723
  return { ...msg, content, result: hydratedResult };
699
724
  });
700
725
  // Tool messages now have real content and participate in context properly.
package/dist/index.d.ts CHANGED
@@ -48,8 +48,8 @@ export { TTSProcessor } from "./utils/ttsProcessor.js";
48
48
  import { NeuroLink } from "./neurolink.js";
49
49
  export { NeuroLink };
50
50
  export { buildObservabilityConfigFromEnv } from "./utils/observabilityHelpers.js";
51
- import { createContextEnricher, flushOpenTelemetry, getLangfuseContext, getLangfuseHealthStatus, getLangfuseSpanProcessor, getSpanProcessors, getTracer, getTracerProvider, initializeOpenTelemetry, isOpenTelemetryInitialized, isUsingExternalTracerProvider, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry } from "./services/server/ai/observability/instrumentation.js";
52
- export { initializeOpenTelemetry, shutdownOpenTelemetry, flushOpenTelemetry, getLangfuseHealthStatus, setLangfuseContext, getLangfuseSpanProcessor, getTracerProvider, isOpenTelemetryInitialized, getSpanProcessors, createContextEnricher, isUsingExternalTracerProvider, getLangfuseContext, getTracer, runWithCurrentLangfuseContext, };
51
+ import { createContextEnricher, flushOpenTelemetry, getLangfuseContext, getLangfuseHealthStatus, getLangfuseSpanProcessor, getSpanProcessors, getTracer, getTracerProvider, initializeOpenTelemetry, isLangfuseInternalSpan, isOpenTelemetryInitialized, isUsingExternalTracerProvider, langfuseShouldExportSpan, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry } from "./services/server/ai/observability/instrumentation.js";
52
+ export { initializeOpenTelemetry, shutdownOpenTelemetry, flushOpenTelemetry, getLangfuseHealthStatus, setLangfuseContext, getLangfuseSpanProcessor, getTracerProvider, isOpenTelemetryInitialized, getSpanProcessors, createContextEnricher, isUsingExternalTracerProvider, isLangfuseInternalSpan, langfuseShouldExportSpan, getLangfuseContext, getTracer, runWithCurrentLangfuseContext, };
53
53
  export { clearAnalyticsMetrics, createAnalyticsMiddleware, getAnalyticsMetrics, } from "./middleware/builtin/analytics.js";
54
54
  export { createLifecycleMiddleware } from "./middleware/builtin/lifecycle.js";
55
55
  export { MiddlewareFactory } from "./middleware/factory.js";
package/dist/index.js CHANGED
@@ -80,11 +80,14 @@ import { createContextEnricher, flushOpenTelemetry,
80
80
  // Enhanced context and tracing
81
81
  getLangfuseContext, getLangfuseHealthStatus, getLangfuseSpanProcessor,
82
82
  // NEW EXPORTS - External TracerProvider Support
83
- getSpanProcessors, getTracer, getTracerProvider, initializeOpenTelemetry, isOpenTelemetryInitialized, isUsingExternalTracerProvider, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry, } from "./services/server/ai/observability/instrumentation.js";
83
+ getSpanProcessors, getTracer, getTracerProvider, initializeOpenTelemetry, isLangfuseInternalSpan, isOpenTelemetryInitialized, isUsingExternalTracerProvider, langfuseShouldExportSpan, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry, } from "./services/server/ai/observability/instrumentation.js";
84
84
  import { getTelemetryStatus as getStatus, initializeTelemetry as init, } from "./telemetry/index.js";
85
85
  export { initializeOpenTelemetry, shutdownOpenTelemetry, flushOpenTelemetry, getLangfuseHealthStatus, setLangfuseContext, getLangfuseSpanProcessor, getTracerProvider, isOpenTelemetryInitialized,
86
86
  // NEW EXPORTS - External TracerProvider Support
87
87
  getSpanProcessors, createContextEnricher, isUsingExternalTracerProvider,
88
+ // Host-processor filter helpers — reuse NeuroLink's internal-span filtering
89
+ // when the host app registers its own LangfuseSpanProcessor.
90
+ isLangfuseInternalSpan, langfuseShouldExportSpan,
88
91
  // Enhanced context and tracing
89
92
  getLangfuseContext, getTracer,
90
93
  // ALS context propagation helper
@@ -113,6 +113,18 @@ export class StreamHandler {
113
113
  // rather than crashing the process with an unhandled rejection.
114
114
  if (NoOutputGeneratedError.isInstance(error)) {
115
115
  logger.warn(`${providerName}: Stream produced no output (NoOutputGeneratedError), returning empty stream`);
116
+ // Curator P2-5: stamp the active OTel span so ContextEnricher.onEnd()
117
+ // surfaces a WARNING-level Langfuse observation instead of defaulting
118
+ // to DEFAULT with no status message.
119
+ try {
120
+ const activeSpan = trace.getSpan(otelContext.active());
121
+ if (activeSpan) {
122
+ activeSpan.setAttribute("neurolink.no_output", true);
123
+ }
124
+ }
125
+ catch {
126
+ // Tracing not initialized — ignore.
127
+ }
116
128
  // S4 fix: yield a sentinel chunk so Pipeline B can detect the empty stream
117
129
  // and set the span to WARNING status instead of OK
118
130
  yield {
@@ -399,6 +399,10 @@ export class ToolsManager {
399
399
  attributes: {
400
400
  "tool.name": toolName,
401
401
  "tool.type": "custom",
402
+ // Curator P1-3: pure wrapper — duplicates the AI SDK's
403
+ // ai.toolCall observation in Langfuse. Keep the OTel span
404
+ // for internal metrics; filter from Langfuse export.
405
+ "langfuse.internal": true,
402
406
  },
403
407
  });
404
408
  const startTime = Date.now();
@@ -631,6 +631,11 @@ export class RedisConversationMemoryManager {
631
631
  * Applies sendToolPreview toggle and hydrates result.result for backward compat
632
632
  */
633
633
  async buildContextMessages(sessionId, userId, enableSummarization, requestId) {
634
+ logger.debug("[RedisConversationMemoryManager] Building context messages", {
635
+ sessionId,
636
+ userId,
637
+ enableSummarization,
638
+ });
634
639
  await this.ensureInitialized();
635
640
  if (!this.redisClient) {
636
641
  logger.warn("[RedisConversationMemoryManager] Redis client not available in buildContextMessages");
@@ -654,6 +659,11 @@ export class RedisConversationMemoryManager {
654
659
  const redisKey = getSessionKey(this.redisConfig, sessionId, userId);
655
660
  const conversationData = await withTimeout(redisClient.get(redisKey), REDIS_TIMEOUT_MS);
656
661
  const conversation = deserializeConversation(conversationData || null);
662
+ logger.debug("[RedisConversationMemoryManager] Retrieved conversation for context building", {
663
+ sessionId,
664
+ userId,
665
+ conversationFound: !!conversation,
666
+ });
657
667
  if (!conversation) {
658
668
  span.setAttribute("session.found", false);
659
669
  span.setStatus({ code: SpanStatusCode.OK });
@@ -672,6 +682,12 @@ export class RedisConversationMemoryManager {
672
682
  lastActivity: new Date(conversation.updatedAt).getTime(),
673
683
  };
674
684
  const contextMessages = buildContextFromPointer(session, requestId);
685
+ logger.debug("[RedisConversationMemoryManager] Built context messages from pointer", {
686
+ sessionId,
687
+ userId,
688
+ contextMessageCount: contextMessages.length,
689
+ pointerMessageId: session.summarizedUpToMessageId || "none",
690
+ });
675
691
  const sendToolPreview = this.config?.contextCompaction?.sendToolPreview === true;
676
692
  // Map tool_result messages: apply preview toggle + hydrate result.result
677
693
  const finalMessages = contextMessages.map((msg) => {
@@ -695,6 +711,15 @@ export class RedisConversationMemoryManager {
695
711
  }
696
712
  hydratedResult = { ...msg.result, result: parsedResult };
697
713
  }
714
+ logger.debug("[RedisConversationMemoryManager] Processing tool_result message for context", {
715
+ sessionId,
716
+ userId,
717
+ messageId: msg.id,
718
+ sendToolPreview,
719
+ hasPreview: !!msg.metadata?.toolOutputPreview,
720
+ contentLength: content ? String(content).length : 0,
721
+ resultHydrated: hydratedResult !== msg.result,
722
+ });
698
723
  return { ...msg, content, result: hydratedResult };
699
724
  });
700
725
  // Tool messages now have real content and participate in context properly.
@@ -48,8 +48,8 @@ export { TTSProcessor } from "./utils/ttsProcessor.js";
48
48
  import { NeuroLink } from "./neurolink.js";
49
49
  export { NeuroLink };
50
50
  export { buildObservabilityConfigFromEnv } from "./utils/observabilityHelpers.js";
51
- import { createContextEnricher, flushOpenTelemetry, getLangfuseContext, getLangfuseHealthStatus, getLangfuseSpanProcessor, getSpanProcessors, getTracer, getTracerProvider, initializeOpenTelemetry, isOpenTelemetryInitialized, isUsingExternalTracerProvider, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry } from "./services/server/ai/observability/instrumentation.js";
52
- export { initializeOpenTelemetry, shutdownOpenTelemetry, flushOpenTelemetry, getLangfuseHealthStatus, setLangfuseContext, getLangfuseSpanProcessor, getTracerProvider, isOpenTelemetryInitialized, getSpanProcessors, createContextEnricher, isUsingExternalTracerProvider, getLangfuseContext, getTracer, runWithCurrentLangfuseContext, };
51
+ import { createContextEnricher, flushOpenTelemetry, getLangfuseContext, getLangfuseHealthStatus, getLangfuseSpanProcessor, getSpanProcessors, getTracer, getTracerProvider, initializeOpenTelemetry, isLangfuseInternalSpan, isOpenTelemetryInitialized, isUsingExternalTracerProvider, langfuseShouldExportSpan, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry } from "./services/server/ai/observability/instrumentation.js";
52
+ export { initializeOpenTelemetry, shutdownOpenTelemetry, flushOpenTelemetry, getLangfuseHealthStatus, setLangfuseContext, getLangfuseSpanProcessor, getTracerProvider, isOpenTelemetryInitialized, getSpanProcessors, createContextEnricher, isUsingExternalTracerProvider, isLangfuseInternalSpan, langfuseShouldExportSpan, getLangfuseContext, getTracer, runWithCurrentLangfuseContext, };
53
53
  export { clearAnalyticsMetrics, createAnalyticsMiddleware, getAnalyticsMetrics, } from "./middleware/builtin/analytics.js";
54
54
  export { createLifecycleMiddleware } from "./middleware/builtin/lifecycle.js";
55
55
  export { MiddlewareFactory } from "./middleware/factory.js";
package/dist/lib/index.js CHANGED
@@ -80,11 +80,14 @@ import { createContextEnricher, flushOpenTelemetry,
80
80
  // Enhanced context and tracing
81
81
  getLangfuseContext, getLangfuseHealthStatus, getLangfuseSpanProcessor,
82
82
  // NEW EXPORTS - External TracerProvider Support
83
- getSpanProcessors, getTracer, getTracerProvider, initializeOpenTelemetry, isOpenTelemetryInitialized, isUsingExternalTracerProvider, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry, } from "./services/server/ai/observability/instrumentation.js";
83
+ getSpanProcessors, getTracer, getTracerProvider, initializeOpenTelemetry, isLangfuseInternalSpan, isOpenTelemetryInitialized, isUsingExternalTracerProvider, langfuseShouldExportSpan, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry, } from "./services/server/ai/observability/instrumentation.js";
84
84
  import { getTelemetryStatus as getStatus, initializeTelemetry as init, } from "./telemetry/index.js";
85
85
  export { initializeOpenTelemetry, shutdownOpenTelemetry, flushOpenTelemetry, getLangfuseHealthStatus, setLangfuseContext, getLangfuseSpanProcessor, getTracerProvider, isOpenTelemetryInitialized,
86
86
  // NEW EXPORTS - External TracerProvider Support
87
87
  getSpanProcessors, createContextEnricher, isUsingExternalTracerProvider,
88
+ // Host-processor filter helpers — reuse NeuroLink's internal-span filtering
89
+ // when the host app registers its own LangfuseSpanProcessor.
90
+ isLangfuseInternalSpan, langfuseShouldExportSpan,
88
91
  // Enhanced context and tracing
89
92
  getLangfuseContext, getTracer,
90
93
  // ALS context propagation helper
@@ -9,10 +9,72 @@ import { globalCircuitBreakerManager, CircuitBreakerOpenError, } from "./mcpCirc
9
9
  import { isObject, isNullish } from "../utils/typeUtils.js";
10
10
  import { validateToolName, validateToolDescription, } from "../utils/parameterValidation.js";
11
11
  import { withTimeout } from "../utils/errorHandling.js";
12
+ import { extractMcpErrorText } from "../utils/mcpErrorText.js";
12
13
  import { SpanKind, SpanStatusCode } from "@opentelemetry/api";
13
14
  import { tracers } from "../telemetry/tracers.js";
14
15
  import { withSpan } from "../telemetry/withSpan.js";
15
16
  const mcpTracer = tracers.mcp;
17
+ /**
18
+ * JSON-stringify a value for a Langfuse input/output preview attribute,
19
+ * truncated to a hard cap to stay under span attribute size limits. The
20
+ * returned string is guaranteed to be ≤ maxLen characters; when truncated,
21
+ * the last character is replaced with an ellipsis.
22
+ */
23
+ function safeJsonStringify(value, maxLen) {
24
+ if (maxLen <= 0) {
25
+ return "";
26
+ }
27
+ try {
28
+ const str = JSON.stringify(value);
29
+ if (typeof str !== "string") {
30
+ return "";
31
+ }
32
+ if (str.length <= maxLen) {
33
+ return str;
34
+ }
35
+ return str.slice(0, Math.max(0, maxLen - 1)) + "…";
36
+ }
37
+ catch {
38
+ return "";
39
+ }
40
+ }
41
+ /**
42
+ * Match property names that commonly hold secrets. Values under these keys
43
+ * are replaced with `[REDACTED]` before serialization. Case-insensitive.
44
+ * Conservative list — anything matching *here* is masked; the rest of the
45
+ * structure is preserved so Langfuse still gets a meaningful preview.
46
+ */
47
+ const SENSITIVE_KEY_PATTERN = /^(password|passwd|secret|token|api[_-]?key|apikey|access[_-]?key|authorization|auth|bearer|credential|cookie|session[_-]?id|private[_-]?key|client[_-]?secret|refresh[_-]?token|x-api-key)$/i;
48
+ /**
49
+ * Walk a value, producing a structurally-equivalent copy with sensitive-key
50
+ * values masked. Unlike `transformParamsForLogging` (which collapses objects
51
+ * to a "N params" string), this preserves non-sensitive content so Langfuse
52
+ * input/output previews stay useful. Bounded depth guards against cycles.
53
+ */
54
+ function redactForPreview(value, depth = 0) {
55
+ if (depth > 10) {
56
+ return "[...]";
57
+ }
58
+ if (value === null || value === undefined) {
59
+ return value;
60
+ }
61
+ if (typeof value !== "object") {
62
+ return value;
63
+ }
64
+ if (Array.isArray(value)) {
65
+ return value.map((v) => redactForPreview(v, depth + 1));
66
+ }
67
+ const out = {};
68
+ for (const [k, v] of Object.entries(value)) {
69
+ if (SENSITIVE_KEY_PATTERN.test(k)) {
70
+ out[k] = "[REDACTED]";
71
+ }
72
+ else {
73
+ out[k] = redactForPreview(v, depth + 1);
74
+ }
75
+ }
76
+ return out;
77
+ }
16
78
  /**
17
79
  * Default timeout for MCP tool execution operations in milliseconds.
18
80
  * Configurable via MCP_TOOL_TIMEOUT env var.
@@ -376,6 +438,18 @@ export class ToolDiscoveryService extends EventEmitter {
376
438
  "mcp.server_id": serverId,
377
439
  "mcp.tool_name": toolName,
378
440
  "mcp.timeout_ms": effectiveTimeout,
441
+ // Curator P1-4: Langfuse observations rely on ai.*/gen_ai.*
442
+ // attributes for tool name and I/O previews. Provide them so
443
+ // the SPAN observation in Langfuse is legible without
444
+ // timestamp-joining against the parent ai.toolCall. Redact
445
+ // parameters via the existing secret-stripping helper so
446
+ // tokens/credentials/paths don't leave the process.
447
+ "ai.tool.name": toolName,
448
+ "gen_ai.tool.name": toolName,
449
+ "gen_ai.request": safeJsonStringify({
450
+ name: toolName,
451
+ arguments: redactForPreview(parameters),
452
+ }, 2048),
379
453
  },
380
454
  }, async (callSpan) => {
381
455
  try {
@@ -384,11 +458,26 @@ export class ToolDiscoveryService extends EventEmitter {
384
458
  name: toolName,
385
459
  arguments: parameters,
386
460
  }), timeout, new Error(`Tool execution timeout: ${toolName}`));
387
- callSpan.setStatus({ code: SpanStatusCode.OK });
461
+ // Curator P0-1/P0-2: the MCP client does NOT throw on protocol
462
+ // errors — it returns { isError: true, content: [...] }. Detect
463
+ // that pattern so the span status reflects reality.
464
+ const resultObj = callResult;
465
+ if (resultObj && resultObj.isError === true) {
466
+ const errorText = extractMcpErrorText(resultObj);
467
+ callSpan.setStatus({
468
+ code: SpanStatusCode.ERROR,
469
+ message: errorText || `Tool ${toolName} returned isError`,
470
+ });
471
+ }
472
+ else {
473
+ callSpan.setStatus({ code: SpanStatusCode.OK });
474
+ }
388
475
  // ── MCP output normalization ──────────────────────────────────
389
476
  // Intercept here — after receive, before cache, before memory,
390
477
  // before LLM context injection. Returns a compact surrogate when
391
478
  // the payload exceeds mcp.outputLimits.maxBytes.
479
+ let resultForPreview = callResult;
480
+ let resultForReturn = callResult;
392
481
  if (this.outputNormalizer) {
393
482
  try {
394
483
  const normalized = await this.outputNormalizer.normalize(callResult, { toolName, serverId });
@@ -396,7 +485,8 @@ export class ToolDiscoveryService extends EventEmitter {
396
485
  if (normalized.isExternalized) {
397
486
  callSpan.setAttribute("mcp.output.original_bytes", normalized.originalBytes);
398
487
  }
399
- return normalized.result;
488
+ resultForPreview = normalized.result;
489
+ resultForReturn = normalized.result;
400
490
  }
401
491
  catch (normErr) {
402
492
  mcpLogger.warn(`[ToolDiscoveryService] McpOutputNormalizer failed for ` +
@@ -405,7 +495,13 @@ export class ToolDiscoveryService extends EventEmitter {
405
495
  }
406
496
  }
407
497
  // ── end normalization ─────────────────────────────────────────
408
- return callResult;
498
+ // Curator P1-4: build gen_ai.response AFTER normalization so
499
+ // large payloads use the compact surrogate instead of the raw
500
+ // result (avoids redundant stringify + memory hit on payloads
501
+ // that were specifically externalized to Redis). Redact via the
502
+ // same secret-stripping path used for request parameters.
503
+ callSpan.setAttribute("gen_ai.response", safeJsonStringify(redactForPreview(resultForPreview), 2048));
504
+ return resultForReturn;
409
505
  }
410
506
  catch (err) {
411
507
  callSpan.setStatus({
@@ -257,6 +257,9 @@ export class MCPToolRegistry extends MCPRegistry {
257
257
  attributes: {
258
258
  [ATTR.GEN_AI_TOOL_NAME]: toolName,
259
259
  [ATTR.MCP_SERVER_ID]: preResolvedServerId || "builtin",
260
+ // Curator P1-3: registry-level wrapper — duplicates ai.toolCall in
261
+ // Langfuse. Retained for OTel/metrics; skipped for Langfuse export.
262
+ "langfuse.internal": true,
260
263
  },
261
264
  }, async (span) => {
262
265
  try {
@@ -64,6 +64,7 @@ import { CircuitBreaker, ERROR_CODES, ErrorFactory, isAbortError, isRetriableErr
64
64
  // Factory processing imports
65
65
  import { createCleanStreamOptions, enhanceTextGenerationOptions, processFactoryOptions, processStreamingFactoryOptions, validateFactoryConfig, } from "./utils/factoryProcessing.js";
66
66
  import { logger, mcpLogger } from "./utils/logger.js";
67
+ import { extractMcpErrorText } from "./utils/mcpErrorText.js";
67
68
  import { createCustomToolServerInfo, detectCategory, } from "./utils/mcpDefaults.js";
68
69
  import { resolveModel } from "./utils/modelAliasResolver.js";
69
70
  // Import orchestration components
@@ -133,29 +134,6 @@ function mcpCategoryToErrorCategory(mcpCategory) {
133
134
  return ErrorCategory.EXECUTION;
134
135
  }
135
136
  }
136
- /**
137
- * Extract a human-readable error string from an MCP isError result object.
138
- * Returns an empty string if nothing useful can be extracted.
139
- */
140
- function extractMcpErrorText(raw) {
141
- try {
142
- const resultObj = typeof raw === "string" ? JSON.parse(raw) : raw;
143
- if (!resultObj || typeof resultObj !== "object") {
144
- return "";
145
- }
146
- const content = resultObj.content;
147
- if (!Array.isArray(content)) {
148
- return "";
149
- }
150
- const texts = content
151
- .filter((c) => c.type === "text" && c.text)
152
- .map((c) => c.text);
153
- return texts.join(" ").substring(0, 500);
154
- }
155
- catch {
156
- return "";
157
- }
158
- }
159
137
  /**
160
138
  * Check if an error is a non-retryable provider error that should immediately
161
139
  * stop the retry/fallback chain. These errors represent permanent failures
@@ -4508,6 +4486,16 @@ Current user's request: ${currentInput}`;
4508
4486
  * @throws {Error} When conversation memory operations fail (if enabled)
4509
4487
  */
4510
4488
  async stream(options) {
4489
+ logger.debug("[NeuroLink] stream() called with options", {
4490
+ provider: options.provider,
4491
+ model: options.model,
4492
+ inputLength: options.input?.text?.length || 0,
4493
+ disableTools: options.disableTools,
4494
+ enableAnalytics: options.enableAnalytics,
4495
+ enableEvaluation: options.enableEvaluation,
4496
+ contextKeys: options.context ? Object.keys(options.context) : [],
4497
+ optionKeys: Object.keys(options),
4498
+ });
4511
4499
  return metricsTraceContextStorage.run(this.createMetricsTraceContext(), () => this.executeStreamRequest({ ...options }));
4512
4500
  }
4513
4501
  async executeStreamRequest(options) {
@@ -4600,8 +4588,26 @@ Current user's request: ${currentInput}`;
4600
4588
  }
4601
4589
  async runStandardStreamRequest(params) {
4602
4590
  const { options, streamSpan, spanStartTime, startTime, hrTimeStart, streamId, originalPrompt, } = params;
4591
+ logger.debug("[NeuroLink] Running standard stream request", {
4592
+ streamId,
4593
+ provider: options.provider,
4594
+ model: options.model,
4595
+ inputLength: options.input?.text?.length || 0,
4596
+ disableTools: options.disableTools,
4597
+ enableAnalytics: options.enableAnalytics,
4598
+ enableEvaluation: options.enableEvaluation,
4599
+ contextKeys: options.context ? Object.keys(options.context) : [],
4600
+ optionKeys: Object.keys(options),
4601
+ sessionId: options.context?.sessionId,
4602
+ });
4603
4603
  try {
4604
4604
  const { enhancedOptions, factoryResult } = await this.prepareStreamOptions(options, streamId, startTime, hrTimeStart);
4605
+ logger.debug("[NeuroLink] Stream options prepared", {
4606
+ streamId,
4607
+ options: enhancedOptions,
4608
+ factoryResult,
4609
+ sessionId: enhancedOptions.context?.sessionId,
4610
+ });
4605
4611
  const { stream: mcpStream, provider: providerName, usage: streamUsage, model: streamModel, finishReason: streamFinishReason, toolCalls: streamToolCalls, toolResults: streamToolResults, analytics: streamAnalytics, } = await this.createMCPStream(enhancedOptions);
4606
4612
  const streamState = {
4607
4613
  finishReason: streamFinishReason ?? "stop",
@@ -4691,6 +4697,16 @@ Current user's request: ${currentInput}`;
4691
4697
  });
4692
4698
  }
4693
4699
  catch (error) {
4700
+ logger.debug("[NeuroLink.stream] Stream error occurred", {
4701
+ error: error instanceof Error ? error.message : String(error),
4702
+ name: error instanceof Error ? error.name : "UnknownError",
4703
+ provider: providerName,
4704
+ model: enhancedOptions.model,
4705
+ chunkCount,
4706
+ totalLength: accumulatedContent.length,
4707
+ durationMs: Date.now() - streamStartTime,
4708
+ sessionId,
4709
+ });
4694
4710
  streamError = error;
4695
4711
  self.emitter.emit("stream:error", {
4696
4712
  type: "stream:error",
@@ -4709,6 +4725,16 @@ Current user's request: ${currentInput}`;
4709
4725
  throw error;
4710
4726
  }
4711
4727
  finally {
4728
+ logger.debug("[NeuroLink.stream] Stream finished, performing cleanup", {
4729
+ provider: providerName,
4730
+ model: enhancedOptions.model,
4731
+ totalChunks: chunkCount,
4732
+ totalLength: accumulatedContent.length,
4733
+ durationMs: Date.now() - streamStartTime,
4734
+ fallbackAttempted: metadata.fallbackAttempted,
4735
+ guardrailsBlocked: metadata.guardrailsBlocked,
4736
+ error: metadata.error,
4737
+ });
4712
4738
  self._disableToolCacheForCurrentRequest = false;
4713
4739
  cleanupListeners();
4714
4740
  streamSpan.setAttribute("neurolink.response_time_ms", Date.now() - spanStartTime);
@@ -5106,6 +5132,11 @@ Current user's request: ${currentInput}`;
5106
5132
  */
5107
5133
  async storeStreamConversationMemory(params) {
5108
5134
  const { enhancedOptions, providerName, originalPrompt, accumulatedContent, startTime, eventSequence, } = params;
5135
+ logger.debug("[NeuroLink.stream] Preparing to store conversation turn in memory", {
5136
+ options: JSON.stringify(enhancedOptions),
5137
+ sessionId: enhancedOptions.context
5138
+ ?.sessionId,
5139
+ });
5109
5140
  // Guard: skip storing if no meaningful content was produced (no text AND no tool activity)
5110
5141
  const hasToolEvents = eventSequence.some((e) => e.type === "tool:start" || e.type === "tool:end");
5111
5142
  if (!accumulatedContent.trim() && !hasToolEvents) {
@@ -5115,6 +5146,12 @@ Current user's request: ${currentInput}`;
5115
5146
  });
5116
5147
  return;
5117
5148
  }
5149
+ logger.debug("[NeuroLink.stream] Storing conversation turn in memory", {
5150
+ options: JSON.stringify(enhancedOptions),
5151
+ sessionId: enhancedOptions.context
5152
+ ?.sessionId,
5153
+ conversationMemoryExists: this.conversationMemory ? true : false,
5154
+ });
5118
5155
  // Store memory after stream consumption is complete
5119
5156
  if (this.conversationMemory && enhancedOptions.context?.sessionId) {
5120
5157
  const sessionId = enhancedOptions.context
@@ -6267,6 +6304,13 @@ Current user's request: ${currentInput}`;
6267
6304
  "tool.type": executionContext.toolType,
6268
6305
  "tool.input_size": executionContext.inputSize,
6269
6306
  "tool.input_preview": executionContext.truncatedInput,
6307
+ // NOT marked langfuse.internal: this is the public entrypoint for
6308
+ // `NeuroLink.executeTool()`. Direct API callers (not going through
6309
+ // the AI SDK) would otherwise produce zero Langfuse observations —
6310
+ // the lower-level registry/discovery spans are internal wrappers.
6311
+ // AI-SDK-initiated custom tools will produce both ai.toolCall and
6312
+ // this span, which is the accepted tradeoff for keeping direct
6313
+ // invocations observable.
6270
6314
  },
6271
6315
  }, (toolSpan) => this.executeToolWithSpan(toolName, params, options, executionContext, toolSpan));
6272
6316
  }
@@ -11,6 +11,32 @@ import { LoggerProvider } from "@opentelemetry/sdk-logs";
11
11
  import { type SpanProcessor } from "@opentelemetry/sdk-trace-base";
12
12
  import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
13
13
  import type { LangfuseConfig, LangfuseContext } from "../../../../types/index.js";
14
+ /**
15
+ * True when a span is an internal NeuroLink wrapper that should NOT be sent to
16
+ * Langfuse. Internal wrappers carry the `langfuse.internal: true` attribute.
17
+ *
18
+ * Exposed so host apps that bring their own `LangfuseSpanProcessor` (e.g.
19
+ * `skipLangfuseSpanProcessor: true`, or manual registration on an existing
20
+ * TracerProvider) can apply the same filter and avoid duplicate observations.
21
+ */
22
+ export declare function isLangfuseInternalSpan(span: {
23
+ attributes?: Record<string, unknown>;
24
+ }): boolean;
25
+ /**
26
+ * Drop-in `shouldExportSpan` predicate for a `LangfuseSpanProcessor` that
27
+ * filters out NeuroLink internal wrapper spans.
28
+ *
29
+ * Usage in host apps:
30
+ * ```ts
31
+ * import { langfuseShouldExportSpan } from "@juspay/neurolink";
32
+ * new LangfuseSpanProcessor({ ..., shouldExportSpan: langfuseShouldExportSpan });
33
+ * ```
34
+ */
35
+ export declare function langfuseShouldExportSpan({ otelSpan, }: {
36
+ otelSpan: {
37
+ attributes?: Record<string, unknown>;
38
+ };
39
+ }): boolean;
14
40
  /**
15
41
  * Initialize OpenTelemetry with Langfuse span processor
16
42
  *