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

182 lines (181 loc) • 7.3 kB
import { getPropagatedAttributesFromContext } from "@langfuse/core"; import { SpanStatusCode } from "@opentelemetry/api"; import { MAX_REMEMBERED_SPANS } from "../constants/telemetry.js"; import { SpanStatus, SpanType } from "../types/index.js"; import { logger } from "../utils/logger.js"; import { getExporterRegistry } from "./exporterRegistry.js"; const LANGFUSE_OBSERVATION_TYPE = "langfuse.observation.type"; const GEN_AI_OBSERVATION_TYPE = "gen_ai.observation.type"; const GEN_AI_OPERATION = "gen_ai.operation.name"; const OBSERVATION_SPAN_TYPES = { agent: SpanType.AGENT_RUN, tool: SpanType.TOOL_CALL, chain: SpanType.WORKFLOW_STEP, retriever: SpanType.RETRIEVAL, evaluator: SpanType.EVALUATION, embedding: SpanType.EMBEDDING, }; function isObservation(span) { return (span.attributes[LANGFUSE_OBSERVATION_TYPE] !== undefined || span.attributes[GEN_AI_OBSERVATION_TYPE] !== undefined || Object.keys(span.attributes).some((key) => key.startsWith("gen_ai."))); } function spanTypeOf(span) { const observationType = String(span.attributes[LANGFUSE_OBSERVATION_TYPE] ?? "").toLowerCase(); if (observationType === "generation") { return span.attributes[GEN_AI_OPERATION] === "decide" ? SpanType.MODEL_DECISION : SpanType.MODEL_GENERATION; } return OBSERVATION_SPAN_TYPES[observationType] ?? SpanType.CUSTOM; } const STATUS_BY_CODE = { [SpanStatusCode.UNSET]: SpanStatus.UNSET, [SpanStatusCode.OK]: SpanStatus.OK, [SpanStatusCode.ERROR]: SpanStatus.ERROR, }; function toMillis(time) { return time[0] * 1_000 + time[1] / 1_000_000; } function toIso(time) { return new Date(toMillis(time)).toISOString(); } function toSpanData(span, parentSpanId) { const { traceId, spanId } = span.spanContext(); return { spanId, traceId, ...(parentSpanId && { parentSpanId }), type: spanTypeOf(span), name: span.name, startTime: toIso(span.startTime), endTime: toIso(span.endTime), durationMs: toMillis(span.duration), status: STATUS_BY_CODE[span.status.code], ...(span.status.message && { statusMessage: span.status.message }), attributes: { ...span.attributes }, events: span.events.map((event) => ({ name: event.name, timestamp: toIso(event.time), ...(event.attributes && { attributes: { ...event.attributes } }), })), links: span.links.map((link) => ({ traceId: link.context.traceId, spanId: link.context.spanId, ...(link.attributes && { attributes: { ...link.attributes } }), })), }; } /** * @description OpenTelemetry span processor that turns every observation span into `SpanData` and hands it to an `ExporterRegistry`, so one instrumentation feeds Langfuse, Datadog, Sentry and every other registered exporter. Spans that are not observations are skipped and their children are re-parented onto the nearest exported ancestor. Delivery never blocks the span's thread. */ export class ExporterSpanProcessor { registry; open = new Map(); ended = new Map(); /** * @description Creates a processor bound to a registry. * @param registry {ExporterRegistry} - Registry that receives every exported span. */ constructor(registry) { this.registry = registry; } /** * @description Copies the propagated Langfuse trace attributes (user, session, trace name, tags, metadata) from the parent context onto the new span and remembers it until it ends. * @param span {Span} - Span that just started. * @param parentContext {Context} - Context the span was started in. * @returns {void} Nothing. */ onStart(span, parentContext) { span.setAttributes(getPropagatedAttributesFromContext(parentContext)); this.open.set(span.spanContext().spanId, span); if (this.open.size > MAX_REMEMBERED_SPANS) { const oldest = this.open.keys().next().value; if (oldest !== undefined) { this.open.delete(oldest); } } } /** * @description Exports an ended observation span without waiting for the exporters. * @param span {ReadableSpan} - Span that just ended. * @returns {void} Nothing. */ onEnd(span) { const { spanId } = span.spanContext(); this.open.delete(spanId); const observation = isObservation(span); this.remember(spanId, span.parentSpanContext?.spanId, observation); if (!observation) { return; } const spanData = toSpanData(span, this.nearestExportedAncestor(span)); void this.exportQuietly(spanData); } /** * @description Flushes every exporter in the registry. * @returns {Promise<void>} Resolves when all exporters have flushed. */ forceFlush() { return this.registry.flushAll(); } /** * @description Shuts down every exporter in the registry. * @returns {Promise<void>} Resolves when all exporters have shut down. */ shutdown() { return this.registry.shutdownAll(); } async exportQuietly(spanData) { try { await this.registry.exportToAll(spanData); } catch (error) { logger.debug("[ExporterSpanProcessor] Export failed", { spanId: spanData.spanId, error: error instanceof Error ? error.message : String(error), }); } } remember(spanId, parentSpanId, observation) { this.ended.set(spanId, { parentSpanId, observation }); if (this.ended.size > MAX_REMEMBERED_SPANS) { const oldest = this.ended.keys().next().value; if (oldest !== undefined) { this.ended.delete(oldest); } } } nearestExportedAncestor(span) { let candidate = span.parentSpanContext?.spanId; while (candidate) { const live = this.open.get(candidate); if (live) { if (isObservation(live)) { return candidate; } candidate = live.parentSpanContext?.spanId; continue; } const finished = this.ended.get(candidate); if (!finished) { return undefined; } if (finished.observation) { return candidate; } candidate = finished.parentSpanId; } return undefined; } } /** * @description Creates a span processor that feeds NeuroLink's observation spans to an exporter registry. Add it to a host `TracerProvider` to deliver spans through the registered exporters (such as `LangfuseExporter`) instead of `LangfuseSpanProcessor`. * @param registry {ExporterRegistry} - Registry to export to; defaults to the process-wide registry. * @returns {ExporterSpanProcessor} A processor ready to be added to a TracerProvider. * @example * const provider = new NodeTracerProvider({ spanProcessors: [createExporterSpanProcessor()] }); */ export function createExporterSpanProcessor(registry = getExporterRegistry()) { return new ExporterSpanProcessor(registry); }