UNPKG

@posthog/ai

Version:
512 lines (492 loc) 17.2 kB
'use strict'; var exporterTraceOtlpHttp = require('@opentelemetry/exporter-trace-otlp-http'); var core = require('@opentelemetry/core'); var sdkTraceBase = require('@opentelemetry/sdk-trace-base'); const DATA_URL_PREFIX_RE = /^data:([^;,\s]+)(?:;[^;,\s]+)*;base64,/i; const BASE64_ALPHABET_RE = /^[A-Za-z0-9+/_=-]+$/; class Base64Recognizer { recognize(value, minLength) { const dataUrl = DATA_URL_PREFIX_RE.exec(value); if (dataUrl) return { kind: 'data-url', mediaType: dataUrl[1] }; if (value.length < minLength) return { kind: 'none' }; const confidencePrefix = value.slice(0, minLength); if (BASE64_ALPHABET_RE.test(confidencePrefix)) { return { kind: 'raw' }; } else { return { kind: 'none' }; } } } const MIME_HINT_KEYS = ['mediaType', 'media_type', 'mimeType', 'mime_type']; const STRONG_CONTEXT_KEYS = new Set(['data', 'file_data', 'fileData', 'image_url', 'imageUrl', 'video_url', 'videoUrl', 'audio', 'audio_data', 'audioData', 'inline_data', 'inlineData', 'source', 'result']); const STRONG_CONTEXT_TYPES = new Set(['image', 'image_url', 'input_image', 'audio', 'input_audio', 'video', 'video_url', 'file', 'input_file', 'document', 'media', 'file-data']); const FILE_FAMILY_TYPES = new Set(['file', 'input_file', 'document', 'media', 'file-data']); const KNOWN_AUDIO_FORMATS = new Set(['wav', 'mp3', 'ogg', 'flac', 'm4a', 'aac', 'webm']); class MediaTypeContext { static EMPTY = new MediaTypeContext(undefined, undefined); constructor(parent, key) { this.parent = parent; this.key = key; } inferMediaType() { return this.inferFromSiblingMime() ?? this.inferFromSiblingFormat() ?? this.inferFromParentType() ?? this.inferFromKey(); } inferFromSiblingMime() { if (!this.parent) return undefined; for (const hint of MIME_HINT_KEYS) { const v = this.parent[hint]; if (typeof v === 'string') return v; } return undefined; } inferFromSiblingFormat() { if (!this.parent) return undefined; const fmt = this.parent.format; if (typeof fmt === 'string' && KNOWN_AUDIO_FORMATS.has(fmt.toLowerCase())) { return `audio/${fmt.toLowerCase()}`; } return undefined; } inferFromParentType() { if (!this.parent) return undefined; const t = this.parent.type; if (typeof t !== 'string') return undefined; if (t === 'image' || t === 'image_url' || t === 'input_image') return 'image'; if (t === 'audio' || t === 'input_audio') return 'audio'; if (t === 'video' || t === 'video_url') return 'video'; if (FILE_FAMILY_TYPES.has(t)) return 'application/octet-stream'; return undefined; } inferFromKey() { if (!this.key) return undefined; const key = this.key.toLowerCase(); if (key.includes('audio')) return 'audio'; if (key.includes('video')) return 'video'; if (key.includes('image')) return 'image'; if (key.includes('file') || key.includes('document')) return 'application/octet-stream'; return undefined; } signalsBinary() { if (this.parent) { for (const hint of MIME_HINT_KEYS) { if (typeof this.parent[hint] === 'string') return true; } const fmt = this.parent.format; if (typeof fmt === 'string' && KNOWN_AUDIO_FORMATS.has(fmt.toLowerCase())) return true; const t = this.parent.type; if (typeof t === 'string' && STRONG_CONTEXT_TYPES.has(t)) return true; } if (this.key && STRONG_CONTEXT_KEYS.has(this.key)) return true; return false; } } const STRONG_CONTEXT_MIN_LENGTH = 64; const WEAK_CONTEXT_MIN_LENGTH = 1024; class BinaryContentRedactor { visited = new WeakSet(); constructor(recognizer = new Base64Recognizer()) { this.recognizer = recognizer; } redact(value) { if (this.isMultimodalEnabled()) return value; this.visited = new WeakSet(); return this.walk(value, MediaTypeContext.EMPTY); } walk(value, ctx) { if (value === null || value === undefined) return value; if (typeof value === 'string') return this.redactString(value, ctx); if (typeof value !== 'object') return value; // Buffer extends Uint8Array, so this branch catches both. if (typeof Uint8Array !== 'undefined' && value instanceof Uint8Array) { return this.placeholderFor(ctx.inferMediaType()); } if (this.visited.has(value)) return null; this.visited.add(value); if (Array.isArray(value)) { return value.map(item => this.walk(item, ctx)); } const obj = value; const out = {}; for (const k of Object.keys(obj)) { out[k] = this.walk(obj[k], new MediaTypeContext(obj, k)); } return out; } redactString(value, ctx) { const minLength = ctx.signalsBinary() ? STRONG_CONTEXT_MIN_LENGTH : WEAK_CONTEXT_MIN_LENGTH; const recognition = this.recognizer.recognize(value, minLength); switch (recognition.kind) { case 'data-url': return this.placeholderFor(recognition.mediaType); case 'raw': return this.placeholderFor(ctx.inferMediaType()); case 'none': return value; } } placeholderFor(mediaType) { if (!mediaType) return '[base64 redacted]'; if (mediaType === 'application/octet-stream') return '[base64 file redacted]'; return `[base64 ${mediaType} redacted]`; } isMultimodalEnabled() { const val = process.env._INTERNAL_LLMA_MULTIMODAL || ''; return val.toLowerCase() === 'true' || val === '1' || val.toLowerCase() === 'yes'; } } const redactor = new BinaryContentRedactor(); function redactSpan(span) { const attributes = span.attributes ? redactAttributes(span.attributes) : span.attributes; const events = span.events ? redactEvents(span.events) : span.events; if (attributes === span.attributes && events === span.events) { return span; } // Copy rather than mutate: the span is shared across every registered processor/exporter. return Object.create(span, { attributes: { value: attributes, enumerable: true, configurable: true }, events: { value: events, enumerable: true, configurable: true } }); } function redactAttributes(attributes) { let changed = false; const out = {}; for (const key of Object.keys(attributes)) { const value = attributes[key]; const redacted = value === undefined ? value : redactAttributeValue(value); if (redacted !== value) { changed = true; } out[key] = redacted; } return changed ? out : attributes; } function redactEvents(events) { let changed = false; const out = events.map(event => { if (!event.attributes) { return event; } const attributes = redactAttributes(event.attributes); if (attributes === event.attributes) { return event; } changed = true; return { ...event, attributes }; }); return changed ? out : events; } function redactAttributeValue(value) { if (typeof value === 'string') { return redactString(value); } if (isStringArray(value)) { let changed = false; const out = value.map(item => { if (typeof item !== 'string') { return item; } const redacted = redactString(item); if (redacted !== item) { changed = true; } return redacted; }); return changed ? out : value; } return value; } function isStringArray(value) { return Array.isArray(value) && value.every(item => typeof item !== 'number' && typeof item !== 'boolean'); } function redactString(value) { const trimmed = value.trimStart(); if (trimmed.startsWith('{') || trimmed.startsWith('[')) { const redacted = redactJson(value); if (redacted !== undefined) { return redacted; } } return redactor.redact(value); } function redactJson(value) { let parsed; try { parsed = JSON.parse(value); } catch { return undefined; } if (parsed === null || typeof parsed !== 'object') { return undefined; } const redactedStr = JSON.stringify(redactor.redact(parsed)); // Preserve the original string (and its formatting) when nothing was redacted. return redactedStr === JSON.stringify(parsed) ? value : redactedStr; } const AI_SPAN_PREFIXES = ['gen_ai.', 'llm.', 'ai.', 'traceloop.']; /** * Returns `true` when the span is AI-related — its name or any attribute * key starts with `gen_ai.`, `llm.`, `ai.`, or `traceloop.`. */ function isAISpan(span) { if (AI_SPAN_PREFIXES.some(prefix => span.name.startsWith(prefix))) { return true; } const attributes = span.attributes; if (attributes) { return Object.keys(attributes).some(key => AI_SPAN_PREFIXES.some(prefix => key.startsWith(prefix))); } return false; } // Warn when a wrapper's base_url points at the PostHog AI Gateway: the gateway // emits its own $ai_generation, so each call would be captured (and, for billable // products, billed) twice. We only warn — the wrapper's event carries data the // gateway never sees (groups, custom properties, trace hierarchy). // Keep in sync with the gateway's deployed hosts (see services/llm-gateway in the // main repo). gateway.us.posthog.com is live today; the rest are listed ahead of // any traffic moving to them. const POSTHOG_AI_GATEWAY_HOSTS = ['gateway.posthog.com', 'gateway.us.posthog.com', 'gateway.eu.posthog.com', 'ai-gateway.us.posthog.com', 'ai-gateway.eu.posthog.com']; // Swap for the dedicated AI Gateway page once it ships. const GATEWAY_DOCS_URL = 'https://posthog.com/docs/ai-observability'; const extractHost = baseURL => { try { // Tolerate bare hosts that omit a scheme, e.g. "gateway.us.posthog.com/v1". const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(baseURL); return new URL(hasScheme ? baseURL : `https://${baseURL}`).hostname.toLowerCase(); } catch { return undefined; } }; const isPostHogAiGatewayUrl = baseURL => { if (!baseURL) { return false; } const host = extractHost(baseURL); return host !== undefined && POSTHOG_AI_GATEWAY_HOSTS.includes(host); }; // Warns on every gateway call by design: the misconfiguration is impossible to // miss that way, and a doubled bill is worse than noisy logs. const warnIfPostHogAiGateway = baseURL => { if (!isPostHogAiGatewayUrl(baseURL)) { return; } console.warn('[PostHog] The PostHog AI wrapper is pointed at the PostHog AI Gateway. ' + 'Both capture $ai_generation, so every call is double-counted and double-billed. ' + `Use one or the other — see ${GATEWAY_DOCS_URL}.`); }; // OTel spans don't pass through captureAiGeneration, so detect the gateway from // the span's host/URL attributes instead. These follow the GenAI / HTTP semantic // conventions: `server.address` is a bare host, `url.full` a full URL, both of // which isPostHogAiGatewayUrl accepts. const OTEL_GATEWAY_URL_ATTRIBUTES = ['server.address', 'url.full']; const warnIfPostHogAiGatewayOtelAttributes = attributes => { if (!attributes) { return; } for (const key of OTEL_GATEWAY_URL_ATTRIBUTES) { const value = attributes[key]; if (typeof value === 'string' && isPostHogAiGatewayUrl(value)) { warnIfPostHogAiGateway(value); return; } } }; const DEFAULT_OTEL_HOST$1 = 'https://us.i.posthog.com'; function normalizeToken$1(value) { return typeof value === 'string' ? value.trim() : ''; } function normalizeHost$1(value) { const normalizedValue = typeof value === 'string' ? value.trim() : ''; return normalizedValue || DEFAULT_OTEL_HOST$1; } /** * Options for the PostHogTraceExporter. `projectToken` is required; a blank token disables the * exporter as a defensive no-op. You can also optionally override the `host` URL. `host` defaults to `https://us.i.posthog.com`. * * @example * ```ts * import { PostHogTraceExporter } from '@posthog/ai/otel' * * new PostHogTraceExporter({ projectToken: 'phc_...' }) * ``` * * @example * ```ts * import { PostHogTraceExporter } from '@posthog/ai/otel' * * new PostHogTraceExporter({ projectToken: 'phc_...', host: 'https://eu.i.posthog.com' }) * ``` */ /** * An OpenTelemetry `TraceExporter` that sends AI traces to PostHog's OTLP * ingestion endpoint. PostHog converts `gen_ai.*` spans into * `$ai_generation` events server-side. * * Only AI-related spans (those whose name or attribute keys start with * `gen_ai.`, `llm.`, `ai.`, or `traceloop.`) are exported; all other * spans are silently dropped. * * Use this when the API you're integrating with only accepts a * `TraceExporter` (e.g. Vercel's `registerOTel`) or when you need to * plug PostHog into an existing processor chain. Otherwise prefer * {@link PostHogSpanProcessor}, which is self-contained. * * `projectToken` is required; a blank token disables the exporter as a defensive no-op. * You can also optionally override the `host` URL. * * @example * ```ts * import { PostHogTraceExporter } from '@posthog/ai/otel' * import { registerOTel } from '@vercel/otel' * * registerOTel({ * serviceName: 'my-app', * traceExporter: new PostHogTraceExporter({ projectToken: 'phc_...' }), * }) * ``` */ class PostHogTraceExporter extends exporterTraceOtlpHttp.OTLPTraceExporter { constructor(options) { const token = normalizeToken$1(options.projectToken); const disabled = !token; const host = token ? new URL(normalizeHost$1(options.host)).origin : DEFAULT_OTEL_HOST$1; super({ url: `${host}/i/v0/ai/otel`, headers: token ? { Authorization: `Bearer ${token}` } : {} }); this.disabled = disabled; if (this.disabled) { console.warn('[PostHogTraceExporter] projectToken is missing or blank; the exporter will be disabled.'); } } export(spans, resultCallback) { if (this.disabled) { // Intentionally report success: missing or blank tokens disable exporting as a compatibility no-op. // Reporting failure would make OpenTelemetry treat every span as an export error. resultCallback({ code: core.ExportResultCode.SUCCESS }); return; } const aiSpans = spans.filter(isAISpan); if (aiSpans.length === 0) { resultCallback({ code: core.ExportResultCode.SUCCESS }); return; } for (const span of aiSpans) { warnIfPostHogAiGatewayOtelAttributes(span.attributes); } super.export(aiSpans.map(redactSpan), resultCallback); } } const DEFAULT_OTEL_HOST = 'https://us.i.posthog.com'; function normalizeToken(value) { return typeof value === 'string' ? value.trim() : ''; } function normalizeHost(value) { const normalizedValue = typeof value === 'string' ? value.trim() : ''; return normalizedValue || DEFAULT_OTEL_HOST; } class NoopSpanProcessor { onStart(_span, _parentContext) { return; } onEnd(_span) { return; } shutdown() { return Promise.resolve(); } forceFlush() { return Promise.resolve(); } } /** * An OpenTelemetry `SpanProcessor` that sends AI traces to PostHog. * * `projectToken` is required; a blank token disables the processor as a defensive no-op. * * Internally batches spans and exports them to PostHog's OTLP ingestion * endpoint. Only AI-related spans (those whose name or attribute keys * start with `gen_ai.`, `llm.`, `ai.`, or `traceloop.`) are exported; * all other spans are silently dropped. * * This is the recommended integration point when your setup accepts a * `SpanProcessor`. If you need a `TraceExporter` instead (e.g. for * Vercel's `registerOTel`), use {@link PostHogTraceExporter}. * * @example * ```ts * import { PostHogSpanProcessor } from '@posthog/ai/otel' * import { NodeSDK } from '@opentelemetry/sdk-node' * * const sdk = new NodeSDK({ * spanProcessors: [new PostHogSpanProcessor({ projectToken: 'phc_...' })], * }) * sdk.start() * ``` */ class PostHogSpanProcessor { constructor(options) { const token = normalizeToken(options.projectToken); if (!token) { console.warn('[PostHogSpanProcessor] projectToken is missing or blank; the processor will be disabled.'); this.inner = new NoopSpanProcessor(); return; } if (options._spanProcessor) { this.inner = options._spanProcessor; } else { const host = new URL(normalizeHost(options.host)).origin; const exporter = new exporterTraceOtlpHttp.OTLPTraceExporter({ url: `${host}/i/v0/ai/otel`, headers: { Authorization: `Bearer ${token}` } }); this.inner = new sdkTraceBase.BatchSpanProcessor(exporter); } } onStart(span, parentContext) { // Forwarded unconditionally — filtering happens in onEnd. We can't filter // here because the span hasn't finished yet and may not have AI attributes // set. BatchSpanProcessor.onStart is a no-op so this is safe. this.inner.onStart(span, parentContext); } onEnd(span) { if (isAISpan(span)) { warnIfPostHogAiGatewayOtelAttributes(span.attributes); this.inner.onEnd(redactSpan(span)); } } shutdown() { return this.inner.shutdown(); } forceFlush() { return this.inner.forceFlush(); } } exports.PostHogSpanProcessor = PostHogSpanProcessor; exports.PostHogTraceExporter = PostHogTraceExporter; //# sourceMappingURL=index.cjs.map