diff --git a/.changeset/langchain-run-name-string.md b/.changeset/langchain-run-name-string.md new file mode 100644 index 0000000000..c501d7b1a3 --- /dev/null +++ b/.changeset/langchain-run-name-string.md @@ -0,0 +1,5 @@ +--- +'@posthog/ai': patch +--- + +Fix LangChain spans being named after their class instead of the runnable. LangChain passes `runName` as a bare string, which the name resolver skipped because it only inspected object arguments, so every tool span was captured as `DynamicStructuredTool` rather than the tool's own name. diff --git a/packages/ai/src/langchain/callbacks.ts b/packages/ai/src/langchain/callbacks.ts index ef173bb17c..fb325d3e4b 100644 --- a/packages/ai/src/langchain/callbacks.ts +++ b/packages/ai/src/langchain/callbacks.ts @@ -562,10 +562,17 @@ export class LangChainCallbackHandler extends BaseCallbackHandler { private _getLangchainRunName(serialized: any, ...args: any): string | undefined { if (args && args.length > 0) { for (const arg of args) { - if (arg && typeof arg === 'object' && 'name' in arg) { - return arg.name - } else if (arg && typeof arg === 'object' && 'runName' in arg) { - return arg.runName + // LangChain hands runName through as a bare string, not wrapped in an object + if (typeof arg === 'string' && arg) { + return arg + } + if (arg && typeof arg === 'object') { + if (arg.name) { + return arg.name + } + if (arg.runName) { + return arg.runName + } } } } diff --git a/packages/ai/tests/callbacks.test.ts b/packages/ai/tests/callbacks.test.ts index 0d4134a11e..2c37a99be3 100644 --- a/packages/ai/tests/callbacks.test.ts +++ b/packages/ai/tests/callbacks.test.ts @@ -687,6 +687,29 @@ describe('LangChainCallbackHandler', () => { }) }) +describe('LangChainCallbackHandler span naming', () => { + it('names a tool span from runName rather than the serialized class', () => { + const handler = new LangChainCallbackHandler({ client: mockPostHogClient }) + jest.clearAllMocks() + + const serialized = { + lc: 1, + type: 'constructor' as const, + id: ['langchain', 'tools', 'DynamicStructuredTool'], + kwargs: {}, + } + const runId = 'run_tool_name' + + // LangChain calls this with (tool, input, runId, parentRunId, tags, metadata, runName) + handler.handleToolStart(serialized, '{"city":"Paris"}', runId, 'parent_run', [], {}, 'get_weather') + handler.handleToolEnd('sunny', runId, 'parent_run') + + const [captureCall] = (mockPostHogClient.capture as jest.Mock).mock.calls + expect(captureCall[0].event).toBe('$ai_span') + expect(captureCall[0].properties['$ai_span_name']).toBe('get_weather') + }) +}) + describe('LangChainCallbackHandler trace/span state sanitization', () => { it('redacts base64 data URLs from $ai_input_state and $ai_output_state', () => { const handler = new LangChainCallbackHandler({ client: mockPostHogClient })