diff --git a/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/build.gradle b/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/build.gradle
new file mode 100644
index 00000000..a58752b1
--- /dev/null
+++ b/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/build.gradle
@@ -0,0 +1,62 @@
+def awsBedrockVersion = '2.30.0'
+
+muzzle {
+ pass {
+ group = 'software.amazon.awssdk'
+ module = 'bedrockruntime'
+ // num of aws patch versions is huge so list out minor versions instead
+ pinVersions '2.30.0', '2.31.0', '2.32.0', '2.33.0', '2.34.0',
+ '2.35.0', '2.36.0', '2.37.0', '2.38.0', '2.39.0',
+ '2.40.0', '2.41.0'
+ // sdk-core is listed explicitly because SdkDefaultClientBuilder (the ByteBuddy
+ // interception target) lives there, not in bedrockruntime itself
+ extraDependency 'software.amazon.awssdk:sdk-core'
+ extraDependency "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}"
+ extraDependency "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jacksonVersion}"
+ extraDependency "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:${jacksonVersion}"
+ }
+ pass {
+ group = 'software.amazon.awssdk'
+ module = 'bedrockruntime'
+ versions = '[2.42.36,)' // latest version and up
+ extraDependency 'software.amazon.awssdk:sdk-core'
+ extraDependency "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}"
+ extraDependency "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jacksonVersion}"
+ extraDependency "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:${jacksonVersion}"
+ }
+}
+
+dependencies {
+ compileOnly project(':braintrust-java-agent:instrumenter')
+ implementation "io.opentelemetry:opentelemetry-api:${otelVersion}"
+ implementation 'com.google.code.findbugs:jsr305:3.0.2'
+ implementation "org.slf4j:slf4j-api:${slf4jVersion}"
+ implementation project(':braintrust-sdk')
+
+ // ByteBuddy for ElementMatcher types used in instrumentation definitions
+ compileOnly 'net.bytebuddy:byte-buddy:1.17.5'
+
+ // Target library — compileOnly because it will be on the app classpath at runtime
+ compileOnly "software.amazon.awssdk:bedrockruntime:${awsBedrockVersion}"
+
+ // Test dependencies
+ testImplementation(testFixtures(project(":test-harness")))
+ testImplementation project(':braintrust-java-agent:instrumenter')
+ testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}"
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+ testImplementation 'net.bytebuddy:byte-buddy-agent:1.17.5'
+ testRuntimeOnly "org.slf4j:slf4j-simple:${slf4jVersion}"
+ testImplementation "software.amazon.awssdk:bedrockruntime:${awsBedrockVersion}"
+ testImplementation "software.amazon.awssdk:netty-nio-client:${awsBedrockVersion}"
+ testImplementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}"
+}
+
+test {
+ useJUnitPlatform()
+ workingDir = rootProject.projectDir
+ testLogging {
+ events "passed", "skipped", "failed"
+ showStandardStreams = true
+ exceptionFormat "full"
+ }
+}
diff --git a/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/src/main/java/dev/braintrust/instrumentation/awsbedrock/v2_30_0/BraintrustAWSBedrock.java b/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/src/main/java/dev/braintrust/instrumentation/awsbedrock/v2_30_0/BraintrustAWSBedrock.java
new file mode 100644
index 00000000..7d5716fa
--- /dev/null
+++ b/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/src/main/java/dev/braintrust/instrumentation/awsbedrock/v2_30_0/BraintrustAWSBedrock.java
@@ -0,0 +1,71 @@
+package dev.braintrust.instrumentation.awsbedrock.v2_30_0;
+
+import io.opentelemetry.api.OpenTelemetry;
+import lombok.extern.slf4j.Slf4j;
+import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
+import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeAsyncClientBuilder;
+import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClientBuilder;
+
+/** Braintrust instrumentation for the AWS Bedrock Runtime client. */
+@Slf4j
+public class BraintrustAWSBedrock {
+
+ /**
+ * Wraps a {@link BedrockRuntimeClientBuilder} so that every {@code converse} call made through
+ * the resulting client is traced via OpenTelemetry.
+ *
+ *
Call this method after applying all custom builder settings
+ *
+ * @param openTelemetry the OpenTelemetry instance to use for tracing
+ * @param builder the client builder to instrument
+ * @return the same builder (for fluent chaining)
+ */
+ public static BedrockRuntimeClientBuilder wrap(
+ OpenTelemetry openTelemetry, BedrockRuntimeClientBuilder builder) {
+ try {
+ // Read existing config so we don't clobber user-registered interceptors
+ ClientOverrideConfiguration existing = builder.overrideConfiguration();
+ ClientOverrideConfiguration.Builder configBuilder = existing.toBuilder();
+ for (var interceptor : configBuilder.executionInterceptors()) {
+ if (interceptor instanceof BraintrustBedrockInterceptor) {
+ log.info("builder already wrapped. Skipping");
+ return builder;
+ }
+ }
+ configBuilder.addExecutionInterceptor(new BraintrustBedrockInterceptor(openTelemetry));
+ builder.overrideConfiguration(configBuilder.build());
+ } catch (Exception e) {
+ log.warn("Failed to apply Bedrock instrumentation", e);
+ }
+ return builder;
+ }
+
+ /**
+ * Wraps a {@link BedrockRuntimeAsyncClientBuilder} so that every {@code converseStream} call
+ * made through the resulting client is traced via OpenTelemetry.
+ *
+ *
Call this method after applying all custom builder settings.
+ *
+ * @param openTelemetry the OpenTelemetry instance to use for tracing
+ * @param builder the async client builder to instrument
+ * @return the same builder (for fluent chaining)
+ */
+ public static BedrockRuntimeAsyncClientBuilder wrap(
+ OpenTelemetry openTelemetry, BedrockRuntimeAsyncClientBuilder builder) {
+ try {
+ ClientOverrideConfiguration existing = builder.overrideConfiguration();
+ ClientOverrideConfiguration.Builder configBuilder = existing.toBuilder();
+ for (var interceptor : configBuilder.executionInterceptors()) {
+ if (interceptor instanceof BraintrustBedrockInterceptor) {
+ log.info("async builder already wrapped. Skipping");
+ return builder;
+ }
+ }
+ configBuilder.addExecutionInterceptor(new BraintrustBedrockInterceptor(openTelemetry));
+ builder.overrideConfiguration(configBuilder.build());
+ } catch (Exception e) {
+ log.warn("Failed to apply async Bedrock instrumentation", e);
+ }
+ return builder;
+ }
+}
diff --git a/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/src/main/java/dev/braintrust/instrumentation/awsbedrock/v2_30_0/BraintrustBedrockInterceptor.java b/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/src/main/java/dev/braintrust/instrumentation/awsbedrock/v2_30_0/BraintrustBedrockInterceptor.java
new file mode 100644
index 00000000..6f3e0d58
--- /dev/null
+++ b/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/src/main/java/dev/braintrust/instrumentation/awsbedrock/v2_30_0/BraintrustBedrockInterceptor.java
@@ -0,0 +1,412 @@
+package dev.braintrust.instrumentation.awsbedrock.v2_30_0;
+
+import dev.braintrust.instrumentation.InstrumentationSemConv;
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.context.Context;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringWriter;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import lombok.extern.slf4j.Slf4j;
+import org.reactivestreams.Publisher;
+import org.reactivestreams.Subscriber;
+import org.reactivestreams.Subscription;
+import software.amazon.awssdk.core.SdkRequest;
+import software.amazon.awssdk.core.interceptor.Context.BeforeExecution;
+import software.amazon.awssdk.core.interceptor.Context.ModifyHttpRequest;
+import software.amazon.awssdk.core.interceptor.Context.ModifyHttpResponse;
+import software.amazon.awssdk.core.interceptor.ExecutionAttribute;
+import software.amazon.awssdk.core.interceptor.ExecutionAttributes;
+import software.amazon.awssdk.core.interceptor.ExecutionInterceptor;
+import software.amazon.awssdk.core.interceptor.SdkExecutionAttribute;
+import software.amazon.awssdk.http.SdkHttpRequest;
+import software.amazon.awssdk.services.bedrockruntime.model.ConverseRequest;
+import software.amazon.awssdk.services.bedrockruntime.model.ConverseStreamRequest;
+import software.amazon.awssdk.thirdparty.jackson.core.JsonFactory;
+import software.amazon.awssdk.thirdparty.jackson.core.JsonParser;
+import software.amazon.awssdk.thirdparty.jackson.core.JsonToken;
+import software.amazon.eventstream.Message;
+import software.amazon.eventstream.MessageDecoder;
+
+/**
+ * AWS SDK ExecutionInterceptor that creates OpenTelemetry spans for Bedrock Converse calls,
+ * capturing the raw request and response bodies via {@link InstrumentationSemConv}.
+ */
+@Slf4j
+class BraintrustBedrockInterceptor implements ExecutionInterceptor {
+ private static final String INSTRUMENTATION_NAME = "braintrust-aws-bedrock";
+
+ private static final ExecutionAttribute SPAN_ATTRIBUTE =
+ new ExecutionAttribute<>("braintrust.span");
+ private static final ExecutionAttribute MODEL_ID_ATTRIBUTE =
+ new ExecutionAttribute<>("braintrust.modelId");
+
+ private static final JsonFactory JSON_FACTORY = new JsonFactory();
+
+ private final Tracer tracer;
+
+ BraintrustBedrockInterceptor(OpenTelemetry openTelemetry) {
+ this.tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME);
+ }
+
+ private static final Set INSTRUMENTED_OPERATIONS = Set.of("Converse", "ConverseStream");
+
+ @Override
+ public void beforeExecution(BeforeExecution context, ExecutionAttributes executionAttributes) {
+ String operationName =
+ executionAttributes.getAttribute(SdkExecutionAttribute.OPERATION_NAME);
+
+ // Only instrument Converse and ConverseStream — other Bedrock operations
+ // (InvokeModel, ApplyGuardrail, etc.) are not LLM calls we know how to tag.
+ if (!INSTRUMENTED_OPERATIONS.contains(operationName)) {
+ return;
+ }
+
+ SdkRequest sdkRequest = context.request();
+ String modelId = extractModelId(sdkRequest);
+
+ Span span = tracer.spanBuilder(operationName).setParent(Context.current()).startSpan();
+ executionAttributes.putAttribute(SPAN_ATTRIBUTE, span);
+ if (modelId != null) {
+ executionAttributes.putAttribute(MODEL_ID_ATTRIBUTE, modelId);
+ }
+ }
+
+ @Override
+ public SdkHttpRequest modifyHttpRequest(
+ ModifyHttpRequest context, ExecutionAttributes executionAttributes) {
+ Span span = executionAttributes.getAttribute(SPAN_ATTRIBUTE);
+ if (span == null) {
+ return context.httpRequest();
+ }
+
+ SdkHttpRequest httpRequest = context.httpRequest();
+ String modelId = executionAttributes.getAttribute(MODEL_ID_ATTRIBUTE);
+
+ if (modelId == null) {
+ modelId = extractModelIdFromPath(httpRequest.encodedPath());
+ if (modelId != null) {
+ executionAttributes.putAttribute(MODEL_ID_ATTRIBUTE, modelId);
+ }
+ }
+
+ String requestBody = null;
+ if (context.requestBody().isPresent()) {
+ try (InputStream is = context.requestBody().get().contentStreamProvider().newStream()) {
+ requestBody = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ log.debug("Failed to capture request body", e);
+ }
+ }
+
+ String baseUrl = httpRequest.protocol() + "://" + httpRequest.host();
+ List pathSegments =
+ Arrays.stream(httpRequest.encodedPath().split("/"))
+ .filter(s -> !s.isEmpty())
+ .toList();
+
+ InstrumentationSemConv.tagLLMSpanRequest(
+ span,
+ InstrumentationSemConv.PROVIDER_NAME_BEDROCK,
+ baseUrl,
+ pathSegments,
+ "POST",
+ requestBody,
+ modelId);
+
+ return httpRequest;
+ }
+
+ @Override
+ public Optional modifyHttpResponseContent(
+ ModifyHttpResponse context, ExecutionAttributes executionAttributes) {
+ Span span = executionAttributes.getAttribute(SPAN_ATTRIBUTE);
+ if (span == null) {
+ return context.responseBody();
+ }
+
+ // Only intercept successful responses. On 4xx/5xx the SDK needs to read the body
+ // itself to parse the AWS error message — consuming it here would swallow that.
+ if (context.httpResponse().statusCode() >= 300) {
+ return context.responseBody();
+ }
+
+ Optional body = context.responseBody();
+ if (body.isPresent()) {
+ try {
+ final byte[] bytes = body.get().readAllBytes();
+ try {
+ String responseBodyStr = new String(bytes, StandardCharsets.UTF_8);
+ InstrumentationSemConv.tagLLMSpanResponse(
+ span, InstrumentationSemConv.PROVIDER_NAME_BEDROCK, responseBodyStr);
+ } catch (Exception e) {
+ log.debug("Failed to capture response body", e);
+ }
+ return Optional.of(new ByteArrayInputStream(bytes));
+ } catch (IOException e) {
+ // unlikely this will happen, but if we get here there's no sensible recovery
+ throw new RuntimeException("failed to ready response body bytes", e);
+ }
+ }
+ return body;
+ }
+
+ /**
+ * Intercepts the async response stream for {@code converseStream} calls. Tees the reactive
+ * {@link Publisher} so that bytes are fed to a {@link MessageDecoder} as they arrive, and on
+ * completion the decoded event-stream frames are used to tag the span.
+ */
+ @Override
+ public Optional> modifyAsyncHttpResponseContent(
+ software.amazon.awssdk.core.interceptor.Context.ModifyHttpResponse context,
+ ExecutionAttributes executionAttributes) {
+ Span span = executionAttributes.getAttribute(SPAN_ATTRIBUTE);
+ Optional> publisherOpt = context.responsePublisher();
+ if (span == null || publisherOpt.isEmpty()) {
+ return publisherOpt;
+ }
+
+ // Only intercept successful responses — error responses must flow through untouched
+ // so the SDK can parse the AWS error body.
+ if (context.httpResponse().statusCode() >= 300) {
+ return publisherOpt;
+ }
+
+ Publisher original = publisherOpt.get();
+ Publisher teed =
+ subscriber -> original.subscribe(new TeeingSubscriber(subscriber, span));
+ return Optional.of(teed);
+ }
+
+ @Override
+ public void afterExecution(
+ software.amazon.awssdk.core.interceptor.Context.AfterExecution context,
+ ExecutionAttributes executionAttributes) {
+ endSpan(executionAttributes, null);
+ }
+
+ @Override
+ public void onExecutionFailure(
+ software.amazon.awssdk.core.interceptor.Context.FailedExecution context,
+ ExecutionAttributes executionAttributes) {
+ endSpan(executionAttributes, context.exception());
+ }
+
+ private static void endSpan(
+ ExecutionAttributes executionAttributes, @javax.annotation.Nullable Throwable error) {
+ Span span = executionAttributes.getAttribute(SPAN_ATTRIBUTE);
+ if (span == null) {
+ return;
+ }
+ if (error != null) {
+ InstrumentationSemConv.tagLLMSpanResponse(span, error);
+ }
+ span.end();
+ }
+
+ private static String extractModelId(SdkRequest request) {
+ if (request instanceof ConverseRequest r) return r.modelId();
+ if (request instanceof ConverseStreamRequest r) return r.modelId();
+ return null;
+ }
+
+ private static String extractModelIdFromPath(String path) {
+ if (path != null && path.startsWith("/model/")) {
+ int start = "/model/".length();
+ int end = path.indexOf("/", start);
+ if (end > start) {
+ return java.net.URLDecoder.decode(
+ path.substring(start, end), StandardCharsets.UTF_8);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Tees the reactive byte stream into a {@link MessageDecoder}. On completion, decodes each
+ * event-stream frame using the AWS SDK's shaded Jackson streaming parser, accumulates the
+ * response content, and hands a synthetic Converse-shaped JSON string to semconv.
+ */
+ private static class TeeingSubscriber implements Subscriber {
+ private final Subscriber super ByteBuffer> downstream;
+ private final Span span;
+ private final MessageDecoder decoder = new MessageDecoder();
+
+ // Accumulated incrementally in onNext — no message list retained.
+ private final StringBuilder text = new StringBuilder();
+ private String stopReason = null;
+ private int inputTokens = 0;
+ private int outputTokens = 0;
+ private long startNanos;
+ private Long timeToFirstTokenNanos = null;
+
+ TeeingSubscriber(Subscriber super ByteBuffer> downstream, Span span) {
+ this.downstream = downstream;
+ this.span = span;
+ }
+
+ @Override
+ public void onSubscribe(Subscription s) {
+ startNanos = System.nanoTime();
+ downstream.onSubscribe(s);
+ }
+
+ @Override
+ public void onNext(ByteBuffer buf) {
+ byte[] copy = new byte[buf.remaining()];
+ buf.duplicate().get(copy);
+ try {
+ decoder.feed(copy);
+ for (Message msg : decoder.getDecodedMessages()) {
+ var h = msg.getHeaders().get(":event-type");
+ if (h == null) continue;
+ String eventType = h.getString();
+ byte[] payload = msg.getPayload();
+ switch (eventType) {
+ case "contentBlockDelta" -> {
+ String t = parseDeltaText(payload);
+ if (t != null) {
+ text.append(t);
+ if (timeToFirstTokenNanos == null) {
+ timeToFirstTokenNanos = System.nanoTime() - startNanos;
+ }
+ }
+ }
+ case "messageStop" -> stopReason = parseStopReason(payload);
+ case "metadata" -> {
+ int[] tokens = parseTokenUsage(payload);
+ inputTokens = tokens[0];
+ outputTokens = tokens[1];
+ }
+ default -> {}
+ }
+ }
+ } catch (Exception e) {
+ log.debug("Failed to feed event-stream decoder", e);
+ }
+ downstream.onNext(buf);
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ downstream.onError(t);
+ }
+
+ @Override
+ public void onComplete() {
+ try {
+ InstrumentationSemConv.tagLLMSpanResponse(
+ span,
+ InstrumentationSemConv.PROVIDER_NAME_BEDROCK,
+ buildConverseJson(text.toString(), stopReason, inputTokens, outputTokens),
+ timeToFirstTokenNanos);
+ } catch (Exception e) {
+ log.debug("Failed to tag span from streaming response", e);
+ } finally {
+ downstream.onComplete();
+ }
+ }
+
+ /**
+ * Parses {@code delta.text} from a {@code contentBlockDelta} payload: {@code
+ * {"contentBlockIndex":0,"delta":{"text":"...","type":"text_delta"}}}
+ */
+ private static String parseDeltaText(byte[] payload) throws Exception {
+ try (JsonParser p = JSON_FACTORY.createParser(payload)) {
+ boolean inDelta = false;
+ while (p.nextToken() != null) {
+ if (p.currentToken() == JsonToken.FIELD_NAME) {
+ if ("delta".equals(p.currentName())) {
+ inDelta = true;
+ } else if (inDelta && "text".equals(p.currentName())) {
+ p.nextToken();
+ return p.getText();
+ }
+ } else if (p.currentToken() == JsonToken.END_OBJECT) {
+ inDelta = false;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Parses {@code stopReason} from a {@code messageStop} payload: {@code
+ * {"stopReason":"end_turn"}}
+ */
+ private static String parseStopReason(byte[] payload) throws Exception {
+ try (JsonParser p = JSON_FACTORY.createParser(payload)) {
+ while (p.nextToken() != null) {
+ if (p.currentToken() == JsonToken.FIELD_NAME
+ && "stopReason".equals(p.currentName())) {
+ p.nextToken();
+ return p.getText();
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Parses {@code [inputTokens, outputTokens]} from a {@code metadata} payload: {@code
+ * {"usage":{"inputTokens":N,"outputTokens":M},"metrics":{...}}}
+ */
+ private static int[] parseTokenUsage(byte[] payload) throws Exception {
+ int inputTokens = 0;
+ int outputTokens = 0;
+ try (JsonParser p = JSON_FACTORY.createParser(payload)) {
+ while (p.nextToken() != null) {
+ if (p.currentToken() == JsonToken.FIELD_NAME) {
+ if ("inputTokens".equals(p.currentName())) {
+ p.nextToken();
+ inputTokens = p.getIntValue();
+ } else if ("outputTokens".equals(p.currentName())) {
+ p.nextToken();
+ outputTokens = p.getIntValue();
+ }
+ }
+ }
+ }
+ return new int[] {inputTokens, outputTokens};
+ }
+
+ /**
+ * Builds a synthetic Converse-shaped JSON string matching what {@code tagBedrockResponse}
+ * expects, using the shaded Jackson generator for correct escaping.
+ */
+ private static String buildConverseJson(
+ String text, String stopReason, int inputTokens, int outputTokens)
+ throws Exception {
+ StringWriter sw = new StringWriter();
+ try (var gen = JSON_FACTORY.createGenerator(sw)) {
+ gen.writeStartObject();
+ gen.writeObjectFieldStart("output");
+ gen.writeObjectFieldStart("message");
+ gen.writeStringField("role", "assistant");
+ gen.writeArrayFieldStart("content");
+ gen.writeStartObject();
+ gen.writeStringField("text", text);
+ gen.writeEndObject();
+ gen.writeEndArray();
+ gen.writeEndObject(); // message
+ gen.writeEndObject(); // output
+ gen.writeStringField("stopReason", stopReason != null ? stopReason : "end_turn");
+ gen.writeObjectFieldStart("usage");
+ gen.writeNumberField("inputTokens", inputTokens);
+ gen.writeNumberField("outputTokens", outputTokens);
+ gen.writeNumberField("totalTokens", inputTokens + outputTokens);
+ gen.writeEndObject(); // usage
+ gen.writeEndObject();
+ }
+ return sw.toString();
+ }
+ }
+}
diff --git a/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/src/main/java/dev/braintrust/instrumentation/awsbedrock/v2_30_0/auto/AWSBedrockInstrumentationModule.java b/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/src/main/java/dev/braintrust/instrumentation/awsbedrock/v2_30_0/auto/AWSBedrockInstrumentationModule.java
new file mode 100644
index 00000000..3b32edc8
--- /dev/null
+++ b/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/src/main/java/dev/braintrust/instrumentation/awsbedrock/v2_30_0/auto/AWSBedrockInstrumentationModule.java
@@ -0,0 +1,83 @@
+package dev.braintrust.instrumentation.awsbedrock.v2_30_0.auto;
+
+import static net.bytebuddy.matcher.ElementMatchers.named;
+import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
+
+import com.google.auto.service.AutoService;
+import dev.braintrust.instrumentation.InstrumentationModule;
+import dev.braintrust.instrumentation.TypeInstrumentation;
+import dev.braintrust.instrumentation.TypeTransformer;
+import dev.braintrust.instrumentation.awsbedrock.v2_30_0.BraintrustAWSBedrock;
+import io.opentelemetry.api.GlobalOpenTelemetry;
+import java.util.List;
+import net.bytebuddy.asm.Advice;
+import net.bytebuddy.description.type.TypeDescription;
+import net.bytebuddy.matcher.ElementMatcher;
+import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeAsyncClientBuilder;
+import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClientBuilder;
+
+/**
+ * Auto-instruments the AWS Bedrock Runtime sync and async client builders by hooking into {@code
+ * SdkDefaultClientBuilder.build()} — the single {@code final} method in the AWS SDK builder
+ * hierarchy that both {@link BedrockRuntimeClientBuilder} and {@link
+ * BedrockRuntimeAsyncClientBuilder} ultimately call. The advice checks the runtime type of {@code
+ * this} to limit instrumentation to Bedrock builders only.
+ */
+@AutoService(InstrumentationModule.class)
+public class AWSBedrockInstrumentationModule extends InstrumentationModule {
+ private static final String MANUAL_INSTRUMENTATION_PACKAGE =
+ "dev.braintrust.instrumentation.awsbedrock.v2_30_0.";
+
+ public AWSBedrockInstrumentationModule() {
+ super("aws_bedrock_2_30_0");
+ }
+
+ @Override
+ public List getHelperClassNames() {
+ return List.of(
+ MANUAL_INSTRUMENTATION_PACKAGE + "BraintrustAWSBedrock",
+ MANUAL_INSTRUMENTATION_PACKAGE + "BraintrustBedrockInterceptor",
+ MANUAL_INSTRUMENTATION_PACKAGE + "BraintrustBedrockInterceptor$TeeingSubscriber",
+ "dev.braintrust.json.BraintrustJsonMapper",
+ "dev.braintrust.instrumentation.InstrumentationSemConv");
+ }
+
+ @Override
+ public List typeInstrumentations() {
+ return List.of(new SdkDefaultClientBuilderInstrumentation());
+ }
+
+ /**
+ * Targets {@code SdkDefaultClientBuilder} — the abstract base that defines the {@code final
+ * build()} method inherited by all AWS SDK client builders, including both Bedrock variants.
+ */
+ public static class SdkDefaultClientBuilderInstrumentation implements TypeInstrumentation {
+ @Override
+ public ElementMatcher typeMatcher() {
+ return named("software.amazon.awssdk.core.client.builder.SdkDefaultClientBuilder");
+ }
+
+ @Override
+ public void transform(TypeTransformer transformer) {
+ transformer.applyAdviceToMethod(
+ named("build").and(takesArguments(0)),
+ AWSBedrockInstrumentationModule.class.getName() + "$BedrockBuilderAdvice");
+ }
+ }
+
+ /**
+ * Fires on entry to {@code build()} for any AWS SDK client builder. Uses {@code instanceof}
+ * checks to limit actual work to Bedrock builders, then calls the idempotent {@code wrap()}
+ * method to register the Braintrust {@code ExecutionInterceptor} before the client is built.
+ */
+ public static class BedrockBuilderAdvice {
+ @Advice.OnMethodEnter
+ public static void build(@Advice.This Object builder) {
+ if (builder instanceof BedrockRuntimeClientBuilder bedrockBuilder) {
+ BraintrustAWSBedrock.wrap(GlobalOpenTelemetry.get(), bedrockBuilder);
+ } else if (builder instanceof BedrockRuntimeAsyncClientBuilder bedrockBuilder) {
+ BraintrustAWSBedrock.wrap(GlobalOpenTelemetry.get(), bedrockBuilder);
+ }
+ }
+ }
+}
diff --git a/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/src/test/java/dev/braintrust/instrumentation/awsbedrock/v2_30_0/BraintrustAWSBedrockTest.java b/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/src/test/java/dev/braintrust/instrumentation/awsbedrock/v2_30_0/BraintrustAWSBedrockTest.java
new file mode 100644
index 00000000..4876436a
--- /dev/null
+++ b/braintrust-sdk/instrumentation/aws_bedrock_2_30_0/src/test/java/dev/braintrust/instrumentation/awsbedrock/v2_30_0/BraintrustAWSBedrockTest.java
@@ -0,0 +1,152 @@
+package dev.braintrust.instrumentation.awsbedrock.v2_30_0;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.braintrust.Bedrock30TestUtils;
+import dev.braintrust.TestHarness;
+import dev.braintrust.instrumentation.Instrumenter;
+import io.opentelemetry.api.common.AttributeKey;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Stream;
+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;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import software.amazon.awssdk.services.bedrockruntime.model.ContentBlock;
+import software.amazon.awssdk.services.bedrockruntime.model.ConversationRole;
+import software.amazon.awssdk.services.bedrockruntime.model.ConverseRequest;
+import software.amazon.awssdk.services.bedrockruntime.model.ConverseStreamRequest;
+import software.amazon.awssdk.services.bedrockruntime.model.ConverseStreamResponseHandler;
+import software.amazon.awssdk.services.bedrockruntime.model.Message;
+
+public class BraintrustAWSBedrockTest {
+
+ private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
+
+ @BeforeAll
+ static void beforeAll() {
+ var instrumentation = ByteBuddyAgent.install();
+ Instrumenter.install(instrumentation, BraintrustAWSBedrockTest.class.getClassLoader());
+ }
+
+ private TestHarness testHarness;
+ private Bedrock30TestUtils bedrockUtils;
+
+ @BeforeEach
+ void beforeEach() {
+ testHarness = TestHarness.setup();
+ bedrockUtils = new Bedrock30TestUtils(testHarness);
+ }
+
+ static Stream modelProvider() {
+ return Stream.of(
+ Arguments.of("us.anthropic.claude-3-haiku-20240307-v1:0"),
+ Arguments.of("us.amazon.nova-lite-v1:0"));
+ }
+
+ @ParameterizedTest(name = "converse with {0}")
+ @MethodSource("modelProvider")
+ @SneakyThrows
+ void converseProducesLlmSpan(String modelId) {
+ try (var client = bedrockUtils.syncClientBuilder().build()) {
+ var response =
+ client.converse(
+ ConverseRequest.builder()
+ .modelId(modelId)
+ .messages(
+ Message.builder()
+ .role(ConversationRole.USER)
+ .content(
+ ContentBlock.fromText(
+ "What is the capital of France?"
+ + " Reply in one word."))
+ .build())
+ .build());
+ assertNotNull(response);
+ assertFalse(
+ response.output().message().content().isEmpty(),
+ "response should have content");
+
+ var spans = testHarness.awaitExportedSpans(1);
+ assertEquals(1, spans.size(), "expected exactly one span");
+ var span = spans.get(0);
+
+ String spanAttributesJson =
+ span.getAttributes().get(AttributeKey.stringKey("braintrust.span_attributes"));
+ assertNotNull(spanAttributesJson, "braintrust.span_attributes should be set");
+ JsonNode spanAttributes = JSON_MAPPER.readTree(spanAttributesJson);
+ assertEquals("llm", spanAttributes.get("type").asText());
+ assertNotNull(
+ span.getAttributes().get(AttributeKey.stringKey("braintrust.input_json")),
+ "braintrust.input_json should be set");
+ assertNotNull(
+ span.getAttributes().get(AttributeKey.stringKey("braintrust.output_json")),
+ "braintrust.output_json should be set");
+ }
+ }
+
+ @Test
+ @SneakyThrows
+ void converseStreamProducesLlmSpan() {
+ String modelId = "us.anthropic.claude-3-haiku-20240307-v1:0";
+
+ try (var client = bedrockUtils.asyncClientBuilder().build()) {
+ var accumulatedText = new AtomicReference<>(new StringBuilder());
+
+ var responseHandler =
+ ConverseStreamResponseHandler.builder()
+ .subscriber(
+ ConverseStreamResponseHandler.Visitor.builder()
+ .onContentBlockDelta(
+ evt -> {
+ if (evt.delta().text() != null) {
+ accumulatedText
+ .get()
+ .append(evt.delta().text());
+ }
+ })
+ .build())
+ .build();
+
+ client.converseStream(
+ ConverseStreamRequest.builder()
+ .modelId(modelId)
+ .messages(
+ Message.builder()
+ .role(ConversationRole.USER)
+ .content(
+ ContentBlock.fromText(
+ "What is the capital of France?"
+ + " Reply in one word."))
+ .build())
+ .build(),
+ responseHandler)
+ .get();
+
+ assertFalse(
+ accumulatedText.get().isEmpty(), "should have received streamed text content");
+
+ var spans = testHarness.awaitExportedSpans(1);
+ assertEquals(1, spans.size(), "expected exactly one span");
+ var span = spans.get(0);
+
+ String spanAttributesJson =
+ span.getAttributes().get(AttributeKey.stringKey("braintrust.span_attributes"));
+ assertNotNull(spanAttributesJson, "braintrust.span_attributes should be set");
+ JsonNode spanAttributes = JSON_MAPPER.readTree(spanAttributesJson);
+ assertEquals("llm", spanAttributes.get("type").asText());
+ assertNotNull(
+ span.getAttributes().get(AttributeKey.stringKey("braintrust.input_json")),
+ "braintrust.input_json should be set");
+ assertNotNull(
+ span.getAttributes().get(AttributeKey.stringKey("braintrust.output_json")),
+ "braintrust.output_json should be set");
+ }
+ }
+}
diff --git a/braintrust-sdk/src/main/java/dev/braintrust/instrumentation/InstrumentationSemConv.java b/braintrust-sdk/src/main/java/dev/braintrust/instrumentation/InstrumentationSemConv.java
index 6f47587c..05c04afd 100644
--- a/braintrust-sdk/src/main/java/dev/braintrust/instrumentation/InstrumentationSemConv.java
+++ b/braintrust-sdk/src/main/java/dev/braintrust/instrumentation/InstrumentationSemConv.java
@@ -17,6 +17,7 @@
public class InstrumentationSemConv {
public static final String PROVIDER_NAME_OPENAI = "openai";
public static final String PROVIDER_NAME_ANTHROPIC = "anthropic";
+ public static final String PROVIDER_NAME_BEDROCK = "bedrock";
public static final String PROVIDER_NAME_OTHER = "generic-ai-provider";
public static final String UNSET_LLM_SPAN_NAME = "llm";
@@ -32,6 +33,25 @@ public static void tagLLMSpanRequest(
@Nonnull List pathSegments,
@Nonnull String method,
@Nullable String requestBody) {
+ tagLLMSpanRequest(span, providerName, baseUrl, pathSegments, method, requestBody, null);
+ }
+
+ /**
+ * Tag a span with LLM request metadata.
+ *
+ * @param modelId explicit model identifier — used by providers (e.g. Bedrock) where the model
+ * is not present in the request body. When {@code null} the model is extracted from the
+ * request body if possible.
+ */
+ @SneakyThrows
+ public static void tagLLMSpanRequest(
+ Span span,
+ @Nonnull String providerName,
+ @Nonnull String baseUrl,
+ @Nonnull List pathSegments,
+ @Nonnull String method,
+ @Nullable String requestBody,
+ @Nullable String modelId) {
switch (providerName) {
case PROVIDER_NAME_OPENAI ->
tagOpenAIRequest(
@@ -39,6 +59,15 @@ public static void tagLLMSpanRequest(
case PROVIDER_NAME_ANTHROPIC ->
tagAnthropicRequest(
span, providerName, baseUrl, pathSegments, method, requestBody);
+ case PROVIDER_NAME_BEDROCK ->
+ tagBedrockRequest(
+ span,
+ providerName,
+ baseUrl,
+ pathSegments,
+ method,
+ requestBody,
+ modelId);
default ->
tagOpenAIRequest(
span, providerName, baseUrl, pathSegments, method, requestBody);
@@ -61,6 +90,8 @@ public static void tagLLMSpanResponse(
tagOpenAIResponse(span, responseBody, timeToFirstTokenNanoseconds);
case PROVIDER_NAME_ANTHROPIC ->
tagAnthropicResponse(span, responseBody, timeToFirstTokenNanoseconds);
+ case PROVIDER_NAME_BEDROCK ->
+ tagBedrockResponse(span, responseBody, timeToFirstTokenNanoseconds);
default -> tagOpenAIResponse(span, responseBody, timeToFirstTokenNanoseconds);
}
}
@@ -234,6 +265,102 @@ private static void tagAnthropicResponse(
}
}
+ // -------------------------------------------------------------------------
+ // AWS Bedrock provider implementation
+ // -------------------------------------------------------------------------
+
+ @SneakyThrows
+ private static void tagBedrockRequest(
+ Span span,
+ String providerName,
+ String baseUrl,
+ List pathSegments,
+ String method,
+ @Nullable String requestBody,
+ @Nullable String modelId) {
+ String endpoint = bedrockEndpoint(pathSegments);
+ span.updateName("bedrock." + endpoint);
+ span.setAttribute("braintrust.span_attributes", toJson(Map.of("type", "llm")));
+
+ Map metadata = new HashMap<>();
+ metadata.put("provider", "bedrock");
+ metadata.put("endpoint", endpoint);
+ metadata.put("request_path", String.join("/", pathSegments));
+ metadata.put("request_base_uri", baseUrl);
+ metadata.put("request_method", method);
+
+ if (modelId != null) {
+ metadata.put("model", modelId);
+ }
+
+ if (requestBody != null) {
+ JsonNode requestJson = BraintrustJsonMapper.get().readTree(requestBody);
+ // Extract inference parameters from inferenceConfig
+ if (requestJson.has("inferenceConfig")) {
+ JsonNode cfg = requestJson.get("inferenceConfig");
+ if (cfg.has("maxTokens")) metadata.put("max_tokens", cfg.get("maxTokens"));
+ if (cfg.has("temperature")) metadata.put("temperature", cfg.get("temperature"));
+ if (cfg.has("topP")) metadata.put("top_p", cfg.get("topP"));
+ if (cfg.has("stopSequences"))
+ metadata.put("stop_sequences", cfg.get("stopSequences"));
+ }
+ // Bedrock Converse uses "messages" with typed content block arrays like
+ // [{"text":"..."}]
+ if (requestJson.has("messages")) {
+ ArrayNode inputArray = BraintrustJsonMapper.get().createArrayNode();
+ // Bedrock puts system prompts in a separate top-level "system" array:
+ // [{"text": "..."}]. Prepend as a synthetic {role:"system", content:[...]} entry.
+ if (requestJson.has("system")
+ && requestJson.get("system").isArray()
+ && !requestJson.get("system").isEmpty()) {
+ var systemNode = BraintrustJsonMapper.get().createObjectNode();
+ systemNode.put("role", "system");
+ systemNode.set("content", requestJson.get("system"));
+ inputArray.add(systemNode);
+ }
+ for (JsonNode msg : requestJson.get("messages")) {
+ inputArray.add(normalizeBedrockMessage(msg));
+ }
+ span.setAttribute("braintrust.input_json", toJson(inputArray));
+ }
+ }
+
+ span.setAttribute("braintrust.metadata", toJson(metadata));
+ }
+
+ @SneakyThrows
+ private static void tagBedrockResponse(
+ Span span, String responseBody, @Nullable Long timeToFirstTokenNanoseconds) {
+ JsonNode responseJson = BraintrustJsonMapper.get().readTree(responseBody);
+
+ // Bedrock output lives at output.message. Normalize to a single-element array matching the
+ // same [{role, content: [...]}] shape as input so the UI can render the LLM thread view.
+ if (responseJson.has("output") && responseJson.get("output").has("message")) {
+ JsonNode message = responseJson.get("output").get("message");
+ ArrayNode outputArray = BraintrustJsonMapper.get().createArrayNode();
+ outputArray.add(normalizeBedrockMessage(message));
+ span.setAttribute("braintrust.output_json", toJson(outputArray));
+ }
+
+ Map metrics = new HashMap<>();
+ if (timeToFirstTokenNanoseconds != null) {
+ metrics.put("time_to_first_token", timeToFirstTokenNanoseconds / 1_000_000_000.0);
+ }
+
+ // Bedrock usage uses camelCase: inputTokens, outputTokens, totalTokens
+ if (responseJson.has("usage")) {
+ JsonNode usage = responseJson.get("usage");
+ if (usage.has("inputTokens")) metrics.put("prompt_tokens", usage.get("inputTokens"));
+ if (usage.has("outputTokens"))
+ metrics.put("completion_tokens", usage.get("outputTokens"));
+ if (usage.has("totalTokens")) metrics.put("tokens", usage.get("totalTokens"));
+ }
+
+ if (!metrics.isEmpty()) {
+ span.setAttribute("braintrust.metrics", toJson(metrics));
+ }
+ }
+
// -------------------------------------------------------------------------
// Shared helpers
// -------------------------------------------------------------------------
@@ -263,6 +390,57 @@ private static JsonNode simplifyAnthropicMessage(JsonNode msg) {
return msg;
}
+ /**
+ * Normalizes a Bedrock Converse message so its content blocks are compatible with the UI's
+ * schema checks. The Converse wire format uses {@code {"text":"..."}} for text blocks, but both
+ * the OpenAI and Anthropic schemas the UI validates against require an explicit {@code
+ * "type":"text"} field. This method adds {@code "type"} to any content block that has a
+ * recognized Bedrock key but is missing it.
+ */
+ private static JsonNode normalizeBedrockMessage(JsonNode msg) {
+ if (!msg.has("content") || !msg.get("content").isArray()) {
+ return msg;
+ }
+ var mapper = BraintrustJsonMapper.get();
+ ArrayNode normalizedContent = mapper.createArrayNode();
+ boolean changed = false;
+ for (JsonNode block : msg.get("content")) {
+ if (block.isObject() && !block.has("type")) {
+ var normalized = (com.fasterxml.jackson.databind.node.ObjectNode) block.deepCopy();
+ if (block.has("text")) {
+ normalized.put("type", "text");
+ changed = true;
+ } else if (block.has("toolUse")) {
+ normalized.put("type", "tool_use");
+ changed = true;
+ } else if (block.has("toolResult")) {
+ normalized.put("type", "tool_result");
+ changed = true;
+ } else if (block.has("image")) {
+ normalized.put("type", "image");
+ changed = true;
+ }
+ normalizedContent.add(normalized);
+ } else {
+ normalizedContent.add(block);
+ }
+ }
+ if (!changed) {
+ return msg;
+ }
+ var result = (com.fasterxml.jackson.databind.node.ObjectNode) msg.deepCopy();
+ result.set("content", normalizedContent);
+ return result;
+ }
+
+ /** Returns the Bedrock endpoint name from the last URL path segment (e.g. "converse"). */
+ private static String bedrockEndpoint(List pathSegments) {
+ if (pathSegments.isEmpty()) {
+ return "unknown";
+ }
+ return pathSegments.get(pathSegments.size() - 1);
+ }
+
private static String getSpanName(String providerName, List pathSegments) {
if (pathSegments.isEmpty()) {
return UNSET_LLM_SPAN_NAME;
diff --git a/btx/build.gradle b/btx/build.gradle
index 27de2956..150600cf 100644
--- a/btx/build.gradle
+++ b/btx/build.gradle
@@ -21,6 +21,7 @@ dependencies {
testImplementation project(':braintrust-sdk:instrumentation:genai_1_18_0')
testImplementation project(':braintrust-sdk:instrumentation:langchain_1_8_0')
testImplementation project(':braintrust-sdk:instrumentation:springai_1_0_0')
+ testImplementation project(':braintrust-sdk:instrumentation:aws_bedrock_2_30_0')
// Jackson for JSON processing
testImplementation 'com.fasterxml.jackson.core:jackson-databind:2.16.1'
@@ -31,6 +32,10 @@ dependencies {
// Anthropic SDK
testImplementation 'com.anthropic:anthropic-java:2.10.0'
+ // AWS Bedrock SDK
+ testImplementation 'software.amazon.awssdk:bedrockruntime:2.30.0'
+ testImplementation 'software.amazon.awssdk:netty-nio-client:2.30.0'
+
// Gemini SDK
testImplementation 'org.springframework.ai:spring-ai-google-genai:1.1.0'
@@ -95,6 +100,14 @@ tasks.register('fetchSpec', Exec) {
}
test {
- dependsOn fetchSpec
- systemProperty 'btx.spec.root', specOutputDir.get().asFile.absolutePath + '/test/llm_span'
+ // BTX_SPEC_ROOT env var lets you point at a local checkout of braintrust-spec instead of the
+ // fetched copy. When set, fetchSpec is skipped and the system property is set to that path.
+ // Example: BTX_SPEC_ROOT=/Users/you/braintrust/spec/test/llm_span ./gradlew btx:test
+ def specRootEnv = System.getenv('BTX_SPEC_ROOT')
+ if (specRootEnv) {
+ systemProperty 'btx.spec.root', specRootEnv
+ } else {
+ dependsOn fetchSpec
+ systemProperty 'btx.spec.root', specOutputDir.get().asFile.absolutePath + '/test/llm_span'
+ }
}
diff --git a/btx/src/test/java/dev/braintrust/sdkspecimpl/SpanConverter.java b/btx/src/test/java/dev/braintrust/sdkspecimpl/SpanConverter.java
index fa24035f..f4f18ca1 100644
--- a/btx/src/test/java/dev/braintrust/sdkspecimpl/SpanConverter.java
+++ b/btx/src/test/java/dev/braintrust/sdkspecimpl/SpanConverter.java
@@ -153,6 +153,31 @@ private static Object transformContentPart(Object part) {
return part;
}
+ // Bedrock: {type: image, image: {format: "png", source: {bytes: ""}}}
+ if ("image".equals(p.get("type")) && p.get("image") instanceof Map) {
+ // TODO:
+ // The Braintrust backend does not transform Bedrock image bytes into attachments,
+ // so we pass this shape through unchanged to match what the backend stores.
+ //
+ // Map imageMap = (Map) p.get("image");
+ // if (imageMap.get("source") instanceof Map) {
+ // Map source = (Map) imageMap.get("source");
+ // String bytes = (String) source.get("bytes");
+ // if (bytes != null) {
+ // String format = (String) imageMap.getOrDefault("format", "png");
+ // String mimeType = "image/" + format;
+ // Map attachment =
+ // toAttachment("data:" + mimeType + ";base64," + bytes);
+ // Map newImage = new LinkedHashMap<>(imageMap);
+ // newImage.put("source", attachment);
+ // Map newPart = new LinkedHashMap<>(p);
+ // newPart.put("image", newImage);
+ // return newPart;
+ // }
+ // }
+ return part;
+ }
+
if (!"image_url".equals(p.get("type"))) return part;
Object imageUrlObj = p.get("image_url");
diff --git a/btx/src/test/java/dev/braintrust/sdkspecimpl/SpecExecutor.java b/btx/src/test/java/dev/braintrust/sdkspecimpl/SpecExecutor.java
index 238ea897..40dda005 100644
--- a/btx/src/test/java/dev/braintrust/sdkspecimpl/SpecExecutor.java
+++ b/btx/src/test/java/dev/braintrust/sdkspecimpl/SpecExecutor.java
@@ -17,8 +17,10 @@
import com.openai.models.responses.ResponseCreateParams;
import com.openai.models.responses.ResponseInputItem;
import com.openai.models.responses.ResponseOutputItem;
+import dev.braintrust.Bedrock30TestUtils;
import dev.braintrust.TestHarness;
import dev.braintrust.instrumentation.anthropic.BraintrustAnthropic;
+import dev.braintrust.instrumentation.awsbedrock.v2_30_0.BraintrustAWSBedrock;
import dev.braintrust.instrumentation.genai.BraintrustGenAI;
import dev.braintrust.instrumentation.langchain.BraintrustLangchain;
import dev.braintrust.instrumentation.openai.BraintrustOpenAI;
@@ -53,6 +55,16 @@
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.function.FunctionToolCallback;
+import software.amazon.awssdk.core.SdkBytes;
+import software.amazon.awssdk.services.bedrockruntime.model.ContentBlock;
+import software.amazon.awssdk.services.bedrockruntime.model.ConversationRole;
+import software.amazon.awssdk.services.bedrockruntime.model.ConverseRequest;
+import software.amazon.awssdk.services.bedrockruntime.model.ConverseStreamRequest;
+import software.amazon.awssdk.services.bedrockruntime.model.ConverseStreamResponseHandler;
+import software.amazon.awssdk.services.bedrockruntime.model.ImageBlock;
+import software.amazon.awssdk.services.bedrockruntime.model.ImageFormat;
+import software.amazon.awssdk.services.bedrockruntime.model.ImageSource;
+import software.amazon.awssdk.services.bedrockruntime.model.Message;
/**
* Executes LLM spec tests in-process using the Braintrust Java SDK instrumentation.
@@ -73,6 +85,7 @@ public class SpecExecutor {
private final String openAiApiKey;
private final String anthropicBaseUrl;
private final String anthropicApiKey;
+ private final Bedrock30TestUtils bedrockUtils;
private final io.opentelemetry.api.OpenTelemetry otel;
public SpecExecutor(TestHarness harness) {
@@ -83,6 +96,7 @@ public SpecExecutor(TestHarness harness) {
this.openAiApiKey = harness.openAiApiKey();
this.anthropicBaseUrl = harness.anthropicBaseUrl();
this.anthropicApiKey = harness.anthropicApiKey();
+ this.bedrockUtils = new Bedrock30TestUtils(harness);
this.openAIClient =
BraintrustOpenAI.wrapOpenAI(
@@ -155,6 +169,10 @@ private void dispatchRequest(
} else {
executeAnthropicMessages(request);
}
+ } else if ("bedrock".equals(provider) && endpoint.contains("/converse-stream")) {
+ executeBedrockConverseStream(request);
+ } else if ("bedrock".equals(provider) && endpoint.contains("/converse")) {
+ executeBedrockConverse(request);
} else if ("google".equals(provider) && endpoint.contains(":generateContent")) {
executeGeminiGenerateContent(request, endpoint);
} else {
@@ -615,6 +633,87 @@ private void executeAnthropicMessages(Map request) throws Except
}
}
+ // ---- AWS Bedrock ------------------------------------------------------------
+
+ @SuppressWarnings("unchecked")
+ private void executeBedrockConverse(Map request) {
+ String modelId = (String) request.get("modelId");
+
+ // Build messages from the spec YAML format: [{role, content: [{text: ...} | {image: ...}]}]
+ List messages = new ArrayList<>();
+ for (Map msg : (List