@juspay/neurolink 9.94.6 → 9.95.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 (34) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/analytics/index.d.ts +7 -0
  3. package/dist/analytics/index.js +7 -0
  4. package/dist/analytics/parseQualityScore.d.ts +9 -0
  5. package/dist/analytics/parseQualityScore.js +29 -0
  6. package/dist/analytics/pricing.d.ts +24 -0
  7. package/dist/analytics/pricing.js +37 -0
  8. package/dist/analytics/service.d.ts +36 -0
  9. package/dist/analytics/service.js +393 -0
  10. package/dist/analytics/storage.d.ts +19 -0
  11. package/dist/analytics/storage.js +32 -0
  12. package/dist/browser/neurolink.min.js +389 -389
  13. package/dist/cli/factories/commandFactory.d.ts +0 -2
  14. package/dist/cli/factories/commandFactory.js +36 -45
  15. package/dist/cli/loop/session.js +21 -0
  16. package/dist/cli/utils/inputValidation.d.ts +38 -0
  17. package/dist/cli/utils/inputValidation.js +143 -0
  18. package/dist/lib/analytics/index.d.ts +7 -0
  19. package/dist/lib/analytics/index.js +8 -0
  20. package/dist/lib/analytics/parseQualityScore.d.ts +9 -0
  21. package/dist/lib/analytics/parseQualityScore.js +30 -0
  22. package/dist/lib/analytics/pricing.d.ts +24 -0
  23. package/dist/lib/analytics/pricing.js +38 -0
  24. package/dist/lib/analytics/service.d.ts +36 -0
  25. package/dist/lib/analytics/service.js +394 -0
  26. package/dist/lib/analytics/storage.d.ts +19 -0
  27. package/dist/lib/analytics/storage.js +33 -0
  28. package/dist/lib/neurolink.d.ts +54 -0
  29. package/dist/lib/neurolink.js +302 -204
  30. package/dist/lib/types/analytics.d.ts +127 -0
  31. package/dist/neurolink.d.ts +54 -0
  32. package/dist/neurolink.js +302 -204
  33. package/dist/types/analytics.d.ts +127 -0
  34. package/package.json +2 -1
package/dist/neurolink.js CHANGED
@@ -59,6 +59,7 @@ import { buildSkillActivationMessage } from "./skills/skillSessionTracker.js";
59
59
  import { SkillsManager } from "./skills/skillsManager.js";
60
60
  import { createSkillCallTools, createSkillTools } from "./skills/skillTools.js";
61
61
  import { getMetricsAggregator, MetricsAggregator, } from "./observability/metricsAggregator.js";
62
+ import { AnalyticsService, calculateAdvancedCost, parseAnalyticsQualityScore, } from "./analytics/index.js";
62
63
  import { SpanStatus, SpanType, CircuitBreakerOpenError, ConversationMemoryError, ModelAccessDeniedError, } from "./types/index.js";
63
64
  import { SpanSerializer } from "./observability/utils/spanSerializer.js";
64
65
  import { flushOpenTelemetry, getLangfuseContext, getLangfuseHealthStatus, initializeOpenTelemetry, isOpenTelemetryInitialized, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry, stampGuestRescueIdentity, } from "./services/server/ai/observability/instrumentation.js";
@@ -802,6 +803,7 @@ export class NeuroLink {
802
803
  */
803
804
  observabilityConfig;
804
805
  metricsAggregator = new MetricsAggregator();
806
+ analyticsService;
805
807
  /**
806
808
  * Per-request metrics trace context backed by AsyncLocalStorage.
807
809
  * Safe for concurrent requests on the same SDK instance.
@@ -814,6 +816,7 @@ export class NeuroLink {
814
816
  this.toolRegistry = config?.toolRegistry || new MCPToolRegistry();
815
817
  this.fileRegistry = new FileReferenceRegistry();
816
818
  this.observabilityConfig = config?.observability;
819
+ this.analyticsService = new AnalyticsService();
817
820
  // Initialize orchestration setting
818
821
  this.enableOrchestration = config?.enableOrchestration ?? false;
819
822
  // NL-004: Initialize model alias configuration
@@ -2564,6 +2567,38 @@ Current user's request: ${currentInput}`;
2564
2567
  recordMetricsSpan(span) {
2565
2568
  this.metricsAggregator.recordSpan(span);
2566
2569
  }
2570
+ /**
2571
+ * Get provider metrics analysis
2572
+ * Retrieves aggregated performance, token usage, latency, and success rates per provider.
2573
+ *
2574
+ * @param options - Filtering options
2575
+ * @returns Comprehensive provider metrics result
2576
+ */
2577
+ async getProviderMetrics(options) {
2578
+ // Propagate unexpected failures — empty datasets are returned by the
2579
+ // service as valid zeroed results; fabricating those here hid real bugs.
2580
+ return this.analyticsService.getProviderMetrics(options);
2581
+ }
2582
+ /**
2583
+ * Get cost analysis breakdown
2584
+ * Analyzes AI generation costs across requested groups and provides future projections.
2585
+ *
2586
+ * @param options - Cost configuration options
2587
+ * @returns Detailed cost analysis breakdown
2588
+ */
2589
+ async getCostAnalysis(options) {
2590
+ return this.analyticsService.getCostAnalysis(options);
2591
+ }
2592
+ /**
2593
+ * Get team-wide usage analytics
2594
+ * Retrieves request counts, unique active users, provider breakdown, and quality scoring.
2595
+ *
2596
+ * @param options - Team query options
2597
+ * @returns Comprehensive team analytics report
2598
+ */
2599
+ async getTeamAnalytics(options) {
2600
+ return this.analyticsService.getTeamAnalytics(options);
2601
+ }
2567
2602
  /**
2568
2603
  * Record a memory operation span to both instance and global metrics aggregators.
2569
2604
  * This ensures memory spans are visible via sdk.getSpans() and getMetricsAggregator().getSpans().
@@ -2661,29 +2696,49 @@ Current user's request: ${currentInput}`;
2661
2696
  }
2662
2697
  }
2663
2698
  /**
2664
- * Initialize event listeners that feed span data to MetricsAggregator.
2665
- * Listens to generation:end, stream:complete, and tool:end events.
2699
+ * Fire-and-forget analytics tracking with rejection logging.
2666
2700
  */
2667
- initializeMetricsListeners() {
2668
- this.emitter.on("generation:end", ((...args) => {
2669
- const data = args[0];
2670
- // A2 fix: When Pipeline A (AI SDK @langfuse/otel) already creates a
2671
- // GENERATION observation, skip the Pipeline B span to avoid duplicates.
2672
- // Native providers (Bedrock, Ollama, Gemini 3) do NOT set this flag —
2673
- // Pipeline B remains their only observation source.
2674
- if (data.pipelineAHandled) {
2675
- return;
2676
- }
2677
- try {
2678
- const result = data.result;
2679
- const usage = result?.usage;
2680
- const analytics = result?.analytics;
2681
- const provider = data.provider ||
2682
- result?.provider ||
2683
- "unknown";
2684
- const model = result?.model || "unknown";
2685
- const responseTime = data.responseTime || 0;
2686
- const traceCtx = this._metricsTraceContext;
2701
+ enqueueAnalyticsTrackRequest(record) {
2702
+ this.analyticsService.trackRequest(record).catch((error) => {
2703
+ logger.debug("[NeuroLink] analytics trackRequest failed", {
2704
+ error: error instanceof Error ? error.message : String(error),
2705
+ });
2706
+ });
2707
+ }
2708
+ /**
2709
+ * Estimate cost using the canonical SDK pricing table (same source as
2710
+ * AnalyticsService.trackRequest) — avoids diverging TokenTracker rates.
2711
+ */
2712
+ estimateCostFromUsage(provider, model, usage) {
2713
+ if (!usage || model === "unknown") {
2714
+ return undefined;
2715
+ }
2716
+ const totalCost = calculateAdvancedCost(model, usage.input || 0, usage.output || 0, provider === "unknown" ? undefined : provider);
2717
+ return totalCost > 0 ? totalCost : undefined;
2718
+ }
2719
+ /**
2720
+ * Handle generation:end for Pipeline B spans + advanced analytics.
2721
+ * When pipelineAHandled is set, skips Pipeline B spans but still tracks
2722
+ * analytics so successful AI-SDK generate/stream calls are recorded.
2723
+ * Stream analytics are owned here (not stream:complete) to avoid duplicates.
2724
+ */
2725
+ handleGenerationEndMetrics(data) {
2726
+ const skipPipelineBSpan = data.pipelineAHandled === true;
2727
+ try {
2728
+ const result = data.result;
2729
+ const usage = result?.usage;
2730
+ const analytics = result?.analytics;
2731
+ const provider = data.provider || result?.provider || "unknown";
2732
+ const model = result?.model || data.model || "unknown";
2733
+ const responseTime = data.responseTime || 0;
2734
+ const eventTimestamp = typeof data.timestamp === "number" && Number.isFinite(data.timestamp)
2735
+ ? data.timestamp
2736
+ : Date.now();
2737
+ const traceCtx = this._metricsTraceContext;
2738
+ let computedCost = typeof analytics?.cost === "number" && Number.isFinite(analytics.cost)
2739
+ ? analytics.cost
2740
+ : undefined;
2741
+ if (!skipPipelineBSpan) {
2687
2742
  let span = SpanSerializer.createGenerationSpan({
2688
2743
  provider,
2689
2744
  model,
@@ -2693,17 +2748,9 @@ Current user's request: ${currentInput}`;
2693
2748
  temperature: data.temperature,
2694
2749
  maxTokens: data.maxTokens,
2695
2750
  });
2696
- // Link to the OTel parent span; each Pipeline B span keeps its own
2697
- // unique spanId to comply with OTel/W3C uniqueness requirements.
2698
2751
  if (traceCtx) {
2699
2752
  span.parentSpanId = traceCtx.parentSpanId;
2700
2753
  }
2701
- // Mark failed generations with ERROR status so metrics count them
2702
- // correctly. Client aborts (data.aborted === true) are NOT failures —
2703
- // they are user-initiated cancellations and must not pollute the
2704
- // failure rate. Map them to WARNING with the canonical
2705
- // "Generation aborted by client" message (matches the Langfuse
2706
- // ContextEnricher mapping for outer/internal generation spans).
2707
2754
  let spanStatus;
2708
2755
  let statusMessage;
2709
2756
  if (data.aborted === true) {
@@ -2719,7 +2766,6 @@ Current user's request: ${currentInput}`;
2719
2766
  }
2720
2767
  span = SpanSerializer.endSpan(span, spanStatus, statusMessage);
2721
2768
  span.durationMs = responseTime;
2722
- // G2 fix: Check finishReason and escalate to WARNING for partial failures
2723
2769
  const finishReason = result?.finishReason ??
2724
2770
  data.finishReason;
2725
2771
  if (finishReason) {
@@ -2728,7 +2774,6 @@ Current user's request: ${currentInput}`;
2728
2774
  span = SpanSerializer.endSpan(span, SpanStatus.WARNING, `Generation stopped: finishReason=${finishReason}`);
2729
2775
  }
2730
2776
  }
2731
- // G6 fix: Record retry count on Pipeline B span
2732
2777
  if (data.retryCount !== undefined) {
2733
2778
  span.attributes["gen_ai.retry_count"] = data.retryCount;
2734
2779
  }
@@ -2744,24 +2789,14 @@ Current user's request: ${currentInput}`;
2744
2789
  totalCost: analytics.cost,
2745
2790
  });
2746
2791
  }
2747
- else if (usage && model !== "unknown") {
2748
- // Fallback: compute cost from token usage + built-in pricing
2749
- const tokenTracker = this.metricsAggregator.getTokenTracker();
2750
- const pricing = tokenTracker.getModelPricing(model);
2751
- if (pricing) {
2752
- const inputCost = ((usage.input || 0) / 1_000_000) * pricing.inputPricePerMillion;
2753
- const outputCost = ((usage.output || 0) / 1_000_000) * pricing.outputPricePerMillion;
2754
- const totalCost = inputCost + outputCost;
2755
- if (totalCost > 0) {
2756
- span = SpanSerializer.enrichWithCost(span, {
2757
- inputCost,
2758
- outputCost,
2759
- totalCost,
2760
- });
2761
- }
2792
+ else {
2793
+ const estimated = this.estimateCostFromUsage(provider, model, usage);
2794
+ if (estimated !== undefined) {
2795
+ span = SpanSerializer.enrichWithCost(span, {
2796
+ totalCost: estimated,
2797
+ });
2762
2798
  }
2763
2799
  }
2764
- // Record output (truncated for safety)
2765
2800
  const content = result?.content || result?.text;
2766
2801
  if (content) {
2767
2802
  span = SpanSerializer.updateAttributes(span, {
@@ -2772,174 +2807,218 @@ Current user's request: ${currentInput}`;
2772
2807
  }
2773
2808
  this.metricsAggregator.recordSpan(span);
2774
2809
  getMetricsAggregator().recordSpan(span);
2810
+ const spanCost = span.attributes["ai.cost.total"];
2811
+ if (typeof spanCost === "number" && spanCost > 0) {
2812
+ computedCost = spanCost;
2813
+ }
2814
+ this.enqueueAnalyticsTrackRequest({
2815
+ provider,
2816
+ model,
2817
+ userId: data.userId || data.user || undefined,
2818
+ teamId: data.teamId || undefined,
2819
+ department: data.department || undefined,
2820
+ timestamp: eventTimestamp,
2821
+ latency: responseTime,
2822
+ inputTokens: usage?.input || 0,
2823
+ outputTokens: usage?.output || 0,
2824
+ totalTokens: usage?.total || (usage?.input || 0) + (usage?.output || 0),
2825
+ cost: computedCost,
2826
+ isError: span.status === SpanStatus.ERROR,
2827
+ errorMessage: statusMessage,
2828
+ qualityScore: parseAnalyticsQualityScore(data.qualityScore),
2829
+ });
2775
2830
  }
2776
- catch {
2777
- // Non-blocking
2778
- }
2779
- }));
2780
- this.emitter.on("stream:complete", ((...args) => {
2781
- const data = args[0];
2782
- try {
2783
- const metadata = data.metadata;
2784
- const durationMs = metadata?.durationMs || 0;
2785
- const chunkCount = metadata?.chunkCount || 0;
2786
- const totalLength = metadata?.totalLength || 0;
2787
- const provider = data.provider || "unknown";
2788
- const model = data.model || "unknown";
2789
- const traceCtx = this._metricsTraceContext;
2790
- let span = SpanSerializer.createGenerationSpan({
2831
+ else {
2832
+ // Pipeline A handled the observation — still record analytics so
2833
+ // successful AI-SDK generate()/stream() calls are not silently dropped.
2834
+ if (computedCost === undefined) {
2835
+ computedCost = this.estimateCostFromUsage(provider, model, usage);
2836
+ }
2837
+ this.enqueueAnalyticsTrackRequest({
2791
2838
  provider,
2792
2839
  model,
2793
- name: `gen_ai.${provider}.stream`,
2794
- traceId: traceCtx?.traceId,
2840
+ userId: data.userId || data.user || undefined,
2841
+ teamId: data.teamId || undefined,
2842
+ department: data.department || undefined,
2843
+ timestamp: eventTimestamp,
2844
+ latency: responseTime,
2845
+ inputTokens: usage?.input || 0,
2846
+ outputTokens: usage?.output || 0,
2847
+ totalTokens: usage?.total || (usage?.input || 0) + (usage?.output || 0),
2848
+ cost: computedCost,
2849
+ isError: data.success === false || Boolean(data.error),
2850
+ errorMessage: data.error ? String(data.error) : undefined,
2851
+ qualityScore: parseAnalyticsQualityScore(data.qualityScore),
2795
2852
  });
2796
- // Link to the OTel parent span; keep unique spanId per W3C spec.
2797
- if (traceCtx) {
2798
- span.parentSpanId = traceCtx.parentSpanId;
2853
+ }
2854
+ }
2855
+ catch {
2856
+ // Non-blocking
2857
+ }
2858
+ }
2859
+ /**
2860
+ * Pipeline B span recording for stream:complete.
2861
+ * Advanced analytics are recorded from generation:end only (avoids duplicates
2862
+ * when both stream:complete and generation:end fire for the same request).
2863
+ */
2864
+ handleStreamCompleteMetrics(data) {
2865
+ try {
2866
+ const metadata = data.metadata;
2867
+ const durationMs = metadata?.durationMs || 0;
2868
+ const chunkCount = metadata?.chunkCount || 0;
2869
+ const totalLength = metadata?.totalLength || 0;
2870
+ const provider = data.provider || "unknown";
2871
+ const model = data.model || "unknown";
2872
+ const traceCtx = this._metricsTraceContext;
2873
+ let span = SpanSerializer.createGenerationSpan({
2874
+ provider,
2875
+ model,
2876
+ name: `gen_ai.${provider}.stream`,
2877
+ traceId: traceCtx?.traceId,
2878
+ });
2879
+ if (traceCtx) {
2880
+ span.parentSpanId = traceCtx.parentSpanId;
2881
+ }
2882
+ span = SpanSerializer.endSpan(span, SpanStatus.OK);
2883
+ span.durationMs = durationMs;
2884
+ span.attributes["stream.chunk_count"] = chunkCount;
2885
+ span.attributes["stream.content_length"] = totalLength;
2886
+ const streamFinishReason = metadata?.finishReason ??
2887
+ data.finishReason;
2888
+ if (streamFinishReason) {
2889
+ span.attributes["gen_ai.finish_reason"] = streamFinishReason;
2890
+ if (streamFinishReason === "content-filter" ||
2891
+ streamFinishReason === "length") {
2892
+ span = SpanSerializer.endSpan(span, SpanStatus.WARNING, `Stream stopped: finishReason=${streamFinishReason}`);
2893
+ }
2894
+ }
2895
+ if (data.prompt) {
2896
+ const promptStr = String(data.prompt);
2897
+ span = SpanSerializer.updateAttributes(span, {
2898
+ input: promptStr.length > 5000
2899
+ ? promptStr.substring(0, 5000) + "...[truncated]"
2900
+ : promptStr,
2901
+ });
2902
+ }
2903
+ const streamContent = data.content;
2904
+ if (streamContent) {
2905
+ span = SpanSerializer.updateAttributes(span, {
2906
+ output: streamContent.length > 5000
2907
+ ? streamContent.substring(0, 5000) + "...[truncated]"
2908
+ : streamContent,
2909
+ });
2910
+ }
2911
+ const usage = metadata?.usage;
2912
+ if (usage) {
2913
+ span = SpanSerializer.enrichWithTokenUsage(span, {
2914
+ promptTokens: usage.input || 0,
2915
+ completionTokens: usage.output || 0,
2916
+ totalTokens: usage.total || (usage.input || 0) + (usage.output || 0),
2917
+ });
2918
+ const estimated = this.estimateCostFromUsage(provider, model, usage);
2919
+ if (estimated !== undefined) {
2920
+ span = SpanSerializer.enrichWithCost(span, { totalCost: estimated });
2799
2921
  }
2800
- span = SpanSerializer.endSpan(span, SpanStatus.OK);
2801
- span.durationMs = durationMs;
2802
- span.attributes["stream.chunk_count"] = chunkCount;
2803
- span.attributes["stream.content_length"] = totalLength;
2804
- // S3 fix: Record finishReason on Pipeline B stream span
2805
- const streamFinishReason = metadata?.finishReason ??
2806
- data.finishReason;
2807
- if (streamFinishReason) {
2808
- span.attributes["gen_ai.finish_reason"] = streamFinishReason;
2809
- if (streamFinishReason === "content-filter" ||
2810
- streamFinishReason === "length") {
2811
- span = SpanSerializer.endSpan(span, SpanStatus.WARNING, `Stream stopped: finishReason=${streamFinishReason}`);
2812
- }
2922
+ }
2923
+ this.metricsAggregator.recordSpan(span);
2924
+ getMetricsAggregator().recordSpan(span);
2925
+ }
2926
+ catch {
2927
+ // Non-blocking
2928
+ }
2929
+ }
2930
+ handleToolEndMetrics(data) {
2931
+ try {
2932
+ const toolName = data.toolName || data.tool || "unknown";
2933
+ const responseTime = data.responseTime || data.duration || 0;
2934
+ const success = data.success !== undefined ? data.success : !data.error;
2935
+ const traceCtx = this._metricsTraceContext;
2936
+ let span = SpanSerializer.createSpan(SpanType.TOOL_CALL, `tool.${toolName}`, {
2937
+ "tool.name": toolName,
2938
+ "tool.success": success,
2939
+ }, traceCtx?.parentSpanId, traceCtx?.traceId);
2940
+ span = SpanSerializer.endSpan(span, success ? SpanStatus.OK : SpanStatus.ERROR);
2941
+ span.durationMs = responseTime;
2942
+ if (!success) {
2943
+ if (data.error) {
2944
+ span.statusMessage = String(data.error);
2813
2945
  }
2814
- // Record stream input prompt
2815
- if (data.prompt) {
2816
- const promptStr = String(data.prompt);
2817
- span = SpanSerializer.updateAttributes(span, {
2818
- input: promptStr.length > 5000
2819
- ? promptStr.substring(0, 5000) + "...[truncated]"
2820
- : promptStr,
2821
- });
2946
+ else if (data.result) {
2947
+ span.statusMessage = extractMcpErrorText(data.result);
2822
2948
  }
2823
- // Record streamed output (truncated for safety)
2824
- const streamContent = data.content;
2825
- if (streamContent) {
2826
- span = SpanSerializer.updateAttributes(span, {
2827
- output: streamContent.length > 5000
2828
- ? streamContent.substring(0, 5000) + "...[truncated]"
2829
- : streamContent,
2830
- });
2949
+ }
2950
+ if (data.result) {
2951
+ try {
2952
+ span.attributes["tool.result"] = JSON.stringify(data.result).substring(0, 500);
2831
2953
  }
2832
- // Enrich stream span with token usage if available
2833
- const usage = metadata?.usage;
2834
- if (usage) {
2835
- span = SpanSerializer.enrichWithTokenUsage(span, {
2836
- promptTokens: usage.input || 0,
2837
- completionTokens: usage.output || 0,
2838
- totalTokens: usage.total || (usage.input || 0) + (usage.output || 0),
2839
- });
2840
- // Compute cost from token usage
2841
- if (model !== "unknown") {
2842
- const tokenTracker = this.metricsAggregator.getTokenTracker();
2843
- const pricing = tokenTracker.getModelPricing(model);
2844
- if (pricing) {
2845
- const inputCost = ((usage.input || 0) / 1_000_000) * pricing.inputPricePerMillion;
2846
- const outputCost = ((usage.output || 0) / 1_000_000) *
2847
- pricing.outputPricePerMillion;
2848
- const totalCost = inputCost + outputCost;
2849
- if (totalCost > 0) {
2850
- span = SpanSerializer.enrichWithCost(span, {
2851
- inputCost,
2852
- outputCost,
2853
- totalCost,
2854
- });
2855
- }
2856
- }
2857
- }
2954
+ catch {
2955
+ // Non-blocking
2858
2956
  }
2859
- this.metricsAggregator.recordSpan(span);
2860
- getMetricsAggregator().recordSpan(span);
2861
2957
  }
2862
- catch {
2863
- // Non-blocking
2958
+ this.metricsAggregator.recordSpan(span);
2959
+ getMetricsAggregator().recordSpan(span);
2960
+ }
2961
+ catch {
2962
+ // Non-blocking
2963
+ }
2964
+ }
2965
+ /**
2966
+ * Pipeline B span recording for stream:error.
2967
+ * Analytics for failed streams come from generation:end (success: false).
2968
+ */
2969
+ handleStreamErrorMetrics(data) {
2970
+ try {
2971
+ const metadata = data.metadata;
2972
+ const durationMs = metadata?.durationMs || 0;
2973
+ const chunkCount = metadata?.chunkCount || 0;
2974
+ const errorName = metadata?.errorName || "UnknownError";
2975
+ const errorMessage = data.content || "Stream error";
2976
+ const provider = data.provider || "unknown";
2977
+ const model = data.model || "unknown";
2978
+ const traceCtx = this._metricsTraceContext;
2979
+ let span = SpanSerializer.createGenerationSpan({
2980
+ provider,
2981
+ model,
2982
+ name: `gen_ai.${provider}.stream.error`,
2983
+ traceId: traceCtx?.traceId,
2984
+ });
2985
+ if (traceCtx) {
2986
+ span.parentSpanId = traceCtx.parentSpanId;
2864
2987
  }
2988
+ span = SpanSerializer.endSpan(span, SpanStatus.ERROR);
2989
+ span.durationMs = durationMs;
2990
+ span.statusMessage = `${errorName}: ${errorMessage}`;
2991
+ span.attributes["stream.chunk_count"] = chunkCount;
2992
+ const isAbort = errorName === "AbortError" ||
2993
+ errorMessage.toLowerCase().includes("aborted") ||
2994
+ errorMessage.toLowerCase().includes("abort");
2995
+ span.attributes["error.type"] = isAbort ? "abort" : errorName;
2996
+ if (isAbort) {
2997
+ span.attributes["stream.aborted"] = true;
2998
+ }
2999
+ this.metricsAggregator.recordSpan(span);
3000
+ getMetricsAggregator().recordSpan(span);
3001
+ }
3002
+ catch {
3003
+ // Non-blocking
3004
+ }
3005
+ }
3006
+ /**
3007
+ * Initialize event listeners that feed span data to MetricsAggregator.
3008
+ * Listens to generation:end, stream:complete, and tool:end events.
3009
+ */
3010
+ initializeMetricsListeners() {
3011
+ this.emitter.on("generation:end", ((...args) => {
3012
+ this.handleGenerationEndMetrics(args[0]);
3013
+ }));
3014
+ this.emitter.on("stream:complete", ((...args) => {
3015
+ this.handleStreamCompleteMetrics(args[0]);
2865
3016
  }));
2866
3017
  this.emitter.on("tool:end", ((...args) => {
2867
- const data = args[0];
2868
- try {
2869
- // Handle both event formats: {toolName} (from emitToolEnd) and {tool} (from executeToolInternal)
2870
- const toolName = data.toolName || data.tool || "unknown";
2871
- const responseTime = data.responseTime || data.duration || 0;
2872
- // success is explicit in one format; infer from error presence in the other
2873
- const success = data.success !== undefined ? data.success : !data.error;
2874
- const traceCtx = this._metricsTraceContext;
2875
- let span = SpanSerializer.createSpan(SpanType.TOOL_CALL, `tool.${toolName}`, {
2876
- "tool.name": toolName,
2877
- "tool.success": success,
2878
- }, traceCtx?.parentSpanId, traceCtx?.traceId);
2879
- span = SpanSerializer.endSpan(span, success ? SpanStatus.OK : SpanStatus.ERROR);
2880
- span.durationMs = responseTime;
2881
- if (!success) {
2882
- if (data.error) {
2883
- span.statusMessage = String(data.error);
2884
- }
2885
- else if (data.result) {
2886
- span.statusMessage = extractMcpErrorText(data.result);
2887
- }
2888
- }
2889
- if (data.result) {
2890
- try {
2891
- span.attributes["tool.result"] = JSON.stringify(data.result).substring(0, 500);
2892
- }
2893
- catch {
2894
- // Non-blocking
2895
- }
2896
- }
2897
- this.metricsAggregator.recordSpan(span);
2898
- getMetricsAggregator().recordSpan(span);
2899
- }
2900
- catch {
2901
- // Non-blocking
2902
- }
3018
+ this.handleToolEndMetrics(args[0]);
2903
3019
  }));
2904
3020
  this.emitter.on("stream:error", ((...args) => {
2905
- const data = args[0];
2906
- try {
2907
- const metadata = data.metadata;
2908
- const durationMs = metadata?.durationMs || 0;
2909
- const chunkCount = metadata?.chunkCount || 0;
2910
- const errorName = metadata?.errorName || "UnknownError";
2911
- const errorMessage = data.content || "Stream error";
2912
- const provider = data.provider || "unknown";
2913
- const model = data.model || "unknown";
2914
- const traceCtx = this._metricsTraceContext;
2915
- let span = SpanSerializer.createGenerationSpan({
2916
- provider,
2917
- model,
2918
- name: `gen_ai.${provider}.stream.error`,
2919
- traceId: traceCtx?.traceId,
2920
- });
2921
- // Link to the OTel parent span; keep unique spanId per W3C spec.
2922
- if (traceCtx) {
2923
- span.parentSpanId = traceCtx.parentSpanId;
2924
- }
2925
- span = SpanSerializer.endSpan(span, SpanStatus.ERROR);
2926
- span.durationMs = durationMs;
2927
- span.statusMessage = `${errorName}: ${errorMessage}`;
2928
- span.attributes["stream.chunk_count"] = chunkCount;
2929
- // S7 fix: Distinguish aborts from errors
2930
- const isAbort = errorName === "AbortError" ||
2931
- errorMessage.toLowerCase().includes("aborted") ||
2932
- errorMessage.toLowerCase().includes("abort");
2933
- span.attributes["error.type"] = isAbort ? "abort" : errorName;
2934
- if (isAbort) {
2935
- span.attributes["stream.aborted"] = true;
2936
- }
2937
- this.metricsAggregator.recordSpan(span);
2938
- getMetricsAggregator().recordSpan(span);
2939
- }
2940
- catch {
2941
- // Non-blocking
2942
- }
3021
+ this.handleStreamErrorMetrics(args[0]);
2943
3022
  }));
2944
3023
  }
2945
3024
  /**
@@ -8251,21 +8330,40 @@ Current user's request: ${currentInput}`;
8251
8330
  responseTimeMs: Date.now() - startTime,
8252
8331
  contentLength: fallbackAccumulatedContent.length,
8253
8332
  });
8254
- // S6 fix: Emit stream:complete after successful fallback so Pipeline B records it
8333
+ // S6 fix: Emit stream:complete after successful fallback so Pipeline B records it.
8334
+ // Also emit generation:end so advanced analytics tracks the successful fallback
8335
+ // (stream:complete no longer records analytics — avoids double-counting elsewhere).
8255
8336
  try {
8337
+ const fallbackModel = options.model || "unknown";
8338
+ const fallbackDuration = Date.now() - startTime;
8256
8339
  self.emitter.emit("stream:complete", {
8257
8340
  content: fallbackAccumulatedContent,
8258
8341
  provider: providerName,
8259
- model: options.model || "unknown",
8342
+ model: fallbackModel,
8260
8343
  finishReason: "stop",
8261
8344
  metadata: {
8262
- durationMs: Date.now() - startTime,
8345
+ durationMs: fallbackDuration,
8263
8346
  chunkCount: 0,
8264
8347
  totalLength: fallbackAccumulatedContent.length,
8265
8348
  isFallback: true,
8266
8349
  finishReason: "stop",
8267
8350
  },
8268
8351
  });
8352
+ self.emitter.emit("generation:end", {
8353
+ provider: providerName,
8354
+ model: fallbackModel,
8355
+ responseTime: fallbackDuration,
8356
+ timestamp: Date.now(),
8357
+ result: {
8358
+ content: fallbackAccumulatedContent,
8359
+ usage: { input: 0, output: 0, total: 0 },
8360
+ model: fallbackModel,
8361
+ provider: providerName,
8362
+ finishReason: "stop",
8363
+ },
8364
+ success: true,
8365
+ pipelineAHandled: true,
8366
+ });
8269
8367
  }
8270
8368
  catch {
8271
8369
  /* non-blocking */