diff --git a/AGENTS.md b/AGENTS.md index 42ee2aaf..75db5c5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,11 +160,12 @@ VCR_MODE=record ./gradlew :braintrust-sdk:test --tests 'dev.braintrust.devserver - when running btx, use the spec filter to target what is specifically under development: `VCR_MODE=off ./gradlew :btx:test -Pbtx.spec.filter=openai/prompt_cach --rerun` - don't reformat the whole repo, but do run `./gradlew spotlessApply` on files you changed before committing. the pre-commit hook and `./gradlew check` both run `spotlessCheck`, which fails on unformatted code. -## Gotchas +## Misc Tips and Best Practices - **don't hand-edit cassettes.** they're content-hashed and guarded against committed secrets. a failing VCR test means the recorded interaction changed — re-record it (see the VCR section), don't patch the json. - **`braintrust-api` is generated code.** don't edit sources under it by hand; it's regenerated from the braintrust openapi spec pinned as `braintrustOpenApiRef` in gradle.properties. - **there are no version constants to bump.** the sdk version is derived from git tags at build time (`generateVersion()` in build.gradle) and written into braintrust.properties. "bump the version" is not a source change. +- When adding test cases, favor adding to the test file of the module being changed rather than making a new file. For example, if you fix a bug in the `Foo` module, add the test case to `FooTest.java` instead of making a new file, `FooTestMyBuggyCase.java` ## Releasing diff --git a/braintrust-sdk/instrumentation/anthropic_2_2_0/src/main/java/dev/braintrust/instrumentation/anthropic/v2_2_0/ResponseReassembler.java b/braintrust-sdk/instrumentation/anthropic_2_2_0/src/main/java/dev/braintrust/instrumentation/anthropic/v2_2_0/ResponseReassembler.java new file mode 100644 index 00000000..5decf031 --- /dev/null +++ b/braintrust-sdk/instrumentation/anthropic_2_2_0/src/main/java/dev/braintrust/instrumentation/anthropic/v2_2_0/ResponseReassembler.java @@ -0,0 +1,117 @@ +package dev.braintrust.instrumentation.anthropic.v2_2_0; + +import com.anthropic.helpers.MessageAccumulator; +import com.anthropic.models.messages.RawMessageStreamEvent; +import dev.braintrust.json.BraintrustJsonMapper; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import javax.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; + +/** + * Turns the raw bytes of an Anthropic response into a single JSON document the semconv layer can + * tag. + * + *
All of the wire-format bookkeeping lives here — SSE-vs-plain-JSON detection and chunk + * reassembly — so that {@code TracingHttpClient} is left holding only the span lifecycle and one + * flat call into {@code InstrumentationSemConv}. + */ +@Slf4j +class ResponseReassembler { + + private ResponseReassembler() {} + + /** + * A reassembled response body plus the timing that belongs with it. + * + *
{@code body} is null when there was nothing usable to reassemble — an empty response, or + * one we couldn't parse. Callers should still tag the response in that case; the headers remain + * worth recording. + * + *
{@code timeToFirstTokenNanos} is only populated for a stream, since a non-streaming + * response has no first token to time. + */ + record Result(@Nullable String body, @Nullable Long timeToFirstTokenNanos) { + static final Result EMPTY = new Result(null, null); + } + + /** Detects the wire format and reassembles accordingly. Never throws. */ + static Result reassemble(byte[] bytes, long timeToFirstTokenNanos) { + if (bytes.length == 0) { + return Result.EMPTY; + } + try { + String firstLine = firstNonEmptyLine(bytes); + // Anthropic SSE starts with "event: message_start\ndata: ..." so we detect either + // prefix. OpenAI SSE starts directly with "data:". + boolean isSse = + firstLine != null + && (firstLine.startsWith("data:") || firstLine.startsWith("event:")); + if (isSse) { + return new Result(reassembleSse(bytes), timeToFirstTokenNanos); + } + // Non-streaming: plain Message JSON — pass it whole, no time_to_first_token + return new Result(new String(bytes, StandardCharsets.UTF_8), null); + } catch (Exception e) { + log.error("Could not reassemble Anthropic response buffer", e); + return Result.EMPTY; + } + } + + @Nullable + private static String firstNonEmptyLine(byte[] bytes) { + int start = 0; + for (int i = 0; i <= bytes.length; i++) { + if (i == bytes.length || bytes[i] == '\n') { + String line = new String(bytes, start, i - start, StandardCharsets.UTF_8).strip(); + if (!line.isEmpty()) return line; + start = i + 1; + } + } + return null; + } + + /** + * Anthropic SSE wire format has named events: + * + *
+ * event: message_start
+ * data: {"type":"message_start","message":{...}}
+ *
+ * event: content_block_delta
+ * data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}
+ *
+ *
+ * We only need the {@code data:} lines — the event name is redundant with the {@code type}
+ * field inside the JSON. Feed each data payload to {@link MessageAccumulator} and serialize the
+ * assembled {@link com.anthropic.models.messages.Message}.
+ */
+ @Nullable
+ private static String reassembleSse(byte[] sseBytes) {
+ try {
+ var mapper = BraintrustJsonMapper.get();
+ var reader =
+ new BufferedReader(
+ new InputStreamReader(
+ new ByteArrayInputStream(sseBytes), StandardCharsets.UTF_8));
+ var accumulator = MessageAccumulator.create();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (!line.startsWith("data:")) continue;
+ String data = line.substring("data:".length()).strip();
+ if (data.isEmpty()) continue;
+ try {
+ accumulator.accumulate(mapper.readValue(data, RawMessageStreamEvent.class));
+ } catch (Exception ignored) {
+ // skip unrecognized event types (e.g. ping)
+ }
+ }
+ return BraintrustJsonMapper.toJson(accumulator.message());
+ } catch (Exception e) {
+ log.error("Could not parse Anthropic SSE buffer to tag streaming span output", e);
+ return null;
+ }
+ }
+}
diff --git a/braintrust-sdk/instrumentation/anthropic_2_2_0/src/main/java/dev/braintrust/instrumentation/anthropic/v2_2_0/TracingHttpClient.java b/braintrust-sdk/instrumentation/anthropic_2_2_0/src/main/java/dev/braintrust/instrumentation/anthropic/v2_2_0/TracingHttpClient.java
index 7fc9f905..65dd2fe8 100644
--- a/braintrust-sdk/instrumentation/anthropic_2_2_0/src/main/java/dev/braintrust/instrumentation/anthropic/v2_2_0/TracingHttpClient.java
+++ b/braintrust-sdk/instrumentation/anthropic_2_2_0/src/main/java/dev/braintrust/instrumentation/anthropic/v2_2_0/TracingHttpClient.java
@@ -5,11 +5,8 @@
import com.anthropic.core.http.HttpRequest;
import com.anthropic.core.http.HttpRequestBody;
import com.anthropic.core.http.HttpResponse;
-import com.anthropic.helpers.MessageAccumulator;
-import com.anthropic.models.messages.RawMessageStreamEvent;
import dev.braintrust.bootstrap.BraintrustBridge;
import dev.braintrust.instrumentation.InstrumentationSemConv;
-import dev.braintrust.json.BraintrustJsonMapper;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanContext;
@@ -17,13 +14,13 @@
import io.opentelemetry.api.trace.TraceState;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Context;
-import java.io.BufferedReader;
-import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
-import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
@@ -125,7 +122,9 @@ public void close() {
bufferedRequest.baseUrl() != null ? bufferedRequest.baseUrl() : "",
bufferedRequest.pathSegments(),
bufferedRequest.method().name(),
- inputJson);
+ inputJson,
+ null,
+ headersAsMap(bufferedRequest.headers()));
var response = underlying.execute(bufferedRequest, requestOptions);
return new TeeingStreamHttpResponse(response, span, tracer);
@@ -153,7 +152,9 @@ public void close() {
bufferedRequest.baseUrl() != null ? bufferedRequest.baseUrl() : "",
bufferedRequest.pathSegments(),
bufferedRequest.method().name(),
- inputJson);
+ inputJson,
+ null,
+ headersAsMap(bufferedRequest.headers()));
return underlying
.executeAsync(bufferedRequest, requestOptions)
.thenApply(
@@ -264,9 +265,39 @@ private void onStreamClosed() {
synchronized (teeBuffer) {
bytes = teeBuffer.toByteArray();
}
+
+ // Recorded before tagging: the anthropic sdk raises above this layer, so the
+ // error status is ours alone to set, and losing it to a body-parsing problem is
+ // worse than losing the parsed output.
+ // Anything outside 2xx, not just 4xx/5xx: both vendor SDKs treat success as
+ // exactly 200..299, so a final 3xx that the http client did not follow (a 304, or
+ // a redirect with no usable Location) is raised to the caller as an
+ // UnexpectedStatusCodeException and must mark the span failed too.
+ int statusCode = delegate.statusCode();
+ if (statusCode < 200 || statusCode >= 300) {
+ InstrumentationSemConv.tagLLMSpanHttpError(
+ span, statusCode, new String(bytes, StandardCharsets.UTF_8));
+ }
+
+ // Wire-format bookkeeping lives in ResponseReassembler; this hands semconv
+ // everything the response carried in one flat call. A null body (empty or
+ // unparseable response) still tags the headers.
// 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());
+ try {
+ var reassembled =
+ ResponseReassembler.reassemble(bytes, timeToFirstTokenNanos.get());
+ InstrumentationSemConv.tagLLMSpanResponse(
+ tracer,
+ span,
+ InstrumentationSemConv.PROVIDER_NAME_ANTHROPIC,
+ reassembled.body(),
+ reassembled.timeToFirstTokenNanos(),
+ headersAsMap(delegate.headers()));
+ } catch (Exception e) {
+ // Observability must never change the response behavior seen by the caller.
+ log.error("Could not tag span from response buffer", e);
+ }
} finally {
span.end();
}
@@ -360,89 +391,25 @@ private void notifyClosed() {
// Span tagging from buffered bytes
// -------------------------------------------------------------------------
- private static void tagSpanFromBuffer(
- Tracer tracer, Span span, byte[] bytes, Long timeToFirstTokenNanos) {
- if (bytes.length == 0) return;
- try {
- String firstLine = firstNonEmptyLine(bytes);
- // Anthropic SSE starts with "event: message_start\ndata: ..." so we detect
- // either prefix. OpenAI SSE starts directly with "data:".
- boolean isSse =
- firstLine != null
- && (firstLine.startsWith("data:") || firstLine.startsWith("event:"));
- if (isSse) {
- 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,
- responseJson,
- null);
- }
- } catch (Exception e) {
- log.error("Could not tag span from Anthropic response buffer", e);
- }
- }
-
- private static String firstNonEmptyLine(byte[] bytes) {
- int start = 0;
- for (int i = 0; i <= bytes.length; i++) {
- if (i == bytes.length || bytes[i] == '\n') {
- String line = new String(bytes, start, i - start, StandardCharsets.UTF_8).strip();
- if (!line.isEmpty()) return line;
- start = i + 1;
- }
- }
- return null;
- }
-
/**
- * Anthropic SSE wire format has named events:
- *
- *
- * event: message_start
- * data: {"type":"message_start","message":{...}}
- *
- * event: content_block_delta
- * data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}
- *
- *
- * We only need the {@code data:} lines — the event name is redundant with the {@code type}
- * field inside the JSON. Feed each data payload to {@link MessageAccumulator} and serialize the
- * assembled {@link com.anthropic.models.messages.Message} for the span.
+ * Adapts the anthropic sdk's {@code Headers} to the vendor-neutral shape {@link
+ * InstrumentationSemConv} consumes. Returns an empty map on failure so a header-shape change
+ * can never take down the tagging that follows it.
*/
- private static void tagSpanFromSseBytes(
- Tracer tracer, Span span, byte[] sseBytes, Long timeToFirstTokenNanos) {
+ private static MapAll of the wire-format bookkeeping lives here — SSE-vs-plain-JSON detection, per-endpoint + * accumulator selection, chunk reassembly — so that {@code TracingHttpClient} is left holding only + * the span lifecycle and one flat call into {@code InstrumentationSemConv}. + */ +@Slf4j +class ResponseReassembler { + private static final JsonMapper JSON_MAPPER = ObjectMappers.jsonMapper(); + + private ResponseReassembler() {} + + /** + * A reassembled response body plus the timing that belongs with it. + * + *
{@code body} is null when there was nothing usable to reassemble — an empty response, or + * an SSE stream whose shape we don't recognize. Callers should still tag the response in that + * case; the headers remain worth recording. + * + *
{@code timeToFirstTokenNanos} is only populated for a stream, since a non-streaming
+ * response has no first token to time and a zero would land in latency aggregates as if it were
+ * a real measurement.
+ */
+ record Result(@Nullable String body, @Nullable Long timeToFirstTokenNanos) {
+ static final Result EMPTY = new Result(null, null);
+ }
+
+ /** Detects the wire format and reassembles accordingly. Never throws. */
+ static Result reassemble(byte[] bytes, long timeToFirstTokenNanos) {
+ if (bytes.length == 0) {
+ return Result.EMPTY;
+ }
+ try {
+ String firstLine = firstNonEmptyLine(bytes);
+ boolean isSse =
+ firstLine != null
+ && (firstLine.startsWith("data:") || firstLine.startsWith("event:"));
+ if (isSse) {
+ return new Result(reassembleSse(bytes), timeToFirstTokenNanos);
+ }
+ return new Result(new String(bytes, StandardCharsets.UTF_8), null);
+ } catch (Exception e) {
+ log.error("Could not reassemble OpenAI response buffer", e);
+ return Result.EMPTY;
+ }
+ }
+
+ @Nullable
+ private static String firstNonEmptyLine(byte[] bytes) {
+ int start = 0;
+ for (int i = 0; i <= bytes.length; i++) {
+ if (i == bytes.length || bytes[i] == '\n') {
+ String line = new String(bytes, start, i - start, StandardCharsets.UTF_8).strip();
+ if (!line.isEmpty()) return line;
+ start = i + 1;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Parses SSE wire bytes and feeds each {@code data:} chunk through the accumulator matching the
+ * stream's shape, returning the reassembled response JSON — or null if the shape was not
+ * recognized.
+ */
+ @Nullable
+ private static String reassembleSse(byte[] sseBytes) {
+ try {
+ var reader =
+ new BufferedReader(
+ new InputStreamReader(
+ new ByteArrayInputStream(sseBytes), StandardCharsets.UTF_8));
+ String line;
+ String responseJson = null;
+ while ((line = reader.readLine()) != null) {
+ if (!line.startsWith("data:")) continue;
+ var firstEventJson = line.substring("data:".length()).strip();
+ // after the first data chunk is found, read the rest of the stream with the proper
+ // accumulator type
+ var jsonTree = JSON_MAPPER.readTree(firstEventJson);
+ if (jsonTree.has("type") && jsonTree.get("type").asText().startsWith("response")) {
+ // response API SSEvents
+ ResponseAccumulator accumulator = ResponseAccumulator.create();
+ accumulator.accumulate(
+ JSON_MAPPER.readValue(firstEventJson, ResponseStreamEvent.class));
+ while ((line = reader.readLine()) != null) {
+ if (!line.startsWith("data:")) continue;
+ String data = line.substring("data:".length()).strip();
+ if (data.isEmpty() || data.equals("[DONE]")) continue;
+ ResponseStreamEvent rse =
+ JSON_MAPPER.readValue(data, ResponseStreamEvent.class);
+ accumulator.accumulate(rse);
+ }
+ responseJson = JSON_MAPPER.writeValueAsString(accumulator.response());
+ } else if (jsonTree.has("object")
+ && jsonTree.get("object").asText().equals("chat.completion.chunk")) {
+ // completions API SSEvents
+ var accumulator = ChatCompletionAccumulator.create();
+ accumulator.accumulate(
+ JSON_MAPPER.readValue(firstEventJson, ChatCompletionChunk.class));
+ while ((line = reader.readLine()) != null) {
+ if (!line.startsWith("data:")) continue;
+ String data = line.substring("data:".length()).strip();
+ if (data.isEmpty() || data.equals("[DONE]")) continue;
+ ChatCompletionChunk chunk =
+ BraintrustJsonMapper.get()
+ .readValue(data, ChatCompletionChunk.class);
+ accumulator.accumulate(chunk);
+ }
+ responseJson = JSON_MAPPER.writeValueAsString(accumulator.chatCompletion());
+ } else {
+ log.warn("unknown SSE object {}", firstEventJson);
+ }
+ break;
+ }
+ return responseJson;
+ } catch (Exception e) {
+ log.error("Could not parse SSE buffer to tag streaming span output", e);
+ return null;
+ }
+ }
+}
diff --git a/braintrust-sdk/instrumentation/openai_2_15_0/src/main/java/dev/braintrust/instrumentation/openai/v2_15_0/TracingHttpClient.java b/braintrust-sdk/instrumentation/openai_2_15_0/src/main/java/dev/braintrust/instrumentation/openai/v2_15_0/TracingHttpClient.java
index 2d4d958b..dcb1f017 100644
--- a/braintrust-sdk/instrumentation/openai_2_15_0/src/main/java/dev/braintrust/instrumentation/openai/v2_15_0/TracingHttpClient.java
+++ b/braintrust-sdk/instrumentation/openai_2_15_0/src/main/java/dev/braintrust/instrumentation/openai/v2_15_0/TracingHttpClient.java
@@ -4,13 +4,8 @@
import com.openai.core.ObjectMappers;
import com.openai.core.RequestOptions;
import com.openai.core.http.*;
-import com.openai.helpers.ChatCompletionAccumulator;
-import com.openai.helpers.ResponseAccumulator;
-import com.openai.models.chat.completions.ChatCompletionChunk;
-import com.openai.models.responses.ResponseStreamEvent;
import dev.braintrust.bootstrap.BraintrustBridge;
import dev.braintrust.instrumentation.InstrumentationSemConv;
-import dev.braintrust.json.BraintrustJsonMapper;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanContext;
@@ -20,6 +15,9 @@
import io.opentelemetry.context.Context;
import java.io.*;
import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
@@ -68,7 +66,7 @@ private static ExtractedRequest extractCallerContext(HttpRequest request) {
Context context = contextFromTraceparent(values.get(0));
HttpRequest stripped =
request.toBuilder()
- .replaceHeaders(ContextCapturingProxy.CONTEXT_HEADER, java.util.List.of())
+ .replaceHeaders(ContextCapturingProxy.CONTEXT_HEADER, List.of())
.build();
return new ExtractedRequest(stripped, context);
}
@@ -126,7 +124,9 @@ public void close() {
bufferedRequest.baseUrl() != null ? bufferedRequest.baseUrl() : "",
bufferedRequest.pathSegments(),
bufferedRequest.method().name(),
- inputJson);
+ inputJson,
+ null,
+ headersAsMap(bufferedRequest.headers()));
var response = underlying.execute(bufferedRequest, requestOptions);
// Always tee the response body. onStreamClosed() detects whether the collected
// bytes are SSE or plain JSON and tags the span accordingly.
@@ -155,7 +155,9 @@ public void close() {
bufferedRequest.baseUrl() != null ? bufferedRequest.baseUrl() : "",
bufferedRequest.pathSegments(),
bufferedRequest.method().name(),
- inputJson);
+ inputJson,
+ null,
+ headersAsMap(bufferedRequest.headers()));
return underlying
.executeAsync(bufferedRequest, requestOptions)
.thenApply(
@@ -237,104 +239,30 @@ private static String readBodyAsString(HttpRequestBody body) {
}
/**
- * Tags the span from bytes collected by {@link TeeingStreamHttpResponse}. Auto-detects whether
- * the bytes are an SSE stream (first non-empty line starts with {@code "data: "}) or a plain
- * JSON response, and parses accordingly.
+ * The response body as a single JSON document, plus the timing that goes with it. Reassembly is
+ * kept separate from tagging so that {@link TeeingStreamHttpResponse#onStreamClosed} can hand
+ * the semconv layer everything about the response in one call — a body we couldn't produce
+ * becomes a null {@code body} rather than a skipped tagging call, which is what keeps the
+ * response headers from being lost on an empty or unrecognized body.
*/
- private static void tagSpanFromBuffer(
- Tracer tracer, Span span, byte[] bytes, Long timeToFirstTokenNanos) {
- if (bytes.length == 0) return;
- try {
- String firstLine = firstNonEmptyLine(bytes);
- if (firstLine != null
- && (firstLine.startsWith("data:") || firstLine.startsWith("event:"))) {
- tagSpanFromSseBytes(tracer, span, bytes, timeToFirstTokenNanos);
- } else {
- String responseJson = new String(bytes, StandardCharsets.UTF_8);
- InstrumentationSemConv.tagLLMSpanResponse(
- tracer, span, InstrumentationSemConv.PROVIDER_NAME_OPENAI, responseJson);
- }
- } catch (Exception e) {
- log.error("Could not tag span from response buffer", e);
- }
- }
-
- private static String firstNonEmptyLine(byte[] bytes) {
- int start = 0;
- for (int i = 0; i <= bytes.length; i++) {
- if (i == bytes.length || bytes[i] == '\n') {
- String line = new String(bytes, start, i - start, StandardCharsets.UTF_8).strip();
- if (!line.isEmpty()) return line;
- start = i + 1;
- }
- }
- return null;
- }
-
/**
- * Parses SSE wire bytes, feeds each {@code data:} chunk through {@link
- * ChatCompletionAccumulator}, then tags the span with the reassembled output JSON.
+ * Adapts openai-java's {@link Headers} to the vendor-neutral shape {@link
+ * InstrumentationSemConv} consumes. Returns an empty map on failure so a header-shape change
+ * can never take down the tagging that follows it.
*/
- private static void tagSpanFromSseBytes(
- Tracer tracer, Span span, byte[] sseBytes, Long timeToFirstTokenNanos) {
+ private static Map {@code responseBody} is nullable because a response can legitimately arrive with nothing
+ * usable in it — an empty error body, or an SSE stream whose shape we don't recognize. The
+ * headers are still worth tagging in those cases, so body handling is skipped rather than the
+ * whole call being abandoned.
*/
@SneakyThrows
public static void tagLLMSpanResponse(
@Nonnull Tracer tracer,
Span span,
@Nonnull String providerName,
- @Nonnull String responseBody,
- @Nullable Long timeToFirstTokenNanoseconds) {
+ @Nullable String responseBody,
+ @Nullable Long timeToFirstTokenNanoseconds,
+ @Nonnull Map Matching is case-insensitive here rather than at the call site because callers disagree —
+ * openai-java's {@code Headers} is backed by a case-insensitive {@code TreeMap}, while
+ * langchain4j hands over a plain {@code HashMap}.
+ */
+ private static void tagIdHeaders(
+ @Nonnull Span span, @Nonnull Map Absent on responses that carry no ID of their own (Bedrock, and any error body), which is
+ * exactly when the ID headers matter instead.
+ */
+ private static void tagResponseId(Span span, JsonNode responseJson) {
+ JsonNode id = responseJson.get("id");
+ if (id != null && id.isTextual() && !id.asText().isBlank()) {
+ span.setAttribute("response_id", id.asText());
+ }
+ }
+
/**
* Emit child {@code type:"tool"} spans for built-in tool calls the vendor executed server
* side (web search, file search, code interpreter, image generation, remote MCP) that the
@@ -200,6 +326,8 @@ private static void tagOpenAIRequest(
@SneakyThrows
private static void tagOpenAIResponse(
Span span, JsonNode responseJson, @Nullable Long timeToFirstTokenNanoseconds) {
+ tagResponseId(span, responseJson);
+
// Output — chat completions API uses "choices"; Responses API uses "output"; audio
// transcriptions and translations return a text-keyed object.
if (responseJson.has("choices")) {
@@ -493,6 +621,8 @@ private static void tagAnthropicResponse(
String responseBody,
JsonNode responseJson,
@Nullable Long timeToFirstTokenNanoseconds) {
+ tagResponseId(span, responseJson);
+
// Anthropic response is the full Message object — output it whole
span.setAttribute("braintrust.output_json", responseBody);
diff --git a/test-harness/src/testFixtures/java/dev/braintrust/VCR.java b/test-harness/src/testFixtures/java/dev/braintrust/VCR.java
index 5b519f60..073baa6b 100644
--- a/test-harness/src/testFixtures/java/dev/braintrust/VCR.java
+++ b/test-harness/src/testFixtures/java/dev/braintrust/VCR.java
@@ -20,6 +20,7 @@
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
@@ -474,6 +475,7 @@ private void createProgrammaticStubFromMapping(
com.github.tomakehurst.wiremock.client.WireMock.aResponse()
.withStatus(status)
.withHeader("Content-Type", responseContentType);
+ copyRecordedResponseHeaders(mapping, response);
// Binary event-stream bodies must be served as raw bytes to avoid UTF-8 corruption
if (isEventStream) {
@@ -499,6 +501,45 @@ private void createProgrammaticStubFromMapping(
wireMock.stubFor(stub.willReturn(response));
}
+ /**
+ * Replays the recorded response headers onto a programmatically-built stub.
+ *
+ * Mappings that WireMock loads natively serve their recorded headers for free, but the
+ * programmatic path above rebuilds the response from scratch and would otherwise serve only
+ * {@code Content-Type} — silently hiding provider headers (rate limits, {@code x-request-id})
+ * from any instrumentation under test on exactly the SSE responses these stubs exist for.
+ */
+ private static void copyRecordedResponseHeaders(
+ JsonNode mapping,
+ com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder response) {
+ JsonNode headers = mapping.at("/response/headers");
+ if (!headers.isObject()) {
+ return;
+ }
+ Iterator