Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/purple-toys-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@posthog/ai': minor
---

Added AI SDK OTEL span processing pipeline
59 changes: 57 additions & 2 deletions packages/ai/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PostHog Node AI

Initial Typescript SDK for LLM Observability
TypeScript SDK for LLM observability with PostHog.

[SEE FULL DOCS](https://posthog.com/docs/ai-engineering/observability)

Expand All @@ -10,7 +10,7 @@ Initial Typescript SDK for LLM Observability
npm install @posthog/ai
```

## Usage
## Direct Provider Usage

```typescript
import { OpenAI } from '@posthog/ai'
Expand Down Expand Up @@ -39,6 +39,61 @@ console.log(completion.choices[0].message.content)
await phClient.shutdown()
```

## OTEL + AI SDK (`experimental_telemetry`)

Use this when working with Vercel AI SDK telemetry. `@posthog/ai` exposes an OTEL `SpanProcessor` that maps spans to PostHog AI events and sends them through `posthog-node`.

```typescript
import { NodeSDK } from '@opentelemetry/sdk-node'
import { PostHog } from 'posthog-node'
import { generateText } from 'ai'
import { openai } from '@ai-sdk/openai'
import { PostHogSpanProcessor } from '@posthog/ai/otel'

const phClient = new PostHog('<YOUR_PROJECT_API_KEY>', { host: 'https://us.i.posthog.com' })

const sdk = new NodeSDK({
spanProcessors: [
new PostHogSpanProcessor(phClient),
],
})

sdk.start()

await generateText({
model: openai('gpt-5.1'),
prompt: 'Write a short haiku about debugging',
experimental_telemetry: {
isEnabled: true,
functionId: 'my-awesome-function',
metadata: {
conversation_id: 'abc123',
plan: 'pro',
},
},
})

await phClient.shutdown()
```

### Custom Mappers

The OTEL processor supports adapter mappers for different span formats:

- `aiSdkSpanMapper` is the default mapper.
- You can pass custom `mappers` in `PostHogSpanProcessor` options to support additional span schemas.

### Per-call Metadata (Recommended)

For dynamic properties, pass values in `experimental_telemetry.metadata` on each AI SDK call.
These are captured from `ai.telemetry.metadata.*` and forwarded as PostHog event properties.
Use processor options (`posthogProperties`) only for global defaults.

## Notes

- The OTEL route currently maps supported spans into PostHog AI events (manual capture path).
- Existing wrapper-based tracing (for example `withTracing`) still works and is unchanged.

LLM Observability [docs](https://posthog.com/docs/ai-engineering/observability)

Please see the main [PostHog docs](https://www.posthog.com/docs).
Expand Down
15 changes: 15 additions & 0 deletions packages/ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
},
"devDependencies": {
"@ai-sdk/provider": "^3.0.8",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/sdk-trace-base": "^2.2.0",
"@babel/preset-env": "catalog:",
"@babel/preset-typescript": "catalog:",
"@posthog-tooling/rollup-utils": "workspace:*",
Expand Down Expand Up @@ -55,10 +57,18 @@
"zod": "^4.1.13"
},
"peerDependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/sdk-trace-base": "^2.2.0",
"@ai-sdk/provider": "^2.0.0 || ^3.0.0",
"posthog-node": "^5.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@opentelemetry/sdk-trace-base": {
"optional": true
},
"@ai-sdk/provider": {
"optional": true
}
Expand Down Expand Up @@ -92,6 +102,11 @@
"import": "./dist/vercel/index.mjs",
"types": "./dist/vercel/index.d.ts"
},
"./otel": {
"require": "./dist/otel/index.cjs",
"import": "./dist/otel/index.mjs",
"types": "./dist/otel/index.d.ts"
},
"./langchain": {
"require": "./dist/langchain/index.cjs",
"import": "./dist/langchain/index.mjs",
Expand Down
2 changes: 1 addition & 1 deletion packages/ai/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ configs.push({
})

// Add submodule builds for posthog-ai
const providers = ['anthropic', 'openai', 'vercel', 'langchain', 'gemini']
const providers = ['anthropic', 'openai', 'vercel', 'langchain', 'gemini', 'otel']

providers.forEach((provider) => {
configs.push({
Expand Down
2 changes: 2 additions & 0 deletions packages/ai/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import PostHogOpenAI from './openai'
import PostHogAzureOpenAI from './openai/azure'
import { wrapVercelLanguageModel } from './vercel/middleware'
import { PostHogSpanProcessor, createPostHogSpanProcessor, captureSpan } from './otel'
import PostHogAnthropic from './anthropic'
import PostHogGoogleGenAI from './gemini'
import { LangChainCallbackHandler } from './langchain/callbacks'
Expand All @@ -11,5 +12,6 @@ export { PostHogAzureOpenAI as AzureOpenAI }
export { PostHogAnthropic as Anthropic }
export { PostHogGoogleGenAI as GoogleGenAI }
export { wrapVercelLanguageModel as withTracing }
export { PostHogSpanProcessor, createPostHogSpanProcessor, captureSpan }
export { LangChainCallbackHandler }
export { Prompts }
120 changes: 120 additions & 0 deletions packages/ai/src/otel/capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { v4 as uuidv4 } from 'uuid'
import { PostHog } from 'posthog-node'
import { sendEventToPosthog, sendEventWithErrorToPosthog } from '../utils'
import { defaultSpanMappers } from './mappers'
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'
import type { PostHogTelemetryOptions, PostHogSpanMapper, UsageData } from './types'

function pickMapper(span: ReadableSpan, mappers: PostHogSpanMapper[]): PostHogSpanMapper | undefined {
return mappers.find((mapper) => {
try {
return mapper.canMap(span)
} catch {
return false
}
})
}

function getTraceId(span: ReadableSpan, options: PostHogTelemetryOptions, mapperTraceId?: string): string {
if (mapperTraceId) {
return mapperTraceId
}
if (options.posthogTraceId) {
return options.posthogTraceId
}
const spanTraceId = span.spanContext?.().traceId
return spanTraceId || uuidv4()
}

function buildPosthogParams(
options: PostHogTelemetryOptions,
traceId: string,
distinctId: string | undefined,
modelParams: Record<string, unknown>,
posthogProperties: Record<string, unknown>
): Record<string, unknown> {
return {
...modelParams,
posthogDistinctId: distinctId,
posthogTraceId: traceId,
posthogProperties,
posthogPrivacyMode: options.posthogPrivacyMode,
posthogGroups: options.posthogGroups,
posthogModelOverride: options.posthogModelOverride,
posthogProviderOverride: options.posthogProviderOverride,
posthogCostOverride: options.posthogCostOverride,
posthogCaptureImmediate: options.posthogCaptureImmediate,
}
}

export async function captureSpan(
span: ReadableSpan,
phClient: PostHog,
options: PostHogTelemetryOptions = {}
): Promise<void> {
if (options.shouldExportSpan && options.shouldExportSpan({ otelSpan: span }) === false) {
return
}

const mappers = options.mappers ?? defaultSpanMappers
const mapper = pickMapper(span, mappers)
if (!mapper) {
return
}

const mapped = mapper.map(span, { options })
if (!mapped) {
return
}

const traceId = getTraceId(span, options, mapped.traceId)
const distinctId = mapped.distinctId ?? options.posthogDistinctId
const posthogProperties = {
...options.posthogProperties,
...mapped.posthogProperties,
}

const params = buildPosthogParams(options, traceId, distinctId, mapped.modelParams ?? {}, posthogProperties)
const baseURL = mapped.baseURL ?? ''
const usage: UsageData = mapped.usage ?? {}

if (mapped.error !== undefined) {
await sendEventWithErrorToPosthog({
eventType: mapped.eventType,
client: phClient,
distinctId,
traceId,
model: mapped.model,
provider: mapped.provider,
input: mapped.input,
output: mapped.output,
latency: mapped.latency,
baseURL,
params: params as any,
usage,
tools: mapped.tools,
error: mapped.error,
captureImmediate: options.posthogCaptureImmediate,
})
return
}

await sendEventToPosthog({
eventType: mapped.eventType,
client: phClient,
distinctId,
traceId,
model: mapped.model,
provider: mapped.provider,
input: mapped.input,
output: mapped.output,
latency: mapped.latency,
timeToFirstToken: mapped.timeToFirstToken,
baseURL,
params: params as any,
httpStatus: mapped.httpStatus ?? 200,
usage,
tools: mapped.tools,
captureImmediate: options.posthogCaptureImmediate,
})
}
11 changes: 11 additions & 0 deletions packages/ai/src/otel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export { captureSpan } from './capture'
export { PostHogSpanProcessor, createPostHogSpanProcessor } from './processor'
export { aiSdkSpanMapper } from './mappers'
export type {
PostHogTelemetryOptions,
PostHogReadableSpan,
PostHogTelemetrySpanProcessor,
PostHogSpanMapper,
PostHogSpanMapperResult,
ShouldExportSpan,
} from './types'
Loading
Loading