@juspay/neurolink 13.5.0 → 13.5.1

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 (118) hide show
  1. package/CHANGELOG.md +3 -3
  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 +467 -467
  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.js +2 -0
  17. package/dist/core/geminiLoopAdapter.js +19 -0
  18. package/dist/core/loopEngine.js +10 -7
  19. package/dist/core/modules/StreamHandler.js +5 -4
  20. package/dist/core/modules/TelemetryHandler.d.ts +5 -1
  21. package/dist/core/modules/TelemetryHandler.js +16 -0
  22. package/dist/core/modules/ToolsManager.js +3 -0
  23. package/dist/core/nativeGenerateLoop.js +1 -1
  24. package/dist/evaluation/index.d.ts +1 -0
  25. package/dist/evaluation/index.js +4 -0
  26. package/dist/evaluation/ragasEvaluator.js +1 -0
  27. package/dist/mcp/externalServerManager.js +6 -2
  28. package/dist/mcp/toolRegistry.js +3 -0
  29. package/dist/neurolink.d.ts +16 -8
  30. package/dist/neurolink.js +115 -61
  31. package/dist/observability/FEATURE-STATUS.md +0 -4
  32. package/dist/observability/exporterRegistry.d.ts +6 -1
  33. package/dist/observability/exporterRegistry.js +18 -0
  34. package/dist/observability/exporterSpanProcessor.d.ts +20 -6
  35. package/dist/observability/exporterSpanProcessor.js +166 -34
  36. package/dist/observability/exporters/baseExporter.d.ts +22 -3
  37. package/dist/observability/exporters/baseExporter.js +87 -5
  38. package/dist/observability/exporters/langfuseExporter.d.ts +29 -3
  39. package/dist/observability/exporters/langfuseExporter.js +251 -41
  40. package/dist/observability/langfuseMasking.d.ts +1 -1
  41. package/dist/observability/langfuseMasking.js +1 -1
  42. package/dist/observability/otlpEncoding.d.ts +18 -5
  43. package/dist/observability/otlpEncoding.js +69 -12
  44. package/dist/observability/utils/spanSerializer.d.ts +3 -2
  45. package/dist/observability/utils/spanSerializer.js +5 -3
  46. package/dist/processors/base/BaseFileProcessor.d.ts +1 -0
  47. package/dist/processors/base/BaseFileProcessor.js +4 -0
  48. package/dist/processors/index.d.ts +1 -0
  49. package/dist/processors/index.js +4 -0
  50. package/dist/processors/registry/ProcessorRegistry.d.ts +1 -0
  51. package/dist/processors/registry/ProcessorRegistry.js +5 -0
  52. package/dist/providers/amazonBedrock/client.js +7 -5
  53. package/dist/providers/amazonSagemaker.js +32 -12
  54. package/dist/providers/anthropic/client.js +1 -0
  55. package/dist/providers/anthropic/loopAdapter.js +7 -3
  56. package/dist/providers/googleAiStudio/client.js +2 -0
  57. package/dist/providers/googleVertex/client.d.ts +6 -5
  58. package/dist/providers/googleVertex/client.js +46 -19
  59. package/dist/providers/openaiChatCompletionsBase.js +4 -4
  60. package/dist/proxy/codexUsage.js +59 -2
  61. package/dist/proxy/proxyTracer.d.ts +25 -1
  62. package/dist/proxy/proxyTracer.js +146 -22
  63. package/dist/proxy/proxyTranslationEngine.js +4 -2
  64. package/dist/proxy/sseInterceptor.js +6 -4
  65. package/dist/rag/chunkers/BaseChunker.js +1 -0
  66. package/dist/rag/index.d.ts +1 -0
  67. package/dist/rag/index.js +4 -0
  68. package/dist/rag/ragIntegration.js +1 -0
  69. package/dist/rag/reranker/reranker.js +2 -0
  70. package/dist/rag/retrieval/vectorQueryTool.js +1 -0
  71. package/dist/server/routes/codexProxyRoutes.js +3 -1
  72. package/dist/server/utils/validation.d.ts +3 -3
  73. package/dist/services/server/ai/observability/instrumentation.d.ts +8 -7
  74. package/dist/services/server/ai/observability/instrumentation.js +217 -47
  75. package/dist/services/server/ai/observability/langfuseSetup.d.ts +19 -1
  76. package/dist/services/server/ai/observability/langfuseSetup.js +62 -4
  77. package/dist/services/server/ai/observability/otlpSetup.d.ts +12 -2
  78. package/dist/services/server/ai/observability/otlpSetup.js +55 -4
  79. package/dist/telemetry/aiMetrics.d.ts +56 -0
  80. package/dist/telemetry/aiMetrics.js +229 -0
  81. package/dist/telemetry/attributes.d.ts +24 -1
  82. package/dist/telemetry/attributes.js +94 -9
  83. package/dist/telemetry/chatMessages.d.ts +6 -0
  84. package/dist/telemetry/chatMessages.js +37 -0
  85. package/dist/telemetry/index.d.ts +1 -1
  86. package/dist/telemetry/index.js +1 -1
  87. package/dist/telemetry/observation.d.ts +5 -5
  88. package/dist/telemetry/observation.js +128 -39
  89. package/dist/telemetry/spanSanitizer.d.ts +2 -2
  90. package/dist/telemetry/spanSanitizer.js +76 -8
  91. package/dist/telemetry/telemetryStarter.d.ts +16 -0
  92. package/dist/telemetry/telemetryStarter.js +28 -0
  93. package/dist/telemetry/traceContext.d.ts +3 -3
  94. package/dist/telemetry/traceContext.js +3 -3
  95. package/dist/telemetry/tracers.d.ts +13 -1
  96. package/dist/telemetry/tracers.js +49 -10
  97. package/dist/telemetry/withSpan.d.ts +19 -3
  98. package/dist/telemetry/withSpan.js +149 -20
  99. package/dist/types/exporter.d.ts +98 -3
  100. package/dist/types/livekit.d.ts +16 -1
  101. package/dist/types/loopEngine.d.ts +16 -1
  102. package/dist/types/observability.d.ts +38 -5
  103. package/dist/types/proxy.d.ts +2 -0
  104. package/dist/types/span.d.ts +26 -0
  105. package/dist/utils/timeout.js +4 -2
  106. package/dist/voice/livekit/realtimeMcpTools.js +11 -2
  107. package/dist/voice/livekit/realtimeMemory.d.ts +20 -2
  108. package/dist/voice/livekit/realtimeMemory.js +139 -46
  109. package/dist/voice/livekit/realtimeVoiceAgent.js +20 -8
  110. package/dist/voice/livekit/turnRecorder.js +22 -9
  111. package/dist/workflow/core/ensembleExecutor.js +2 -0
  112. package/dist/workflow/core/judgeScorer.js +1 -0
  113. package/dist/workflow/core/responseConditioner.js +1 -0
  114. package/dist/workflow/core/workflowRunner.js +6 -1
  115. package/dist/workflow/index.d.ts +1 -0
  116. package/dist/workflow/index.js +4 -0
  117. package/docs-site/static/search-index.json +32 -27
  118. package/package.json +21 -2
@@ -64,6 +64,10 @@
64
64
  * });
65
65
  * ```
66
66
  */
67
+ // Imported on its own, this entry starts tracing at its first span, as the
68
+ // main entry does. The import sits here rather than in the shared tracers so
69
+ // that the client entry stays free of the OpenTelemetry Node SDK.
70
+ import "../services/server/ai/observability/instrumentation.js";
67
71
  // =============================================================================
68
72
  // BASE PROCESSOR INFRASTRUCTURE (single source of truth for ALL types)
69
73
  // =============================================================================
@@ -44,6 +44,7 @@
44
44
  * ```
45
45
  */
46
46
  import type { FileInfo, ProcessorFileProcessingResult, ProcessedFileBase, ProcessOptions, ProcessorMatch, RegistryOptions, RegistryProcessResult, ProcessorRegistration } from "../../types/index.js";
47
+ import "../../services/server/ai/observability/instrumentation.js";
47
48
  /**
48
49
  * Central registry for file processors.
49
50
  * Uses singleton pattern and priority-based selection.
@@ -45,6 +45,10 @@
45
45
  */
46
46
  import { withSpan } from "../../telemetry/withSpan.js";
47
47
  import { tracers } from "../../telemetry/tracers.js";
48
+ // Starts tracing at the first span, as this module always did: the shared
49
+ // tracers only start what a loaded entry registered, and this path can be
50
+ // imported without the package entry. The client entry stays free of it.
51
+ import "../../services/server/ai/observability/instrumentation.js";
48
52
  // =============================================================================
49
53
  // UTILITY FUNCTIONS
50
54
  // =============================================================================
@@ -404,6 +408,7 @@ export class ProcessorRegistry {
404
408
  return withSpan({
405
409
  name: "neurolink.processor.processFile",
406
410
  tracer: tracers.processor,
411
+ observationType: "span",
407
412
  attributes: {
408
413
  "processor.filename": fileInfo.name ?? "unknown",
409
414
  "processor.mimetype": fileInfo.mimetype ?? "unknown",
@@ -279,9 +279,9 @@ export class AmazonBedrockProvider extends BaseProvider {
279
279
  }
280
280
  throw error;
281
281
  }
282
- // Emit generation:end so Pipeline B (Langfuse) creates a GENERATION observation.
283
- // Bedrock bypasses the Vercel AI SDK so experimental_telemetry is never injected;
284
- // we emit the event manually to fill that gap.
282
+ // Emit generation:end for the event listeners and the in-process metrics
283
+ // aggregator. Bedrock bypasses the Vercel AI SDK so nothing else emits it;
284
+ // its Langfuse GENERATION observations are the loop's own per-step spans.
285
285
  const generateEmitter = this.neurolink?.getEventEmitter();
286
286
  if (generateEmitter) {
287
287
  generateEmitter.emit("generation:end", {
@@ -383,6 +383,7 @@ export class AmazonBedrockProvider extends BaseProvider {
383
383
  }),
384
384
  });
385
385
  const { resultPromise } = runAgenticLoop(adapter, conversation, {
386
+ operation: "generate",
386
387
  tools: this.toEngineTools(tools),
387
388
  abortSignal: signal,
388
389
  // `toEngineTools` adds no deadline of its own, so this is the only
@@ -1261,6 +1262,7 @@ export class AmazonBedrockProvider extends BaseProvider {
1261
1262
  },
1262
1263
  };
1263
1264
  return runAgenticLoop(adapter, conversation, {
1265
+ operation: "stream",
1264
1266
  tools: this.toEngineTools(tools),
1265
1267
  abortSignal: signal,
1266
1268
  // `toEngineTools` adds no deadline of its own, so this is the only
@@ -1368,8 +1370,8 @@ export class AmazonBedrockProvider extends BaseProvider {
1368
1370
  // Analytics is resolved off `settled` above, so it lands whether or
1369
1371
  // not anyone drains the stream.
1370
1372
  // Bedrock bypasses the Vercel AI SDK, so experimental_telemetry is
1371
- // never injected and generation:end is emitted by hand for
1372
- // Pipeline B (Langfuse).
1373
+ // never injected and generation:end is emitted by hand for the
1374
+ // event listeners and the in-process metrics aggregator.
1373
1375
  if (streamEmitter) {
1374
1376
  // #1741: tell the orchestration's finally block this stream's
1375
1377
  // `generation:end` has already been emitted here, exactly as the
@@ -13,6 +13,7 @@ import { hasNativeDoGenerate, runNativeGenerateLoop, } from "../core/nativeGener
13
13
  import { generationName } from "../telemetry/observationNames.js";
14
14
  import { observeGeneration } from "../telemetry/observation.js";
15
15
  import { tracers } from "../telemetry/tracers.js";
16
+ import { markFailure } from "../telemetry/withSpan.js";
16
17
  // SageMaker-specific imports
17
18
  import { getDefaultSageMakerEndpoint, getSageMakerConfig, getSageMakerModel, getSageMakerModelConfig, } from "./sagemaker/config.js";
18
19
  import { handleSageMakerError, SageMakerError } from "./sagemaker/errors.js";
@@ -375,10 +376,18 @@ export class AmazonSageMakerProvider extends BaseProvider {
375
376
  span.setStatus({ code: SpanStatusCode.OK });
376
377
  }
377
378
  catch (error) {
378
- span.setStatus({
379
- code: SpanStatusCode.ERROR,
380
- message: error instanceof Error ? error.message : String(error),
381
- });
379
+ markFailure(span, error);
380
+ // markFailure leaves an abort at WARNING; the released pump ended
381
+ // every failure at ERROR, an abort included.
382
+ try {
383
+ span.setStatus({
384
+ code: SpanStatusCode.ERROR,
385
+ message: error instanceof Error ? error.message : String(error),
386
+ });
387
+ }
388
+ catch {
389
+ // An error whose text cannot be read is already recorded above.
390
+ }
382
391
  channel.error(error);
383
392
  }
384
393
  finally {
@@ -390,14 +399,25 @@ export class AmazonSageMakerProvider extends BaseProvider {
390
399
  settleFinishReason("unknown");
391
400
  settleUsage({ inputTokens: 0, outputTokens: 0 });
392
401
  }
393
- observeGeneration(span, {
394
- ...call,
395
- output: text,
396
- usage: tokens,
397
- finishReason: finish,
398
- completionStartTime: firstChunkAt,
399
- });
400
- span.end();
402
+ // Recording is best effort and span.end() must follow it either
403
+ // way: this pump is detached, so nothing else would catch a throw.
404
+ try {
405
+ observeGeneration(span, {
406
+ ...call,
407
+ output: text,
408
+ usage: tokens,
409
+ finishReason: finish,
410
+ completionStartTime: firstChunkAt,
411
+ });
412
+ }
413
+ catch (error) {
414
+ logger.debug("[SageMaker] Failed to record the stream generation", {
415
+ error: error instanceof Error ? error.message : String(error),
416
+ });
417
+ }
418
+ finally {
419
+ span.end();
420
+ }
401
421
  }
402
422
  })();
403
423
  return {
@@ -2208,6 +2208,7 @@ export class AnthropicProvider extends BaseProvider {
2208
2208
  }
2209
2209
  : undefined;
2210
2210
  const { stream, resultPromise } = runAgenticLoop(adapter, payload.messages.slice(), {
2211
+ operation: "stream",
2211
2212
  tools: engineTools,
2212
2213
  ...(abortSignal ? { abortSignal } : {}),
2213
2214
  ...(activeSpan ? { span: activeSpan } : {}),
@@ -401,9 +401,13 @@ export function createAnthropicLoopAdapter(config) {
401
401
  ? params.thinking.budget_tokens
402
402
  : undefined,
403
403
  },
404
- input: {
405
- system: params.system,
406
- messages: sanitizeAnthropicMessagesForTrace(params.messages),
404
+ // Read only by a recording span, so a request nobody traces is not
405
+ // copied and sanitized on every step.
406
+ get input() {
407
+ return {
408
+ system: params.system,
409
+ messages: sanitizeAnthropicMessagesForTrace(params.messages),
410
+ };
407
411
  },
408
412
  },
409
413
  };
@@ -771,6 +771,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
771
771
  name: "provider.stream",
772
772
  tracer: tracers.provider,
773
773
  detached: true,
774
+ rootWhenOrphaned: true,
774
775
  attributes: {
775
776
  [ATTR.NL_MODEL]: modelName,
776
777
  [ATTR.NL_PROVIDER]: this.providerName,
@@ -1474,6 +1475,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
1474
1475
  name: "provider.generate",
1475
1476
  tracer: tracers.provider,
1476
1477
  detached: true,
1478
+ rootWhenOrphaned: true,
1477
1479
  attributes: {
1478
1480
  [ATTR.NL_MODEL]: modelName,
1479
1481
  [ATTR.NL_PROVIDER]: this.providerName,
@@ -520,11 +520,12 @@ export declare class GoogleVertexProvider extends BaseProvider {
520
520
  private preserveStreamResultAccessors;
521
521
  private withClaudeGeneration;
522
522
  /**
523
- * Emit `generation:end` so the Pipeline B observability listener creates
524
- * the corresponding `model.generation` span. Vertex bypasses the AI SDK
525
- * (and therefore the experimental_telemetry plumbing), so this hand-off
526
- * is the only way native Vertex calls show up in Langfuse / Pipeline B
527
- * exporters. Mirrors the Bedrock + Ollama pattern.
523
+ * Emit `generation:end` so event listeners and the in-process metrics
524
+ * aggregator record the generation. Vertex bypasses the AI SDK (and
525
+ * therefore the experimental_telemetry plumbing), so nothing else emits it;
526
+ * the in-process store is never exported, and the Langfuse generation
527
+ * observations are the spans opened where each model call is made.
528
+ * Mirrors the Bedrock + Ollama pattern.
528
529
  */
529
530
  private emitGenerationEnd;
530
531
  /**
@@ -43,7 +43,9 @@ 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, spanContentAttribute, spanModelRequestAttribute, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "../../telemetry/index.js";
46
+ import { ATTR, recordsSpanInputs, spanContentAttribute, spanModelRequestAttribute, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "../../telemetry/index.js";
47
+ import { recordsSpanContent } from "../../telemetry/attributes.js";
48
+ import { inTurnlessTree } from "../../telemetry/tracers.js";
47
49
  import { observe, observeGeneration, observeTrace, } from "../../telemetry/observation.js";
48
50
  import { withGeneration, withStepSpan } from "../../telemetry/withSpan.js";
49
51
  import { context as otelContext, SpanKind, SpanStatusCode, trace as otelTrace, } from "@opentelemetry/api";
@@ -1004,6 +1006,7 @@ export class GoogleVertexProvider extends BaseProvider {
1004
1006
  name: "provider.stream",
1005
1007
  tracer: tracers.provider,
1006
1008
  detached: true,
1009
+ rootWhenOrphaned: true,
1007
1010
  attributes: {
1008
1011
  [ATTR.NL_MODEL]: modelName,
1009
1012
  [ATTR.NL_PROVIDER]: this.providerName,
@@ -3472,7 +3475,7 @@ export class GoogleVertexProvider extends BaseProvider {
3472
3475
  // Langfuse/OTel: the native SDK bypasses the Vercel AI SDK's
3473
3476
  // experimental_telemetry, so emit spans manually — one turn span, one
3474
3477
  // generation span per API call, one tool span per execution — all carrying
3475
- // langfuse.* attributes the LangfuseSpanProcessor maps to observations.
3478
+ // the langfuse.* observation attributes ExporterSpanProcessor exports.
3476
3479
  // Usage lives ONLY on the generation spans (Langfuse sums usage across
3477
3480
  // observations for trace totals, so repeating it on the turn double-counts).
3478
3481
  const offeredToolNames = (tools ?? []).map((anthropicTool) => anthropicTool.name);
@@ -5515,12 +5518,20 @@ export class GoogleVertexProvider extends BaseProvider {
5515
5518
  name: "provider.generate",
5516
5519
  tracer: tracers.provider,
5517
5520
  detached: true,
5521
+ rootWhenOrphaned: true,
5518
5522
  attributes: {
5519
5523
  [ATTR.NL_MODEL]: modelName,
5520
5524
  [ATTR.NL_PROVIDER]: this.providerName,
5521
5525
  },
5522
5526
  }, async (generateSpan) => {
5523
5527
  const generateStartTime = Date.now();
5528
+ // Neither this span nor executeGeneration is an observation: the
5529
+ // generation spans below carry the model call. Marked internal so no
5530
+ // exporter of ours sends them as duplicates, except for the root a
5531
+ // direct call opened for itself, which the loop hangs from.
5532
+ if (!inTurnlessTree(generateSpan)) {
5533
+ generateSpan.setAttribute(ATTR.NL_INTERNAL, true);
5534
+ }
5524
5535
  // One dispatch decision for the whole override: Vertex's generate()
5525
5536
  // bypasses BaseProvider.generate(), so it re-runs the same
5526
5537
  // resolveRequestKind() the core call sites use rather than keeping
@@ -5597,15 +5608,21 @@ export class GoogleVertexProvider extends BaseProvider {
5597
5608
  const inputPrompt = mergedOptions.input?.text ||
5598
5609
  mergedOptions.prompt ||
5599
5610
  "";
5600
- // Set generation input before the call so error paths still carry the
5601
- // request; output is set after the native call resolves.
5602
- const generationInputAttribute = spanModelRequestAttribute({
5603
- ...(mergedOptions.systemPrompt
5604
- ? { system: mergedOptions.systemPrompt }
5605
- : {}),
5606
- prompt: inputPrompt,
5607
- });
5608
- generateSpan.setAttribute(LangfuseOtelSpanAttributes.OBSERVATION_INPUT, generationInputAttribute);
5611
+ // A host exporter on the same provider reads the input and output of
5612
+ // these two spans, so they follow the policy of a generation: masked,
5613
+ // and left out when inputs (or all content) are not recorded. The
5614
+ // input is set before the call so an error path still carries it.
5615
+ const generationInputAttribute = recordsSpanInputs()
5616
+ ? spanModelRequestAttribute({
5617
+ ...(mergedOptions.systemPrompt
5618
+ ? { system: mergedOptions.systemPrompt }
5619
+ : {}),
5620
+ prompt: inputPrompt,
5621
+ })
5622
+ : undefined;
5623
+ if (generationInputAttribute !== undefined) {
5624
+ generateSpan.setAttribute(LangfuseOtelSpanAttributes.OBSERVATION_INPUT, generationInputAttribute);
5625
+ }
5609
5626
  try {
5610
5627
  let result;
5611
5628
  // Wrap the actual native generate call in `neurolink.executeGeneration`
@@ -5619,10 +5636,15 @@ export class GoogleVertexProvider extends BaseProvider {
5619
5636
  detached: true,
5620
5637
  attributes: {
5621
5638
  [ATTR.NL_MODEL]: modelName,
5639
+ [ATTR.NL_INTERNAL]: true,
5622
5640
  path: isAnthropicModel(modelName)
5623
5641
  ? "native.anthropic"
5624
5642
  : "native.google-genai",
5625
- [LangfuseOtelSpanAttributes.OBSERVATION_INPUT]: generationInputAttribute,
5643
+ ...(generationInputAttribute !== undefined
5644
+ ? {
5645
+ [LangfuseOtelSpanAttributes.OBSERVATION_INPUT]: generationInputAttribute,
5646
+ }
5647
+ : {}),
5626
5648
  },
5627
5649
  }, async (executionSpan) => {
5628
5650
  let nativeResult;
@@ -5640,7 +5662,9 @@ export class GoogleVertexProvider extends BaseProvider {
5640
5662
  });
5641
5663
  nativeResult = await this.runNativeGenerateWithMiddleware(mergedOptions, modelName, (effectiveOptions) => this.executeNativeGemini3GenerateWithSchemaFallback(effectiveOptions, modelName));
5642
5664
  }
5643
- executionSpan.setAttribute(LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT, spanContentAttribute(nativeResult?.content ?? ""));
5665
+ if (recordsSpanContent()) {
5666
+ executionSpan.setAttribute(LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT, spanContentAttribute(nativeResult?.content ?? ""));
5667
+ }
5644
5668
  return nativeResult;
5645
5669
  });
5646
5670
  // Pipe through TTS-of-AI-response when caller asks for it. The
@@ -5648,7 +5672,9 @@ export class GoogleVertexProvider extends BaseProvider {
5648
5672
  // enabled / useAiResponse is false, so the cost is zero on
5649
5673
  // non-TTS paths.
5650
5674
  result = await this.synthesizeAIResponseIfNeeded(result, options);
5651
- generateSpan.setAttribute(LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT, spanContentAttribute(result?.content ?? ""));
5675
+ if (recordsSpanContent()) {
5676
+ generateSpan.setAttribute(LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT, spanContentAttribute(result?.content ?? ""));
5677
+ }
5652
5678
  // Fire onFinish lifecycle callback for the native generate path.
5653
5679
  // Pipeline A providers get this for free via the AI SDK middleware
5654
5680
  // wrapper (LifecycleMiddleware); native @google/genai bypasses
@@ -6343,11 +6369,12 @@ export class GoogleVertexProvider extends BaseProvider {
6343
6369
  }));
6344
6370
  }
6345
6371
  /**
6346
- * Emit `generation:end` so the Pipeline B observability listener creates
6347
- * the corresponding `model.generation` span. Vertex bypasses the AI SDK
6348
- * (and therefore the experimental_telemetry plumbing), so this hand-off
6349
- * is the only way native Vertex calls show up in Langfuse / Pipeline B
6350
- * exporters. Mirrors the Bedrock + Ollama pattern.
6372
+ * Emit `generation:end` so event listeners and the in-process metrics
6373
+ * aggregator record the generation. Vertex bypasses the AI SDK (and
6374
+ * therefore the experimental_telemetry plumbing), so nothing else emits it;
6375
+ * the in-process store is never exported, and the Langfuse generation
6376
+ * observations are the spans opened where each model call is made.
6377
+ * Mirrors the Bedrock + Ollama pattern.
6351
6378
  */
6352
6379
  emitGenerationEnd(modelName, result, startTime, success, error, prompt) {
6353
6380
  const emitter = this.neurolink?.getEventEmitter();
@@ -1946,9 +1946,9 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
1946
1946
  // a blanket "stop" for every stream that drained without throwing.
1947
1947
  streamMetadata.finishReason = toUnifiedFinishReason(rawFinishReason);
1948
1948
  // No-output path: stream completed normally but yielded zero text.
1949
- // Build an enriched sentinel + stamp the active OTel span so
1950
- // Pipeline B (ContextEnricher) surfaces a WARNING-level Langfuse
1951
- // observation instead of silently succeeding.
1949
+ // Build an enriched sentinel + stamp the active OTel span with the
1950
+ // WARNING observation level, which ExporterSpanProcessor exports,
1951
+ // so the observation does not read as a silent success.
1952
1952
  if (contentYielded === 0 && toolsUsed.length === 0) {
1953
1953
  logger.warn(`${providerName}: Stream produced no output — emitting enriched sentinel`);
1954
1954
  const fauxNoOutput = new NoOutputGeneratedError({
@@ -2078,7 +2078,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
2078
2078
  ...(reasoningTokens > 0 ? { reasoningTokens } : {}),
2079
2079
  };
2080
2080
  };
2081
- const stepSpans = new StepSpans();
2081
+ const stepSpans = new StepSpans("stream");
2082
2082
  try {
2083
2083
  // May grow mid-turn: hydrated tools with wire-unsafe names need
2084
2084
  // reverse-mapping even when the initial name set required none.
@@ -42,6 +42,14 @@
42
42
  import { appendFileSync } from "node:fs";
43
43
  import { extractSSEEvents } from "./sseInterceptor.js";
44
44
  import { sanitizeForLog } from "../utils/logSanitize.js";
45
+ /**
46
+ * Characters of `response.output_item.done` payloads kept to stand in for a
47
+ * completed response that carries no output. Bounds what the tap holds for a
48
+ * stream whose items it cannot otherwise discard.
49
+ */
50
+ const OUTPUT_ITEMS_LIMIT_CHARS = 1024 * 1024;
51
+ /** Type of the item that stands where output items past the cap were left out. */
52
+ const OMITTED_ITEMS_TYPE = "neurolink_output_omitted";
45
53
  const nonNegativeInt = (value) => typeof value === "number" && Number.isFinite(value) && value > 0
46
54
  ? Math.floor(value)
47
55
  : 0;
@@ -208,6 +216,42 @@ function createCaptureSink() {
208
216
  export function createCodexUsageTap() {
209
217
  const evidence = { completed: false, terminalBytes: 0 };
210
218
  let totalBytes = 0;
219
+ // Items announced by response.output_item.done. The terminal response may
220
+ // arrive with an empty or absent output, leaving these as the only record
221
+ // of what the model said.
222
+ const streamedItems = [];
223
+ let streamedItemChars = 0;
224
+ let omittedItems = 0;
225
+ // An item past the cap leaves a marker in its place, so the record reads as
226
+ // incomplete rather than as a shorter reply; adjacent omissions share one.
227
+ const omitItem = () => {
228
+ omittedItems += 1;
229
+ const last = streamedItems[streamedItems.length - 1];
230
+ if (last?.type === OMITTED_ITEMS_TYPE) {
231
+ streamedItems[streamedItems.length - 1] = {
232
+ ...last,
233
+ omitted_items: Number(last.omitted_items) + 1,
234
+ };
235
+ return;
236
+ }
237
+ const marker = {
238
+ type: OMITTED_ITEMS_TYPE,
239
+ omitted_items: 1,
240
+ note: `…[truncated: output items past ${OUTPUT_ITEMS_LIMIT_CHARS} characters are not recorded]`,
241
+ };
242
+ streamedItems.push(marker);
243
+ streamedItemChars += JSON.stringify(marker).length;
244
+ };
245
+ const withStreamedOutput = (response) => {
246
+ if ((Array.isArray(response.output) && response.output.length > 0) ||
247
+ streamedItems.length === 0) {
248
+ return response;
249
+ }
250
+ if (omittedItems > 0) {
251
+ evidence.outputItemsOmitted = true;
252
+ }
253
+ return { ...response, output: [...streamedItems] };
254
+ };
211
255
  const inspectEvidence = (events) => {
212
256
  for (const frame of events) {
213
257
  if (frame.data.trim() === "[DONE]") {
@@ -234,6 +278,19 @@ export function createCodexUsageTap() {
234
278
  evidence.firstUsefulOutputEvent = String(type);
235
279
  }
236
280
  }
281
+ if (type === "response.output_item.done" &&
282
+ event.item &&
283
+ typeof event.item === "object" &&
284
+ !Array.isArray(event.item)) {
285
+ if (streamedItemChars + frame.data.length <=
286
+ OUTPUT_ITEMS_LIMIT_CHARS) {
287
+ streamedItems.push(event.item);
288
+ streamedItemChars += frame.data.length;
289
+ }
290
+ else {
291
+ omitItem();
292
+ }
293
+ }
237
294
  // Some clients/providers emit complete output items without deltas.
238
295
  // Reasoning/control events are not useful client output. Recognize
239
296
  // actual tool calls and text only, without inventing an earlier time.
@@ -277,8 +334,8 @@ export function createCodexUsageTap() {
277
334
  type.startsWith("response.") &&
278
335
  completedResponse &&
279
336
  typeof completedResponse === "object" &&
280
- "output" in completedResponse) {
281
- evidence.response = completedResponse;
337
+ ("output" in completedResponse || streamedItems.length > 0)) {
338
+ evidence.response = withStreamedOutput(completedResponse);
282
339
  }
283
340
  if (type === "response.completed") {
284
341
  evidence.completed = true;
@@ -12,7 +12,7 @@
12
12
  * - calculateCost() from pricing.ts for cost tracking
13
13
  */
14
14
  import { type Span } from "@opentelemetry/api";
15
- import type { AccountSelectionContext, ProxyRequestContext, ProxyRequestOrigin, ResponseInfoContext, UpstreamAttemptContext, UsageContext, ProxyUsageAttributionOptions, RuntimeRequestMetadata, ProxyLogTraceContext } from "../types/index.js";
15
+ import type { AccountSelectionContext, JsonValue, ProxyRequestContext, ProxyRequestOrigin, ResponseInfoContext, UpstreamAttemptContext, UsageContext, ProxyUsageAttributionOptions, RuntimeRequestMetadata, ProxyLogTraceContext } from "../types/index.js";
16
16
  declare class ProxyTracer {
17
17
  private readonly rootSpan;
18
18
  private readonly traceContext;
@@ -43,6 +43,12 @@ declare class ProxyTracer {
43
43
  private readonly requestedModel;
44
44
  private input?;
45
45
  private output?;
46
+ /** The parsed response outranks the raw upstream body that arrives after it. */
47
+ private outputIsParsed;
48
+ /** A model call made through `runModelCall` records the generation. */
49
+ private modelCallDelegated;
50
+ /** That call's records carry no content; fixed when the call starts. */
51
+ private modelContentWithheld;
46
52
  private responseModel?;
47
53
  private finishReason?;
48
54
  private firstOutputAt?;
@@ -65,6 +71,20 @@ declare class ProxyTracer {
65
71
  startUpstreamAttempt(ctx: UpstreamAttemptContext): Span;
66
72
  /** Span covering the SSE stream relay phase. */
67
73
  startStream(): Span;
74
+ /**
75
+ * Run a model call inside this request's trace context, so the spans it
76
+ * starts nest under the proxy root and carry the request's user and session.
77
+ * That call's generation holds usage and cost, so the root records none of
78
+ * its own. Its prompt and answer are request and response bodies, so its
79
+ * records carry them only with `NEUROLINK_PROXY_TRACE_BODY_LOGGING=true`.
80
+ */
81
+ runModelCall<T>(fn: () => T): T;
82
+ /**
83
+ * The delegated call's stream, read and closed under the same content rule:
84
+ * the call records its answer while the stream is consumed, which happens
85
+ * after `runModelCall` has returned.
86
+ */
87
+ modelStream<T>(iterator: AsyncIterator<T>): AsyncIterator<T>;
68
88
  /** Record account selection outcome on the root span. */
69
89
  setAccountSelection(ctx: AccountSelectionContext): void;
70
90
  /** Attribute fallback metrics to the account that actually served them. */
@@ -99,6 +119,8 @@ declare class ProxyTracer {
99
119
  attemptCount: number;
100
120
  reason: string;
101
121
  }): void;
122
+ /** Record the client request body as the input of the root and its generation. */
123
+ setRequestInput(body: JsonValue): void;
102
124
  /** Log the incoming client request body (redacted). */
103
125
  logRequestBody(body: string): void;
104
126
  /** Log the incoming client request headers (redacted). */
@@ -127,6 +149,8 @@ declare class ProxyTracer {
127
149
  getUsage(): UsageContext | undefined;
128
150
  /** End the root span with final HTTP status and duration, and emit OTEL metrics. */
129
151
  end(responseStatus: number, durationMs: number): void;
152
+ /** Bodies are only worth keeping while something is recording the span. */
153
+ private recordsBodies;
130
154
  private levelFor;
131
155
  private observeOutcome;
132
156
  /**