@posthog/ai
Version:
PostHog Node.js AI integrations
509 lines (490 loc) • 17.1 kB
JavaScript
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { ExportResultCode } from '@opentelemetry/core';
import { BatchSpanProcessor } from '@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 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: ExportResultCode.SUCCESS
});
return;
}
const aiSpans = spans.filter(isAISpan);
if (aiSpans.length === 0) {
resultCallback({
code: 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 OTLPTraceExporter({
url: `${host}/i/v0/ai/otel`,
headers: {
Authorization: `Bearer ${token}`
}
});
this.inner = new 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();
}
}
export { PostHogSpanProcessor, PostHogTraceExporter };
//# sourceMappingURL=index.mjs.map