diff --git a/.changeset/node-ai-capture-lane.md b/.changeset/node-ai-capture-lane.md new file mode 100644 index 0000000000..424f8a0958 --- /dev/null +++ b/.changeset/node-ai-capture-lane.md @@ -0,0 +1,7 @@ +--- +'@posthog/core': minor +'posthog-node': minor +'@posthog/ai': minor +--- + +Public beta `captureAi()` / `captureAiImmediate()`: AI events on a dedicated isolated endpoint with the event UUID returned. New `enableFullAiCapture` option replaces the internal `_useAiLane` / `_enableMultimodalCapture`; wrappers route through the AI endpoint and skip redaction/truncation when set (privacy mode still wins). diff --git a/compliance/node/adapter.js b/compliance/node/adapter.js index 8b73204069..959949e016 100644 --- a/compliance/node/adapter.js +++ b/compliance/node/adapter.js @@ -47,9 +47,10 @@ async function discardClient() { try { client.clearFlushTimer?.() client.setPersistedProperty?.('queue', []) - // v1 mode routes $ai_* events to a separate queue; clear it too so they can't - // leak into the next scenario. + // v1 mode routes $ai_* events to a separate queue, and captureAi() events to + // their own dedicated queue; clear both so they can't leak into the next scenario. client.setPersistedProperty?.('ai_queue', []) + client.setPersistedProperty?.('ai_capture_queue', []) await client.shutdown(1) } catch (error) { // Ignore reset-time shutdown errors; the next test starts with a fresh client. @@ -61,7 +62,10 @@ app.get('/health', (req, res) => { sdk_name: 'posthog-node', sdk_version: require('../packages/node/package.json').version, adapter_version: '1.0.0', - capabilities: CAPTURE_MODE === 'v1' ? ['capture_v1', 'encoding_gzip'] : ['capture_v0', 'encoding_gzip'], + capabilities: + CAPTURE_MODE === 'v1' + ? ['capture_v1', 'capture_ai_v0', 'encoding_gzip'] + : ['capture_v0', 'capture_ai_v0', 'encoding_gzip'], }) }) @@ -185,6 +189,46 @@ app.post('/capture', (req, res) => { } }) +app.post('/capture_ai', (req, res) => { + if (!state.client) { + return res.status(400).json({ error: 'SDK not initialized' }) + } + + const { distinct_id, event, properties, timestamp, options, uuid } = req.body + + if (!distinct_id || !event) { + return res.status(400).json({ error: 'distinct_id and event are required' }) + } + + try { + const mergedProperties = { ...(properties || {}) } + if (options && typeof options === 'object') { + for (const [optionKey, sentinel] of Object.entries(OPTION_SENTINELS)) { + if (Object.prototype.hasOwnProperty.call(options, optionKey)) { + mergedProperties[sentinel] = options[optionKey] + } + } + } + + // Unlike /capture, forward a supplied uuid so it's echoed back to the caller. + const returnedUuid = state.client.captureAi({ + distinctId: distinct_id, + event, + properties: mergedProperties, + timestamp: timestamp ? new Date(timestamp) : undefined, + uuid, + }) + + state.totalEventsCaptured++ + state.pendingEvents++ + + res.json({ success: true, uuid: returnedUuid }) + } catch (error) { + state.lastError = error.message + res.status(500).json({ error: error.message }) + } +}) + app.post('/flush', async (req, res) => { if (!state.client) { return res.status(400).json({ error: 'SDK not initialized' }) diff --git a/packages/ai/src/anthropic/index.ts b/packages/ai/src/anthropic/index.ts index 3ad1030112..4ec3d81629 100644 --- a/packages/ai/src/anthropic/index.ts +++ b/packages/ai/src/anthropic/index.ts @@ -244,7 +244,7 @@ export class WrappedMessages extends AnthropicOriginal.Messages { ...posthogParams, model: anthropicParams.model, provider: 'anthropic', - input: sanitizeAnthropic(mergeSystemPrompt(anthropicParams, 'anthropic')), + input: sanitizeAnthropic(mergeSystemPrompt(anthropicParams, 'anthropic'), this.phClient), output: formattedOutput, latency, timeToFirstToken, @@ -260,7 +260,7 @@ export class WrappedMessages extends AnthropicOriginal.Messages { ...posthogParams, model: anthropicParams.model, provider: 'anthropic', - input: sanitizeAnthropic(mergeSystemPrompt(anthropicParams, 'anthropic')), + input: sanitizeAnthropic(mergeSystemPrompt(anthropicParams, 'anthropic'), this.phClient), output: [], latency: 0, baseURL: this.baseURL, @@ -298,7 +298,7 @@ export class WrappedMessages extends AnthropicOriginal.Messages { ...posthogParams, model: anthropicParams.model, provider: 'anthropic', - input: sanitizeAnthropic(mergeSystemPrompt(anthropicParams, 'anthropic')), + input: sanitizeAnthropic(mergeSystemPrompt(anthropicParams, 'anthropic'), this.phClient), output: formatResponseAnthropic(result), latency, baseURL: this.baseURL, @@ -323,7 +323,7 @@ export class WrappedMessages extends AnthropicOriginal.Messages { ...posthogParams, model: anthropicParams.model, provider: 'anthropic', - input: sanitizeAnthropic(mergeSystemPrompt(anthropicParams, 'anthropic')), + input: sanitizeAnthropic(mergeSystemPrompt(anthropicParams, 'anthropic'), this.phClient), output: [], latency: 0, baseURL: this.baseURL, diff --git a/packages/ai/src/captureAiEvent.ts b/packages/ai/src/captureAiEvent.ts new file mode 100644 index 0000000000..6743f0befc --- /dev/null +++ b/packages/ai/src/captureAiEvent.ts @@ -0,0 +1,37 @@ +import type { EventMessage } from 'posthog-node' + +/** @internal */ +export type FullAiCaptureGate = { + readonly enableFullAiCapture?: boolean +} + +/** @internal */ +export interface AiLaneCapableClient extends FullAiCaptureGate { + capture(props: EventMessage): void + captureImmediate(props: EventMessage): Promise + captureAi?(props: EventMessage): string | undefined + captureAiImmediate?(props: EventMessage): Promise +} + +/** @internal */ +export function isFullAiCaptureEnabled(client?: FullAiCaptureGate): boolean { + return client?.enableFullAiCapture === true +} + +/** @internal */ +export function captureAiEvent(client: AiLaneCapableClient, event: EventMessage): void { + if (isFullAiCaptureEnabled(client) && typeof client.captureAi === 'function') { + client.captureAi(event) + return + } + client.capture(event) +} + +/** @internal */ +export async function captureAiEventImmediate(client: AiLaneCapableClient, event: EventMessage): Promise { + if (isFullAiCaptureEnabled(client) && typeof client.captureAiImmediate === 'function') { + await client.captureAiImmediate(event) + return + } + await client.captureImmediate(event) +} diff --git a/packages/ai/src/captureAiGeneration.ts b/packages/ai/src/captureAiGeneration.ts index e11d88c8e1..a5ca1a95f1 100644 --- a/packages/ai/src/captureAiGeneration.ts +++ b/packages/ai/src/captureAiGeneration.ts @@ -6,6 +6,7 @@ import type { TokenUsage } from './types' import { stringifyError } from './serializeError' import { AIEvent, CostOverride, getTokensSource, hasTokenOverrides, withPrivacyMode } from './utils' import { warnIfPostHogAiGateway } from './gatewayWarning' +import { captureAiEvent, captureAiEventImmediate } from './captureAiEvent' /** * Options for `captureAiGeneration`. Mirrors the `$ai_generation` event shape @@ -222,9 +223,9 @@ export const captureAiGeneration = async (client: PostHog, options: CaptureAiGen } if (options.captureImmediate) { - await client.captureImmediate(event) + await captureAiEventImmediate(client, event) } else { - client.capture(event) + captureAiEvent(client, event) } } catch (error) { // Telemetry failures must never affect the instrumented provider call. diff --git a/packages/ai/src/gemini/index.ts b/packages/ai/src/gemini/index.ts index 06877a9aca..9b0f77734f 100644 --- a/packages/ai/src/gemini/index.ts +++ b/packages/ai/src/gemini/index.ts @@ -68,7 +68,7 @@ export class WrappedModels { model: geminiParams.model, provider: 'gemini', input: this.formatInputForPostHog(geminiParams), - output: formatResponseGemini(response), + output: formatResponseGemini(response, this.phClient), latency, baseURL: 'https://generativelanguage.googleapis.com', modelParameters: getModelParams(params as GenerateContentParameters & MonitoringParams), @@ -424,7 +424,7 @@ export class WrappedModels { } private formatInputForPostHog(params: GenerateContentParameters): FormattedMessage[] { - const sanitized = sanitizeGemini(params.contents) + const sanitized = sanitizeGemini(params.contents, this.phClient) const messages = this.formatInput(sanitized) const systemInstruction = this.extractSystemInstruction(params) diff --git a/packages/ai/src/langchain/callbacks.ts b/packages/ai/src/langchain/callbacks.ts index 8dd0b31fd2..6ce1b0f940 100644 --- a/packages/ai/src/langchain/callbacks.ts +++ b/packages/ai/src/langchain/callbacks.ts @@ -13,6 +13,7 @@ import { sanitizeLangChain } from '../sanitization' import { stringifyError } from '../serializeError' import { warnIfPostHogAiGateway } from '../gatewayWarning' import { isObject } from '../typeGuards' +import { captureAiEvent } from '../captureAiEvent' // Mirror LangGraph's isGraphBubbleUp guard without adding LangGraph as a dependency. Every // LangGraph control-flow exception (GraphInterrupt, NodeInterrupt, ParentCommand, GraphDrained, @@ -338,7 +339,7 @@ export class LangChainCallbackHandler extends BaseCallbackHandler { const runNameFound = this._getLangchainRunName(serialized, { extraParams, runName }) || 'generation' const generation: GenerationMetadata = { name: runNameFound, - input: sanitizeLangChain(messages), + input: sanitizeLangChain(messages, this.client), startTime: Date.now(), } if (extraParams) { @@ -388,7 +389,7 @@ export class LangChainCallbackHandler extends BaseCallbackHandler { private _safeCapture(message: EventMessage): void { try { - this.client.capture(message) + captureAiEvent(this.client, message) } catch { // Telemetry delivery must never affect the LangChain callback lifecycle. } @@ -426,7 +427,7 @@ export class LangChainCallbackHandler extends BaseCallbackHandler { $ai_lib: 'posthog-ai', $ai_lib_version: version, $ai_trace_id: traceId, - $ai_input_state: withPrivacyMode(this.client, this.privacyMode, sanitizeLangChain(run.input)), + $ai_input_state: withPrivacyMode(this.client, this.privacyMode, sanitizeLangChain(run.input, this.client)), $ai_latency: latency, $ai_span_name: run.name, $ai_span_id: runId, @@ -450,7 +451,7 @@ export class LangChainCallbackHandler extends BaseCallbackHandler { eventProperties['$ai_output_state'] = withPrivacyMode( this.client, this.privacyMode, - sanitizeLangChain({ __interrupt__: interrupts }) + sanitizeLangChain({ __interrupt__: interrupts }, this.client) ) } } else { @@ -458,7 +459,11 @@ export class LangChainCallbackHandler extends BaseCallbackHandler { eventProperties['$ai_is_error'] = true } } else if (outputs !== undefined) { - eventProperties['$ai_output_state'] = withPrivacyMode(this.client, this.privacyMode, sanitizeLangChain(outputs)) + eventProperties['$ai_output_state'] = withPrivacyMode( + this.client, + this.privacyMode, + sanitizeLangChain(outputs, this.client) + ) } this._safeCapture({ distinctId: this.distinctId ? this.distinctId.toString() : runId, @@ -685,7 +690,7 @@ export class LangChainCallbackHandler extends BaseCallbackHandler { } // Sanitize the message content to redact base64 images - return sanitizeLangChain(messageDict) as Record + return sanitizeLangChain(messageDict, this.client) as Record } private _extractStopReason(output: LLMResult): string | undefined { diff --git a/packages/ai/src/openai-agents/processor.ts b/packages/ai/src/openai-agents/processor.ts index 0297c245a4..7d3d072a33 100644 --- a/packages/ai/src/openai-agents/processor.ts +++ b/packages/ai/src/openai-agents/processor.ts @@ -20,6 +20,7 @@ import type { import { MAX_OUTPUT_SIZE, toContentString, truncate, utf8ByteLength, withPrivacyMode } from '../utils' import { version } from '../../package.json' import { warnIfPostHogAiGateway } from '../gatewayWarning' +import { captureAiEvent, isFullAiCaptureEnabled } from '../captureAiEvent' /** * Normalize OpenAI Responses API input items to include a `role` field. @@ -181,7 +182,10 @@ export class PostHogTracingProcessor implements TracingProcessor { private _prepareCapturedValue(value: unknown): unknown { const serializableValue = ensureSerializable(value) const serializedValue = stringifyForSizeCheck(serializableValue) - const boundedValue = exceedsMaxOutputSize(serializedValue) ? truncate(serializedValue) : serializableValue + const boundedValue = + isFullAiCaptureEnabled(this._client) || !exceedsMaxOutputSize(serializedValue) + ? serializableValue + : truncate(serializedValue, this._client) return this._withPrivacyMode(boundedValue) } @@ -230,7 +234,7 @@ export class PostHogTracingProcessor implements TracingProcessor { groups: Object.keys(this._groups).length > 0 ? this._groups : undefined, } - this._client.capture(eventMessage) + captureAiEvent(this._client, eventMessage) } catch (error) { this._handleError(error, 'capture') } diff --git a/packages/ai/src/openai/azure.ts b/packages/ai/src/openai/azure.ts index 5fb11de520..5e5b3beb9d 100644 --- a/packages/ai/src/openai/azure.ts +++ b/packages/ai/src/openai/azure.ts @@ -280,8 +280,8 @@ export class WrappedCompletions extends AzureOpenAI.Chat.Completions { ...posthogParams, model: openAIParams.model ?? modelFromResponse, provider: 'azure', - input: sanitizeOpenAI(openAIParams.messages), - output: sanitizeOpenAIResponse(formattedOutput), + input: sanitizeOpenAI(openAIParams.messages, this.phClient), + output: sanitizeOpenAIResponse(formattedOutput, this.phClient), latency, timeToFirstToken, baseURL: this.baseURL, @@ -296,7 +296,7 @@ export class WrappedCompletions extends AzureOpenAI.Chat.Completions { ...posthogParams, model: openAIParams.model, provider: 'azure', - input: sanitizeOpenAI(openAIParams.messages), + input: sanitizeOpenAI(openAIParams.messages, this.phClient), output: [], latency: 0, baseURL: this.baseURL, @@ -332,8 +332,8 @@ export class WrappedCompletions extends AzureOpenAI.Chat.Completions { ...posthogParams, model: openAIParams.model ?? result.model, provider: 'azure', - input: sanitizeOpenAI(openAIParams.messages), - output: sanitizeOpenAIResponse(formatResponseOpenAI(result)), + input: sanitizeOpenAI(openAIParams.messages, this.phClient), + output: sanitizeOpenAIResponse(formatResponseOpenAI(result), this.phClient), latency, baseURL: this.baseURL, modelParameters: getModelParams(body, result.service_tier), @@ -364,7 +364,7 @@ export class WrappedCompletions extends AzureOpenAI.Chat.Completions { ...posthogParams, model: openAIParams.model, provider: 'azure', - input: sanitizeOpenAI(openAIParams.messages), + input: sanitizeOpenAI(openAIParams.messages, this.phClient), output: [], latency: 0, baseURL: this.baseURL, @@ -541,10 +541,10 @@ export class WrappedResponses extends AzureOpenAI.Responses { model: openAIParams.model ?? modelFromResponse, provider: 'azure', input: formatOpenAIResponsesInput( - sanitizeOpenAIResponse(openAIParams.input), + sanitizeOpenAIResponse(openAIParams.input, this.phClient), openAIParams.instructions ), - output: sanitizeOpenAIResponse(finalContent), + output: sanitizeOpenAIResponse(finalContent, this.phClient), latency, timeToFirstToken, baseURL: this.baseURL, @@ -572,7 +572,7 @@ export class WrappedResponses extends AzureOpenAI.Responses { model: openAIParams.model, provider: 'azure', input: formatOpenAIResponsesInput( - sanitizeOpenAIResponse(openAIParams.input), + sanitizeOpenAIResponse(openAIParams.input, this.phClient), openAIParams.instructions ), output: [], @@ -612,8 +612,11 @@ export class WrappedResponses extends AzureOpenAI.Responses { ...posthogParams, model: openAIParams.model ?? result.model, provider: 'azure', - input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions), - output: sanitizeOpenAIResponse(result.output), + input: formatOpenAIResponsesInput( + sanitizeOpenAIResponse(openAIParams.input, this.phClient), + openAIParams.instructions + ), + output: sanitizeOpenAIResponse(result.output, this.phClient), latency, baseURL: this.baseURL, modelParameters: getModelParams(body, result.service_tier), @@ -647,7 +650,10 @@ export class WrappedResponses extends AzureOpenAI.Responses { ...posthogParams, model: openAIParams.model, provider: 'azure', - input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions), + input: formatOpenAIResponsesInput( + sanitizeOpenAIResponse(openAIParams.input, this.phClient), + openAIParams.instructions + ), output: [], latency: 0, baseURL: this.baseURL, @@ -769,8 +775,11 @@ export class WrappedResponses extends AzureOpenAI.Responses { ...posthogParams, model: openAIParams.model ?? result.model, provider: 'azure', - input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions), - output: sanitizeOpenAIResponse(result.output), + input: formatOpenAIResponsesInput( + sanitizeOpenAIResponse(openAIParams.input, this.phClient), + openAIParams.instructions + ), + output: sanitizeOpenAIResponse(result.output, this.phClient), latency, baseURL: this.baseURL, modelParameters: getModelParams(body, result.service_tier), @@ -798,7 +807,10 @@ export class WrappedResponses extends AzureOpenAI.Responses { ...posthogParams, model: openAIParams.model, provider: 'azure', - input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions), + input: formatOpenAIResponsesInput( + sanitizeOpenAIResponse(openAIParams.input, this.phClient), + openAIParams.instructions + ), output: [], latency: 0, baseURL: this.baseURL, diff --git a/packages/ai/src/openai/index.ts b/packages/ai/src/openai/index.ts index 16b660402a..085f7d8cf8 100644 --- a/packages/ai/src/openai/index.ts +++ b/packages/ai/src/openai/index.ts @@ -321,8 +321,8 @@ export class WrappedCompletions extends Completions { ...posthogParams, model: openAIParams.model ?? modelFromResponse, provider: 'openai', - input: sanitizeOpenAI(openAIParams.messages), - output: sanitizeOpenAIResponse(formattedOutput), + input: sanitizeOpenAI(openAIParams.messages, this.phClient), + output: sanitizeOpenAIResponse(formattedOutput, this.phClient), latency, timeToFirstToken, baseURL: this.baseURL, @@ -347,7 +347,7 @@ export class WrappedCompletions extends Completions { ...posthogParams, model: openAIParams.model, provider: 'openai', - input: sanitizeOpenAI(openAIParams.messages), + input: sanitizeOpenAI(openAIParams.messages, this.phClient), output: [], latency: 0, baseURL: this.baseURL, @@ -385,8 +385,8 @@ export class WrappedCompletions extends Completions { ...posthogParams, model: openAIParams.model ?? result.model, provider: 'openai', - input: sanitizeOpenAI(openAIParams.messages), - output: sanitizeOpenAIResponse(formattedOutput), + input: sanitizeOpenAI(openAIParams.messages, this.phClient), + output: sanitizeOpenAIResponse(formattedOutput, this.phClient), latency, baseURL: this.baseURL, modelParameters: getModelParams(body, result.service_tier), @@ -421,7 +421,7 @@ export class WrappedCompletions extends Completions { ...posthogParams, model: openAIParams.model, provider: 'openai', - input: sanitizeOpenAI(openAIParams.messages), + input: sanitizeOpenAI(openAIParams.messages, this.phClient), output: [], latency: 0, baseURL: this.baseURL, @@ -462,7 +462,10 @@ export class WrappedResponses extends Responses { ...posthogParams, model: openAIParams.model ?? result.model, provider: 'openai', - input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions), + input: formatOpenAIResponsesInput( + sanitizeOpenAIResponse(openAIParams.input, this.phClient), + openAIParams.instructions + ), output: formatResponseOpenAI({ output: result.output }), latency: getBackgroundResponseLatency(result), baseURL: this.baseURL, @@ -614,10 +617,10 @@ export class WrappedResponses extends Responses { model: openAIParams.model ?? modelFromResponse, provider: 'openai', input: formatOpenAIResponsesInput( - sanitizeOpenAIResponse(openAIParams.input), + sanitizeOpenAIResponse(openAIParams.input, this.phClient), openAIParams.instructions ), - output: sanitizeOpenAIResponse(finalContent), + output: sanitizeOpenAIResponse(finalContent, this.phClient), latency, timeToFirstToken, baseURL: this.baseURL, @@ -654,7 +657,7 @@ export class WrappedResponses extends Responses { model: openAIParams.model, provider: 'openai', input: formatOpenAIResponsesInput( - sanitizeOpenAIResponse(openAIParams.input), + sanitizeOpenAIResponse(openAIParams.input, this.phClient), openAIParams.instructions ), output: [], @@ -696,8 +699,11 @@ export class WrappedResponses extends Responses { ...posthogParams, model: openAIParams.model ?? result.model, provider: 'openai', - input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions), - output: sanitizeOpenAIResponse(formattedOutput), + input: formatOpenAIResponsesInput( + sanitizeOpenAIResponse(openAIParams.input, this.phClient), + openAIParams.instructions + ), + output: sanitizeOpenAIResponse(formattedOutput, this.phClient), latency, baseURL: this.baseURL, modelParameters: getModelParams(body, result.service_tier), @@ -733,7 +739,10 @@ export class WrappedResponses extends Responses { ...posthogParams, model: openAIParams.model, provider: 'openai', - input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions), + input: formatOpenAIResponsesInput( + sanitizeOpenAIResponse(openAIParams.input, this.phClient), + openAIParams.instructions + ), output: [], latency: 0, baseURL: this.baseURL, @@ -855,8 +864,11 @@ export class WrappedResponses extends Responses { ...posthogParams, model: openAIParams.model ?? result.model, provider: 'openai', - input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions), - output: sanitizeOpenAIResponse(result.output), + input: formatOpenAIResponsesInput( + sanitizeOpenAIResponse(openAIParams.input, this.phClient), + openAIParams.instructions + ), + output: sanitizeOpenAIResponse(result.output, this.phClient), latency, baseURL: this.baseURL, modelParameters: getModelParams(body, result.service_tier), @@ -884,7 +896,10 @@ export class WrappedResponses extends Responses { ...posthogParams, model: openAIParams.model, provider: 'openai', - input: formatOpenAIResponsesInput(sanitizeOpenAIResponse(openAIParams.input), openAIParams.instructions), + input: formatOpenAIResponsesInput( + sanitizeOpenAIResponse(openAIParams.input, this.phClient), + openAIParams.instructions + ), output: [], latency: 0, baseURL: this.baseURL, @@ -1110,7 +1125,7 @@ export class WrappedTranscriptions extends Transcriptions { model: openAIParams.model, provider: 'openai', input: openAIParams.prompt, - output: sanitizeOpenAIResponse(finalContent), + output: sanitizeOpenAIResponse(finalContent, this.phClient), latency, timeToFirstToken, baseURL: this.baseURL, @@ -1158,7 +1173,7 @@ export class WrappedTranscriptions extends Transcriptions { model: openAIParams.model, provider: 'openai', input: openAIParams.prompt, - output: sanitizeOpenAIResponse(result.text), + output: sanitizeOpenAIResponse(result.text, this.phClient), latency, baseURL: this.baseURL, modelParameters: getModelParams(body), diff --git a/packages/ai/src/otel/redact.ts b/packages/ai/src/otel/redact.ts index 98480e342d..748be0fb80 100644 --- a/packages/ai/src/otel/redact.ts +++ b/packages/ai/src/otel/redact.ts @@ -3,6 +3,7 @@ import type { ReadableSpan, TimedEvent } from '@opentelemetry/sdk-trace-base' import { BinaryContentRedactor } from '../sanitization/binary_content_redactor' +// Deliberately always redacts here — the OTLP export path has no per-client passthrough gate. const redactor = new BinaryContentRedactor() export function redactSpan(span: ReadableSpan): ReadableSpan { diff --git a/packages/ai/src/sanitization.ts b/packages/ai/src/sanitization.ts index faa437a200..edaaad4bf3 100644 --- a/packages/ai/src/sanitization.ts +++ b/packages/ai/src/sanitization.ts @@ -1,3 +1,4 @@ +import { isFullAiCaptureEnabled, type FullAiCaptureGate } from './captureAiEvent' import { BinaryContentRedactor } from './sanitization/binary_content_redactor' const redactor = new BinaryContentRedactor() @@ -8,9 +9,12 @@ export function redactBase64DataUrl(str: unknown, mediaType?: string): unknown { return redactor.redact(str, mediaType) } -export const sanitizeOpenAI = (data: unknown): unknown => redactor.redact(data) -export const sanitizeOpenAIResponse = (data: unknown): unknown => redactor.redact(data) -export const sanitizeAnthropic = (data: unknown): unknown => redactor.redact(data) -export const sanitizeGemini = (data: unknown): unknown => redactor.redact(data) -export const sanitizeLangChain = (data: unknown): unknown => redactor.redact(data) -export const sanitizeVercel = (data: unknown): unknown => redactor.redact(data) +const sanitize = (data: unknown, client?: FullAiCaptureGate): unknown => + isFullAiCaptureEnabled(client) ? data : redactor.redact(data) + +export const sanitizeOpenAI = (data: unknown, client?: FullAiCaptureGate): unknown => sanitize(data, client) +export const sanitizeOpenAIResponse = (data: unknown, client?: FullAiCaptureGate): unknown => sanitize(data, client) +export const sanitizeAnthropic = (data: unknown, client?: FullAiCaptureGate): unknown => sanitize(data, client) +export const sanitizeGemini = (data: unknown, client?: FullAiCaptureGate): unknown => sanitize(data, client) +export const sanitizeLangChain = (data: unknown, client?: FullAiCaptureGate): unknown => sanitize(data, client) +export const sanitizeVercel = (data: unknown, client?: FullAiCaptureGate): unknown => sanitize(data, client) diff --git a/packages/ai/src/sanitization/binary_content_redactor.ts b/packages/ai/src/sanitization/binary_content_redactor.ts index 10534928eb..98a6e94f92 100644 --- a/packages/ai/src/sanitization/binary_content_redactor.ts +++ b/packages/ai/src/sanitization/binary_content_redactor.ts @@ -11,7 +11,6 @@ export class BinaryContentRedactor { redact(value: T, mediaType?: string): T redact(value: unknown, mediaType?: string): unknown { - if (this.isMultimodalEnabled()) return value this.visited = new WeakSet() return this.walk(value, mediaType ? new MediaTypeContext(undefined, undefined, mediaType) : MediaTypeContext.EMPTY) } @@ -65,9 +64,4 @@ export class BinaryContentRedactor { if (mediaType === 'application/octet-stream') return '[base64 file redacted]' return `[base64 ${mediaType} redacted]` } - - private isMultimodalEnabled(): boolean { - const val = process.env._INTERNAL_LLMA_MULTIMODAL || '' - return val.toLowerCase() === 'true' || val === '1' || val.toLowerCase() === 'yes' - } } diff --git a/packages/ai/src/utils.ts b/packages/ai/src/utils.ts index cfbe4a6b3a..7279ef2ff8 100644 --- a/packages/ai/src/utils.ts +++ b/packages/ai/src/utils.ts @@ -12,6 +12,7 @@ import type { import { v4 as uuidv4 } from 'uuid' import { isString } from './typeGuards' import { redactBase64DataUrl } from './sanitization' +import { isFullAiCaptureEnabled, type FullAiCaptureGate } from './captureAiEvent' type ChatCompletionCreateParamsBase = OpenAIOrignal.Chat.Completions.ChatCompletionCreateParams type MessageCreateParams = AnthropicOriginal.Messages.MessageCreateParams @@ -149,7 +150,7 @@ export const getModelParams = ( /** * Helper to format responses (non-streaming) for consumption */ -export const formatResponse = (response: any, provider: string): FormattedMessage[] => { +export const formatResponse = (response: any, provider: string, client?: FullAiCaptureGate): FormattedMessage[] => { if (!response) { return [] } @@ -158,7 +159,7 @@ export const formatResponse = (response: any, provider: string): FormattedMessag } else if (provider === 'openai') { return formatResponseOpenAI(response) } else if (provider === 'gemini') { - return formatResponseGemini(response) + return formatResponseGemini(response, client) } return [] } @@ -301,7 +302,7 @@ export const buildInlineDataBlock = ( return { type: 'document', inline_data: { mime_type: mimeType, data } } } -export const formatResponseGemini = (response: any): FormattedMessage[] => { +export const formatResponseGemini = (response: any, client?: FullAiCaptureGate): FormattedMessage[] => { const output: FormattedMessage[] = [] if (response.candidates && Array.isArray(response.candidates)) { @@ -339,7 +340,7 @@ export const formatResponseGemini = (response: any): FormattedMessage[] => { } // Sanitize base64 data for images and other large inline data - data = redactBase64DataUrl(data, mimeType) + data = isFullAiCaptureEnabled(client) ? data : redactBase64DataUrl(data, mimeType) content.push(buildInlineDataBlock(mimeType, data)) } @@ -399,12 +400,16 @@ function toSafeString(input: unknown): string { } } -export const truncate = (input: unknown): string => { +export const truncate = (input: unknown, client?: FullAiCaptureGate): string => { const str = toSafeString(input) if (str === '') { return '' } + if (isFullAiCaptureEnabled(client)) { + return str + } + // Check if we need to truncate and ensure STRING_FORMAT is respected const buffer = sharedTextEncoder.encode(str) if (buffer.length <= MAX_OUTPUT_SIZE) { diff --git a/packages/ai/src/vercel/middleware.ts b/packages/ai/src/vercel/middleware.ts index 51f6815daf..d403b7e0e7 100644 --- a/packages/ai/src/vercel/middleware.ts +++ b/packages/ai/src/vercel/middleware.ts @@ -25,6 +25,7 @@ import { import { captureAiGeneration } from '../captureAiGeneration' import { redactBase64DataUrl, sanitizeVercel } from '../sanitization' import { isObject, isString } from '../typeGuards' +import { isFullAiCaptureEnabled, type FullAiCaptureGate } from '../captureAiEvent' // Union types for dual version support type LanguageModel = LanguageModelV2 | LanguageModelV3 @@ -79,12 +80,14 @@ type OutputContentItem = | { type: 'file'; name: string; mediaType: string; data: string } | { type: 'source'; sourceType: string; id: string; url: string; title: string } -const redactFileData = (data: unknown, mediaType?: string): string | undefined => { +const redactFileData = (data: unknown, mediaType?: string, client?: FullAiCaptureGate): string | undefined => { if (data instanceof URL) { - return redactBase64DataUrl(data.toString(), data.protocol === 'data:' ? mediaType : undefined) + return isFullAiCaptureEnabled(client) + ? data.toString() + : redactBase64DataUrl(data.toString(), data.protocol === 'data:' ? mediaType : undefined) } if (isString(data)) { - return redactBase64DataUrl(data, mediaType) + return isFullAiCaptureEnabled(client) ? data : redactBase64DataUrl(data, mediaType) } return undefined } @@ -101,7 +104,7 @@ const mapVercelParams = (params: any): Record => { } } -const mapVercelPrompt = (messages: LanguageModelPrompt): PostHogInput[] => { +const mapVercelPrompt = (messages: LanguageModelPrompt, client?: FullAiCaptureGate): PostHogInput[] => { // Map and truncate individual content const inputs: PostHogInput[] = messages.map((message) => { let content: any @@ -111,7 +114,7 @@ const mapVercelPrompt = (messages: LanguageModelPrompt): PostHogInput[] => { content = [ { type: 'text', - text: truncate(toContentString(message.content)), + text: truncate(toContentString(message.content), client), }, ] } else { @@ -121,11 +124,11 @@ const mapVercelPrompt = (messages: LanguageModelPrompt): PostHogInput[] => { if (c.type === 'text') { return { type: 'text', - text: truncate(c.text), + text: truncate(c.text, client), } } else if (c.type === 'file') { // Redact base64 data URLs and raw base64 to prevent oversized events - const fileData = redactFileData(c.data, c.mediaType) ?? 'raw files not supported' + const fileData = redactFileData(c.data, c.mediaType, client) ?? 'raw files not supported' return { type: 'file', @@ -135,7 +138,7 @@ const mapVercelPrompt = (messages: LanguageModelPrompt): PostHogInput[] => { } else if (c.type === 'reasoning') { return { type: 'reasoning', - text: truncate(c.text), + text: truncate(c.text, client), } } else if (c.type === 'tool-call') { return { @@ -149,7 +152,7 @@ const mapVercelPrompt = (messages: LanguageModelPrompt): PostHogInput[] => { type: 'tool-result', toolCallId: c.toolCallId, toolName: c.toolName, - output: sanitizeVercel(c.output), + output: sanitizeVercel(c.output, client), isError: c.isError, } } @@ -163,7 +166,7 @@ const mapVercelPrompt = (messages: LanguageModelPrompt): PostHogInput[] => { content = [ { type: 'text', - text: truncate(toContentString(message.content)), + text: truncate(toContentString(message.content), client), }, ] } @@ -175,6 +178,12 @@ const mapVercelPrompt = (messages: LanguageModelPrompt): PostHogInput[] => { } }) + // Full AI capture means no truncation of any kind; the aggregate trim below exists + // only to keep the default-mode payload under MAX_OUTPUT_SIZE. + if (isFullAiCaptureEnabled(client)) { + return inputs + } + try { // Trim the inputs array until its serialized JSON size fits within MAX_OUTPUT_SIZE. // Pre-compute each message's byte size once so we can shift by accumulated budget @@ -209,10 +218,10 @@ const mapVercelPrompt = (messages: LanguageModelPrompt): PostHogInput[] => { return inputs } -const mapVercelOutput = (result: LanguageModelContent[]): PostHogInput[] => { +const mapVercelOutput = (result: LanguageModelContent[], client?: FullAiCaptureGate): PostHogInput[] => { const content: OutputContentItem[] = result.map((item) => { if (item.type === 'text') { - return { type: 'text', text: truncate(item.text) } + return { type: 'text', text: truncate(item.text, client) } } if (item.type === 'tool-call') { const toolCall = item as { input?: unknown; args?: unknown; arguments?: unknown } @@ -227,14 +236,19 @@ const mapVercelOutput = (result: LanguageModelContent[]): PostHogInput[] => { } } if (item.type === 'reasoning') { - return { type: 'reasoning', text: truncate(item.text) } + return { type: 'reasoning', text: truncate(item.text, client) } } if (item.type === 'file') { // Handle files similar to input mapping - avoid large base64 data - let fileData = redactFileData(item.data, item.mediaType) ?? `[binary ${item.mediaType} file]` - - // If not redacted and still large, replace with size indicator - if (typeof item.data === 'string' && fileData === item.data && item.data.length > 1000) { + let fileData = redactFileData(item.data, item.mediaType, client) ?? `[binary ${item.mediaType} file]` + + // Skipped under full AI capture: media stays untouched, so no placeholder swap either. + if ( + !isFullAiCaptureEnabled(client) && + typeof item.data === 'string' && + fileData === item.data && + item.data.length > 1000 + ) { fileData = `[${item.mediaType} file - ${item.data.length} bytes]` } @@ -255,7 +269,7 @@ const mapVercelOutput = (result: LanguageModelContent[]): PostHogInput[] => { } } // Fallback for unknown types - try to extract text if possible - return { type: 'text', text: truncate(JSON.stringify(item)) } + return { type: 'text', text: truncate(JSON.stringify(item), client) } }) if (content.length > 0) { @@ -269,7 +283,7 @@ const mapVercelOutput = (result: LanguageModelContent[]): PostHogInput[] => { // otherwise stringify and truncate try { const jsonOutput = JSON.stringify(result) - return [{ content: truncate(jsonOutput), role: 'assistant' }] + return [{ content: truncate(jsonOutput, client), role: 'assistant' }] } catch { console.error('Error stringifying output') return [] @@ -511,7 +525,7 @@ export const wrapVercelLanguageModel = ( mergedOptions.posthogModelOverride ?? (result.response?.modelId ? result.response.modelId : model.modelId) const provider = mergedOptions.posthogProviderOverride ?? extractProvider(model) // result.content is undefined when the model returns only tool calls with no text output - const content = mapVercelOutput((result.content ?? []) as LanguageModelContent[]) + const content = mapVercelOutput((result.content ?? []) as LanguageModelContent[], phClient) const latency = (Date.now() - startTime) / 1000 const providerMetadata = result.providerMetadata const additionalTokenValues = extractAdditionalTokenValues(providerMetadata, result.usage) @@ -563,7 +577,9 @@ export const wrapVercelLanguageModel = ( ...baseOptions, model: modelId, provider: provider, - input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt as LanguageModelPrompt), + input: mergedOptions.posthogPrivacyMode + ? '' + : mapVercelPrompt(params.prompt as LanguageModelPrompt, phClient), output: content, latency, baseURL, @@ -581,7 +597,9 @@ export const wrapVercelLanguageModel = ( ...baseOptions, model: modelId, provider: model.provider, - input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt as LanguageModelPrompt), + input: mergedOptions.posthogPrivacyMode + ? '' + : mapVercelPrompt(params.prompt as LanguageModelPrompt, phClient), output: [], latency: 0, baseURL, @@ -733,10 +751,10 @@ export const wrapVercelLanguageModel = ( const timeToFirstToken = firstTokenTime !== undefined ? (firstTokenTime - startTime) / 1000 : undefined const content: OutputContentItem[] = [] if (reasoningText) { - content.push({ type: 'reasoning', text: truncate(reasoningText) }) + content.push({ type: 'reasoning', text: truncate(reasoningText, phClient) }) } if (generatedText) { - content.push({ type: 'text', text: truncate(generatedText) }) + content.push({ type: 'text', text: truncate(generatedText, phClient) }) } for (const toolCall of toolCallsInProgress.values()) { @@ -782,7 +800,9 @@ export const wrapVercelLanguageModel = ( ...baseOptions, model: modelId, provider: provider, - input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt as LanguageModelPrompt), + input: mergedOptions.posthogPrivacyMode + ? '' + : mapVercelPrompt(params.prompt as LanguageModelPrompt, phClient), output, latency, timeToFirstToken, @@ -844,7 +864,9 @@ export const wrapVercelLanguageModel = ( ...baseOptions, model: modelId, provider: provider, - input: mergedOptions.posthogPrivacyMode ? '' : mapVercelPrompt(params.prompt as LanguageModelPrompt), + input: mergedOptions.posthogPrivacyMode + ? '' + : mapVercelPrompt(params.prompt as LanguageModelPrompt, phClient), output: [], latency: 0, baseURL, diff --git a/packages/ai/tests/anthropic.test.ts b/packages/ai/tests/anthropic.test.ts index db7413e1ac..5b113b6de4 100644 --- a/packages/ai/tests/anthropic.test.ts +++ b/packages/ai/tests/anthropic.test.ts @@ -394,6 +394,47 @@ describe('PostHogAnthropic', () => { expect(properties['$ai_tokens_source']).toBe('sdk') }) + conditionalTest('preserves images when the client enables multimodal capture', async () => { + Object.assign(mockPostHogClient, { enableFullAiCapture: true }) + const dataUrl = 'a'.repeat(80) + + await client.messages.create({ + model: 'claude-3-opus-20240229', + messages: [ + { + role: 'user', + content: [{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: dataUrl } }], + } as any, + ], + max_tokens: 100, + posthogDistinctId: 'test-user-123', + }) + + const captureMock = mockPostHogClient.capture as jest.Mock + const [captureArgs] = captureMock.mock.calls + expect(JSON.stringify(captureArgs[0].properties['$ai_input'])).toContain(dataUrl) + }) + + conditionalTest('redacts images when the client does not enable multimodal capture', async () => { + const dataUrl = 'a'.repeat(80) + + await client.messages.create({ + model: 'claude-3-opus-20240229', + messages: [ + { + role: 'user', + content: [{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: dataUrl } }], + } as any, + ], + max_tokens: 100, + posthogDistinctId: 'test-user-123', + }) + + const captureMock = mockPostHogClient.capture as jest.Mock + const [captureArgs] = captureMock.mock.calls + expect(JSON.stringify(captureArgs[0].properties['$ai_input'])).toContain('redacted') + }) + conditionalTest('should set tokens_source to passthrough when token properties are overridden', async () => { const response = await client.messages.create({ model: 'claude-3-opus-20240229', diff --git a/packages/ai/tests/azure-openai.test.ts b/packages/ai/tests/azure-openai.test.ts index 59cc77c88f..8978e47f7d 100644 --- a/packages/ai/tests/azure-openai.test.ts +++ b/packages/ai/tests/azure-openai.test.ts @@ -1112,6 +1112,57 @@ describe('PostHogAzureOpenAI - cache token reporting convention', () => { }) }) +describe('PostHogAzureOpenAI - full AI capture', () => { + // Deliberately not `conditionalTest`: see the cache-token-reporting suite above for why — + // a credential-gated test would never run in CI and so would never catch the non-streaming + // chat path regressing back to redacting binary content under full capture. + test('preserves binary audio content in chat completions input/output when enabled', async () => { + const binary = 'A'.repeat(80) + const mockPostHogClient = new (PostHog as any)() + ;(mockPostHogClient as any).enableFullAiCapture = true + const client = new PostHogAzureOpenAI({ + apiKey: 'mock-azure-key', + posthog: mockPostHogClient as any, + }) + + const chatResponse = { + id: 'chatcmpl-full-capture', + model: 'gpt-4o-audio-preview', + choices: [ + { + index: 0, + finish_reason: 'stop', + message: { + role: 'assistant', + content: null, + audio: { id: 'audio-1', data: binary, transcript: 'hello', expires_at: 0 }, + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } + const ChatMock: any = openaiModule.Chat + ;(ChatMock.Completions as any).prototype.create = jest.fn().mockResolvedValue(chatResponse) + + await client.chat.completions.create({ + model: 'gpt-4o-audio-preview', + messages: [ + { + role: 'user' as const, + content: [{ type: 'input_audio' as const, input_audio: { data: binary, format: 'wav' as const } }], + }, + ], + posthogDistinctId: 'test-id', + }) + + expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) + const { properties } = (mockPostHogClient.capture as jest.Mock).mock.calls[0][0] + expect(JSON.stringify(properties['$ai_input'])).toContain(binary) + expect(JSON.stringify(properties['$ai_output_choices'])).toContain(binary) + expect(JSON.stringify(properties)).not.toContain('redacted') + }) +}) + describe('PostHogAzureOpenAI - Responses terminal statuses', () => { let mockPostHogClient: PostHog let client: PostHogAzureOpenAI diff --git a/packages/ai/tests/binary_content_redactor.test.ts b/packages/ai/tests/binary_content_redactor.test.ts index 46722a3525..08896282e3 100644 --- a/packages/ai/tests/binary_content_redactor.test.ts +++ b/packages/ai/tests/binary_content_redactor.test.ts @@ -275,19 +275,6 @@ describe('redactBinaryContent', () => { }) }) - describe('multimodal escape hatch', () => { - afterEach(() => { - delete process.env._INTERNAL_LLMA_MULTIMODAL - }) - - it('returns input unchanged when _INTERNAL_LLMA_MULTIMODAL is set', () => { - process.env._INTERNAL_LLMA_MULTIMODAL = 'true' - const input = { type: 'image', data: PURE_B64 } - const out = redactBinaryContent(input) - expect(out).toBe(input) - }) - }) - describe('primitives and edge values', () => { it.each([null, undefined, 0, 1, true, false])('passes %p through unchanged', (v) => { expect(redactBinaryContent(v)).toBe(v) diff --git a/packages/ai/tests/callbacks.test.ts b/packages/ai/tests/callbacks.test.ts index 9502122d85..a2c667ed8c 100644 --- a/packages/ai/tests/callbacks.test.ts +++ b/packages/ai/tests/callbacks.test.ts @@ -295,6 +295,110 @@ describe('LangChainCallbackHandler', () => { } ) + it('routes generation events through captureAi when the client opted into the AI lane', async () => { + const aiLaneClient = { + capture: jest.fn(), + enableFullAiCapture: true, + captureAi: jest.fn(), + } as unknown as PostHog + const aiLaneHandler = new LangChainCallbackHandler({ + client: aiLaneClient, + }) + + const serialized = { + lc: 1, + type: 'constructor' as const, + id: ['langchain', 'llms', 'openai', 'OpenAI'], + kwargs: { openai_api_base: 'https://api.openai.com/v1' }, + } + + const prompts = ['Test prompt for library version'] + const runId = 'run_lib_test' + const parentRunId = 'parent_lib' + const metadata = { ls_model_name: 'gpt-4', ls_provider: 'openai' } + const extraParams = { + invocation_params: { + temperature: 0.7, + max_tokens: 100, + }, + } + + aiLaneHandler.handleLLMStart(serialized, prompts, runId, parentRunId, extraParams, undefined, metadata) + + const llmResult = { + generations: [ + [ + { + text: 'Test response', + message: new AIMessage('Test response'), + }, + ], + ], + llmOutput: { + tokenUsage: { + promptTokens: 10, + completionTokens: 3, + totalTokens: 13, + }, + }, + } + + aiLaneHandler.handleLLMEnd(llmResult, runId) + + expect((aiLaneClient as any).captureAi).toHaveBeenCalledTimes(1) + expect(aiLaneClient.capture).not.toHaveBeenCalled() + const [captureCall] = (aiLaneClient as any).captureAi.mock.calls + + expect(captureCall[0].properties['$ai_lib']).toBe('posthog-ai') + expect(captureCall[0].properties['$ai_lib_version']).toBe(version) + expect(captureCall[0].properties['$ai_framework']).toBe('langchain') + expect(captureCall[0].event).toBe('$ai_generation') + expect(captureCall[0].properties.$ai_model).toBe('gpt-4') + expect(captureCall[0].properties.$ai_provider).toBe('openai') + }) + + it('strips wrapper-sanitized state when privacy mode wins over the full-capture flag', () => { + const privacyModeClient = { + capture: jest.fn(), + captureAi: jest.fn(), + enableFullAiCapture: true, + privacy_mode: true, + } as unknown as PostHog + const handler = new LangChainCallbackHandler({ client: privacyModeClient }) + + const dataUrl = 'data:image/jpeg;base64,' + 'A'.repeat(2000) + const serialized = { + lc: 1, + type: 'constructor' as const, + id: ['langchain', 'schema', 'runnable', 'RunnableSequence'], + kwargs: {}, + } + const runId = 'run_chain_privacy' + + handler.handleChainStart( + serialized, + { + messages: [ + { + content: [ + { type: 'text', text: 'describe this image' }, + { type: 'image_url', image_url: { url: dataUrl } }, + ], + }, + ], + } as any, + runId + ) + handler.handleChainEnd({ echoed: dataUrl }, runId) + + expect((privacyModeClient as any).captureAi).toHaveBeenCalledTimes(1) + expect(privacyModeClient.capture).not.toHaveBeenCalled() + const [captureCall] = (privacyModeClient as any).captureAi.mock.calls + + expect(captureCall[0].properties['$ai_input_state']).toBeNull() + expect(captureCall[0].properties['$ai_output_state']).toBeNull() + }) + it('should convert AIMessage with tool calls to dict format', () => { const toolCalls = [ { @@ -1742,6 +1846,53 @@ describe('LangChainCallbackHandler trace/span state sanitization', () => { expect(outputState).toContain('[base64 image/jpeg redacted]') expect(outputState).not.toContain('AAAAAAAA') }) + + it('preserves base64 data URLs in a $ai_span input/output state when full AI capture is enabled', () => { + const fullCaptureClient = { + capture: jest.fn(), + enableFullAiCapture: true, + } as unknown as PostHog + const handler = new LangChainCallbackHandler({ client: fullCaptureClient }) + + const dataUrl = 'data:image/jpeg;base64,' + 'A'.repeat(2000) + const serialized = { + lc: 1, + type: 'constructor' as const, + id: ['langchain', 'schema', 'runnable', 'RunnableSequence'], + kwargs: {}, + } + const runId = 'run_span_full_capture' + const parentRunId = 'parent_run_full_capture' + + handler.handleChainStart( + serialized, + { + messages: [ + { + content: [ + { type: 'text', text: 'describe this image' }, + { type: 'image_url', image_url: { url: dataUrl } }, + ], + }, + ], + } as any, + runId, + parentRunId + ) + handler.handleChainEnd({ echoed: dataUrl, parsed: { title: 'ok' } }, runId, parentRunId) + + expect(fullCaptureClient.capture).toHaveBeenCalledTimes(1) + const [captureCall] = (fullCaptureClient.capture as jest.Mock).mock.calls + expect(captureCall[0].event).toBe('$ai_span') + + const inputState = JSON.stringify(captureCall[0].properties['$ai_input_state']) + expect(inputState).toContain(dataUrl) + expect(inputState).not.toContain('redacted') + + const outputState = JSON.stringify(captureCall[0].properties['$ai_output_state']) + expect(outputState).toContain(dataUrl) + expect(outputState).not.toContain('redacted') + }) }) describe('LangChainCallbackHandler LangGraph interrupts', () => { diff --git a/packages/ai/tests/captureAiEvent.test.ts b/packages/ai/tests/captureAiEvent.test.ts new file mode 100644 index 0000000000..93b4e1a6fd --- /dev/null +++ b/packages/ai/tests/captureAiEvent.test.ts @@ -0,0 +1,60 @@ +import { captureAiEvent, captureAiEventImmediate, isFullAiCaptureEnabled } from '../src/captureAiEvent' + +const makeClient = (overrides: Record = {}): any => ({ + capture: jest.fn(), + captureImmediate: jest.fn().mockResolvedValue(undefined), + captureAi: jest.fn(), + captureAiImmediate: jest.fn().mockResolvedValue(undefined), + ...overrides, +}) + +describe('captureAiEvent routing', () => { + const event = { distinctId: 'u', event: '$ai_generation', properties: {} } + + it('uses capture() by default (no opt-in)', () => { + const client = makeClient() + captureAiEvent(client, event) + expect(client.capture).toHaveBeenCalledWith(event) + expect(client.captureAi).not.toHaveBeenCalled() + }) + + it('routes to captureAi when enableFullAiCapture is true', () => { + const client = makeClient({ enableFullAiCapture: true }) + captureAiEvent(client, event) + expect(client.captureAi).toHaveBeenCalledWith(event) + expect(client.capture).not.toHaveBeenCalled() + }) + + it('requires the flag to be strictly true (truthy mock attributes stay opted out)', () => { + const client = makeClient({ enableFullAiCapture: jest.fn() }) + captureAiEvent(client, event) + expect(client.capture).toHaveBeenCalledWith(event) + expect(client.captureAi).not.toHaveBeenCalled() + }) + + it('falls back to capture() when an opted-in client lacks a callable captureAi', () => { + const client = makeClient({ enableFullAiCapture: true, captureAi: undefined }) + captureAiEvent(client, event) + expect(client.capture).toHaveBeenCalledWith(event) + }) + + it('immediate variant mirrors the routing', async () => { + const optedIn = makeClient({ enableFullAiCapture: true }) + await captureAiEventImmediate(optedIn, event) + expect(optedIn.captureAiImmediate).toHaveBeenCalledWith(event) + expect(optedIn.captureImmediate).not.toHaveBeenCalled() + + const fallback = makeClient({ enableFullAiCapture: true, captureAiImmediate: undefined }) + await captureAiEventImmediate(fallback, event) + expect(fallback.captureImmediate).toHaveBeenCalledWith(event) + }) +}) + +describe('isFullAiCaptureEnabled', () => { + it('is true only for a strict true flag', () => { + expect(isFullAiCaptureEnabled({ enableFullAiCapture: true })).toBe(true) + expect(isFullAiCaptureEnabled({ enableFullAiCapture: 1 } as any)).toBe(false) + expect(isFullAiCaptureEnabled({})).toBe(false) + expect(isFullAiCaptureEnabled(undefined)).toBe(false) + }) +}) diff --git a/packages/ai/tests/captureAiGeneration.test.ts b/packages/ai/tests/captureAiGeneration.test.ts index 31c8094a64..7447c418bf 100644 --- a/packages/ai/tests/captureAiGeneration.test.ts +++ b/packages/ai/tests/captureAiGeneration.test.ts @@ -454,3 +454,37 @@ describe('captureAiGeneration', () => { expect(lastCaptureProperties(client).$ai_tokens_source).toBe('passthrough') }) }) + +describe('AI lane routing', () => { + const baseOptions = { provider: 'openai', model: 'gpt-4', input: 'hi', output: 'hello' } + + const makeClient = (overrides: Record = {}): any => ({ + capture: jest.fn(), + captureImmediate: jest.fn().mockResolvedValue(undefined), + captureAi: jest.fn(), + captureAiImmediate: jest.fn().mockResolvedValue(undefined), + ...overrides, + }) + + it('routes through captureAi when the client opted into the lane', async () => { + const client = makeClient({ enableFullAiCapture: true }) + await captureAiGeneration(client, { ...baseOptions, distinctId: 'u' }) + expect(client.captureAi).toHaveBeenCalledTimes(1) + expect(client.capture).not.toHaveBeenCalled() + expect(client.captureAi.mock.calls[0][0].event).toBe('$ai_generation') + }) + + it('uses capture() for clients that did not opt in', async () => { + const client = makeClient() + await captureAiGeneration(client, { ...baseOptions, distinctId: 'u' }) + expect(client.capture).toHaveBeenCalledTimes(1) + expect(client.captureAi).not.toHaveBeenCalled() + }) + + it('routes captureImmediate mode through captureAiImmediate when opted in', async () => { + const client = makeClient({ enableFullAiCapture: true }) + await captureAiGeneration(client, { ...baseOptions, distinctId: 'u', captureImmediate: true }) + expect(client.captureAiImmediate).toHaveBeenCalledTimes(1) + expect(client.captureImmediate).not.toHaveBeenCalled() + }) +}) diff --git a/packages/ai/tests/gemini.test.ts b/packages/ai/tests/gemini.test.ts index f5484e46c1..b8b997df99 100644 --- a/packages/ai/tests/gemini.test.ts +++ b/packages/ai/tests/gemini.test.ts @@ -316,6 +316,64 @@ describe('PostHogGemini - Jest test suite', () => { expect(mockPostHogClient.captureImmediate).toHaveBeenCalledTimes(1) }) + test('preserves output inline data when the client enables multimodal capture', async () => { + const base64Data = 'A'.repeat(2000) + ;(client as any).client.models.generateContent = jest.fn().mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ inlineData: { mimeType: 'image/png', data: base64Data } }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { promptTokenCount: 15, candidatesTokenCount: 8, totalTokenCount: 23 }, + }) + ;(mockPostHogClient as PostHog & { enableFullAiCapture?: boolean }).enableFullAiCapture = true + + await client.models.generateContent({ + model: 'gemini-2.0-flash-001', + contents: 'Describe this image', + posthogDistinctId: 'test-id', + }) + + expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) + const [captureArgs] = (mockPostHogClient.capture as jest.Mock).mock.calls + const { properties } = captureArgs[0] + + const imageBlock = properties['$ai_output_choices'][0].content[0] + expect(imageBlock.inline_data.data).toBe(base64Data) + }) + + test('redacts output inline data when the client does not enable multimodal capture', async () => { + const base64Data = 'A'.repeat(2000) + ;(client as any).client.models.generateContent = jest.fn().mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ inlineData: { mimeType: 'image/png', data: base64Data } }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { promptTokenCount: 15, candidatesTokenCount: 8, totalTokenCount: 23 }, + }) + + await client.models.generateContent({ + model: 'gemini-2.0-flash-001', + contents: 'Describe this image', + posthogDistinctId: 'test-id', + }) + + expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) + const [captureArgs] = (mockPostHogClient.capture as jest.Mock).mock.calls + const { properties } = captureArgs[0] + + const imageBlock = properties['$ai_output_choices'][0].content[0] + expect(imageBlock.inline_data.data).not.toBe(base64Data) + expect(imageBlock.inline_data.data).toContain('redacted') + }) + test('error handling', async () => { const error = new Error('API Error') ;(error as any).status = 400 diff --git a/packages/ai/tests/openai-agents.test.ts b/packages/ai/tests/openai-agents.test.ts index 14967eec91..7a0a4ca156 100644 --- a/packages/ai/tests/openai-agents.test.ts +++ b/packages/ai/tests/openai-agents.test.ts @@ -118,6 +118,35 @@ describe('PostHogTracingProcessor', () => { expect(call.properties.$ai_latency).toBeDefined() }) + it('routes trace events through captureAi when the client opted into the AI lane', async () => { + const aiLaneClient = { + ...createMockClient(), + enableFullAiCapture: true, + captureAi: jest.fn(), + } + const aiLaneProcessor = new PostHogTracingProcessor({ + client: aiLaneClient, + distinctId: 'test-user', + privacyMode: false, + }) + + const trace = createMockTrace() + await aiLaneProcessor.onTraceStart(trace as any) + await aiLaneProcessor.onTraceEnd(trace as any) + + expect(aiLaneClient.captureAi).toHaveBeenCalledTimes(1) + expect(aiLaneClient.capture).not.toHaveBeenCalled() + const call = aiLaneClient.captureAi.mock.calls[0][0] + + expect(call.event).toBe('$ai_trace') + expect(call.distinctId).toBe('test-user') + expect(call.properties.$ai_trace_id).toBe('trace_123456789') + expect(call.properties.$ai_trace_name).toBe('Test Workflow') + expect(call.properties.$ai_provider).toBe('openai') + expect(call.properties.$ai_framework).toBe('openai-agents') + expect(call.properties.$ai_latency).toBeDefined() + }) + it('includes group_id in trace and span events as both session and group id', async () => { const trace = createMockTrace({ groupId: 'group_abc' }) const span = createMockSpan({ spanData: { type: 'generation', model: 'gpt-4o' } }) @@ -487,6 +516,34 @@ describe('PostHogTracingProcessor', () => { expect(typeof call.properties.$ai_output_choices).toBe('string') expect(call.properties.$ai_output_choices).toContain('[truncated]') }) + + it('keeps oversized structured payloads intact when multimodal passthrough is enabled', async () => { + const largeContent = 'x'.repeat(220000) + const multimodalClient = { ...createMockClient(), enableFullAiCapture: true } + const multimodalProcessor = new PostHogTracingProcessor({ + client: multimodalClient, + distinctId: 'test-user', + privacyMode: false, + }) + const span = createMockSpan({ + spanData: { + type: 'generation', + input: [{ role: 'user', content: largeContent }], + output: [{ role: 'assistant', content: largeContent }], + model: 'gpt-4o', + }, + }) + + await multimodalProcessor.onSpanStart(span as any) + await multimodalProcessor.onSpanEnd(span as any) + + const call = multimodalClient.capture.mock.calls[0][0] + + expect(Array.isArray(call.properties.$ai_input)).toBe(true) + expect(call.properties.$ai_input).toEqual([{ role: 'user', content: largeContent }]) + expect(Array.isArray(call.properties.$ai_output_choices)).toBe(true) + expect(call.properties.$ai_output_choices).toEqual([{ role: 'assistant', content: largeContent }]) + }) }) describe('input role normalization', () => { diff --git a/packages/ai/tests/openai.test.ts b/packages/ai/tests/openai.test.ts index b34636a063..de4563555e 100644 --- a/packages/ai/tests/openai.test.ts +++ b/packages/ai/tests/openai.test.ts @@ -565,6 +565,45 @@ describe('PostHogOpenAI - Jest test suite', () => { ) }) + conditionalTest('preserves images when the client enables multimodal capture', async () => { + Object.assign(mockPostHogClient, { enableFullAiCapture: true }) + const dataUrl = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ...' + + await client.chat.completions.create({ + model: 'gpt-4', + messages: [ + { + role: 'user', + content: [{ type: 'image_url', image_url: { url: dataUrl } }], + } as any, + ], + posthogDistinctId: 'test-id', + }) + + expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) + const [captureArgs] = (mockPostHogClient.capture as jest.Mock).mock.calls + expect(JSON.stringify(captureArgs[0].properties['$ai_input'])).toContain(dataUrl) + }) + + conditionalTest('redacts images when the client does not enable multimodal capture', async () => { + const dataUrl = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ...' + + await client.chat.completions.create({ + model: 'gpt-4', + messages: [ + { + role: 'user', + content: [{ type: 'image_url', image_url: { url: dataUrl } }], + } as any, + ], + posthogDistinctId: 'test-id', + }) + + expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) + const [captureArgs] = (mockPostHogClient.capture as jest.Mock).mock.calls + expect(JSON.stringify(captureArgs[0].properties['$ai_input'])).toContain('redacted') + }) + test('chat completions create preserves OpenAI APIPromise helpers', async () => { const promise = client.chat.completions.create({ model: 'gpt-4', @@ -1284,6 +1323,41 @@ describe('PostHogOpenAI - Jest test suite', () => { }) }) + describe('full AI capture', () => { + test('preserves binary image content in a streaming Responses output when enabled', async () => { + Object.assign(mockPostHogClient, { enableFullAiCapture: true }) + const binary = 'A'.repeat(80) + const response = { + ...mockOpenAiParsedResponse, + id: 'resp_full_capture', + status: 'completed', + output: [{ type: 'image_generation_call', id: 'image-1', status: 'completed', result: binary }], + usage: { input_tokens: 11, output_tokens: 7, total_tokens: 18 }, + } + const chunks = [{ type: 'response.completed', sequence_number: 0, response }] + const ResponsesMock: any = openaiModule.Responses + ResponsesMock.prototype.create = jest + .fn() + .mockImplementation(() => createMockAPIPromise(createMockAsyncIterator(chunks))) + + const stream = await client.responses.create({ + model: 'gpt-4', + input: 'Hello', + stream: true, + posthogDistinctId: 'test-id', + }) + for await (const _chunk of stream) { + // consume the returned stream while analytics consumes its monitored copy + } + await flushPromises() + + expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) + const properties = (mockPostHogClient.capture as jest.Mock).mock.calls[0][0].properties + expect(JSON.stringify(properties['$ai_output_choices'])).toContain(binary) + expect(JSON.stringify(properties)).not.toContain('redacted') + }) + }) + describe('Responses cache creation tokens', () => { const usageWithCacheWrite = { input_tokens: 33400, diff --git a/packages/ai/tests/sanitization.test.ts b/packages/ai/tests/sanitization.test.ts index ce7441049e..45af5b21f9 100644 --- a/packages/ai/tests/sanitization.test.ts +++ b/packages/ai/tests/sanitization.test.ts @@ -6,19 +6,20 @@ import { sanitizeGemini, sanitizeLangChain, } from '../src/sanitization' +import type { FullAiCaptureGate } from '../src/captureAiEvent' -const sanitize = (data: unknown, provider: string): unknown => { +const sanitize = (data: unknown, provider: string, client?: FullAiCaptureGate): unknown => { switch (provider) { case 'openai-chat-completions': - return sanitizeOpenAI(data) + return sanitizeOpenAI(data, client) case 'openai-response': - return sanitizeOpenAIResponse(data) + return sanitizeOpenAIResponse(data, client) case 'anthropic': - return sanitizeAnthropic(data) + return sanitizeAnthropic(data, client) case 'gemini': - return sanitizeGemini(data) + return sanitizeGemini(data, client) case 'langchain': - return sanitizeLangChain(data) + return sanitizeLangChain(data, client) } } @@ -612,45 +613,9 @@ describe('Base64 image redaction', () => { }) }) - describe('Multimodal environment variable control', () => { - beforeEach(() => { - delete process.env._INTERNAL_LLMA_MULTIMODAL - }) - - afterEach(() => { - delete process.env._INTERNAL_LLMA_MULTIMODAL - }) - + describe('Per-client multimodal capture gate', () => { describe('Flag value handling', () => { - it('should redact images when _INTERNAL_LLMA_MULTIMODAL is not set', () => { - const base64Image = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ...' - const result = redactBase64DataUrl(base64Image) - expect(result).toBe('[base64 image/jpeg redacted]') - }) - - it('should preserve images when _INTERNAL_LLMA_MULTIMODAL is "true"', () => { - process.env._INTERNAL_LLMA_MULTIMODAL = 'true' - const base64Image = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ...' - const result = redactBase64DataUrl(base64Image) - expect(result).toBe(base64Image) - }) - - it('should preserve images when _INTERNAL_LLMA_MULTIMODAL is "1"', () => { - process.env._INTERNAL_LLMA_MULTIMODAL = '1' - const base64Image = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ...' - const result = redactBase64DataUrl(base64Image) - expect(result).toBe(base64Image) - }) - - it('should preserve images when _INTERNAL_LLMA_MULTIMODAL is "yes"', () => { - process.env._INTERNAL_LLMA_MULTIMODAL = 'yes' - const base64Image = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ...' - const result = redactBase64DataUrl(base64Image) - expect(result).toBe(base64Image) - }) - - it('should redact images when _INTERNAL_LLMA_MULTIMODAL is "false"', () => { - process.env._INTERNAL_LLMA_MULTIMODAL = 'false' + it('redacts images when the client is absent', () => { const base64Image = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ...' const result = redactBase64DataUrl(base64Image) expect(result).toBe('[base64 image/jpeg redacted]') @@ -658,8 +623,8 @@ describe('Base64 image redaction', () => { }) describe('OpenAI provider', () => { - it('should preserve images when flag is enabled', () => { - process.env._INTERNAL_LLMA_MULTIMODAL = 'true' + it('preserves images when the client enables multimodal capture', () => { + const gate = { enableFullAiCapture: true } const input = [ { role: 'user', @@ -672,12 +637,12 @@ describe('Base64 image redaction', () => { }, ] - const result = sanitize(input, 'openai-chat-completions') as any + const result = sanitize(input, 'openai-chat-completions', gate) as any expect(result[0].content[0].image_url.url).toBe('data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ...') }) - it('should preserve videos when flag is enabled', () => { - process.env._INTERNAL_LLMA_MULTIMODAL = 'true' + it('preserves videos when the client enables multimodal capture', () => { + const gate = { enableFullAiCapture: true } const input = [ { role: 'user', @@ -690,12 +655,11 @@ describe('Base64 image redaction', () => { }, ] - const result = sanitize(input, 'openai-chat-completions') as any + const result = sanitize(input, 'openai-chat-completions', gate) as any expect(result[0].content[0].video_url.url).toBe('data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28y...') }) - it('should redact audio when flag is disabled', () => { - delete process.env._INTERNAL_LLMA_MULTIMODAL + it('redacts audio when the client flag is not strictly true', () => { const input = [ { role: 'assistant', @@ -708,8 +672,8 @@ describe('Base64 image redaction', () => { expect(result[0].content[0].id).toBe('audio_123') }) - it('should preserve audio when flag is enabled', () => { - process.env._INTERNAL_LLMA_MULTIMODAL = 'true' + it('preserves audio when the client enables multimodal capture', () => { + const gate = { enableFullAiCapture: true } const input = [ { role: 'assistant', @@ -717,14 +681,14 @@ describe('Base64 image redaction', () => { }, ] - const result = sanitize(input, 'openai-chat-completions') as any + const result = sanitize(input, 'openai-chat-completions', gate) as any expect(result[0].content[0].data).toBe('base64audiodata') }) }) describe('Anthropic provider', () => { - it('should preserve images when flag is enabled', () => { - process.env._INTERNAL_LLMA_MULTIMODAL = 'true' + it('preserves images when the client enables multimodal capture', () => { + const gate = { enableFullAiCapture: true } const input = [ { role: 'user', @@ -741,12 +705,12 @@ describe('Base64 image redaction', () => { }, ] - const result = sanitize(input, 'anthropic') as any + const result = sanitize(input, 'anthropic', gate) as any expect(result[0].content[0].source.data).toBe('base64data') }) - it('should preserve PDFs/documents when flag is enabled', () => { - process.env._INTERNAL_LLMA_MULTIMODAL = 'true' + it('preserves PDFs/documents when the client enables multimodal capture', () => { + const gate = { enableFullAiCapture: true } const input = [ { role: 'user', @@ -763,12 +727,11 @@ describe('Base64 image redaction', () => { }, ] - const result = sanitize(input, 'anthropic') as any + const result = sanitize(input, 'anthropic', gate) as any expect(result[0].content[0].source.data).toBe('base64pdfdata') }) - it('should redact documents when flag is disabled', () => { - delete process.env._INTERNAL_LLMA_MULTIMODAL + it('redacts documents when the client flag is not strictly true', () => { const input = [ { role: 'user', @@ -791,16 +754,15 @@ describe('Base64 image redaction', () => { }) describe('Gemini provider', () => { - it('should preserve images when flag is enabled', () => { - process.env._INTERNAL_LLMA_MULTIMODAL = 'true' + it('preserves images when the client enables multimodal capture', () => { + const gate = { enableFullAiCapture: true } const input = [{ parts: [{ inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }] }] - const result = sanitize(input, 'gemini') as any + const result = sanitize(input, 'gemini', gate) as any expect(result[0].parts[0].inlineData.data).toBe('base64data') }) - it('should redact audio when flag is disabled', () => { - delete process.env._INTERNAL_LLMA_MULTIMODAL + it('redacts audio when the client flag is not strictly true', () => { const input = [ { parts: [ @@ -818,8 +780,8 @@ describe('Base64 image redaction', () => { expect(result[0].parts[0].inlineData.data).toBe('[base64 audio/L16;codec=pcm;rate=24000 redacted]') }) - it('should preserve audio when flag is enabled', () => { - process.env._INTERNAL_LLMA_MULTIMODAL = 'true' + it('preserves audio when the client enables multimodal capture', () => { + const gate = { enableFullAiCapture: true } const input = [ { parts: [ @@ -833,14 +795,14 @@ describe('Base64 image redaction', () => { }, ] - const result = sanitize(input, 'gemini') as any + const result = sanitize(input, 'gemini', gate) as any expect(result[0].parts[0].inlineData.data).toBe('base64audiodata') }) }) describe('LangChain provider', () => { - it('should preserve Anthropic-style images when flag is enabled', () => { - process.env._INTERNAL_LLMA_MULTIMODAL = 'true' + it('preserves Anthropic-style images when the client enables multimodal capture', () => { + const gate = { enableFullAiCapture: true } const input = [ { role: 'user', @@ -853,12 +815,11 @@ describe('Base64 image redaction', () => { }, ] - const result = sanitize(input, 'langchain') as any + const result = sanitize(input, 'langchain', gate) as any expect(result[0].content[0].source.data).toBe('base64data') }) - it('should redact Anthropic-style images when flag is disabled', () => { - delete process.env._INTERNAL_LLMA_MULTIMODAL + it('redacts Anthropic-style images when the client flag is not strictly true', () => { const input = [ { role: 'user', @@ -871,8 +832,11 @@ describe('Base64 image redaction', () => { }, ] - const result = sanitize(input, 'langchain') as any + const result = sanitize(input, 'langchain', { enableFullAiCapture: 'true' } as any) as any expect(result[0].content[0].source.data).toBe('[base64 image/jpeg redacted]') + + const resultNoClient = sanitize(input, 'langchain') as any + expect(resultNoClient[0].content[0].source.data).toBe('[base64 image/jpeg redacted]') }) }) }) diff --git a/packages/ai/tests/vercel.test.ts b/packages/ai/tests/vercel.test.ts index 6fe35655d5..7e441bef2d 100644 --- a/packages/ai/tests/vercel.test.ts +++ b/packages/ai/tests/vercel.test.ts @@ -317,6 +317,39 @@ describe('Vercel AI SDK - Dual Version Support', () => { expect(input).toContain('[base64 image/png redacted]') }) + it('preserves binary content nested in tool results when the client enables multimodal capture', async () => { + const binary = 'U0hPUlQgVE9PTCBSRVNVTFQ=' + const clientWithMultimodal = mockPostHogClient as PostHog & { enableFullAiCapture?: boolean } + clientWithMultimodal.enableFullAiCapture = true + const baseModel = createMockV3Model('tool-model') + const model = withTracing(baseModel, clientWithMultimodal, { posthogDistinctId: 'test-user' }) + const params = { + prompt: [ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'tool-call-id', + toolName: 'read-file', + output: { + type: 'content', + value: [{ type: 'media', data: binary, mediaType: 'image/png' }], + }, + }, + ], + }, + ], + } as any + + await model.doGenerate(params) + + expect(baseModel.doGenerate).toHaveBeenCalledWith(params) + const input = JSON.stringify((mockPostHogClient.capture as jest.Mock).mock.calls[0][0].properties['$ai_input']) + expect(input).toContain(binary) + expect(input).not.toContain('redacted') + }) + it('redacts data URL objects from input while preserving HTTP URL objects', async () => { const binary = 'SU5QVVQgVVJMIERBVEE=' const dataUrl = new URL(`data:audio/wav;base64,${binary}`) @@ -1134,6 +1167,180 @@ describe('Vercel AI SDK - Dual Version Support', () => { expect(JSON.stringify(input).length).toBeLessThan(210_000) } ) + + it('skips the oversized-prompt aggregate trim entirely when the client enables multimodal capture', async () => { + const clientWithMultimodal = mockPostHogClient as PostHog & { enableFullAiCapture?: boolean } + clientWithMultimodal.enableFullAiCapture = true + const baseModel = createMockV3Model('gpt-4') + const model = withTracing(baseModel, clientWithMultimodal, { + posthogDistinctId: 'test-user', + posthogTraceId: 'test-trim-bypass', + }) + + // Same oversized prompt as the default-mode trim test above: well past MAX_OUTPUT_SIZE (200kb). + const oversizedPrompt = Array.from({ length: 100 }, (_, i) => ({ + role: (i % 2 === 0 ? 'user' : 'assistant') as 'user' | 'assistant', + content: [{ type: 'text' as const, text: 'x'.repeat(15_000) }], + })) + + await model.doGenerate({ prompt: oversizedPrompt as any }) + + expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) + const [captureCall] = (mockPostHogClient.capture as jest.Mock).mock.calls + + const input = captureCall[0].properties.$ai_input as Array<{ role: string; content: unknown }> + expect(input).toHaveLength(oversizedPrompt.length) + expect(input.some((message) => message.role === 'posthog')).toBe(false) + expect(JSON.stringify(input).length).toBeGreaterThan(1_000_000) + }) + + it('skips truncation of oversized output when the client enables multimodal capture', async () => { + const oversizedText = 'y'.repeat(250_000) + const baseModel: LanguageModelV3 = { + specificationVersion: 'v3', + provider: 'openai', + modelId: 'gpt-4', + supportedUrls: {}, + doGenerate: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: oversizedText }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: { unified: 'stop' as const, raw: undefined }, + warnings: [], + }), + doStream: jest.fn(), + } + + const clientWithMultimodal = mockPostHogClient as PostHog & { enableFullAiCapture?: boolean } + clientWithMultimodal.enableFullAiCapture = true + const model = withTracing(baseModel, clientWithMultimodal, { + posthogDistinctId: 'test-user', + posthogTraceId: 'test-truncation-skip', + }) + + await model.doGenerate({ + prompt: [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'hi' }] }], + } as any) + + expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) + const [captureCall] = (mockPostHogClient.capture as jest.Mock).mock.calls + const output = captureCall[0].properties.$ai_output_choices[0].content + expect(output).toBe(oversizedText) + expect(output).not.toContain('[truncated]') + }) + + it('truncates oversized output when the client does not enable multimodal capture', async () => { + const oversizedText = 'y'.repeat(250_000) + const baseModel: LanguageModelV3 = { + specificationVersion: 'v3', + provider: 'openai', + modelId: 'gpt-4', + supportedUrls: {}, + doGenerate: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: oversizedText }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: { unified: 'stop' as const, raw: undefined }, + warnings: [], + }), + doStream: jest.fn(), + } + + const model = withTracing(baseModel, mockPostHogClient, { + posthogDistinctId: 'test-user', + posthogTraceId: 'test-truncation', + }) + + await model.doGenerate({ + prompt: [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'hi' }] }], + } as any) + + expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) + const [captureCall] = (mockPostHogClient.capture as jest.Mock).mock.calls + const output = captureCall[0].properties.$ai_output_choices[0].content + expect(output.length).toBeLessThan(oversizedText.length) + expect(output).toContain('... [truncated]') + }) + + it('preserves input and output file part base64 data when the client enables multimodal capture', async () => { + const base64Data = `data:image/png;base64,${'A'.repeat(2000)}` + const baseModel: LanguageModelV3 = { + specificationVersion: 'v3', + provider: 'openai', + modelId: 'gpt-4', + supportedUrls: {}, + doGenerate: jest.fn().mockResolvedValue({ + content: [{ type: 'file', data: base64Data, mediaType: 'image/png' }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: { unified: 'stop' as const, raw: undefined }, + warnings: [], + }), + doStream: jest.fn(), + } + + const clientWithMultimodal = mockPostHogClient as PostHog & { enableFullAiCapture?: boolean } + clientWithMultimodal.enableFullAiCapture = true + const model = withTracing(baseModel, clientWithMultimodal, { + posthogDistinctId: 'test-user', + posthogTraceId: 'test-multimodal-passthrough', + }) + + await model.doGenerate({ + prompt: [ + { + role: 'user' as const, + content: [{ type: 'file' as const, data: base64Data, mediaType: 'image/png' }], + }, + ], + } as any) + + expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) + const [captureCall] = (mockPostHogClient.capture as jest.Mock).mock.calls + const inputFile = captureCall[0].properties.$ai_input[0].content[0] + const outputFile = captureCall[0].properties.$ai_output_choices[0].content[0] + + expect(inputFile.file).toBe(base64Data) + expect(outputFile.data).toBe(base64Data) + }) + + it('redacts input and output file part base64 data when the client does not enable multimodal capture', async () => { + const base64Data = `data:image/png;base64,${'A'.repeat(2000)}` + const baseModel: LanguageModelV3 = { + specificationVersion: 'v3', + provider: 'openai', + modelId: 'gpt-4', + supportedUrls: {}, + doGenerate: jest.fn().mockResolvedValue({ + content: [{ type: 'file', data: base64Data, mediaType: 'image/png' }], + usage: { inputTokens: 1, outputTokens: 1 }, + finishReason: { unified: 'stop' as const, raw: undefined }, + warnings: [], + }), + doStream: jest.fn(), + } + + const model = withTracing(baseModel, mockPostHogClient, { + posthogDistinctId: 'test-user', + posthogTraceId: 'test-multimodal-redacted', + }) + + await model.doGenerate({ + prompt: [ + { + role: 'user' as const, + content: [{ type: 'file' as const, data: base64Data, mediaType: 'image/png' }], + }, + ], + } as any) + + expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) + const [captureCall] = (mockPostHogClient.capture as jest.Mock).mock.calls + const inputFile = captureCall[0].properties.$ai_input[0].content[0] + const outputFile = captureCall[0].properties.$ai_output_choices[0].content[0] + + expect(inputFile.file).not.toBe(base64Data) + expect(inputFile.file).toContain('redacted') + expect(outputFile.data).not.toBe(base64Data) + expect(outputFile.data).toContain('redacted') + }) }) describe('Anthropic V3 cache token handling', () => { diff --git a/packages/core/src/__tests__/posthog.explicit-route.spec.ts b/packages/core/src/__tests__/posthog.explicit-route.spec.ts new file mode 100644 index 0000000000..1dd4267cce --- /dev/null +++ b/packages/core/src/__tests__/posthog.explicit-route.spec.ts @@ -0,0 +1,161 @@ +import { JsonType, PostHogCoreOptions, PostHogEventProperties, PostHogPersistedProperty } from '@/types' +import { PostHogCoreTestClient, PostHogCoreTestClientMocks } from '@/testing' + +class ExplicitRouteTestClient extends PostHogCoreTestClient { + public sendBatchCalls: { route: string; events: (string | undefined)[] }[] = [] + + protected getQueueRouteKey(_message: PostHogEventProperties): string { + return 'content-based' + } + + protected persistedQueueKeyForRoute(route: string): PostHogPersistedProperty { + return route === 'explicit' ? PostHogPersistedProperty.AiCaptureQueue : PostHogPersistedProperty.Queue + } + + protected getActiveQueueRoutes(): string[] { + return ['content-based', 'explicit'] + } + + protected async sendBatch( + batch: (PostHogEventProperties | undefined)[], + retryOptions?: any, + route?: string + ): Promise { + this.sendBatchCalls.push({ + route: route ?? 'default', + events: batch.map((m) => m?.event as string | undefined), + }) + return super.sendBatch(batch, retryOptions, route) + } + + public enqueueOnRoute(route: string | undefined, event: string): void { + this.enqueue('capture', { event, distinct_id: 'test-user', properties: {} }, {}, route) + } + + public sendImmediateOnRoute(route: string | undefined, event: string): Promise { + return this.sendImmediate('capture', { event, distinct_id: 'test-user', properties: {} }, {}, route) + } +} + +type ExplicitRouteTestClientClass = new ( + mocks: PostHogCoreTestClientMocks, + apiKey: string, + options: PostHogCoreOptions +) => T + +function createClient(options?: PostHogCoreOptions): [ExplicitRouteTestClient, PostHogCoreTestClientMocks] +function createClient( + options: PostHogCoreOptions | undefined, + ClientClass: ExplicitRouteTestClientClass +): [T, PostHogCoreTestClientMocks] +function createClient( + options?: PostHogCoreOptions, + ClientClass: ExplicitRouteTestClientClass = ExplicitRouteTestClient +): [ExplicitRouteTestClient, PostHogCoreTestClientMocks] { + const storageCache: { [key: string]: string | JsonType } = {} + const mocks: PostHogCoreTestClientMocks = { + fetch: jest.fn(), + storage: { + getItem: jest.fn((key) => storageCache[key]), + setItem: jest.fn((key, val) => { + storageCache[key] = val == null ? undefined : val + }), + }, + } + mocks.fetch.mockImplementation(() => + Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => Promise.resolve({ status: 'ok' }), + }) + ) + const client = new ClientClass(mocks, 'TEST_API_KEY', { + flushAt: 100, + flushInterval: 0, + fetchRetryCount: 0, + disableCompression: true, + ...options, + }) + return [client, mocks] +} + +const queueEvents = (client: ExplicitRouteTestClient, key: PostHogPersistedProperty): (string | undefined)[] => + (client.getPersistedProperty(key) || []).map((item) => item?.message?.event) + +describe('PostHog Core explicit queue route', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2022-01-01')) + }) + + it('enqueues onto the explicit route, overriding getQueueRouteKey', () => { + const [posthog] = createClient() + + posthog.enqueueOnRoute('explicit', 'lane_event') + posthog.enqueueOnRoute(undefined, 'normal_event') + + expect(queueEvents(posthog, PostHogPersistedProperty.AiCaptureQueue)).toEqual(['lane_event']) + expect(queueEvents(posthog, PostHogPersistedProperty.Queue)).toEqual(['normal_event']) + }) + + it('flushes the explicit route independently and tags sendBatch with it', async () => { + const [posthog, mocks] = createClient() + + posthog.enqueueOnRoute('explicit', 'lane_event') + posthog.enqueueOnRoute(undefined, 'normal_event') + await posthog.flush() + + expect(posthog.sendBatchCalls).toEqual([ + { route: 'content-based', events: ['normal_event'] }, + { route: 'explicit', events: ['lane_event'] }, + ]) + expect(mocks.fetch).toHaveBeenCalledTimes(2) + expect(queueEvents(posthog, PostHogPersistedProperty.AiCaptureQueue)).toEqual([]) + expect(queueEvents(posthog, PostHogPersistedProperty.Queue)).toEqual([]) + }) + + it('reset() preserves the AiCaptureQueue route alongside the default queue', () => { + const [posthog] = createClient() + + posthog.enqueueOnRoute('explicit', 'lane_event') + posthog.enqueueOnRoute(undefined, 'normal_event') + + posthog.reset() + + expect(queueEvents(posthog, PostHogPersistedProperty.AiCaptureQueue)).toEqual(['lane_event']) + expect(queueEvents(posthog, PostHogPersistedProperty.Queue)).toEqual(['normal_event']) + }) + + it('sendImmediate honors the explicit route', async () => { + const [posthog] = createClient() + + await posthog.sendImmediateOnRoute('explicit', 'lane_event') + await posthog.sendImmediateOnRoute(undefined, 'normal_event') + + expect(posthog.sendBatchCalls).toEqual([ + { route: 'explicit', events: ['lane_event'] }, + { route: 'content-based', events: ['normal_event'] }, + ]) + }) + + it('sendBatch posts each route to its getBatchEndpointPath', async () => { + class EndpointRoutedClient extends ExplicitRouteTestClient { + protected getBatchEndpointPath(route: string): string { + return route === 'explicit' ? '/i/v0/test-lane/' : super.getBatchEndpointPath(route) + } + } + const [posthog, mocks] = createClient(undefined, EndpointRoutedClient) + + posthog.enqueueOnRoute('explicit', 'lane_event') + posthog.enqueueOnRoute(undefined, 'normal_event') + await posthog.flush() + + const urls = mocks.fetch.mock.calls.map((call) => call[0]) + expect(urls.some((url) => url.endsWith('/batch/'))).toBe(true) + expect(urls.some((url) => url.endsWith('/i/v0/test-lane/'))).toBe(true) + + const laneCall = mocks.fetch.mock.calls.find((call) => call[0].endsWith('/i/v0/test-lane/')) + const body = JSON.parse(laneCall![1].body as string) + expect(Object.keys(body).sort()).toEqual(['api_key', 'batch', 'sent_at']) + expect(body.batch.map((event: any) => event.event)).toEqual(['lane_event']) + }) +}) diff --git a/packages/core/src/posthog-core-stateless.ts b/packages/core/src/posthog-core-stateless.ts index 056c1ff702..e65d1e6876 100644 --- a/packages/core/src/posthog-core-stateless.ts +++ b/packages/core/src/posthog-core-stateless.ts @@ -205,7 +205,7 @@ function isRetryableFlagsFetchError( return code !== 'ECONNREFUSED' } -function isPostHogFetchContentTooLargeError(err: unknown): err is PostHogFetchHttpError & { status: 413 } { +export function isPostHogFetchContentTooLargeError(err: unknown): err is PostHogFetchHttpError & { status: 413 } { return typeof err === 'object' && err instanceof PostHogFetchHttpError && err.status === 413 } @@ -1162,7 +1162,7 @@ export abstract class PostHogCoreStateless { return this.getPersistedProperty(this.persistedQueueKeyForRoute(route)) || [] } - protected enqueue(type: string, _message: any, options?: PostHogCaptureOptions): void { + protected enqueue(type: string, _message: any, options?: PostHogCaptureOptions, explicitRoute?: string): void { this.wrap(() => { if (this.optedOut) { this._events.emit(type, `Library is disabled. Not sending event. To re-enable, call posthog.optIn()`) @@ -1178,7 +1178,7 @@ export abstract class PostHogCoreStateless { } message = this.normalizeMessage(message) - const queueKey = this.persistedQueueKeyForRoute(this.getQueueRouteKey(message)) + const queueKey = this.persistedQueueKeyForRoute(explicitRoute ?? this.getQueueRouteKey(message)) const queue = this.getPersistedProperty(queueKey) || [] if (queue.length >= this.maxQueueSize) { @@ -1202,7 +1202,12 @@ export abstract class PostHogCoreStateless { }) } - protected async sendImmediate(type: string, _message: any, options?: PostHogCaptureOptions): Promise { + protected async sendImmediate( + type: string, + _message: any, + options?: PostHogCaptureOptions, + explicitRoute?: string + ): Promise { if (this.disabled) { this._logger.warn('The client is disabled') return @@ -1227,7 +1232,7 @@ export abstract class PostHogCoreStateless { message = this.normalizeMessage(message) try { - await this.sendBatch([message], undefined, this.getQueueRouteKey(message)) + await this.sendBatch([message], undefined, explicitRoute ?? this.getQueueRouteKey(message)) } catch (err) { this._events.emit('error', err) } @@ -1431,6 +1436,10 @@ export abstract class PostHogCoreStateless { return gzipCompress(payload, this.isDebug) } + protected getBatchEndpointPath(_route: string): string { + return '/batch/' + } + /** * Builds and sends one `/batch/` request for the given already-normalized * messages, throwing on transport/HTTP error. Batch-size (413) shrinking, @@ -1446,7 +1455,7 @@ export abstract class PostHogCoreStateless { protected async sendBatch( batchMessages: (PostHogEventProperties | undefined)[], retryOptions?: Partial, - _route: string = DEFAULT_QUEUE_ROUTE + route: string = DEFAULT_QUEUE_ROUTE ): Promise { const data: Record = { api_key: this.apiKey, @@ -1460,7 +1469,7 @@ export abstract class PostHogCoreStateless { const payload = safeJsonStringify(data) - const url = `${this.host}/batch/` + const url = `${this.host}${this.getBatchEndpointPath(route)}` const gzippedPayload = !this.disableCompression ? await this.compressPayload(payload) : null const fetchOptions: PostHogFetchOptions = { diff --git a/packages/core/src/posthog-core.ts b/packages/core/src/posthog-core.ts index 83bc4d4102..464f2dc1d3 100644 --- a/packages/core/src/posthog-core.ts +++ b/packages/core/src/posthog-core.ts @@ -161,9 +161,9 @@ export abstract class PostHogCore extends PostHogCoreStateless { * Resets the user's ID and clears all persisted properties. * * Note: The event queues (`PostHogPersistedProperty.Queue` and the isolated - * `PostHogPersistedProperty.AiQueue`) and the logs queue - * (`PostHogPersistedProperty.LogsQueue`) are always preserved regardless - * of what is passed in `propertiesToKeep`, to ensure in-flight data + * `PostHogPersistedProperty.AiQueue` and `PostHogPersistedProperty.AiCaptureQueue`) + * and the logs queue (`PostHogPersistedProperty.LogsQueue`) are always preserved + * regardless of what is passed in `propertiesToKeep`, to ensure in-flight data * is not lost when identity changes. * * @param propertiesToKeep - Optional array of persisted properties to preserve during reset. @@ -173,6 +173,7 @@ export abstract class PostHogCore extends PostHogCoreStateless { const allPropertiesToKeep = [ PostHogPersistedProperty.Queue, PostHogPersistedProperty.AiQueue, + PostHogPersistedProperty.AiCaptureQueue, PostHogPersistedProperty.LogsQueue, ...(propertiesToKeep || []), ] diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 442f144e2e..40d8c96203 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -272,6 +272,7 @@ export enum PostHogPersistedProperty { // legacy (v0) transport while other events move to Capture V1 — segregated so a // failure on one route can't re-send events already accepted on the other. AiQueue = 'ai_queue', + AiCaptureQueue = 'ai_capture_queue', // Logs queue. Individual SDKs may route this key to an isolated storage // instance if they want to separate logs write volume from main state. LogsQueue = 'logs_queue', diff --git a/packages/node/references/posthog-node-references-latest.json b/packages/node/references/posthog-node-references-latest.json index 1a9f6054b4..77a30c766e 100644 --- a/packages/node/references/posthog-node-references-latest.json +++ b/packages/node/references/posthog-node-references-latest.json @@ -162,6 +162,64 @@ }, "path": "src/entrypoints/index.node.ts" }, + { + "category": "Capture", + "description": "Capture an AI event on the dedicated AI capture endpoint.\nBeta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Delivery is async, and no redaction or truncation is applied to the payload.", + "details": null, + "id": "captureAi", + "showDocs": true, + "title": "captureAi", + "examples": [ + { + "id": "captureai", + "name": "Generated example for captureAi", + "code": "// Generated example for captureAi\nposthog.captureAi();" + } + ], + "releaseTag": "public", + "params": [ + { + "description": "The event properties", + "isOptional": false, + "type": "EventMessage", + "name": "props" + } + ], + "returnType": { + "id": "string | undefined", + "name": "string | undefined" + }, + "path": "src/entrypoints/index.node.ts" + }, + { + "category": "Capture", + "description": "Capture an AI event on the dedicated AI capture endpoint, resolving after the send completes. Use in short-lived processes (serverless) where the runtime may freeze before a background flush runs.", + "details": null, + "id": "captureAiImmediate", + "showDocs": true, + "title": "captureAiImmediate", + "examples": [ + { + "id": "captureaiimmediate", + "name": "Generated example for captureAiImmediate", + "code": "// Generated example for captureAiImmediate\nposthog.captureAiImmediate();" + } + ], + "releaseTag": "public", + "params": [ + { + "description": "The event properties", + "isOptional": false, + "type": "EventMessage", + "name": "props" + } + ], + "returnType": { + "id": "Promise", + "name": "Promise" + }, + "path": "src/entrypoints/index.node.ts" + }, { "category": "Error tracking", "description": "Capture an error exception as an event.", @@ -3477,6 +3535,11 @@ "type": "boolean", "name": "isServer" }, + { + "description": "Capture full AI content: PostHog AI wrapper events route through the\ndedicated AI capture endpoint, skip string truncation, and pass media\n(base64 / data URIs) through unredacted. Privacy mode always wins.\nDefaults to false.", + "type": "boolean", + "name": "enableFullAiCapture" + }, { "description": "ADVANCED: alters the refill rate for the error tracking rate limiter's token bucket.\nNormally only altered alongside PostHog support guidance.\nAccepts values between 0 and 100.", "type": "number", @@ -3494,6 +3557,10 @@ "id": "PostHogPersistedProperty", "name": "PostHogPersistedProperty", "properties": [ + { + "type": "\"ai_capture_queue\"", + "name": "AiCaptureQueue" + }, { "type": "\"ai_queue\"", "name": "AiQueue" diff --git a/packages/node/src/__tests__/ai-capture/batching.spec.ts b/packages/node/src/__tests__/ai-capture/batching.spec.ts new file mode 100644 index 0000000000..612ec54531 --- /dev/null +++ b/packages/node/src/__tests__/ai-capture/batching.spec.ts @@ -0,0 +1,42 @@ +import { partitionAiBatch } from '@/ai-capture/batching' + +const eventOfBytes = (name: string, bytes: number): any => { + return { event: name, properties: { pad: 'x'.repeat(bytes) } } +} + +describe('partitionAiBatch', () => { + it('passes small events through as a single batch and skips undefined entries', () => { + const { batches, dropped } = partitionAiBatch([eventOfBytes('a', 10), undefined, eventOfBytes('b', 10)]) + expect(batches).toHaveLength(1) + expect(batches[0].map((event) => event.event)).toEqual(['a', 'b']) + expect(dropped).toEqual([]) + }) + + it('drops events over the per-event cap, reporting name and size only', () => { + const { batches, dropped } = partitionAiBatch([eventOfBytes('huge', 300), eventOfBytes('ok', 10)], 200, 1000) + expect(batches).toHaveLength(1) + expect(batches[0].map((event) => event.event)).toEqual(['ok']) + expect(dropped).toHaveLength(1) + expect(dropped[0].event).toBe('huge') + expect(dropped[0].bytes).toBeGreaterThan(200) + expect(Object.keys(dropped[0]).sort()).toEqual(['bytes', 'event']) + }) + + it('packs greedily under the target batch size', () => { + const events = [eventOfBytes('a', 400), eventOfBytes('b', 400), eventOfBytes('c', 400)] + const { batches, dropped } = partitionAiBatch(events, 2000, 1000) + expect(batches.map((batch) => batch.map((event) => event.event))).toEqual([['a', 'b'], ['c']]) + expect(dropped).toEqual([]) + }) + + it('allows a single event above the target (but under the cap) alone in its batch', () => { + const { batches, dropped } = partitionAiBatch([eventOfBytes('big', 1500)], 2000, 1000) + expect(batches.map((batch) => batch.map((event) => event.event))).toEqual([['big']]) + expect(dropped).toEqual([]) + }) + + it('reports a non-string event name as unknown', () => { + const { dropped } = partitionAiBatch([{ properties: { pad: 'x'.repeat(300) } } as any], 200, 1000) + expect(dropped[0].event).toBe('unknown') + }) +}) diff --git a/packages/node/src/__tests__/ai-capture/wiring.spec.ts b/packages/node/src/__tests__/ai-capture/wiring.spec.ts new file mode 100644 index 0000000000..18d36a8717 --- /dev/null +++ b/packages/node/src/__tests__/ai-capture/wiring.spec.ts @@ -0,0 +1,235 @@ +import { PostHogPersistedProperty } from '@posthog/core' + +import { PostHog } from '@/entrypoints/index.node' + +import { V1WiringHarness, v413Response, v0Response, waitForFlushTimer } from '../utils/v1-wiring' + +jest.mock('../../version', () => ({ version: '1.2.3' })) + +describe('AI capture lane wiring (Node SDK)', () => { + const harness = new V1WiringHarness() + + const aiCaptureQueueEvents = (posthog: PostHog): string[] => + (posthog.getPersistedProperty(PostHogPersistedProperty.AiCaptureQueue) || []).map((item: any) => item.message.event) + + const deliveredEventsIn = async (fragment: string): Promise => { + const events: string[] = [] + for (const [call, result] of harness.fetch.mock.calls.map( + (call, i) => [call, harness.fetch.mock.results[i]] as const + )) { + if (!(call[0] as string).includes(fragment)) { + continue + } + const response = await result.value + if (response.status >= 200 && response.status < 300) { + events.push(...JSON.parse(call[1].body).batch.map((event: any) => event.event)) + } + } + return events + } + + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}) + jest.spyOn(console, 'error').mockImplementation(() => {}) + jest.spyOn(console, 'info').mockImplementation(() => {}) + jest.spyOn(console, 'log').mockImplementation(() => {}) + jest.spyOn(console, 'debug').mockImplementation(() => {}) + harness.useDefaultRouting() + }) + + afterEach(async () => { + await harness.cleanup() + jest.clearAllMocks() + }) + + it('captureAi posts to the AI endpoint with the V0 body shape', async () => { + const posthog = harness.makeClient() + posthog.captureAi({ distinctId: 'u', event: '$ai_generation', properties: { $ai_model: 'gpt' } }) + await posthog.flush() + + const calls = harness.callsTo('/i/v0/ai/batch/') + expect(calls).toHaveLength(1) + const body = JSON.parse(calls[0][1].body) + expect(Object.keys(body).sort()).toEqual(['api_key', 'batch', 'sent_at']) + expect(body.batch.map((event: any) => event.event)).toEqual(['$ai_generation']) + expect(harness.callsTo('example.com/batch/')).toHaveLength(0) + }) + + it('capture() is never rerouted to the AI endpoint, in either capture mode', async () => { + for (const mode of ['v0', 'v1'] as const) { + const posthog = harness.makeClient({}, mode) + posthog.capture({ distinctId: 'u', event: '$ai_generation', properties: {} }) + await posthog.flush() + } + expect(harness.callsTo('/i/v0/ai/batch/')).toHaveLength(0) + expect(harness.eventsIn('example.com/batch/')).toEqual(['$ai_generation', '$ai_generation']) + }) + + it('captureAi stays on the AI endpoint in v1 mode, isolated from both other routes', async () => { + const posthog = harness.makeClient({}, 'v1') + posthog.capture({ distinctId: 'u', event: 'custom', properties: {} }) + posthog.capture({ distinctId: 'u', event: '$ai_generation', properties: {} }) + posthog.captureAi({ distinctId: 'u', event: '$ai_span', properties: {} }) + await waitForFlushTimer() + await posthog.flush() + + expect(harness.eventsIn('/i/v1/analytics/events')).toEqual(['custom']) + expect(harness.eventsIn('example.com/batch/')).toEqual(['$ai_generation']) + expect(harness.eventsIn('/i/v0/ai/batch/')).toEqual(['$ai_span']) + }) + + it('captureAiImmediate awaits a single AI endpoint delivery', async () => { + const posthog = harness.makeClient() + await posthog.captureAiImmediate({ distinctId: 'u', event: '$ai_embedding', properties: {} }) + expect(harness.eventsIn('/i/v0/ai/batch/')).toEqual(['$ai_embedding']) + }) + + it('drops events over 8MiB with a name-and-size-only error log, delivering the rest', async () => { + const posthog = harness.makeClient() + posthog.debug(true) + const onError = jest.fn() + posthog.on('error', onError) + const pad = 'x'.repeat(9 * 1024 * 1024) + posthog.captureAi({ distinctId: 'u', event: '$ai_generation', properties: { pad } }) + posthog.captureAi({ distinctId: 'u', event: '$ai_span', properties: {} }) + await posthog.flush() + + expect(harness.eventsIn('/i/v0/ai/batch/')).toEqual(['$ai_span']) + const errorLog = (console.error as jest.Mock).mock.calls.flat().join(' ') + expect(errorLog).toContain('$ai_generation') + expect(errorLog).toMatch(/\d+ bytes/) + expect(errorLog).not.toContain('xxxx') + + expect(onError).toHaveBeenCalledTimes(1) + const emittedError = onError.mock.calls[0][0] + expect(emittedError).toBeInstanceOf(Error) + expect(emittedError.message).toContain('$ai_generation') + expect(emittedError.message).toMatch(/\d+ bytes/) + expect(emittedError.message).not.toContain('xxxx') + }) + + it('splits multi-MB batches into byte-bounded requests', async () => { + const posthog = harness.makeClient() + const pad = 'x'.repeat(2 * 1024 * 1024) + for (const event of ['$ai_a', '$ai_b', '$ai_c'] as const) { + posthog.captureAi({ distinctId: 'u', event, properties: { pad } }) + } + await posthog.flush() + + const calls = harness.callsTo('/i/v0/ai/batch/') + expect(calls).toHaveLength(2) + expect(harness.eventsIn('/i/v0/ai/batch/')).toEqual(['$ai_a', '$ai_b', '$ai_c']) + }) + + it('bisects a sub-batch in-lane on 413 without tripping the shared batch-size halving', async () => { + const posthog = harness.makeClient() + harness.fetch.mockImplementationOnce(() => Promise.resolve(v413Response())) + harness.fetch.mockImplementation(() => Promise.resolve(v0Response())) + + for (const event of ['$ai_a', '$ai_b', '$ai_c'] as const) { + posthog.captureAi({ distinctId: 'u', event, properties: {} }) + } + await expect(posthog.flush()).resolves.not.toThrow() + + expect((await deliveredEventsIn('/i/v0/ai/batch/')).sort()).toEqual(['$ai_a', '$ai_b', '$ai_c']) + expect(harness.callsTo('/i/v0/ai/batch/').length).toBeGreaterThan(1) + + harness.useDefaultRouting() + for (const event of ['custom_1', 'custom_2', 'custom_3'] as const) { + posthog.capture({ distinctId: 'u', event, properties: {} }) + } + await posthog.flush() + + const analyticsCalls = harness.callsTo('example.com/batch/') + expect(analyticsCalls).toHaveLength(1) + expect(JSON.parse(analyticsCalls[0][1].body).batch.map((e: any) => e.event)).toEqual([ + 'custom_1', + 'custom_2', + 'custom_3', + ]) + }) + + it('drops a single event that still 413s alone, without throwing, and keeps the lane usable', async () => { + const posthog = harness.makeClient() + posthog.debug(true) + const onError = jest.fn() + posthog.on('error', onError) + harness.fetch.mockImplementation((url: any) => + Promise.resolve(url.includes('/i/v0/ai/batch/') ? v413Response() : v0Response()) + ) + + posthog.captureAi({ distinctId: 'u', event: '$ai_undeliverable', properties: {} }) + await expect(posthog.flush()).resolves.not.toThrow() + + expect(await deliveredEventsIn('/i/v0/ai/batch/')).toEqual([]) + const errorLog = (console.error as jest.Mock).mock.calls.flat().join(' ') + expect(errorLog).toContain('$ai_undeliverable') + expect(errorLog).toMatch(/\d+ bytes/) + + expect(onError).toHaveBeenCalledTimes(1) + const emittedError = onError.mock.calls[0][0] + expect(emittedError).toBeInstanceOf(Error) + expect(emittedError.message).toContain('$ai_undeliverable') + expect(emittedError.message).toMatch(/\d+ bytes/) + + harness.useDefaultRouting() + posthog.captureAi({ distinctId: 'u', event: '$ai_next', properties: {} }) + await posthog.flush() + expect(await deliveredEventsIn('/i/v0/ai/batch/')).toEqual(['$ai_next']) + }) + + it('keeps the AI route inactive (and flush silent) until first use', async () => { + const posthog = harness.makeClient() + posthog.capture({ distinctId: 'u', event: 'custom', properties: {} }) + await posthog.flush() + expect(harness.callsTo('/i/v0/ai/batch/')).toHaveLength(0) + + posthog.captureAi({ distinctId: 'u', event: '$ai_generation', properties: {} }) + await posthog.shutdown() + expect(harness.eventsIn('/i/v0/ai/batch/')).toEqual(['$ai_generation']) + expect(aiCaptureQueueEvents(posthog)).toEqual([]) + }) + + it('routes non-$ai_ events through the lane anyway, with a debug log', async () => { + const posthog = harness.makeClient() + posthog.debug(true) + posthog.captureAi({ distinctId: 'u', event: 'custom_event', properties: {} }) + await posthog.flush() + expect(harness.eventsIn('/i/v0/ai/batch/')).toEqual(['custom_event']) + const debugLog = (console.debug as jest.Mock).mock.calls.flat().join(' ') + expect(debugLog).toContain('custom_event') + }) + + it('exposes enableFullAiCapture as a readonly field, default false', () => { + expect(harness.makeClient().enableFullAiCapture).toBe(false) + expect(harness.makeClient({ enableFullAiCapture: true }).enableFullAiCapture).toBe(true) + }) + + it('captureAi returns the event uuid and stamps it on the wire event', async () => { + const posthog = harness.makeClient() + const uuid = posthog.captureAi({ distinctId: 'u', event: '$ai_generation' }) + await posthog.flush() + const body = JSON.parse(harness.callsTo('/i/v0/ai/batch/')[0][1].body) + expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/) + expect(body.batch[0].uuid).toBe(uuid) + }) + + it('captureAi keeps a caller-supplied uuid', async () => { + const posthog = harness.makeClient() + const supplied = '0198c0de-0000-7000-8000-000000000abc' + expect(posthog.captureAi({ distinctId: 'u', event: '$ai_generation', uuid: supplied })).toBe(supplied) + }) + + it('captureAi returns undefined when the client is disabled', () => { + const posthog = harness.makeClient({ disabled: true }) + expect(posthog.captureAi({ distinctId: 'u', event: '$ai_generation' })).toBeUndefined() + }) + + it('captureAiImmediate resolves with the uuid after the send completes', async () => { + const posthog = harness.makeClient() + const uuid = await posthog.captureAiImmediate({ distinctId: 'u', event: '$ai_generation' }) + const calls = harness.callsTo('/i/v0/ai/batch/') + expect(calls).toHaveLength(1) + expect(JSON.parse(calls[0][1].body).batch[0].uuid).toBe(uuid) + }) +}) diff --git a/packages/node/src/__tests__/utils/v1-wiring.ts b/packages/node/src/__tests__/utils/v1-wiring.ts index a3c44ac561..fa35cab0fd 100644 --- a/packages/node/src/__tests__/utils/v1-wiring.ts +++ b/packages/node/src/__tests__/utils/v1-wiring.ts @@ -27,6 +27,16 @@ export function v0Response(): any { } } +export function v413Response(): any { + return { + status: 413, + text: () => Promise.resolve('Content Too Large'), + json: () => Promise.resolve({ status: 'Content Too Large' }), + headers: { get: () => null }, + body: null, + } +} + /** Default fetch behavior: the v1 endpoint returns all-accepted, everything else a v0 200. */ export function routeByUrl(url: string): any { return url.includes('/i/v1/analytics/events') ? v1Response() : v0Response() diff --git a/packages/node/src/ai-capture/batching.ts b/packages/node/src/ai-capture/batching.ts new file mode 100644 index 0000000000..c1955b17cc --- /dev/null +++ b/packages/node/src/ai-capture/batching.ts @@ -0,0 +1,47 @@ +import { PostHogEventProperties, safeJsonStringify } from '@posthog/core' + +import { AI_BATCH_TARGET_BYTES, AI_MAX_EVENT_BYTES } from './routing' + +const encoder = new TextEncoder() + +export type AiBatchPartition = { + batches: PostHogEventProperties[][] + dropped: { event: string; bytes: number }[] +} + +export function eventByteSize(message: PostHogEventProperties): number { + return encoder.encode(safeJsonStringify(message)).length +} + +export function partitionAiBatch( + messages: (PostHogEventProperties | undefined)[], + maxEventBytes: number = AI_MAX_EVENT_BYTES, + targetBatchBytes: number = AI_BATCH_TARGET_BYTES +): AiBatchPartition { + const batches: PostHogEventProperties[][] = [] + const dropped: { event: string; bytes: number }[] = [] + let current: PostHogEventProperties[] = [] + let currentBytes = 0 + + for (const message of messages) { + if (message === undefined) { + continue + } + const bytes = eventByteSize(message) + if (bytes > maxEventBytes) { + dropped.push({ event: typeof message.event === 'string' ? message.event : 'unknown', bytes }) + continue + } + if (current.length > 0 && currentBytes + bytes > targetBatchBytes) { + batches.push(current) + current = [] + currentBytes = 0 + } + current.push(message) + currentBytes += bytes + } + if (current.length > 0) { + batches.push(current) + } + return { batches, dropped } +} diff --git a/packages/node/src/ai-capture/routing.ts b/packages/node/src/ai-capture/routing.ts new file mode 100644 index 0000000000..819f53f97c --- /dev/null +++ b/packages/node/src/ai-capture/routing.ts @@ -0,0 +1,7 @@ +export const AI_CAPTURE_ROUTE = 'ai-capture' + +export const AI_CAPTURE_ENDPOINT_PATH = '/i/v0/ai/batch/' + +export const AI_MAX_EVENT_BYTES = 8 * 1024 * 1024 + +export const AI_BATCH_TARGET_BYTES = 5 * 1024 * 1024 diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 73902572ce..dbd47b1f3d 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -2,8 +2,10 @@ import { version } from './version' import { FeatureFlagValue, + getEventUuid, isBlockedUA, isPlainObject, + isPostHogFetchContentTooLargeError, JsonType, minimizeFlagCalledEventProperties, PostHogCaptureOptions, @@ -58,6 +60,8 @@ import { ContextData, ContextOptions, IPostHogContext } from './extensions/conte import { type CaptureMode, resolveCaptureMode } from './capture-v1/config' import { AI_ROUTE, ANALYTICS_ROUTE, isLegacyOnlyEvent } from './capture-v1/routing' import { V1CaptureSender } from './capture-v1/sender' +import { eventByteSize, partitionAiBatch } from './ai-capture/batching' +import { AI_CAPTURE_ENDPOINT_PATH, AI_CAPTURE_ROUTE, AI_MAX_EVENT_BYTES } from './ai-capture/routing' // Standard local evaluation rate limit is 600 per minute (10 per second), // so the fastest a poller should ever be set is 100ms. @@ -143,6 +147,9 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen private readonly captureMode: CaptureMode private _v1Sender?: V1CaptureSender + /** Whether this client captures full AI content: see `enableFullAiCapture` in `PostHogOptions`. */ + public readonly enableFullAiCapture: boolean + private _aiCaptureRouteActive = false // Feature flag overrides for local testing/development private _flagOverrides?: Record @@ -209,6 +216,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen this.options = normalizedOptions this.captureMode = resolveCaptureMode() + this.enableFullAiCapture = normalizedOptions.enableFullAiCapture === true this.context = this.initializeContext() this.options.featureFlagsPollingInterval = @@ -262,8 +270,13 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen this.maxCacheSize = normalizedOptions.maxCacheSize || MAX_CACHE_SIZE } - protected override enqueue(type: string, message: any, options?: PostHogCaptureOptions): void { - super.enqueue(type, message, options) + protected override enqueue( + type: string, + message: any, + options?: PostHogCaptureOptions, + explicitRoute?: string + ): void { + super.enqueue(type, message, options, explicitRoute) this.scheduleDebouncedFlush() } @@ -433,13 +446,22 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen } protected persistedQueueKeyForRoute(route: string): PostHogPersistedProperty { + if (route === AI_CAPTURE_ROUTE) { + return PostHogPersistedProperty.AiCaptureQueue + } return route === AI_ROUTE ? PostHogPersistedProperty.AiQueue : PostHogPersistedProperty.Queue } protected getActiveQueueRoutes(): string[] { - // Only surface the AI route in v1 mode — v0 mode never enqueues onto it, so keeping it out - // keeps v0's flush/shutdown identical to before (a single queue on ANALYTICS_ROUTE). - return this.captureMode === 'v1' ? [ANALYTICS_ROUTE, AI_ROUTE] : [ANALYTICS_ROUTE] + const routes = this.captureMode === 'v1' ? [ANALYTICS_ROUTE, AI_ROUTE] : [ANALYTICS_ROUTE] + if (this._aiCaptureRouteActive) { + routes.push(AI_CAPTURE_ROUTE) + } + return routes + } + + protected getBatchEndpointPath(route: string): string { + return route === AI_CAPTURE_ROUTE ? AI_CAPTURE_ENDPOINT_PATH : super.getBatchEndpointPath(route) } /** @@ -455,6 +477,10 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen retryOptions?: Partial, route: string = ANALYTICS_ROUTE ): Promise { + if (route === AI_CAPTURE_ROUTE) { + return this.sendAiCaptureBatch(batchMessages, retryOptions) + } + if (this.captureMode !== 'v1' || route === AI_ROUTE) { return super.sendBatch(batchMessages, retryOptions, route) } @@ -465,6 +491,46 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen await this.getV1Sender().sendV1Batch(v1Events) } + private async sendAiCaptureBatch( + batchMessages: (PostHogEventProperties | undefined)[], + retryOptions?: Partial + ): Promise { + const { batches, dropped } = partitionAiBatch(batchMessages) + for (const { event, bytes } of dropped) { + const message = `Event ${event} (${bytes} bytes) exceeds the ${AI_MAX_EVENT_BYTES / (1024 * 1024)}MiB limit for ${AI_CAPTURE_ENDPOINT_PATH}, dropping.` + this._logger.error(message) + this._events.emit('error', new Error(message)) + } + for (const batch of batches) { + await this.sendAiSubBatch(batch, retryOptions) + } + } + + // A single-event 413 means the server's cap is below that event's size, so no split can save it. + private async sendAiSubBatch( + batch: PostHogEventProperties[], + retryOptions?: Partial + ): Promise { + try { + await super.sendBatch(batch, retryOptions, AI_CAPTURE_ROUTE) + } catch (err) { + if (!isPostHogFetchContentTooLargeError(err)) { + throw err + } + if (batch.length === 1) { + const [event] = batch + const eventName = typeof event.event === 'string' ? event.event : 'unknown' + const message = `Event ${eventName} (${eventByteSize(event)} bytes) was rejected with 413 by ${AI_CAPTURE_ENDPOINT_PATH} on its own, dropping.` + this._logger.error(message) + this._events.emit('error', new Error(message)) + return + } + const mid = Math.ceil(batch.length / 2) + await this.sendAiSubBatch(batch.slice(0, mid), retryOptions) + await this.sendAiSubBatch(batch.slice(mid), retryOptions) + } + } + private getV1Sender(): V1CaptureSender { if (!this._v1Sender) { this._v1Sender = new V1CaptureSender( @@ -658,7 +724,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen type: string, props: EventMessage, immediate: boolean, - prepareOptions?: { includeContextProperties?: boolean } + prepareOptions?: { includeContextProperties?: boolean }, + explicitRoute?: string ): Promise { return this.addPendingPromise( this._prepareEventMessage(props, prepareOptions) @@ -677,8 +744,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen }, } return immediate - ? this.sendImmediate(type, message, captureOptions) - : this.enqueue(type, message, captureOptions) + ? this.sendImmediate(type, message, captureOptions, explicitRoute) + : this.enqueue(type, message, captureOptions, explicitRoute) }) .catch((err) => { if (err) { @@ -771,6 +838,54 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen return this._capturePreparedEvent(props, true) } + /** + * Capture an AI event on the dedicated AI capture endpoint. + * + * Beta: the signature is stable; operational limits (per-event size cap, + * batching, endpoint) may change without notice. Delivery is async, and no + * redaction or truncation is applied to the payload. + * + * {@label Capture} + * + * @param props - The event properties + * @returns The event UUID, or `undefined` when the client is disabled + */ + captureAi(props: EventMessage): string | undefined { + if (this.disabled) { + return undefined + } + const uuid = getEventUuid(props.uuid, uuidv7) + void this._sendPreparedAiEvent({ ...props, uuid }, false) + return uuid + } + + /** + * Capture an AI event on the dedicated AI capture endpoint, resolving after + * the send completes. Use in short-lived processes (serverless) where the + * runtime may freeze before a background flush runs. + * + * {@label Capture} + * + * @param props - The event properties + * @returns The event UUID, or `undefined` when the client is disabled + */ + async captureAiImmediate(props: EventMessage): Promise { + if (this.disabled) { + return undefined + } + const uuid = getEventUuid(props.uuid, uuidv7) + await this._sendPreparedAiEvent({ ...props, uuid }, true) + return uuid + } + + private _sendPreparedAiEvent(props: EventMessage, immediate: boolean): Promise { + if (typeof props?.event === 'string' && !props.event.startsWith('$ai_')) { + this._logger.debug(`captureAi called with non-AI event ${props.event}; routing it to the AI endpoint anyway.`) + } + this._aiCaptureRouteActive = true + return this._sendPreparedEvent('capture', props, immediate, undefined, AI_CAPTURE_ROUTE) + } + /** * Identify a user and set their properties. * diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 8963645589..9ae58d5d92 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -336,6 +336,13 @@ export type PostHogOptions = Omit + /** + * @description Capture an AI event on the dedicated AI capture endpoint. + * Beta: the signature is stable; operational limits (per-event size cap, batching, endpoint) may change without notice. Delivery is async, and no redaction or truncation is applied to the payload. + * @param distinctId which uniquely identifies your user + * @param event We recommend using [verb] [noun], like movie played or movie updated to easily identify what your events mean later on. + * @param properties OPTIONAL | which can be a object with any information you'd like to add + * @param groups OPTIONAL | object of what groups are related to this event, example: { company: 'id:5' }. Can be used to analyze companies instead of users. + * @param flags OPTIONAL | A `FeatureFlagEvaluations` snapshot from `evaluateFlags()`. Attaches those exact flag values to the event with no extra network call. + * @param sendFeatureFlags OPTIONAL | Deprecated — prefer `flags`. Fires a hidden `/flags` request on capture to enrich the event with flag values. + * @returns The event UUID, or `undefined` when the client is disabled + */ + captureAi({ distinctId, event, properties, groups, flags, sendFeatureFlags }: EventMessage): string | undefined + + /** + * @description Capture an AI event on the dedicated AI capture endpoint, resolving after the send completes. Use in short-lived processes (serverless) where the runtime may freeze before a background flush runs. + * @param distinctId which uniquely identifies your user + * @param event We recommend using [verb] [noun], like movie played or movie updated to easily identify what your events mean later on. + * @param properties OPTIONAL | which can be a object with any information you'd like to add + * @param groups OPTIONAL | object of what groups are related to this event, example: { company: 'id:5' }. Can be used to analyze companies instead of users. + * @param flags OPTIONAL | A `FeatureFlagEvaluations` snapshot from `evaluateFlags()`. Attaches those exact flag values to the event with no extra network call. + * @param sendFeatureFlags OPTIONAL | Deprecated — prefer `flags`. Fires a hidden `/flags` request on capture to enrich the event with flag values. + * @returns The event UUID, or `undefined` when the client is disabled + */ + captureAiImmediate({ + distinctId, + event, + properties, + groups, + flags, + sendFeatureFlags, + }: EventMessage): Promise + /** * @description Identify lets you add metadata on your users so you can more easily identify who they are in PostHog, * and even do things like segment users by these properties. diff --git a/packages/react-native/references/posthog-react-native-references-latest.json b/packages/react-native/references/posthog-react-native-references-latest.json index bf3ef3be7b..b82bf66390 100644 --- a/packages/react-native/references/posthog-react-native-references-latest.json +++ b/packages/react-native/references/posthog-react-native-references-latest.json @@ -3837,6 +3837,10 @@ "id": "PostHogPersistedProperty", "name": "PostHogPersistedProperty", "properties": [ + { + "type": "\"ai_capture_queue\"", + "name": "AiCaptureQueue" + }, { "type": "\"ai_queue\"", "name": "AiQueue"