UNPKG

@xynehq/jaf

Version:

Juspay Agent Framework - A purely functional agent framework with immutable state and composable tools

1,043 lines (1,042 loc) 52.9 kB
import tunnel from 'tunnel'; import http from 'http'; import https from 'https'; // Optional imports for tracing (these might not be available) let trace; let context; let Resource; let NodeSDK; let OTLPTraceExporter; let SemanticResourceAttributes; let Langfuse; try { // eslint-disable-next-line @typescript-eslint/no-var-requires const otelApi = require('@opentelemetry/api'); trace = otelApi.trace; context = otelApi.context; // eslint-disable-next-line @typescript-eslint/no-var-requires const otelResources = require('@opentelemetry/resources'); Resource = otelResources.Resource; // eslint-disable-next-line @typescript-eslint/no-var-requires const otelSdkNode = require('@opentelemetry/sdk-node'); NodeSDK = otelSdkNode.NodeSDK; // eslint-disable-next-line @typescript-eslint/no-var-requires const otelExporter = require('@opentelemetry/exporter-trace-otlp-http'); OTLPTraceExporter = otelExporter.OTLPTraceExporter; // eslint-disable-next-line @typescript-eslint/no-var-requires const otelSemantic = require('@opentelemetry/semantic-conventions'); SemanticResourceAttributes = otelSemantic.SemanticResourceAttributes; } catch (e) { // OpenTelemetry not available } // Default sensitive fields that should be redacted from logs const DEFAULT_SENSITIVE_FIELDS = [ 'password', 'token', 'apiKey', 'api_key', 'secret', 'authorization', 'auth', 'credential', 'credentials', 'sessionId', 'session_id', 'accessToken', 'access_token', 'refreshToken', 'refresh_token', 'privateKey', 'private_key', 'expiry', 'davv' ]; /** * Global sanitization configuration */ let globalSanitizationConfig = {}; /** * Configure global sanitization settings for all trace collectors * @param config - Sanitization configuration * * @example * ```typescript * // Add custom sensitive fields * configureSanitization({ * sensitiveFields: ['customerId', 'bankAccount', 'ssn'] * }); * * // Use custom sanitizer function * configureSanitization({ * customSanitizer: (key, value, depth) => { * // Custom logic for email masking * if (key === 'email' && typeof value === 'string') { * const [local, domain] = value.split('@'); * return `${local.substring(0, 2)}***@${domain}`; * } * // Return undefined to use default sanitization logic * return undefined; * } * }); * ``` */ export function configureSanitization(config) { globalSanitizationConfig = { ...globalSanitizationConfig, ...config, // Deep copy sensitiveFields array to prevent external mutation sensitiveFields: config.sensitiveFields ? [...config.sensitiveFields] : globalSanitizationConfig.sensitiveFields, // Deep copy allowedFields array to prevent external mutation allowedFields: config.allowedFields ? [...config.allowedFields] : globalSanitizationConfig.allowedFields }; } /** * Reset sanitization configuration to defaults */ export function resetSanitizationConfig() { globalSanitizationConfig = {}; } /** * Sanitize an object by redacting sensitive fields * @param obj - Object to sanitize * @param depth - Current recursion depth * @param config - Optional sanitization config (uses global config if not provided) */ export function sanitizeObject(obj, depth = 0, config, currentPath = '') { const effectiveConfig = config || globalSanitizationConfig; const mode = effectiveConfig.mode || 'blacklist'; const allowedFields = effectiveConfig.allowedFields || []; const maxDepth = effectiveConfig.maxDepth ?? 5; const redactionPlaceholder = effectiveConfig.redactionPlaceholder ?? '[REDACTED]'; const customSanitizer = effectiveConfig.customSanitizer; const additionalSensitiveFields = effectiveConfig.sensitiveFields || []; // Combine default and custom sensitive fields (for blacklist mode) const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...additionalSensitiveFields]; if (depth > maxDepth) return '[Max Depth Reached]'; if (obj === null || obj === undefined) return obj; if (typeof obj !== 'object') return obj; if (Array.isArray(obj)) { // For arrays, keep the same path (don't add indices) return obj.map(item => sanitizeObject(item, depth + 1, config, currentPath)); } const sanitized = {}; for (const [key, value] of Object.entries(obj)) { // Build the full path for this key const fullPath = currentPath ? `${currentPath}.${key}` : key; // Try custom sanitizer first if (customSanitizer) { try { const customResult = customSanitizer(key, value, depth); if (customResult !== undefined) { sanitized[key] = customResult; continue; } } catch (error) { console.warn(`[JAF:SANITIZATION] Custom sanitizer error for key "${key}":`, error); // Fall through to default sanitization } } const lowerKey = key.toLowerCase(); const lowerFullPath = fullPath.toLowerCase(); // WHITELIST MODE: Redact everything EXCEPT allowed fields if (mode === 'whitelist') { // Check if the full path OR just the key is in the whitelist const isAllowed = allowedFields.some(field => { const lowerField = field.toLowerCase(); return lowerFullPath === lowerField || lowerKey === lowerField; }); if (!isAllowed) { // Field not in whitelist - redact it sanitized[key] = redactionPlaceholder; } else if (typeof value === 'object' && value !== null) { // Field is allowed and is an object - sanitize recursively sanitized[key] = sanitizeObject(value, depth + 1, config, fullPath); } else { // Field is allowed and is a primitive - keep it sanitized[key] = value; } continue; } // BLACKLIST MODE (default): Allow everything EXCEPT sensitive fields const isSensitiveField = allSensitiveFields.some(field => lowerKey.includes(field.toLowerCase())); if (isSensitiveField) { // Redact sensitive fields completely (including nested data) // This prevents leaking sensitive information in nested objects sanitized[key] = redactionPlaceholder; } else if (typeof value === 'object' && value !== null) { sanitized[key] = sanitizeObject(value, depth + 1, config, fullPath); } else { sanitized[key] = value; } } return sanitized; } export class InMemoryTraceCollector { traces = new Map(); collect(event) { let traceId = null; if ('traceId' in event.data) { traceId = event.data.traceId; } else if ('runId' in event.data) { traceId = event.data.runId; } if (!traceId) return; if (!this.traces.has(traceId)) { this.traces.set(traceId, []); } const events = this.traces.get(traceId); events.push(event); } getTrace(traceId) { return this.traces.get(traceId) || []; } getAllTraces() { return new Map(this.traces); } clear(traceId) { if (traceId) { this.traces.delete(traceId); } else { this.traces.clear(); } } } export class ConsoleTraceCollector { inMemory = new InMemoryTraceCollector(); collect(event) { this.inMemory.collect(event); const timestamp = new Date().toISOString(); const prefix = `[${timestamp}] JAF:${event.type}`; switch (event.type) { case 'run_start': console.log(`${prefix} Starting run ${event.data.runId} (trace: ${event.data.traceId})`); break; case 'llm_call_start': console.log(`${prefix} Calling ${event.data.model} for agent ${event.data.agentName}`); break; case 'turn_start': console.log(`${prefix} Turn ${event.data.turn} started for ${event.data.agentName}`); break; case 'llm_call_end': { const choice = event.data.choice; const hasTools = choice.message?.tool_calls?.length > 0; const hasContent = !!choice.message?.content; console.log(`${prefix} LLM responded with ${hasTools ? 'tool calls' : hasContent ? 'content' : 'empty response'}`); break; } case 'token_usage': console.log(`${prefix} Token usage: prompt=${event.data.prompt ?? '-'} completion=${event.data.completion ?? '-'} total=${event.data.total ?? '-'}`); break; case 'tool_call_start': console.log(`${prefix} Executing tool ${event.data.toolName} with args:`, sanitizeObject(event.data.args)); break; case 'tool_call_end': console.log(`${prefix} Tool ${event.data.toolName} completed`); break; case 'handoff': console.log(`${prefix} Agent handoff: ${event.data.from} → ${event.data.to}`); break; case 'handoff_denied': console.warn(`${prefix} Handoff denied: ${event.data.from} → ${event.data.to}. Reason:`, sanitizeObject({ reason: event.data.reason })); break; case 'guardrail_violation': console.warn(`${prefix} Guardrail violation (${event.data.stage}):`, sanitizeObject({ reason: event.data.reason })); break; case 'decode_error': console.error(`${prefix} Decode error:`, sanitizeObject(event.data.errors)); break; case 'agent_processing': console.log(`${prefix} Agent ${event.data.agentName} processing (turn ${event.data.turnCount}, ${event.data.messageCount} messages, ${event.data.toolsAvailable.length} tools)`); break; case 'turn_end': console.log(`${prefix} Turn ${event.data.turn} ended for ${event.data.agentName}`); break; case 'run_end': { const outcome = event.data.outcome; if (outcome.status === 'completed') { console.log(`${prefix} Run completed successfully`); } else if (outcome.status === 'error') { console.error(`${prefix} Run failed:`, outcome.error._tag, outcome.error); } else { console.warn(`${prefix} Run interrupted`); } break; } } } getTrace(traceId) { return this.inMemory.getTrace(traceId); } getAllTraces() { return this.inMemory.getAllTraces(); } clear(traceId) { this.inMemory.clear(traceId); } } export class FileTraceCollector { filePath; inMemory = new InMemoryTraceCollector(); constructor(filePath) { this.filePath = filePath; } collect(event) { this.inMemory.collect(event); const logEntry = { timestamp: new Date().toISOString(), ...event }; try { // eslint-disable-next-line @typescript-eslint/no-var-requires const fs = require('fs'); fs.appendFileSync(this.filePath, JSON.stringify(logEntry) + '\n'); } catch (error) { console.error('Failed to write trace to file:', error); } } getTrace(traceId) { return this.inMemory.getTrace(traceId); } getAllTraces() { return this.inMemory.getAllTraces(); } clear(traceId) { this.inMemory.clear(traceId); } } // Global variables for OpenTelemetry setup let otelSdk = null; let manualProxyConfig = null; // Store manual proxy URL /** * Configure proxy settings manually for OpenTelemetry trace exports * * This function allows you to programmatically set a proxy URL that will be used * for all OpenTelemetry trace exports. It takes priority over environment variables. * * @param proxyUrl - The proxy URL (e.g., 'http://proxy.example.com:8080') * * @example * ```typescript * import { configureProxy, OpenTelemetryTraceCollector } from '@xynehq/jaf'; * * // Configure proxy before creating trace collector * configureProxy('http://proxy.example.com:8080'); * * // With authentication * configureProxy('http://username:password@proxy.example.com:8080'); * * // Then create your trace collector * const collector = new OpenTelemetryTraceCollector(); * ``` */ export function configureProxy(proxyUrl) { if (!proxyUrl) { console.warn('[JAF:PROXY] Empty proxy URL provided to configureProxy()'); return; } // Validate proxy URL format try { new URL(proxyUrl); } catch (error) { console.error(`[JAF:PROXY] Invalid proxy URL format: ${proxyUrl}`); throw new Error(`Invalid proxy URL: ${proxyUrl}`); } manualProxyConfig = proxyUrl; console.log(`[JAF:PROXY] Manual proxy configuration set: ${proxyUrl}`); } /** * Reset manual proxy configuration * * This clears any manually configured proxy settings. After calling this, * JAF will fall back to environment variables for proxy configuration. * * @example * ```typescript * import { resetProxyConfig } from '@xynehq/jaf'; * * resetProxyConfig(); // Clear manual proxy config * ``` */ export function resetProxyConfig() { manualProxyConfig = null; console.log('[JAF:PROXY] Manual proxy configuration cleared'); } const createTunnelAgent = (proxyUrl, isHttps) => { const url = new URL(proxyUrl); console.log(`[JAF:OTEL:PROXY] Creating tunnel agent for proxy: ${proxyUrl} (${isHttps ? 'HTTPS' : 'HTTP'} traffic)`); console.log(`[JAF:OTEL:PROXY] Proxy host: ${url.hostname}, port: ${url.port}`); // Create appropriate tunnel agent based on target protocol const agent = isHttps ? tunnel.httpsOverHttp({ proxy: { host: url.hostname, port: parseInt(url.port) }, rejectUnauthorized: false }) : tunnel.httpOverHttp({ proxy: { host: url.hostname, port: parseInt(url.port) } }); console.log(`[JAF:OTEL:PROXY] Tunnel agent created successfully`); return agent; }; function getProxyConfiguration(collectorUrl) { try { // Check if collector URL is localhost - skip proxy for localhost const collectorUrlObj = new URL(collectorUrl); // Priority: Manual config > Environment variables let proxyUrl; if (manualProxyConfig) { proxyUrl = manualProxyConfig; console.log(`[JAF:OTEL:PROXY] Using manual proxy configuration: ${proxyUrl}`); } else { // Check for proxy environment variables const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy; const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; const allProxy = process.env.ALL_PROXY || process.env.all_proxy; console.log(`[JAF:OTEL:PROXY] Environment variables check:`); console.log(`[JAF:OTEL:PROXY] - HTTP_PROXY: ${httpProxy || 'not set'}`); console.log(`[JAF:OTEL:PROXY] - HTTPS_PROXY: ${httpsProxy || 'not set'}`); console.log(`[JAF:OTEL:PROXY] - ALL_PROXY: ${allProxy || 'not set'}`); // Use the first available proxy configuration proxyUrl = httpProxy || httpsProxy || allProxy; } if (proxyUrl) { console.log(`[JAF:OTEL:PROXY] Using proxy URL: ${proxyUrl}`); // Validate proxy URL format try { new URL(proxyUrl); } catch (urlError) { console.error(`[JAF:OTEL:PROXY] Invalid proxy URL format: ${proxyUrl}`); return undefined; } // Determine if collector uses HTTPS const isHttps = collectorUrl.startsWith('https://'); const tunnelAgent = createTunnelAgent(proxyUrl, isHttps); console.log(`[JAF:OTEL:PROXY] Created tunnel agent successfully for ${isHttps ? 'HTTPS' : 'HTTP'} traffic`); return { httpsAgent: isHttps ? tunnelAgent : undefined, httpAgent: !isHttps ? tunnelAgent : undefined, proxyUrl: proxyUrl }; } else { console.log(`[JAF:OTEL:PROXY] No proxy configuration found - using direct connection`); return { httpsAgent: undefined, httpAgent: undefined, proxyUrl: undefined }; } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`[JAF:OTEL:PROXY] Error configuring proxy: ${errorMessage}`); console.error(`[JAF:OTEL:PROXY] Stack trace:`, error); return undefined; } } function setupOpenTelemetry(serviceName = 'jaf-agent', collectorUrl) { if (!NodeSDK || !OTLPTraceExporter || !Resource || !SemanticResourceAttributes || !collectorUrl) { return; } try { // Parse headers from environment variable const headersEnv = process.env.OTEL_EXPORTER_OTLP_HEADERS; const headers = {}; if (headersEnv) { console.log(`[JAF:OTEL] Parsing headers: ${headersEnv}`); // Parse comma-separated key=value pairs headersEnv.split(',').forEach(header => { const [key, value] = header.trim().split('='); if (key && value) { headers[key] = value; } }); console.log(`[JAF:OTEL] Parsed headers:`, Object.keys(headers)); } // Configure proxy settings const proxyConfig = getProxyConfiguration(collectorUrl); const resource = new Resource({ [SemanticResourceAttributes.SERVICE_NAME]: serviceName, }); // Create exporter configuration with proxy support const exporterConfig = { url: collectorUrl, headers: headers, }; // Add proxy agents if configured if (proxyConfig && (proxyConfig.httpsAgent || proxyConfig.httpAgent)) { console.log(`[JAF:OTEL:PROXY] Configuring OTLP exporter with proxy: ${proxyConfig.proxyUrl}`); console.log(`[JAF:OTEL:PROXY] Target collector: ${collectorUrl}`); // Override the global HTTP/HTTPS modules to use our proxy agents const originalHttpsRequest = https.request; const originalHttpRequest = http.request; if (proxyConfig.httpsAgent) { console.log(`[JAF:OTEL:PROXY] Configuring HTTPS proxy agent`); https.request = function (options, callback) { console.log(`[JAF:OTEL:PROXY] HTTPS request intercepted:`, { hostname: options.hostname, port: options.port, path: options.path, method: options.method }); // Force use of our proxy agent options.agent = proxyConfig.httpsAgent; const req = originalHttpsRequest.call(this, options, callback); req.on('response', (res) => { console.log(`[JAF:OTEL:PROXY] HTTPS response via proxy:`, { statusCode: res.statusCode, statusMessage: res.statusMessage }); }); req.on('error', (error) => { console.error(`[JAF:OTEL:PROXY] HTTPS request error via proxy:`, error.message); }); return req; }; // Also override https.get for consistency const originalHttpsGet = https.get; https.get = function (options, callback) { options.agent = proxyConfig.httpsAgent; return originalHttpsGet.call(this, options, callback); }; console.log(`[JAF:OTEL:PROXY] Overridden https.request to use HTTPS proxy agent`); } if (proxyConfig.httpAgent) { console.log(`[JAF:OTEL:PROXY] Configuring HTTP proxy agent`); http.request = function (options, callback) { console.log(`[JAF:OTEL:PROXY] HTTP request intercepted:`, { hostname: options.hostname, port: options.port, path: options.path, method: options.method }); options.agent = proxyConfig.httpAgent; const req = originalHttpRequest.call(this, options, callback); req.on('response', (res) => { console.log(`[JAF:OTEL:PROXY] HTTP response via proxy:`, { statusCode: res.statusCode, statusMessage: res.statusMessage }); }); req.on('error', (error) => { console.error(`[JAF:OTEL:PROXY] HTTP request error via proxy:`, error.message); }); return req; }; console.log(`[JAF:OTEL:PROXY] Overridden http.request to use HTTP proxy agent`); } // Add request timeout for better debugging exporterConfig.timeoutMillis = 30000; console.log(`[JAF:OTEL:PROXY] OTLP exporter configured to use proxy for requests to: ${collectorUrl}`); console.log(`[JAF:OTEL:PROXY] Request timeout set to: ${exporterConfig.timeoutMillis}ms`); } else { console.log(`[JAF:OTEL:PROXY] OTLP exporter configured for direct connection to: ${collectorUrl}`); } console.log(`[JAF:OTEL:CONFIG] Final exporter configuration:`, { url: exporterConfig.url, headers: Object.keys(exporterConfig.headers || {}), hasHttpsAgent: !!proxyConfig?.httpsAgent, hasHttpAgent: !!proxyConfig?.httpAgent, timeoutMillis: exporterConfig.timeoutMillis || 'default', proxyUrl: proxyConfig?.proxyUrl || 'none' }); otelSdk = new NodeSDK({ traceExporter: new OTLPTraceExporter(exporterConfig), resource: resource.merge(new Resource({})), // Disable default resource detectors to minimize attributes autoDetectResources: false }); console.log(`[JAF:OTEL] Starting OpenTelemetry SDK with URL: ${collectorUrl}`); otelSdk.start(); console.log(`[JAF:OTEL] OpenTelemetry SDK started successfully`); // Add shutdown hook to flush traces process.on('beforeExit', async () => { console.log('[JAF:OTEL] Flushing traces before exit...'); try { await otelSdk.shutdown(); console.log('[JAF:OTEL] Traces flushed successfully.'); } catch (error) { console.error('[JAF:OTEL] Error flushing traces:', JSON.stringify(error, null, 2)); } }); } catch (error) { console.error('[JAF:OTEL] Failed to setup OpenTelemetry:', error); } } export class OpenTelemetryTraceCollector { inMemory = new InMemoryTraceCollector(); activeSpans = new Map(); traceSpans = new Map(); tokenUsage = new Map(); traceModels = new Map(); tracer; constructor(serviceName = 'jaf-agent') { // Initialize OpenTelemetry SDK if URL is configured and not already initialized const collectorUrl = process.env.TRACE_COLLECTOR_URL; console.log(`[OTEL] Constructor called with serviceName: ${serviceName}`); console.log(`[OTEL] TRACE_COLLECTOR_URL: ${collectorUrl}`); console.log(`[OTEL] otelSdk already initialized: ${!!otelSdk}`); console.log(`[OTEL] NodeSDK available: ${!!NodeSDK}`); console.log(`[OTEL] OTLPTraceExporter available: ${!!OTLPTraceExporter}`); if (collectorUrl && !otelSdk) { console.log(`[OTEL] Initializing OpenTelemetry SDK with collector URL: ${collectorUrl}`); setupOpenTelemetry(serviceName, collectorUrl); } this.tracer = trace?.getTracer(serviceName); if (!this.tracer) { console.warn('[OTEL] OpenTelemetry tracer not available'); console.warn('[OTEL] trace object:', !!trace); if (!collectorUrl) { console.warn('[OTEL] TRACE_COLLECTOR_URL not set - traces will not be exported'); } } else { console.log(`[OTEL] OpenTelemetry tracer initialized for service: ${serviceName}`); if (collectorUrl) { console.log(`[OTEL] Configured to export traces to: ${collectorUrl}`); } } } collect(event) { this.inMemory.collect(event); if (!this.tracer) { return; } try { const traceId = this._getTraceId(event); if (!traceId) { console.warn('[OTEL] No trace ID found in event:', event.type); return; } const eventType = event.type; const data = event.data || {}; switch (eventType) { case 'run_start': { if (this.traceSpans.has(traceId)) { console.warn(`[OTEL] Trace with ID ${traceId} already exists. Skipping creation of new root span.`); return; } // Start a new trace for the entire run console.log(`[OTEL] Starting trace for run: ${traceId}`); // Initialize token usage tracking for this trace this.tokenUsage.set(traceId, { prompt: 0, completion: 0, total: 0 }); // Extract user query from the run_start data let userQuery = null; let userId = null; // Debug: Print the event data structure (sanitized for security) console.log(`[OTEL DEBUG] Event data keys: ${Object.keys(data).join(', ')}`); if (data.context) { const context = data.context; console.log(`[OTEL DEBUG] Context type: ${typeof context}`); console.log(`[OTEL DEBUG] Context keys: ${Object.keys(context || {}).join(', ')}`); // Log sanitized context for debugging console.log(`[OTEL DEBUG] Sanitized context:`, sanitizeObject(context)); } // Try to extract from context first const context = data.context; if (context) { // Try direct attribute access if (context.query) { userQuery = context.query; console.log(`[OTEL DEBUG] Found user_query from context.query:`, sanitizeObject({ query: userQuery })); } // Try to extract from combined_history if (context.combined_history && Array.isArray(context.combined_history)) { const history = context.combined_history; console.log(`[OTEL DEBUG] Found combined_history with ${history.length} messages`); for (let i = history.length - 1; i >= 0; i--) { const msg = history[i]; // Log sanitized message instead of raw content console.log(`[OTEL DEBUG] History message ${history.length - 1 - i}:`, sanitizeObject(msg)); if (typeof msg === 'object' && msg?.role === 'user') { userQuery = msg.content || ''; console.log(`[OTEL DEBUG] Found user_query from history:`, sanitizeObject({ query: userQuery })); break; } } } // Try to extract user_id from token_response if (context.token_response) { const tokenResponse = context.token_response; console.log(`[OTEL DEBUG] Found token_response: ${typeof tokenResponse}`); if (typeof tokenResponse === 'object') { userId = tokenResponse.email || tokenResponse.username || null; console.log(`[OTEL DEBUG] Extracted user_id:`, sanitizeObject({ userId })); } } // Also try direct userId from context if (context.userId) { userId = context.userId; console.log(`[OTEL DEBUG] Found userId directly in context:`, sanitizeObject({ userId })); } } // Fallback: try to extract from messages if context didn't work if (!userQuery && data.messages) { console.log(`[OTEL DEBUG] Trying fallback from messages`); const messages = data.messages; console.log(`[OTEL DEBUG] Found ${messages.length} messages`); // Find the last user message which should be the current query for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; // Log sanitized message instead of raw content console.log(`[OTEL DEBUG] Message ${messages.length - 1 - i}:`, sanitizeObject(msg)); if (typeof msg === 'object' && msg?.role === 'user') { userQuery = msg.content || ''; console.log(`[OTEL DEBUG] Found user_query from messages:`, sanitizeObject({ query: userQuery })); break; } } } console.log(`[OTEL DEBUG] Final extracted:`, sanitizeObject({ user_query: userQuery, user_id: userId })); // Create comprehensive input data for the trace (sanitized) const traceInput = { user_query: userQuery, run_id: String(traceId), agent_name: data.agentName || 'analytics_agent_jaf', session_info: { session_id: data.sessionId, user_id: userId || data.userId } }; const rootSpan = this.tracer.startSpan(`jaf-run-${traceId}`, { attributes: { 'framework': 'jaf', 'event.type': 'run_start', 'trace.id': String(traceId), 'user.query': userQuery || 'unknown', 'user.id': userId || data.userId || 'anonymous', 'agent.name': data.agentName || 'analytics_agent_jaf', 'session.id': data.sessionId || 'unknown', 'input': JSON.stringify(sanitizeObject(traceInput)), 'gen_ai.request.model': data.model || 'unknown' } }); this.traceSpans.set(traceId, rootSpan); // Store user_id and user_query for later use in generations rootSpan._user_id = userId || data.userId; rootSpan._user_query = userQuery; console.log(`[OTEL] Created trace with user query:`, sanitizeObject({ query: userQuery ? userQuery.substring(0, 100) + '...' : 'None' })); break; } case 'run_end': { if (this.traceSpans.has(traceId)) { console.log(`[OTEL] Ending trace for run: ${traceId}`); const rootSpan = this.traceSpans.get(traceId); // Get accumulated token usage for this trace const totalUsage = this.tokenUsage.get(traceId) || { prompt: 0, completion: 0, total: 0 }; // Get model for this trace const model = this.traceModels.get(traceId) || 'unknown'; // Set final attributes const outcome = data.outcome; if (outcome) { const attributes = { 'run_status': outcome.status || 'unknown', 'output': JSON.stringify(sanitizeObject(outcome.output)), 'model': model, 'gen_ai.request.model': model, 'llm.token_count.total': totalUsage.total, 'gen_ai.usage.prompt_tokens': totalUsage.prompt, 'langfuse.observation.model.name': model, 'gen_ai.usage.completion_tokens': totalUsage.completion, 'gen_ai.usage.total_tokens': totalUsage.total, 'gen_ai.usage.input_tokens': totalUsage.prompt, 'gen_ai.usage.output_tokens': totalUsage.completion, }; rootSpan.setAttributes(attributes); console.log('[OTEL] Root span attributes:', sanitizeObject(attributes)); if (outcome.status !== 'completed' && outcome.error) { rootSpan.recordException(new Error(outcome.error._tag || 'Unknown error')); rootSpan.setStatus({ code: 2, message: outcome.error._tag || 'Run failed' }); // ERROR status } else { rootSpan.setStatus({ code: 1 }); // OK status } } rootSpan.end(); this.traceSpans.delete(traceId); // Clean up token usage and model tracking this.tokenUsage.delete(traceId); this.traceModels.delete(traceId); console.log(`[OTEL] Trace ended for run: ${traceId} with total usage: ${JSON.stringify(totalUsage)}`); } break; } case 'llm_call_start': { if (this.traceSpans.has(traceId)) { // Start a generation for LLM calls const model = data.model || 'unknown'; this.traceModels.set(traceId, model); console.log(`[OTEL] Starting generation for LLM call with model: ${model}`); // Get stored user information from the trace const rootSpan = this.traceSpans.get(traceId); const userId = rootSpan._user_id || null; const userQuery = rootSpan._user_query || null; const ctx = trace.setSpan(context.active(), rootSpan); const generationSpan = this.tracer.startSpan(`llm-call-${model}`, { attributes: { 'gen_ai.operation.name': 'chat', 'gen_ai.provider.name': 'jaf', 'model': model, 'langfuse.observation.model.name': model, 'gen_ai.request.model': model, 'agent.name': data.agentName || 'unknown', 'user.id': userId || 'unknown', 'user.query': userQuery || 'unknown', 'gen_ai.input.messages': JSON.stringify(sanitizeObject(data.messages || {})), } }, ctx); generationSpan.setAttribute('model', model); const spanId = this._getSpanId(event); this.activeSpans.set(spanId, generationSpan); console.log(`[OTEL] Created LLM generation span for model: ${model}`); } break; } case 'llm_call_end': { const spanId = this._getSpanId(event); if (this.activeSpans.has(spanId)) { console.log(`[OTEL] Ending generation for LLM call`); const generationSpan = this.activeSpans.get(spanId); const choice = data.choice || {}; const usage = data.usage || {}; // Extract model information from event data (not from choice) const model = data.model || 'unknown'; // Set comprehensive attributes for cost calculation and tracking const promptTokens = usage.prompt_tokens || 0; const completionTokens = usage.completion_tokens || 0; const totalTokens = usage.total_tokens || 0; // Accumulate token usage for this trace if (this.tokenUsage.has(traceId)) { const currentUsage = this.tokenUsage.get(traceId); currentUsage.prompt += promptTokens; currentUsage.completion += completionTokens; currentUsage.total += totalTokens; this.tokenUsage.set(traceId, currentUsage); } const attributes = { 'model': model, 'langfuse.observation.model.name': model, 'llm.model_name': model, 'gen_ai.request.model': model, 'gen_ai.response.model': model, 'gen_ai.output.messages': JSON.stringify(sanitizeObject(choice.message)), 'llm.token_count.prompt': promptTokens, 'llm.token_count.completion': completionTokens, 'gen_ai.usage.input_tokens': promptTokens, 'gen_ai.usage.output_tokens': completionTokens, 'gen_ai.usage.total_tokens': totalTokens, 'gen_ai.usage.prompt_tokens': promptTokens, 'gen_ai.usage.completion_tokens': completionTokens, 'gen_ai.response.finish_reasons': JSON.stringify(choice.finish_reason ? [choice.finish_reason] : []), 'gen_ai.response.id': choice.id || 'unknown', }; generationSpan.setAttributes(attributes); console.log('[OTEL] Generation span attributes:', sanitizeObject(attributes)); console.log(`[OTEL] Usage data for cost tracking: prompt=${promptTokens}, completion=${completionTokens}, total=${totalTokens}`); generationSpan.setStatus({ code: 1 }); // OK status generationSpan.end(); // Clean up the span reference this.activeSpans.delete(spanId); console.log(`[OTEL] Generation ended with cost tracking`); } else { console.log(`[OTEL] No generation found for llm_call_end: ${spanId}`); } break; } case 'tool_call_start': { if (this.traceSpans.has(traceId)) { // Start a span for tool calls with detailed input information const toolName = data.toolName || 'unknown'; const toolArgs = data.args || {}; console.log(`[OTEL] Starting span for tool call: ${toolName}`); // Create comprehensive input data for the tool call (sanitized) const toolInput = { tool_name: toolName, arguments: sanitizeObject(toolArgs), call_id: data.callId, timestamp: new Date().toISOString() }; const rootSpan = this.traceSpans.get(traceId); const ctx = trace.setSpan(context.active(), rootSpan); const toolSpan = this.tracer.startSpan(`tool-${toolName}`, { attributes: { 'tool.name': toolName, 'call.id': data.callId || 'unknown', 'framework': 'jaf', 'input': JSON.stringify(toolInput), 'event.type': 'tool_call', 'args': JSON.stringify(sanitizeObject(toolArgs)) } }, ctx); const toolSpanId = this._getSpanId(event); this.activeSpans.set(toolSpanId, toolSpan); console.log(`[OTEL] Created tool span for ${toolName} with sanitized args`); } break; } case 'tool_call_end': { const toolSpanId = this._getSpanId(event); if (this.activeSpans.has(toolSpanId)) { const toolName = data.toolName || 'unknown'; const toolResult = data.result; console.log(`[OTEL] Ending span for tool call: ${toolName}`); // Create comprehensive output data for the tool call (sanitized) const toolOutput = { tool_name: toolName, result: sanitizeObject(toolResult), call_id: data.callId, timestamp: new Date().toISOString(), status: 'completed' }; // End the span with detailed output const toolSpan = this.activeSpans.get(toolSpanId); toolSpan.setAttributes({ 'tool.name': toolName, 'call.id': data.callId || 'unknown', 'result.length': toolResult ? String(toolResult).length : 0, 'framework': 'jaf', 'event.type': 'tool_call_end', 'output': JSON.stringify(toolOutput), 'status': 'completed' }); toolSpan.setStatus({ code: 1 }); // OK status toolSpan.end(); // Clean up the span reference this.activeSpans.delete(toolSpanId); console.log(`[OTEL] Tool span ended for ${toolName} with result length: ${toolResult ? String(toolResult).length : 0}`); } else { console.log(`[OTEL] No tool span found for tool_call_end: ${toolSpanId}`); } break; } case 'handoff': { if (this.traceSpans.has(traceId)) { console.log(`[OTEL] Creating event for handoff`); const rootSpan = this.traceSpans.get(traceId); const ctx = trace.setSpan(context.active(), rootSpan); const handoffSpan = this.tracer.startSpan('agent-handoff', { attributes: { 'from_agent': data.from || 'unknown', 'to_agent': data.to || 'unknown', 'framework': 'jaf', 'event_type': 'handoff', 'input': JSON.stringify(sanitizeObject(data)) } }, ctx); handoffSpan.setStatus({ code: 1 }); // OK status handoffSpan.end(); console.log(`[OTEL] Handoff event created: ${data.from} → ${data.to}`); } break; } case 'agent_processing': { if (this.traceSpans.has(traceId)) { console.log(`[OTEL] Creating span for agent processing: ${data.agentName}`); const rootSpan = this.traceSpans.get(traceId); const ctx = trace.setSpan(context.active(), rootSpan); const processingSpan = this.tracer.startSpan(`agent-processing-${data.agentName}`, { attributes: { 'agent_name': data.agentName || 'unknown', 'turn_count': data.turnCount || 0, 'message_count': data.messageCount || 0, 'tools_available': JSON.stringify(data.toolsAvailable || []), 'handoffs_available': JSON.stringify(data.handoffsAvailable || []), 'framework': 'jaf', 'event_type': 'agent_processing', 'input': JSON.stringify(sanitizeObject(data)) } }, ctx); processingSpan.setStatus({ code: 1 }); // OK status processingSpan.end(); // Agent processing is instantaneous console.log(`[OTEL] Agent processing span completed for: ${data.agentName}`); } break; } default: { // Handle other event types with generic spans if (this.traceSpans.has(traceId)) { console.log(`[OTEL] Creating generic span for: ${eventType}`); const rootSpan = this.traceSpans.get(traceId); const ctx = trace.setSpan(context.active(), rootSpan); const genericSpan = this.tracer.startSpan(eventType, { attributes: { 'framework': 'jaf', 'event_type': eventType, 'input': JSON.stringify(sanitizeObject(data)) } }, ctx); genericSpan.setStatus({ code: 1 }); // OK status genericSpan.end(); console.log(`[OTEL] Generic span created for: ${eventType}`); } break; } } } catch (error) { console.error('[OTEL] Error collecting trace event:', error); // Try to record the exception in the root span if available const errorTraceId = this._getTraceId(event); if (errorTraceId && this.traceSpans.has(errorTraceId)) { const rootSpan = this.traceSpans.get(errorTraceId); if (rootSpan) { rootSpan.recordException(error); } } } } _getTraceId(event) { const data = event.data; if (data?.traceId) return data.traceId; if (data?.runId) return data.runId; // if ((event as any).traceId) return (event as any).traceId; if (data?.trace_id) return data.trace_id; if (data?.run_id) return data.run_id; return null; } _getSpanId(event) { const traceId = this._getTraceId(event); const data = event.data; if (event.type.startsWith('tool_call')) { const toolName = data?.toolName || 'unknown'; return `tool-${toolName}-${traceId}`; } else if (event.type.startsWith('llm_call')) { // For LLM calls, use a simpler consistent ID that matches between start and end // Get run_id for more consistent matching