@juspay/neurolink 12.59.0 → 13.0.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 (143) hide show
  1. package/CHANGELOG.md +13 -2
  2. package/dist/auth/anthropicOAuth.js +3 -3
  3. package/dist/auth/middleware/AuthMiddleware.d.ts +1 -1
  4. package/dist/auth/sessionManager.js +4 -4
  5. package/dist/auth/tokenStore.js +4 -4
  6. package/dist/browser/neurolink.min.js +474 -473
  7. package/dist/cli/commands/observability.d.ts +1 -6
  8. package/dist/cli/commands/observability.js +0 -106
  9. package/dist/cli/commands/proxy.js +9 -18
  10. package/dist/cli/commands/telemetry.d.ts +3 -13
  11. package/dist/cli/commands/telemetry.js +7 -354
  12. package/dist/cli/loop/session.js +1 -1
  13. package/dist/client/httpClient.js +1 -1
  14. package/dist/client/sseClient.js +1 -1
  15. package/dist/client/streamingClient.js +2 -2
  16. package/dist/client/wsClient.js +1 -1
  17. package/dist/constants/langfuse.d.ts +25 -0
  18. package/dist/constants/langfuse.js +38 -0
  19. package/dist/constants/telemetry.d.ts +20 -0
  20. package/dist/constants/telemetry.js +34 -0
  21. package/dist/core/baseProvider.d.ts +2 -1
  22. package/dist/core/baseProvider.js +4 -11
  23. package/dist/core/conversationMemoryManager.js +5 -8
  24. package/dist/core/factory.js +1 -1
  25. package/dist/core/geminiLoopAdapter.d.ts +8 -1
  26. package/dist/core/geminiLoopAdapter.js +25 -1
  27. package/dist/core/loopEngine.js +69 -40
  28. package/dist/core/modules/MessageBuilder.js +2 -2
  29. package/dist/core/modules/StreamHandler.js +2 -2
  30. package/dist/core/modules/TelemetryHandler.d.ts +2 -18
  31. package/dist/core/modules/TelemetryHandler.js +1 -66
  32. package/dist/core/modules/ToolsManager.js +40 -31
  33. package/dist/core/nativeGenerateLoop.js +78 -8
  34. package/dist/core/redisConversationMemoryManager.js +8 -15
  35. package/dist/core/toolExecutionGuards.js +1 -13
  36. package/dist/index.d.ts +5 -12
  37. package/dist/index.js +5 -37
  38. package/dist/mcp/batching/requestBatcher.js +1 -1
  39. package/dist/mcp/externalServerManager.js +3 -21
  40. package/dist/mcp/toolDiscoveryService.js +4 -16
  41. package/dist/mcp/toolRegistry.js +2 -5
  42. package/dist/memory/memoryRetrievalTools.js +1 -1
  43. package/dist/neurolink.d.ts +16 -27
  44. package/dist/neurolink.js +788 -792
  45. package/dist/observability/exporterSpanProcessor.d.ts +50 -0
  46. package/dist/observability/exporterSpanProcessor.js +181 -0
  47. package/dist/observability/exporters/langfuseExporter.d.ts +13 -34
  48. package/dist/observability/exporters/langfuseExporter.js +101 -154
  49. package/dist/observability/index.d.ts +1 -0
  50. package/dist/observability/index.js +1 -0
  51. package/dist/observability/langfuseMasking.d.ts +8 -0
  52. package/dist/observability/langfuseMasking.js +47 -0
  53. package/dist/observability/otlpEncoding.d.ts +20 -0
  54. package/dist/observability/otlpEncoding.js +85 -0
  55. package/dist/providers/amazonBedrock/client.js +142 -176
  56. package/dist/providers/amazonBedrock/loopAdapter.js +14 -1
  57. package/dist/providers/amazonSagemaker.js +118 -80
  58. package/dist/providers/anthropic/client.js +3 -52
  59. package/dist/providers/anthropic/loopAdapter.js +24 -1
  60. package/dist/providers/anthropic/rateLimitCapture.js +9 -12
  61. package/dist/providers/googleAiStudio/client.d.ts +4 -5
  62. package/dist/providers/googleAiStudio/client.js +38 -40
  63. package/dist/providers/googleVertex/client.d.ts +2 -7
  64. package/dist/providers/googleVertex/client.js +282 -378
  65. package/dist/providers/googleVertex/constants.js +2 -2
  66. package/dist/providers/litellm/client.d.ts +2 -9
  67. package/dist/providers/litellm/client.js +1 -63
  68. package/dist/providers/openAI/client.d.ts +1 -8
  69. package/dist/providers/openAI/client.js +0 -65
  70. package/dist/providers/openaiChatCompletionsBase.d.ts +1 -0
  71. package/dist/providers/openaiChatCompletionsBase.js +61 -4
  72. package/dist/providers/replicate.js +22 -4
  73. package/dist/proxy/codexUsage.js +7 -0
  74. package/dist/proxy/proxyFetch.js +19 -25
  75. package/dist/proxy/proxyTracer.d.ts +14 -7
  76. package/dist/proxy/proxyTracer.js +129 -83
  77. package/dist/proxy/proxyTranslationEngine.js +0 -1
  78. package/dist/proxy/requestLogger.d.ts +1 -1
  79. package/dist/proxy/requestLogger.js +39 -45
  80. package/dist/server/middleware/common.js +1 -1
  81. package/dist/server/routes/agentRoutes.js +4 -4
  82. package/dist/server/routes/claudeProxyRoutes.js +99 -196
  83. package/dist/server/routes/codexProxyRoutes.js +621 -591
  84. package/dist/server/routes/healthRoutes.js +6 -6
  85. package/dist/server/routes/mcpRoutes.js +7 -7
  86. package/dist/server/routes/memoryRoutes.js +7 -7
  87. package/dist/server/routes/openApiRoutes.js +3 -3
  88. package/dist/server/routes/toolRoutes.js +5 -5
  89. package/dist/server/routes/transcribeRoutes.js +1 -1
  90. package/dist/server/utils/validation.d.ts +3 -3
  91. package/dist/services/server/ai/observability/instrumentation.d.ts +34 -201
  92. package/dist/services/server/ai/observability/instrumentation.js +220 -1267
  93. package/dist/services/server/ai/observability/langfuseSetup.d.ts +14 -0
  94. package/dist/services/server/ai/observability/langfuseSetup.js +55 -0
  95. package/dist/services/server/ai/observability/otlpSetup.d.ts +16 -0
  96. package/dist/services/server/ai/observability/otlpSetup.js +58 -0
  97. package/dist/session/globalSessionState.js +0 -11
  98. package/dist/telemetry/attributes.d.ts +23 -46
  99. package/dist/telemetry/attributes.js +76 -162
  100. package/dist/telemetry/chatMessages.d.ts +13 -0
  101. package/dist/telemetry/chatMessages.js +59 -0
  102. package/dist/telemetry/index.d.ts +5 -19
  103. package/dist/telemetry/index.js +5 -26
  104. package/dist/telemetry/observation.d.ts +37 -0
  105. package/dist/telemetry/observation.js +112 -0
  106. package/dist/telemetry/observationNames.d.ts +18 -0
  107. package/dist/telemetry/observationNames.js +24 -0
  108. package/dist/telemetry/spanSanitizer.d.ts +39 -0
  109. package/dist/telemetry/spanSanitizer.js +92 -0
  110. package/dist/telemetry/spanSerialization.d.ts +13 -0
  111. package/dist/telemetry/spanSerialization.js +46 -0
  112. package/dist/telemetry/tracers.d.ts +37 -18
  113. package/dist/telemetry/tracers.js +97 -19
  114. package/dist/telemetry/withSpan.d.ts +47 -25
  115. package/dist/telemetry/withSpan.js +158 -71
  116. package/dist/types/cli.d.ts +0 -11
  117. package/dist/types/decision.d.ts +3 -0
  118. package/dist/types/exporter.d.ts +54 -0
  119. package/dist/types/exporter.js +0 -4
  120. package/dist/types/generate.d.ts +4 -1
  121. package/dist/types/isolatedAgent.d.ts +5 -4
  122. package/dist/types/loopEngine.d.ts +18 -28
  123. package/dist/types/observability.d.ts +127 -160
  124. package/dist/types/providers.d.ts +15 -0
  125. package/dist/types/proxy.d.ts +41 -2
  126. package/dist/utils/anthropicTraceSanitizer.d.ts +2 -2
  127. package/dist/utils/anthropicTraceSanitizer.js +3 -1
  128. package/dist/utils/conversationMemory.js +4 -4
  129. package/dist/utils/fileDetector.js +1 -1
  130. package/dist/utils/messageBuilder.js +1 -1
  131. package/dist/utils/noOutputSentinel.d.ts +4 -16
  132. package/dist/utils/noOutputSentinel.js +12 -28
  133. package/dist/utils/providerRetry.js +4 -4
  134. package/dist/voice/livekit/brain.js +1 -1
  135. package/dist/voice/livekit/realtimeMemory.d.ts +1 -1
  136. package/dist/voice/livekit/realtimeMemory.js +66 -1
  137. package/dist/voice/livekit/realtimeVoiceAgent.js +1 -1
  138. package/docs-site/static/search-index.json +41 -63
  139. package/package.json +5 -4
  140. package/dist/telemetry/telemetryService.d.ts +0 -79
  141. package/dist/telemetry/telemetryService.js +0 -572
  142. package/dist/utils/observabilityHelpers.d.ts +0 -27
  143. package/dist/utils/observabilityHelpers.js +0 -47
@@ -1,1332 +1,285 @@
1
- /**
2
- * OpenTelemetry Instrumentation for Langfuse v4
3
- *
4
- * Configures OpenTelemetry TracerProvider with LangfuseSpanProcessor to capture
5
- * traces from Vercel AI SDK's experimental_telemetry feature.
6
- *
7
- * Flow: Vercel AI SDK → OpenTelemetry Spans → LangfuseSpanProcessor → Langfuse Platform
8
- */
9
- import { metrics, SpanStatusCode, trace, } from "@opentelemetry/api";
10
- import { AsyncLocalStorage } from "async_hooks";
11
- import { extractMcpErrorText } from "../../../../utils/mcpErrorText.js";
1
+ import { propagateAttributes, setLangfuseTracerProvider, } from "@langfuse/tracing";
2
+ import { context, createContextKey, metrics, ProxyTracerProvider, trace, } from "@opentelemetry/api";
3
+ import { W3CTraceContextPropagator } from "@opentelemetry/core";
4
+ import { resourceFromAttributes } from "@opentelemetry/resources";
5
+ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
6
+ import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
7
+ import { OTEL_LOG_PREFIX, TRACE_METADATA_CONTEXT_KEY, } from "../../../../constants/telemetry.js";
8
+ import { getExporterRegistry } from "../../../../observability/exporterRegistry.js";
9
+ import { ExporterSpanProcessor } from "../../../../observability/exporterSpanProcessor.js";
10
+ import { LangfuseExporter } from "../../../../observability/exporters/langfuseExporter.js";
11
+ import { spanJsonAttribute } from "../../../../telemetry/attributes.js";
12
12
  import { logger } from "../../../../utils/logger.js";
13
- import { tryImport } from "../../../../utils/tryImport.js";
14
- import { LANGFUSE_ATTR } from "../../../../telemetry/attributes.js";
15
- const LOG_PREFIX = "[OpenTelemetry]";
16
- async function createOtelResource(config, serviceName) {
17
- const [{ resourceFromAttributes }, { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION },] = await Promise.all([
18
- import("@opentelemetry/resources"),
19
- import("@opentelemetry/semantic-conventions"),
20
- ]);
21
- return resourceFromAttributes({
22
- [ATTR_SERVICE_NAME]: serviceName,
23
- [ATTR_SERVICE_VERSION]: config.release || "v1.0.0",
24
- "deployment.environment": config.environment || "dev",
25
- });
26
- }
27
- /**
28
- * Configure OTLP metrics and logs with bounded serialization batches for
29
- * request-serving processes. Initialization awaits this so a caller may flush
30
- * or shut down immediately after it returns without racing providers that have
31
- * not been constructed yet. Signal-specific failures stay non-fatal here.
32
- */
33
- async function initializeOtlpMetricsAndLogs(resource, otlpEndpoint, serviceName) {
34
- if (!otlpEndpoint) {
35
- return;
36
- }
37
- try {
38
- const [{ OTLPMetricExporter }, { MeterProvider, PeriodicExportingMetricReader },] = await Promise.all([
39
- import("@opentelemetry/exporter-metrics-otlp-http"),
40
- import("@opentelemetry/sdk-metrics"),
41
- ]);
42
- const metricExporter = new OTLPMetricExporter({
43
- url: `${otlpEndpoint}/v1/metrics`,
44
- });
45
- const metricReader = new PeriodicExportingMetricReader({
46
- exporter: metricExporter,
47
- exportIntervalMillis: 15000,
48
- exportTimeoutMillis: 10000,
49
- });
50
- meterProvider = new MeterProvider({
51
- resource,
52
- readers: [metricReader],
53
- });
54
- metrics.setGlobalMeterProvider(meterProvider);
55
- logger.info(`${LOG_PREFIX} OTLP metric exporter added — MeterProvider registered globally`, {
56
- endpoint: `${otlpEndpoint}/v1/metrics`,
57
- exportIntervalMs: 15000,
58
- serviceName,
59
- meterProviderType: meterProvider.constructor.name,
60
- });
61
- }
62
- catch (metricsError) {
63
- logger.warn(`${LOG_PREFIX} Failed to create OTLP metric exporter (non-fatal)`, {
64
- error: metricsError instanceof Error
65
- ? metricsError.message
66
- : String(metricsError),
67
- endpoint: otlpEndpoint,
68
- });
69
- }
70
- try {
71
- const [{ OTLPLogExporter }, { BatchLogRecordProcessor, LoggerProvider }] = await Promise.all([
72
- import("@opentelemetry/exporter-logs-otlp-http"),
73
- import("@opentelemetry/sdk-logs"),
74
- ]);
75
- const logExporter = new OTLPLogExporter({
76
- url: `${otlpEndpoint}/v1/logs`,
77
- });
78
- const logProcessor = new BatchLogRecordProcessor(logExporter, {
79
- maxQueueSize: 2048,
80
- // Body logs contain up to 16 KB each. Bound exporter JSON work to
81
- // roughly 1 MB per batch instead of an 8 MB main-thread serialization.
82
- maxExportBatchSize: 64,
83
- scheduledDelayMillis: 2000,
84
- exportTimeoutMillis: 30000,
85
- });
86
- loggerProvider = new LoggerProvider({
87
- resource,
88
- processors: [logProcessor],
89
- });
90
- logger.info(`${LOG_PREFIX} OTLP log exporter added — LoggerProvider created`, {
91
- endpoint: `${otlpEndpoint}/v1/logs`,
92
- serviceName,
93
- });
94
- }
95
- catch (logsError) {
96
- logger.warn(`${LOG_PREFIX} Failed to create OTLP log exporter (non-fatal)`, {
97
- error: logsError instanceof Error ? logsError.message : String(logsError),
98
- endpoint: otlpEndpoint,
99
- });
100
- }
101
- }
102
- const contextStorage = new AsyncLocalStorage();
13
+ import { createLangfuseExporter, isLangfuseEnabled } from "./langfuseSetup.js";
14
+ import { createOtlpMeterProvider, createOtlpSpanProcessor, } from "./otlpSetup.js";
15
+ const MAX_PROPAGATED_CHARS = 200;
16
+ const LANGFUSE_EXPORTER_NAME = "langfuse";
17
+ const TRACE_METADATA_KEY = createContextKey(TRACE_METADATA_CONTEXT_KEY);
103
18
  let tracerProvider = null;
104
19
  let meterProvider = null;
105
- let loggerProvider = null;
106
- let langfuseProcessor = null;
107
- let isInitialized = false;
108
- let initializationPromise = null;
109
- let shutdownPromise = null;
110
- let isCredentialsValid = false;
111
- let currentConfig = null;
20
+ let langfuseExporter = null;
112
21
  let usingExternalProvider = false;
113
- let cachedContextEnricher = null;
114
- /**
115
- * Check if a real TracerProvider (not ProxyTracerProvider) is already registered
116
- *
117
- * IMPORTANT: This function checks the @opentelemetry/api global state as seen by THIS
118
- * module's bundled copy of @opentelemetry/api. If Neurolink is bundled with its own
119
- * copy of @opentelemetry/api (which is common in bundled libraries), this function
120
- * will NOT detect TracerProviders registered by the host application on their
121
- * @opentelemetry/api instance. Use `useExternalTracerProvider: true` or
122
- * `autoDetectExternalProvider: true` to explicitly signal external provider usage.
123
- *
124
- * @returns true if an external TracerProvider is detected in this module's OTEL instance
125
- */
126
- function _hasExternalTracerProvider() {
127
- try {
128
- const provider = trace.getTracerProvider();
129
- if (!provider) {
130
- return false;
131
- }
132
- // ProxyTracerProvider is the default "no-op" provider
133
- // Any other provider means someone else registered one
134
- const providerName = provider.constructor?.name || "";
135
- const isProxy = providerName === "ProxyTracerProvider" ||
136
- providerName === "NoopTracerProvider";
137
- if (!isProxy) {
138
- logger.debug(`${LOG_PREFIX} Detected external TracerProvider: ${providerName}`);
139
- }
140
- return !isProxy;
141
- }
142
- catch (error) {
143
- logger.warn(`${LOG_PREFIX} Error checking for external TracerProvider`, {
144
- error: error instanceof Error ? error.message : String(error),
145
- });
146
- return false;
147
- }
22
+ let started = false;
23
+ let initialized = false;
24
+ let ignoredConfigLogged = false;
25
+ let initializationError = null;
26
+ let terminated = false;
27
+ let terminationLogged = false;
28
+ let unconfiguredLogged = false;
29
+ let holders = 0;
30
+ function hasHostTracerProvider() {
31
+ const global = trace.getTracerProvider();
32
+ if (!(global instanceof ProxyTracerProvider)) {
33
+ return true;
34
+ }
35
+ return global.getDelegateTracer("ai") !== undefined;
148
36
  }
149
- /**
150
- * Parse `ai.toolCall.result` on a Vercel AI SDK tool span and surface any
151
- * embedded MCP `{ isError: true }` as a Langfuse ERROR + status message.
152
- */
153
- function applyToolCallIsErrorStatus(attrs) {
154
- const resultAttr = attrs["ai.toolCall.result"];
155
- if (typeof resultAttr !== "string" || resultAttr.length === 0) {
156
- return;
37
+ function registerLangfuseExporter(config) {
38
+ const registry = getExporterRegistry();
39
+ const existing = registry.get(LANGFUSE_EXPORTER_NAME);
40
+ if (existing) {
41
+ return existing instanceof LangfuseExporter ? existing : null;
157
42
  }
158
- let parsed;
159
- try {
160
- parsed = JSON.parse(resultAttr);
161
- }
162
- catch {
163
- return;
43
+ const exporter = createLangfuseExporter(config);
44
+ if (exporter) {
45
+ registry.register(exporter);
164
46
  }
165
- if (!parsed ||
166
- typeof parsed !== "object" ||
167
- parsed.isError !== true) {
168
- return;
169
- }
170
- attrs["langfuse.level"] = "ERROR";
171
- // Always set a status_message, even when the MCP payload has non-text or
172
- // empty content. Without a fallback the Curator P0-1 gap reappears for
173
- // those failures (level=ERROR but statusMessage=null).
174
- const errorText = extractMcpErrorText(parsed);
175
- const toolName = typeof attrs["ai.toolCall.name"] === "string"
176
- ? attrs["ai.toolCall.name"]
177
- : "tool";
178
- attrs["langfuse.status_message"] =
179
- errorText || `MCP ${toolName} returned isError=true`;
47
+ return exporter;
180
48
  }
181
- /**
182
- * Map non-ERROR span conditions (content-filter, length, client abort, SDK
183
- * timeout, empty output) onto Langfuse WARNING/ERROR levels. Mutates `attrs`.
184
- */
185
- function applyNonErrorLangfuseLevel(attrs) {
186
- const finishReason = attrs["ai.finishReason"] ?? attrs["gen_ai.response.finish_reasons"];
187
- const reasonStr = Array.isArray(finishReason)
188
- ? finishReason.join(",")
189
- : String(finishReason ?? "");
190
- if (reasonStr.includes("content-filter") || reasonStr === "length") {
191
- attrs["langfuse.level"] = "WARNING";
192
- attrs["langfuse.status_message"] =
193
- `Generation stopped: finishReason=${reasonStr}`;
194
- return;
195
- }
196
- if (attrs["neurolink.no_output"] === true) {
197
- attrs["langfuse.level"] = "WARNING";
198
- // Preserve any enriched status message StreamHandler already set
199
- // (carries finishReason / token counts via buildNoOutputStatusMessage).
200
- // Only fall back to the generic message when none was set upstream.
201
- if (typeof attrs["langfuse.status_message"] !== "string") {
202
- attrs["langfuse.status_message"] =
203
- "Stream produced no output (NoOutputGeneratedError)";
204
- }
205
- return;
206
- }
207
- if (reasonStr === "aborted") {
208
- attrs["langfuse.level"] = "WARNING";
209
- attrs["langfuse.status_message"] = "Generation aborted by client";
210
- }
211
- }
212
- /**
213
- * Span processor that enriches spans with user and session context from AsyncLocalStorage
214
- * Also extracts GenAI semantic convention attributes for Langfuse integration
215
- *
216
- * Key features:
217
- * - Enriches spans with userId, sessionId, conversationId, requestId
218
- * - Auto-detects operation names from Vercel AI SDK span names
219
- * - Builds formatted trace names for Langfuse (e.g., "user@email.com:ai.streamText")
220
- * - Supports custom trace name formats via configuration
221
- * - Handles wrapper spans by detecting operations from child spans and updating trace name in onEnd()
222
- */
223
- class ContextEnricher {
224
- /**
225
- * Maximum number of detected operations to track to prevent memory leaks.
226
- * Once this limit is reached, oldest entries are evicted (FIFO).
227
- */
228
- static MAX_DETECTED_OPERATIONS = 10000;
229
- /**
230
- * Track detected operations per trace for wrapper span support.
231
- * When a host app creates a wrapper span before AI operations, the wrapper's
232
- * onStart() runs before the AI SDK child span exists. We store detected
233
- * operations here so we can update the trace name in onEnd().
234
- */
235
- detectedOperations = new Map();
236
- onStart(span, parentContext) {
237
- const context = contextStorage.getStore();
238
- const userId = context?.userId ?? currentConfig?.userId ?? "guest";
239
- const sessionId = context?.sessionId ?? currentConfig?.sessionId;
240
- // Get span name for operation auto-detection
241
- // (sdk-trace-base's Span type includes the ReadableSpan members)
242
- const spanName = span.name;
243
- // Determine if auto-detection is enabled for this context
244
- const autoDetect = this.shouldAutoDetectOperationName(context);
245
- // Resolve operation name: explicit > auto-detected > undefined
246
- const operationName = this.resolveOperationName(context?.operationName, spanName, autoDetect);
247
- // Store detected AI operations for wrapper span support (optional, defensive).
248
- // When a host app creates a wrapper span before calling AI operations,
249
- // this allows us to update the trace name in onEnd() with the operation.
250
- // Only store the first detected operation for each trace (subsequent operations are ignored).
251
- try {
252
- if (operationName && spanName?.startsWith("ai.")) {
253
- const traceId = span.spanContext?.()?.traceId;
254
- if (traceId && !this.detectedOperations.has(traceId)) {
255
- // Evict oldest entry if at capacity to prevent memory leak
256
- if (this.detectedOperations.size >=
257
- ContextEnricher.MAX_DETECTED_OPERATIONS) {
258
- const firstKey = this.detectedOperations.keys().next().value;
259
- if (firstKey) {
260
- this.detectedOperations.delete(firstKey);
261
- }
262
- }
263
- this.detectedOperations.set(traceId, operationName);
264
- }
265
- }
266
- }
267
- catch {
268
- // Wrapper span support is optional - don't fail if spanContext isn't available
269
- }
270
- // Build trace name based on priority:
271
- // 1. Explicit traceName (100% backward compatible)
272
- // 2. Formatted name with userId + operationName
273
- // 3. userId only (legacy fallback)
274
- const traceName = this.buildTraceName(context?.traceName, userId, operationName);
275
- // Apply custom attributes FIRST so internal attributes always take precedence
276
- // and cannot be accidentally overwritten by user-provided values
277
- if (context?.customAttributes) {
278
- for (const [key, value] of Object.entries(context.customAttributes)) {
279
- span.setAttribute(key, value);
280
- }
281
- }
282
- // Set user and session attributes (internal - always override custom)
283
- if (userId && userId !== "guest") {
284
- span.setAttribute("user.id", userId);
285
- }
286
- if (sessionId) {
287
- span.setAttribute("session.id", sessionId);
288
- }
289
- // Add extended context fields
290
- if (context?.conversationId) {
291
- span.setAttribute("conversation.id", context.conversationId);
292
- }
293
- if (context?.requestId) {
294
- span.setAttribute("request.id", context.requestId);
295
- }
296
- const isRootSpan = !trace.getSpan(parentContext);
297
- if (traceName && isRootSpan) {
298
- span.setAttribute("langfuse.trace.name", traceName);
299
- span.setAttribute("trace.name", traceName);
300
- }
301
- if (isRootSpan && context?.tags && context.tags.length > 0) {
302
- span.setAttribute("langfuse.trace.tags", [...context.tags]);
303
- }
304
- // Set operation name as separate attribute for filtering/analytics
305
- if (operationName) {
306
- span.setAttribute("gen_ai.operation.name", operationName);
307
- }
308
- // Add custom metadata as span attributes
309
- const metadata = context?.metadata;
310
- if (metadata && typeof metadata === "object") {
311
- for (const [key, value] of Object.entries(metadata)) {
312
- if (value !== undefined && value !== null) {
313
- // Preserve primitive types that OTEL supports natively
314
- if (typeof value === "string" ||
315
- typeof value === "number" ||
316
- typeof value === "boolean") {
317
- if (metadata && isRootSpan) {
318
- span.setAttribute("langfuse.trace.metadata", JSON.stringify(metadata));
319
- }
320
- }
321
- else if (Array.isArray(value) &&
322
- value.every((v) => typeof v === "string" ||
323
- typeof v === "number" ||
324
- typeof v === "boolean")) {
325
- // OTEL supports homogeneous arrays of primitives
326
- span.setAttribute(`metadata.${key}`, JSON.stringify(value));
327
- }
328
- else {
329
- // Fall back to JSON string for complex types
330
- span.setAttribute(`metadata.${key}`, JSON.stringify(value));
331
- }
332
- }
333
- }
334
- }
335
- }
336
- /**
337
- * Determine if auto-detection should be used for operation names
338
- */
339
- shouldAutoDetectOperationName(context) {
340
- // Context-level override takes precedence
341
- if (context?.autoDetectOperationName !== undefined) {
342
- return context.autoDetectOperationName;
343
- }
344
- // Fall back to global config (default: true)
345
- return currentConfig?.autoDetectOperationName !== false;
346
- }
347
- /**
348
- * Resolve operation name from explicit setting, auto-detection, or undefined
349
- */
350
- resolveOperationName(explicit, spanName, autoDetect) {
351
- // Explicit operation name takes precedence
352
- if (explicit) {
353
- return explicit;
354
- }
355
- // Auto-detect from span name if enabled
356
- if (autoDetect && spanName) {
357
- // Detect Vercel AI SDK operation spans (ai.streamText, ai.generateText, etc.)
358
- if (spanName.startsWith("ai.")) {
359
- return spanName;
360
- }
361
- // Detect OpenTelemetry GenAI convention spans (chat, embeddings, text_completion)
362
- if (spanName === "chat" ||
363
- spanName === "embeddings" ||
364
- spanName === "text_completion") {
365
- return spanName;
366
- }
49
+ function initialize(config) {
50
+ const langfuse = isLangfuseEnabled(config);
51
+ const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
52
+ if (!langfuse && !otlpEndpoint) {
53
+ if (!unconfiguredLogged) {
54
+ unconfiguredLogged = true;
55
+ logger.debug(`${OTEL_LOG_PREFIX} Langfuse and OTLP not configured; skipping`);
367
56
  }
368
- return undefined;
57
+ return config?.langfuse === false;
369
58
  }
370
- /**
371
- * Build trace name based on format configuration and available data
372
- */
373
- buildTraceName(explicitTraceName, userId, operationName) {
374
- // 1. Explicit traceName always wins (100% backward compatibility)
375
- if (explicitTraceName) {
376
- return explicitTraceName;
377
- }
378
- // 2. Build formatted trace name based on config
379
- const format = currentConfig?.traceNameFormat ?? "userId:operationName";
380
- // Handle custom function format
381
- if (typeof format === "function") {
382
- return format({ userId, operationName });
59
+ if (hasHostTracerProvider()) {
60
+ usingExternalProvider = true;
61
+ initialized = true;
62
+ langfuseExporter = langfuse ? registerLangfuseExporter(config) : null;
63
+ if (langfuseExporter) {
64
+ logger.info(`${OTEL_LOG_PREFIX} Host TracerProvider detected; Langfuse exporter registered. Add \`createExporterSpanProcessor()\` to the host provider to export NeuroLink spans to it.`);
383
65
  }
384
- // Handle predefined string formats
385
- switch (format) {
386
- case "userId:operationName":
387
- return operationName ? `${userId}:${operationName}` : userId;
388
- case "operationName:userId":
389
- return operationName ? `${operationName}:${userId}` : userId;
390
- case "operationName":
391
- return operationName || userId;
392
- case "userId":
393
- default:
394
- return userId;
66
+ else {
67
+ logger.info(`${OTEL_LOG_PREFIX} Host TracerProvider detected; NeuroLink registers no provider and no exporter.`);
395
68
  }
69
+ return true;
396
70
  }
397
- /**
398
- * Called when span ends - extracts GenAI semantic convention attributes
399
- * from Vercel AI SDK spans and enriches them for Langfuse.
400
- *
401
- * Also handles wrapper span support: when a host app creates a wrapper/trace-root
402
- * span before AI operations, we update the trace name here with the detected operation.
403
- */
404
- onEnd(span) {
405
- try {
406
- // Get span attributes (sdk-trace-base's Span type includes the
407
- // ReadableSpan members, so attributes/name/status are directly typed).
408
- // Keep the {} fallback: this processor can be attached to an external
409
- // app-owned TracerProvider whose span implementation may omit attributes.
410
- const attributes = span.attributes ?? {};
411
- // Handle wrapper/trace-root spans: update trace name with detected operation
412
- // This supports host apps (like Curator) that create wrapper spans before AI calls
413
- // This is optional - if spanContext fails, we skip wrapper span support
414
- try {
415
- const traceId = span.spanContext?.()?.traceId;
416
- if (traceId) {
417
- const isTraceRoot = attributes["langfuse.span.type"] === "trace-root";
418
- const detectedOp = this.detectedOperations.get(traceId);
419
- if (isTraceRoot && detectedOp) {
420
- const context = contextStorage.getStore();
421
- const userId = attributes["user.id"] ||
422
- context?.userId ||
423
- currentConfig?.userId ||
424
- "guest";
425
- // Only update if there's no explicit traceName set
426
- const existingTraceName = attributes["langfuse.trace.name"];
427
- const hasExplicitTraceName = context?.traceName ||
428
- (existingTraceName && existingTraceName !== userId);
429
- if (!hasExplicitTraceName) {
430
- const newTraceName = this.buildTraceName(null, userId, detectedOp);
431
- // Update the trace name attribute
432
- span.setAttribute("langfuse.trace.name", newTraceName);
433
- span.setAttribute("trace.name", newTraceName);
434
- span.setAttribute("gen_ai.operation.name", detectedOp);
435
- logger.debug(`${LOG_PREFIX} Updated trace-root span with detected operation`, {
436
- traceId,
437
- operation: detectedOp,
438
- newTraceName,
439
- });
440
- }
441
- // Cleanup the detected operation
442
- this.detectedOperations.delete(traceId);
443
- }
444
- }
445
- }
446
- catch {
447
- // Wrapper span support is optional - don't fail if spanContext isn't available
448
- }
449
- // Check if this is a GenAI span (from Vercel AI SDK)
450
- const isGenAISpan = attributes["gen_ai.system"] ||
451
- attributes["ai.model.id"] ||
452
- attributes["gen_ai.request.model"];
453
- if (isGenAISpan) {
454
- const model = attributes["gen_ai.request.model"] ||
455
- attributes["ai.model.id"];
456
- const provider = attributes["gen_ai.system"] ||
457
- attributes["ai.model.provider"];
458
- logger.debug(`${LOG_PREFIX} GenAI span detected`, {
459
- spanName: span.name,
460
- model,
461
- provider,
462
- });
463
- // L4/L6 fix: Set explicit Langfuse observation attributes so
464
- // cost dashboards and model analytics work correctly.
465
- try {
466
- const mAttrs = span.attributes;
467
- // L6: Model identity
468
- if (model) {
469
- mAttrs["gen_ai.response.model"] = model;
470
- }
471
- // L4: Usage details — aggregate from AI SDK attributes into a
472
- // structured JSON object that Langfuse can parse for cost analysis.
473
- const inputTokens = attributes["gen_ai.usage.input_tokens"] ??
474
- attributes["ai.usage.promptTokens"];
475
- const outputTokens = attributes["gen_ai.usage.output_tokens"] ??
476
- attributes["ai.usage.completionTokens"];
477
- const totalTokens = attributes["gen_ai.usage.total_tokens"] ??
478
- (inputTokens !== undefined && outputTokens !== undefined
479
- ? inputTokens + outputTokens
480
- : undefined);
481
- const reasoningTokens = attributes["gen_ai.usage.reasoning_tokens"] ??
482
- attributes["ai.usage.reasoningTokens"];
483
- const cachedTokens = attributes["gen_ai.usage.input_cached_tokens"];
484
- if (inputTokens !== undefined || outputTokens !== undefined) {
485
- const usageDetails = {};
486
- if (inputTokens !== undefined) {
487
- usageDetails.input = inputTokens;
488
- }
489
- if (outputTokens !== undefined) {
490
- usageDetails.output = outputTokens;
491
- }
492
- if (totalTokens !== undefined) {
493
- usageDetails.total = totalTokens;
494
- }
495
- if (reasoningTokens !== undefined) {
496
- usageDetails.reasoning_tokens = reasoningTokens;
497
- }
498
- if (cachedTokens !== undefined) {
499
- usageDetails.input_cached_tokens = cachedTokens;
500
- }
501
- mAttrs["langfuse.usage_details"] = JSON.stringify(usageDetails);
502
- logger.debug(`${LOG_PREFIX} Token usage captured`, {
503
- inputTokens,
504
- outputTokens,
505
- totalTokens,
506
- });
507
- }
508
- // L7: Model parameters — surface temperature and max_tokens for
509
- // generation tuning visibility.
510
- const temperature = attributes["gen_ai.request.temperature"] ??
511
- attributes["ai.settings.temperature"];
512
- const maxTokens = attributes["gen_ai.request.max_tokens"] ??
513
- attributes["ai.settings.maxTokens"];
514
- const topP = attributes["gen_ai.request.top_p"] ??
515
- attributes["ai.settings.topP"];
516
- if (temperature !== undefined ||
517
- maxTokens !== undefined ||
518
- topP !== undefined) {
519
- const params = {};
520
- if (temperature !== undefined) {
521
- params.temperature = temperature;
522
- }
523
- if (maxTokens !== undefined) {
524
- params.max_tokens = maxTokens;
525
- }
526
- if (topP !== undefined) {
527
- params.top_p = topP;
528
- }
529
- mAttrs["gen_ai.request.model_parameters"] = JSON.stringify(params);
530
- }
531
- }
532
- catch {
533
- // Read-only attributes — cannot enrich; Pipeline A will still
534
- // export the raw GenAI attributes that Langfuse can parse.
535
- }
536
- }
537
- // P8 fix: Propagate error status to Langfuse-consumable attributes.
538
- // OTel ReadableSpan attributes may be readonly at onEnd() time; the type
539
- // cast attempts late mutation. LangfuseSpanProcessor runs after
540
- // ContextEnricher in the spanProcessors array and reads these attributes,
541
- // so setting them here allows Langfuse to surface the correct level and
542
- // status message on the trace/generation.
543
- const readableStatus = span.status;
544
- try {
545
- const mutableAttrs = span.attributes;
546
- // Curator P0-1/P0-2: detect MCP isError pattern on AI SDK tool call spans.
547
- // The AI SDK's `ai.toolCall` span stays status=UNSET when the tool
548
- // *returns* { isError:true } (no exception thrown), so Langfuse sees
549
- // level=DEFAULT and no status message. Parse the stringified result
550
- // and surface the embedded error text.
551
- if (span.name === "ai.toolCall" &&
552
- readableStatus?.code !== SpanStatusCode.ERROR) {
553
- applyToolCallIsErrorStatus(mutableAttrs);
554
- }
555
- if (readableStatus?.code === SpanStatusCode.ERROR) {
556
- mutableAttrs["langfuse.level"] = "ERROR";
557
- if (readableStatus.message) {
558
- mutableAttrs["langfuse.status_message"] = readableStatus.message;
559
- }
560
- }
561
- else if (mutableAttrs["langfuse.level"] === undefined) {
562
- applyNonErrorLangfuseLevel(mutableAttrs);
563
- }
564
- }
565
- catch {
566
- // Readonly enforcement by OTel SDK — mutation not possible; log at debug.
567
- logger.debug(`${LOG_PREFIX} Could not set langfuse.level on span (read-only attributes)`);
568
- }
569
- }
570
- catch (error) {
571
- // Don't fail span processing on errors
572
- logger.debug(`${LOG_PREFIX} Error reading span attributes`, {
573
- error: error instanceof Error ? error.message : String(error),
71
+ const resource = resourceFromAttributes({
72
+ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || "ai-service",
73
+ });
74
+ const exporter = langfuse ? registerLangfuseExporter(config) : null;
75
+ const otlpProcessor = otlpEndpoint
76
+ ? createOtlpSpanProcessor(otlpEndpoint)
77
+ : null;
78
+ const spanProcessors = [
79
+ new ExporterSpanProcessor(getExporterRegistry()),
80
+ ];
81
+ if (otlpProcessor) {
82
+ spanProcessors.push(otlpProcessor);
83
+ }
84
+ const provider = new NodeTracerProvider({ resource, spanProcessors });
85
+ provider.register({ propagator: new W3CTraceContextPropagator() });
86
+ setLangfuseTracerProvider(provider);
87
+ tracerProvider = provider;
88
+ langfuseExporter = exporter;
89
+ meterProvider = otlpEndpoint
90
+ ? createOtlpMeterProvider(otlpEndpoint, resource)
91
+ : null;
92
+ usingExternalProvider = false;
93
+ initialized = true;
94
+ logger.info(`${OTEL_LOG_PREFIX} Observability initialized`, {
95
+ langfuseEnabled: exporter !== null,
96
+ otlpEnabled: otlpProcessor !== null,
97
+ });
98
+ return true;
99
+ }
100
+ async function stop() {
101
+ const provider = tracerProvider;
102
+ const meters = meterProvider;
103
+ const hostExporter = usingExternalProvider ? langfuseExporter : null;
104
+ terminated = true;
105
+ tracerProvider = null;
106
+ meterProvider = null;
107
+ langfuseExporter = null;
108
+ usingExternalProvider = false;
109
+ started = false;
110
+ initialized = false;
111
+ initializationError = null;
112
+ if (provider) {
113
+ setLangfuseTracerProvider(null);
114
+ }
115
+ if (meters) {
116
+ metrics.disable();
117
+ }
118
+ if (hostExporter) {
119
+ getExporterRegistry().unregister(hostExporter.getName());
120
+ }
121
+ const results = await Promise.allSettled([
122
+ provider?.shutdown(),
123
+ hostExporter?.shutdown(),
124
+ meters?.shutdown(),
125
+ ]);
126
+ for (const result of results) {
127
+ if (result.status === "rejected") {
128
+ logger.error(`${OTEL_LOG_PREFIX} Shutdown failed`, {
129
+ error: result.reason instanceof Error
130
+ ? result.reason.message
131
+ : String(result.reason),
574
132
  });
575
133
  }
576
134
  }
577
- shutdown() {
578
- // Clean up tracked operations to prevent memory leaks
579
- this.detectedOperations.clear();
580
- return Promise.resolve();
581
- }
582
- forceFlush() {
583
- return Promise.resolve();
584
- }
585
- }
586
- async function createLangfuseProcessor(config) {
587
- const mod = await tryImport("@langfuse/otel", "Langfuse observability");
588
- return new mod.LangfuseSpanProcessor({
589
- publicKey: config.publicKey,
590
- secretKey: config.secretKey,
591
- baseUrl: config.baseUrl || "https://cloud.langfuse.com",
592
- environment: config.environment || "dev",
593
- release: config.release || "v1.0.0",
594
- // Curator P1-3: skip internal wrapper spans that duplicate ai.toolCall /
595
- // ai.generateText observations in Langfuse. Wrappers still emit OTel spans
596
- // for internal metrics; they just aren't forwarded to Langfuse.
597
- shouldExportSpan: langfuseShouldExportSpan,
598
- });
599
- }
600
- /**
601
- * True when a span is an internal NeuroLink wrapper that should NOT be sent to
602
- * Langfuse. Internal wrappers carry the `langfuse.internal: true` attribute.
603
- *
604
- * Exposed so host apps that bring their own `LangfuseSpanProcessor` (e.g.
605
- * `skipLangfuseSpanProcessor: true`, or manual registration on an existing
606
- * TracerProvider) can apply the same filter and avoid duplicate observations.
607
- */
608
- export function isLangfuseInternalSpan(span) {
609
- return span.attributes?.["langfuse.internal"] === true;
610
135
  }
611
136
  /**
612
- * Drop-in `shouldExportSpan` predicate for a `LangfuseSpanProcessor` that
613
- * filters out NeuroLink internal wrapper spans.
614
- *
615
- * Usage in host apps:
616
- * ```ts
617
- * import { langfuseShouldExportSpan } from "@juspay/neurolink";
618
- * new LangfuseSpanProcessor({ ..., shouldExportSpan: langfuseShouldExportSpan });
619
- * ```
137
+ * @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.
138
+ * @param config {ObservabilityConfig | undefined} - Observability options, honoured only when this is the first call.
139
+ * @returns {void} Nothing; a failed setup is logged and recorded in `getLangfuseHealthStatus`.
620
140
  */
621
- export function langfuseShouldExportSpan({ otelSpan, }) {
622
- return !isLangfuseInternalSpan(otelSpan);
623
- }
624
- async function initializeExternalOpenTelemetryMode(config, resource, otlpEndpoint, serviceName, langfuseRequested, hasLangfuseCreds) {
625
- if (langfuseRequested && !hasLangfuseCreds) {
626
- if (!otlpEndpoint) {
627
- logger.warn(`${LOG_PREFIX} External provider mode requested Langfuse but credentials are missing, and no OTLP endpoint is configured; skipping initialization`, {
628
- hasPublicKey: !!config?.publicKey,
629
- hasSecretKey: !!config?.secretKey,
630
- });
631
- isInitialized = true;
632
- isCredentialsValid = false;
633
- return;
634
- }
635
- logger.warn(`${LOG_PREFIX} External provider mode missing Langfuse credentials; continuing with OTLP-only metrics/logs`, {
636
- hasPublicKey: !!config?.publicKey,
637
- hasSecretKey: !!config?.secretKey,
638
- otlpEnabled: true,
639
- });
640
- }
641
- try {
642
- currentConfig = config;
643
- isCredentialsValid = hasLangfuseCreds;
644
- langfuseProcessor =
645
- langfuseRequested && hasLangfuseCreds
646
- ? await createLangfuseProcessor(config)
647
- : null;
648
- usingExternalProvider = true;
649
- await initializeOtlpMetricsAndLogs(resource, otlpEndpoint, serviceName);
650
- isInitialized = true;
651
- try {
652
- const globalProvider = trace.getTracerProvider();
653
- const provider = globalProvider;
654
- if (globalProvider && typeof provider.addSpanProcessor === "function") {
655
- provider.addSpanProcessor(new ContextEnricher());
656
- // Auto-detect: skip if consumer already registered a LangfuseSpanProcessor.
657
- //
658
- // Detection strategy (ordered by robustness):
659
- // 1. Duck-type check for Langfuse-specific public member
660
- // (`langfuseClient` property) — survives minification.
661
- // 2. `constructor.name === "LangfuseSpanProcessor"` — last resort,
662
- // brittle under minification or bundler renaming.
663
- //
664
- // NOTE: `_registeredSpanProcessors` is an internal OpenTelemetry field.
665
- // If the OTel SDK removes or renames it, the array defaults to [] and
666
- // `hasExistingLangfuse` is false — NeuroLink registers its own processor
667
- // (same behavior as before this check). Consumers can always force skip
668
- // via `skipLangfuseSpanProcessor: true`.
669
- const existingProcessors = provider
670
- ._registeredSpanProcessors ?? [];
671
- const hasExistingLangfuse = existingProcessors.some((p) => {
672
- if (p === null || p === undefined || typeof p !== "object") {
673
- return false;
674
- }
675
- // Duck-type: Langfuse processor exposes a langfuseClient property
676
- if ("langfuseClient" in p) {
677
- return true;
678
- }
679
- // Fallback: constructor name (brittle under minification)
680
- return (p.constructor?.name ===
681
- "LangfuseSpanProcessor");
682
- });
683
- const skipLangfuse = config.skipLangfuseSpanProcessor === true ||
684
- !langfuseProcessor ||
685
- hasExistingLangfuse;
686
- if (hasExistingLangfuse && !config.skipLangfuseSpanProcessor) {
687
- logger.info(`${LOG_PREFIX} Auto-detected existing LangfuseSpanProcessor — skipping SDK registration to avoid duplicates`);
688
- }
689
- if (!skipLangfuse && langfuseProcessor) {
690
- provider.addSpanProcessor(langfuseProcessor);
691
- }
692
- logger.info(`${LOG_PREFIX} Auto-registered processors with global TracerProvider`, {
693
- processors: skipLangfuse
694
- ? ["ContextEnricher"]
695
- : ["ContextEnricher", "LangfuseSpanProcessor"],
696
- reason: "External provider mode with auto-registration",
697
- skippedLangfuseSpanProcessor: skipLangfuse,
698
- });
699
- return;
700
- }
701
- logger.info(`${LOG_PREFIX} Using external TracerProvider mode`, {
702
- reason: config.useExternalTracerProvider
703
- ? "useExternalTracerProvider=true"
704
- : "autoDetectExternalProvider=true (trusting host signal)",
705
- instructions: "Add span processors to your TracerProvider using getSpanProcessors()",
706
- });
707
- logger.info(`${LOG_PREFIX} Span processors ready for external use`, {
708
- processors: langfuseProcessor
709
- ? ["ContextEnricher", "LangfuseSpanProcessor"]
710
- : ["ContextEnricher"],
711
- usage: "import { getSpanProcessors } from '@juspay/neurolink'",
712
- });
141
+ export function ensureTelemetry(config) {
142
+ if (terminated) {
143
+ if (!terminationLogged) {
144
+ terminationLogged = true;
145
+ logger.debug(`${OTEL_LOG_PREFIX} Telemetry was shut down for this process; not restarting`);
713
146
  }
714
- catch (autoRegisterError) {
715
- logger.warn(`${LOG_PREFIX} Auto-registration failed, manual registration required`, {
716
- error: autoRegisterError instanceof Error
717
- ? autoRegisterError.message
718
- : String(autoRegisterError),
719
- instructions: "Add span processors to your TracerProvider using getSpanProcessors()",
720
- });
721
- }
722
- }
723
- catch (error) {
724
- logger.error(`${LOG_PREFIX} Failed to create span processor for external mode`, {
725
- error: error instanceof Error ? error.message : String(error),
726
- stack: error instanceof Error ? error.stack : undefined,
727
- });
728
- isInitialized = true;
147
+ return;
729
148
  }
730
- }
731
- async function initializeStandaloneOpenTelemetryMode(config, resource, otlpEndpoint, serviceName, langfuseRequested, hasLangfuseCreds) {
732
- if ((!langfuseRequested || !hasLangfuseCreds) && !otlpEndpoint) {
733
- if (langfuseRequested && !hasLangfuseCreds) {
734
- logger.warn(`${LOG_PREFIX} Langfuse requested but credentials are missing, and no OTLP endpoint is configured; skipping initialization`, {
735
- hasPublicKey: !!config.publicKey,
736
- hasSecretKey: !!config.secretKey,
737
- });
738
- }
739
- else {
740
- logger.debug(`${LOG_PREFIX} Langfuse disabled and OTLP endpoint missing, skipping initialization`);
149
+ if (started) {
150
+ if (config && !ignoredConfigLogged) {
151
+ ignoredConfigLogged = true;
152
+ logger.debug(`${OTEL_LOG_PREFIX} Telemetry already started; ignoring observability config`);
741
153
  }
742
- isInitialized = true;
743
154
  return;
744
155
  }
745
- if (langfuseRequested && !hasLangfuseCreds) {
746
- logger.warn(`${LOG_PREFIX} Langfuse requested but credentials are missing; continuing with OTLP-only telemetry`, {
747
- hasPublicKey: !!config.publicKey,
748
- hasSecretKey: !!config.secretKey,
749
- otlpEnabled: !!otlpEndpoint,
750
- });
751
- }
156
+ initializationError = null;
752
157
  try {
753
- currentConfig = config;
754
- isCredentialsValid = hasLangfuseCreds;
755
- langfuseProcessor =
756
- langfuseRequested && hasLangfuseCreds
757
- ? await createLangfuseProcessor(config)
758
- : null;
759
- logger.debug(`${LOG_PREFIX} Standalone observability mode`, {
760
- langfuseEnabled: !!langfuseProcessor,
761
- otlpEnabled: !!otlpEndpoint,
762
- baseUrl: config.baseUrl || "https://cloud.langfuse.com",
763
- environment: config.environment || "dev",
764
- });
765
- const spanProcessors = [new ContextEnricher()];
766
- if (langfuseProcessor) {
767
- spanProcessors.push(langfuseProcessor);
768
- }
769
- if (otlpEndpoint) {
770
- try {
771
- const [{ OTLPTraceExporter }, { BatchSpanProcessor }] = await Promise.all([
772
- import("@opentelemetry/exporter-trace-otlp-http"),
773
- import("@opentelemetry/sdk-trace-base"),
774
- ]);
775
- const otlpExporter = new OTLPTraceExporter({
776
- url: `${otlpEndpoint}/v1/traces`,
777
- });
778
- spanProcessors.push(new BatchSpanProcessor(otlpExporter, {
779
- maxQueueSize: 2048,
780
- maxExportBatchSize: 512,
781
- scheduledDelayMillis: 1000,
782
- exportTimeoutMillis: 30000,
783
- }));
784
- logger.info(`${LOG_PREFIX} OTLP trace exporter added`, {
785
- endpoint: `${otlpEndpoint}/v1/traces`,
786
- serviceName,
787
- });
788
- }
789
- catch (otlpError) {
790
- logger.warn(`${LOG_PREFIX} Failed to create OTLP exporter (non-fatal)`, {
791
- error: otlpError instanceof Error
792
- ? otlpError.message
793
- : String(otlpError),
794
- endpoint: otlpEndpoint,
795
- });
796
- }
797
- }
798
- const [{ NodeTracerProvider }, { W3CTraceContextPropagator }] = await Promise.all([
799
- import("@opentelemetry/sdk-trace-node"),
800
- import("@opentelemetry/core"),
801
- ]);
802
- tracerProvider = new NodeTracerProvider({ resource, spanProcessors });
803
- tracerProvider.register({
804
- propagator: new W3CTraceContextPropagator(),
805
- });
806
- usingExternalProvider = false;
807
- await initializeOtlpMetricsAndLogs(resource, otlpEndpoint, serviceName);
808
- isInitialized = true;
809
- logger.info(`${LOG_PREFIX} Observability initialized`, {
810
- baseUrl: config.baseUrl || "https://cloud.langfuse.com",
811
- environment: config.environment || "dev",
812
- release: config.release || "v1.0.0",
813
- mode: "standalone",
814
- langfuseEnabled: !!langfuseProcessor,
815
- otlpEnabled: !!otlpEndpoint,
816
- serviceName,
817
- });
158
+ started = initialize(config);
818
159
  }
819
160
  catch (error) {
820
- const errorMessage = error instanceof Error ? error.message : String(error);
821
- const isDuplicateError = errorMessage.includes("duplicate registration") ||
822
- errorMessage.includes("already registered") ||
823
- errorMessage.includes("already set");
824
- if (isDuplicateError) {
825
- logger.warn(`${LOG_PREFIX} TracerProvider already registered, switching to external mode`, {
826
- error: errorMessage,
827
- recommendation: "Set useExternalTracerProvider=true or autoDetectExternalProvider=true in config",
828
- });
829
- usingExternalProvider = true;
830
- isInitialized = true;
831
- return;
832
- }
833
- logger.error(`${LOG_PREFIX} Initialization failed`, {
834
- error: errorMessage,
835
- stack: error instanceof Error ? error.stack : undefined,
161
+ started = true;
162
+ initializationError =
163
+ error instanceof Error ? error.message : String(error);
164
+ logger.error(`${OTEL_LOG_PREFIX} Initialization failed`, {
165
+ error: initializationError,
836
166
  });
837
- throw error;
838
167
  }
839
168
  }
840
169
  /**
841
- * Initialize OpenTelemetry once and let concurrent callers share the work.
842
- *
843
- * NeuroLink constructors intentionally start this asynchronously. Keeping the
844
- * in-flight promise here lets another constructor join it and lets an immediate
845
- * flush or shutdown wait for the same initialization before touching providers.
170
+ * @description Registers a holder of the process-wide telemetry and starts it if needed. Pair every call with `releaseTelemetry`.
171
+ * @param config {ObservabilityConfig | undefined} - Observability options forwarded to `ensureTelemetry`.
172
+ * @returns {void} Nothing.
846
173
  */
847
- export async function initializeOpenTelemetry(config) {
848
- if (shutdownPromise) {
849
- // A shutdown already in flight is what made isInitialized/
850
- // initializationPromise true or null in the first place; either reading
851
- // is stale until it actually finishes. Waiting here means this call sees
852
- // the settled post-teardown state — nothing initialized, nothing
853
- // pending — and falls through to build fresh providers, rather than
854
- // returning early on a flag that is about to flip out from under it.
855
- await shutdownPromise;
856
- }
857
- if (isInitialized) {
858
- currentConfig = config;
859
- logger.debug(`${LOG_PREFIX} Already initialized, config updated`, {
860
- usingExternalProvider,
861
- hasLangfuseProcessor: !!langfuseProcessor,
862
- hasTraceNameFormat: typeof config.traceNameFormat === "function",
863
- });
864
- return;
865
- }
866
- if (initializationPromise) {
867
- await initializationPromise;
868
- currentConfig = config;
869
- return;
870
- }
871
- const pending = initializeOpenTelemetryOnce(config);
872
- initializationPromise = pending;
873
- try {
874
- await pending;
875
- }
876
- finally {
877
- if (initializationPromise === pending) {
878
- initializationPromise = null;
879
- }
880
- }
174
+ export function acquireTelemetry(config) {
175
+ holders++;
176
+ ensureTelemetry(config);
881
177
  }
882
178
  /**
883
- * Initialize OpenTelemetry with Langfuse span processor
884
- *
885
- * This connects Vercel AI SDK's experimental_telemetry to Langfuse by:
886
- * 1. Creating LangfuseSpanProcessor with Langfuse credentials
887
- * 2. Creating a NodeTracerProvider with service metadata and span processor
888
- * 3. Registering the provider globally for AI SDK to use
889
- *
890
- * NEW: If useExternalTracerProvider is true or autoDetectExternalProvider detects
891
- * an existing provider, steps 2 and 3 are skipped. The span processors are still
892
- * created and can be retrieved via getSpanProcessors().
893
- *
894
- * @param config - Langfuse configuration passed from parent application
179
+ * @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.
180
+ * @returns {Promise<void>} Resolves when pending spans are flushed.
181
+ * @throws {Error} When any provider fails to flush.
895
182
  */
896
- async function initializeOpenTelemetryOnce(config) {
897
- // FIRST: Check for external provider mode - bypasses enabled check
898
- // NOTE: When autoDetectExternalProvider is true, we trust the flag directly rather than
899
- // calling hasExternalTracerProvider(). This is because Neurolink may bundle its own copy
900
- // of @opentelemetry/api, which has a separate global state from the host application.
901
- // The hasExternalTracerProvider() check would query Neurolink's bundled @opentelemetry/api
902
- // global state (which has no provider registered), not the host's global state.
903
- // By trusting autoDetectExternalProvider=true, we let the host application signal that
904
- // it has already registered a TracerProvider.
905
- const shouldUseExternal = config?.useExternalTracerProvider === true ||
906
- config?.autoDetectExternalProvider === true;
907
- const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
908
- const langfuseRequested = config?.enabled === true;
909
- const hasLangfuseCreds = !!config.publicKey && !!config.secretKey;
910
- const serviceName = process.env.OTEL_SERVICE_NAME || "neurolink";
911
- const resource = await createOtelResource(config, serviceName);
912
- if (shouldUseExternal) {
913
- await initializeExternalOpenTelemetryMode(config, resource, otlpEndpoint, serviceName, langfuseRequested, hasLangfuseCreds);
914
- return;
915
- }
916
- await initializeStandaloneOpenTelemetryMode(config, resource, otlpEndpoint, serviceName, langfuseRequested, hasLangfuseCreds);
183
+ export async function releaseTelemetry() {
184
+ holders = Math.max(0, holders - 1);
185
+ await flushOpenTelemetry();
917
186
  }
918
187
  /**
919
- * Flush all pending spans to Langfuse
188
+ * @description Exports pending spans and metrics from the providers NeuroLink owns and flushes its Langfuse exporter. A host provider is never flushed.
189
+ * @returns {Promise<void>} Resolves when every owned provider has flushed.
190
+ * @throws {Error} When any provider fails to flush.
920
191
  */
921
192
  export async function flushOpenTelemetry() {
922
- if (initializationPromise) {
923
- try {
924
- await initializationPromise;
925
- }
926
- catch {
927
- // Initialization owns its error logging; a failed init leaves nothing
928
- // initialized, so the check below skips the flush as it always has.
929
- }
930
- }
931
- if (!isInitialized) {
932
- logger.debug(`${LOG_PREFIX} Not initialized, skipping flush`);
933
- return;
934
- }
935
- const failures = [];
936
- if (langfuseProcessor) {
937
- try {
938
- logger.info(`${LOG_PREFIX} Flushing Langfuse spans...`);
939
- await langfuseProcessor.forceFlush();
940
- }
941
- catch (error) {
942
- failures.push({ signal: "langfuse", error });
943
- logger.error(`${LOG_PREFIX} Langfuse flush failed`, {
944
- error: error instanceof Error ? error.message : String(error),
945
- stack: error instanceof Error ? error.stack : undefined,
946
- });
947
- }
948
- }
949
- else {
950
- logger.debug(`${LOG_PREFIX} Langfuse disabled, skipping Langfuse flush`);
951
- }
952
- if (tracerProvider && !usingExternalProvider) {
953
- try {
954
- logger.info(`${LOG_PREFIX} Flushing OTLP traces...`);
955
- await tracerProvider.forceFlush();
956
- }
957
- catch (error) {
958
- failures.push({ signal: "traces", error });
959
- logger.error(`${LOG_PREFIX} Trace flush failed`, {
960
- error: error instanceof Error ? error.message : String(error),
961
- stack: error instanceof Error ? error.stack : undefined,
962
- });
963
- }
964
- }
965
- else {
966
- logger.debug(`${LOG_PREFIX} No TracerProvider to flush`);
967
- }
968
- if (meterProvider) {
969
- try {
970
- logger.info(`${LOG_PREFIX} Flushing OTLP metrics...`);
971
- await meterProvider.forceFlush();
972
- }
973
- catch (error) {
974
- failures.push({ signal: "metrics", error });
975
- logger.error(`${LOG_PREFIX} Metric flush failed`, {
976
- error: error instanceof Error ? error.message : String(error),
977
- stack: error instanceof Error ? error.stack : undefined,
978
- });
979
- }
980
- }
981
- else {
982
- logger.debug(`${LOG_PREFIX} No MeterProvider to flush`);
983
- }
984
- if (loggerProvider) {
985
- try {
986
- logger.info(`${LOG_PREFIX} Flushing OTLP logs...`);
987
- await loggerProvider.forceFlush();
988
- }
989
- catch (error) {
990
- failures.push({ signal: "logs", error });
991
- logger.error(`${LOG_PREFIX} Log flush failed`, {
992
- error: error instanceof Error ? error.message : String(error),
993
- stack: error instanceof Error ? error.stack : undefined,
994
- });
995
- }
996
- }
997
- else {
998
- logger.debug(`${LOG_PREFIX} No LoggerProvider to flush`);
999
- }
1000
- if (failures.length > 0) {
1001
- throw new Error(`${LOG_PREFIX} Flush failed for: ${failures.map((f) => f.signal).join(", ")}`);
193
+ const results = await Promise.allSettled([
194
+ tracerProvider?.forceFlush(),
195
+ usingExternalProvider ? langfuseExporter?.flush() : undefined,
196
+ meterProvider?.forceFlush(),
197
+ ]);
198
+ const failed = results.filter((result) => result.status === "rejected");
199
+ if (failed.length > 0) {
200
+ throw new Error(`${OTEL_LOG_PREFIX} Flush failed`, {
201
+ cause: failed.map((result) => result.reason),
202
+ });
1002
203
  }
1003
- logger.info(`${LOG_PREFIX} Flush complete`);
1004
204
  }
1005
205
  /**
1006
- * Shutdown OpenTelemetry and Langfuse span processor
1007
- *
1008
- * Concurrent callers share one in-flight teardown, the same way concurrent
1009
- * initializers share one in-flight `initializeOpenTelemetry`: a second
1010
- * shutdown call joins the first instead of tearing down the same provider
1011
- * instances a second time.
206
+ * @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.
207
+ * @returns {Promise<void>} Resolves when telemetry is flushed or shut down.
1012
208
  */
1013
209
  export async function shutdownOpenTelemetry() {
1014
- if (shutdownPromise) {
1015
- await shutdownPromise;
210
+ if (holders > 0) {
211
+ await flushOpenTelemetry();
1016
212
  return;
1017
213
  }
1018
- const pending = shutdownOpenTelemetryOnce();
1019
- shutdownPromise = pending;
1020
- try {
1021
- await pending;
1022
- }
1023
- finally {
1024
- if (shutdownPromise === pending) {
1025
- shutdownPromise = null;
1026
- }
1027
- }
1028
- }
1029
- async function shutdownOpenTelemetryOnce() {
1030
- if (initializationPromise) {
1031
- try {
1032
- await initializationPromise;
1033
- }
1034
- catch {
1035
- // Initialization owns its error logging. Continue so any provider it
1036
- // created before failing is still shut down below.
1037
- }
1038
- }
1039
- try {
1040
- // Only shutdown tracerProvider if we created it
1041
- if (tracerProvider && !usingExternalProvider) {
1042
- await tracerProvider.shutdown();
1043
- }
1044
- // Always shutdown the Langfuse processor
1045
- if (langfuseProcessor) {
1046
- await langfuseProcessor.shutdown();
1047
- }
1048
- // Shutdown cached ContextEnricher
1049
- if (cachedContextEnricher) {
1050
- await cachedContextEnricher.shutdown();
1051
- }
1052
- // Shutdown MeterProvider if we created it
1053
- if (meterProvider) {
1054
- await meterProvider.shutdown();
1055
- }
1056
- // Shutdown LoggerProvider if we created it
1057
- if (loggerProvider) {
1058
- await loggerProvider.shutdown();
1059
- }
1060
- tracerProvider = null;
1061
- meterProvider = null;
1062
- loggerProvider = null;
1063
- langfuseProcessor = null;
1064
- cachedContextEnricher = null;
1065
- isInitialized = false;
1066
- isCredentialsValid = false;
1067
- usingExternalProvider = false;
1068
- logger.debug(`${LOG_PREFIX} Shutdown complete`);
1069
- }
1070
- catch (error) {
1071
- logger.error(`${LOG_PREFIX} Shutdown failed`, {
1072
- error: error instanceof Error ? error.message : String(error),
1073
- });
1074
- }
1075
- }
1076
- /**
1077
- * Get the Langfuse span processor
1078
- */
1079
- export function getLangfuseSpanProcessor() {
1080
- return langfuseProcessor;
214
+ await stop();
1081
215
  }
1082
216
  /**
1083
- * Get the tracer provider
1084
- */
1085
- export function getTracerProvider() {
1086
- return tracerProvider;
1087
- }
1088
- /**
1089
- * Get the logger provider for emitting OTLP log records.
1090
- * Returns null if OTLP is not configured or LoggerProvider was not created.
1091
- */
1092
- export function getLoggerProvider() {
1093
- return loggerProvider;
1094
- }
1095
- /**
1096
- * Check if OpenTelemetry is initialized
217
+ * @description Reports whether NeuroLink's tracing is active, either on its own provider or alongside a host provider.
218
+ * @returns {boolean} `true` once `ensureTelemetry` has set tracing up.
1097
219
  */
1098
220
  export function isOpenTelemetryInitialized() {
1099
- return isInitialized;
221
+ return initialized;
1100
222
  }
1101
223
  /**
1102
- * Get health status for Langfuse observability
1103
- *
1104
- * @returns Health status object with initialization and configuration details
224
+ * @description Summarizes the Langfuse export state for health checks.
225
+ * @returns {{ isHealthy: boolean; initialized: boolean; hasProcessor: boolean; usingExternalProvider: boolean; error?: string }} `isHealthy` and `hasProcessor` are `true` only while a Langfuse exporter is registered, on NeuroLink's own provider or beside a host provider (where it still needs `createExporterSpanProcessor()` on the host provider to receive spans); `error` carries the message of a failed initialization.
1105
226
  */
1106
227
  export function getLangfuseHealthStatus() {
1107
228
  return {
1108
- isHealthy: !!(currentConfig?.enabled &&
1109
- isInitialized &&
1110
- isCredentialsValid &&
1111
- langfuseProcessor !== null),
1112
- initialized: isInitialized,
1113
- credentialsValid: isCredentialsValid,
1114
- enabled: currentConfig?.enabled || false,
1115
- hasProcessor: langfuseProcessor !== null,
229
+ isHealthy: initialized && langfuseExporter !== null,
230
+ initialized,
231
+ hasProcessor: langfuseExporter !== null,
1116
232
  usingExternalProvider,
1117
- config: currentConfig
1118
- ? {
1119
- baseUrl: currentConfig.baseUrl || "https://cloud.langfuse.com",
1120
- environment: currentConfig.environment || "dev",
1121
- release: currentConfig.release || "v1.0.0",
1122
- }
1123
- : undefined,
233
+ ...(initializationError !== null && { error: initializationError }),
1124
234
  };
1125
235
  }
1126
236
  /**
1127
- * Set user and session context for Langfuse spans in the current async context
1128
- *
1129
- * Merges the provided context with existing AsyncLocalStorage context. If a callback is provided,
1130
- * the context is scoped to that callback execution and returns the callback's result.
1131
- * Without a callback, the context applies to the current execution context and its children.
1132
- *
1133
- * Uses AsyncLocalStorage to properly scope context per request, avoiding race conditions
1134
- * in concurrent scenarios.
1135
- *
1136
- * @param context - Object containing context fields to merge with existing context
1137
- * @param callback - Optional callback to run within the context scope. If omitted, context applies to current execution
1138
- * @returns The callback's return value if provided, otherwise void
1139
- *
1140
- * @example
1141
- * // With callback - returns the result
1142
- * const result = await setLangfuseContext({ userId: "user123" }, async () => {
1143
- * return await generateText({ model: "gpt-4", prompt: "Hello" });
1144
- * });
1145
- *
1146
- * @example
1147
- * // Without callback - sets context for current execution
1148
- * await setLangfuseContext({ sessionId: "session456", traceName: "chat-completion" });
237
+ * @description Reads the full trace metadata that `setLangfuseContext` stored on a context.
238
+ * @param ctx {Context} - Context to read, normally the active one.
239
+ * @returns {Record<string, SpanContent>} Metadata set by enclosing `setLangfuseContext` calls, inner keys winning; empty when none was set.
1149
240
  */
1150
- export async function setLangfuseContext(context, callback) {
1151
- const currentContext = contextStorage.getStore() || {};
1152
- const newContext = {
1153
- userId: context.userId !== undefined ? context.userId : currentContext.userId,
1154
- sessionId: context.sessionId !== undefined
1155
- ? context.sessionId
1156
- : currentContext.sessionId,
1157
- conversationId: context.conversationId !== undefined
1158
- ? context.conversationId
1159
- : currentContext.conversationId,
1160
- requestId: context.requestId !== undefined
1161
- ? context.requestId
1162
- : currentContext.requestId,
1163
- traceName: context.traceName !== undefined
1164
- ? context.traceName
1165
- : currentContext.traceName,
1166
- metadata: context.metadata !== undefined
1167
- ? context.metadata
1168
- : currentContext.metadata,
1169
- tags: context.tags !== undefined ? context.tags : currentContext.tags,
1170
- // Operation name support
1171
- operationName: context.operationName !== undefined
1172
- ? context.operationName
1173
- : currentContext.operationName,
1174
- autoDetectOperationName: context.autoDetectOperationName !== undefined
1175
- ? context.autoDetectOperationName
1176
- : currentContext.autoDetectOperationName,
1177
- // Custom attributes support
1178
- customAttributes: context.customAttributes !== undefined
1179
- ? context.customAttributes
1180
- : currentContext.customAttributes,
1181
- };
1182
- if (callback) {
1183
- return await contextStorage.run(newContext, callback);
1184
- }
1185
- else {
1186
- contextStorage.enterWith(newContext);
1187
- }
241
+ export function traceMetadataFromContext(ctx) {
242
+ const stored = ctx.getValue(TRACE_METADATA_KEY);
243
+ return stored && typeof stored === "object"
244
+ ? stored
245
+ : {};
1188
246
  }
1189
247
  /**
1190
- * Get the current Langfuse context from AsyncLocalStorage
1191
- *
1192
- * Returns the current context including userId, sessionId, conversationId,
1193
- * requestId, traceName, and metadata. Returns undefined if no context is set.
1194
- *
1195
- * @returns The current LangfuseContext or undefined
1196
- *
248
+ * @description Runs `fn` with trace attributes propagated to every span it creates. `conversationId` and `requestId` become metadata; empty values are dropped. Metadata values of up to 200 characters once stringified are on every observation; longer values and arrays are recorded in full on the trace only, written by the trace root.
249
+ * @param params {NeurolinkTraceContext} - User, session, trace name, tags and metadata for the trace.
250
+ * @param fn {() => T} - Work to run inside the propagated context.
251
+ * @returns {T} Whatever `fn` returns.
1197
252
  * @example
1198
- * const context = getLangfuseContext();
1199
- * console.log(context?.userId, context?.sessionId);
253
+ * await setLangfuseContext({ userId: "u1", sessionId: "s1" }, () => neurolink.generate({ input: { text: "hi" } }));
1200
254
  */
1201
- export function getLangfuseContext() {
1202
- return contextStorage.getStore();
1203
- }
1204
- /**
1205
- * Fill a span's Langfuse identity when the caller's context would otherwise
1206
- * fall back to "guest". Identity (user.id / session.id) is set additively —
1207
- * only fields ambient context didn't already provide, so a host's own context
1208
- * is never overridden. trace.name (the title) is rescued only when no ambient
1209
- * name source exists AND this span is the trace root, mirroring
1210
- * ContextEnricher.onStart so a host wrapper span isn't relabelled.
1211
- */
1212
- export function stampGuestRescueIdentity(span, callContext, isRootSpan) {
1213
- const ambient = getLangfuseContext();
1214
- const ctx = callContext;
1215
- const userId = typeof ctx?.userId === "string" && ctx.userId ? ctx.userId : undefined;
1216
- const sessionId = typeof ctx?.sessionId === "string" && ctx.sessionId
1217
- ? ctx.sessionId
1218
- : undefined;
1219
- // Title: the trace name comes from traceName ?? userId, so only rescue it
1220
- // from "guest" when ambient has neither, and only on the trace root.
1221
- if (isRootSpan && !ambient?.traceName && !ambient?.userId) {
1222
- const traceName = typeof ctx?.traceName === "string" && ctx.traceName
1223
- ? ctx.traceName
1224
- : userId;
1225
- if (traceName) {
1226
- span.setAttribute(LANGFUSE_ATTR.TRACE_NAME, traceName);
1227
- span.setAttribute("trace.name", traceName);
1228
- }
1229
- }
1230
- // Identity: additive — set each field only where ambient didn't.
1231
- if (userId && !ambient?.userId) {
1232
- span.setAttribute("user.id", userId);
1233
- }
1234
- if (sessionId && !ambient?.sessionId) {
1235
- span.setAttribute("session.id", sessionId);
1236
- }
1237
- }
1238
- /**
1239
- * Capture the current Langfuse AsyncLocalStorage context and return a wrapper
1240
- * that re-enters that context when executing the provided callback.
1241
- *
1242
- * This is essential for preserving trace context across async boundaries that
1243
- * break the automatic ALS propagation chain, such as `setImmediate()`,
1244
- * `setTimeout()`, or event-emitter callbacks. Without this, spans created
1245
- * inside those callbacks become orphaned traces in Langfuse.
1246
- *
1247
- * **How it works:**
1248
- * 1. Captures the current ALS store at call time (synchronously).
1249
- * 2. Returns an async function that, when invoked, re-enters the captured
1250
- * context via `contextStorage.run()` before executing the callback.
1251
- * 3. If no context exists at capture time, the callback runs without
1252
- * ALS wrapping (no-op passthrough).
1253
- *
1254
- * @param fn - The async function to execute within the captured context
1255
- * @returns A new async function that preserves the Langfuse ALS context
1256
- *
1257
- * @example
1258
- * // Before (broken — setImmediate loses ALS context):
1259
- * setImmediate(async () => {
1260
- * await this.checkAndSummarize(session, threshold);
1261
- * });
1262
- *
1263
- * // After (fixed — context is captured and re-entered):
1264
- * const wrappedFn = runWithCurrentLangfuseContext(async () => {
1265
- * await this.checkAndSummarize(session, threshold);
1266
- * });
1267
- * setImmediate(wrappedFn);
1268
- */
1269
- export function runWithCurrentLangfuseContext(fn) {
1270
- const capturedContext = contextStorage.getStore();
1271
- if (capturedContext) {
1272
- return () => contextStorage.run(capturedContext, fn);
1273
- }
1274
- // No context to preserve — return the function as-is
1275
- return fn;
1276
- }
1277
- /**
1278
- * Get an OpenTelemetry Tracer for creating custom spans
1279
- *
1280
- * This allows applications to create their own spans that will be
1281
- * processed by the same span processors (ContextEnricher + LangfuseSpanProcessor).
1282
- *
1283
- * @param name - Tracer name, defaults to "neurolink"
1284
- * @param version - Tracer version, optional
1285
- * @returns OpenTelemetry Tracer instance
1286
- *
1287
- * @example
1288
- * const tracer = getTracer("my-app");
1289
- * const span = tracer.startSpan("custom-operation");
1290
- * try {
1291
- * // ... do work
1292
- * } finally {
1293
- * span.end();
1294
- * }
1295
- */
1296
- export function getTracer(name = "neurolink", version) {
1297
- return trace.getTracer(name, version);
1298
- }
1299
- /**
1300
- * Create a new ContextEnricher span processor
1301
- * Use this when useExternalTracerProvider is true to add to your own TracerProvider
1302
- *
1303
- * @returns A new ContextEnricher instance
1304
- */
1305
- export function createContextEnricher() {
1306
- return new ContextEnricher();
1307
- }
1308
- /**
1309
- * Get all span processors that NeuroLink would use
1310
- * Convenience function that returns [ContextEnricher, LangfuseSpanProcessor]
1311
- *
1312
- * @returns Array of span processors, or empty array if not initialized
1313
- */
1314
- export function getSpanProcessors() {
1315
- if (!isInitialized || !langfuseProcessor) {
1316
- logger.warn(`${LOG_PREFIX} getSpanProcessors called but not initialized`);
1317
- return [];
1318
- }
1319
- // Reuse cached ContextEnricher to avoid creating multiple instances
1320
- if (!cachedContextEnricher) {
1321
- cachedContextEnricher = new ContextEnricher();
1322
- }
1323
- return [cachedContextEnricher, langfuseProcessor];
1324
- }
1325
- /**
1326
- * Check if using external TracerProvider mode
1327
- *
1328
- * @returns true if operating in external TracerProvider mode
1329
- */
1330
- export function isUsingExternalTracerProvider() {
1331
- return usingExternalProvider;
255
+ export function setLangfuseContext(params, fn) {
256
+ const full = {};
257
+ const propagated = {};
258
+ for (const [key, value] of Object.entries({
259
+ ...params.metadata,
260
+ conversationId: params.conversationId,
261
+ requestId: params.requestId,
262
+ })) {
263
+ if (value === undefined || value === null || value === "") {
264
+ continue;
265
+ }
266
+ full[key] = value;
267
+ const text = spanJsonAttribute(value, Number.POSITIVE_INFINITY);
268
+ if (text.length <= MAX_PROPAGATED_CHARS) {
269
+ propagated[key] = text;
270
+ }
271
+ }
272
+ const tags = params.tags?.filter((tag) => tag.length > 0);
273
+ const active = context.active();
274
+ const withMetadata = active.setValue(TRACE_METADATA_KEY, {
275
+ ...traceMetadataFromContext(active),
276
+ ...full,
277
+ });
278
+ return context.with(withMetadata, () => propagateAttributes({
279
+ userId: params.userId || undefined,
280
+ sessionId: params.sessionId || undefined,
281
+ traceName: params.traceName || undefined,
282
+ ...(tags && tags.length > 0 && { tags }),
283
+ ...(Object.keys(propagated).length > 0 && { metadata: propagated }),
284
+ }, fn));
1332
285
  }