UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

286 lines (285 loc) • 12.9 kB
import { propagateAttributes, setLangfuseTracerProvider, } from "@langfuse/tracing"; import { context, createContextKey, metrics, ProxyTracerProvider, trace, } from "@opentelemetry/api"; import { W3CTraceContextPropagator } from "@opentelemetry/core"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; import { OTEL_LOG_PREFIX, TRACE_METADATA_CONTEXT_KEY, } from "../../../../constants/telemetry.js"; import { getExporterRegistry } from "../../../../observability/exporterRegistry.js"; import { ExporterSpanProcessor } from "../../../../observability/exporterSpanProcessor.js"; import { LangfuseExporter } from "../../../../observability/exporters/langfuseExporter.js"; import { spanJsonAttribute } from "../../../../telemetry/attributes.js"; import { logger } from "../../../../utils/logger.js"; import { createLangfuseExporter, isLangfuseEnabled } from "./langfuseSetup.js"; import { createOtlpMeterProvider, createOtlpSpanProcessor, } from "./otlpSetup.js"; const MAX_PROPAGATED_CHARS = 200; const LANGFUSE_EXPORTER_NAME = "langfuse"; const TRACE_METADATA_KEY = createContextKey(TRACE_METADATA_CONTEXT_KEY); let tracerProvider = null; let meterProvider = null; let langfuseExporter = null; let usingExternalProvider = false; let started = false; let initialized = false; let ignoredConfigLogged = false; let initializationError = null; let terminated = false; let terminationLogged = false; let unconfiguredLogged = false; let holders = 0; function hasHostTracerProvider() { const global = trace.getTracerProvider(); if (!(global instanceof ProxyTracerProvider)) { return true; } return global.getDelegateTracer("ai") !== undefined; } function registerLangfuseExporter(config) { const registry = getExporterRegistry(); const existing = registry.get(LANGFUSE_EXPORTER_NAME); if (existing) { return existing instanceof LangfuseExporter ? existing : null; } const exporter = createLangfuseExporter(config); if (exporter) { registry.register(exporter); } return exporter; } function initialize(config) { const langfuse = isLangfuseEnabled(config); const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; if (!langfuse && !otlpEndpoint) { if (!unconfiguredLogged) { unconfiguredLogged = true; logger.debug(`${OTEL_LOG_PREFIX} Langfuse and OTLP not configured; skipping`); } return config?.langfuse === false; } if (hasHostTracerProvider()) { usingExternalProvider = true; initialized = true; langfuseExporter = langfuse ? registerLangfuseExporter(config) : null; if (langfuseExporter) { logger.info(`${OTEL_LOG_PREFIX} Host TracerProvider detected; Langfuse exporter registered. Add \`createExporterSpanProcessor()\` to the host provider to export NeuroLink spans to it.`); } else { logger.info(`${OTEL_LOG_PREFIX} Host TracerProvider detected; NeuroLink registers no provider and no exporter.`); } return true; } const resource = resourceFromAttributes({ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || "ai-service", }); const exporter = langfuse ? registerLangfuseExporter(config) : null; const otlpProcessor = otlpEndpoint ? createOtlpSpanProcessor(otlpEndpoint) : null; const spanProcessors = [ new ExporterSpanProcessor(getExporterRegistry()), ]; if (otlpProcessor) { spanProcessors.push(otlpProcessor); } const provider = new NodeTracerProvider({ resource, spanProcessors }); provider.register({ propagator: new W3CTraceContextPropagator() }); setLangfuseTracerProvider(provider); tracerProvider = provider; langfuseExporter = exporter; meterProvider = otlpEndpoint ? createOtlpMeterProvider(otlpEndpoint, resource) : null; usingExternalProvider = false; initialized = true; logger.info(`${OTEL_LOG_PREFIX} Observability initialized`, { langfuseEnabled: exporter !== null, otlpEnabled: otlpProcessor !== null, }); return true; } async function stop() { const provider = tracerProvider; const meters = meterProvider; const hostExporter = usingExternalProvider ? langfuseExporter : null; terminated = true; tracerProvider = null; meterProvider = null; langfuseExporter = null; usingExternalProvider = false; started = false; initialized = false; initializationError = null; if (provider) { setLangfuseTracerProvider(null); } if (meters) { metrics.disable(); } if (hostExporter) { getExporterRegistry().unregister(hostExporter.getName()); } const results = await Promise.allSettled([ provider?.shutdown(), hostExporter?.shutdown(), meters?.shutdown(), ]); for (const result of results) { if (result.status === "rejected") { logger.error(`${OTEL_LOG_PREFIX} Shutdown failed`, { error: result.reason instanceof Error ? result.reason.message : String(result.reason), }); } } } /** * @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. * @param config {ObservabilityConfig | undefined} - Observability options, honoured only when this is the first call. * @returns {void} Nothing; a failed setup is logged and recorded in `getLangfuseHealthStatus`. */ export function ensureTelemetry(config) { if (terminated) { if (!terminationLogged) { terminationLogged = true; logger.debug(`${OTEL_LOG_PREFIX} Telemetry was shut down for this process; not restarting`); } return; } if (started) { if (config && !ignoredConfigLogged) { ignoredConfigLogged = true; logger.debug(`${OTEL_LOG_PREFIX} Telemetry already started; ignoring observability config`); } return; } initializationError = null; try { started = initialize(config); } catch (error) { started = true; initializationError = error instanceof Error ? error.message : String(error); logger.error(`${OTEL_LOG_PREFIX} Initialization failed`, { error: initializationError, }); } } /** * @description Registers a holder of the process-wide telemetry and starts it if needed. Pair every call with `releaseTelemetry`. * @param config {ObservabilityConfig | undefined} - Observability options forwarded to `ensureTelemetry`. * @returns {void} Nothing. */ export function acquireTelemetry(config) { holders++; ensureTelemetry(config); } /** * @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. * @returns {Promise<void>} Resolves when pending spans are flushed. * @throws {Error} When any provider fails to flush. */ export async function releaseTelemetry() { holders = Math.max(0, holders - 1); await flushOpenTelemetry(); } /** * @description Exports pending spans and metrics from the providers NeuroLink owns and flushes its Langfuse exporter. A host provider is never flushed. * @returns {Promise<void>} Resolves when every owned provider has flushed. * @throws {Error} When any provider fails to flush. */ export async function flushOpenTelemetry() { const results = await Promise.allSettled([ tracerProvider?.forceFlush(), usingExternalProvider ? langfuseExporter?.flush() : undefined, meterProvider?.forceFlush(), ]); const failed = results.filter((result) => result.status === "rejected"); if (failed.length > 0) { throw new Error(`${OTEL_LOG_PREFIX} Flush failed`, { cause: failed.map((result) => result.reason), }); } } /** * @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. * @returns {Promise<void>} Resolves when telemetry is flushed or shut down. */ export async function shutdownOpenTelemetry() { if (holders > 0) { await flushOpenTelemetry(); return; } await stop(); } /** * @description Reports whether NeuroLink's tracing is active, either on its own provider or alongside a host provider. * @returns {boolean} `true` once `ensureTelemetry` has set tracing up. */ export function isOpenTelemetryInitialized() { return initialized; } /** * @description Summarizes the Langfuse export state for health checks. * @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. */ export function getLangfuseHealthStatus() { return { isHealthy: initialized && langfuseExporter !== null, initialized, hasProcessor: langfuseExporter !== null, usingExternalProvider, ...(initializationError !== null && { error: initializationError }), }; } /** * @description Reads the full trace metadata that `setLangfuseContext` stored on a context. * @param ctx {Context} - Context to read, normally the active one. * @returns {Record<string, SpanContent>} Metadata set by enclosing `setLangfuseContext` calls, inner keys winning; empty when none was set. */ export function traceMetadataFromContext(ctx) { const stored = ctx.getValue(TRACE_METADATA_KEY); return stored && typeof stored === "object" ? stored : {}; } /** * @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. * @param params {NeurolinkTraceContext} - User, session, trace name, tags and metadata for the trace. * @param fn {() => T} - Work to run inside the propagated context. * @returns {T} Whatever `fn` returns. * @example * await setLangfuseContext({ userId: "u1", sessionId: "s1" }, () => neurolink.generate({ input: { text: "hi" } })); */ export function setLangfuseContext(params, fn) { const full = {}; const propagated = {}; for (const [key, value] of Object.entries({ ...params.metadata, conversationId: params.conversationId, requestId: params.requestId, })) { if (value === undefined || value === null || value === "") { continue; } full[key] = value; const text = spanJsonAttribute(value, Number.POSITIVE_INFINITY); if (text.length <= MAX_PROPAGATED_CHARS) { propagated[key] = text; } } const tags = params.tags?.filter((tag) => tag.length > 0); const active = context.active(); const withMetadata = active.setValue(TRACE_METADATA_KEY, { ...traceMetadataFromContext(active), ...full, }); return context.with(withMetadata, () => propagateAttributes({ userId: params.userId || undefined, sessionId: params.sessionId || undefined, traceName: params.traceName || undefined, ...(tags && tags.length > 0 && { tags }), ...(Object.keys(propagated).length > 0 && { metadata: propagated }), }, fn)); }