Skip to content

Commit eccebc7

Browse files
committed
response accumulator util
1 parent c98c40c commit eccebc7

15 files changed

Lines changed: 953 additions & 159 deletions

File tree

‎braintrust-sdk/instrumentation/langchain_1_8_0/src/main/java/dev/braintrust/instrumentation/langchain/v1_8_0/WrappedHttpClient.java‎

Lines changed: 8 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
package dev.braintrust.instrumentation.langchain.v1_8_0;
22

3-
import static dev.braintrust.json.BraintrustJsonMapper.toJson;
4-
5-
import com.fasterxml.jackson.databind.JsonNode;
63
import dev.braintrust.bootstrap.BraintrustBridge;
74
import dev.braintrust.instrumentation.InstrumentationSemConv;
5+
import dev.braintrust.instrumentation.SseResponseAccumulator;
86
import dev.braintrust.json.BraintrustJsonMapper;
97
import dev.langchain4j.exception.HttpException;
108
import dev.langchain4j.http.client.HttpClient;
@@ -126,9 +124,8 @@ static class WrappedServerSentEventListener implements ServerSentEventListener {
126124
private final String providerName;
127125
private final long startNanos = System.nanoTime();
128126
private final AtomicLong timeToFirstTokenNanos = new AtomicLong();
129-
private final StringBuilder contentBuffer = new StringBuilder();
130-
private String finishReason = null;
131-
private JsonNode usageData = null;
127+
private final SseResponseAccumulator accumulator =
128+
new SseResponseAccumulator(BraintrustJsonMapper.get());
132129

133130
WrappedServerSentEventListener(
134131
ServerSentEventListener delegate, Span span, String providerName) {
@@ -182,52 +179,17 @@ public void onClose() {
182179

183180
private void accumulateChunk(String data) {
184181
if (data == null || data.isEmpty() || "[DONE]".equals(data)) return;
185-
try {
186-
if (timeToFirstTokenNanos.get() == 0L) {
187-
timeToFirstTokenNanos.compareAndExchange(0L, System.nanoTime() - startNanos);
188-
}
189-
JsonNode chunk = BraintrustJsonMapper.get().readTree(data);
190-
if (chunk.has("choices") && chunk.get("choices").size() > 0) {
191-
JsonNode choice = chunk.get("choices").get(0);
192-
if (choice.has("delta")) {
193-
JsonNode delta = choice.get("delta");
194-
if (delta.has("content")) {
195-
contentBuffer.append(delta.get("content").asText());
196-
}
197-
}
198-
if (choice.has("finish_reason") && !choice.get("finish_reason").isNull()) {
199-
finishReason = choice.get("finish_reason").asText();
200-
}
201-
}
202-
if (chunk.has("usage") && !chunk.get("usage").isNull()) {
203-
usageData = chunk.get("usage");
204-
}
205-
} catch (Exception e) {
206-
log.debug("Failed to parse SSE chunk: {}", data, e);
182+
if (timeToFirstTokenNanos.get() == 0L) {
183+
timeToFirstTokenNanos.compareAndExchange(0L, System.nanoTime() - startNanos);
207184
}
185+
accumulator.merge(data);
208186
}
209187

210188
private void finalizeSpan() {
211189
try {
212-
var root = BraintrustJsonMapper.get().createObjectNode();
213-
214-
var choicesArray = BraintrustJsonMapper.get().createArrayNode();
215-
var choice = BraintrustJsonMapper.get().createObjectNode();
216-
choice.put("index", 0);
217-
if (finishReason != null) choice.put("finish_reason", finishReason);
218-
var message = BraintrustJsonMapper.get().createObjectNode();
219-
message.put("role", "assistant");
220-
message.put("content", contentBuffer.toString());
221-
choice.set("message", message);
222-
choicesArray.add(choice);
223-
root.set("choices", choicesArray);
224-
225-
if (usageData != null) {
226-
root.set("usage", usageData);
227-
}
228-
229190
Long ttft = timeToFirstTokenNanos.get();
230-
InstrumentationSemConv.tagLLMSpanResponse(span, providerName, toJson(root), ttft);
191+
InstrumentationSemConv.tagLLMSpanResponse(
192+
span, providerName, accumulator.build(), ttft);
231193
} catch (Exception e) {
232194
log.debug("Failed to finalize streaming span", e);
233195
}

‎braintrust-sdk/instrumentation/langchain_1_8_0/src/main/java/dev/braintrust/instrumentation/langchain/v1_8_0/auto/LangchainInstrumentationModule.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public List<String> getHelperClassNames() {
3636
MANUAL_PACKAGE + "TracingProxy",
3737
MANUAL_PACKAGE + "TracingToolExecutor",
3838
MANUAL_PACKAGE + "OtelContextPassingExecutor",
39+
"dev.braintrust.instrumentation.SseResponseAccumulator",
3940
"dev.braintrust.instrumentation.InstrumentationSemConv",
4041
"dev.braintrust.json.BraintrustJsonMapper");
4142
}

‎braintrust-sdk/instrumentation/langchain_1_8_0/src/test/java/dev/braintrust/instrumentation/langchain/v1_8_0/BraintrustLangchainTest.java‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
import dev.braintrust.TestHarness;
88
import dev.braintrust.instrumentation.Instrumenter;
99
import dev.langchain4j.agent.tool.Tool;
10+
import dev.langchain4j.agent.tool.ToolSpecification;
1011
import dev.langchain4j.data.message.UserMessage;
1112
import dev.langchain4j.model.chat.ChatModel;
1213
import dev.langchain4j.model.chat.StreamingChatModel;
14+
import dev.langchain4j.model.chat.request.ChatRequest;
15+
import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
1316
import dev.langchain4j.model.chat.response.ChatResponse;
1417
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
1518
import dev.langchain4j.model.openai.OpenAiChatModel;
@@ -109,6 +112,9 @@ void testSyncChatCompletion() {
109112
assertNotNull(
110113
output.get(0).get("message").get("content"),
111114
"Output should contain assistant response content");
115+
116+
// The serialized span output should reflect the full response the client received.
117+
assertSpanOutputReflects(response, span);
112118
}
113119

114120
@Test
@@ -239,6 +245,129 @@ public void onError(Throwable error) {
239245
choice.get("message").get("content"),
240246
"Output should contain the complete streamed response");
241247
assertNotNull(choice.get("finish_reason"), "Output should have finish_reason");
248+
249+
// The reconstructed streaming span output should reflect the full response the client
250+
// received — the instrumentation must feed every SSE event to the accumulator.
251+
assertSpanOutputReflects(response, llmSpan);
252+
}
253+
254+
@Test
255+
@SneakyThrows
256+
void testStreamingChatCompletionWithTools() {
257+
// Auto-instrumentation intercepts OpenAiStreamingChatModel.Builder.build()
258+
StreamingChatModel model =
259+
OpenAiStreamingChatModel.builder()
260+
.apiKey(testHarness.openAiApiKey())
261+
.baseUrl(testHarness.openAiBaseUrl())
262+
.modelName("gpt-4o")
263+
.temperature(0.0)
264+
.build();
265+
266+
var weatherTool =
267+
ToolSpecification.builder()
268+
.name("get_weather")
269+
.description("Get the current weather for a location")
270+
.parameters(
271+
JsonObjectSchema.builder()
272+
.addStringProperty(
273+
"location",
274+
"The city and state, e.g. San" + " Francisco, CA")
275+
.required("location")
276+
.build())
277+
.build();
278+
279+
var chatRequest =
280+
ChatRequest.builder()
281+
.messages(UserMessage.from("What is the weather in Paris, France?"))
282+
.toolSpecifications(weatherTool)
283+
.build();
284+
285+
var future = new CompletableFuture<ChatResponse>();
286+
model.chat(
287+
chatRequest,
288+
new StreamingChatResponseHandler() {
289+
@Override
290+
public void onPartialResponse(String token) {}
291+
292+
@Override
293+
public void onCompleteResponse(ChatResponse response) {
294+
future.complete(response);
295+
}
296+
297+
@Override
298+
public void onError(Throwable error) {
299+
future.completeExceptionally(error);
300+
}
301+
});
302+
var response = future.get();
303+
304+
// The stream must carry tool-call deltas (merged by index) all the way to the span — the
305+
// original bug dropped tool_calls entirely from streaming reconstruction.
306+
assertTrue(
307+
response.aiMessage().hasToolExecutionRequests(),
308+
"Model should have requested a tool call");
309+
310+
var llmSpan =
311+
testHarness.awaitExportedSpans(1).stream()
312+
.filter(s -> s.getName().equals("Chat Completion"))
313+
.findFirst()
314+
.orElseThrow(() -> new AssertionError("no 'Chat Completion' llm span"));
315+
316+
assertSpanOutputReflects(response, llmSpan);
317+
}
318+
319+
/**
320+
* Asserts that the llm span's serialized output ({@code braintrust.output_json}) reflects the
321+
* full response the langchain client received — comparing the reconstructed assistant message
322+
* against the client's parsed {@link ChatResponse} (content, thinking, and tool calls) rather
323+
* than hand-asserting individual fields per test. langchain decodes the same stream
324+
* independently of our accumulator, so agreement is a meaningful end-to-end check.
325+
*/
326+
@SneakyThrows
327+
private void assertSpanOutputReflects(ChatResponse clientResponse, SpanData llmSpan) {
328+
String outputJson =
329+
llmSpan.getAttributes().get(AttributeKey.stringKey("braintrust.output_json"));
330+
assertNotNull(outputJson, "Span should have braintrust.output_json");
331+
JsonNode message = JSON_MAPPER.readTree(outputJson).get(0).get("message");
332+
assertNotNull(message, "Span output should contain a choice message");
333+
334+
var aiMessage = clientResponse.aiMessage();
335+
336+
if (aiMessage.text() != null) {
337+
assertEquals(
338+
aiMessage.text(),
339+
message.path("content").asText(),
340+
"Span output content should match the client's assistant text");
341+
}
342+
if (aiMessage.thinking() != null) {
343+
assertEquals(
344+
aiMessage.thinking(),
345+
message.path("reasoning_content").asText(),
346+
"Span output reasoning_content should match the client's thinking");
347+
}
348+
if (aiMessage.hasToolExecutionRequests()) {
349+
JsonNode toolCalls = message.get("tool_calls");
350+
assertNotNull(toolCalls, "Span output should contain tool_calls");
351+
var requests = aiMessage.toolExecutionRequests();
352+
assertEquals(
353+
requests.size(), toolCalls.size(), "tool_calls count should match the client");
354+
for (int i = 0; i < requests.size(); i++) {
355+
var request = requests.get(i);
356+
JsonNode function = toolCalls.get(i).get("function");
357+
assertEquals(
358+
request.name(), function.get("name").asText(), "tool name should match");
359+
assertEquals(
360+
JSON_MAPPER.readTree(request.arguments()),
361+
JSON_MAPPER.readTree(function.get("arguments").asText()),
362+
"tool arguments should match");
363+
if (request.id() != null) {
364+
assertEquals(
365+
request.id(),
366+
toolCalls.get(i).get("id").asText(),
367+
"tool id should match");
368+
}
369+
}
370+
}
242371
}
243372

244373
@Test

0 commit comments

Comments
 (0)