@posthog/ai
Version:
PostHog Node.js AI integrations
722 lines (694 loc) • 25.5 kB
JavaScript
'use strict';
require('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;
}
exports.PostHogTracingProcessor = PostHogTracingProcessor;
exports.instrument = instrument;
//# sourceMappingURL=index.cjs.map