UNPKG

@posthog/ai

Version:
719 lines (692 loc) 25.4 kB
import 'uuid'; const MIME_HINT_KEYS = ['mediaType', 'media_type', 'mimeType', 'mime_type']; const STRONG_CONTEXT_KEYS = new Set(['data', 'file_data', 'fileData', 'image_url', 'imageUrl', 'video_url', 'videoUrl', 'audio', 'audio_data', 'audioData', 'inline_data', 'inlineData', 'source', 'result']); const STRONG_CONTEXT_TYPES = new Set(['image', 'image_url', 'input_image', 'audio', 'input_audio', 'video', 'video_url', 'file', 'input_file', 'document', 'media', 'file-data']); const FILE_FAMILY_TYPES = new Set(['file', 'input_file', 'document', 'media', 'file-data']); const KNOWN_AUDIO_FORMATS = new Set(['wav', 'mp3', 'ogg', 'flac', 'm4a', 'aac', 'webm']); class MediaTypeContext { static EMPTY = new MediaTypeContext(undefined, undefined); constructor(parent, key) { this.parent = parent; this.key = key; } inferMediaType() { return this.inferFromSiblingMime() ?? this.inferFromSiblingFormat() ?? this.inferFromParentType() ?? this.inferFromKey(); } inferFromSiblingMime() { if (!this.parent) return undefined; for (const hint of MIME_HINT_KEYS) { const v = this.parent[hint]; if (typeof v === 'string') return v; } return undefined; } inferFromSiblingFormat() { if (!this.parent) return undefined; const fmt = this.parent.format; if (typeof fmt === 'string' && KNOWN_AUDIO_FORMATS.has(fmt.toLowerCase())) { return `audio/${fmt.toLowerCase()}`; } return undefined; } inferFromParentType() { if (!this.parent) return undefined; const t = this.parent.type; if (typeof t !== 'string') return undefined; if (t === 'image' || t === 'image_url' || t === 'input_image') return 'image'; if (t === 'audio' || t === 'input_audio') return 'audio'; if (t === 'video' || t === 'video_url') return 'video'; if (FILE_FAMILY_TYPES.has(t)) return 'application/octet-stream'; return undefined; } inferFromKey() { if (!this.key) return undefined; const key = this.key.toLowerCase(); if (key.includes('audio')) return 'audio'; if (key.includes('video')) return 'video'; if (key.includes('image')) return 'image'; if (key.includes('file') || key.includes('document')) return 'application/octet-stream'; return undefined; } signalsBinary() { if (this.parent) { for (const hint of MIME_HINT_KEYS) { if (typeof this.parent[hint] === 'string') return true; } const fmt = this.parent.format; if (typeof fmt === 'string' && KNOWN_AUDIO_FORMATS.has(fmt.toLowerCase())) return true; const t = this.parent.type; if (typeof t === 'string' && STRONG_CONTEXT_TYPES.has(t)) return true; } if (this.key && STRONG_CONTEXT_KEYS.has(this.key)) return true; return false; } } // limit large outputs by truncating to 200kb (approx 200k bytes) const MAX_OUTPUT_SIZE = 200000; const STRING_FORMAT = 'utf8'; // Reused across calls to avoid per-invocation allocation; truncate() runs // hundreds of times for prompts with many parts. const sharedTextEncoder = new TextEncoder(); const sharedTextDecoder = new TextDecoder(STRING_FORMAT, { fatal: false }); const withPrivacyMode = (client, privacyMode, input) => { return client.privacy_mode || privacyMode ? null : input; }; function toSafeString(input) { if (input === undefined || input === null) { return ''; } if (typeof input === 'string') { return input; } try { return JSON.stringify(input); } catch { console.warn('Failed to stringify input', input); return ''; } } const truncate = input => { const str = toSafeString(input); if (str === '') { return ''; } // Check if we need to truncate and ensure STRING_FORMAT is respected const buffer = sharedTextEncoder.encode(str); if (buffer.length <= MAX_OUTPUT_SIZE) { // Ensure STRING_FORMAT is respected return sharedTextDecoder.decode(buffer); } // Truncate the buffer and ensure a valid string is returned. // fatal: false means we get U+FFFD at the end if truncation broke the encoding. const truncatedBuffer = buffer.slice(0, MAX_OUTPUT_SIZE); let truncatedStr = sharedTextDecoder.decode(truncatedBuffer); if (truncatedStr.endsWith('\uFFFD')) { truncatedStr = truncatedStr.slice(0, -1); } return `${truncatedStr}... [truncated]`; }; var version = "7.19.5"; /** * Normalize OpenAI Responses API input items to include a `role` field. * Items like `function_call` and `function_call_result` don't have a role, * causing PostHog's trace viewer to default them to "user". */ function normalizeInputRoles(input) { if (!Array.isArray(input)) { return input; } return input.map(item => { if (item && typeof item === 'object' && !('role' in item) && 'type' in item) { if (item.type === 'function_call') { return { ...item, role: 'assistant' }; } if (item.type === 'function_call_result') { return { ...item, role: 'tool' }; } } return item; }); } function ensureSerializable(obj) { if (obj === null || obj === undefined) { return obj; } try { JSON.stringify(obj); return obj; } catch { return String(obj); } } function exceedsMaxOutputSize(value) { if (value === null || value === undefined) { return false; } try { const serializedValue = typeof value === 'string' ? value : JSON.stringify(value); return new TextEncoder().encode(serializedValue).length > MAX_OUTPUT_SIZE; } catch { return false; } } function parseIsoTimestamp(isoStr) { if (!isoStr) { return null; } try { const ts = new Date(isoStr).getTime(); return isNaN(ts) ? null : ts / 1000; } catch { return null; } } /** * A tracing processor that sends OpenAI Agents SDK traces to PostHog. * * Implements the TracingProcessor interface from the OpenAI Agents SDK * and maps agent traces, spans, and generations to PostHog's LLM analytics events. * * @example * ```typescript * import { PostHogTracingProcessor } from '@posthog/ai/openai-agents' * import { addTraceProcessor } from '@openai/agents' * * const processor = new PostHogTracingProcessor({ * client: posthog, * distinctId: 'user@example.com', * }) * addTraceProcessor(processor) * ``` */ class PostHogTracingProcessor { _spanStartTimes = new Map(); _traceMetadata = new Map(); _maxTrackedEntries = 10000; constructor(options) { this._client = options.client; this._distinctId = options.distinctId; this._privacyMode = options.privacyMode ?? false; this._groups = options.groups ?? {}; this._properties = options.properties ?? {}; } _getDistinctId(trace) { if (typeof this._distinctId === 'function') { if (trace) { const result = this._distinctId(trace); if (result) { return String(result); } } return undefined; } else if (this._distinctId) { return String(this._distinctId); } return undefined; } _withPrivacyMode(value) { return withPrivacyMode(this._client, this._privacyMode, value); } _prepareCapturedValue(value) { const serializableValue = ensureSerializable(value); const boundedValue = exceedsMaxOutputSize(serializableValue) ? truncate(serializableValue) : serializableValue; return this._withPrivacyMode(boundedValue); } _evictStaleEntries() { if (this._spanStartTimes.size > this._maxTrackedEntries) { const entries = [...this._spanStartTimes.entries()].sort((a, b) => a[1] - b[1]); const toRemove = entries.slice(0, Math.floor(entries.length / 2)); for (const [key] of toRemove) { this._spanStartTimes.delete(key); } } if (this._traceMetadata.size > this._maxTrackedEntries) { const keys = [...this._traceMetadata.keys()]; const toRemove = keys.slice(0, Math.floor(keys.length / 2)); for (const key of toRemove) { this._traceMetadata.delete(key); } } } _captureEvent(event, properties, distinctId) { try { if (!this._client?.capture) { return; } const finalProperties = { ...this._properties, ...properties }; const eventMessage = { distinctId: distinctId || 'unknown', event, properties: finalProperties, groups: Object.keys(this._groups).length > 0 ? this._groups : undefined }; this._client.capture(eventMessage); } catch { // Silently ignore capture errors } } _baseProperties(traceId, spanId, parentId, latency, groupId, errorProperties) { const properties = { $ai_lib: 'posthog-ai', $ai_lib_version: version, $ai_trace_id: traceId, $ai_span_id: spanId, $ai_parent_id: parentId, $ai_provider: 'openai', $ai_framework: 'openai-agents', $ai_latency: latency, ...errorProperties }; if (groupId) { properties.$ai_group_id = groupId; } return properties; } _getErrorProperties(error) { if (!error) { return {}; } const errorMessage = error.message || String(error); let errorType = 'unknown'; if (errorMessage.includes('ModelBehaviorError')) { errorType = 'model_behavior_error'; } else if (errorMessage.includes('UserError')) { errorType = 'user_error'; } else if (errorMessage.includes('InputGuardrailTripwireTriggered')) { errorType = 'input_guardrail_triggered'; } else if (errorMessage.includes('OutputGuardrailTripwireTriggered')) { errorType = 'output_guardrail_triggered'; } else if (errorMessage.includes('MaxTurnsExceeded')) { errorType = 'max_turns_exceeded'; } return { $ai_is_error: true, $ai_error: errorMessage, $ai_error_type: errorType }; } // --- TracingProcessor interface --- async onTraceStart(trace) { try { this._evictStaleEntries(); const traceId = trace.traceId; const traceName = trace.name; const groupId = trace.groupId ?? null; const metadata = trace.metadata; const distinctId = this._getDistinctId(trace); this._traceMetadata.set(traceId, { name: traceName, groupId, metadata, distinctId, startTime: Date.now() / 1000 }); } catch { // Silently ignore errors } } async onTraceEnd(trace) { try { const traceId = trace.traceId; const traceInfo = this._traceMetadata.get(traceId); this._traceMetadata.delete(traceId); const traceName = traceInfo?.name ?? trace.name; const groupId = traceInfo?.groupId ?? trace.groupId ?? null; const metadata = traceInfo?.metadata ?? trace.metadata; const distinctId = traceInfo?.distinctId ?? this._getDistinctId(trace); const startTime = traceInfo?.startTime; const latency = startTime != null ? Date.now() / 1000 - startTime : undefined; const properties = { $ai_lib: 'posthog-ai', $ai_lib_version: version, $ai_trace_id: traceId, $ai_trace_name: traceName, $ai_provider: 'openai', $ai_framework: 'openai-agents' }; if (latency != null) { properties.$ai_latency = latency; } if (groupId) { properties.$ai_group_id = groupId; } if (metadata && Object.keys(metadata).length > 0) { properties.$ai_trace_metadata = this._prepareCapturedValue(metadata); } if (distinctId == null) { properties.$process_person_profile = false; } this._captureEvent('$ai_trace', properties, distinctId ?? traceId); } catch { // Silently ignore errors } } async onSpanStart(span) { try { this._evictStaleEntries(); this._spanStartTimes.set(span.spanId, Date.now() / 1000); } catch { // Silently ignore errors } } async onSpanEnd(span) { try { const spanId = span.spanId; const traceId = span.traceId; const parentId = span.parentId; const spanData = span.spanData; // Calculate latency const startTime = this._spanStartTimes.get(spanId); this._spanStartTimes.delete(spanId); let latency; if (startTime != null) { latency = Date.now() / 1000 - startTime; } else { const started = parseIsoTimestamp(span.startedAt); const ended = parseIsoTimestamp(span.endedAt); latency = started != null && ended != null ? ended - started : 0; } // Get distinct ID from trace metadata const traceInfo = this._traceMetadata.get(traceId); const userDistinctId = traceInfo?.distinctId ?? this._getDistinctId(null); // Get group_id from trace metadata const groupId = traceInfo?.groupId ?? null; // Get error properties const errorProperties = this._getErrorProperties(span.error); // Personless mode: no user-provided distinct_id, fallback to trace_id if (userDistinctId == null) { errorProperties.$process_person_profile = false; } const distinctId = userDistinctId ?? traceId; // Dispatch based on span data type switch (spanData.type) { case 'generation': this._handleGenerationSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties); break; case 'response': this._handleResponseSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties); break; case 'function': this._handleFunctionSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties); break; case 'agent': this._handleAgentSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties); break; case 'handoff': this._handleHandoffSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties); break; case 'guardrail': this._handleGuardrailSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties); break; case 'custom': this._handleCustomSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties); break; case 'transcription': case 'speech': case 'speech_group': this._handleAudioSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties); break; case 'mcp_tools': this._handleMcpSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties); break; default: this._handleGenericSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties); break; } } catch { // Silently ignore errors } } async shutdown() { try { this._spanStartTimes.clear(); this._traceMetadata.clear(); if (typeof this._client?.flush === 'function') { await this._client.flush(); } } catch { // Silently ignore errors } } async forceFlush() { try { if (typeof this._client?.flush === 'function') { await this._client.flush(); } } catch { // Silently ignore errors } } // --- Span handlers --- _handleGenerationSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties) { const usage = spanData.usage ?? {}; const inputTokens = usage.input_tokens || usage.prompt_tokens || 0; const outputTokens = usage.output_tokens || usage.completion_tokens || 0; const modelConfig = spanData.model_config ?? {}; const modelParams = {}; for (const param of ['temperature', 'max_tokens', 'top_p', 'frequency_penalty', 'presence_penalty']) { if (param in modelConfig) { modelParams[param] = modelConfig[param]; } } const properties = { ...this._baseProperties(traceId, spanId, parentId, latency, groupId, errorProperties), $ai_model: spanData.model, $ai_model_parameters: Object.keys(modelParams).length > 0 ? modelParams : null, $ai_input: this._prepareCapturedValue(normalizeInputRoles(spanData.input)), $ai_output_choices: this._prepareCapturedValue(spanData.output), $ai_input_tokens: inputTokens, $ai_output_tokens: outputTokens, $ai_total_tokens: inputTokens + outputTokens }; if (usage.details) { const details = usage.details; if (details.reasoning_tokens) { properties.$ai_reasoning_tokens = details.reasoning_tokens; } if (details.cache_read_input_tokens) { properties.$ai_cache_read_input_tokens = details.cache_read_input_tokens; } if (details.cache_creation_input_tokens) { properties.$ai_cache_creation_input_tokens = details.cache_creation_input_tokens; } } // Also check top-level usage for reasoning/cache tokens (flexible schema) if (usage.reasoning_tokens) { properties.$ai_reasoning_tokens = usage.reasoning_tokens; } if (usage.cache_read_input_tokens) { properties.$ai_cache_read_input_tokens = usage.cache_read_input_tokens; } if (usage.cache_creation_input_tokens) { properties.$ai_cache_creation_input_tokens = usage.cache_creation_input_tokens; } this._captureEvent('$ai_generation', properties, distinctId); } _handleResponseSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties) { // The OpenAI Agents SDK exposes these underscored fields for non-OpenAI tracing providers. // Treat them as best-effort and avoid assuming they are always present. const responseSpanData = spanData; const response = responseSpanData._response; const responseId = spanData.response_id ?? response?.id; // Extract usage from response const usage = response?.usage ?? {}; const inputTokens = usage?.input_tokens ?? 0; const outputTokens = usage?.output_tokens ?? 0; // Extract model from response const model = response?.model; const properties = { ...this._baseProperties(traceId, spanId, parentId, latency, groupId, errorProperties), $ai_model: model, $ai_response_id: responseId, $ai_input: this._prepareCapturedValue(normalizeInputRoles(responseSpanData._input)), $ai_input_tokens: inputTokens, $ai_output_tokens: outputTokens, $ai_total_tokens: inputTokens + outputTokens }; // Extract output from response if (response?.output) { properties.$ai_output_choices = this._prepareCapturedValue(response.output); } this._captureEvent('$ai_generation', properties, distinctId); } _handleFunctionSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties) { const properties = { ...this._baseProperties(traceId, spanId, parentId, latency, groupId, errorProperties), $ai_span_name: spanData.name, $ai_span_type: 'tool', $ai_input_state: this._prepareCapturedValue(spanData.input), $ai_output_state: this._prepareCapturedValue(spanData.output) }; if (spanData.mcp_data) { properties.$ai_mcp_data = this._prepareCapturedValue(spanData.mcp_data); } this._captureEvent('$ai_span', properties, distinctId); } _handleAgentSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties) { const properties = { ...this._baseProperties(traceId, spanId, parentId, latency, groupId, errorProperties), $ai_span_name: spanData.name, $ai_span_type: 'agent' }; if (spanData.handoffs) { properties.$ai_agent_handoffs = spanData.handoffs; } if (spanData.tools) { properties.$ai_agent_tools = spanData.tools; } if (spanData.output_type) { properties.$ai_agent_output_type = spanData.output_type; } this._captureEvent('$ai_span', properties, distinctId); } _handleHandoffSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties) { const properties = { ...this._baseProperties(traceId, spanId, parentId, latency, groupId, errorProperties), $ai_span_name: `${spanData.from_agent} -> ${spanData.to_agent}`, $ai_span_type: 'handoff', $ai_handoff_from_agent: spanData.from_agent, $ai_handoff_to_agent: spanData.to_agent }; this._captureEvent('$ai_span', properties, distinctId); } _handleGuardrailSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties) { const properties = { ...this._baseProperties(traceId, spanId, parentId, latency, groupId, errorProperties), $ai_span_name: spanData.name, $ai_span_type: 'guardrail', $ai_guardrail_triggered: spanData.triggered }; this._captureEvent('$ai_span', properties, distinctId); } _handleCustomSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties) { const properties = { ...this._baseProperties(traceId, spanId, parentId, latency, groupId, errorProperties), $ai_span_name: spanData.name, $ai_span_type: 'custom', $ai_custom_data: this._prepareCapturedValue(spanData.data) }; this._captureEvent('$ai_span', properties, distinctId); } _handleAudioSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties) { const spanType = spanData.type; const properties = { ...this._baseProperties(traceId, spanId, parentId, latency, groupId, errorProperties), $ai_span_name: spanType, $ai_span_type: spanType }; // Add model info if available if ('model' in spanData && spanData.model) { properties.$ai_model = spanData.model; } // Add model config if available if ('model_config' in spanData && spanData.model_config) { properties.$ai_model_config = this._prepareCapturedValue(spanData.model_config); } // Add audio format info if (spanData.type === 'transcription') { const transcription = spanData; if (transcription.input?.format) { properties.$ai_audio_input_format = transcription.input.format; } // Transcription output is text if (transcription.output) { properties.$ai_output_state = this._prepareCapturedValue(transcription.output); } } else if (spanData.type === 'speech') { const speech = spanData; if (speech.output?.format) { properties.$ai_audio_output_format = speech.output.format; } // Text input for TTS if (speech.input) { properties.$ai_input = this._prepareCapturedValue(speech.input); } } else if (spanData.type === 'speech_group') { const speechGroup = spanData; if (speechGroup.input) { properties.$ai_input = this._prepareCapturedValue(speechGroup.input); } } this._captureEvent('$ai_span', properties, distinctId); } _handleMcpSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties) { const properties = { ...this._baseProperties(traceId, spanId, parentId, latency, groupId, errorProperties), $ai_span_name: `mcp:${spanData.server}`, $ai_span_type: 'mcp_tools', $ai_mcp_server: spanData.server, $ai_mcp_tools: this._prepareCapturedValue(spanData.result) }; this._captureEvent('$ai_span', properties, distinctId); } _handleGenericSpan(spanData, traceId, spanId, parentId, latency, distinctId, groupId, errorProperties) { const spanType = spanData.type || 'unknown'; const properties = { ...this._baseProperties(traceId, spanId, parentId, latency, groupId, errorProperties), $ai_span_name: spanType, $ai_span_type: spanType }; this._captureEvent('$ai_span', properties, distinctId); } } /** * One-liner to instrument OpenAI Agents SDK with PostHog tracing. * * This registers a PostHogTracingProcessor with the OpenAI Agents SDK, * automatically capturing traces, spans, and LLM generations. * * @param options - Configuration options * @returns The registered processor instance * * @example * ```typescript * import { instrument } from '@posthog/ai/openai-agents' * import PostHog from 'posthog-node' * * const phClient = new PostHog('<API_KEY>') * * // Simple setup — await before running agents * await instrument({ client: phClient, distinctId: 'user@example.com' }) * * // With dynamic distinct ID * await instrument({ * client: phClient, * distinctId: (trace) => trace.metadata?.userId, * privacyMode: true, * properties: { environment: 'production' }, * }) * * // Now run agents as normal - traces automatically sent to PostHog * import { Agent, run } from '@openai/agents' * const agent = new Agent({ name: 'Assistant', instructions: 'You are helpful.' }) * const result = await run(agent, 'Hello!') * ``` */ async function instrument(options) { const { addTraceProcessor } = await import('@openai/agents-core'); const processor = new PostHogTracingProcessor({ client: options.client, distinctId: options.distinctId, privacyMode: options.privacyMode, groups: options.groups, properties: options.properties }); addTraceProcessor(processor); return processor; } export { PostHogTracingProcessor, instrument }; //# sourceMappingURL=index.mjs.map