Skip to content

Commit fcebfb9

Browse files
committed
feat(server-utils): Rewrite LangChain integration to orchestrion
Rewrites the LangChain integration to a diagnostics-channel listener instead of `InstrumentationBase`, with orchestrion injecting the channels. Chat models are hooked once on `@langchain/core`'s `BaseChatModel` (`invoke` + `_streamIterator`), which every provider class inherits, so a single module covers all providers. The listener injects the Sentry callback handler into the call options, and LangChain's own callback dispatch creates the spans exactly as before, so the span output is identical. Embeddings have no shared base method, so they're hooked per provider (`@langchain/openai` for now) and get their span via `bindTracingChannelToSpan`, reusing the same core span builder as the OTel path. The OTel path stays as the fallback when orchestrion isn't injected. The existing node-integration suite runs against both paths under `INJECT_ORCHESTRION`, proving parity for langchain v0.3 and v1.
1 parent abacdbf commit fcebfb9

5 files changed

Lines changed: 205 additions & 29 deletions

File tree

packages/core/src/shared-exports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export { instrumentStream as instrumentGoogleGenAIStream } from './tracing/googl
216216
export { GOOGLE_GENAI_INTEGRATION_NAME } from './tracing/google-genai/constants';
217217
export type { GoogleGenAIResponse } from './tracing/google-genai/types';
218218
export { createLangChainCallbackHandler, instrumentLangChainEmbeddings } from './tracing/langchain';
219+
export { _INTERNAL_getLangChainEmbeddingsSpanOptions } from './tracing/langchain/embeddings';
219220
export { _INTERNAL_mergeLangChainCallbackHandler } from './tracing/langchain/utils';
220221
export { LANGCHAIN_INTEGRATION_NAME } from './tracing/langchain/constants';
221222
export type { LangChainOptions, LangChainIntegration } from './tracing/langchain/types';

packages/core/src/tracing/langchain/embeddings.ts

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,32 @@ function extractEmbeddingAttributes(instance: unknown): Record<string, unknown>
5555
return attributes;
5656
}
5757

58+
/**
59+
* Builds the span options for a LangChain embedding call from the embeddings instance and input.
60+
*
61+
* @internal Exported so the diagnostics-channel (orchestrion) instrumentation can build the same
62+
* span as the prototype-patching path below.
63+
*/
64+
export function _INTERNAL_getLangChainEmbeddingsSpanOptions(
65+
instance: unknown,
66+
input: unknown,
67+
options: LangChainOptions = {},
68+
): { name: string; op: string; attributes: Record<string, SpanAttributeValue> } {
69+
const { recordInputs } = resolveAIRecordingOptions(options);
70+
const attributes = extractEmbeddingAttributes(instance);
71+
const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] || 'unknown';
72+
73+
if (recordInputs && input != null) {
74+
attributes[GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE] = typeof input === 'string' ? input : JSON.stringify(input);
75+
}
76+
77+
return {
78+
name: `embeddings ${modelName}`,
79+
op: GEN_AI_EMBEDDINGS_OPERATION_ATTRIBUTE,
80+
attributes: attributes as Record<string, SpanAttributeValue>,
81+
};
82+
}
83+
5884
/**
5985
* Wraps a LangChain embedding method (embedQuery or embedDocuments) to create Sentry spans.
6086
*
@@ -64,35 +90,16 @@ export function instrumentEmbeddingMethod(
6490
originalMethod: (...args: unknown[]) => Promise<unknown>,
6591
options: LangChainOptions = {},
6692
): (...args: unknown[]) => Promise<unknown> {
67-
const { recordInputs } = resolveAIRecordingOptions(options);
68-
6993
return new Proxy(originalMethod, {
7094
apply(target, thisArg, args: unknown[]): Promise<unknown> {
71-
const attributes = extractEmbeddingAttributes(thisArg);
72-
const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] || 'unknown';
73-
74-
if (recordInputs) {
75-
const input = args[0];
76-
if (input != null) {
77-
attributes[GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE] = typeof input === 'string' ? input : JSON.stringify(input);
78-
}
79-
}
80-
81-
return startSpan(
82-
{
83-
name: `embeddings ${modelName}`,
84-
op: GEN_AI_EMBEDDINGS_OPERATION_ATTRIBUTE,
85-
attributes: attributes as Record<string, SpanAttributeValue>,
86-
},
87-
() => {
88-
return Reflect.apply(target, thisArg, args).then(undefined, error => {
89-
captureException(error, {
90-
mechanism: { handled: false, type: 'auto.ai.langchain' },
91-
});
92-
throw error;
95+
return startSpan(_INTERNAL_getLangChainEmbeddingsSpanOptions(thisArg, args[0], options), () => {
96+
return Reflect.apply(target, thisArg, args).then(undefined, error => {
97+
captureException(error, {
98+
mechanism: { handled: false, type: 'auto.ai.langchain' },
9399
});
94-
},
95-
);
100+
throw error;
101+
});
102+
});
96103
},
97104
}) as (...args: unknown[]) => Promise<unknown>;
98105
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import * as diagnosticsChannel from 'node:diagnostics_channel';
2+
import type { IntegrationFn, LangChainOptions, Span } from '@sentry/core';
3+
import {
4+
_INTERNAL_getLangChainEmbeddingsSpanOptions,
5+
_INTERNAL_mergeLangChainCallbackHandler,
6+
_INTERNAL_skipAiProviderWrapping,
7+
ANTHROPIC_AI_INTEGRATION_NAME,
8+
createLangChainCallbackHandler,
9+
debug,
10+
defineIntegration,
11+
GOOGLE_GENAI_INTEGRATION_NAME,
12+
LANGCHAIN_INTEGRATION_NAME,
13+
OPENAI_INTEGRATION_NAME,
14+
startInactiveSpan,
15+
waitForTracingChannelBinding,
16+
} from '@sentry/core';
17+
import { DEBUG_BUILD } from '../../debug-build';
18+
import { CHANNELS } from '../../orchestrion/channels';
19+
import { bindTracingChannelToSpan } from '../../tracing-channel';
20+
21+
// Same name as the OTel integration by design: when enabled, the OTel 'LangChain' integration is
22+
// dropped from the default set (see the Node opt-in loader).
23+
const INTEGRATION_NAME = LANGCHAIN_INTEGRATION_NAME;
24+
25+
// LangChain drives the underlying AI provider SDKs itself, so while it's active those providers must
26+
// not also instrument, or every call would produce two spans (mirrors the OTel path's skip list).
27+
const SKIPPED_PROVIDERS = [OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME, GOOGLE_GENAI_INTEGRATION_NAME];
28+
29+
// The chat-model channels carry the live args array of `invoke(input, options)` / `_streamIterator(input, options)`.
30+
interface RunnableChannelContext {
31+
arguments: unknown[];
32+
}
33+
34+
// The embeddings channels carry the instance (`self`) and the `embedQuery(text)` / `embedDocuments(texts)` args.
35+
interface EmbeddingsChannelContext {
36+
self?: unknown;
37+
arguments: unknown[];
38+
}
39+
40+
let subscribed = false;
41+
42+
// Registered lazily on the first LangChain call (not at `setupOnce`) so a direct provider call made
43+
// before any LangChain call still gets its own span — matches the OTel patch-on-import timing. It
44+
// also stops the underlying SDK from double-instrumenting embeddings, whose `embedQuery`/
45+
// `embedDocuments` call the provider SDK (e.g. `openai`) internally.
46+
function markProvidersSkipped(): void {
47+
_INTERNAL_skipAiProviderWrapping(SKIPPED_PROVIDERS);
48+
}
49+
50+
const _langChainChannelIntegration = ((options: LangChainOptions = {}) => {
51+
return {
52+
name: INTEGRATION_NAME,
53+
setupOnce() {
54+
// `tracingChannel` is unavailable before Node 18.19, and a second `init()` would double-subscribe.
55+
if (!diagnosticsChannel.tracingChannel || subscribed) {
56+
return;
57+
}
58+
subscribed = true;
59+
60+
// One stateful handler tracks spans across the whole run tree, just like the OTel path.
61+
const sentryHandler = createLangChainCallbackHandler(options);
62+
63+
// Chat models: inject the Sentry callback handler into the call options (arg 1). LangChain's own
64+
// callback dispatch then creates the spans, exactly as in the OTel path, so no span is opened
65+
// here — a `start` subscriber (which also makes orchestrion wrap the function) is enough.
66+
const injectHandler = (message: unknown): void => {
67+
markProvidersSkipped();
68+
69+
const args = (message as RunnableChannelContext).arguments;
70+
if (!Array.isArray(args)) {
71+
return;
72+
}
73+
74+
let callOptions = args[1] as Record<string, unknown> | undefined;
75+
if (!callOptions || typeof callOptions !== 'object' || Array.isArray(callOptions)) {
76+
callOptions = {};
77+
args[1] = callOptions;
78+
}
79+
80+
callOptions.callbacks = _INTERNAL_mergeLangChainCallbackHandler(callOptions.callbacks, sentryHandler);
81+
};
82+
83+
for (const channelName of [CHANNELS.LANGCHAIN_CHAT_MODEL_INVOKE, CHANNELS.LANGCHAIN_CHAT_MODEL_STREAM]) {
84+
DEBUG_BUILD && debug.log(`[orchestrion:langchain] subscribing to channel "${channelName}"`);
85+
diagnosticsChannel.tracingChannel<RunnableChannelContext>(channelName).start.subscribe(injectHandler);
86+
}
87+
88+
// Embeddings don't use the callback system — the OTel path wraps the method in its own span, so
89+
// do the same here. `bindTracingChannelToSpan` needs the async-context binding that
90+
// `initOpenTelemetry()` registers after `setupOnce`, so wait for it before subscribing.
91+
waitForTracingChannelBinding(() => {
92+
for (const channelName of [CHANNELS.LANGCHAIN_EMBED_QUERY, CHANNELS.LANGCHAIN_EMBED_DOCUMENTS]) {
93+
DEBUG_BUILD && debug.log(`[orchestrion:langchain] subscribing to channel "${channelName}"`);
94+
bindTracingChannelToSpan(
95+
diagnosticsChannel.tracingChannel<EmbeddingsChannelContext>(channelName),
96+
data => createEmbeddingsSpan(data, options),
97+
{ captureError: () => ({ mechanism: { handled: false, type: 'auto.ai.langchain' } }) },
98+
);
99+
}
100+
});
101+
},
102+
};
103+
}) satisfies IntegrationFn;
104+
105+
function createEmbeddingsSpan(data: EmbeddingsChannelContext, options: LangChainOptions): Span {
106+
// `embedQuery`/`embedDocuments` call the provider SDK internally, so skip that SDK's own
107+
// instrumentation before its channel fires (the producer runs at the embeddings channel's `start`).
108+
markProvidersSkipped();
109+
110+
const input = (data.arguments ?? [])[0];
111+
112+
return startInactiveSpan(_INTERNAL_getLangChainEmbeddingsSpanOptions(data.self, input, options));
113+
}
114+
115+
/**
116+
* EXPERIMENTAL — orchestrion-driven LangChain integration. Subscribes to the diagnostics_channels
117+
* injected into `@langchain/core`'s `BaseChatModel` (to inject the Sentry callback handler) and into
118+
* `@langchain/openai`'s embedding methods, so it requires the orchestrion runtime hook or bundler plugin.
119+
*/
120+
export const langChainChannelIntegration = defineIntegration(_langChainChannelIntegration);
Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,51 @@
11
import type { InstrumentationConfig } from '@apm-js-collab/code-transformer';
22

3-
// TODO: Stub for the `langchain` orchestrion integration (ports `SentryLangChainInstrumentation`).
4-
export const langchainConfig: InstrumentationConfig[] = [];
3+
// `@langchain/*` packages ship dual CJS/ESM builds (`.cjs` for `require`, `.js` for `import`) and the
4+
// matcher compares `filePath` exactly, so each hook is declared once per built file.
55

6-
export const langchainChannels = {} as const;
6+
// LangChain's chat model methods live on `BaseChatModel` in `@langchain/core` and are inherited by
7+
// every provider class (`ChatAnthropic`, `ChatOpenAI`, …), so a single hook there covers all
8+
// providers. `invoke` also backs `.batch()` (which calls `invoke` per item); `_streamIterator`
9+
// backs `.stream()`. The vendored OTel instrumentation instead patched each provider package to
10+
// dodge `@langchain/core` being bundled, but orchestrion transforms its source directly regardless
11+
// of bundling.
12+
const chatModelConfig = ['dist/language_models/chat_models.cjs', 'dist/language_models/chat_models.js'].flatMap(
13+
filePath => {
14+
const module = { name: '@langchain/core', versionRange: '>=0.1.0 <2.0.0', filePath };
15+
16+
return [
17+
{
18+
channelName: 'chatModelInvoke',
19+
module,
20+
functionQuery: { className: 'BaseChatModel', methodName: 'invoke', kind: 'Async' as const },
21+
},
22+
{
23+
channelName: 'chatModelStream',
24+
module,
25+
functionQuery: { className: 'BaseChatModel', methodName: '_streamIterator', kind: 'Async' as const },
26+
},
27+
];
28+
},
29+
);
30+
31+
// Embeddings have no shared concrete method on the base class (each provider implements
32+
// `embedQuery`/`embedDocuments`), so they're hooked per provider. Only `@langchain/openai` is wired
33+
// for now; other providers follow the same shape (a class extending `Embeddings` with async
34+
// `embedQuery`/`embedDocuments`).
35+
const embeddingsConfig = ['dist/embeddings.cjs', 'dist/embeddings.js'].flatMap(filePath => {
36+
const module = { name: '@langchain/openai', versionRange: '>=0.1.0 <2.0.0', filePath };
37+
38+
return [
39+
{ channelName: 'embedQuery', module, functionQuery: { methodName: 'embedQuery', kind: 'Async' as const } },
40+
{ channelName: 'embedDocuments', module, functionQuery: { methodName: 'embedDocuments', kind: 'Async' as const } },
41+
];
42+
});
43+
44+
export const langchainConfig = [...chatModelConfig, ...embeddingsConfig] satisfies InstrumentationConfig[];
45+
46+
export const langchainChannels = {
47+
LANGCHAIN_CHAT_MODEL_INVOKE: 'orchestrion:@langchain/core:chatModelInvoke',
48+
LANGCHAIN_CHAT_MODEL_STREAM: 'orchestrion:@langchain/core:chatModelStream',
49+
LANGCHAIN_EMBED_QUERY: 'orchestrion:@langchain/openai:embedQuery',
50+
LANGCHAIN_EMBED_DOCUMENTS: 'orchestrion:@langchain/openai:embedDocuments',
51+
} as const;

packages/server-utils/src/orchestrion/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { koaChannelIntegration } from '../integrations/tracing-channel/koa';
1212
import { ioredisChannelIntegration } from '../integrations/tracing-channel/ioredis';
1313
import { kafkajsChannelIntegration } from '../integrations/tracing-channel/kafkajs';
1414
import { knexChannelIntegration } from '../integrations/tracing-channel/knex';
15+
import { langChainChannelIntegration } from '../integrations/tracing-channel/langchain';
1516
import { langGraphChannelIntegration } from '../integrations/tracing-channel/langgraph';
1617
import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer';
1718
import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql';
@@ -38,6 +39,7 @@ export {
3839
ioredisChannelIntegration,
3940
kafkajsChannelIntegration,
4041
knexChannelIntegration,
42+
langChainChannelIntegration,
4143
langGraphChannelIntegration,
4244
lruMemoizerChannelIntegration,
4345
mysqlChannelIntegration,
@@ -90,6 +92,7 @@ export const channelIntegrations = {
9092
openaiIntegration: openaiChannelIntegration,
9193
anthropicIntegration: anthropicChannelIntegration,
9294
googleGenAIIntegration: googleGenAIChannelIntegration,
95+
langChainIntegration: langChainChannelIntegration,
9396
langGraphIntegration: langGraphChannelIntegration,
9497
vercelAiIntegration: vercelAiChannelIntegration,
9598
amqplibIntegration: amqplibChannelIntegration,

0 commit comments

Comments
 (0)