Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ public void close() {
inputJson);

var response = underlying.execute(bufferedRequest, requestOptions);
return new TeeingStreamHttpResponse(response, span);
return new TeeingStreamHttpResponse(response, span, tracer);
} catch (Exception e) {
InstrumentationSemConv.tagLLMSpanResponse(span, e);
span.end();
Expand Down Expand Up @@ -157,7 +157,9 @@ public void close() {
return underlying
.executeAsync(bufferedRequest, requestOptions)
.thenApply(
response -> (HttpResponse) new TeeingStreamHttpResponse(response, span))
response ->
(HttpResponse)
new TeeingStreamHttpResponse(response, span, tracer))
.whenComplete(
(response, t) -> {
if (t != null) {
Expand Down Expand Up @@ -237,14 +239,16 @@ private static String readBodyAsString(HttpRequestBody body) {
private static final class TeeingStreamHttpResponse implements HttpResponse {
private final HttpResponse delegate;
private final Span span;
private final Tracer tracer;
private final long spanStartNanos = System.nanoTime();
private final AtomicLong timeToFirstTokenNanos = new AtomicLong();
private final ByteArrayOutputStream teeBuffer = new ByteArrayOutputStream();
private final InputStream teeStream;

TeeingStreamHttpResponse(HttpResponse delegate, Span span) {
TeeingStreamHttpResponse(HttpResponse delegate, Span span, Tracer tracer) {
this.delegate = delegate;
this.span = span;
this.tracer = tracer;
this.teeStream =
new TeeInputStream(
delegate.body(), teeBuffer, this::onFirstByte, this::onStreamClosed);
Expand All @@ -260,7 +264,9 @@ private void onStreamClosed() {
synchronized (teeBuffer) {
bytes = teeBuffer.toByteArray();
}
tagSpanFromBuffer(span, bytes, timeToFirstTokenNanos.get());
// tagLLMSpanResponse also emits child spans for any server-side tool calls (web
// search, etc.) nested under the LLM span while it is still live.
tagSpanFromBuffer(tracer, span, bytes, timeToFirstTokenNanos.get());
} finally {
span.end();
}
Expand Down Expand Up @@ -354,7 +360,8 @@ private void notifyClosed() {
// Span tagging from buffered bytes
// -------------------------------------------------------------------------

private static void tagSpanFromBuffer(Span span, byte[] bytes, Long timeToFirstTokenNanos) {
private static void tagSpanFromBuffer(
Tracer tracer, Span span, byte[] bytes, Long timeToFirstTokenNanos) {
if (bytes.length == 0) return;
try {
String firstLine = firstNonEmptyLine(bytes);
Expand All @@ -364,13 +371,15 @@ private static void tagSpanFromBuffer(Span span, byte[] bytes, Long timeToFirstT
firstLine != null
&& (firstLine.startsWith("data:") || firstLine.startsWith("event:"));
if (isSse) {
tagSpanFromSseBytes(span, bytes, timeToFirstTokenNanos);
tagSpanFromSseBytes(tracer, span, bytes, timeToFirstTokenNanos);
} else {
// Non-streaming: plain Message JSON — pass it whole, no time_to_first_token
String responseJson = new String(bytes, StandardCharsets.UTF_8);
InstrumentationSemConv.tagLLMSpanResponse(
tracer,
span,
InstrumentationSemConv.PROVIDER_NAME_ANTHROPIC,
new String(bytes, StandardCharsets.UTF_8),
responseJson,
null);
}
} catch (Exception e) {
Expand Down Expand Up @@ -406,7 +415,7 @@ private static String firstNonEmptyLine(byte[] bytes) {
* assembled {@link com.anthropic.models.messages.Message} for the span.
*/
private static void tagSpanFromSseBytes(
Span span, byte[] sseBytes, Long timeToFirstTokenNanos) {
Tracer tracer, Span span, byte[] sseBytes, Long timeToFirstTokenNanos) {
try {
var mapper = BraintrustJsonMapper.get();
var reader =
Expand All @@ -427,6 +436,7 @@ private static void tagSpanFromSseBytes(
}
String assembledMessageJson = BraintrustJsonMapper.toJson(accumulator.message());
InstrumentationSemConv.tagLLMSpanResponse(
tracer,
span,
InstrumentationSemConv.PROVIDER_NAME_ANTHROPIC,
assembledMessageJson,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package dev.braintrust.instrumentation.anthropic.v2_2_0;

import static org.junit.jupiter.api.Assertions.*;

import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.models.messages.MessageCreateParams;
import com.anthropic.models.messages.Model;
import com.anthropic.models.messages.WebSearchTool20250305;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.braintrust.TestHarness;
import dev.braintrust.instrumentation.Instrumenter;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.sdk.trace.data.SpanData;
import java.util.List;
import lombok.SneakyThrows;
import net.bytebuddy.agent.ByteBuddyAgent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

/**
* Verifies that Anthropic server-side tool calls are captured as both a cost metric on the LLM span
* and a child {@code type:"tool"} span parented to it, giving each call its own cost/latency
* visibility on the trace timeline. Web search ({@code server_tool_use_web_search_requests}) is the
* case exercised here.
*/
public class BraintrustAnthropicServerSideSpansTest {
private static final String TEST_MODEL = "claude-sonnet-4-5-20250929";
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
private static final AttributeKey<String> SPAN_ATTRIBUTES =
AttributeKey.stringKey("braintrust.span_attributes");
private static final AttributeKey<String> METADATA =
AttributeKey.stringKey("braintrust.metadata");
private static final AttributeKey<String> METRICS =
AttributeKey.stringKey("braintrust.metrics");

@BeforeAll
public static void beforeAll() {
var instrumentation = ByteBuddyAgent.install();
Instrumenter.install(
instrumentation, BraintrustAnthropicServerSideSpansTest.class.getClassLoader());
}

private TestHarness testHarness;

@BeforeEach
void beforeEach() {
testHarness = TestHarness.setup();
}

private static MessageCreateParams webSearchRequest() {
return MessageCreateParams.builder()
.model(Model.of(TEST_MODEL))
.maxTokens(1024)
.addUserMessage(
"Search the web for one recent AI news headline and answer in one"
+ " sentence.")
.addTool(WebSearchTool20250305.builder().maxUses(3).build())
.build();
}

@Test
@SneakyThrows
void testWebSearch() {
AnthropicClient client =
AnthropicOkHttpClient.builder()
.baseUrl(testHarness.anthropicBaseUrl())
.apiKey(testHarness.anthropicApiKey())
.build();

var response = client.messages().create(webSearchRequest());
assertNotNull(response);

assertWebSearch(testHarness.awaitExportedSpans(2));
}

@Test
@SneakyThrows
void testWebSearchStreaming() {
AnthropicClient client =
AnthropicOkHttpClient.builder()
.baseUrl(testHarness.anthropicBaseUrl())
.apiKey(testHarness.anthropicApiKey())
.build();

try (var stream = client.messages().createStreaming(webSearchRequest())) {
stream.stream().forEach(event -> {});
}

assertWebSearch(testHarness.awaitExportedSpans(2));
}

@SneakyThrows
private static void assertWebSearch(List<SpanData> spans) {
var llmSpans = spans.stream().filter(s -> isType(s, "llm")).toList();
assertEquals(1, llmSpans.size(), "expected a single LLM span");
var llm = llmSpans.get(0);

// Cost metric on the LLM span.
JsonNode metrics = JSON_MAPPER.readTree(llm.getAttributes().get(METRICS));
assertTrue(
metrics.has("server_tool_use_web_search_requests"),
"expected server_tool_use_web_search_requests metric, got: " + metrics);
assertTrue(metrics.get("server_tool_use_web_search_requests").asDouble() >= 1.0);

// At least one web_search tool span, parented to the LLM span.
var toolSpans =
spans.stream()
.filter(s -> isType(s, "tool"))
.filter(s -> "web_search".equals(s.getName()))
.toList();
assertFalse(
toolSpans.isEmpty(),
"expected at least one web_search tool span, got: "
+ spans.stream().map(SpanData::getName).toList());

for (var tool : toolSpans) {
assertEquals(
llm.getSpanId(),
tool.getParentSpanId(),
"web_search tool span must be a child of the LLM span");
JsonNode metadata = JSON_MAPPER.readTree(tool.getAttributes().get(METADATA));
assertEquals("server_tool_use", metadata.path("tool_call_type").asText());
assertEquals("web_search_tool_result", metadata.path("tool_result_type").asText());
assertFalse(metadata.path("tool_use_id").asText().isEmpty());
}
}

@SneakyThrows
private static boolean isType(SpanData span, String type) {
String attr = span.getAttributes().get(SPAN_ATTRIBUTES);
return attr != null && type.equals(JSON_MAPPER.readTree(attr).path("type").asText());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ public Optional<InputStream> modifyHttpResponseContent(
try {
String responseBodyStr = new String(bytes, StandardCharsets.UTF_8);
InstrumentationSemConv.tagLLMSpanResponse(
span, InstrumentationSemConv.PROVIDER_NAME_BEDROCK, responseBodyStr);
tracer,
span,
InstrumentationSemConv.PROVIDER_NAME_BEDROCK,
responseBodyStr);
} catch (Exception e) {
log.debug("Failed to capture response body", e);
}
Expand Down Expand Up @@ -182,7 +185,7 @@ public Optional<Publisher<ByteBuffer>> modifyAsyncHttpResponseContent(

Publisher<ByteBuffer> original = publisherOpt.get();
Publisher<ByteBuffer> teed =
subscriber -> original.subscribe(new TeeingSubscriber(subscriber, span));
subscriber -> original.subscribe(new TeeingSubscriber(subscriber, span, tracer));
return Optional.of(teed);
}

Expand Down Expand Up @@ -238,6 +241,7 @@ private static String extractModelIdFromPath(String path) {
private static class TeeingSubscriber implements Subscriber<ByteBuffer> {
private final Subscriber<? super ByteBuffer> downstream;
private final Span span;
private final Tracer tracer;
private final MessageDecoder decoder = new MessageDecoder();

// Accumulated incrementally in onNext — no message list retained.
Expand All @@ -248,9 +252,10 @@ private static class TeeingSubscriber implements Subscriber<ByteBuffer> {
private long startNanos;
private Long timeToFirstTokenNanos = null;

TeeingSubscriber(Subscriber<? super ByteBuffer> downstream, Span span) {
TeeingSubscriber(Subscriber<? super ByteBuffer> downstream, Span span, Tracer tracer) {
this.downstream = downstream;
this.span = span;
this.tracer = tracer;
}

@Override
Expand Down Expand Up @@ -304,6 +309,7 @@ public void onError(Throwable t) {
public void onComplete() {
try {
InstrumentationSemConv.tagLLMSpanResponse(
tracer,
span,
InstrumentationSemConv.PROVIDER_NAME_BEDROCK,
buildConverseJson(text.toString(), stopReason, inputTokens, outputTokens),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ private void tagSpan(
@Nullable String responseBody) {
try {
Map<String, Object> metadata = new java.util.HashMap<>();
metadata.put("provider", "gemini");
metadata.put("provider", "google");

// Parse request
if (requestBody != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ void testWrapGemini() {
span.getAttributes().get(AttributeKey.stringKey("braintrust.metadata"));
assertNotNull(metadataJson, "braintrust.metadata should be set");
var metadata = JSON_MAPPER.readTree(metadataJson);
assertEquals("gemini", metadata.get("provider").asText());
assertEquals("google", metadata.get("provider").asText());
assertEquals(MODEL_ID, metadata.get("model").asText());
assertEquals(0.0, metadata.get("temperature").asDouble());
assertEquals(50, metadata.get("maxOutputTokens").asInt());
Expand Down Expand Up @@ -145,7 +145,7 @@ void testWrapGeminiAsync() {
span.getAttributes().get(AttributeKey.stringKey("braintrust.metadata"));
assertNotNull(metadataJson, "braintrust.metadata should be set");
var metadata = JSON_MAPPER.readTree(metadataJson);
assertEquals("gemini", metadata.get("provider").asText());
assertEquals("google", metadata.get("provider").asText());
assertEquals(MODEL_ID, metadata.get("model").asText());
assertEquals(0.0, metadata.get("temperature").asDouble());
assertEquals(50, metadata.get("maxOutputTokens").asInt());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public SuccessfulHttpResponse execute(HttpRequest request)
tagRequest(span, request);
var response = underlying.execute(request);
InstrumentationSemConv.tagLLMSpanResponse(
span, options.providerName(), response.body());
tracer, span, options.providerName(), response.body());
return response;
} catch (Throwable t) {
InstrumentationSemConv.tagLLMSpanResponse(span, t);
Expand All @@ -73,7 +73,8 @@ public void execute(HttpRequest request, ServerSentEventListener listener) {
tagRequest(span, request);
underlying.execute(
request,
new WrappedServerSentEventListener(listener, span, options.providerName()));
new WrappedServerSentEventListener(
listener, span, options.providerName(), tracer));
} catch (Throwable t) {
InstrumentationSemConv.tagLLMSpanResponse(span, t);
span.end();
Expand All @@ -97,7 +98,8 @@ public void execute(
underlying.execute(
request,
parser,
new WrappedServerSentEventListener(listener, span, options.providerName()));
new WrappedServerSentEventListener(
listener, span, options.providerName(), tracer));
} catch (Throwable t) {
InstrumentationSemConv.tagLLMSpanResponse(span, t);
span.end();
Expand All @@ -122,16 +124,18 @@ static class WrappedServerSentEventListener implements ServerSentEventListener {
private final ServerSentEventListener delegate;
private final Span span;
private final String providerName;
private final Tracer tracer;
private final long startNanos = System.nanoTime();
private final AtomicLong timeToFirstTokenNanos = new AtomicLong();
private final SseResponseAccumulator accumulator =
new SseResponseAccumulator(BraintrustJsonMapper.get());

WrappedServerSentEventListener(
ServerSentEventListener delegate, Span span, String providerName) {
ServerSentEventListener delegate, Span span, String providerName, Tracer tracer) {
this.delegate = delegate;
this.span = span;
this.providerName = providerName;
this.tracer = tracer;
}

@Override
Expand Down Expand Up @@ -188,8 +192,9 @@ private void accumulateChunk(String data) {
private void finalizeSpan() {
try {
Long ttft = timeToFirstTokenNanos.get();
String responseBody = accumulator.build();
InstrumentationSemConv.tagLLMSpanResponse(
span, providerName, accumulator.build(), ttft);
tracer, span, providerName, responseBody, ttft);
} catch (Exception e) {
log.debug("Failed to finalize streaming span", e);
}
Expand Down
Loading
Loading