Skip to content
Draft
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
16 changes: 16 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,19 @@ Because the SDK is new and under active development, third-party contribution be
## Development

See [AGENTS.md](./AGENTS.md) for best practices developing, testing, and releasing the SDK.

### Instrumentation origin versions

Instrumentation tests compare exported `span_origin.instrumentation.version` against the
minimum passing version declared in that module's `muzzle` configuration. Gradle supplies
the expected version through the test JVM property `braintrust.muzzle.minimumVersion` and
tracks it as a test input. No Maven version lookup or full muzzle run is needed.

The minimum comes from inclusive range lower bounds or pinned versions, ignoring `fail`
directives. Multiple passing directives for the same artifact use their lowest version;
different artifacts in one module must agree. Unbounded, exclusive, or skipped lower bounds
are rejected rather than guessing a supported version.

When changing a module's minimum supported version, update both its muzzle configuration
and its Java `INSTRUMENTATION_VERSION` constant. Run the module's Gradle `test` task to
check that the emitted origin matches.
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@

/** Globally available bootstrap classpath resource class */
public class BraintrustBridge {
public static final String INSTRUMENTATION_NAME = "braintrust-java";

/**
* Diagnostic utility tracking the number of times braintrust otel has been installed.
*
Expand Down
8 changes: 8 additions & 0 deletions braintrust-sdk/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ subprojects { subproject ->
// Make them available at compile time so instrumentation modules can reference them.
dependencies.add('compileOnly', project(':braintrust-java-agent:bootstrap'))

// Derive provenance expectations from muzzle, not from the Java instrumentation constants.
def minimumMuzzleVersion = subproject.extensions.getByName('muzzle').minimumVersion
tasks.withType(Test).configureEach {
inputs.property 'muzzleMinimumVersion', minimumMuzzleVersion
systemProperty 'braintrust.muzzle.minimumVersion', minimumMuzzleVersion
}

// --- $Muzzle side-class generation (compile-time) ---

// Configuration for the muzzle generator classpath
Expand Down Expand Up @@ -145,6 +152,7 @@ dependencies {

testImplementation "org.slf4j:slf4j-simple:${slf4jVersion}"
testImplementation "io.opentelemetry:opentelemetry-sdk-testing:${otelVersion}"
testImplementation 'io.opentelemetry.proto:opentelemetry-proto:1.11.0-alpha'
testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}"
testImplementation "org.junit.jupiter:junit-jupiter-params:${junitVersion}"
testImplementation 'org.wiremock:wiremock:3.13.1'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.anthropic.core.ClientOptions;
import com.anthropic.core.http.HttpClient;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Tracer;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.function.Consumer;
Expand All @@ -13,10 +14,15 @@
/** Braintrust Anthropic client instrumentation. */
@Slf4j
public final class BraintrustAnthropic {
static final String INSTRUMENTATION_NAME = "anthropic";
static final String INSTRUMENTATION_VERSION = "2.2.0";

/** Instrument Anthropic client with Braintrust traces. */
public static AnthropicClient wrap(OpenTelemetry openTelemetry, AnthropicClient client) {
if (!instrument(openTelemetry, client)) {
if (!instrument(
openTelemetry.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION),
client,
false)) {
return client;
}
return ContextCapturingProxy.wrap(client, AnthropicClient.class);
Expand All @@ -25,7 +31,29 @@ public static AnthropicClient wrap(OpenTelemetry openTelemetry, AnthropicClient
/** Instrument an async Anthropic client with Braintrust traces. */
public static AnthropicClientAsync wrap(
OpenTelemetry openTelemetry, AnthropicClientAsync client) {
if (!instrument(openTelemetry, client)) {
if (!instrument(
openTelemetry.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION),
client,
false)) {
return client;
}
return ContextCapturingProxy.wrap(client, AnthropicClientAsync.class);
}

/**
* Instruments a client using the owning library's tracer, replacing any existing provider
* tracer without adding another tracing layer.
*/
public static AnthropicClient wrap(Tracer tracer, AnthropicClient client) {
if (!instrument(tracer, client, true)) {
return client;
}
return ContextCapturingProxy.wrap(client, AnthropicClient.class);
}

/** Async counterpart of {@link #wrap(Tracer, AnthropicClient)}. */
public static AnthropicClientAsync wrap(Tracer tracer, AnthropicClientAsync client) {
if (!instrument(tracer, client, true)) {
return client;
}
return ContextCapturingProxy.wrap(client, AnthropicClientAsync.class);
Expand All @@ -40,13 +68,15 @@ public static AnthropicClientAsync wrap(
* proxy's internal context header is only stripped by {@link TracingHttpClient}, so
* installing it without one would leak trace/span IDs to the provider.
*/
private static boolean instrument(OpenTelemetry openTelemetry, Object client) {
private static boolean instrument(Tracer tracer, Object client, boolean replaceTracer) {
if (ContextCapturingProxy.isContextCapturingProxy(client)) {
// already instrumented
return true;
if (!replaceTracer) {
return true;
}
client = ContextCapturingProxy.unwrap(client);
}
try {
instrumentHttpClient(openTelemetry, client);
instrumentHttpClient(tracer, client, replaceTracer);
return true;
} catch (Exception e) {
log.error(
Expand All @@ -57,14 +87,14 @@ private static boolean instrument(OpenTelemetry openTelemetry, Object client) {
}
}

private static void instrumentHttpClient(OpenTelemetry openTelemetry, Object client) {
private static void instrumentHttpClient(Tracer tracer, Object client, boolean replaceTracer) {
int[] instrumented = {0};
forAllFields(
client,
fieldName -> {
try {
if (getField(client, fieldName) instanceof ClientOptions clientOptions) {
instrumentClientOptions(openTelemetry, clientOptions);
instrumentClientOptions(tracer, clientOptions, replaceTracer);
instrumented[0]++;
}
} catch (ReflectiveOperationException e) {
Expand All @@ -83,18 +113,22 @@ private static void instrumentHttpClient(OpenTelemetry openTelemetry, Object cli

/** Swaps both HTTP client fields on a {@link ClientOptions} for tracing wrappers. */
private static void instrumentClientOptions(
OpenTelemetry openTelemetry, ClientOptions clientOptions) {
swapHttpClient(openTelemetry, clientOptions, "originalHttpClient");
swapHttpClient(openTelemetry, clientOptions, "httpClient");
Tracer tracer, ClientOptions clientOptions, boolean replaceTracer) {
swapHttpClient(tracer, clientOptions, "originalHttpClient", replaceTracer);
swapHttpClient(tracer, clientOptions, "httpClient", replaceTracer);
}

private static void swapHttpClient(
OpenTelemetry openTelemetry, ClientOptions clientOptions, String fieldName) {
Tracer tracer, ClientOptions clientOptions, String fieldName, boolean replaceTracer) {
try {
HttpClient httpClient = getField(clientOptions, fieldName);
if (!(httpClient instanceof TracingHttpClient)) {
if (httpClient instanceof TracingHttpClient tracing) {
if (replaceTracer) {
setPrivateField(clientOptions, fieldName, tracing.withTracer(tracer));
}
} else {
setPrivateField(
clientOptions, fieldName, new TracingHttpClient(openTelemetry, httpClient));
clientOptions, fieldName, new TracingHttpClient(tracer, httpClient));
}
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ static boolean isContextCapturingProxy(Object o) {
&& Proxy.getInvocationHandler(o) instanceof ContextCapturingProxy;
}

static Object unwrap(Object proxy) {
do {
proxy = ((ContextCapturingProxy) Proxy.getInvocationHandler(proxy)).delegate;
} while (isContextCapturingProxy(proxy));
return proxy;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// equals/hashCode/toString are the only Object methods routed to an InvocationHandler.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import com.anthropic.core.http.HttpRequest;
import com.anthropic.core.http.HttpRequestBody;
import com.anthropic.core.http.HttpResponse;
import dev.braintrust.bootstrap.BraintrustBridge;
import dev.braintrust.instrumentation.InstrumentationSemConv;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Span;
Expand Down Expand Up @@ -34,10 +33,22 @@ public class TracingHttpClient implements HttpClient {
private final HttpClient underlying;

public TracingHttpClient(OpenTelemetry openTelemetry, HttpClient underlying) {
this.tracer = openTelemetry.getTracer(BraintrustBridge.INSTRUMENTATION_NAME);
this(
openTelemetry.getTracer(
BraintrustAnthropic.INSTRUMENTATION_NAME,
BraintrustAnthropic.INSTRUMENTATION_VERSION),
underlying);
}

TracingHttpClient(Tracer tracer, HttpClient underlying) {
this.tracer = tracer;
this.underlying = underlying;
}

TracingHttpClient withTracer(Tracer tracer) {
return this.tracer == tracer ? this : new TracingHttpClient(tracer, underlying);
}

/**
* Starts the LLM span. anthropic-java (and frameworks like Spring AI 2.x) dispatch
* async/streaming requests on executors where the caller's thread-local context is lost — which
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ void beforeEach() {
testHarness = TestHarness.setup();
}

@SneakyThrows
private static void assertInstrumentationOrigin(io.opentelemetry.sdk.trace.data.SpanData span) {
JsonNode instrumentation =
JSON_MAPPER
.readTree(
span.getAttributes()
.get(AttributeKey.stringKey("braintrust.context_json")))
.path("span_origin")
.path("instrumentation");
assertEquals("anthropic", instrumentation.path("name").asText());
assertEquals(
System.getProperty("braintrust.muzzle.minimumVersion"),
instrumentation.path("version").asText(),
"span origin version must match the minimum passing muzzle version");
}

@Test
@SneakyThrows
void testWrapAnthropic() {
Expand Down Expand Up @@ -77,6 +93,7 @@ void testWrapAnthropic() {
var spans = testHarness.awaitExportedSpans();
assertEquals(1, spans.size());
var span = spans.get(0);
assertInstrumentationOrigin(span);

assertFalse(span.getName().isEmpty(), "span name should be non-empty");

Expand Down Expand Up @@ -166,6 +183,7 @@ void testWrapAnthropicStreaming() {
var spans = testHarness.awaitExportedSpans();
assertEquals(1, spans.size());
var span = spans.get(0);
assertInstrumentationOrigin(span);

assertFalse(span.getName().isEmpty(), "span name should be non-empty");

Expand Down Expand Up @@ -243,6 +261,7 @@ void testDirectAsyncClientParenting() {
assertEquals(2, spans.size());
var llmSpan =
spans.stream().filter(s -> !"foo".equals(s.getName())).findFirst().orElseThrow();
assertInstrumentationOrigin(llmSpan);
assertEquals(
parentSpan.getSpanContext().getTraceId(),
llmSpan.getTraceId(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
*/
@Slf4j
class BraintrustBedrockInterceptor implements ExecutionInterceptor {
private static final String INSTRUMENTATION_NAME = "braintrust-aws-bedrock";
private static final String INSTRUMENTATION_NAME = "aws-bedrock";
private static final String INSTRUMENTATION_VERSION = "2.30.0";

private static final ExecutionAttribute<Span> SPAN_ATTRIBUTE =
new ExecutionAttribute<>("braintrust.span");
Expand All @@ -49,7 +50,7 @@ class BraintrustBedrockInterceptor implements ExecutionInterceptor {
private final Tracer tracer;

BraintrustBedrockInterceptor(OpenTelemetry openTelemetry) {
this.tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME);
this.tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION);
}

private static final Set<String> INSTRUMENTED_OPERATIONS = Set.of("Converse", "ConverseStream");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ void converseProducesLlmSpan(String modelId) {
var spans = testHarness.awaitExportedSpans(1);
assertEquals(1, spans.size(), "expected exactly one span");
var span = spans.get(0);
var instrumentation =
JSON_MAPPER
.readTree(
span.getAttributes()
.get(AttributeKey.stringKey("braintrust.context_json")))
.path("span_origin")
.path("instrumentation");
assertEquals("aws-bedrock", instrumentation.path("name").asText());
assertEquals(
System.getProperty("braintrust.muzzle.minimumVersion"),
instrumentation.path("version").asText(),
"span origin version must match the minimum passing muzzle version");

String spanAttributesJson =
span.getAttributes().get(AttributeKey.stringKey("braintrust.span_attributes"));
Expand Down Expand Up @@ -135,6 +147,18 @@ void converseStreamProducesLlmSpan() {
var spans = testHarness.awaitExportedSpans(1);
assertEquals(1, spans.size(), "expected exactly one span");
var span = spans.get(0);
var instrumentation =
JSON_MAPPER
.readTree(
span.getAttributes()
.get(AttributeKey.stringKey("braintrust.context_json")))
.path("span_origin")
.path("instrumentation");
assertEquals("aws-bedrock", instrumentation.path("name").asText());
assertEquals(
System.getProperty("braintrust.muzzle.minimumVersion"),
instrumentation.path("version").asText(),
"span origin version must match the minimum passing muzzle version");

String spanAttributesJson =
span.getAttributes().get(AttributeKey.stringKey("braintrust.span_attributes"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import static dev.braintrust.json.BraintrustJsonMapper.toJson;

import com.google.genai.types.HttpOptions;
import dev.braintrust.bootstrap.BraintrustBridge;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanKind;
Expand All @@ -30,6 +29,9 @@
*/
@Slf4j
class BraintrustApiClient extends ApiClient {
private static final String INSTRUMENTATION_NAME = "genai";
private static final String INSTRUMENTATION_VERSION = "1.18.0";

private final ApiClient delegate;
private final Tracer tracer;

Expand All @@ -44,7 +46,7 @@ public BraintrustApiClient(ApiClient delegate, OpenTelemetry openTelemetry) {
delegate.httpOptions != null ? Optional.of(delegate.httpOptions) : Optional.empty(),
delegate.clientOptions != null ? delegate.clientOptions : Optional.empty());
this.delegate = delegate;
this.tracer = openTelemetry.getTracer(BraintrustBridge.INSTRUMENTATION_NAME);
this.tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION);
}

private void tagSpan(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ void testWrapGemini() {
var spans = testHarness.awaitExportedSpans();
assertEquals(1, spans.size(), "Expected exactly 1 span to be created");
var span = spans.get(0);
var instrumentation =
JSON_MAPPER
.readTree(
span.getAttributes()
.get(AttributeKey.stringKey("braintrust.context_json")))
.path("span_origin")
.path("instrumentation");
assertEquals("genai", instrumentation.path("name").asText());
assertEquals(
System.getProperty("braintrust.muzzle.minimumVersion"),
instrumentation.path("version").asText(),
"span origin version must match the minimum passing muzzle version");

// Verify span name matches the operation
assertEquals("generate_content", span.getName());
Expand Down Expand Up @@ -136,6 +148,18 @@ void testWrapGeminiAsync() {
var spans = testHarness.awaitExportedSpans();
assertEquals(1, spans.size(), "Expected exactly 1 span to be created");
var span = spans.get(0);
var instrumentation =
JSON_MAPPER
.readTree(
span.getAttributes()
.get(AttributeKey.stringKey("braintrust.context_json")))
.path("span_origin")
.path("instrumentation");
assertEquals("genai", instrumentation.path("name").asText());
assertEquals(
System.getProperty("braintrust.muzzle.minimumVersion"),
instrumentation.path("version").asText(),
"span origin version must match the minimum passing muzzle version");

// Verify span name matches the operation
assertEquals("generate_content", span.getName());
Expand Down
Loading
Loading