Skip to main content

Observability Guide

Enterprise-grade observability for AI operations with Langfuse and OpenTelemetry integration.

Overview​

NeuroLink provides comprehensive observability features for monitoring AI operations in production:

  • Langfuse Integration: LLM-specific observability with token tracking, cost analysis, and trace visualization
  • OpenTelemetry Support: Standard distributed tracing compatible with Jaeger, Zipkin, and other backends
  • External Provider Mode: Integrate with existing OpenTelemetry instrumentation without conflicts
  • Context Propagation: Automatic context enrichment with user, session, and custom metadata

For the Claude proxy's local OpenObserve stack and maintained dashboard, use Claude Proxy and Claude Proxy Observability. Those guides cover neurolink proxy telemetry setup, dashboard import, stream names, and how to interpret proxy-specific logs, metrics, and traces.

Quick Start​

Basic Langfuse Setup​

import { NeuroLink } from "@juspay/neurolink";

const neurolink = new NeuroLink({
observability: {
langfuse: {
enabled: true,
publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
secretKey: process.env.LANGFUSE_SECRET_KEY!,
baseUrl: "https://cloud.langfuse.com",
environment: "production",
release: "1.0.0",
},
},
});

Environment Variables​

# Langfuse credentials
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_BASE_URL=https://cloud.langfuse.com # or self-hosted

# Optional defaults
LANGFUSE_ENVIRONMENT=production
LANGFUSE_RELEASE=1.0.0

Context Management​

Setting Context​

Use setLangfuseContext to attach metadata to all spans in an async context:

import { setLangfuseContext } from "@juspay/neurolink";

// With callback - context is scoped to callback execution
const result = await setLangfuseContext(
{
userId: "user-123",
sessionId: "session-456",
conversationId: "conv-789",
requestId: "req-abc",
traceName: "customer-support-chat",
metadata: {
feature: "support",
tier: "premium",
region: "us-east-1",
},
},
async () => {
return await neurolink.generate("Hello");
},
);

// Without callback - context applies to current execution
await setLangfuseContext({
userId: "user-123",
sessionId: "session-456",
});

Context Fields​

FieldPurpose
userIdIdentify the user for per-user analytics
sessionIdGroup traces within a user session
conversationIdGroup traces in a conversation thread
requestIdCorrelate with application logs
traceNameCustom name in Langfuse UI
metadataKey-value pairs for filtering and analysis

Reading Context​

import { getLangfuseContext } from "@juspay/neurolink";

const context = getLangfuseContext();
if (context) {
console.log(
`User: ${context.userId}, Conversation: ${context.conversationId}`,
);
}

Operation Name Support​

NeuroLink automatically detects operation names from AI SDK spans and includes them in trace names for better observability. This provides immediate visibility into what type of AI operation is being performed.

Operation Name Configuration​

By default, NeuroLink automatically detects operation names from:

  • Vercel AI SDK spans: Spans starting with ai. (e.g., ai.streamText, ai.generateText, ai.embed)
  • OpenTelemetry GenAI conventions: Standard semantic convention operations (chat, embeddings, text_completion)
const neurolink = new NeuroLink({
observability: {
langfuse: {
enabled: true,
publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
secretKey: process.env.LANGFUSE_SECRET_KEY!,
autoDetectOperationName: true, // Enabled by default
},
},
});

When auto-detection is enabled, traces automatically include the detected operation:

Trace Name Formats​

Control how trace names are constructed using the traceNameFormat option:

FormatExample OutputDescription
"userId:operationName"[email protected]:ai.streamTextDefault format, user first
"operationName:userId"ai.streamText:[email protected]Operation first
"operationName"ai.streamTextOperation only
"userId"[email protected]User only (legacy behavior)
Custom functionCustom outputFull control over format
// Global configuration with format
const neurolink = new NeuroLink({
observability: {
langfuse: {
enabled: true,
publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
secretKey: process.env.LANGFUSE_SECRET_KEY!,
autoDetectOperationName: true,
traceNameFormat: "operationName:userId", // Operation first
},
},
});

Custom Format Function​

For full control over trace naming, provide a custom function:

const neurolink = new NeuroLink({
observability: {
langfuse: {
enabled: true,
publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
secretKey: process.env.LANGFUSE_SECRET_KEY!,
autoDetectOperationName: true,
traceNameFormat: (context) => {
// Custom logic for trace name
const env = process.env.NODE_ENV === "production" ? "prod" : "dev";
if (context.operationName && context.userId) {
return `[${env}] ${context.operationName} - ${context.userId}`;
}
return context.operationName || context.userId || "unknown";
},
},
},
});
// Output: "[prod] ai.streamText - [email protected]"

Context-Level Configuration​

Override operation name behavior at the context level:

import { setLangfuseContext } from "@juspay/neurolink";

// Explicit operation name (overrides auto-detection)
await setLangfuseContext(
{
userId: "user-123",
operationName: "custom-rag-pipeline",
},
async () => {
return await neurolink.generate("Hello");
},
);
// Trace name: "user-123:custom-rag-pipeline"

// Disable auto-detection for specific context
await setLangfuseContext(
{
userId: "user-123",
autoDetectOperationName: false, // Override global setting
},
async () => {
return await neurolink.generate("Hello");
},
);
// Trace name: "user-123" (legacy behavior)

// Enable auto-detection when globally disabled
await setLangfuseContext(
{
userId: "user-123",
autoDetectOperationName: true, // Enable for this context
},
async () => {
return await neurolink.generate("Hello");
},
);
// Trace name: "user-123:ai.generateText"

Backward Compatibility​

Operation name support is fully backward compatible:

  1. Explicit traceName takes priority: If you set traceName in context, it always overrides auto-detected names:

    await setLangfuseContext(
    {
    userId: "user-123",
    traceName: "my-custom-trace", // This takes priority
    operationName: "ignored-operation",
    },
    async () => {
    return await neurolink.generate("Hello");
    },
    );
    // Trace name: "my-custom-trace"
  2. Disable for legacy behavior: Set autoDetectOperationName: false to restore previous behavior:

    const neurolink = new NeuroLink({
    observability: {
    langfuse: {
    enabled: true,
    publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
    secretKey: process.env.LANGFUSE_SECRET_KEY!,
    autoDetectOperationName: false, // Legacy behavior
    },
    },
    });
    // Trace names will be userId only, as before
  3. Existing code works unchanged: Code using traceName continues to work exactly as before:

    // This still works exactly as before
    await setLangfuseContext(
    {
    userId: "user-123",
    sessionId: "session-456",
    traceName: "customer-support-chat",
    },
    async () => {
    return await neurolink.generate("Hello");
    },
    );
    // Trace name: "customer-support-chat"

Priority Order​

When determining the trace name, NeuroLink follows this priority order:

  1. Explicit traceName in context (highest priority)
  2. Explicit operationName in context + userId (formatted per traceNameFormat)
  3. Auto-detected operation name from span + userId (if autoDetectOperationName is enabled)
  4. userId only (fallback)

Wrapper Span Support​

When host applications create wrapper spans (trace-root spans) before AI operations, the standard auto-detection in onStart() fails because the AI SDK span does not exist yet at wrapper span creation time.

The Problem:

// Host app creates wrapper span first
const span = tracer.startSpan("my-operation"); // onStart() runs here - no AI span yet
await neurolink.generate("Hello"); // AI SDK creates "ai.generateText" span later
span.end();

At the time the wrapper span starts, there is no AI SDK span to detect the operation from, so the trace name would only include the userId.

The Solution:

NeuroLink automatically handles this by detecting operations from child spans and updating the trace name when the wrapper span ends:

  1. Wrapper span starts - onStart() sets traceName to just userId (e.g., user-123)
  2. AI SDK span starts - onStart() detects ai.streamText and stores operation in a map keyed by traceId
  3. Wrapper span ends - onEnd() looks up the stored operation and updates traceName to user-123:ai.streamText

This behavior is automatic and requires no code changes in host applications. The trace name in Langfuse will correctly include both the userId and the detected operation name.

Custom Spans​

Create custom spans for detailed tracing:

import { getTracer, setLangfuseContext } from "@juspay/neurolink";

const tracer = getTracer("my-app", "1.0.0");

await setLangfuseContext({ userId: "user-123" }, async () => {
const span = tracer.startSpan("process-request");
try {
// Add custom attributes
span.setAttribute("request.type", "chat");
span.setAttribute("model", "gpt-4");

const result = await neurolink.generate("Hello");

span.setAttribute("tokens.total", result.usage?.totalTokens ?? 0);
return result;
} catch (error) {
span.recordException(error as Error);
throw error;
} finally {
span.end();
}
});

Proxy Observability​

The NeuroLink proxy automatically initializes OpenTelemetry and exports three signal types (traces, metrics, logs) via OTLP HTTP when OTEL_EXPORTER_OTLP_ENDPOINT is set. Each proxy request creates an OTel span with token usage, model, cost, and rate-limit attributes. Request log entries include traceId and spanId for cross-signal correlation. See the Telemetry Guide for details.

External TracerProvider Mode​

If your application already has OpenTelemetry instrumentation (e.g., for HTTP, database tracing), use external provider mode to avoid "duplicate registration" errors. Note: TelemetryService now automatically detects and reuses an existing global TracerProvider, so in many cases you no longer need explicit external provider configuration.

Configuration​

import { NeuroLink, getSpanProcessors } from "@juspay/neurolink";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";

// 1. Initialize NeuroLink with external provider mode
const neurolink = new NeuroLink({
observability: {
langfuse: {
enabled: true,
publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
secretKey: process.env.LANGFUSE_SECRET_KEY!,
useExternalTracerProvider: true, // Don't create TracerProvider
},
},
});

// 2. Get NeuroLink's span processors
const neurolinkProcessors = getSpanProcessors();
// Returns: [ContextEnricher, LangfuseSpanProcessor]

// 3. Add to your existing OTEL setup
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";

const jaegerExporter = new OTLPTraceExporter({
url: "http://jaeger:4318/v1/traces",
});
const sdk = new NodeSDK({
spanProcessors: [
new BatchSpanProcessor(jaegerExporter),
...neurolinkProcessors,
],
});
sdk.start();

Auto-Detection Mode​

Alternatively, let NeuroLink auto-detect external providers:

const neurolink = new NeuroLink({
observability: {
langfuse: {
enabled: true,
publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
secretKey: process.env.LANGFUSE_SECRET_KEY!,
autoDetectExternalProvider: true, // Auto-detect and skip if needed
},
},
});

Available Exports​

ExportDescription
getSpanProcessors()Returns [ContextEnricher, LangfuseSpanProcessor]
createContextEnricher()Factory for creating ContextEnricher instances
isUsingExternalTracerProvider()Check if in external provider mode
getLangfuseSpanProcessor()Get the LangfuseSpanProcessor directly
getTracerProvider()Get the TracerProvider (null in external mode)

Vercel AI SDK Integration​

If your application also uses the Vercel AI SDK, NeuroLink's ContextEnricher reads the GenAI semantic-convention attributes that experimental_telemetry emits. NeuroLink itself has no dependency on the ai package — the imports below are your application's, and the SDK is not required to use NeuroLink:

import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import { setLangfuseContext } from "@juspay/neurolink";

await setLangfuseContext(
{ userId: "user-123", conversationId: "conv-456" },
async () => {
const result = await generateText({
model: openai("gpt-4"),
prompt: "Explain quantum computing",
experimental_telemetry: {
isEnabled: true,
functionId: "explain-topic",
},
});
// Token usage, model info, and finish reason automatically captured
return result;
},
);

Captured Attributes​

The ContextEnricher automatically reads these GenAI attributes:

  • gen_ai.system - AI provider (openai, anthropic, etc.)
  • gen_ai.request.model - Model requested
  • gen_ai.usage.input_tokens - Input tokens used
  • gen_ai.usage.output_tokens - Output tokens used
  • ai.finishReason - Why generation finished

Health Monitoring​

Check Langfuse health status:

import { getLangfuseHealthStatus } from "@juspay/neurolink";

const status = getLangfuseHealthStatus();
console.log({
isHealthy: status.isHealthy,
initialized: status.initialized,
credentialsValid: status.credentialsValid,
enabled: status.enabled,
hasProcessor: status.hasProcessor,
usingExternalProvider: status.usingExternalProvider,
config: status.config,
});

Flushing and Shutdown​

Ensure all spans are sent before process exit:

import { flushOpenTelemetry, shutdownOpenTelemetry } from "@juspay/neurolink";

// Flush pending spans
await flushOpenTelemetry();

// Graceful shutdown (flushes and cleans up)
await shutdownOpenTelemetry();

Graceful Shutdown Example​

process.on("SIGTERM", async () => {
console.log("Shutting down...");
await flushOpenTelemetry();
await shutdownOpenTelemetry();
process.exit(0);
});

Best Practices​

1. Always Set Context at Request Boundaries​

app.use(async (req, res, next) => {
await setLangfuseContext({
userId: req.user?.id,
sessionId: req.session?.id,
requestId: req.headers["x-request-id"],
});
next();
});

2. Use Metadata for Filtering​

await setLangfuseContext({
metadata: {
feature: "chat",
experiment: "gpt4-vs-claude",
abTestGroup: "B",
},
});

3. Create Spans for Business Logic​

const tracer = getTracer("my-app");
const span = tracer.startSpan("retrieve-context");
try {
const docs = await vectorStore.search(query);
span.setAttribute("docs.count", docs.length);
} finally {
span.end();
}

4. Handle Errors Properly​

const span = tracer.startSpan("ai-generation");
try {
return await neurolink.generate({ input: { text: prompt } });
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: 2, message: (error as Error).message });
throw error;
} finally {
span.end();
}

Troubleshooting​

Empty span processors from getSpanProcessors()​

Problem: getSpanProcessors() returns an empty array.

Solution: Ensure NeuroLink is initialized before calling getSpanProcessors():

// Wrong - calling before initialization
const processors = getSpanProcessors(); // Returns []

// Correct - call after initialization
const neurolink = new NeuroLink({
observability: { langfuse: { enabled: true, ... } }
});
const processors = getSpanProcessors(); // Returns [ContextEnricher, LangfuseSpanProcessor]

Context not appearing in Langfuse traces​

Problem: userId, sessionId, or other context fields don't appear in Langfuse.

Solution: Ensure setLangfuseContext is called in the same async context as your AI operations:

// Wrong - context set outside the request handler
await setLangfuseContext({ userId: "user-123" });
// ... later in different async context
await neurolink.generate("Hello"); // Context lost!

// Correct - use callback to scope context
await setLangfuseContext({ userId: "user-123" }, async () => {
await neurolink.generate("Hello"); // Context attached!
});

Duplicate TracerProvider registration errors​

Problem: Error like "TracerProvider already registered" or "duplicate registration".

Solution: Set useExternalTracerProvider: true in your config:

const neurolink = new NeuroLink({
observability: {
langfuse: {
enabled: true,
publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
secretKey: process.env.LANGFUSE_SECRET_KEY!,
useExternalTracerProvider: true, // Add this!
},
},
});

Spans not being sent to Langfuse​

Problem: Traces don't appear in Langfuse dashboard.

Solution:

  1. Verify credentials are correct

  2. Check health status:

    const status = getLangfuseHealthStatus();
    console.log(status); // Check isHealthy, credentialsValid
  3. Ensure flushOpenTelemetry() is called before process exit

  4. Check network connectivity to Langfuse endpoint

API Reference​

The following functions and types are exported from @juspay/neurolink:

Functions:

  • setLangfuseContext - Set context for Langfuse traces
  • getLangfuseContext - Get current Langfuse context
  • getTracer - Get OpenTelemetry tracer instance
  • getSpanProcessors - Get span processors for external TracerProvider integration

Types:

  • LangfuseConfig - Configuration options for Langfuse integration
  • LangfuseSpanAttributes - GenAI semantic convention attributes

See Also​