@juspay/neurolink 13.5.0 → 13.6.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/dist/adapters/video/videoAnalyzer.d.ts +1 -0
  3. package/dist/adapters/video/videoAnalyzer.js +4 -0
  4. package/dist/autoresearch/index.d.ts +1 -0
  5. package/dist/autoresearch/index.js +4 -0
  6. package/dist/autoresearch/worker.js +4 -0
  7. package/dist/browser/neurolink.min.js +476 -476
  8. package/dist/cli/commands/observability.js +11 -4
  9. package/dist/cli/commands/telemetry.js +36 -6
  10. package/dist/cli/utils/formatters.d.ts +17 -0
  11. package/dist/cli/utils/formatters.js +39 -0
  12. package/dist/constants/langfuse.d.ts +17 -1
  13. package/dist/constants/langfuse.js +23 -1
  14. package/dist/constants/telemetry.d.ts +6 -0
  15. package/dist/constants/telemetry.js +6 -0
  16. package/dist/core/baseProvider.d.ts +3 -1
  17. package/dist/core/baseProvider.js +30 -8
  18. package/dist/core/geminiLoopAdapter.js +19 -0
  19. package/dist/core/loopEngine.js +10 -7
  20. package/dist/core/modules/StreamHandler.js +5 -4
  21. package/dist/core/modules/TelemetryHandler.d.ts +5 -1
  22. package/dist/core/modules/TelemetryHandler.js +16 -0
  23. package/dist/core/modules/ToolsManager.js +14 -3
  24. package/dist/core/nativeGenerateLoop.d.ts +2 -0
  25. package/dist/core/nativeGenerateLoop.js +35 -9
  26. package/dist/evaluation/index.d.ts +1 -0
  27. package/dist/evaluation/index.js +4 -0
  28. package/dist/evaluation/ragasEvaluator.js +1 -0
  29. package/dist/mcp/externalServerManager.js +6 -2
  30. package/dist/mcp/toolRegistry.js +3 -0
  31. package/dist/neurolink.d.ts +16 -8
  32. package/dist/neurolink.js +115 -61
  33. package/dist/observability/FEATURE-STATUS.md +0 -4
  34. package/dist/observability/exporterRegistry.d.ts +6 -1
  35. package/dist/observability/exporterRegistry.js +18 -0
  36. package/dist/observability/exporterSpanProcessor.d.ts +20 -6
  37. package/dist/observability/exporterSpanProcessor.js +166 -34
  38. package/dist/observability/exporters/baseExporter.d.ts +22 -3
  39. package/dist/observability/exporters/baseExporter.js +87 -5
  40. package/dist/observability/exporters/langfuseExporter.d.ts +29 -3
  41. package/dist/observability/exporters/langfuseExporter.js +251 -41
  42. package/dist/observability/langfuseMasking.d.ts +1 -1
  43. package/dist/observability/langfuseMasking.js +1 -1
  44. package/dist/observability/otlpEncoding.d.ts +18 -5
  45. package/dist/observability/otlpEncoding.js +69 -12
  46. package/dist/observability/utils/spanSerializer.d.ts +3 -2
  47. package/dist/observability/utils/spanSerializer.js +5 -3
  48. package/dist/processors/base/BaseFileProcessor.d.ts +1 -0
  49. package/dist/processors/base/BaseFileProcessor.js +4 -0
  50. package/dist/processors/index.d.ts +1 -0
  51. package/dist/processors/index.js +4 -0
  52. package/dist/processors/registry/ProcessorRegistry.d.ts +1 -0
  53. package/dist/processors/registry/ProcessorRegistry.js +5 -0
  54. package/dist/providers/amazonBedrock/client.js +7 -5
  55. package/dist/providers/amazonSagemaker.js +32 -12
  56. package/dist/providers/anthropic/client.js +1 -0
  57. package/dist/providers/anthropic/loopAdapter.js +7 -3
  58. package/dist/providers/googleAiStudio/client.js +2 -0
  59. package/dist/providers/googleVertex/client.d.ts +6 -5
  60. package/dist/providers/googleVertex/client.js +46 -19
  61. package/dist/providers/openaiChatCompletionsBase.js +29 -18
  62. package/dist/proxy/codexUsage.js +59 -2
  63. package/dist/proxy/proxyTracer.d.ts +25 -1
  64. package/dist/proxy/proxyTracer.js +146 -22
  65. package/dist/proxy/proxyTranslationEngine.js +4 -2
  66. package/dist/proxy/sseInterceptor.js +6 -4
  67. package/dist/rag/chunkers/BaseChunker.js +1 -0
  68. package/dist/rag/index.d.ts +1 -0
  69. package/dist/rag/index.js +4 -0
  70. package/dist/rag/ragIntegration.js +1 -0
  71. package/dist/rag/reranker/reranker.js +2 -0
  72. package/dist/rag/retrieval/vectorQueryTool.js +1 -0
  73. package/dist/server/routes/codexProxyRoutes.js +3 -1
  74. package/dist/server/utils/validation.d.ts +3 -3
  75. package/dist/services/server/ai/observability/instrumentation.d.ts +8 -7
  76. package/dist/services/server/ai/observability/instrumentation.js +217 -47
  77. package/dist/services/server/ai/observability/langfuseSetup.d.ts +19 -1
  78. package/dist/services/server/ai/observability/langfuseSetup.js +62 -4
  79. package/dist/services/server/ai/observability/otlpSetup.d.ts +12 -2
  80. package/dist/services/server/ai/observability/otlpSetup.js +55 -4
  81. package/dist/telemetry/aiMetrics.d.ts +56 -0
  82. package/dist/telemetry/aiMetrics.js +229 -0
  83. package/dist/telemetry/attributes.d.ts +24 -1
  84. package/dist/telemetry/attributes.js +94 -9
  85. package/dist/telemetry/chatMessages.d.ts +6 -0
  86. package/dist/telemetry/chatMessages.js +37 -0
  87. package/dist/telemetry/index.d.ts +1 -1
  88. package/dist/telemetry/index.js +1 -1
  89. package/dist/telemetry/observation.d.ts +5 -5
  90. package/dist/telemetry/observation.js +128 -39
  91. package/dist/telemetry/spanSanitizer.d.ts +2 -2
  92. package/dist/telemetry/spanSanitizer.js +76 -8
  93. package/dist/telemetry/telemetryStarter.d.ts +16 -0
  94. package/dist/telemetry/telemetryStarter.js +28 -0
  95. package/dist/telemetry/traceContext.d.ts +3 -3
  96. package/dist/telemetry/traceContext.js +3 -3
  97. package/dist/telemetry/tracers.d.ts +13 -1
  98. package/dist/telemetry/tracers.js +49 -10
  99. package/dist/telemetry/withSpan.d.ts +19 -3
  100. package/dist/telemetry/withSpan.js +149 -20
  101. package/dist/tools/toolDiscovery.js +2 -1
  102. package/dist/tools/toolPolicy.js +22 -2
  103. package/dist/types/config.d.ts +19 -0
  104. package/dist/types/exporter.d.ts +98 -3
  105. package/dist/types/generate.d.ts +7 -1
  106. package/dist/types/livekit.d.ts +16 -1
  107. package/dist/types/loopEngine.d.ts +16 -1
  108. package/dist/types/observability.d.ts +38 -5
  109. package/dist/types/proxy.d.ts +2 -0
  110. package/dist/types/span.d.ts +26 -0
  111. package/dist/types/toolResolution.d.ts +5 -1
  112. package/dist/utils/timeout.js +4 -2
  113. package/dist/voice/livekit/realtimeMcpTools.js +11 -2
  114. package/dist/voice/livekit/realtimeMemory.d.ts +20 -2
  115. package/dist/voice/livekit/realtimeMemory.js +139 -46
  116. package/dist/voice/livekit/realtimeVoiceAgent.js +20 -8
  117. package/dist/voice/livekit/turnRecorder.js +22 -9
  118. package/dist/workflow/core/ensembleExecutor.js +2 -0
  119. package/dist/workflow/core/judgeScorer.js +1 -0
  120. package/dist/workflow/core/responseConditioner.js +1 -0
  121. package/dist/workflow/core/workflowRunner.js +6 -1
  122. package/dist/workflow/index.d.ts +1 -0
  123. package/dist/workflow/index.js +4 -0
  124. package/docs-site/static/search-index.json +32 -27
  125. package/package.json +21 -2
@@ -17,6 +17,7 @@ import { observeGeneration } from "../telemetry/observation.js";
17
17
  import { StepSpans, withGeneration } from "../telemetry/withSpan.js";
18
18
  import { logger } from "../utils/logger.js";
19
19
  import { guardToolExecutor } from "./toolExecutionGuards.js";
20
+ import { resolveLiveTool } from "../tools/toolDiscovery.js";
20
21
  const DEFAULT_TOOL_CHOICE_STEPS = 1;
21
22
  /**
22
23
  * A choice that compels a tool call: `"required"` or a named tool, in either
@@ -74,9 +75,9 @@ const warnUndeclaredForcedTool = (toolName, step, source) => {
74
75
  };
75
76
  /**
76
77
  * The names this request actually carries, in the record shape
77
- * `declaredTools` takes. The loop sends `args.tools`, built once by the
78
- * caller; `args.toolsRecord` can hold more (discovery hydrates into it, and
79
- * this loop never re-declares), so only `tools` answers "is it offered".
78
+ * `declaredTools` takes. `args.toolsRecord` can hold more than is declared
79
+ * (discovery hydrates into it), so only the sent declarations answer "is it
80
+ * offered".
80
81
  * Null prototype: a tool may be named `__proto__` or `constructor`.
81
82
  */
82
83
  const declaredToolRecord = (tools) => {
@@ -336,6 +337,8 @@ const parseToolInput = (raw) => {
336
337
  return { input: {}, error: "arguments were not valid JSON" };
337
338
  }
338
339
  };
340
+ /** A tool's inputSchema with its lazy (function) form, if any, resolved. */
341
+ export const resolveToolInputSchema = (schema) => typeof schema === "function" ? resolveToolInputSchema(schema()) : schema;
339
342
  /**
340
343
  * Validate parsed input against the tool's own schema when it exposes one.
341
344
  *
@@ -346,8 +349,12 @@ const parseToolInput = (raw) => {
346
349
  * validator this loop has no business carrying.
347
350
  */
348
351
  const validateToolInput = (tool, input) => {
349
- const schema = tool?.inputSchema;
350
- if (typeof schema?.safeParse !== "function") {
352
+ const schema = tool?.inputSchema
353
+ ? resolveToolInputSchema(tool.inputSchema)
354
+ : undefined;
355
+ if (!schema ||
356
+ !("safeParse" in schema) ||
357
+ typeof schema.safeParse !== "function") {
351
358
  return undefined;
352
359
  }
353
360
  const result = schema.safeParse(input);
@@ -447,7 +454,7 @@ function generateObservedStep(args, request) {
447
454
  });
448
455
  }
449
456
  export async function runNativeGenerateLoop(args, toolExecutionSummaries) {
450
- const stepSpans = new StepSpans();
457
+ const stepSpans = new StepSpans("generate");
451
458
  try {
452
459
  return await runGenerateSteps(args, toolExecutionSummaries, stepSpans);
453
460
  }
@@ -478,11 +485,28 @@ async function runGenerateSteps(args, toolExecutionSummaries, stepSpans) {
478
485
  // answer is that result — not a thrown turn.
479
486
  let preReask;
480
487
  const hasTools = Boolean(args.tools && args.tools.length > 0);
488
+ // The declarations actually sent. Starts as the caller's list and grows when
489
+ // a tool joins toolsRecord mid-turn (discovery loads tools into it).
490
+ const liveTools = args.tools ? [...args.tools] : undefined;
491
+ const declaredNames = declaredToolRecord(args.tools);
481
492
  // Completed-step records for `prepareStep`; one per step that returned.
482
493
  const stepRecords = [];
483
494
  for (let step = 0; step < args.maxSteps; step++) {
484
495
  steps = step + 1;
485
496
  stepSpans.begin(steps);
497
+ if (liveTools && args.declareTool) {
498
+ const loaded = [];
499
+ for (const name of Object.keys(args.toolsRecord)) {
500
+ if (!declaredNames[name]) {
501
+ liveTools.push(args.declareTool(name, args.toolsRecord[name]));
502
+ declaredNames[name] = true;
503
+ loaded.push(name);
504
+ }
505
+ }
506
+ if (loaded.length > 0) {
507
+ logger.debug(`[nativeGenerateLoop] ${loaded.length} tool(s) hydrated mid-turn via discovery: ${loaded.join(", ")}`);
508
+ }
509
+ }
486
510
  // The forced-choice window and the caller's hook are resolved BEFORE the
487
511
  // re-ask override below: the re-ask has to win, since its whole point is
488
512
  // a request with tools disabled.
@@ -498,7 +522,7 @@ async function runGenerateSteps(args, toolExecutionSummaries, stepSpans) {
498
522
  steps: stepRecords,
499
523
  maxSteps: args.maxSteps,
500
524
  model: args.modelId,
501
- declaredTools: declaredToolRecord(args.tools),
525
+ declaredTools: declaredToolRecord(liveTools),
502
526
  ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
503
527
  })
504
528
  : undefined;
@@ -519,7 +543,7 @@ async function runGenerateSteps(args, toolExecutionSummaries, stepSpans) {
519
543
  }
520
544
  const runThisStep = () => generateObservedStep(args, {
521
545
  prompt: args.conversation,
522
- ...(args.tools && args.tools.length > 0 ? { tools: args.tools } : {}),
546
+ ...(liveTools && liveTools.length > 0 ? { tools: liveTools } : {}),
523
547
  // The v3 call option is an OBJECT — `{ type: "none" }`. Passing the
524
548
  // bare string "none" type-checks against `unknown` and is then
525
549
  // dropped by every converter that switches on `choice.type`, so the
@@ -688,7 +712,9 @@ async function runGenerateSteps(args, toolExecutionSummaries, stepSpans) {
688
712
  const startTime = new Date();
689
713
  const parsed = parseToolInput(call.input);
690
714
  const input = parsed.input;
691
- const tool = args.toolsRecord[name];
715
+ // resolveLiveTool also loads a deferred tool the model called by its
716
+ // catalog name without searching first.
717
+ const tool = resolveLiveTool(args.toolsRecord, name);
692
718
  let output;
693
719
  let failure;
694
720
  const rejection = parsed.error ??
@@ -1,3 +1,4 @@
1
+ import "../services/server/ai/observability/instrumentation.js";
1
2
  import type { EvaluationData, GenerateResult, AutoEvaluationConfig, EvaluationConfig } from "../types/index.js";
2
3
  import type { LanguageModelV3CallOptions } from "../types/index.js";
3
4
  export * from "./errors/index.js";
@@ -1,3 +1,7 @@
1
+ // Imported on its own, this entry starts tracing at its first span, as the
2
+ // main entry does. The import sits here rather than in the shared tracers so
3
+ // that the client entry stays free of the OpenTelemetry Node SDK.
4
+ import "../services/server/ai/observability/instrumentation.js";
1
5
  import { ContextBuilder } from "./contextBuilder.js";
2
6
  import { RAGASEvaluator } from "./ragasEvaluator.js";
3
7
  import { mapToEvaluationData } from "./scoring.js";
@@ -38,6 +38,7 @@ export class RAGASEvaluator {
38
38
  return withSpan({
39
39
  name: "neurolink.evaluation.ragas",
40
40
  tracer: tracers.sdk,
41
+ observationType: "span",
41
42
  attributes: {
42
43
  "evaluation.provider": this.providerName,
43
44
  "evaluation.model": this.evaluationModel,
@@ -14,6 +14,7 @@ import { toolRegistry } from "./toolRegistry.js";
14
14
  import { HITLUserRejectedError, HITLTimeoutError } from "../hitl/hitlErrors.js";
15
15
  import { detectCategory } from "../utils/mcpDefaults.js";
16
16
  import { isObject, isNonNullObject } from "../utils/typeUtils.js";
17
+ import { recordMCPToolCall } from "../telemetry/aiMetrics.js";
17
18
  import { tracers } from "../telemetry/tracers.js";
18
19
  import { SpanStatusCode } from "@opentelemetry/api";
19
20
  // `@modelcontextprotocol/sdk`'s stdio transport (imported above) does
@@ -2045,8 +2046,9 @@ export class ExternalServerManager extends EventEmitter {
2045
2046
  if (result.success) {
2046
2047
  // A resolved `{ isError: true }` still comes back with success:true so
2047
2048
  // the caller receives the MCP error payload as-is, but it is a failed
2048
- // call for telemetry: label mcp_tool_calls_total accordingly instead
2049
- // of counting every resolved error as a success.
2049
+ // call for telemetry: count it in the server's totalErrors and as
2050
+ // success=false in mcp_tool_calls_total instead of counting every
2051
+ // resolved error as a success.
2050
2052
  const resolvedError = result.isErrorResult === true;
2051
2053
  if (resolvedError) {
2052
2054
  instance.metrics.totalErrors++;
@@ -2056,6 +2058,7 @@ export class ExternalServerManager extends EventEmitter {
2056
2058
  : `[ExternalServerManager] Tool executed successfully: ${toolName} on ${serverId}`, {
2057
2059
  duration,
2058
2060
  });
2061
+ recordMCPToolCall(toolName, duration, !resolvedError);
2059
2062
  return result.data;
2060
2063
  }
2061
2064
  else {
@@ -2064,6 +2067,7 @@ export class ExternalServerManager extends EventEmitter {
2064
2067
  }
2065
2068
  catch (error) {
2066
2069
  instance.metrics.totalErrors++;
2070
+ recordMCPToolCall(toolName, Date.now() - startTime, false);
2067
2071
  mcpLogger.error(`[ExternalServerManager] Tool execution failed: ${toolName} on ${serverId}`, error);
2068
2072
  // The SDK rejects with its connection-closed error when the server is
2069
2073
  // gone. Without this transition the instance stayed "connected" and
@@ -293,6 +293,9 @@ export class MCPToolRegistry extends MCPRegistry {
293
293
  attributes: {
294
294
  "tool.name": toolName,
295
295
  [ATTR.MCP_SERVER_ID]: preResolvedServerId || "builtin",
296
+ // Registry-level wrapper around the `tool` observation of the same
297
+ // call: it stays an OpenTelemetry span but is not exported.
298
+ [ATTR.NL_INTERNAL]: true,
296
299
  },
297
300
  }, async (span) => {
298
301
  try {
@@ -1,5 +1,5 @@
1
1
  import type { DecisionCallerOptions, DecisionLimitsQuery, DecisionLimitsReading, DecisionOptions, DecisionResult, AgentDefinition, AgentNetworkConfig, AgentRunOptions, AgentRunOutcome, AgentToolRegistrationOptions, IsolatedAgentDefinition, NetworkExecutionInput, NetworkExecutionOptions, NetworkExecutionResult, NetworkStreamChunk, WorkerInstanceOptions } from "./types/index.js";
2
- import type { CompactionConfig, CompactionResult, ArtifactStore, SpanData, ObservabilityConfig, MetricsSummary, MCPToolAnnotations, TraceView, AuthenticatedContext, AuthProvider, JsonObject, NeuroLinkEvents, TypedEventEmitter, MCPEnhancementsConfig, NeuroLinkAuthConfig, NeurolinkConstructorConfig, ChatMessage, ConversationSeedMessage, ConversationSeedOptions, RecordConversationTurnOptions, RecordConversationTurnResult, ExternalMCPOperationResult, ExternalMCPServerInstance, ExternalMCPServerHealth, ExternalMCPToolInfo, GenerateOptions, GenerateResult, ProviderStatus, TextGenerationOptions, TextGenerationResult, MCPExecutableTool, MCPServerInfo, MCPStatus, StreamOptions, StreamResult, ToolExecutionContext, ToolExecutionSummary, ToolInfo, ToolRegistrationOptions, BatchOperationResult, StreamGenerationEndContext, ToolRoutingServerDescriptor, ToolDedupConfig, ToolConfig, KnowledgeEngineStatus, ChecklistState, ArtifactPageRequest, BankArtifactOptions, BankedArtifactRef, DelegateCollectRequest, DelegateCollectResult, DelegateHandle, DelegateRegistrationOptions, DelegateSpawnOptions, BackgroundCommandHandle, BackgroundCommandOptions, BackgroundCommandOutputPage, BackgroundCommandPageRequest, BackgroundCommandPolicy, BackgroundCommandStatus, GitToolResult, GitToolsetOptions } from "./types/index.js";
2
+ import type { CompactionConfig, CompactionResult, ArtifactStore, SpanData, ExporterStatus, ObservabilityConfig, MetricsSummary, MCPToolAnnotations, TraceView, AuthenticatedContext, AuthProvider, JsonObject, NeuroLinkEvents, TypedEventEmitter, MCPEnhancementsConfig, NeuroLinkAuthConfig, NeurolinkConstructorConfig, ChatMessage, ConversationSeedMessage, ConversationSeedOptions, RecordConversationTurnOptions, RecordConversationTurnResult, ExternalMCPOperationResult, ExternalMCPServerInstance, ExternalMCPServerHealth, ExternalMCPToolInfo, GenerateOptions, GenerateResult, ProviderStatus, TextGenerationOptions, TextGenerationResult, MCPExecutableTool, MCPServerInfo, MCPStatus, StreamOptions, StreamResult, ToolExecutionContext, ToolExecutionSummary, ToolInfo, ToolRegistrationOptions, BatchOperationResult, StreamGenerationEndContext, ToolRoutingServerDescriptor, ToolDedupConfig, ToolConfig, KnowledgeEngineStatus, ChecklistState, ArtifactPageRequest, BankArtifactOptions, BankedArtifactRef, DelegateCollectRequest, DelegateCollectResult, DelegateHandle, DelegateRegistrationOptions, DelegateSpawnOptions, BackgroundCommandHandle, BackgroundCommandOptions, BackgroundCommandOutputPage, BackgroundCommandPageRequest, BackgroundCommandPolicy, BackgroundCommandStatus, GitToolResult, GitToolsetOptions } from "./types/index.js";
3
3
  import { ConversationMemoryManager } from "./core/conversationMemoryManager.js";
4
4
  import type { RedisConversationMemoryManager } from "./core/redisConversationMemoryManager.js";
5
5
  import { ExternalServerManager } from "./mcp/externalServerManager.js";
@@ -687,6 +687,10 @@ export declare class NeuroLink {
687
687
  isTelemetryEnabled(): boolean;
688
688
  /**
689
689
  * Get comprehensive telemetry status including Langfuse and OpenTelemetry state
690
+ * and every registered exporter with its queued, delivered and dropped spans.
691
+ * `openTelemetry.enabled` is whether tracing is initialized, as it always
692
+ * was. Beside a host TracerProvider, `openTelemetry.usingExternalProvider`
693
+ * is `true`: NeuroLink exports nothing to the endpoint.
690
694
  */
691
695
  getTelemetryStatus(): {
692
696
  enabled: boolean;
@@ -699,7 +703,9 @@ export declare class NeuroLink {
699
703
  enabled: boolean;
700
704
  endpoint?: string;
701
705
  serviceName?: string;
706
+ usingExternalProvider?: boolean;
702
707
  };
708
+ exporters: ExporterStatus[];
703
709
  };
704
710
  /**
705
711
  * Get aggregated observability metrics (latency, tokens, cost, success rate)
@@ -2714,13 +2720,15 @@ export declare class NeuroLink {
2714
2720
  * on the wire can differ from the instance's.
2715
2721
  *
2716
2722
  * Fail-open by construction, in three layers. Without a decision provider
2717
- * nothing runs at all — no hook, no event — and the consumer gets the null
2718
- * it always got. Everything a host can reach (its hook's return value, the
2719
- * event payloads, the answer split, `onAnswers`) runs inside one `try`;
2720
- * whatever a host throws, the consumer gets what `tryRunDecide` alone
2721
- * would have given it. And every host-facing value is a `structuredClone`,
2722
- * so a hook or listener that edits what it is handed edits neither the
2723
- * wire request nor the consumer's own question objects.
2723
+ * no hook runs and no event is emitted: the call goes to `tryRunDecide`
2724
+ * alone, which records the WARNING `decide` observation it always did, and
2725
+ * the consumer gets the null it always got. Everything a host can reach
2726
+ * (its hook's return value, the event payloads, the answer split,
2727
+ * `onAnswers`) runs inside one `try`; whatever a host throws, the consumer
2728
+ * gets what `tryRunDecide` alone would have given it. And every
2729
+ * host-facing value is a `structuredClone`, so a hook or listener that
2730
+ * edits what it is handed edits neither the wire request nor the
2731
+ * consumer's own question objects.
2724
2732
  *
2725
2733
  * `outer` is what the surrounding `generate()` / `stream()` supplies —
2726
2734
  * per-call credentials, abort signal, ids — and wins over the same fields
package/dist/neurolink.js CHANGED
@@ -71,8 +71,9 @@ import { getMetricsAggregator, MetricsAggregator, } from "./observability/metric
71
71
  import { AnalyticsService, calculateAdvancedCost, parseAnalyticsQualityScore, } from "./analytics/index.js";
72
72
  import { SpanStatus, SpanType, CircuitBreakerOpenError, ConversationMemoryError, DEFAULT_DECISION_HOOK_TIMEOUT_MS, MAX_DECISION_HOOK_TIMEOUT_MS, ModelAccessDeniedError, } from "./types/index.js";
73
73
  import { resolveTerminalAgentMode } from "./agent/prompts/terminalAgentPrompt.js";
74
+ import { getExporterRegistry } from "./observability/exporterRegistry.js";
74
75
  import { SpanSerializer } from "./observability/utils/spanSerializer.js";
75
- import { withSpan } from "./telemetry/withSpan.js";
76
+ import { endSpanWhenDropped, withSpan } from "./telemetry/withSpan.js";
76
77
  import { getActiveTraceContext } from "./telemetry/traceContext.js";
77
78
  import { calculateCost } from "./utils/pricing.js";
78
79
  import { acquireTelemetry, ensureTelemetry, flushOpenTelemetry, getLangfuseHealthStatus, isOpenTelemetryInitialized, releaseTelemetry, setLangfuseContext, traceMetadataFromContext, } from "./services/server/ai/observability/instrumentation.js";
@@ -410,6 +411,9 @@ function observeTurn(span, attrs) {
410
411
  }
411
412
  }
412
413
  function startTurn(span, optionsOrPrompt) {
414
+ if (!span.isRecording()) {
415
+ return;
416
+ }
413
417
  observeTurn(span, { input: turnInput(optionsOrPrompt) });
414
418
  if (typeof optionsOrPrompt !== "string") {
415
419
  const name = optionsOrPrompt.context?.observationName;
@@ -474,65 +478,87 @@ function traceTurnStream(result, span, turnContext) {
474
478
  : "stream closed before first chunk");
475
479
  }
476
480
  };
481
+ const advance = async (pull, afterThrow = false) => {
482
+ started = true;
483
+ try {
484
+ const step = await context.with(turnContext, pull);
485
+ if (step.done) {
486
+ span.setStatus({ code: SpanStatusCode.OK });
487
+ end(observationLevel(result.finishReason));
488
+ return { done: true, value: undefined };
489
+ }
490
+ const content = typeof step.value === "object" && step.value !== null
491
+ ? step.value.content
492
+ : undefined;
493
+ if (typeof content === "string") {
494
+ output += content;
495
+ }
496
+ return step;
497
+ }
498
+ catch (error) {
499
+ failTurnSpan(span, error instanceof Error ? error : new Error(String(error)));
500
+ // A consumer that throws into the stream ends it early, as return()
501
+ // does, and the root has always kept the model in that case.
502
+ if (afterThrow) {
503
+ observeTurn(span, { metadata: { model: result.model } });
504
+ }
505
+ finish();
506
+ throw error;
507
+ }
508
+ };
477
509
  const traced = {
478
510
  async next() {
479
511
  if (ended) {
480
512
  return { done: true, value: undefined };
481
513
  }
482
- started = true;
483
- try {
484
- const step = await context.with(turnContext, () => iterator.next());
485
- if (step.done) {
486
- span.setStatus({ code: SpanStatusCode.OK });
487
- end(observationLevel(result.finishReason));
488
- return { done: true, value: undefined };
489
- }
490
- const content = typeof step.value === "object" && step.value !== null
491
- ? step.value.content
492
- : undefined;
493
- if (typeof content === "string") {
494
- output += content;
495
- }
496
- return step;
497
- }
498
- catch (error) {
499
- failTurnSpan(span, error instanceof Error ? error : new Error(String(error)));
500
- finish();
501
- throw error;
502
- }
514
+ return advance(() => iterator.next());
503
515
  },
504
516
  async return() {
505
517
  await close();
506
518
  return { done: true, value: undefined };
507
519
  },
508
520
  async throw(error) {
509
- await close();
510
- throw error;
521
+ const forward = iterator.throw;
522
+ if (ended || forward === undefined) {
523
+ await close();
524
+ throw error;
525
+ }
526
+ return advance(() => forward.call(iterator, error), true);
511
527
  },
512
528
  [Symbol.asyncIterator]() {
513
529
  return traced;
514
530
  },
515
531
  };
532
+ endSpanWhenDropped(traced, span);
516
533
  return traced;
517
534
  }
535
+ /**
536
+ * Runs every pull of `stream` inside `drainContext`, and forwards `return()`
537
+ * and `throw()` to it even before the first `next()`. A generator that has not
538
+ * started never enters its body, so wrapping the drain in one would swallow a
539
+ * pre-start close and leave the drain's own settle step unreached.
540
+ */
518
541
  function drainInContext(stream, drainContext) {
519
- return (async function* () {
520
- const iterator = stream[Symbol.asyncIterator]();
521
- try {
522
- while (true) {
523
- const step = await context.with(drainContext, () => iterator.next());
524
- if (step.done) {
525
- return;
526
- }
527
- yield step.value;
542
+ const iterator = stream[Symbol.asyncIterator]();
543
+ const drained = {
544
+ next: () => context.with(drainContext, () => iterator.next()),
545
+ async return() {
546
+ await context.with(drainContext, async () => iterator.return?.());
547
+ return { done: true, value: undefined };
548
+ },
549
+ async throw(error) {
550
+ const forward = iterator.throw;
551
+ if (forward === undefined) {
552
+ await context.with(drainContext, async () => iterator.return?.());
553
+ throw error;
528
554
  }
529
- }
530
- finally {
531
- await context.with(drainContext, async () => {
532
- await iterator.return?.();
533
- });
534
- }
535
- })();
555
+ return context.with(drainContext, () => forward.call(iterator, error));
556
+ },
557
+ [Symbol.asyncIterator]() {
558
+ return drained;
559
+ },
560
+ };
561
+ return drained;
536
562
  }
537
563
  /** Monotonic source for {@link NeuroLink.logInstanceId}. Process-local by design. */
538
564
  let neuroLinkInstanceCounter = 0;
@@ -834,8 +860,8 @@ export class NeuroLink {
834
860
  }, callback);
835
861
  }
836
862
  createMetricsTraceContext() {
837
- // Attempt to reuse the active OTel trace context so Pipeline B spans
838
- // land in the same Langfuse trace as Pipeline A spans.
863
+ // Reuse the active OTel trace context so the in-process span records
864
+ // carry the traceId of the exported OTEL trace of the same request.
839
865
  const activeSpan = trace.getSpan(context.active());
840
866
  if (activeSpan) {
841
867
  const spanCtx = activeSpan.spanContext();
@@ -3168,11 +3194,15 @@ Current user's request: ${currentInput}`;
3168
3194
  }
3169
3195
  /**
3170
3196
  * Get comprehensive telemetry status including Langfuse and OpenTelemetry state
3197
+ * and every registered exporter with its queued, delivered and dropped spans.
3198
+ * `openTelemetry.enabled` is whether tracing is initialized, as it always
3199
+ * was. Beside a host TracerProvider, `openTelemetry.usingExternalProvider`
3200
+ * is `true`: NeuroLink exports nothing to the endpoint.
3171
3201
  */
3172
3202
  getTelemetryStatus() {
3173
3203
  const langfuseConfig = this.observabilityConfig?.langfuse;
3174
3204
  const langfuseOptions = typeof langfuseConfig === "object" ? langfuseConfig : undefined;
3175
- const { hasProcessor } = getLangfuseHealthStatus();
3205
+ const { hasProcessor, usingExternalProvider } = getLangfuseHealthStatus();
3176
3206
  return {
3177
3207
  enabled: isOpenTelemetryInitialized(),
3178
3208
  langfuse: langfuseConfig !== undefined || hasProcessor
@@ -3188,8 +3218,10 @@ Current user's request: ${currentInput}`;
3188
3218
  enabled: isOpenTelemetryInitialized(),
3189
3219
  endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
3190
3220
  serviceName: process.env.OTEL_SERVICE_NAME,
3221
+ ...(usingExternalProvider && { usingExternalProvider }),
3191
3222
  }
3192
3223
  : undefined,
3224
+ exporters: getExporterRegistry().getStatuses(),
3193
3225
  };
3194
3226
  }
3195
3227
  /**
@@ -7435,8 +7467,12 @@ Current user's request: ${currentInput}`;
7435
7467
  return { done: true, value: undefined };
7436
7468
  },
7437
7469
  async throw(error) {
7438
- await scope.run(eventScope, async () => iterator.return?.());
7439
- throw error;
7470
+ const forward = iterator.throw;
7471
+ if (forward === undefined) {
7472
+ await scope.run(eventScope, async () => iterator.return?.());
7473
+ throw error;
7474
+ }
7475
+ return scope.run(eventScope, () => forward.call(iterator, error));
7440
7476
  },
7441
7477
  [Symbol.asyncIterator]() {
7442
7478
  return bound;
@@ -7805,6 +7841,7 @@ Current user's request: ${currentInput}`;
7805
7841
  },
7806
7842
  });
7807
7843
  attempt.stream = drainInContext(attempt.stream, context.active());
7844
+ endSpanWhenDropped(attempt.stream, streamSpan);
7808
7845
  return attempt;
7809
7846
  }));
7810
7847
  if (streamSttTranscription) {
@@ -8615,7 +8652,18 @@ Current user's request: ${currentInput}`;
8615
8652
  }
8616
8653
  return stream.return(value);
8617
8654
  },
8618
- throw: (error) => stream.throw(error),
8655
+ throw: async (error) => {
8656
+ if (!started) {
8657
+ started = true;
8658
+ await this.settleStandardStreamDrain(drain, {
8659
+ accumulatedContent: "",
8660
+ chunkCount: 0,
8661
+ streamError: error instanceof Error ? error : new Error(String(error)),
8662
+ resolvedUsage: undefined,
8663
+ });
8664
+ }
8665
+ return stream.throw(error);
8666
+ },
8619
8667
  [Symbol.asyncIterator]: () => guarded,
8620
8668
  };
8621
8669
  return guarded;
@@ -14066,13 +14114,15 @@ Current user's request: ${currentInput}`;
14066
14114
  * on the wire can differ from the instance's.
14067
14115
  *
14068
14116
  * Fail-open by construction, in three layers. Without a decision provider
14069
- * nothing runs at all — no hook, no event — and the consumer gets the null
14070
- * it always got. Everything a host can reach (its hook's return value, the
14071
- * event payloads, the answer split, `onAnswers`) runs inside one `try`;
14072
- * whatever a host throws, the consumer gets what `tryRunDecide` alone
14073
- * would have given it. And every host-facing value is a `structuredClone`,
14074
- * so a hook or listener that edits what it is handed edits neither the
14075
- * wire request nor the consumer's own question objects.
14117
+ * no hook runs and no event is emitted: the call goes to `tryRunDecide`
14118
+ * alone, which records the WARNING `decide` observation it always did, and
14119
+ * the consumer gets the null it always got. Everything a host can reach
14120
+ * (its hook's return value, the event payloads, the answer split,
14121
+ * `onAnswers`) runs inside one `try`; whatever a host throws, the consumer
14122
+ * gets what `tryRunDecide` alone would have given it. And every
14123
+ * host-facing value is a `structuredClone`, so a hook or listener that
14124
+ * edits what it is handed edits neither the wire request nor the
14125
+ * consumer's own question objects.
14076
14126
  *
14077
14127
  * `outer` is what the surrounding `generate()` / `stream()` supplies —
14078
14128
  * per-call credentials, abort signal, ids — and wins over the same fields
@@ -14094,9 +14144,10 @@ Current user's request: ${currentInput}`;
14094
14144
  if (!site) {
14095
14145
  return this.tryRunDecide(merged);
14096
14146
  }
14097
- // Inert without a decision provider: a host's hook must not sit on the
14098
- // request path of a tenant that has no decision model to reach, and an
14099
- // event for a call that could never be made would only mislead.
14147
+ // No host hook or event without a decision provider: a hook must not sit
14148
+ // on the request path of a tenant that has no decision model to reach, and
14149
+ // an event for a call that could never be made would only mislead. The
14150
+ // plain call still runs, so the WARNING observation it records is kept.
14100
14151
  if (!this.resolveDecisionProviderName(merged.provider, merged.credentials)) {
14101
14152
  return this.tryRunDecide(merged);
14102
14153
  }
@@ -14419,11 +14470,14 @@ Current user's request: ${currentInput}`;
14419
14470
  ? { "decision.upstream_ms": result.upstreamMs }
14420
14471
  : {}),
14421
14472
  });
14422
- // BOTH aggregators, exactly as the generate path does. The
14423
- // instance one is what `neurolink.getMetrics()` / `getSpans()`
14424
- // read; the global one is what the exporters drain. Recording to
14425
- // only the global one would make a decision invisible to the very
14426
- // API a caller would use to check whether it ran.
14473
+ // BOTH aggregators, exactly as the generate path does. They are
14474
+ // in-process stores that are never exported: what Langfuse and the
14475
+ // OTLP exporters receive for this decision is the `decide`
14476
+ // generation span. The instance one is what
14477
+ // `neurolink.getMetrics()` / `getSpans()` read; the global one is
14478
+ // what `getMetricsAggregator()` readers see. Recording to only the
14479
+ // global one would make a decision invisible to the very API a
14480
+ // caller would use to check whether it ran.
14427
14481
  const ended = SpanSerializer.endSpan(span, SpanStatus.OK);
14428
14482
  this.metricsAggregator.recordSpan(ended);
14429
14483
  getMetricsAggregator().recordSpan(ended);
@@ -160,10 +160,6 @@ neurolink observability status
160
160
  neurolink observability metrics
161
161
  neurolink obs metrics --detailed
162
162
 
163
- # List exporters
164
- neurolink observability exporters
165
- neurolink obs exp --format json
166
-
167
163
  # Cost breakdown
168
164
  neurolink observability costs
169
165
  neurolink obs costs --by-model --by-provider
@@ -3,7 +3,7 @@
3
3
  * Manages multiple observability exporters with circuit breaker protection
4
4
  */
5
5
  import type { BaseExporter } from "./exporters/baseExporter.js";
6
- import type { ExportResult, ExporterHealthStatus, ObservabilityCircuitBreakerConfig, ObservabilityCircuitBreakerState, Sampler, SpanData } from "../types/index.js";
6
+ import type { ExportResult, ExporterHealthStatus, ExporterStatus, ObservabilityCircuitBreakerConfig, ObservabilityCircuitBreakerState, Sampler, SpanData } from "../types/index.js";
7
7
  /**
8
8
  * Registry for managing multiple observability exporters
9
9
  * Includes circuit breaker protection to prevent cascading failures
@@ -117,6 +117,11 @@ export declare class ExporterRegistry {
117
117
  * Get total pending spans across all exporters
118
118
  */
119
119
  getTotalPendingSpans(): number;
120
+ /**
121
+ * Snapshot of every registered exporter without a network call: whether it
122
+ * runs, what is queued, delivered and dropped, and an open circuit breaker
123
+ */
124
+ getStatuses(): ExporterStatus[];
120
125
  /**
121
126
  * Clear all registered exporters and reset state
122
127
  * (For testing and cleanup)
@@ -3,6 +3,7 @@
3
3
  * Manages multiple observability exporters with circuit breaker protection
4
4
  */
5
5
  import { logger } from "../utils/logger.js";
6
+ import { exporterStatusOf } from "./exporters/baseExporter.js";
6
7
  import { AlwaysSampler } from "./sampling/samplers.js";
7
8
  /** Default timeout for exporter API calls (30 seconds) */
8
9
  const DEFAULT_EXPORT_TIMEOUT_MS = 30_000;
@@ -378,6 +379,23 @@ export class ExporterRegistry {
378
379
  }
379
380
  return total;
380
381
  }
382
+ /**
383
+ * Snapshot of every registered exporter without a network call: whether it
384
+ * runs, what is queued, delivered and dropped, and an open circuit breaker
385
+ */
386
+ getStatuses() {
387
+ return Array.from(this.exporters.entries()).map(([name, exporter]) => {
388
+ const status = exporterStatusOf(exporter);
389
+ if (this.circuitBreakers.get(name)?.state !== "open") {
390
+ return status;
391
+ }
392
+ return {
393
+ ...status,
394
+ healthy: false,
395
+ errors: [...(status.errors ?? []), "Circuit breaker open"],
396
+ };
397
+ });
398
+ }
381
399
  /**
382
400
  * Clear all registered exporters and reset state
383
401
  * (For testing and cleanup)