diff --git a/README.md b/README.md
index 0d33aaa..7bd307f 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,65 @@ var request =
var response = openAIClient.chat().completions().create(request);
```
+### LangChain4j Instrumentation
+
+```java
+var braintrust = Braintrust.get();
+var openTelemetry = braintrust.openTelemetryCreate();
+
+// Wrap the chat model to trace LLM calls
+ChatModel model = BraintrustLangchain.wrap(
+ openTelemetry,
+ OpenAiChatModel.builder()
+ .apiKey(System.getenv("OPENAI_API_KEY"))
+ .modelName("gpt-4o-mini")
+ .temperature(0.0));
+
+var response = model.chat("What is the capital of France?");
+```
+
+#### Tool Wrapping
+
+Use `BraintrustLangchain.wrapTools()` to automatically trace tool executions in your LangChain4j agents:
+
+```java
+// Create your tool class
+public class WeatherTools {
+ @Tool("Get current weather for a location")
+ public String getWeather(String location) {
+ return "The weather in " + location + " is sunny.";
+ }
+}
+
+// Wrap tools to create spans for each tool execution
+WeatherTools tools = new WeatherTools();
+WeatherTools instrumentedTools = BraintrustLangchain.wrapTools(openTelemetry, tools);
+
+// Use instrumented tools in your AI service
+Assistant assistant = AiServices.builder(Assistant.class)
+ .chatModel(model)
+ .tools(instrumentedTools)
+ .build();
+```
+
+Each tool call will automatically create an OpenTelemetry span in Braintrust with:
+- Tool name and parameters
+- Execution duration
+- Return values
+- Any exceptions thrown
+
+**Note:** For proper display in the Braintrust UI, ensure parent spans (conversation, turn, etc.) also set the required Braintrust attributes:
+```java
+var span = tracer.spanBuilder("my-span").startSpan();
+span.setAttribute("braintrust.span_attributes", "{\"type\":\"task\",\"name\":\"my-span\"}");
+span.setAttribute("braintrust.input_json", "{\"user_message\":\"...\"}");
+// ... do work ...
+span.setAttribute("braintrust.output_json", "{\"result\":\"...\"}");
+span.end();
+```
+
+See [LangchainToolWrappingExample.java](./examples/src/main/java/dev/braintrust/examples/LangchainToolWrappingExample.java) for a complete example with proper span hierarchy.
+
## Running Examples
Example source code can be found [here](./examples/src/main/java/dev/braintrust/examples)
diff --git a/build.gradle b/build.gradle
index ad1b078..90d9c9f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -95,6 +95,8 @@ dependencies {
testImplementation "dev.langchain4j:langchain4j:${langchainVersion}"
testImplementation "dev.langchain4j:langchain4j-http-client:${langchainVersion}"
testImplementation "dev.langchain4j:langchain4j-open-ai:${langchainVersion}"
+
+ implementation 'net.bytebuddy:byte-buddy:1.14.11' // ByteBuddy for LangChain4j tool wrapping
}
/**
diff --git a/examples/build.gradle b/examples/build.gradle
index f4e9c20..553af25 100644
--- a/examples/build.gradle
+++ b/examples/build.gradle
@@ -172,3 +172,17 @@ task runLangchain(type: JavaExec) {
suspend = false
}
}
+
+task runLangchainToolWrapping(type: JavaExec) {
+ group = 'Braintrust SDK Examples'
+ description = 'Run the LangChain4j tool wrapping example. NOTE: this requires OPENAI_API_KEY to be exported and will make a small call to openai, using your tokens'
+ classpath = sourceSets.main.runtimeClasspath
+ mainClass = 'dev.braintrust.examples.LangchainToolWrappingExample'
+ systemProperty 'org.slf4j.simpleLogger.log.dev.braintrust', braintrustLogLevel
+ debugOptions {
+ enabled = true
+ port = 5566
+ server = true
+ suspend = false
+ }
+}
diff --git a/examples/src/main/java/dev/braintrust/examples/LangchainToolWrappingExample.java b/examples/src/main/java/dev/braintrust/examples/LangchainToolWrappingExample.java
new file mode 100644
index 0000000..102880d
--- /dev/null
+++ b/examples/src/main/java/dev/braintrust/examples/LangchainToolWrappingExample.java
@@ -0,0 +1,158 @@
+package dev.braintrust.examples;
+
+import dev.braintrust.Braintrust;
+import dev.braintrust.instrumentation.langchain.BraintrustLangchain;
+import dev.langchain4j.agent.tool.Tool;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.openai.OpenAiChatModel;
+import dev.langchain4j.service.AiServices;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.context.Scope;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+
+/**
+ * Demonstrates how to use BraintrustLangchain.wrapTools() to automatically trace tool executions.
+ *
+ *
This example shows:
+ *
+ *
+ * - Wrapping LangChain4j tool objects to create OpenTelemetry spans for each tool call
+ *
- Creating a conversation hierarchy with turns and tool executions
+ *
- How tool calls appear in Braintrust traces with full context
+ *
+ */
+public class LangchainToolWrappingExample {
+
+ /** Example tool class with weather-related methods */
+ public static class WeatherTools {
+ @Tool("Get current weather for a location")
+ public String getWeather(String location) {
+ // Simulate a weather API call
+ return String.format("The weather in %s is sunny with 72°F temperature.", location);
+ }
+
+ @Tool("Get weather forecast for next N days")
+ public String getForecast(String location, int days) {
+ // Simulate a forecast API call
+ return String.format(
+ "The %d-day forecast for %s: Mostly sunny with temperatures between 65-75°F.",
+ days, location);
+ }
+ }
+
+ /** AI Service interface for the assistant */
+ interface Assistant {
+ String chat(String userMessage);
+ }
+
+ public static void main(String[] args) throws Exception {
+ if (null == System.getenv("OPENAI_API_KEY")) {
+ System.err.println(
+ "\nWARNING envar OPENAI_API_KEY not found. This example will likely fail.\n");
+ }
+
+ var braintrust = Braintrust.get();
+ var openTelemetry = braintrust.openTelemetryCreate();
+ var tracer = openTelemetry.getTracer("langchain-tool-wrapping");
+
+ System.out.println("\n=== LangChain4j Tool Wrapping Example ===\n");
+
+ // Create root span for the conversation
+ var conversationSpan = tracer.spanBuilder("weather-assistant-conversation").startSpan();
+ conversationSpan.setAttribute(
+ "braintrust.span_attributes", "{\"type\":\"task\",\"name\":\"conversation\"}");
+ conversationSpan.setAttribute(
+ "braintrust.input_json",
+ "{\"description\":\"Weather assistant with tool wrapping\",\"turns\":2}");
+
+ try (var ignored = conversationSpan.makeCurrent()) {
+ // Wrap the LLM with Braintrust instrumentation
+ ChatModel model =
+ BraintrustLangchain.wrap(
+ openTelemetry,
+ OpenAiChatModel.builder()
+ .apiKey(System.getenv("OPENAI_API_KEY"))
+ .modelName("gpt-4o-mini")
+ .temperature(0.0));
+
+ // Create tools and wrap them with Braintrust instrumentation
+ WeatherTools tools = new WeatherTools();
+ WeatherTools instrumentedTools = BraintrustLangchain.wrapTools(openTelemetry, tools);
+
+ System.out.println("Tools wrapped with Braintrust instrumentation");
+ System.out.println("Each tool call will create a span in Braintrust\n");
+
+ // Create AI service with the instrumented tools
+ Assistant assistant =
+ AiServices.builder(Assistant.class)
+ .chatModel(model)
+ .tools(instrumentedTools)
+ .build();
+
+ // Example 1: Single tool call
+ System.out.println("--- Turn 1: Single Tool Call ---");
+ String query1 = "What's the weather in San Francisco?";
+ System.out.println("User: " + query1);
+ Span turn1 = tracer.spanBuilder("turn_1").startSpan();
+ turn1.setAttribute(
+ "braintrust.span_attributes", "{\"type\":\"task\",\"name\":\"turn_1\"}");
+ turn1.setAttribute("braintrust.input_json", "{\"user_message\":\"" + query1 + "\"}");
+ String response1;
+ try (Scope scope = turn1.makeCurrent()) {
+ response1 = assistant.chat(query1);
+ System.out.println("Assistant: " + response1);
+ turn1.setAttribute(
+ "braintrust.output_json",
+ "{\"assistant_message\":\"" + response1.replace("\"", "\\\"") + "\"}");
+ } finally {
+ turn1.end();
+ }
+ System.out.println();
+
+ // Example 2: Tool with multiple parameters
+ System.out.println("--- Turn 2: Multiple Parameters ---");
+ String query2 = "What's the 5-day forecast for Tokyo?";
+ System.out.println("User: " + query2);
+ Span turn2 = tracer.spanBuilder("turn_2").startSpan();
+ turn2.setAttribute(
+ "braintrust.span_attributes", "{\"type\":\"task\",\"name\":\"turn_2\"}");
+ turn2.setAttribute("braintrust.input_json", "{\"user_message\":\"" + query2 + "\"}");
+ String response2;
+ try (Scope scope = turn2.makeCurrent()) {
+ response2 = assistant.chat(query2);
+ System.out.println("Assistant: " + response2);
+ turn2.setAttribute(
+ "braintrust.output_json",
+ "{\"assistant_message\":\"" + response2.replace("\"", "\\\"") + "\"}");
+ } finally {
+ turn2.end();
+ }
+ System.out.println();
+
+ } finally {
+ conversationSpan.setAttribute(
+ "braintrust.output_json", "{\"status\":\"completed\",\"turns\":2}");
+ conversationSpan.end();
+ }
+
+ // Flush traces before exit
+ if (openTelemetry instanceof OpenTelemetrySdk) {
+ ((OpenTelemetrySdk) openTelemetry).close();
+ }
+
+ var url =
+ braintrust.projectUri()
+ + "/logs?r=%s&s=%s"
+ .formatted(
+ conversationSpan.getSpanContext().getTraceId(),
+ conversationSpan.getSpanContext().getSpanId());
+
+ System.out.println("Example complete!");
+ System.out.println("\nIn Braintrust, you'll see:");
+ System.out.println(" • Root conversation span");
+ System.out.println(" • Nested turn spans for each user interaction");
+ System.out.println(" • LLM call spans (from BraintrustLangchain.wrap())");
+ System.out.println(" • Tool execution spans (from BraintrustLangchain.wrapTools())");
+ System.out.println("\n View your traces: " + url + "\n");
+ }
+}
diff --git a/src/main/java/dev/braintrust/instrumentation/langchain/BraintrustLangchain.java b/src/main/java/dev/braintrust/instrumentation/langchain/BraintrustLangchain.java
index a3aafa8..9645f88 100644
--- a/src/main/java/dev/braintrust/instrumentation/langchain/BraintrustLangchain.java
+++ b/src/main/java/dev/braintrust/instrumentation/langchain/BraintrustLangchain.java
@@ -49,6 +49,28 @@ public static OpenAiStreamingChatModel wrap(
}
}
+ /**
+ * Wrap a tools object to instrument @Tool method executions with Braintrust traces. Returns a
+ * proxy that intercepts all @Tool annotated methods and creates OpenTelemetry spans.
+ *
+ * Usage: StoryTools tools = new StoryTools(); StoryTools instrumented =
+ * BraintrustLangchain.wrapTools(openTelemetry, tools);
+ * AiServices.builder(Assistant.class).chatModel(model).tools(instrumented).build()
+ *
+ * @param otel OpenTelemetry instance from braintrust.openTelemetryCreate()
+ * @param tools Tool object with @Tool annotated methods
+ * @return Proxied tool object that creates spans for each @Tool method call
+ */
+ @SuppressWarnings("unchecked")
+ public static T wrapTools(OpenTelemetry otel, T tools) {
+ try {
+ return (T) new ByteBuddyToolWrapper(otel).wrap(tools);
+ } catch (Exception e) {
+ log.warn("Failed to wrap tools with instrumentation, returning original", e);
+ return tools;
+ }
+ }
+
private static HttpClientBuilder wrap(
OpenTelemetry otel, HttpClientBuilder builder, Options options) {
return new WrappedHttpClientBuilder(otel, builder, options);
diff --git a/src/main/java/dev/braintrust/instrumentation/langchain/ByteBuddyToolWrapper.java b/src/main/java/dev/braintrust/instrumentation/langchain/ByteBuddyToolWrapper.java
new file mode 100644
index 0000000..0c4ba25
--- /dev/null
+++ b/src/main/java/dev/braintrust/instrumentation/langchain/ByteBuddyToolWrapper.java
@@ -0,0 +1,119 @@
+package dev.braintrust.instrumentation.langchain;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.langchain4j.agent.tool.Tool;
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.StatusCode;
+import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.context.Scope;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import net.bytebuddy.ByteBuddy;
+import net.bytebuddy.implementation.MethodDelegation;
+import net.bytebuddy.implementation.bind.annotation.*;
+import net.bytebuddy.matcher.ElementMatchers;
+
+/**
+ * Uses ByteBuddy to create runtime subclass proxies that intercept @Tool methods and add
+ * OpenTelemetry spans with Braintrust attributes.
+ */
+@Slf4j
+class ByteBuddyToolWrapper {
+ private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
+ private final Tracer tracer;
+
+ ByteBuddyToolWrapper(OpenTelemetry otel) {
+ this.tracer = otel.getTracer("braintrust");
+ }
+
+ @SuppressWarnings("unchecked")
+ public T wrap(T originalTools) throws Exception {
+ Class> toolClass = originalTools.getClass();
+
+ // Create subclass with interceptor, preserving all annotations
+ Class> proxyClass =
+ new ByteBuddy()
+ .subclass(toolClass)
+ .method(ElementMatchers.isAnnotatedWith(Tool.class))
+ .intercept(
+ MethodDelegation.to(
+ new ToolMethodInterceptor(tracer, originalTools)))
+ .attribute(
+ net.bytebuddy.implementation.attribute.MethodAttributeAppender
+ .ForInstrumentedMethod.INCLUDING_RECEIVER)
+ .make()
+ .load(toolClass.getClassLoader())
+ .getLoaded();
+
+ // Create instance - ByteBuddy subclass will delegate to original
+ return (T) proxyClass.getDeclaredConstructor().newInstance();
+ }
+
+ /** Interceptor that wraps @Tool method calls with OpenTelemetry spans */
+ public static class ToolMethodInterceptor {
+ private final Tracer tracer;
+ private final Object originalTools;
+
+ public ToolMethodInterceptor(Tracer tracer, Object originalTools) {
+ this.tracer = tracer;
+ this.originalTools = originalTools;
+ }
+
+ @RuntimeType
+ public Object intercept(
+ @Origin Method method, @AllArguments Object[] args, @SuperCall Callable> zuper)
+ throws Exception {
+
+ String toolName = method.getName();
+
+ // Build input map from parameters
+ Map inputMap = new HashMap<>();
+ var parameters = method.getParameters();
+ for (int i = 0; i < parameters.length && i < args.length; i++) {
+ inputMap.put(parameters[i].getName(), args[i]);
+ }
+
+ Span span = tracer.spanBuilder(toolName).startSpan();
+ try (Scope scope = span.makeCurrent()) {
+ // Set Braintrust span attributes
+ span.setAttribute(
+ "braintrust.span_attributes",
+ json(Map.of("type", "tool", "name", toolName)));
+
+ // Set input
+ span.setAttribute("braintrust.input_json", json(inputMap));
+
+ // Execute method and measure time
+ long startTime = System.nanoTime();
+ Object result = zuper.call(); // Call original method
+ long endTime = System.nanoTime();
+
+ // Set output
+ span.setAttribute("braintrust.output_json", json(result));
+
+ // Set metrics
+ double executionTime = (endTime - startTime) / 1_000_000_000.0;
+ span.setAttribute(
+ "braintrust.metrics", json(Map.of("execution_time", executionTime)));
+
+ return result;
+ } catch (Throwable t) {
+ span.setStatus(StatusCode.ERROR, t.getMessage());
+ span.recordException(t);
+ throw t;
+ } finally {
+ span.end();
+ }
+ }
+
+ @SneakyThrows
+ private static String json(Object o) {
+ return JSON_MAPPER.writeValueAsString(o);
+ }
+ }
+}
diff --git a/src/test/java/dev/braintrust/instrumentation/langchain/BraintrustLangchainTest.java b/src/test/java/dev/braintrust/instrumentation/langchain/BraintrustLangchainTest.java
index e2555bd..67d0927 100644
--- a/src/test/java/dev/braintrust/instrumentation/langchain/BraintrustLangchainTest.java
+++ b/src/test/java/dev/braintrust/instrumentation/langchain/BraintrustLangchainTest.java
@@ -13,6 +13,8 @@
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.trace.StatusCode;
+import java.util.List;
import java.util.concurrent.CompletableFuture;
import lombok.SneakyThrows;
import org.junit.jupiter.api.BeforeEach;
@@ -215,4 +217,218 @@ public void onError(Throwable error) {
"Output should contain the complete streamed response");
assertNotNull(choice.get("finish_reason"), "Output should have finish_reason");
}
+
+ @Test
+ @SneakyThrows
+ void testToolWrapping() {
+ // Create and wrap tools
+ TestTools tools = new TestTools();
+ TestTools wrappedTools = BraintrustLangchain.wrapTools(testHarness.openTelemetry(), tools);
+
+ // Call wrapped tool method directly
+ String result = wrappedTools.getWeather("Paris");
+ assertNotNull(result);
+ assertTrue(result.contains("Paris"));
+
+ // Verify span was created
+ var spans = testHarness.awaitExportedSpans();
+ assertEquals(1, spans.size(), "Expected one span for tool execution");
+ var span = spans.get(0);
+
+ // Verify span name
+ assertEquals("getWeather", span.getName());
+
+ // Verify span type
+ var attributes = span.getAttributes();
+ String spanAttrsJson = attributes.get(AttributeKey.stringKey("braintrust.span_attributes"));
+ JsonNode spanAttrs = JSON_MAPPER.readTree(spanAttrsJson);
+ assertEquals("tool", spanAttrs.get("type").asText());
+ assertEquals("getWeather", spanAttrs.get("name").asText());
+
+ // Verify input (parameter names may be arg0, arg1, etc without -parameters flag)
+ String inputJson = attributes.get(AttributeKey.stringKey("braintrust.input_json"));
+ assertNotNull(inputJson);
+ JsonNode input = JSON_MAPPER.readTree(inputJson);
+ assertTrue(input.isObject(), "Input should be an object");
+ assertTrue(input.size() > 0, "Input should have at least one parameter");
+ // Check if parameter value is present (either as "location" or "arg0")
+ String paramValue =
+ input.has("location")
+ ? input.get("location").asText()
+ : input.elements().next().asText();
+ assertEquals("Paris", paramValue);
+
+ // Verify output
+ String outputJson = attributes.get(AttributeKey.stringKey("braintrust.output_json"));
+ assertNotNull(outputJson);
+ JsonNode output = JSON_MAPPER.readTree(outputJson);
+ assertTrue(output.asText().contains("Paris"));
+ assertTrue(output.asText().contains("72"));
+
+ // Verify metrics
+ String metricsJson = attributes.get(AttributeKey.stringKey("braintrust.metrics"));
+ JsonNode metrics = JSON_MAPPER.readTree(metricsJson);
+ assertTrue(metrics.has("execution_time"));
+ assertTrue(metrics.get("execution_time").asDouble() >= 0);
+ }
+
+ @Test
+ @SneakyThrows
+ void testToolWrappingWithException() {
+ TestTools tools = new TestTools();
+ TestTools wrappedTools = BraintrustLangchain.wrapTools(testHarness.openTelemetry(), tools);
+
+ // Execute and expect exception
+ assertThrows(
+ RuntimeException.class,
+ () -> {
+ wrappedTools.throwError();
+ });
+
+ // Verify span with error status
+ var spans = testHarness.awaitExportedSpans();
+ assertEquals(1, spans.size());
+ var span = spans.get(0);
+
+ assertEquals(StatusCode.ERROR, span.getStatus().getStatusCode());
+ assertTrue(
+ span.getEvents().stream().anyMatch(e -> e.getName().equals("exception")),
+ "Span should have exception event");
+ }
+
+ @Test
+ @SneakyThrows
+ void testToolWrappingWithMultipleCalls() {
+ TestTools tools = new TestTools();
+ TestTools wrappedTools = BraintrustLangchain.wrapTools(testHarness.openTelemetry(), tools);
+
+ // Call multiple tool methods
+ wrappedTools.getWeather("Tokyo");
+ int sum = wrappedTools.calculateSum(5, 7);
+ assertEquals(12, sum);
+
+ // Verify two spans created
+ var spans = testHarness.awaitExportedSpans();
+ assertEquals(2, spans.size(), "Expected two spans");
+
+ // Verify first span (getWeather)
+ var weatherSpan = spans.get(0);
+ assertEquals("getWeather", weatherSpan.getName());
+
+ // Verify second span (calculateSum)
+ var sumSpan = spans.get(1);
+ assertEquals("calculateSum", sumSpan.getName());
+ String sumOutput =
+ sumSpan.getAttributes().get(AttributeKey.stringKey("braintrust.output_json"));
+ assertEquals("12", sumOutput);
+ }
+
+ @Test
+ @SneakyThrows
+ void testToolWrappingAllBraintrustAttributesPresent() {
+ // This test verifies ALL required Braintrust attributes are present
+ // to catch issues that would cause UI problems
+ TestTools tools = new TestTools();
+ TestTools wrappedTools = BraintrustLangchain.wrapTools(testHarness.openTelemetry(), tools);
+
+ wrappedTools.getWeather("London");
+
+ var spans = testHarness.awaitExportedSpans();
+ var span = spans.get(0);
+ var attributes = span.getAttributes();
+
+ // CRITICAL: These attributes MUST be present for Braintrust UI to display properly
+ assertNotNull(
+ attributes.get(AttributeKey.stringKey("braintrust.span_attributes")),
+ "braintrust.span_attributes is required for UI");
+ assertNotNull(
+ attributes.get(AttributeKey.stringKey("braintrust.input_json")),
+ "braintrust.input_json is required for UI to show inputs");
+ assertNotNull(
+ attributes.get(AttributeKey.stringKey("braintrust.output_json")),
+ "braintrust.output_json is required for UI to show outputs");
+ assertNotNull(
+ attributes.get(AttributeKey.stringKey("braintrust.metrics")),
+ "braintrust.metrics is required for UI to show metrics");
+
+ // Verify span_attributes has correct structure
+ String spanAttrsJson = attributes.get(AttributeKey.stringKey("braintrust.span_attributes"));
+ JsonNode spanAttrs = JSON_MAPPER.readTree(spanAttrsJson);
+ assertTrue(spanAttrs.has("type"), "span_attributes must have 'type' field");
+ assertTrue(spanAttrs.has("name"), "span_attributes must have 'name' field");
+ assertEquals("tool", spanAttrs.get("type").asText(), "Tool spans must have type='tool'");
+ }
+
+ @Test
+ @SneakyThrows
+ void testToolWrappingIntegrationWithConversationHierarchy() {
+ // This test simulates a realistic usage pattern like the example
+ // and verifies ALL spans in the hierarchy have input/output for UI
+ var tracer = testHarness.openTelemetry().getTracer("test");
+ TestTools tools = new TestTools();
+ TestTools wrappedTools = BraintrustLangchain.wrapTools(testHarness.openTelemetry(), tools);
+
+ // Create conversation span (like example does)
+ var conversationSpan = tracer.spanBuilder("conversation").startSpan();
+ conversationSpan.setAttribute(
+ "braintrust.span_attributes", "{\"type\":\"task\",\"name\":\"conversation\"}");
+ conversationSpan.setAttribute(
+ "braintrust.input_json", "{\"description\":\"test conversation\"}");
+
+ try (var ignored = conversationSpan.makeCurrent()) {
+ // Create turn span (like example does)
+ var turnSpan = tracer.spanBuilder("turn_1").startSpan();
+ turnSpan.setAttribute(
+ "braintrust.span_attributes", "{\"type\":\"task\",\"name\":\"turn_1\"}");
+ turnSpan.setAttribute("braintrust.input_json", "{\"user_message\":\"test query\"}");
+
+ try (var turnScope = turnSpan.makeCurrent()) {
+ // Call tool within turn (tool wrapper should create tool span)
+ String result = wrappedTools.getWeather("Paris");
+ turnSpan.setAttribute(
+ "braintrust.output_json", "{\"assistant_message\":\"" + result + "\"}");
+ } finally {
+ turnSpan.end();
+ }
+ } finally {
+ conversationSpan.setAttribute("braintrust.output_json", "{\"status\":\"completed\"}");
+ conversationSpan.end();
+ }
+
+ // Verify all 3 spans exist and have required attributes
+ var spans = testHarness.awaitExportedSpans();
+ assertEquals(3, spans.size(), "Expected 3 spans: conversation, turn, and tool");
+
+ // Find each span type
+ var toolSpan =
+ spans.stream().filter(s -> s.getName().equals("getWeather")).findFirst().get();
+ var turnSpanData =
+ spans.stream().filter(s -> s.getName().equals("turn_1")).findFirst().get();
+ var convSpan =
+ spans.stream().filter(s -> s.getName().equals("conversation")).findFirst().get();
+
+ // CRITICAL: Every span must have input/output for UI to display properly
+ for (var span : List.of(toolSpan, turnSpanData, convSpan)) {
+ var attrs = span.getAttributes();
+ assertNotNull(
+ attrs.get(AttributeKey.stringKey("braintrust.span_attributes")),
+ span.getName() + " missing braintrust.span_attributes");
+ assertNotNull(
+ attrs.get(AttributeKey.stringKey("braintrust.input_json")),
+ span.getName() + " missing braintrust.input_json - UI won't show input!");
+ assertNotNull(
+ attrs.get(AttributeKey.stringKey("braintrust.output_json")),
+ span.getName() + " missing braintrust.output_json - UI won't show output!");
+ }
+
+ // Verify span hierarchy (parent-child relationships)
+ assertEquals(
+ convSpan.getSpanId(),
+ turnSpanData.getParentSpanId(),
+ "Turn should be child of conversation");
+ assertEquals(
+ turnSpanData.getSpanId(),
+ toolSpan.getParentSpanId(),
+ "Tool should be child of turn");
+ }
}
diff --git a/src/test/java/dev/braintrust/instrumentation/langchain/TestTools.java b/src/test/java/dev/braintrust/instrumentation/langchain/TestTools.java
new file mode 100644
index 0000000..b289611
--- /dev/null
+++ b/src/test/java/dev/braintrust/instrumentation/langchain/TestTools.java
@@ -0,0 +1,22 @@
+package dev.braintrust.instrumentation.langchain;
+
+import dev.langchain4j.agent.tool.Tool;
+
+public class TestTools {
+
+ @Tool("Get weather for a location")
+ public String getWeather(String location) {
+ return String.format(
+ "{\"location\":\"%s\",\"temperature\":72,\"condition\":\"sunny\"}", location);
+ }
+
+ @Tool("Calculate sum")
+ public int calculateSum(int a, int b) {
+ return a + b;
+ }
+
+ @Tool("Tool that throws exception")
+ public String throwError() {
+ throw new RuntimeException("Intentional error for testing");
+ }
+}