UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

1,121 lines 53.3 kB
/** * OpenTelemetry Instrumentation for Langfuse v4 * * Configures OpenTelemetry TracerProvider with LangfuseSpanProcessor to capture * traces from Vercel AI SDK's experimental_telemetry feature. * * Flow: Vercel AI SDK → OpenTelemetry Spans → LangfuseSpanProcessor → Langfuse Platform */ import { metrics, SpanStatusCode, trace } from "@opentelemetry/api"; import { W3CTraceContextPropagator } from "@opentelemetry/core"; import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { MeterProvider, PeriodicExportingMetricReader, } from "@opentelemetry/sdk-metrics"; import { BatchLogRecordProcessor, LoggerProvider, } from "@opentelemetry/sdk-logs"; import { BatchSpanProcessor, } from "@opentelemetry/sdk-trace-base"; import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, } from "@opentelemetry/semantic-conventions"; import { AsyncLocalStorage } from "async_hooks"; import { extractMcpErrorText } from "../../../../utils/mcpErrorText.js"; import { logger } from "../../../../utils/logger.js"; const LOG_PREFIX = "[OpenTelemetry]"; function createOtelResource(config, serviceName) { return resourceFromAttributes({ [ATTR_SERVICE_NAME]: serviceName, [ATTR_SERVICE_VERSION]: config.release || "v1.0.0", "deployment.environment": config.environment || "dev", }); } function initializeOtlpMetricsAndLogs(resource, otlpEndpoint, serviceName) { if (!otlpEndpoint) { return; } try { const metricExporter = new OTLPMetricExporter({ url: `${otlpEndpoint}/v1/metrics`, }); const metricReader = new PeriodicExportingMetricReader({ exporter: metricExporter, exportIntervalMillis: 15000, exportTimeoutMillis: 10000, }); meterProvider = new MeterProvider({ resource, readers: [metricReader], }); metrics.setGlobalMeterProvider(meterProvider); logger.info(`${LOG_PREFIX} OTLP metric exporter added — MeterProvider registered globally`, { endpoint: `${otlpEndpoint}/v1/metrics`, exportIntervalMs: 15000, serviceName, meterProviderType: meterProvider.constructor.name, }); } catch (metricsError) { logger.warn(`${LOG_PREFIX} Failed to create OTLP metric exporter (non-fatal)`, { error: metricsError instanceof Error ? metricsError.message : String(metricsError), endpoint: otlpEndpoint, }); } try { const logExporter = new OTLPLogExporter({ url: `${otlpEndpoint}/v1/logs`, }); const logProcessor = new BatchLogRecordProcessor(logExporter, { maxQueueSize: 2048, maxExportBatchSize: 512, scheduledDelayMillis: 2000, exportTimeoutMillis: 30000, }); loggerProvider = new LoggerProvider({ resource, processors: [logProcessor], }); logger.info(`${LOG_PREFIX} OTLP log exporter added — LoggerProvider created`, { endpoint: `${otlpEndpoint}/v1/logs`, serviceName, }); } catch (logsError) { logger.warn(`${LOG_PREFIX} Failed to create OTLP log exporter (non-fatal)`, { error: logsError instanceof Error ? logsError.message : String(logsError), endpoint: otlpEndpoint, }); } } const contextStorage = new AsyncLocalStorage(); let tracerProvider = null; let meterProvider = null; let loggerProvider = null; let langfuseProcessor = null; let isInitialized = false; let isCredentialsValid = false; let currentConfig = null; let usingExternalProvider = false; let cachedContextEnricher = null; /** * Check if a real TracerProvider (not ProxyTracerProvider) is already registered * * IMPORTANT: This function checks the @opentelemetry/api global state as seen by THIS * module's bundled copy of @opentelemetry/api. If Neurolink is bundled with its own * copy of @opentelemetry/api (which is common in bundled libraries), this function * will NOT detect TracerProviders registered by the host application on their * @opentelemetry/api instance. Use `useExternalTracerProvider: true` or * `autoDetectExternalProvider: true` to explicitly signal external provider usage. * * @returns true if an external TracerProvider is detected in this module's OTEL instance */ function _hasExternalTracerProvider() { try { const provider = trace.getTracerProvider(); if (!provider) { return false; } // ProxyTracerProvider is the default "no-op" provider // Any other provider means someone else registered one const providerName = provider.constructor?.name || ""; const isProxy = providerName === "ProxyTracerProvider" || providerName === "NoopTracerProvider"; if (!isProxy) { logger.debug(`${LOG_PREFIX} Detected external TracerProvider: ${providerName}`); } return !isProxy; } catch (error) { logger.warn(`${LOG_PREFIX} Error checking for external TracerProvider`, { error: error instanceof Error ? error.message : String(error), }); return false; } } /** * Parse `ai.toolCall.result` on a Vercel AI SDK tool span and surface any * embedded MCP `{ isError: true }` as a Langfuse ERROR + status message. */ function applyToolCallIsErrorStatus(attrs) { const resultAttr = attrs["ai.toolCall.result"]; if (typeof resultAttr !== "string" || resultAttr.length === 0) { return; } let parsed; try { parsed = JSON.parse(resultAttr); } catch { return; } if (!parsed || typeof parsed !== "object" || parsed.isError !== true) { return; } attrs["langfuse.level"] = "ERROR"; // Always set a status_message, even when the MCP payload has non-text or // empty content. Without a fallback the Curator P0-1 gap reappears for // those failures (level=ERROR but statusMessage=null). const errorText = extractMcpErrorText(parsed); const toolName = typeof attrs["ai.toolCall.name"] === "string" ? attrs["ai.toolCall.name"] : "tool"; attrs["langfuse.status_message"] = errorText || `MCP ${toolName} returned isError=true`; } /** * Map non-ERROR span conditions (content-filter, length, client abort, SDK * timeout, empty output) onto Langfuse WARNING/ERROR levels. Mutates `attrs`. */ function applyNonErrorLangfuseLevel(attrs) { const finishReason = attrs["ai.finishReason"] ?? attrs["gen_ai.response.finish_reasons"]; const reasonStr = Array.isArray(finishReason) ? finishReason.join(",") : String(finishReason ?? ""); if (reasonStr.includes("content-filter") || reasonStr === "length") { attrs["langfuse.level"] = "WARNING"; attrs["langfuse.status_message"] = `Generation stopped: finishReason=${reasonStr}`; return; } if (attrs["neurolink.no_output"] === true) { attrs["langfuse.level"] = "WARNING"; // Preserve any enriched status message StreamHandler already set // (carries finishReason / token counts via buildNoOutputStatusMessage). // Only fall back to the generic message when none was set upstream. if (typeof attrs["langfuse.status_message"] !== "string") { attrs["langfuse.status_message"] = "Stream produced no output (NoOutputGeneratedError)"; } return; } if (reasonStr === "aborted") { attrs["langfuse.level"] = "WARNING"; attrs["langfuse.status_message"] = "Generation aborted by client"; } } /** * Span processor that enriches spans with user and session context from AsyncLocalStorage * Also extracts GenAI semantic convention attributes for Langfuse integration * * Key features: * - Enriches spans with userId, sessionId, conversationId, requestId * - Auto-detects operation names from Vercel AI SDK span names * - Builds formatted trace names for Langfuse (e.g., "user@email.com:ai.streamText") * - Supports custom trace name formats via configuration * - Handles wrapper spans by detecting operations from child spans and updating trace name in onEnd() */ class ContextEnricher { /** * Maximum number of detected operations to track to prevent memory leaks. * Once this limit is reached, oldest entries are evicted (FIFO). */ static MAX_DETECTED_OPERATIONS = 10000; /** * Track detected operations per trace for wrapper span support. * When a host app creates a wrapper span before AI operations, the wrapper's * onStart() runs before the AI SDK child span exists. We store detected * operations here so we can update the trace name in onEnd(). */ detectedOperations = new Map(); onStart(span, parentContext) { const context = contextStorage.getStore(); const userId = context?.userId ?? currentConfig?.userId ?? "guest"; const sessionId = context?.sessionId ?? currentConfig?.sessionId; // Get span name for operation auto-detection const spanName = span.name; // Determine if auto-detection is enabled for this context const autoDetect = this.shouldAutoDetectOperationName(context); // Resolve operation name: explicit > auto-detected > undefined const operationName = this.resolveOperationName(context?.operationName, spanName, autoDetect); // Store detected AI operations for wrapper span support (optional, defensive). // When a host app creates a wrapper span before calling AI operations, // this allows us to update the trace name in onEnd() with the operation. // Only store the first detected operation for each trace (subsequent operations are ignored). try { if (operationName && spanName?.startsWith("ai.")) { const traceId = span.spanContext?.()?.traceId; if (traceId && !this.detectedOperations.has(traceId)) { // Evict oldest entry if at capacity to prevent memory leak if (this.detectedOperations.size >= ContextEnricher.MAX_DETECTED_OPERATIONS) { const firstKey = this.detectedOperations.keys().next().value; if (firstKey) { this.detectedOperations.delete(firstKey); } } this.detectedOperations.set(traceId, operationName); } } } catch { // Wrapper span support is optional - don't fail if spanContext isn't available } // Build trace name based on priority: // 1. Explicit traceName (100% backward compatible) // 2. Formatted name with userId + operationName // 3. userId only (legacy fallback) const traceName = this.buildTraceName(context?.traceName, userId, operationName); // Apply custom attributes FIRST so internal attributes always take precedence // and cannot be accidentally overwritten by user-provided values if (context?.customAttributes) { for (const [key, value] of Object.entries(context.customAttributes)) { span.setAttribute(key, value); } } // Set user and session attributes (internal - always override custom) if (userId && userId !== "guest") { span.setAttribute("user.id", userId); } if (sessionId) { span.setAttribute("session.id", sessionId); } // Add extended context fields if (context?.conversationId) { span.setAttribute("conversation.id", context.conversationId); } if (context?.requestId) { span.setAttribute("request.id", context.requestId); } const isRootSpan = !trace.getSpan(parentContext); if (traceName && isRootSpan) { span.setAttribute("langfuse.trace.name", traceName); span.setAttribute("trace.name", traceName); } // Set operation name as separate attribute for filtering/analytics if (operationName) { span.setAttribute("gen_ai.operation.name", operationName); } // Add custom metadata as span attributes const metadata = context?.metadata; if (metadata && typeof metadata === "object") { for (const [key, value] of Object.entries(metadata)) { if (value !== undefined && value !== null) { // Preserve primitive types that OTEL supports natively if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { if (metadata && isRootSpan) { span.setAttribute("langfuse.trace.metadata", JSON.stringify(metadata)); } } else if (Array.isArray(value) && value.every((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean")) { // OTEL supports homogeneous arrays of primitives span.setAttribute(`metadata.${key}`, JSON.stringify(value)); } else { // Fall back to JSON string for complex types span.setAttribute(`metadata.${key}`, JSON.stringify(value)); } } } } } /** * Determine if auto-detection should be used for operation names */ shouldAutoDetectOperationName(context) { // Context-level override takes precedence if (context?.autoDetectOperationName !== undefined) { return context.autoDetectOperationName; } // Fall back to global config (default: true) return currentConfig?.autoDetectOperationName !== false; } /** * Resolve operation name from explicit setting, auto-detection, or undefined */ resolveOperationName(explicit, spanName, autoDetect) { // Explicit operation name takes precedence if (explicit) { return explicit; } // Auto-detect from span name if enabled if (autoDetect && spanName) { // Detect Vercel AI SDK operation spans (ai.streamText, ai.generateText, etc.) if (spanName.startsWith("ai.")) { return spanName; } // Detect OpenTelemetry GenAI convention spans (chat, embeddings, text_completion) if (spanName === "chat" || spanName === "embeddings" || spanName === "text_completion") { return spanName; } } return undefined; } /** * Build trace name based on format configuration and available data */ buildTraceName(explicitTraceName, userId, operationName) { // 1. Explicit traceName always wins (100% backward compatibility) if (explicitTraceName) { return explicitTraceName; } // 2. Build formatted trace name based on config const format = currentConfig?.traceNameFormat ?? "userId:operationName"; // Handle custom function format if (typeof format === "function") { return format({ userId, operationName }); } // Handle predefined string formats switch (format) { case "userId:operationName": return operationName ? `${userId}:${operationName}` : userId; case "operationName:userId": return operationName ? `${operationName}:${userId}` : userId; case "operationName": return operationName || userId; case "userId": default: return userId; } } /** * Called when span ends - extracts GenAI semantic convention attributes * from Vercel AI SDK spans and enriches them for Langfuse. * * Also handles wrapper span support: when a host app creates a wrapper/trace-root * span before AI operations, we update the trace name here with the detected operation. */ onEnd(span) { try { // Get span attributes (ReadableSpan interface) const readableSpan = span; const attributes = readableSpan.attributes || {}; // Handle wrapper/trace-root spans: update trace name with detected operation // This supports host apps (like Curator) that create wrapper spans before AI calls // This is optional - if spanContext fails, we skip wrapper span support try { const traceId = span.spanContext?.()?.traceId; if (traceId) { const isTraceRoot = attributes["langfuse.span.type"] === "trace-root"; const detectedOp = this.detectedOperations.get(traceId); if (isTraceRoot && detectedOp) { const context = contextStorage.getStore(); const userId = attributes["user.id"] || context?.userId || currentConfig?.userId || "guest"; // Only update if there's no explicit traceName set const existingTraceName = attributes["langfuse.trace.name"]; const hasExplicitTraceName = context?.traceName || (existingTraceName && existingTraceName !== userId); if (!hasExplicitTraceName) { const newTraceName = this.buildTraceName(null, userId, detectedOp); // Update the trace name attribute span.setAttribute("langfuse.trace.name", newTraceName); span.setAttribute("trace.name", newTraceName); span.setAttribute("gen_ai.operation.name", detectedOp); logger.debug(`${LOG_PREFIX} Updated trace-root span with detected operation`, { traceId, operation: detectedOp, newTraceName, }); } // Cleanup the detected operation this.detectedOperations.delete(traceId); } } } catch { // Wrapper span support is optional - don't fail if spanContext isn't available } // Check if this is a GenAI span (from Vercel AI SDK) const isGenAISpan = attributes["gen_ai.system"] || attributes["ai.model.id"] || attributes["gen_ai.request.model"]; if (isGenAISpan) { const model = attributes["gen_ai.request.model"] || attributes["ai.model.id"]; const provider = attributes["gen_ai.system"] || attributes["ai.model.provider"]; logger.debug(`${LOG_PREFIX} GenAI span detected`, { spanName: readableSpan.name, model, provider, }); // L4/L6 fix: Set explicit Langfuse observation attributes so // cost dashboards and model analytics work correctly. try { const mAttrs = span.attributes; // L6: Model identity if (model) { mAttrs["gen_ai.response.model"] = model; } // L4: Usage details — aggregate from AI SDK attributes into a // structured JSON object that Langfuse can parse for cost analysis. const inputTokens = attributes["gen_ai.usage.input_tokens"] ?? attributes["ai.usage.promptTokens"]; const outputTokens = attributes["gen_ai.usage.output_tokens"] ?? attributes["ai.usage.completionTokens"]; const totalTokens = attributes["gen_ai.usage.total_tokens"] ?? (inputTokens !== undefined && outputTokens !== undefined ? inputTokens + outputTokens : undefined); const reasoningTokens = attributes["gen_ai.usage.reasoning_tokens"] ?? attributes["ai.usage.reasoningTokens"]; const cachedTokens = attributes["gen_ai.usage.input_cached_tokens"]; if (inputTokens !== undefined || outputTokens !== undefined) { const usageDetails = {}; if (inputTokens !== undefined) { usageDetails.input = inputTokens; } if (outputTokens !== undefined) { usageDetails.output = outputTokens; } if (totalTokens !== undefined) { usageDetails.total = totalTokens; } if (reasoningTokens !== undefined) { usageDetails.reasoning_tokens = reasoningTokens; } if (cachedTokens !== undefined) { usageDetails.input_cached_tokens = cachedTokens; } mAttrs["langfuse.usage_details"] = JSON.stringify(usageDetails); logger.debug(`${LOG_PREFIX} Token usage captured`, { inputTokens, outputTokens, totalTokens, }); } // L7: Model parameters — surface temperature and max_tokens for // generation tuning visibility. const temperature = attributes["gen_ai.request.temperature"] ?? attributes["ai.settings.temperature"]; const maxTokens = attributes["gen_ai.request.max_tokens"] ?? attributes["ai.settings.maxTokens"]; const topP = attributes["gen_ai.request.top_p"] ?? attributes["ai.settings.topP"]; if (temperature !== undefined || maxTokens !== undefined || topP !== undefined) { const params = {}; if (temperature !== undefined) { params.temperature = temperature; } if (maxTokens !== undefined) { params.max_tokens = maxTokens; } if (topP !== undefined) { params.top_p = topP; } mAttrs["gen_ai.request.model_parameters"] = JSON.stringify(params); } } catch { // Read-only attributes — cannot enrich; Pipeline A will still // export the raw GenAI attributes that Langfuse can parse. } } // P8 fix: Propagate error status to Langfuse-consumable attributes. // OTel ReadableSpan attributes may be readonly at onEnd() time; the type // cast attempts late mutation. LangfuseSpanProcessor runs after // ContextEnricher in the spanProcessors array and reads these attributes, // so setting them here allows Langfuse to surface the correct level and // status message on the trace/generation. const readableStatus = span.status; try { const mutableAttrs = span.attributes; // Curator P0-1/P0-2: detect MCP isError pattern on AI SDK tool call spans. // The AI SDK's `ai.toolCall` span stays status=UNSET when the tool // *returns* { isError:true } (no exception thrown), so Langfuse sees // level=DEFAULT and no status message. Parse the stringified result // and surface the embedded error text. if (readableSpan.name === "ai.toolCall" && readableStatus?.code !== SpanStatusCode.ERROR) { applyToolCallIsErrorStatus(mutableAttrs); } if (readableStatus?.code === SpanStatusCode.ERROR) { mutableAttrs["langfuse.level"] = "ERROR"; if (readableStatus.message) { mutableAttrs["langfuse.status_message"] = readableStatus.message; } } else if (mutableAttrs["langfuse.level"] === undefined) { applyNonErrorLangfuseLevel(mutableAttrs); } } catch { // Readonly enforcement by OTel SDK — mutation not possible; log at debug. logger.debug(`${LOG_PREFIX} Could not set langfuse.level on span (read-only attributes)`); } } catch (error) { // Don't fail span processing on errors logger.debug(`${LOG_PREFIX} Error reading span attributes`, { error: error instanceof Error ? error.message : String(error), }); } } shutdown() { // Clean up tracked operations to prevent memory leaks this.detectedOperations.clear(); return Promise.resolve(); } forceFlush() { return Promise.resolve(); } } async function createLangfuseProcessor(config) { let mod; try { mod = await import(/* @vite-ignore */ "@langfuse/otel"); } catch (err) { const e = err instanceof Error ? err : null; if (e?.code === "ERR_MODULE_NOT_FOUND" && e.message.includes("langfuse")) { throw new Error('Langfuse observability requires "@langfuse/otel". Install it with:\n pnpm add @langfuse/otel', { cause: err }); } throw err; } return new mod.LangfuseSpanProcessor({ publicKey: config.publicKey, secretKey: config.secretKey, baseUrl: config.baseUrl || "https://cloud.langfuse.com", environment: config.environment || "dev", release: config.release || "v1.0.0", // Curator P1-3: skip internal wrapper spans that duplicate ai.toolCall / // ai.generateText observations in Langfuse. Wrappers still emit OTel spans // for internal metrics; they just aren't forwarded to Langfuse. shouldExportSpan: langfuseShouldExportSpan, }); } /** * True when a span is an internal NeuroLink wrapper that should NOT be sent to * Langfuse. Internal wrappers carry the `langfuse.internal: true` attribute. * * Exposed so host apps that bring their own `LangfuseSpanProcessor` (e.g. * `skipLangfuseSpanProcessor: true`, or manual registration on an existing * TracerProvider) can apply the same filter and avoid duplicate observations. */ export function isLangfuseInternalSpan(span) { return span.attributes?.["langfuse.internal"] === true; } /** * Drop-in `shouldExportSpan` predicate for a `LangfuseSpanProcessor` that * filters out NeuroLink internal wrapper spans. * * Usage in host apps: * ```ts * import { langfuseShouldExportSpan } from "@juspay/neurolink"; * new LangfuseSpanProcessor({ ..., shouldExportSpan: langfuseShouldExportSpan }); * ``` */ export function langfuseShouldExportSpan({ otelSpan, }) { return !isLangfuseInternalSpan(otelSpan); } async function initializeExternalOpenTelemetryMode(config, resource, otlpEndpoint, serviceName, langfuseRequested, hasLangfuseCreds) { if (langfuseRequested && !hasLangfuseCreds) { if (!otlpEndpoint) { logger.warn(`${LOG_PREFIX} External provider mode requested Langfuse but credentials are missing, and no OTLP endpoint is configured; skipping initialization`, { hasPublicKey: !!config?.publicKey, hasSecretKey: !!config?.secretKey, }); isInitialized = true; isCredentialsValid = false; return; } logger.warn(`${LOG_PREFIX} External provider mode missing Langfuse credentials; continuing with OTLP-only metrics/logs`, { hasPublicKey: !!config?.publicKey, hasSecretKey: !!config?.secretKey, otlpEnabled: true, }); } try { currentConfig = config; isCredentialsValid = hasLangfuseCreds; langfuseProcessor = langfuseRequested && hasLangfuseCreds ? await createLangfuseProcessor(config) : null; usingExternalProvider = true; isInitialized = true; initializeOtlpMetricsAndLogs(resource, otlpEndpoint, serviceName); try { const globalProvider = trace.getTracerProvider(); const provider = globalProvider; if (globalProvider && typeof provider.addSpanProcessor === "function") { provider.addSpanProcessor(new ContextEnricher()); // Auto-detect: skip if consumer already registered a LangfuseSpanProcessor. // // Detection strategy (ordered by robustness): // 1. Duck-type check for Langfuse-specific public member // (`langfuseClient` property) — survives minification. // 2. `constructor.name === "LangfuseSpanProcessor"` — last resort, // brittle under minification or bundler renaming. // // NOTE: `_registeredSpanProcessors` is an internal OpenTelemetry field. // If the OTel SDK removes or renames it, the array defaults to [] and // `hasExistingLangfuse` is false — NeuroLink registers its own processor // (same behavior as before this check). Consumers can always force skip // via `skipLangfuseSpanProcessor: true`. const existingProcessors = provider ._registeredSpanProcessors ?? []; const hasExistingLangfuse = existingProcessors.some((p) => { if (p === null || p === undefined || typeof p !== "object") { return false; } // Duck-type: Langfuse processor exposes a langfuseClient property if ("langfuseClient" in p) { return true; } // Fallback: constructor name (brittle under minification) return (p.constructor?.name === "LangfuseSpanProcessor"); }); const skipLangfuse = config.skipLangfuseSpanProcessor === true || !langfuseProcessor || hasExistingLangfuse; if (hasExistingLangfuse && !config.skipLangfuseSpanProcessor) { logger.info(`${LOG_PREFIX} Auto-detected existing LangfuseSpanProcessor — skipping SDK registration to avoid duplicates`); } if (!skipLangfuse && langfuseProcessor) { provider.addSpanProcessor(langfuseProcessor); } logger.info(`${LOG_PREFIX} Auto-registered processors with global TracerProvider`, { processors: skipLangfuse ? ["ContextEnricher"] : ["ContextEnricher", "LangfuseSpanProcessor"], reason: "External provider mode with auto-registration", skippedLangfuseSpanProcessor: skipLangfuse, }); return; } logger.info(`${LOG_PREFIX} Using external TracerProvider mode`, { reason: config.useExternalTracerProvider ? "useExternalTracerProvider=true" : "autoDetectExternalProvider=true (trusting host signal)", instructions: "Add span processors to your TracerProvider using getSpanProcessors()", }); logger.info(`${LOG_PREFIX} Span processors ready for external use`, { processors: langfuseProcessor ? ["ContextEnricher", "LangfuseSpanProcessor"] : ["ContextEnricher"], usage: "import { getSpanProcessors } from '@juspay/neurolink'", }); } catch (autoRegisterError) { logger.warn(`${LOG_PREFIX} Auto-registration failed, manual registration required`, { error: autoRegisterError instanceof Error ? autoRegisterError.message : String(autoRegisterError), instructions: "Add span processors to your TracerProvider using getSpanProcessors()", }); } } catch (error) { logger.error(`${LOG_PREFIX} Failed to create span processor for external mode`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); isInitialized = true; } } async function initializeStandaloneOpenTelemetryMode(config, resource, otlpEndpoint, serviceName, langfuseRequested, hasLangfuseCreds) { if ((!langfuseRequested || !hasLangfuseCreds) && !otlpEndpoint) { if (langfuseRequested && !hasLangfuseCreds) { logger.warn(`${LOG_PREFIX} Langfuse requested but credentials are missing, and no OTLP endpoint is configured; skipping initialization`, { hasPublicKey: !!config.publicKey, hasSecretKey: !!config.secretKey, }); } else { logger.debug(`${LOG_PREFIX} Langfuse disabled and OTLP endpoint missing, skipping initialization`); } isInitialized = true; return; } if (langfuseRequested && !hasLangfuseCreds) { logger.warn(`${LOG_PREFIX} Langfuse requested but credentials are missing; continuing with OTLP-only telemetry`, { hasPublicKey: !!config.publicKey, hasSecretKey: !!config.secretKey, otlpEnabled: !!otlpEndpoint, }); } try { currentConfig = config; isCredentialsValid = hasLangfuseCreds; langfuseProcessor = langfuseRequested && hasLangfuseCreds ? await createLangfuseProcessor(config) : null; logger.debug(`${LOG_PREFIX} Standalone observability mode`, { langfuseEnabled: !!langfuseProcessor, otlpEnabled: !!otlpEndpoint, baseUrl: config.baseUrl || "https://cloud.langfuse.com", environment: config.environment || "dev", }); const spanProcessors = [new ContextEnricher()]; if (langfuseProcessor) { spanProcessors.push(langfuseProcessor); } if (otlpEndpoint) { try { const otlpExporter = new OTLPTraceExporter({ url: `${otlpEndpoint}/v1/traces`, }); spanProcessors.push(new BatchSpanProcessor(otlpExporter, { maxQueueSize: 2048, maxExportBatchSize: 512, scheduledDelayMillis: 1000, exportTimeoutMillis: 30000, })); logger.info(`${LOG_PREFIX} OTLP trace exporter added`, { endpoint: `${otlpEndpoint}/v1/traces`, serviceName, }); } catch (otlpError) { logger.warn(`${LOG_PREFIX} Failed to create OTLP exporter (non-fatal)`, { error: otlpError instanceof Error ? otlpError.message : String(otlpError), endpoint: otlpEndpoint, }); } } tracerProvider = new NodeTracerProvider({ resource, spanProcessors }); tracerProvider.register({ propagator: new W3CTraceContextPropagator(), }); usingExternalProvider = false; isInitialized = true; initializeOtlpMetricsAndLogs(resource, otlpEndpoint, serviceName); logger.info(`${LOG_PREFIX} Observability initialized`, { baseUrl: config.baseUrl || "https://cloud.langfuse.com", environment: config.environment || "dev", release: config.release || "v1.0.0", mode: "standalone", langfuseEnabled: !!langfuseProcessor, otlpEnabled: !!otlpEndpoint, serviceName, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const isDuplicateError = errorMessage.includes("duplicate registration") || errorMessage.includes("already registered") || errorMessage.includes("already set"); if (isDuplicateError) { logger.warn(`${LOG_PREFIX} TracerProvider already registered, switching to external mode`, { error: errorMessage, recommendation: "Set useExternalTracerProvider=true or autoDetectExternalProvider=true in config", }); usingExternalProvider = true; isInitialized = true; return; } logger.error(`${LOG_PREFIX} Initialization failed`, { error: errorMessage, stack: error instanceof Error ? error.stack : undefined, }); throw error; } } /** * Initialize OpenTelemetry with Langfuse span processor * * This connects Vercel AI SDK's experimental_telemetry to Langfuse by: * 1. Creating LangfuseSpanProcessor with Langfuse credentials * 2. Creating a NodeTracerProvider with service metadata and span processor * 3. Registering the provider globally for AI SDK to use * * NEW: If useExternalTracerProvider is true or autoDetectExternalProvider detects * an existing provider, steps 2 and 3 are skipped. The span processors are still * created and can be retrieved via getSpanProcessors(). * * @param config - Langfuse configuration passed from parent application */ export async function initializeOpenTelemetry(config) { // Guard against multiple initializations — but always update config // so that later NeuroLink instances can change traceNameFormat, // autoDetectOperationName, and other configuration preferences // without re-initializing the OTEL infrastructure. if (isInitialized) { currentConfig = config; logger.debug(`${LOG_PREFIX} Already initialized, config updated`, { usingExternalProvider, hasLangfuseProcessor: !!langfuseProcessor, hasTraceNameFormat: typeof config.traceNameFormat === "function", }); return; } // FIRST: Check for external provider mode - bypasses enabled check // NOTE: When autoDetectExternalProvider is true, we trust the flag directly rather than // calling hasExternalTracerProvider(). This is because Neurolink may bundle its own copy // of @opentelemetry/api, which has a separate global state from the host application. // The hasExternalTracerProvider() check would query Neurolink's bundled @opentelemetry/api // global state (which has no provider registered), not the host's global state. // By trusting autoDetectExternalProvider=true, we let the host application signal that // it has already registered a TracerProvider. const shouldUseExternal = config?.useExternalTracerProvider === true || config?.autoDetectExternalProvider === true; const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; const langfuseRequested = config?.enabled === true; const hasLangfuseCreds = !!config.publicKey && !!config.secretKey; const serviceName = process.env.OTEL_SERVICE_NAME || "neurolink"; const resource = createOtelResource(config, serviceName); if (shouldUseExternal) { await initializeExternalOpenTelemetryMode(config, resource, otlpEndpoint, serviceName, langfuseRequested, hasLangfuseCreds); return; } await initializeStandaloneOpenTelemetryMode(config, resource, otlpEndpoint, serviceName, langfuseRequested, hasLangfuseCreds); } /** * Flush all pending spans to Langfuse */ export async function flushOpenTelemetry() { if (!isInitialized) { logger.debug(`${LOG_PREFIX} Not initialized, skipping flush`); return; } const failures = []; if (langfuseProcessor) { try { logger.info(`${LOG_PREFIX} Flushing Langfuse spans...`); await langfuseProcessor.forceFlush(); } catch (error) { failures.push({ signal: "langfuse", error }); logger.error(`${LOG_PREFIX} Langfuse flush failed`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); } } else { logger.debug(`${LOG_PREFIX} Langfuse disabled, skipping Langfuse flush`); } if (tracerProvider && !usingExternalProvider) { try { logger.info(`${LOG_PREFIX} Flushing OTLP traces...`); await tracerProvider.forceFlush(); } catch (error) { failures.push({ signal: "traces", error }); logger.error(`${LOG_PREFIX} Trace flush failed`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); } } else { logger.debug(`${LOG_PREFIX} No TracerProvider to flush`); } if (meterProvider) { try { logger.info(`${LOG_PREFIX} Flushing OTLP metrics...`); await meterProvider.forceFlush(); } catch (error) { failures.push({ signal: "metrics", error }); logger.error(`${LOG_PREFIX} Metric flush failed`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); } } else { logger.debug(`${LOG_PREFIX} No MeterProvider to flush`); } if (loggerProvider) { try { logger.info(`${LOG_PREFIX} Flushing OTLP logs...`); await loggerProvider.forceFlush(); } catch (error) { failures.push({ signal: "logs", error }); logger.error(`${LOG_PREFIX} Log flush failed`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); } } else { logger.debug(`${LOG_PREFIX} No LoggerProvider to flush`); } if (failures.length > 0) { throw new Error(`${LOG_PREFIX} Flush failed for: ${failures.map((f) => f.signal).join(", ")}`); } logger.info(`${LOG_PREFIX} Flush complete`); } /** * Shutdown OpenTelemetry and Langfuse span processor */ export async function shutdownOpenTelemetry() { if (!isInitialized) { return; } try { // Only shutdown tracerProvider if we created it if (tracerProvider && !usingExternalProvider) { await tracerProvider.shutdown(); } // Always shutdown the Langfuse processor if (langfuseProcessor) { await langfuseProcessor.shutdown(); } // Shutdown cached ContextEnricher if (cachedContextEnricher) { await cachedContextEnricher.shutdown(); } // Shutdown MeterProvider if we created it if (meterProvider) { await meterProvider.shutdown(); } // Shutdown LoggerProvider if we created it if (loggerProvider) { await loggerProvider.shutdown(); } tracerProvider = null; meterProvider = null; loggerProvider = null; langfuseProcessor = null; cachedContextEnricher = null; isInitialized = false; isCredentialsValid = false; usingExternalProvider = false; logger.debug(`${LOG_PREFIX} Shutdown complete`); } catch (error) { logger.error(`${LOG_PREFIX} Shutdown failed`, { error: error instanceof Error ? error.message : String(error), }); } } /** * Get the Langfuse span processor */ export function getLangfuseSpanProcessor() { return langfuseProcessor; } /** * Get the tracer provider */ export function getTracerProvider() { return tracerProvider; } /** * Get the logger provider for emitting OTLP log records. * Returns null if OTLP is not configured or LoggerProvider was not created. */ export function getLoggerProvider() { return loggerProvider; } /** * Check if OpenTelemetry is initialized */ export function isOpenTelemetryInitialized() { return isInitialized; } /** * Get health status for Langfuse observability * * @returns Health status object with initialization and configuration details */ export function getLangfuseHealthStatus() { return { isHealthy: !!(currentConfig?.enabled && isInitialized && isCredentialsValid && langfuseProcessor !== null), initialized: isInitialized, credentialsValid: isCredentialsValid, enabled: currentConfig?.enabled || false, hasProcessor: langfuseProcessor !== null, usingExternalProvider, config: currentConfig ? { baseUrl: currentConfig.baseUrl || "https://cloud.langfuse.com", environment: currentConfig.environment || "dev", release: currentConfig.release || "v1.0.0", } : undefined, }; } /** * Set user and session context for Langfuse spans in the current async context * * Merges the provided context with existing AsyncLocalStorage context. If a callback is provided, * the context is scoped to that callback execution and returns the callback's result. * Without a callback, the context applies to the current execution context and its children. * * Uses AsyncLocalStorage to properly scope context per request, avoiding race conditions * in concurrent scenarios. * * @param context - Object containing context fields to merge with existing context * @param callback - Optional callback to run within the context scope. If omitted, context applies to current execution * @returns The callback's return value if provided, otherwise void * * @example * // With callback - returns the result * const result = await setLangfuseContext({ userId: "user123" }, async () => { * return await generateText({ model: "gpt-4", prompt: "Hello" }); * }); * * @example * // Without callback - sets context for current execution * await setLangfuseContext({ sessionId: "session456", traceName: "chat-completion" }); */ export async function setLangfuseContext(context, callback) { const currentContext = contextStorage.getStore() || {}; const newContext = { userId: context.userId !== undefined ? context.userId : currentContext.userId, sessionId: context.sessionId !== undefined ? context.sessionId : currentContext.sessionId, conversationId: context.conversationId !== undefined ? context.conversationId : currentContext.conversationId, requestId: context.requestId !== undefined ? context.requestId : currentContext.requestId, traceName: context.traceName !== undefined ? context.traceName : currentContext.traceName, metadata: context.metadata !== undefined ? context.metadata : currentContext.metadata, // Operation name support operationName: context.operationName !== undefined ? context.operationName : currentContext.operationName, autoDetectOperationName: context.autoDetectOperationName !== undefined ? context.autoDetectOperationName : currentContext.autoDetectOperationName, // Custom attributes support customAttributes: context.customAttributes !== undefined ? context.customAttributes : currentContext.customAttributes, }; if (callback) { return await contextStorage.run(newContext, callback); } else { contextStorage.enterWith(newContext); } } /** * Get the current Langfuse context from AsyncLocalStorage * * Returns the current context including userId, sessionId, conversationId, * requestId, traceName, and metadata. Returns undefined if no context is set. * * @returns The current LangfuseContext or undefined * * @example * const context = getLangfuseContext(); * console.log(context?.userId, context?.sessionId); */ export function getLangfuseContext() { return contextStorage.getStore(); } /** * Capture the current Langfuse AsyncLocal