Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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/langchain-capture-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"braintrust": minor
---

feat(langchain): Add capture controls
10 changes: 10 additions & 0 deletions e2e/scenarios/langchain-anthropic/capture-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { configureInstrumentation } from "braintrust/instrumentation";

export const captureOptions = {
captureChainInputs: false,
captureChainOutputs: false,
captureModelInputs: false,
captureModelOutputs: false,
captureModelTokenUsage: false,
};
configureInstrumentation({ langchain: captureOptions });
8 changes: 7 additions & 1 deletion e2e/scenarios/langchain-anthropic/scenario.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
const { ChatAnthropic } = await import(
process.env.LANGCHAIN_ANTHROPIC_PACKAGE_NAME
);
const captureOptions =
process.env.DISABLE_LANGCHAIN_CAPTURE === "1"
? (await import("./capture-config.mjs")).captureOptions
: {};
const MODEL = "claude-haiku-4-5-20251001";

runMain(async () => {
Expand All @@ -20,7 +24,9 @@ runMain(async () => {
for (const mode of ["manual", "auto"]) {
// The hook respects an explicit handler and injects one when absent.
const callbacks =
mode === "manual" ? [new BraintrustLangChainCallbackHandler()] : [];
mode === "manual"
? [new BraintrustLangChainCallbackHandler(captureOptions)]
: [];
for (const operation of ["invoke", "streaming-invoke", "stream"]) {
await runOperation(
`anthropic-${mode}-${operation}`,
Expand Down
79 changes: 79 additions & 0 deletions e2e/scenarios/langchain-anthropic/scenario.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,82 @@ for (const dependencyName of [
},
);
}

for (const dependencyName of [
"langchain-anthropic-v1",
"langchain-anthropic-v1-latest",
]) {
test(
`Anthropic retains streaming timing without model payloads or usage (${dependencyName})`,
{ timeout: TIMEOUT_MS },
async () => {
await withScenarioHarness(async ({ events, runNodeScenarioDir }) => {
await runNodeScenarioDir({
scenarioDir,
entry: "scenario.mjs",
timeoutMs: TIMEOUT_MS,
nodeArgs: [
"--import",
"./capture-config.mjs",
"--import",
"braintrust/hook.mjs",
],
env: {
LANGCHAIN_ANTHROPIC_PACKAGE_NAME: dependencyName,
DISABLE_LANGCHAIN_CAPTURE: "1",
},
runContext: { variantKey: dependencyName, originalScenarioDir },
});
const captured = events();
for (const mode of ["manual", "auto"]) {
for (const operation of ["invoke", "streaming-invoke", "stream"]) {
const parent = findLatestSpan(
captured,
`anthropic-${mode}-${operation}`,
);
const spans = findChildSpans(
captured,
"ChatAnthropic",
parent?.span.id,
);
expect(spans).toHaveLength(1);
const span = spans[0];
expect(span.input).toBeUndefined();
expect(span.output).toBeUndefined();
expect(span.row.metadata).toMatchObject({
model: "claude-haiku-4-5-20251001",
});
expect(
Object.keys(span.metrics ?? {}).filter((key) =>
key.includes("tokens"),
),
).toEqual([]);
expect(span.metrics?.end).toBeTypeOf("number");
if (operation !== "invoke") {
expect(span.metrics?.time_to_first_token).toBeGreaterThanOrEqual(
0,
);
}
const provider = captured.filter(
(event) =>
event.span.name === "anthropic.messages.create" &&
event.span.parentIds.includes(parent!.span.id!) &&
typeof event.metrics?.tokens === "number" &&
event.metrics.tokens > 0,
);
expect(provider).toHaveLength(1);
expect(provider[0].input).toBeDefined();
expect(provider[0].output).toBeDefined();
}
}
expect(
captured.filter(
(event) =>
typeof event.metrics?.tokens === "number" &&
event.metrics.tokens > 0,
),
).toHaveLength(6);
});
},
);
}
10 changes: 10 additions & 0 deletions e2e/scenarios/langgraph-auto-instrumentation/capture-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { configureInstrumentation } from "braintrust/instrumentation";

export const captureOptions = {
captureChainInputs: false,
captureChainOutputs: false,
captureModelInputs: false,
captureModelOutputs: false,
captureModelTokenUsage: false,
};
configureInstrumentation({ langchain: captureOptions });
75 changes: 75 additions & 0 deletions e2e/scenarios/langgraph-auto-instrumentation/scenario.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
resolveScenarioDir,
withScenarioHarness,
} from "../../helpers/scenario-harness";
import { findLatestSpan } from "../../helpers/trace-selectors";
import { matchSpanTreeSnapshot } from "../../helpers/span-tree";
import { assertLangGraphAutoInstrumentation } from "./assertions";

Expand Down Expand Up @@ -90,3 +91,77 @@ for (const scenario of scenarios) {
},
);
}

for (const scenario of scenarios) {
test(
`langgraph retains spans with payload capture disabled (${scenario.version})`,
{ timeout: TIMEOUT_MS },
async () => {
await withScenarioHarness(async ({ events, runNodeScenarioDir }) => {
await runNodeScenarioDir({
entry: "scenario.mjs",
env: {
LANGCHAIN_CORE_PACKAGE_NAME: scenario.coreDependencyName,
LANGCHAIN_OPENAI_PACKAGE_NAME: scenario.openAIDependencyName,
LANGGRAPH_PACKAGE_NAME: scenario.langgraphDependencyName,
},
nodeArgs: [
"--import",
"./capture-config.mjs",
"--import",
"braintrust/hook.mjs",
],
runContext: { variantKey: scenario.variantKey, originalScenarioDir },
scenarioDir,
timeoutMs: TIMEOUT_MS,
});
const captured = events();
const chainSpans = captured.filter((event) =>
["LangGraph", "sayHello", "sayBye", "ChatOpenAI"].includes(
event.span.name ?? "",
),
);
expect(chainSpans).toHaveLength(4);
for (const event of chainSpans) {
expect(event.input).toBeUndefined();
expect(event.output).toBeUndefined();
expect(event.row.metadata).not.toHaveProperty("inputs");
expect(event.span.parentIds).toHaveLength(1);
expect(event.metrics?.start).toBeTypeOf("number");
expect(event.metrics?.end).toBeTypeOf("number");
expect(
Object.keys(event.metrics ?? {}).filter((key) =>
key.includes("tokens"),
),
).toEqual([]);
}
const model = findLatestSpan(captured, "ChatOpenAI");
expect(model).toBeDefined();
const root = findLatestSpan(
captured,
"langgraph-auto-instrumentation-root",
)!;
const graph = findLatestSpan(captured, "LangGraph")!;
const hello = findLatestSpan(captured, "sayHello")!;
const bye = findLatestSpan(captured, "sayBye")!;
expect(graph.span.parentIds).toEqual([root.span.id]);
expect(hello.span.parentIds).toEqual([graph.span.id]);
expect(bye.span.parentIds).toEqual([graph.span.id]);
expect(model?.span.parentIds).toEqual([hello.span.id]);
const provider = findLatestSpan(captured, "Chat Completion");
// Callback spans use run IDs; provider spans retain the active SDK parent.
expect(provider?.span.parentIds).toEqual([root.span.id]);
expect(provider?.input).toBeDefined();
expect(provider?.output).toBeDefined();
expect(provider?.metrics?.tokens).toBeGreaterThan(0);
expect(
captured.filter(
(event) =>
typeof event.metrics?.tokens === "number" &&
event.metrics.tokens > 0,
),
).toHaveLength(1);
});
},
);
}
8 changes: 6 additions & 2 deletions js/src/instrumentation/braintrust-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,13 @@ import { WorkersAIPlugin } from "./plugins/workers-ai-plugin";
import { TypeSafePlugin } from "./plugins/typesafe-plugin";
import { CloudflareAIChatPlugin } from "./plugins/cloudflare-ai-chat-plugin";
import { CloudflareAgentsPlugin } from "./plugins/cloudflare-agents-plugin";
import type { InstrumentationIntegrationsConfig } from "./config";
import type {
InstrumentationConfig,
InstrumentationIntegrationsConfig,
} from "./config";

export interface BraintrustPluginConfig {
langchain?: InstrumentationConfig["langchain"];
integrations?: InstrumentationIntegrationsConfig;
streamingAttachmentBufferBytes?: number;
}
Expand Down Expand Up @@ -318,7 +322,7 @@ export class BraintrustPlugin extends BasePlugin {
}

if (integrations.langchain !== false && integrations.langgraph !== false) {
this.langChainPlugin = new LangChainPlugin();
this.langChainPlugin = new LangChainPlugin(this.config.langchain);
this.langChainPlugin.enable();
}

Expand Down
25 changes: 25 additions & 0 deletions js/src/instrumentation/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { LangChainCaptureOptions } from "../vendor-sdk-types/langchain";

export type SpanExportData = Record<string, unknown>;

export interface SpanCustomizer {
Expand Down Expand Up @@ -122,6 +124,29 @@ export interface AudioEncodingConfig {
export const DEFAULT_STREAMING_ATTACHMENT_BUFFER_BYTES = 4 * 1024 * 1024;

export interface InstrumentationConfig {
/**
* Capture settings for automatically injected LangChain/LangGraph handlers.
* All fields default to true. Disabled payloads are omitted before copying.
* Spans, timing, errors, and metadata remain available.
* Configure before initializing the SDK or loading braintrust/hook.mjs.
* Use provider instrumentation to capture model payloads and usage when
* disabling their capture here.
*
* @example
* // capture-config.mjs, loaded before --import braintrust/hook.mjs
* import { configureInstrumentation } from "braintrust/instrumentation";
* configureInstrumentation({
* langchain: {
* captureChainInputs: false,
* captureChainOutputs: false,
* captureModelInputs: false,
* captureModelOutputs: false,
* captureModelTokenUsage: false,
* },
* });
*/
langchain?: LangChainCaptureOptions;

/**
* Target attachment payload size in bytes for each segmentable media stream.
* Defaults to 4 MiB. Must be a positive safe integer.
Expand Down
56 changes: 55 additions & 1 deletion js/src/instrumentation/plugins/langchain-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { langChainChannels } from "./langchain-interceptors";
import { LangChainPlugin } from "./langchain-plugin";
import type {
LangChainStartSpanArgs,
LangChainEndSpanArgs,
} from "../../vendor-sdk-types/langchain";
import { BraintrustLangChainCallbackHandler } from "../../wrappers/langchain/callback-handler";

function createManager(handlers: unknown[] = []) {
return {
Expand Down Expand Up @@ -124,3 +129,52 @@ describe("LangChainPlugin", () => {
plugin.disable();
});
});

it.each(["arguments", "result"] as const)(
"applies capture options to the handler injected into %s",
async (path) => {
const plugin = new LangChainPlugin({
captureChainInputs: false,
captureChainOutputs: false,
captureModelInputs: false,
captureModelOutputs: false,
captureModelTokenUsage: false,
});
plugin.enable();
let handler: BraintrustLangChainCallbackHandler;
try {
if (path === "arguments") {
handler = traceConfigureArguments([])[0][0];
} else {
const manager = createManager();
traceConfigureResult(manager);
handler = manager.handlers[0] as BraintrustLangChainCallbackHandler;
}
} finally {
plugin.disable();
}
// Observe the boundary before any logger copying happens.
const boundary = handler as unknown as {
startSpan(args: LangChainStartSpanArgs): void;
endSpan(args: LangChainEndSpanArgs): void;
};
const start = vi.spyOn(boundary, "startSpan").mockImplementation(() => {});
const end = vi.spyOn(boundary, "endSpan").mockImplementation(() => {});
await handler.handleChainStart({}, { state: "input" }, "chain");
await handler.handleChainEnd({ state: "output" }, "chain");
await handler.handleChatModelStart({}, [[{ content: "hello" }]], "model");
await handler.handleLLMEnd(
{ llmOutput: { tokenUsage: { totalTokens: 10 } } },
"model",
);
for (const [args] of start.mock.calls) {
expect(args).not.toHaveProperty("event.input");
}
for (const [args] of end.mock.calls) {
expect(args).not.toHaveProperty("output");
expect(args).not.toHaveProperty("metrics.tokens");
}
expect(start).toHaveBeenCalledTimes(2);
expect(end).toHaveBeenCalledTimes(2);
},
);
Loading
Loading