Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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/honest-lions-observe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@posthog/ai': minor
---

Add LangChain v1 agent middleware for AI observability.
24 changes: 17 additions & 7 deletions packages/ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"scripts": {
"clean": "rimraf dist",
"test:unit": "jest && pnpm test:module-load",
"test:module-load": "node tests/otel-module-load.cjs",
"test:module-load": "node tests/otel-module-load.cjs && node tests/langchain-module-load.cjs",
"lint": "eslint src tests",
"lint:fix": "eslint src tests --fix",
"build": "rollup -c",
Expand All @@ -25,22 +25,23 @@
"devDependencies": {
"@ai-sdk/provider": "^3.0.14",
"@anthropic-ai/sdk": "^0.116.0",
"@babel/preset-env": "catalog:",
"@babel/preset-typescript": "catalog:",
"@google/genai": "^1.52.0",
"@langchain/core": "^1.2.5",
"openai": "^6.49.0",
"@openai/agents": "^0.8.0",
"@openai/agents-core": "^0.8.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
"@opentelemetry/otlp-exporter-base": "^0.200.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@babel/preset-env": "catalog:",
"@babel/preset-typescript": "catalog:",
"@posthog-tooling/rollup-utils": "workspace:*",
"@posthog-tooling/tsconfig-base": "workspace:*",
"@types/jest": "catalog:",
"jest": "catalog:",
"langchain": "^1.5.5",
"node-fetch": "^3.3.2",
"openai": "^6.49.0",
"posthog-node": "workspace:^"
},
"keywords": [
Expand All @@ -62,15 +63,16 @@
"zod": "^4.1.13"
},
"peerDependencies": {
"@ai-sdk/provider": "^2.0.0 || ^3.0.0 || ^4.0.0",
"@anthropic-ai/sdk": "^0.112.3",
"@google/genai": "^1.52.0",
"@langchain/core": "^1.2.3",
"openai": "^6.48.0",
"@openai/agents": "^0.8.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": ">=0.200.0 <1.0.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@ai-sdk/provider": "^2.0.0 || ^3.0.0 || ^4.0.0",
"@openai/agents": "^0.8.0",
"langchain": ">=1.5.5 <2.0.0",
"openai": "^6.48.0",
"posthog-node": "^5.0.0"
},
"peerDependenciesMeta": {
Expand All @@ -83,6 +85,9 @@
"@langchain/core": {
"optional": true
},
"langchain": {
"optional": true
},
"openai": {
"optional": true
},
Expand Down Expand Up @@ -141,6 +146,11 @@
"import": "./dist/langchain/index.mjs",
"types": "./dist/langchain/index.d.ts"
},
"./langchain/middleware": {
"require": "./dist/langchain/middleware/index.cjs",
"import": "./dist/langchain/middleware/index.mjs",
"types": "./dist/langchain/middleware/index.d.ts"
},
"./openai-agents": {
"require": "./dist/openai-agents/index.cjs",
"import": "./dist/openai-agents/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 @@ -36,7 +36,7 @@ configs.push({
})

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

providers.forEach((provider) => {
configs.push({
Expand Down
26 changes: 16 additions & 10 deletions packages/ai/src/langchain/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ type RunMetadata = SpanMetadata | GenerationMetadata
/** Storage for run metadata */
type RunMetadataStorage = { [runId: string]: RunMetadata }

export interface LangChainCallbackHandlerOptions {
client: PostHog
distinctId?: string | number
traceId?: string | number
properties?: Record<string, any>
privacyMode?: boolean
groups?: Record<string, any>
debug?: boolean
}

export class LangChainCallbackHandler extends BaseCallbackHandler {
public name = 'PosthogCallbackHandler'
private client: PostHog
Expand All @@ -66,15 +76,7 @@ export class LangChainCallbackHandler extends BaseCallbackHandler {
private runs: RunMetadataStorage = {}
private parentTree: { [runId: string]: string } = {}

constructor(options: {
client: PostHog
distinctId?: string | number
traceId?: string | number
properties?: Record<string, any>
privacyMode?: boolean
groups?: Record<string, any>
debug?: boolean
}) {
constructor(options: LangChainCallbackHandlerOptions) {
if (!options.client) {
throw new Error('PostHog client is required')
}
Expand All @@ -98,11 +100,15 @@ export class LangChainCallbackHandler extends BaseCallbackHandler {
tags?: string[],
metadata?: Record<string, unknown>,
_runType?: string,
runName?: string
runName?: string,
extra?: Record<string, unknown>
): void {
this._logDebugEvent('on_chain_start', runId, parentRunId, { inputs, tags })
this._setParentOfRun(runId, parentRunId)
this._setTraceOrSpanMetadata(chain, inputs, runId, parentRunId, metadata, tags, runName)
if (typeof extra?.posthogStartTime === 'number' && Number.isFinite(extra.posthogStartTime)) {
this.runs[runId].startTime = extra.posthogStartTime
}
}

public handleChainEnd(
Expand Down
197 changes: 197 additions & 0 deletions packages/ai/src/langchain/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { AIMessage, BaseMessage, ToolMessage } from '@langchain/core/messages'
import type { ChatGeneration, LLMResult } from '@langchain/core/outputs'
import type { Serialized } from '@langchain/core/load/serializable'
import { createMiddleware } from 'langchain'
import { v7 as uuidv7 } from 'uuid'
import { z } from 'zod'
import { toContentString } from '../../utils'
import { LangChainCallbackHandler, LangChainCallbackHandlerOptions } from '../callbacks'

const postHogStateSchema = z.object({
_posthogRunId: z.string().optional(),
_posthogStartTime: z.number().optional(),
_posthogInput: z.record(z.string(), z.unknown()).optional(),
})

type PostHogState = z.infer<typeof postHogStateSchema>

const withoutPostHogState = <T extends Record<string, unknown>>(state: T): Omit<T, keyof PostHogState> => {
const { _posthogRunId: _, _posthogStartTime: __, _posthogInput: ___, ...rest } = state
return rest
}

const getRunId = (state: PostHogState): string => state._posthogRunId ?? uuidv7()

const stringify = (value: unknown): string => {
try {
return JSON.stringify(value) ?? String(value)
} catch {
try {
return String(value)
} catch {
return ''
}
}
}

const toError = (error: unknown): Error => (error instanceof Error ? error : new Error(stringify(error)))

const safely = (callback: () => void): void => {
try {
callback()
} catch {
// Telemetry must never affect the LangChain middleware lifecycle.
}
}

const serializeModel = (model: unknown): Serialized => {
if (model && typeof model === 'object' && 'toJSON' in model && typeof model.toJSON === 'function') {
try {
return model.toJSON() as Serialized
} catch {
// Fall back to a minimal LangChain serialization below.
}
}

return { lc: 1, type: 'constructor', id: ['langchain', 'chat_models', 'unknown'], kwargs: {} }
}

const getModelMetadata = (model: unknown, modelSettings: unknown): Record<string, unknown> | undefined => {
if (model && typeof model === 'object' && 'getLsParams' in model && typeof model.getLsParams === 'function') {
try {
return model.getLsParams(modelSettings) as Record<string, unknown>
} catch {
return undefined
}
}
return undefined
}

const toLLMResult = (response: unknown): LLMResult => {
if (!AIMessage.isInstance(response)) {
return { generations: [] }
}

const generation: ChatGeneration = {
text: toContentString(response.content),
message: response,
}
return { generations: [[generation]] }
}

/** Options shared with the LangChain callback integration. */
export type PostHogLangChainMiddlewareOptions = LangChainCallbackHandlerOptions

/**
* Creates PostHog AI observability middleware for LangChain v1 agents.
*
* Use either this middleware or `LangChainCallbackHandler`, not both, to avoid
* capturing the same model and tool calls twice.
*
* LangChain only invokes `afterAgent` for completed runs. A terminal agent
* failure still captures the failed model or tool call, but not a root trace.
*/
export const createPostHogMiddleware = (options: PostHogLangChainMiddlewareOptions) => {
const callback = new LangChainCallbackHandler(options)

return createMiddleware({
name: 'PostHogMiddleware',
stateSchema: postHogStateSchema,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: Preserve custom agent state in traces — LangChain projects lifecycle-hook state through this middleware schema, which only declares the _posthog* fields plus built-ins. With a custom createAgent({ stateSchema }), fields such as tenant or workflow context remain in the agent result but are silently absent from $ai_input_state and $ai_output_state; a custom-state probe reproduced the omission.


beforeAgent: (state) => {
return {
_posthogRunId: uuidv7(),
_posthogStartTime: Date.now(),
_posthogInput: withoutPostHogState(state),
}
},

afterAgent: (state) => {
safely(() => {
const runId = getRunId(state)
callback.handleChainStart(
{ lc: 1, type: 'constructor', id: ['langchain', 'agents', 'PostHogMiddleware'], kwargs: {} },
state._posthogInput ?? withoutPostHogState(state),
runId,
undefined,
undefined,
undefined,
undefined,
'LangChain Agent',
{ posthogStartTime: state._posthogStartTime }
)
callback.handleChainEnd(withoutPostHogState(state), runId)
})
},

wrapModelCall: async (request, handler) => {
const runId = uuidv7()
const parentRunId = getRunId(request.state)
Comment thread
gouveags marked this conversation as resolved.
const messages = [request.systemMessage, ...request.messages].filter(
(message): message is BaseMessage => message !== undefined
)
const invocationParams = {
...request.modelSettings,
tools: request.tools,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: Normalize tools before capture — request.tools contains DynamicStructuredTool instances, so standard tool(...) values serialize in $ai_tools as a generic {"type":"not_implemented","id":["langchain","tools","DynamicStructuredTool"]}, losing the actual name, description, and schema. Normalize these instances to capture-ready definitions before passing them to the callback handler.

}

safely(() =>
callback.handleChatModelStart(
serializeModel(request.model),
[messages],
runId,
parentRunId,
{ invocation_params: invocationParams },
undefined,
getModelMetadata(request.model, request.modelSettings)
)
)

try {
const response = await handler(request)
safely(() => callback.handleLLMEnd(toLLMResult(response), runId, parentRunId))
return response
} catch (error) {
safely(() => callback.handleLLMError(toError(error), runId, parentRunId))
throw error
}
},

wrapToolCall: async (request, handler) => {
const runId = uuidv7()
const parentRunId = getRunId(request.state)
const toolName = String(request.tool?.name ?? request.toolCall.name)
const serializedTool: Serialized = {
lc: 1,
type: 'constructor',
id: ['langchain', 'tools', toolName],
kwargs: {},
}

safely(() =>
callback.handleToolStart(
serializedTool,
stringify(request.toolCall.args),
runId,
parentRunId,
undefined,
undefined,
toolName
)
)

try {
const result = await handler(request)
if (ToolMessage.isInstance(result) && result.status === 'error') {
safely(() => callback.handleToolError(new Error(toContentString(result.content)), runId, parentRunId))
} else {
safely(() => callback.handleToolEnd(result, runId, parentRunId))
}
return result
} catch (error) {
safely(() => callback.handleToolError(toError(error), runId, parentRunId))
throw error
}
},
})
}
Loading