@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
@@ -12,10 +12,12 @@
12
12
  * - calculateCost() from pricing.ts for cost tracking
13
13
  */
14
14
  import { SpanStatusCode, SpanKind, ROOT_CONTEXT, isSpanContextValid, propagation, context, metrics, trace, } from "@opentelemetry/api";
15
- import { setLangfuseContext } from "../services/server/ai/observability/instrumentation.js";
16
- import { spanContentAttribute } from "../telemetry/attributes.js";
15
+ import { PROXY_TRACER_SCOPE } from "../constants/telemetry.js";
16
+ import { ensureTelemetry, setLangfuseContext, } from "../services/server/ai/observability/instrumentation.js";
17
+ import { spanContentAttribute, withoutSpanContent, } from "../telemetry/attributes.js";
17
18
  import { observationLevel, observe, observeTrace, usageDetails, } from "../telemetry/observation.js";
18
19
  import { generationName } from "../telemetry/observationNames.js";
20
+ import { recordAIRequest } from "../telemetry/aiMetrics.js";
19
21
  import { proxyTokenUsage } from "./proxyTokenUsage.js";
20
22
  import { calculateCost, hasPricing, isExactPricingMatch, } from "../utils/pricing.js";
21
23
  import { logger } from "../utils/logger.js";
@@ -23,11 +25,12 @@ import { registerProxyRequestTraceContext, releaseProxyRequestTraceContext, } fr
23
25
  // ---------------------------------------------------------------------------
24
26
  // OTEL Metric Instruments — lazy singleton
25
27
  //
26
- // The MeterProvider is registered in initializeOpenTelemetry() which runs
27
- // *after* module import time. @opentelemetry/api v1.x getMeter() returns a
28
- // NoopMeter if called before a real MeterProvider is set. We therefore
29
- // defer instrument creation until the first ProxyTracer.end() call, at which
30
- // point the MeterProvider is guaranteed to be registered.
28
+ // The MeterProvider is created by ensureTelemetry() (instrumentation.ts) when
29
+ // OTEL_EXPORTER_OTLP_ENDPOINT is set; `neurolink proxy start` calls it at
30
+ // startup, *after* module import time. @opentelemetry/api v1.x getMeter()
31
+ // returns a NoopMeter if called before a real MeterProvider is set. We
32
+ // therefore defer instrument creation until the first ProxyTracer.end()
33
+ // call, at which point the MeterProvider is registered.
31
34
  // ---------------------------------------------------------------------------
32
35
  let _metrics = null;
33
36
  function getProxyMetrics() {
@@ -141,7 +144,17 @@ function redactHeaders(headers) {
141
144
  }
142
145
  /** Redact sensitive JSON fields in request/response bodies before logging. */
143
146
  const SENSITIVE_BODY_KEYS = /api[_-]?key|token|secret|password|credential|authorization/i;
144
- const BODY_LOGGING_ENABLED = process.env.NEUROLINK_PROXY_TRACE_BODY_LOGGING === "true";
147
+ const envFlag = (name) => process.env[name]?.trim().toLowerCase();
148
+ /**
149
+ * Request and response bodies, both as observation input and output and as raw
150
+ * span events, are recorded only on an explicit
151
+ * `NEUROLINK_PROXY_TRACE_BODY_LOGGING=true`, read per call. Bodies are the
152
+ * most sensitive thing a proxy sees, so tracing the request alone never turns
153
+ * them on.
154
+ */
155
+ const bodyLoggingEnabled = () => process.env.NEUROLINK_PROXY_TRACE_BODY_LOGGING === "true";
156
+ /** `NEUROLINK_RECORD_INPUTS=false` keeps request content out of every trace. */
157
+ const recordInputsEnabled = () => envFlag("NEUROLINK_RECORD_INPUTS") !== "false";
145
158
  const MAX_BODY_LOG_SIZE = Number.parseInt(process.env.NEUROLINK_PROXY_TRACE_BODY_LOG_BYTES ?? "8192", 10);
146
159
  const MAX_STREAM_EVENTS_TO_LOG = 200;
147
160
  function redactBodyForLogging(body, maxLen = 8192) {
@@ -205,7 +218,7 @@ function detectClientApp(userAgent) {
205
218
  class ProxyTracer {
206
219
  rootSpan;
207
220
  traceContext;
208
- proxyTracer = trace.getTracer("proxy");
221
+ proxyTracer = trace.getTracer(PROXY_TRACER_SCOPE);
209
222
  requestId;
210
223
  /**
211
224
  * Model used for costing. Starts as the model the client asked for and is
@@ -232,6 +245,12 @@ class ProxyTracer {
232
245
  requestedModel;
233
246
  input;
234
247
  output;
248
+ /** The parsed response outranks the raw upstream body that arrives after it. */
249
+ outputIsParsed = false;
250
+ /** A model call made through `runModelCall` records the generation. */
251
+ modelCallDelegated = false;
252
+ /** That call's records carry no content; fixed when the call starts. */
253
+ modelContentWithheld = false;
235
254
  responseModel;
236
255
  finishReason;
237
256
  firstOutputAt;
@@ -255,7 +274,11 @@ class ProxyTracer {
255
274
  * will be linked to the caller's trace via the OTel propagation API.
256
275
  */
257
276
  static startRequest(ctx, incomingHeaders) {
258
- const tracer = trace.getTracer("proxy");
277
+ // The proxy tracer is not a scrubbed tracer, so nothing else starts
278
+ // telemetry before its first root span; without this an embedder that only
279
+ // set the Langfuse keys loses that first request.
280
+ ensureTelemetry();
281
+ const tracer = trace.getTracer(PROXY_TRACER_SCOPE);
259
282
  // Extract parent context from incoming headers (Claude Code may send traceparent)
260
283
  let parentContext = context.active();
261
284
  if (incomingHeaders && !trace.getSpan(context.active())) {
@@ -270,9 +293,10 @@ class ProxyTracer {
270
293
  }
271
294
  }
272
295
  const clientApp = ctx.clientApp ?? detectClientApp(ctx.userAgent);
296
+ const sessionId = incomingHeaders?.["x-neurolink-session-id"] ?? ctx.sessionId;
273
297
  const traceContext = context.with(parentContext, () => setLangfuseContext({
274
298
  userId: incomingHeaders?.["x-neurolink-user-id"],
275
- sessionId: incomingHeaders?.["x-neurolink-session-id"] ?? ctx.sessionId,
299
+ sessionId,
276
300
  conversationId: incomingHeaders?.["x-neurolink-conversation-id"],
277
301
  requestId: ctx.requestId,
278
302
  traceName: `proxy:${ctx.model}`,
@@ -282,20 +306,41 @@ class ProxyTracer {
282
306
  toolCount: ctx.toolCount,
283
307
  },
284
308
  }, () => context.active()));
309
+ // Model and usage are written under the gen_ai.* names collectors read
310
+ // before as well as under proxy.*; Langfuse's export leaves the gen_ai.*
311
+ // ones off this plain span, so the generation stays the priced record.
285
312
  const rootSpan = tracer.startSpan("proxy.request", {
286
313
  attributes: {
287
314
  "proxy.request_id": ctx.requestId,
288
315
  "http.method": ctx.method,
289
316
  "http.target": ctx.path,
290
317
  "proxy.request.model": ctx.model,
318
+ "gen_ai.request.model": ctx.model,
291
319
  "proxy.stream": ctx.stream,
292
320
  "proxy.tool_count": ctx.toolCount,
293
321
  "proxy.client_app": clientApp,
294
322
  },
295
323
  }, traceContext);
324
+ // Marked at the start so a model call that ends before this span (the
325
+ // translated path) is attached to it rather than to the span above it.
326
+ observe(rootSpan, "span", {});
296
327
  if (ctx.userAgent) {
297
328
  rootSpan.setAttribute("http.user_agent", ctx.userAgent);
298
329
  }
330
+ // Plain attributes for backends that index identity by key; Langfuse reads
331
+ // the propagated user and session instead.
332
+ for (const [key, value] of [
333
+ ["neurolink.user_id", incomingHeaders?.["x-neurolink-user-id"]],
334
+ ["neurolink.session_id", sessionId],
335
+ [
336
+ "neurolink.conversation_id",
337
+ incomingHeaders?.["x-neurolink-conversation-id"],
338
+ ],
339
+ ]) {
340
+ if (value) {
341
+ rootSpan.setAttribute(key, value);
342
+ }
343
+ }
299
344
  if (ctx.toolNames && ctx.toolNames.length > 0) {
300
345
  // What the caller exposed to the model (tool catalogue for this request).
301
346
  rootSpan.setAttribute("gen_ai.request.tool_names", JSON.stringify(ctx.toolNames));
@@ -345,9 +390,39 @@ class ProxyTracer {
345
390
  attributes: {
346
391
  "proxy.request_id": this.requestId,
347
392
  "proxy.request.model": this.model,
393
+ "gen_ai.request.model": this.model,
348
394
  },
349
395
  }, this.traceContext);
350
396
  }
397
+ /**
398
+ * Run a model call inside this request's trace context, so the spans it
399
+ * starts nest under the proxy root and carry the request's user and session.
400
+ * That call's generation holds usage and cost, so the root records none of
401
+ * its own. Its prompt and answer are request and response bodies, so its
402
+ * records carry them only with `NEUROLINK_PROXY_TRACE_BODY_LOGGING=true`.
403
+ */
404
+ runModelCall(fn) {
405
+ this.modelCallDelegated = true;
406
+ this.modelContentWithheld = !bodyLoggingEnabled();
407
+ return context.with(this.traceContext, () => this.modelContentWithheld ? withoutSpanContent(fn) : fn());
408
+ }
409
+ /**
410
+ * The delegated call's stream, read and closed under the same content rule:
411
+ * the call records its answer while the stream is consumed, which happens
412
+ * after `runModelCall` has returned.
413
+ */
414
+ modelStream(iterator) {
415
+ if (!this.modelContentWithheld) {
416
+ return iterator;
417
+ }
418
+ const close = iterator.return?.bind(iterator);
419
+ return {
420
+ next: (...value) => withoutSpanContent(() => iterator.next(...value)),
421
+ ...(close && {
422
+ return: (value) => withoutSpanContent(() => close(value)),
423
+ }),
424
+ };
425
+ }
351
426
  // -------------------------------------------------------------------------
352
427
  // Attribute setters
353
428
  // -------------------------------------------------------------------------
@@ -414,6 +489,9 @@ class ProxyTracer {
414
489
  "proxy.usage.input_tokens": totalTokens - ctx.outputTokens,
415
490
  "proxy.usage.output_tokens": ctx.outputTokens,
416
491
  "proxy.usage.total_tokens": totalTokens,
492
+ "gen_ai.usage.input_tokens": totalTokens - ctx.outputTokens,
493
+ "gen_ai.usage.output_tokens": ctx.outputTokens,
494
+ "gen_ai.usage.total_tokens": totalTokens,
417
495
  });
418
496
  // Prefix-inferred prices are not an observed model rate. Keep provenance,
419
497
  // but emit an amount only when this model has an exact price-table entry.
@@ -451,11 +529,17 @@ class ProxyTracer {
451
529
  this.responseModel = ctx.responseModel ?? this.responseModel;
452
530
  this.finishReason = ctx.finishReason ?? this.finishReason;
453
531
  this.firstOutputAt = ctx.firstOutputAt ?? this.firstOutputAt;
454
- if (BODY_LOGGING_ENABLED && ctx.output !== undefined) {
532
+ // A reply parsed to nothing (no content block, or an error before any)
533
+ // is still parsed: the raw upstream text must not stand in for it.
534
+ if (ctx.output !== undefined && this.recordsBodies()) {
455
535
  this.output = ctx.output;
536
+ this.outputIsParsed = true;
456
537
  }
457
538
  if (ctx.responseModel) {
458
- this.rootSpan.setAttribute("proxy.response.model", ctx.responseModel);
539
+ this.rootSpan.setAttributes({
540
+ "proxy.response.model": ctx.responseModel,
541
+ "gen_ai.response.model": ctx.responseModel,
542
+ });
459
543
  }
460
544
  if (ctx.finishReason) {
461
545
  this.rootSpan.setAttribute("gen_ai.response.finish_reason", ctx.finishReason);
@@ -506,6 +590,7 @@ class ProxyTracer {
506
590
  "proxy.original_model": requestedModel,
507
591
  "proxy.actual_model": actualModel,
508
592
  "proxy.response.model": actualModel,
593
+ "gen_ai.response.model": actualModel,
509
594
  ...(actualProvider ? { "proxy.actual_provider": actualProvider } : {}),
510
595
  });
511
596
  if (this.recordRequestMetrics) {
@@ -531,16 +616,24 @@ class ProxyTracer {
531
616
  // -------------------------------------------------------------------------
532
617
  // Log payloads as span events
533
618
  // -------------------------------------------------------------------------
619
+ /** Record the client request body as the input of the root and its generation. */
620
+ setRequestInput(body) {
621
+ if (this.recordsBodies() && recordInputsEnabled()) {
622
+ this.input = body;
623
+ }
624
+ }
534
625
  /** Log the incoming client request body (redacted). */
535
626
  logRequestBody(body) {
536
- if (!BODY_LOGGING_ENABLED) {
627
+ if (this.recordsBodies() && recordInputsEnabled()) {
628
+ this.input = parseBody(body);
629
+ }
630
+ if (!bodyLoggingEnabled() || !recordInputsEnabled()) {
537
631
  this.rootSpan.addEvent("proxy.client.request_body", {
538
632
  "proxy.body.size": body.length,
539
633
  "proxy.body.logged": false,
540
634
  });
541
635
  return;
542
636
  }
543
- this.input = parseBody(body);
544
637
  this.rootSpan.addEvent("proxy.client.request_body", {
545
638
  ...buildBodyEventAttributes(body),
546
639
  });
@@ -553,7 +646,7 @@ class ProxyTracer {
553
646
  }
554
647
  /** Log the upstream request body (redacted, as sent to Anthropic). */
555
648
  logUpstreamRequestBody(body) {
556
- if (!BODY_LOGGING_ENABLED) {
649
+ if (!bodyLoggingEnabled() || !recordInputsEnabled()) {
557
650
  this.rootSpan.addEvent("proxy.upstream.request_body", {
558
651
  "proxy.body.size": body.length,
559
652
  "proxy.body.logged": false,
@@ -578,21 +671,23 @@ class ProxyTracer {
578
671
  }
579
672
  /** Log the upstream response body (redacted). */
580
673
  logUpstreamResponseBody(body) {
581
- if (!BODY_LOGGING_ENABLED) {
674
+ if (this.recordsBodies() && !this.outputIsParsed) {
675
+ this.output = parseBody(body);
676
+ }
677
+ if (!bodyLoggingEnabled()) {
582
678
  this.rootSpan.addEvent("proxy.upstream.response_body", {
583
679
  "proxy.body.size": body.length,
584
680
  "proxy.body.logged": false,
585
681
  });
586
682
  return;
587
683
  }
588
- this.output = parseBody(body);
589
684
  this.rootSpan.addEvent("proxy.upstream.response_body", {
590
685
  ...buildBodyEventAttributes(body),
591
686
  });
592
687
  }
593
688
  /** Log SSE stream events (each event has type, timestamp, data). */
594
689
  logStreamEvents(events) {
595
- if (!BODY_LOGGING_ENABLED) {
690
+ if (!bodyLoggingEnabled()) {
596
691
  this.rootSpan.addEvent("proxy.stream.events", {
597
692
  "proxy.stream.event_count": events.length,
598
693
  "proxy.body.logged": false,
@@ -746,6 +841,7 @@ class ProxyTracer {
746
841
  if (cost !== undefined) {
747
842
  m.costTotal.add(cost, tokenLabels);
748
843
  }
844
+ recordAIRequest(this.billingProvider, this.model, billingUsage.total, durationMs, cost);
749
845
  }
750
846
  // Error metrics
751
847
  if (this.recordRequestMetrics && responseStatus >= 400) {
@@ -764,6 +860,10 @@ class ProxyTracer {
764
860
  });
765
861
  }
766
862
  }
863
+ /** Bodies are only worth keeping while something is recording the span. */
864
+ recordsBodies() {
865
+ return bodyLoggingEnabled() && this.rootSpan.isRecording();
866
+ }
767
867
  levelFor(responseStatus) {
768
868
  if (responseStatus === 499) {
769
869
  return "WARNING";
@@ -793,7 +893,7 @@ class ProxyTracer {
793
893
  metadata: { account: this.accountEmail },
794
894
  });
795
895
  observeTrace(this.rootSpan, io);
796
- if (this.upstreamStartedAt === undefined) {
896
+ if (this.upstreamStartedAt === undefined || this.modelCallDelegated) {
797
897
  return;
798
898
  }
799
899
  const generation = this.proxyTracer.startSpan(generationName(this.isStream ? "stream" : "generate"), {
@@ -913,6 +1013,29 @@ function trackFallbackLegHealth(legKey, status, errorMessage) {
913
1013
  (errorMessage ? `; last error: ${errorMessage.slice(0, 200)}` : ""));
914
1014
  reportedDeadFallbackLegs.add(legKey);
915
1015
  }
1016
+ /** Leg failure classes the fallback paths put in brackets before a message. */
1017
+ const FALLBACK_ERROR_CLASSES = new Set([
1018
+ "unknown",
1019
+ "unavailable",
1020
+ "rate_limit",
1021
+ "context_overflow",
1022
+ "empty_response",
1023
+ "schema_mismatch",
1024
+ "provider_quota",
1025
+ ]);
1026
+ /**
1027
+ * A metric label from a fixed set: an error message is unbounded cardinality
1028
+ * and can echo the prompt or a credential.
1029
+ */
1030
+ function fallbackErrorLabel(errorMessage) {
1031
+ if (errorMessage === undefined) {
1032
+ return "unknown";
1033
+ }
1034
+ const named = /^\[([a-z_]+)\]/.exec(errorMessage)?.[1];
1035
+ return named !== undefined && FALLBACK_ERROR_CLASSES.has(named)
1036
+ ? named
1037
+ : "other";
1038
+ }
916
1039
  export function recordFallbackAttempt(attrs) {
917
1040
  // Leg health is tracked outside the metrics try/catch: a telemetry failure
918
1041
  // must not be the reason an operator never hears that a leg is dead.
@@ -934,7 +1057,7 @@ export function recordFallbackAttempt(attrs) {
934
1057
  else {
935
1058
  m.fallbackFailureTotal.add(1, {
936
1059
  ...labels,
937
- error: attrs.errorMessage?.slice(0, 100) ?? "unknown",
1060
+ error: fallbackErrorLabel(attrs.errorMessage),
938
1061
  });
939
1062
  }
940
1063
  }
@@ -995,6 +1118,7 @@ export { ProxyTracer };
995
1118
  /** Standard SERVER span covers every proxy door, including parsing and admission failures. */
996
1119
  export function startProxyHttpTrace(metadata, headers) {
997
1120
  try {
1121
+ ensureTelemetry();
998
1122
  const normalizedHeaders = { ...headers };
999
1123
  if (normalizedHeaders.traceparent?.includes(",")) {
1000
1124
  normalizedHeaders.traceparent = normalizedHeaders.traceparent
@@ -1002,7 +1126,7 @@ export function startProxyHttpTrace(metadata, headers) {
1002
1126
  .trim();
1003
1127
  }
1004
1128
  const parent = propagation.extract(ROOT_CONTEXT, normalizedHeaders);
1005
- const span = trace.getTracer("proxy").startSpan("proxy.http.request", {
1129
+ const span = trace.getTracer(PROXY_TRACER_SCOPE).startSpan("proxy.http.request", {
1006
1130
  kind: SpanKind.SERVER,
1007
1131
  attributes: {
1008
1132
  "http.request.method": metadata.method,
@@ -582,9 +582,11 @@ async function* runTranslationAttempts(args) {
582
582
  },
583
583
  });
584
584
  recordAttempt(attempt.label ?? "translation", "translation");
585
- const result = await raceWithAbort(ctx.neurolink.stream(request.withVettedImages(prepared.body)), signal);
585
+ const modelCall = () => ctx.neurolink.stream(request.withVettedImages(prepared.body));
586
+ const result = await raceWithAbort(args.tracer ? args.tracer.runModelCall(modelCall) : modelCall(), signal);
586
587
  request.setResult(result);
587
- const iterator = result.stream[Symbol.asyncIterator]();
588
+ const source = result.stream[Symbol.asyncIterator]();
589
+ const iterator = args.tracer ? args.tracer.modelStream(source) : source;
588
590
  request.setIterator(iterator);
589
591
  let hasText = false;
590
592
  while (true) {
@@ -293,10 +293,12 @@ function processMessageDelta(acc, parsed) {
293
293
  }
294
294
  const usage = parsed.usage;
295
295
  if (usage) {
296
- // message_delta provides the final output_tokens count; treat it as
297
- // additive because message_start reports output_tokens: 0 for the
298
- // initial placeholder.
299
- acc.outputTokens += usage.output_tokens ?? 0;
296
+ // message_delta reports the cumulative output_tokens for the whole
297
+ // message, including the token message_start already counted (it reports
298
+ // 1, not 0), so it replaces that count rather than adding to it.
299
+ if (typeof usage.output_tokens === "number") {
300
+ acc.outputTokens = usage.output_tokens;
301
+ }
300
302
  }
301
303
  }
302
304
  /* eslint-enable @typescript-eslint/no-explicit-any */
@@ -69,6 +69,7 @@ export class BaseChunker {
69
69
  return withSpan({
70
70
  name: "neurolink.rag.chunk",
71
71
  tracer: tracers.rag,
72
+ observationType: "span",
72
73
  attributes: {
73
74
  "rag.chunker.strategy": this.strategy,
74
75
  "rag.chunker.content_chars": content.length,
@@ -43,6 +43,7 @@
43
43
  * const chunks = await chunker.chunk(text);
44
44
  * ```
45
45
  */
46
+ import "../services/server/ai/observability/instrumentation.js";
46
47
  export { ChunkerFactory, chunkerFactory, createChunker, getAvailableStrategies as getFactoryStrategies, getDefaultConfig as getFactoryDefaultConfig, } from "./ChunkerFactory.js";
47
48
  export { ChunkerRegistry as ChunkerRegistryV2, chunkerRegistry, getAvailableChunkers, getChunker, getChunkerMetadata, } from "./ChunkerRegistry.js";
48
49
  export * from "./chunkers/index.js";
package/dist/rag/index.js CHANGED
@@ -43,6 +43,10 @@
43
43
  * const chunks = await chunker.chunk(text);
44
44
  * ```
45
45
  */
46
+ // Imported on its own, this entry starts tracing at its first span, as the
47
+ // main entry does. The import sits here rather than in the shared tracers so
48
+ // that the client entry stays free of the OpenTelemetry Node SDK.
49
+ import "../services/server/ai/observability/instrumentation.js";
46
50
  export { ChunkerFactory, chunkerFactory, createChunker, getAvailableStrategies as getFactoryStrategies, getDefaultConfig as getFactoryDefaultConfig, } from "./ChunkerFactory.js";
47
51
  // ChunkerFactory and ChunkerRegistry patterns (from main worktree)
48
52
  export { ChunkerRegistry as ChunkerRegistryV2, chunkerRegistry, getAvailableChunkers, getChunker, getChunkerMetadata, } from "./ChunkerRegistry.js";
@@ -478,6 +478,7 @@ async function _prepareRAGToolInner(ragConfig, fallbackProvider) {
478
478
  return withSpan({
479
479
  name: "neurolink.rag.search",
480
480
  tracer: tracers.rag,
481
+ observationType: "span",
481
482
  attributes: {
482
483
  "rag.query_length": query ? String(query).length : 0,
483
484
  "rag.top_k": topK ?? 5,
@@ -33,6 +33,7 @@ export async function rerank(results, query, model, options) {
33
33
  return withSpan({
34
34
  name: "neurolink.rag.rerank",
35
35
  tracer: tracers.rag,
36
+ observationType: "span",
36
37
  attributes: {
37
38
  "rag.reranker.input_count": results.length,
38
39
  "rag.reranker.top_k": options?.topK ?? 3,
@@ -153,6 +154,7 @@ export async function batchRerank(results, query, model, options) {
153
154
  return withSpan({
154
155
  name: "neurolink.rag.batchRerank",
155
156
  tracer: tracers.rag,
157
+ observationType: "span",
156
158
  attributes: {
157
159
  "rag.reranker.input_count": results.length,
158
160
  "rag.reranker.top_k": options?.topK ?? 3,
@@ -51,6 +51,7 @@ export function createVectorQueryTool(config, vectorStore) {
51
51
  return withSpan({
52
52
  name: "neurolink.rag.vectorQuery",
53
53
  tracer: tracers.rag,
54
+ observationType: "span",
54
55
  attributes: {
55
56
  "rag.vector.index": indexName,
56
57
  "rag.vector.top_k": params.topK ?? topK,
@@ -1026,7 +1026,7 @@ function captureCodexResponse(ctx, response, phase, account, attempt) {
1026
1026
  }
1027
1027
  function startCodexTracer(ctx, model, body, isFallbackRequest) {
1028
1028
  try {
1029
- return ProxyTracer.startRequest({
1029
+ const tracer = ProxyTracer.startRequest({
1030
1030
  requestId: ctx.requestId,
1031
1031
  method: ctx.method,
1032
1032
  path: ctx.path,
@@ -1038,6 +1038,8 @@ function startCodexTracer(ctx, model, body, isFallbackRequest) {
1038
1038
  recordRequestMetrics: !isFallbackRequest,
1039
1039
  recordUsageMetrics: true,
1040
1040
  }, ctx.headers);
1041
+ tracer.setRequestInput(body);
1042
+ return tracer;
1041
1043
  }
1042
1044
  catch {
1043
1045
  return undefined;
@@ -192,8 +192,8 @@ export declare const TranscribeConfigSchema: z.ZodObject<{
192
192
  correction: z.ZodOptional<z.ZodObject<{
193
193
  enabled: z.ZodOptional<z.ZodBoolean>;
194
194
  guard: z.ZodOptional<z.ZodEnum<{
195
- decide: "decide";
196
195
  none: "none";
196
+ decide: "decide";
197
197
  }>>;
198
198
  decideTimeoutMs: z.ZodOptional<z.ZodNumber>;
199
199
  rewrite: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
@@ -242,8 +242,8 @@ export declare const TranscribeRequestSchema: z.ZodObject<{
242
242
  correction: z.ZodOptional<z.ZodObject<{
243
243
  enabled: z.ZodOptional<z.ZodBoolean>;
244
244
  guard: z.ZodOptional<z.ZodEnum<{
245
- decide: "decide";
246
245
  none: "none";
246
+ decide: "decide";
247
247
  }>>;
248
248
  decideTimeoutMs: z.ZodOptional<z.ZodNumber>;
249
249
  rewrite: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
@@ -308,8 +308,8 @@ export declare const TranscribeStreamConfigSchema: z.ZodObject<{
308
308
  correction: z.ZodOptional<z.ZodObject<{
309
309
  enabled: z.ZodOptional<z.ZodBoolean>;
310
310
  guard: z.ZodOptional<z.ZodEnum<{
311
- decide: "decide";
312
311
  none: "none";
312
+ decide: "decide";
313
313
  }>>;
314
314
  decideTimeoutMs: z.ZodOptional<z.ZodNumber>;
315
315
  rewrite: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
@@ -1,7 +1,7 @@
1
1
  import { type Context } from "@opentelemetry/api";
2
2
  import type { NeurolinkTraceContext, ObservabilityConfig, SpanContent } from "../../../../types/index.js";
3
3
  /**
4
- * @description Starts NeuroLink's tracing once per process, synchronously. Every span created through the shared tracers calls it first, so direct provider calls without a NeuroLink instance are traced too. Langfuse is enabled unless `config.langfuse` is `false`, when an options object is given or `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` are set; an OTLP exporter is added when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. When the host already registered a TracerProvider no provider is created; the Langfuse exporter, if configured, is registered on the shared exporter registry and the host adds `createExporterSpanProcessor()` to its provider. The first call that finds tracing configured decides the setup and a later `config` is ignored; calls made before anything is configured start nothing, so a later config or environment can still start tracing, unless `langfuse: false` was passed. After `shutdownOpenTelemetry` it does nothing.
4
+ * @description Starts NeuroLink's tracing once per process, synchronously. Every span created through the shared tracers calls it first once this module is loaded, as the main, server and livekit entries do, so direct provider calls without a NeuroLink instance are traced too. Langfuse is enabled unless `config.langfuse` is `false` or has `enabled: false`, when `config.langfuse` is `true` or an options object, or when `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` are set and `LANGFUSE_ENABLED` is not `false`, `0`, `off` or `no`; an OTLP exporter is added when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. When the host already registered a TracerProvider no provider is created and `OTEL_EXPORTER_OTLP_ENDPOINT` is ignored with a warning; the Langfuse exporter, if configured, is registered on the shared exporter registry and the host adds `createExporterSpanProcessor()` to its provider. The first call that finds tracing configured decides the setup and a later `config` is ignored, with a warning naming the options that differ from the first when it does differ; calls made before anything is configured start nothing, so a later config or environment can still start tracing, unless `langfuse: false` was passed. Langfuse enabled without both keys (in config or the environment) exports nothing to Langfuse, and a warning says so once. With `langfuse: true` or the environment alone that counts as not configured, so only an OTLP endpoint starts tracing; an options object starts tracing anyway, as it always did, so exporters registered on the shared registry receive the spans. Pending spans and metrics are flushed when the process runs out of work (`beforeExit`), within the same budget as `flushOpenTelemetry`, which an explicit `process.exit()` skips; `NEUROLINK_TELEMETRY_EXIT_FLUSH` set to `false`, `0`, `off` or `no` skips that flush too, and the process ends as soon as it has no work. After `shutdownOpenTelemetry` it does nothing.
5
5
  * @param config {ObservabilityConfig | undefined} - Observability options, honoured only when this is the first call.
6
6
  * @returns {void} Nothing; a failed setup is logged and recorded in `getLangfuseHealthStatus`.
7
7
  */
@@ -13,20 +13,21 @@ export declare function ensureTelemetry(config?: ObservabilityConfig): void;
13
13
  */
14
14
  export declare function acquireTelemetry(config?: ObservabilityConfig): void;
15
15
  /**
16
- * @description Releases one holder registered by `acquireTelemetry` and flushes pending spans. The provider stays registered for the process so cached tracers keep working and later instances reuse it.
16
+ * @description Releases one holder registered by `acquireTelemetry` and flushes pending spans as `flushOpenTelemetry` does. The provider stays registered for the process so cached tracers keep working and later instances reuse it.
17
17
  * @returns {Promise<void>} Resolves when pending spans are flushed.
18
- * @throws {Error} When any provider fails to flush.
18
+ * @throws {Error} When any provider fails to flush or does not finish within the flush budget.
19
19
  */
20
20
  export declare function releaseTelemetry(): Promise<void>;
21
21
  /**
22
- * @description Exports pending spans and metrics from the providers NeuroLink owns and flushes its Langfuse exporter. A host provider is never flushed.
22
+ * @description Exports pending spans and metrics from the providers NeuroLink owns and flushes its Langfuse exporter. A host provider is never flushed. The OTLP span and metric exports are waited for at most the flush budget (`observability.langfuse.flushBudgetMs`, default 3000 ms) and the Langfuse exporter within its own budget. A provider that fails to flush, or does not finish within the budget (a collector that cannot be reached, for one), is logged as a warning and makes the call reject once every provider has settled.
23
23
  * @returns {Promise<void>} Resolves when every owned provider has flushed.
24
- * @throws {Error} When any provider fails to flush.
24
+ * @throws {Error} When any provider fails to flush or does not finish within the flush budget.
25
25
  */
26
26
  export declare function flushOpenTelemetry(): Promise<void>;
27
27
  /**
28
- * @description Terminally shuts down the providers NeuroLink owns once no `acquireTelemetry` holder remains; while holders remain it only flushes. Telemetry cannot be restarted in this process afterwards. A host provider is never shut down; with one, only NeuroLink's Langfuse exporter is shut down and unregistered.
29
- * @returns {Promise<void>} Resolves when telemetry is flushed or shut down.
28
+ * @description Terminally shuts down the providers NeuroLink owns once no `acquireTelemetry` holder remains; while holders remain it only flushes, as `flushOpenTelemetry` does. Telemetry cannot be restarted in this process afterwards. A host provider is never shut down; with one, only NeuroLink's Langfuse exporter is shut down and unregistered. The OTLP exporters get the same budget as `flushOpenTelemetry`, and a failure of the shutdown itself is logged as a warning.
29
+ * @returns {Promise<void>} Resolves when telemetry is flushed or shut down, or the flush budget has run out.
30
+ * @throws {Error} When holders remain and the flush they get fails.
30
31
  */
31
32
  export declare function shutdownOpenTelemetry(): Promise<void>;
32
33
  /**