Skip to content

Commit 526520a

Browse files
feat(llma): add OTEL manual capture pipeline for AI SDK spans (#3103)
* feat(ai): add OTEL manual capture pipeline for AI SDK spans * add changeset * Update packages/ai/src/otel/processor.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * chore(ai): apply lint formatting fixes * fix(ai): flush pending OTEL captures on forceFlush and shutdown * fix(ai): infer AI SDK version from OTEL span metadata --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent 6015fc5 commit 526520a

File tree

13 files changed

+2064
-3
lines changed

13 files changed

+2064
-3
lines changed

.changeset/purple-toys-enjoy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@posthog/ai': minor
3+
---
4+
5+
Added AI SDK OTEL span processing pipeline

packages/ai/README.md

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PostHog Node AI
22

3-
Initial Typescript SDK for LLM Observability
3+
TypeScript SDK for LLM observability with PostHog.
44

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

@@ -10,7 +10,7 @@ Initial Typescript SDK for LLM Observability
1010
npm install @posthog/ai
1111
```
1212

13-
## Usage
13+
## Direct Provider Usage
1414

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

42+
## OTEL + AI SDK (`experimental_telemetry`)
43+
44+
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`.
45+
46+
```typescript
47+
import { NodeSDK } from '@opentelemetry/sdk-node'
48+
import { PostHog } from 'posthog-node'
49+
import { generateText } from 'ai'
50+
import { openai } from '@ai-sdk/openai'
51+
import { PostHogSpanProcessor } from '@posthog/ai/otel'
52+
53+
const phClient = new PostHog('<YOUR_PROJECT_API_KEY>', { host: 'https://us.i.posthog.com' })
54+
55+
const sdk = new NodeSDK({
56+
spanProcessors: [
57+
new PostHogSpanProcessor(phClient),
58+
],
59+
})
60+
61+
sdk.start()
62+
63+
await generateText({
64+
model: openai('gpt-5.1'),
65+
prompt: 'Write a short haiku about debugging',
66+
experimental_telemetry: {
67+
isEnabled: true,
68+
functionId: 'my-awesome-function',
69+
metadata: {
70+
conversation_id: 'abc123',
71+
plan: 'pro',
72+
},
73+
},
74+
})
75+
76+
await phClient.shutdown()
77+
```
78+
79+
### Custom Mappers
80+
81+
The OTEL processor supports adapter mappers for different span formats:
82+
83+
- `aiSdkSpanMapper` is the default mapper.
84+
- You can pass custom `mappers` in `PostHogSpanProcessor` options to support additional span schemas.
85+
86+
### Per-call Metadata (Recommended)
87+
88+
For dynamic properties, pass values in `experimental_telemetry.metadata` on each AI SDK call.
89+
These are captured from `ai.telemetry.metadata.*` and forwarded as PostHog event properties.
90+
Use processor options (`posthogProperties`) only for global defaults.
91+
92+
## Notes
93+
94+
- The OTEL route currently maps supported spans into PostHog AI events (manual capture path).
95+
- Existing wrapper-based tracing (for example `withTracing`) still works and is unchanged.
96+
4297
LLM Observability [docs](https://posthog.com/docs/ai-engineering/observability)
4398

4499
Please see the main [PostHog docs](https://www.posthog.com/docs).

packages/ai/package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
},
2424
"devDependencies": {
2525
"@ai-sdk/provider": "^3.0.8",
26+
"@opentelemetry/api": "^1.9.0",
27+
"@opentelemetry/sdk-trace-base": "^2.2.0",
2628
"@babel/preset-env": "catalog:",
2729
"@babel/preset-typescript": "catalog:",
2830
"@posthog-tooling/rollup-utils": "workspace:*",
@@ -55,10 +57,18 @@
5557
"zod": "^4.1.13"
5658
},
5759
"peerDependencies": {
60+
"@opentelemetry/api": "^1.9.0",
61+
"@opentelemetry/sdk-trace-base": "^2.2.0",
5862
"@ai-sdk/provider": "^2.0.0 || ^3.0.0",
5963
"posthog-node": "^5.0.0"
6064
},
6165
"peerDependenciesMeta": {
66+
"@opentelemetry/api": {
67+
"optional": true
68+
},
69+
"@opentelemetry/sdk-trace-base": {
70+
"optional": true
71+
},
6272
"@ai-sdk/provider": {
6373
"optional": true
6474
}
@@ -92,6 +102,11 @@
92102
"import": "./dist/vercel/index.mjs",
93103
"types": "./dist/vercel/index.d.ts"
94104
},
105+
"./otel": {
106+
"require": "./dist/otel/index.cjs",
107+
"import": "./dist/otel/index.mjs",
108+
"types": "./dist/otel/index.d.ts"
109+
},
95110
"./langchain": {
96111
"require": "./dist/langchain/index.cjs",
97112
"import": "./dist/langchain/index.mjs",

packages/ai/rollup.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ configs.push({
3333
})
3434

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

3838
providers.forEach((provider) => {
3939
configs.push({

packages/ai/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import PostHogOpenAI from './openai'
22
import PostHogAzureOpenAI from './openai/azure'
33
import { wrapVercelLanguageModel } from './vercel/middleware'
4+
import { PostHogSpanProcessor, createPostHogSpanProcessor, captureSpan } from './otel'
45
import PostHogAnthropic from './anthropic'
56
import PostHogGoogleGenAI from './gemini'
67
import { LangChainCallbackHandler } from './langchain/callbacks'
@@ -11,5 +12,6 @@ export { PostHogAzureOpenAI as AzureOpenAI }
1112
export { PostHogAnthropic as Anthropic }
1213
export { PostHogGoogleGenAI as GoogleGenAI }
1314
export { wrapVercelLanguageModel as withTracing }
15+
export { PostHogSpanProcessor, createPostHogSpanProcessor, captureSpan }
1416
export { LangChainCallbackHandler }
1517
export { Prompts }

packages/ai/src/otel/capture.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { v4 as uuidv4 } from 'uuid'
2+
import { PostHog } from 'posthog-node'
3+
import { sendEventToPosthog, sendEventWithErrorToPosthog } from '../utils'
4+
import { defaultSpanMappers } from './mappers'
5+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'
6+
import type { PostHogTelemetryOptions, PostHogSpanMapper, UsageData } from './types'
7+
8+
function pickMapper(span: ReadableSpan, mappers: PostHogSpanMapper[]): PostHogSpanMapper | undefined {
9+
return mappers.find((mapper) => {
10+
try {
11+
return mapper.canMap(span)
12+
} catch {
13+
return false
14+
}
15+
})
16+
}
17+
18+
function getTraceId(span: ReadableSpan, options: PostHogTelemetryOptions, mapperTraceId?: string): string {
19+
if (mapperTraceId) {
20+
return mapperTraceId
21+
}
22+
if (options.posthogTraceId) {
23+
return options.posthogTraceId
24+
}
25+
const spanTraceId = span.spanContext?.().traceId
26+
return spanTraceId || uuidv4()
27+
}
28+
29+
function buildPosthogParams(
30+
options: PostHogTelemetryOptions,
31+
traceId: string,
32+
distinctId: string | undefined,
33+
modelParams: Record<string, unknown>,
34+
posthogProperties: Record<string, unknown>
35+
): Record<string, unknown> {
36+
return {
37+
...modelParams,
38+
posthogDistinctId: distinctId,
39+
posthogTraceId: traceId,
40+
posthogProperties,
41+
posthogPrivacyMode: options.posthogPrivacyMode,
42+
posthogGroups: options.posthogGroups,
43+
posthogModelOverride: options.posthogModelOverride,
44+
posthogProviderOverride: options.posthogProviderOverride,
45+
posthogCostOverride: options.posthogCostOverride,
46+
posthogCaptureImmediate: options.posthogCaptureImmediate,
47+
}
48+
}
49+
50+
export async function captureSpan(
51+
span: ReadableSpan,
52+
phClient: PostHog,
53+
options: PostHogTelemetryOptions = {}
54+
): Promise<void> {
55+
if (options.shouldExportSpan && options.shouldExportSpan({ otelSpan: span }) === false) {
56+
return
57+
}
58+
59+
const mappers = options.mappers ?? defaultSpanMappers
60+
const mapper = pickMapper(span, mappers)
61+
if (!mapper) {
62+
return
63+
}
64+
65+
const mapped = mapper.map(span, { options })
66+
if (!mapped) {
67+
return
68+
}
69+
70+
const traceId = getTraceId(span, options, mapped.traceId)
71+
const distinctId = mapped.distinctId ?? options.posthogDistinctId
72+
const posthogProperties = {
73+
...options.posthogProperties,
74+
...mapped.posthogProperties,
75+
}
76+
77+
const params = buildPosthogParams(options, traceId, distinctId, mapped.modelParams ?? {}, posthogProperties)
78+
const baseURL = mapped.baseURL ?? ''
79+
const usage: UsageData = mapped.usage ?? {}
80+
81+
if (mapped.error !== undefined) {
82+
await sendEventWithErrorToPosthog({
83+
eventType: mapped.eventType,
84+
client: phClient,
85+
distinctId,
86+
traceId,
87+
model: mapped.model,
88+
provider: mapped.provider,
89+
input: mapped.input,
90+
output: mapped.output,
91+
latency: mapped.latency,
92+
baseURL,
93+
params: params as any,
94+
usage,
95+
tools: mapped.tools,
96+
error: mapped.error,
97+
captureImmediate: options.posthogCaptureImmediate,
98+
})
99+
return
100+
}
101+
102+
await sendEventToPosthog({
103+
eventType: mapped.eventType,
104+
client: phClient,
105+
distinctId,
106+
traceId,
107+
model: mapped.model,
108+
provider: mapped.provider,
109+
input: mapped.input,
110+
output: mapped.output,
111+
latency: mapped.latency,
112+
timeToFirstToken: mapped.timeToFirstToken,
113+
baseURL,
114+
params: params as any,
115+
httpStatus: mapped.httpStatus ?? 200,
116+
usage,
117+
tools: mapped.tools,
118+
captureImmediate: options.posthogCaptureImmediate,
119+
})
120+
}

packages/ai/src/otel/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export { captureSpan } from './capture'
2+
export { PostHogSpanProcessor, createPostHogSpanProcessor } from './processor'
3+
export { aiSdkSpanMapper } from './mappers'
4+
export type {
5+
PostHogTelemetryOptions,
6+
PostHogReadableSpan,
7+
PostHogTelemetrySpanProcessor,
8+
PostHogSpanMapper,
9+
PostHogSpanMapperResult,
10+
ShouldExportSpan,
11+
} from './types'

0 commit comments

Comments
 (0)