@posthog/ai
Version:
PostHog Node.js AI integrations
1,128 lines (1,107 loc) • 36 kB
JavaScript
'use strict';
var uuid = require('uuid');
var core = require('@posthog/core');
var version = "7.9.2";
// Type guards for safer type checking
const isString = value => {
return typeof value === 'string';
};
const REDACTED_IMAGE_PLACEHOLDER = '[base64 image redacted]';
// ============================================
// Multimodal Feature Toggle
// ============================================
const isMultimodalEnabled = () => {
const val = process.env._INTERNAL_LLMA_MULTIMODAL || '';
return val.toLowerCase() === 'true' || val === '1' || val.toLowerCase() === 'yes';
};
// ============================================
// Base64 Detection Helpers
// ============================================
const isBase64DataUrl = str => {
return /^data:([^;]+);base64,/.test(str);
};
const isValidUrl = str => {
try {
new URL(str);
return true;
} catch {
// Not an absolute URL, check if it's a relative URL or path
return str.startsWith('/') || str.startsWith('./') || str.startsWith('../');
}
};
const isRawBase64 = str => {
// Skip if it's a valid URL or path
if (isValidUrl(str)) {
return false;
}
// Check if it's a valid base64 string
// Base64 images are typically at least a few hundred chars, but we'll be conservative
return str.length > 20 && /^[A-Za-z0-9+/]+=*$/.test(str);
};
function redactBase64DataUrl(str) {
if (isMultimodalEnabled()) return str;
if (!isString(str)) return str;
// Check for data URL format
if (isBase64DataUrl(str)) {
return REDACTED_IMAGE_PLACEHOLDER;
}
// Check for raw base64 (Vercel sends raw base64 for inline images)
if (isRawBase64(str)) {
return REDACTED_IMAGE_PLACEHOLDER;
}
return str;
}
// limit large outputs by truncating to 200kb (approx 200k bytes)
const MAX_OUTPUT_SIZE = 200000;
const STRING_FORMAT = 'utf8';
const getModelParams = params => {
if (!params) {
return {};
}
const modelParams = {};
const paramKeys = ['temperature', 'max_tokens', 'max_completion_tokens', 'top_p', 'frequency_penalty', 'presence_penalty', 'n', 'stop', 'stream', 'streaming', 'language', 'response_format', 'timestamp_granularities'];
for (const key of paramKeys) {
if (key in params && params[key] !== undefined) {
modelParams[key] = params[key];
}
}
return modelParams;
};
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 encoder = new TextEncoder();
const buffer = encoder.encode(str);
if (buffer.length <= MAX_OUTPUT_SIZE) {
// Ensure STRING_FORMAT is respected
return new TextDecoder(STRING_FORMAT).decode(buffer);
}
// Truncate the buffer and ensure a valid string is returned
const truncatedBuffer = buffer.slice(0, MAX_OUTPUT_SIZE);
// fatal: false means we get U+FFFD at the end if truncation broke the encoding
const decoder = new TextDecoder(STRING_FORMAT, {
fatal: false
});
let truncatedStr = decoder.decode(truncatedBuffer);
if (truncatedStr.endsWith('\uFFFD')) {
truncatedStr = truncatedStr.slice(0, -1);
}
return `${truncatedStr}... [truncated]`;
};
let AIEvent = /*#__PURE__*/function (AIEvent) {
AIEvent["Generation"] = "$ai_generation";
AIEvent["Embedding"] = "$ai_embedding";
return AIEvent;
}({});
function sanitizeValues(obj) {
if (obj === undefined || obj === null) {
return obj;
}
const jsonSafe = JSON.parse(JSON.stringify(obj));
if (typeof jsonSafe === 'string') {
// Sanitize lone surrogates by round-tripping through UTF-8
return new TextDecoder().decode(new TextEncoder().encode(jsonSafe));
} else if (Array.isArray(jsonSafe)) {
return jsonSafe.map(sanitizeValues);
} else if (jsonSafe && typeof jsonSafe === 'object') {
return Object.fromEntries(Object.entries(jsonSafe).map(([k, v]) => [k, sanitizeValues(v)]));
}
return jsonSafe;
}
const sendEventWithErrorToPosthog = async ({
client,
traceId,
error,
...args
}) => {
const httpStatus = error && typeof error === 'object' && 'status' in error ? error.status ?? 500 : 500;
const properties = {
client,
traceId,
httpStatus,
error: JSON.stringify(error),
...args
};
const enrichedError = error;
if (client.options?.enableExceptionAutocapture) {
// assign a uuid that can be used to link the trace and exception events
const exceptionId = core.uuidv7();
client.captureException(error, undefined, {
$ai_trace_id: traceId
}, exceptionId);
enrichedError.__posthog_previously_captured_error = true;
properties.exceptionId = exceptionId;
}
await sendEventToPosthog(properties);
return enrichedError;
};
const sendEventToPosthog = async ({
client,
eventType = AIEvent.Generation,
distinctId,
traceId,
model,
provider,
input,
output,
latency,
timeToFirstToken,
baseURL,
params,
httpStatus = 200,
usage = {},
error,
exceptionId,
tools,
captureImmediate = false
}) => {
if (!client.capture) {
return Promise.resolve();
}
// sanitize input and output for UTF-8 validity
const safeInput = sanitizeValues(input);
const safeOutput = sanitizeValues(output);
const safeError = sanitizeValues(error);
let errorData = {};
if (error) {
errorData = {
$ai_is_error: true,
$ai_error: safeError,
$exception_event_id: exceptionId
};
}
let costOverrideData = {};
if (params.posthogCostOverride) {
const inputCostUSD = (params.posthogCostOverride.inputCost ?? 0) * (usage.inputTokens ?? 0);
const outputCostUSD = (params.posthogCostOverride.outputCost ?? 0) * (usage.outputTokens ?? 0);
costOverrideData = {
$ai_input_cost_usd: inputCostUSD,
$ai_output_cost_usd: outputCostUSD,
$ai_total_cost_usd: inputCostUSD + outputCostUSD
};
}
const additionalTokenValues = {
...(usage.reasoningTokens ? {
$ai_reasoning_tokens: usage.reasoningTokens
} : {}),
...(usage.cacheReadInputTokens ? {
$ai_cache_read_input_tokens: usage.cacheReadInputTokens
} : {}),
...(usage.cacheCreationInputTokens ? {
$ai_cache_creation_input_tokens: usage.cacheCreationInputTokens
} : {}),
...(usage.webSearchCount ? {
$ai_web_search_count: usage.webSearchCount
} : {}),
...(usage.rawUsage ? {
$ai_usage: usage.rawUsage
} : {})
};
const properties = {
$ai_lib: 'posthog-ai',
$ai_lib_version: version,
$ai_provider: params.posthogProviderOverride ?? provider,
$ai_model: params.posthogModelOverride ?? model,
$ai_model_parameters: getModelParams(params),
$ai_input: withPrivacyMode(client, params.posthogPrivacyMode ?? false, safeInput),
$ai_output_choices: withPrivacyMode(client, params.posthogPrivacyMode ?? false, safeOutput),
$ai_http_status: httpStatus,
$ai_input_tokens: usage.inputTokens ?? 0,
...(usage.outputTokens !== undefined ? {
$ai_output_tokens: usage.outputTokens
} : {}),
...additionalTokenValues,
$ai_latency: latency,
...(timeToFirstToken !== undefined ? {
$ai_time_to_first_token: timeToFirstToken
} : {}),
$ai_trace_id: traceId,
$ai_base_url: baseURL,
...params.posthogProperties,
...(distinctId ? {} : {
$process_person_profile: false
}),
...(tools ? {
$ai_tools: tools
} : {}),
...errorData,
...costOverrideData
};
const event = {
distinctId: distinctId ?? traceId,
event: eventType,
properties,
groups: params.posthogGroups
};
if (captureImmediate) {
// await capture promise to send single event in serverless environments
await client.captureImmediate(event);
} else {
client.capture(event);
}
return Promise.resolve();
};
const OTEL_STATUS_ERROR = 2;
const AI_TELEMETRY_METADATA_PREFIX = 'ai.telemetry.metadata.';
function parseJsonValue(value) {
if (value === undefined || value === null) {
return null;
}
if (typeof value !== 'string') {
return value;
}
try {
return JSON.parse(value);
} catch {
return null;
}
}
function toNumber(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}
function toStringValue(value) {
return typeof value === 'string' ? value : undefined;
}
function toStringArray(value) {
if (!Array.isArray(value)) {
return [];
}
return value.filter(item => typeof item === 'string');
}
function toSafeBinaryData(value) {
const asString = typeof value === 'string' ? value : JSON.stringify(value ?? '');
return truncate(redactBase64DataUrl(asString));
}
function toMimeType(value) {
return typeof value === 'string' && value.length > 0 ? value : 'application/octet-stream';
}
function getSpanLatencySeconds(span) {
const duration = span.duration;
if (!duration || !Array.isArray(duration) || duration.length !== 2) {
return 0;
}
const seconds = Number(duration[0]) || 0;
const nanos = Number(duration[1]) || 0;
return seconds + nanos / 1_000_000_000;
}
function getOperationId(span) {
const attributes = span.attributes || {};
const operationId = toStringValue(attributes['ai.operationId']);
if (operationId) {
return operationId;
}
return span.name || '';
}
function isDoGenerateSpan(operationId) {
return operationId.endsWith('.doGenerate');
}
function isDoStreamSpan(operationId) {
return operationId.endsWith('.doStream');
}
function isDoEmbedSpan(operationId) {
return operationId.endsWith('.doEmbed');
}
function shouldMapAiSdkSpan(span) {
const operationId = getOperationId(span);
return isDoGenerateSpan(operationId) || isDoStreamSpan(operationId) || isDoEmbedSpan(operationId);
}
function extractAiSdkTelemetryMetadata(attributes) {
const metadata = {};
for (const [key, value] of Object.entries(attributes)) {
if (key.startsWith(AI_TELEMETRY_METADATA_PREFIX)) {
metadata[key.slice(AI_TELEMETRY_METADATA_PREFIX.length)] = value;
}
}
if (metadata.traceId && typeof metadata.traceId === 'string') {
metadata.trace_id = metadata.traceId;
}
return metadata;
}
function mapPromptMessagesInput(attributes) {
const promptMessages = parseJsonValue(attributes['ai.prompt.messages']) || [];
if (!Array.isArray(promptMessages)) {
return [];
}
return promptMessages.map(message => {
const role = typeof message?.role === 'string' ? message.role : 'user';
const content = message?.content;
if (typeof content === 'string') {
return {
role,
content: [{
type: 'text',
text: truncate(content)
}]
};
}
if (Array.isArray(content)) {
return {
role,
content: content.map(part => {
if (part && typeof part === 'object' && 'type' in part) {
const typedPart = part;
if (typedPart.type === 'text' && typeof typedPart.text === 'string') {
return {
type: 'text',
text: truncate(typedPart.text)
};
}
return typedPart;
}
return {
type: 'text',
text: truncate(String(part))
};
})
};
}
return {
role,
content: [{
type: 'text',
text: truncate(content)
}]
};
});
}
function mapPromptInput(attributes, operationId) {
if (isDoEmbedSpan(operationId)) {
if (attributes['ai.values'] !== undefined) {
return attributes['ai.values'];
}
return attributes['ai.value'] ?? null;
}
const promptMessages = mapPromptMessagesInput(attributes);
if (promptMessages.length > 0) {
return promptMessages;
}
if (attributes['ai.prompt'] !== undefined) {
return [{
role: 'user',
content: [{
type: 'text',
text: truncate(attributes['ai.prompt'])
}]
}];
}
return [];
}
function mapOutputPart(part) {
const partType = toStringValue(part.type);
if (partType === 'text' && typeof part.text === 'string') {
return {
type: 'text',
text: truncate(part.text)
};
}
if (partType === 'tool-call') {
const toolName = toStringValue(part.toolName) || toStringValue(part.function?.name) || '';
const toolCallId = toStringValue(part.toolCallId) || toStringValue(part.id) || '';
const input = 'input' in part ? part.input : part.function?.arguments;
if (toolName) {
return {
type: 'tool-call',
id: toolCallId,
function: {
name: toolName,
arguments: typeof input === 'string' ? input : JSON.stringify(input ?? {})
}
};
}
}
if (partType === 'file') {
const mediaType = toMimeType(part.mediaType ?? part.mimeType ?? part.contentType);
const data = part.data ?? part.base64 ?? part.bytes ?? part.url ?? part.uri;
if (data !== undefined) {
return {
type: 'file',
name: 'generated_file',
mediaType,
data: toSafeBinaryData(data)
};
}
}
if (partType === 'image') {
const mediaType = toMimeType(part.mediaType ?? part.mimeType ?? part.contentType ?? 'image/unknown');
const data = part.data ?? part.base64 ?? part.bytes ?? part.url ?? part.uri ?? part.image ?? part.image_url;
if (data !== undefined) {
return {
type: 'file',
name: 'generated_file',
mediaType,
data: toSafeBinaryData(data)
};
}
}
const inlineData = part.inlineData ?? part.inline_data;
if (inlineData && typeof inlineData === 'object' && inlineData.data !== undefined) {
const mediaType = toMimeType(inlineData.mimeType ?? inlineData.mime_type);
return {
type: 'file',
name: 'generated_file',
mediaType,
data: toSafeBinaryData(inlineData.data)
};
}
if (partType === 'object' && part.object !== undefined) {
return {
type: 'object',
object: part.object
};
}
return null;
}
function mapResponseMessagesOutput(attributes) {
const messagesRaw = parseJsonValue(attributes['ai.response.messages']) ?? parseJsonValue(attributes['ai.response.message']);
if (!messagesRaw) {
return [];
}
const messages = Array.isArray(messagesRaw) ? messagesRaw : [messagesRaw];
const mappedMessages = [];
for (const message of messages) {
if (!message || typeof message !== 'object') {
continue;
}
const role = toStringValue(message.role) || 'assistant';
const content = message.content;
if (typeof content === 'string') {
mappedMessages.push({
role,
content: [{
type: 'text',
text: truncate(content)
}]
});
continue;
}
if (Array.isArray(content)) {
const parts = content.map(part => part && typeof part === 'object' ? mapOutputPart(part) : null).filter(part => part !== null);
if (parts.length > 0) {
mappedMessages.push({
role,
content: parts
});
}
continue;
}
}
return mappedMessages;
}
function mapTextToolObjectOutputParts(attributes) {
const responseText = toStringValue(attributes['ai.response.text']) || '';
const toolCalls = parseJsonValue(attributes['ai.response.toolCalls']) || [];
const responseObjectRaw = attributes['ai.response.object'];
const responseObject = parseJsonValue(responseObjectRaw);
const contentParts = [];
if (responseText) {
contentParts.push({
type: 'text',
text: truncate(responseText)
});
}
if (responseObjectRaw !== undefined) {
contentParts.push({
type: 'object',
object: responseObject ?? responseObjectRaw
});
}
if (Array.isArray(toolCalls)) {
for (const toolCall of toolCalls) {
if (!toolCall || typeof toolCall !== 'object') {
continue;
}
const toolName = typeof toolCall.toolName === 'string' ? toolCall.toolName : '';
const toolCallId = typeof toolCall.toolCallId === 'string' ? toolCall.toolCallId : '';
if (!toolName) {
continue;
}
const input = 'input' in toolCall ? toolCall.input : {};
contentParts.push({
type: 'tool-call',
id: toolCallId,
function: {
name: toolName,
arguments: typeof input === 'string' ? input : JSON.stringify(input)
}
});
}
}
return contentParts;
}
function mapResponseFilesOutput(attributes) {
const responseFiles = parseJsonValue(attributes['ai.response.files']) || [];
if (!Array.isArray(responseFiles)) {
return [];
}
const mapped = [];
for (const file of responseFiles) {
if (!file || typeof file !== 'object') {
continue;
}
const mimeType = toMimeType(file.mimeType ?? file.mediaType ?? file.contentType);
const data = file.data ?? file.base64 ?? file.bytes;
const url = typeof file.url === 'string' ? file.url : typeof file.uri === 'string' ? file.uri : undefined;
if (data !== undefined) {
mapped.push({
type: 'file',
name: 'generated_file',
mediaType: mimeType,
data: toSafeBinaryData(data)
});
continue;
}
if (url) {
mapped.push({
type: 'file',
name: 'generated_file',
mediaType: mimeType,
data: truncate(url)
});
}
}
return mapped;
}
function extractGeminiParts(providerMetadata) {
const parts = [];
const visit = node => {
if (!node || typeof node !== 'object') {
return;
}
if (Array.isArray(node)) {
for (const item of node) {
visit(item);
}
return;
}
const objectNode = node;
const maybeParts = objectNode.parts;
if (Array.isArray(maybeParts)) {
for (const part of maybeParts) {
if (part && typeof part === 'object') {
parts.push(part);
}
}
}
for (const value of Object.values(objectNode)) {
visit(value);
}
};
visit(providerMetadata);
return parts;
}
function mapProviderMetadataInlineDataOutput(providerMetadata) {
const parts = extractGeminiParts(providerMetadata);
const mapped = [];
for (const part of parts) {
const inlineData = part.inlineData ?? part.inline_data;
if (!inlineData || typeof inlineData !== 'object') {
continue;
}
const mimeType = toMimeType(inlineData.mimeType ?? inlineData.mime_type);
if (inlineData.data === undefined) {
continue;
}
mapped.push({
type: 'file',
name: 'generated_file',
mediaType: mimeType,
data: toSafeBinaryData(inlineData.data)
});
}
return mapped;
}
function mapProviderMetadataTextOutput(providerMetadata) {
const parts = extractGeminiParts(providerMetadata);
const mapped = [];
for (const part of parts) {
if (typeof part.text === 'string' && part.text.length > 0) {
mapped.push({
type: 'text',
text: truncate(part.text)
});
}
}
return mapped;
}
function extractMediaBlocksFromUnknownNode(node) {
const mapped = [];
const visit = value => {
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
for (const item of value) {
visit(item);
}
return;
}
const objectValue = value;
const inlineData = objectValue.inlineData ?? objectValue.inline_data;
if (inlineData && typeof inlineData === 'object' && inlineData.data !== undefined) {
const mediaType = toMimeType(inlineData.mimeType ?? inlineData.mime_type);
mapped.push({
type: 'file',
name: 'generated_file',
mediaType,
data: toSafeBinaryData(inlineData.data)
});
}
if ((objectValue.type === 'file' || 'mediaType' in objectValue || 'mimeType' in objectValue) && objectValue.data) {
const mediaType = toMimeType(objectValue.mediaType ?? objectValue.mimeType);
mapped.push({
type: 'file',
name: 'generated_file',
mediaType,
data: toSafeBinaryData(objectValue.data)
});
}
for (const child of Object.values(objectValue)) {
visit(child);
}
};
visit(node);
return mapped;
}
function mapUnknownResponseAttributeMediaOutput(attributes) {
const mapped = [];
for (const [key, value] of Object.entries(attributes)) {
if (!key.startsWith('ai.response.')) {
continue;
}
if (key === 'ai.response.text' || key === 'ai.response.toolCalls' || key === 'ai.response.object' || key === 'ai.response.files' || key === 'ai.response.message' || key === 'ai.response.messages' || key === 'ai.response.providerMetadata') {
continue;
}
const parsed = typeof value === 'string' ? parseJsonValue(value) ?? value : value;
mapped.push(...extractMediaBlocksFromUnknownNode(parsed));
}
return mapped;
}
function mapGenericResponseAttributeMediaOutput(attributes) {
const mapped = [];
for (const [key, value] of Object.entries(attributes)) {
if (!key.includes('response') || key.startsWith('ai.response.') || key === 'ai.response.providerMetadata' || key.startsWith('ai.prompt.') || key.startsWith('gen_ai.request.')) {
continue;
}
const parsed = typeof value === 'string' ? parseJsonValue(value) : value;
if (parsed === null || parsed === undefined) {
continue;
}
mapped.push(...extractMediaBlocksFromUnknownNode(parsed));
}
return mapped;
}
function dedupeContentParts(parts) {
const seen = new Set();
const deduped = [];
for (const part of parts) {
const key = JSON.stringify(part);
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(part);
}
return deduped;
}
function mapOutput(attributes, operationId, providerMetadata) {
if (isDoEmbedSpan(operationId)) {
// Keep embedding behavior aligned with existing provider wrappers.
return null;
}
const responseMessages = mapResponseMessagesOutput(attributes);
if (responseMessages.length > 0) {
return responseMessages;
}
const textToolObjectParts = mapTextToolObjectOutputParts(attributes);
const responseFileParts = mapResponseFilesOutput(attributes);
const unknownMediaParts = mapUnknownResponseAttributeMediaOutput(attributes);
const genericResponseMediaParts = mapGenericResponseAttributeMediaOutput(attributes);
const providerMetadataTextParts = mapProviderMetadataTextOutput(providerMetadata);
const providerMetadataInlineParts = mapProviderMetadataInlineDataOutput(providerMetadata);
const mergedContentParts = dedupeContentParts([...textToolObjectParts, ...responseFileParts, ...unknownMediaParts, ...genericResponseMediaParts, ...providerMetadataTextParts, ...providerMetadataInlineParts]);
const contentParts = mergedContentParts;
if (contentParts.length === 0) {
return [];
}
return [{
role: 'assistant',
content: contentParts
}];
}
function mapModelSettings(attributes, operationId) {
const temperature = toNumber(attributes['ai.settings.temperature']) ?? toNumber(attributes['gen_ai.request.temperature']);
const maxTokens = toNumber(attributes['ai.settings.maxTokens']) ?? toNumber(attributes['gen_ai.request.max_tokens']);
const maxOutputTokens = toNumber(attributes['ai.settings.maxOutputTokens']);
const topP = toNumber(attributes['ai.settings.topP']) ?? toNumber(attributes['gen_ai.request.top_p']);
const frequencyPenalty = toNumber(attributes['ai.settings.frequencyPenalty']) ?? toNumber(attributes['gen_ai.request.frequency_penalty']);
const presencePenalty = toNumber(attributes['ai.settings.presencePenalty']) ?? toNumber(attributes['gen_ai.request.presence_penalty']);
const stopSequences = parseJsonValue(attributes['ai.settings.stopSequences']) ?? parseJsonValue(attributes['gen_ai.request.stop_sequences']);
const stream = isDoStreamSpan(operationId);
return {
...(temperature !== undefined ? {
temperature
} : {}),
...(maxTokens !== undefined ? {
max_tokens: maxTokens
} : {}),
...(maxOutputTokens !== undefined ? {
max_completion_tokens: maxOutputTokens
} : {}),
...(topP !== undefined ? {
top_p: topP
} : {}),
...(frequencyPenalty !== undefined ? {
frequency_penalty: frequencyPenalty
} : {}),
...(presencePenalty !== undefined ? {
presence_penalty: presencePenalty
} : {}),
...(stopSequences !== null ? {
stop: stopSequences
} : {}),
...(stream ? {
stream: true
} : {})
};
}
function mapUsage(attributes, providerMetadata, operationId) {
if (isDoEmbedSpan(operationId)) {
const tokens = toNumber(attributes['ai.usage.tokens']) ?? toNumber(attributes['gen_ai.usage.input_tokens']) ?? 0;
return {
inputTokens: tokens,
rawUsage: {
usage: {
tokens
},
providerMetadata
}
};
}
const inputTokens = toNumber(attributes['ai.usage.promptTokens']) ?? toNumber(attributes['gen_ai.usage.input_tokens']) ?? 0;
const outputTokens = toNumber(attributes['ai.usage.completionTokens']) ?? toNumber(attributes['gen_ai.usage.output_tokens']) ?? 0;
const totalTokens = toNumber(attributes['ai.usage.totalTokens']);
const reasoningTokens = toNumber(attributes['ai.usage.reasoningTokens']);
const cachedInputTokens = toNumber(attributes['ai.usage.cachedInputTokens']);
return {
inputTokens,
outputTokens,
...(reasoningTokens !== undefined ? {
reasoningTokens
} : {}),
...(cachedInputTokens !== undefined ? {
cacheReadInputTokens: cachedInputTokens
} : {}),
rawUsage: {
usage: {
promptTokens: inputTokens,
completionTokens: outputTokens,
...(totalTokens !== undefined ? {
totalTokens
} : {})
},
providerMetadata
}
};
}
function parsePromptTools(attributes) {
const rawTools = attributes['ai.prompt.tools'];
if (!Array.isArray(rawTools)) {
return null;
}
const parsedTools = [];
for (const rawTool of rawTools) {
if (typeof rawTool === 'string') {
const parsed = parseJsonValue(rawTool);
if (parsed !== null) {
parsedTools.push(parsed);
}
continue;
}
if (rawTool && typeof rawTool === 'object') {
parsedTools.push(rawTool);
}
}
return parsedTools.length > 0 ? parsedTools : null;
}
function extractProviderMetadata(attributes) {
const rawProviderMetadata = attributes['ai.response.providerMetadata'];
return parseJsonValue(rawProviderMetadata) || {};
}
function getAiSdkFrameworkVersion(span) {
const instrumentedSpan = span;
const attributes = span.attributes || {};
const instrumentationScopeVersion = toStringValue(instrumentedSpan.instrumentationScope?.version) || toStringValue(instrumentedSpan.instrumentationLibrary?.version);
const aiUserAgent = toStringValue(attributes['ai.request.headers.user-agent']);
const userAgentVersionMatch = aiUserAgent?.match(/\bai\/(\d+(?:\.\d+)*)\b/i);
const userAgentVersion = userAgentVersionMatch?.[1];
const rawVersion = instrumentationScopeVersion || userAgentVersion;
if (!rawVersion) {
return undefined;
}
const majorVersionMatch = rawVersion.match(/^v?(\d+)/i);
return majorVersionMatch ? majorVersionMatch[1] : rawVersion;
}
function buildPosthogProperties(attributes, operationId) {
const telemetryMetadata = extractAiSdkTelemetryMetadata(attributes);
const finishReasons = toStringArray(parseJsonValue(attributes['gen_ai.response.finish_reasons']));
const finishReason = toStringValue(attributes['ai.response.finishReason']) || finishReasons[0];
const toolChoice = parseJsonValue(attributes['ai.prompt.toolChoice']) ?? attributes['ai.prompt.toolChoice'];
return {
...telemetryMetadata,
$ai_framework: 'vercel',
ai_operation_id: operationId,
...(finishReason ? {
ai_finish_reason: finishReason
} : {}),
...(toStringValue(attributes['ai.response.model']) ? {
ai_response_model: attributes['ai.response.model']
} : {}),
...(toStringValue(attributes['gen_ai.response.model']) ? {
ai_response_model: attributes['gen_ai.response.model']
} : {}),
...(toStringValue(attributes['ai.response.id']) ? {
ai_response_id: attributes['ai.response.id']
} : {}),
...(toStringValue(attributes['gen_ai.response.id']) ? {
ai_response_id: attributes['gen_ai.response.id']
} : {}),
...(toStringValue(attributes['ai.response.timestamp']) ? {
ai_response_timestamp: attributes['ai.response.timestamp']
} : {}),
...(toNumber(attributes['ai.response.msToFinish']) !== undefined ? {
ai_response_ms_to_finish: toNumber(attributes['ai.response.msToFinish'])
} : {}),
...(toNumber(attributes['ai.response.avgCompletionTokensPerSecond']) !== undefined ? {
ai_response_avg_completion_tokens_per_second: toNumber(attributes['ai.response.avgCompletionTokensPerSecond'])
} : {}),
...(toStringValue(attributes['ai.telemetry.functionId']) ? {
ai_telemetry_function_id: attributes['ai.telemetry.functionId']
} : {}),
...(toNumber(attributes['ai.settings.maxRetries']) !== undefined ? {
ai_settings_max_retries: toNumber(attributes['ai.settings.maxRetries'])
} : {}),
...(toNumber(attributes['gen_ai.request.top_k']) !== undefined ? {
ai_request_top_k: toNumber(attributes['gen_ai.request.top_k'])
} : {}),
...(attributes['ai.schema.name'] !== undefined ? {
ai_schema_name: attributes['ai.schema.name']
} : {}),
...(attributes['ai.schema.description'] !== undefined ? {
ai_schema_description: attributes['ai.schema.description']
} : {}),
...(attributes['ai.settings.output'] !== undefined ? {
ai_settings_output: attributes['ai.settings.output']
} : {}),
...(toolChoice ? {
ai_prompt_tool_choice: toolChoice
} : {})
};
}
function buildAiSdkMapperResult(span) {
const attributes = span.attributes || {};
const operationId = getOperationId(span);
const providerMetadata = extractProviderMetadata(attributes);
const model = toStringValue(attributes['ai.model.id']) || toStringValue(attributes['gen_ai.request.model']) || 'unknown';
const provider = (toStringValue(attributes['ai.model.provider']) || toStringValue(attributes['gen_ai.system']) || 'unknown').toLowerCase();
const latency = getSpanLatencySeconds(span);
const timeToFirstTokenMs = toNumber(attributes['ai.response.msToFirstChunk']);
const timeToFirstToken = timeToFirstTokenMs !== undefined ? timeToFirstTokenMs / 1000 : undefined;
const input = mapPromptInput(attributes, operationId);
const output = mapOutput(attributes, operationId, providerMetadata);
const usage = mapUsage(attributes, providerMetadata, operationId);
const modelParams = mapModelSettings(attributes, operationId);
const tools = parsePromptTools(attributes);
const httpStatus = toNumber(attributes['http.response.status_code']) || 200;
const eventType = isDoEmbedSpan(operationId) ? AIEvent.Embedding : AIEvent.Generation;
const frameworkVersion = getAiSdkFrameworkVersion(span);
const error = span.status?.code === OTEL_STATUS_ERROR ? span.status.message || 'AI SDK span recorded error status' : undefined;
return {
model,
provider,
input,
output,
latency,
timeToFirstToken,
httpStatus,
eventType,
usage,
tools,
modelParams,
posthogProperties: {
...buildPosthogProperties(attributes, operationId),
...(frameworkVersion ? {
$ai_framework_version: frameworkVersion
} : {})
},
error
};
}
const aiSdkSpanMapper = {
name: 'ai-sdk',
canMap: shouldMapAiSdkSpan,
map: span => {
return buildAiSdkMapperResult(span);
}
};
const defaultSpanMappers = [aiSdkSpanMapper];
function pickMapper(span, mappers) {
return mappers.find(mapper => {
try {
return mapper.canMap(span);
} catch {
return false;
}
});
}
function getTraceId(span, options, mapperTraceId) {
if (mapperTraceId) {
return mapperTraceId;
}
if (options.posthogTraceId) {
return options.posthogTraceId;
}
const spanTraceId = span.spanContext?.().traceId;
return spanTraceId || uuid.v4();
}
function buildPosthogParams(options, traceId, distinctId, modelParams, posthogProperties) {
return {
...modelParams,
posthogDistinctId: distinctId,
posthogTraceId: traceId,
posthogProperties,
posthogPrivacyMode: options.posthogPrivacyMode,
posthogGroups: options.posthogGroups,
posthogModelOverride: options.posthogModelOverride,
posthogProviderOverride: options.posthogProviderOverride,
posthogCostOverride: options.posthogCostOverride,
posthogCaptureImmediate: options.posthogCaptureImmediate
};
}
async function captureSpan(span, phClient, options = {}) {
if (options.shouldExportSpan && options.shouldExportSpan({
otelSpan: span
}) === false) {
return;
}
const mappers = options.mappers ?? defaultSpanMappers;
const mapper = pickMapper(span, mappers);
if (!mapper) {
return;
}
const mapped = mapper.map(span, {
options
});
if (!mapped) {
return;
}
const traceId = getTraceId(span, options, mapped.traceId);
const distinctId = mapped.distinctId ?? options.posthogDistinctId;
const posthogProperties = {
...options.posthogProperties,
...mapped.posthogProperties
};
const params = buildPosthogParams(options, traceId, distinctId, mapped.modelParams ?? {}, posthogProperties);
const baseURL = mapped.baseURL ?? '';
const usage = mapped.usage ?? {};
if (mapped.error !== undefined) {
await sendEventWithErrorToPosthog({
eventType: mapped.eventType,
client: phClient,
distinctId,
traceId,
model: mapped.model,
provider: mapped.provider,
input: mapped.input,
output: mapped.output,
latency: mapped.latency,
baseURL,
params: params,
usage,
tools: mapped.tools,
error: mapped.error,
captureImmediate: options.posthogCaptureImmediate
});
return;
}
await sendEventToPosthog({
eventType: mapped.eventType,
client: phClient,
distinctId,
traceId,
model: mapped.model,
provider: mapped.provider,
input: mapped.input,
output: mapped.output,
latency: mapped.latency,
timeToFirstToken: mapped.timeToFirstToken,
baseURL,
params: params,
httpStatus: mapped.httpStatus ?? 200,
usage,
tools: mapped.tools,
captureImmediate: options.posthogCaptureImmediate
});
}
class PostHogSpanProcessor {
pendingCaptures = new Set();
constructor(phClient, options = {}) {
this.phClient = phClient;
this.options = options;
}
onStart(_span, _parentContext) {
// no-op
}
onEnd(span) {
const capturePromise = captureSpan(span, this.phClient, this.options).catch(error => {
console.error('Failed to capture telemetry span', error);
}).finally(() => {
this.pendingCaptures.delete(capturePromise);
});
this.pendingCaptures.add(capturePromise);
}
async shutdown() {
await this.forceFlush();
}
async forceFlush() {
while (this.pendingCaptures.size > 0) {
await Promise.allSettled([...this.pendingCaptures]);
}
}
}
function createPostHogSpanProcessor(phClient, options = {}) {
return new PostHogSpanProcessor(phClient, options);
}
exports.PostHogSpanProcessor = PostHogSpanProcessor;
exports.aiSdkSpanMapper = aiSdkSpanMapper;
exports.captureSpan = captureSpan;
exports.createPostHogSpanProcessor = createPostHogSpanProcessor;
//# sourceMappingURL=index.cjs.map