Skip to content

Commit a506983

Browse files
committed
wip
1 parent 6e88944 commit a506983

35 files changed

Lines changed: 2421 additions & 699 deletions

‎braintrust-sdk/instrumentation/anthropic_2_2_0/src/main/java/dev/braintrust/instrumentation/anthropic/v2_2_0/BraintrustAnthropic.java‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,24 @@ public final class BraintrustAnthropic {
1717

1818
/** Instrument Anthropic client with Braintrust traces. */
1919
public static AnthropicClient wrap(OpenTelemetry openTelemetry, AnthropicClient client) {
20+
return wrapClient(openTelemetry, client);
21+
}
22+
23+
/**
24+
* Instrument any anthropic-java client ({@code AnthropicClient}, {@code AnthropicClientAsync},
25+
* ...) with Braintrust traces. The client's {@code ClientOptions.httpClient} is swapped for a
26+
* {@link TracingHttpClient} in place; wrapping is idempotent.
27+
*/
28+
public static <T> T wrapClient(OpenTelemetry openTelemetry, T client) {
2029
try {
2130
instrumentHttpClient(openTelemetry, client);
22-
return client;
2331
} catch (Exception e) {
2432
log.error("failed to apply anthropic instrumentation", e);
25-
return client;
2633
}
34+
return client;
2735
}
2836

29-
private static void instrumentHttpClient(OpenTelemetry openTelemetry, AnthropicClient client) {
37+
private static void instrumentHttpClient(OpenTelemetry openTelemetry, Object client) {
3038
forAllFields(
3139
client,
3240
fieldName -> {

‎braintrust-sdk/instrumentation/anthropic_2_2_0/src/main/java/dev/braintrust/instrumentation/anthropic/v2_2_0/TracingHttpClient.java‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import io.opentelemetry.api.OpenTelemetry;
1414
import io.opentelemetry.api.trace.Span;
1515
import io.opentelemetry.api.trace.Tracer;
16+
import io.opentelemetry.context.Context;
1617
import java.io.BufferedReader;
1718
import java.io.ByteArrayInputStream;
1819
import java.io.ByteArrayOutputStream;
@@ -31,9 +32,28 @@ public class TracingHttpClient implements HttpClient {
3132
private final Tracer tracer;
3233
private final HttpClient underlying;
3334

35+
/**
36+
* OTel context captured when the client was instrumented. Frameworks (e.g. Spring AI 2.x) may
37+
* dispatch async/streaming requests on executors or schedulers where the caller's thread-local
38+
* context is lost — which would orphan the LLM span. When the executing thread has no active
39+
* context, we fall back to the context active at wrap time.
40+
*/
41+
private final Context instrumentationContext;
42+
3443
public TracingHttpClient(OpenTelemetry openTelemetry, HttpClient underlying) {
3544
this.tracer = openTelemetry.getTracer(BraintrustBridge.INSTRUMENTATION_NAME);
3645
this.underlying = underlying;
46+
this.instrumentationContext = Context.current();
47+
}
48+
49+
private Span startLlmSpan() {
50+
Context parent = Context.current();
51+
if (parent == Context.root() && instrumentationContext != Context.root()) {
52+
parent = instrumentationContext;
53+
}
54+
return tracer.spanBuilder(InstrumentationSemConv.UNSET_LLM_SPAN_NAME)
55+
.setParent(parent)
56+
.startSpan();
3757
}
3858

3959
@Override
@@ -44,7 +64,7 @@ public void close() {
4464
@Override
4565
public @Nonnull HttpResponse execute(
4666
@Nonnull HttpRequest httpRequest, @Nonnull RequestOptions requestOptions) {
47-
var span = tracer.spanBuilder(InstrumentationSemConv.UNSET_LLM_SPAN_NAME).startSpan();
67+
var span = startLlmSpan();
4868
try (var ignored = span.makeCurrent()) {
4969
var bufferedRequest = bufferRequestBody(httpRequest);
5070

@@ -73,7 +93,7 @@ public void close() {
7393
@Override
7494
public @Nonnull CompletableFuture<HttpResponse> executeAsync(
7595
@Nonnull HttpRequest httpRequest, @Nonnull RequestOptions requestOptions) {
76-
var span = tracer.spanBuilder(InstrumentationSemConv.UNSET_LLM_SPAN_NAME).startSpan();
96+
var span = startLlmSpan();
7797
try {
7898
var bufferedRequest = bufferRequestBody(httpRequest);
7999
String inputJson =

‎braintrust-sdk/instrumentation/openai_2_15_0/src/main/java/dev/braintrust/instrumentation/openai/v2_15_0/BraintrustOpenAI.java‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,21 @@ public class BraintrustOpenAI {
2323

2424
/** Instrument openai client with braintrust traces */
2525
public static OpenAIClient wrapOpenAI(OpenTelemetry openTelemetry, OpenAIClient openAIClient) {
26+
return wrapClient(openTelemetry, openAIClient);
27+
}
28+
29+
/**
30+
* Instrument any openai-java client ({@code OpenAIClient}, {@code OpenAIClientAsync}, ...) with
31+
* braintrust traces. The client's {@code ClientOptions.httpClient} is swapped for a {@link
32+
* TracingHttpClient} in place; wrapping is idempotent.
33+
*/
34+
public static <T> T wrapClient(OpenTelemetry openTelemetry, T client) {
2635
try {
27-
instrumentHttpClient(openTelemetry, openAIClient);
28-
return openAIClient;
36+
instrumentHttpClient(openTelemetry, client);
2937
} catch (Exception e) {
3038
log.error("failed to apply openai instrumentation", e);
31-
return openAIClient;
3239
}
40+
return client;
3341
}
3442

3543
@SneakyThrows
@@ -49,8 +57,7 @@ public static ChatCompletionCreateParams buildChatCompletionsPrompt(
4957
.build();
5058
}
5159

52-
private static void instrumentHttpClient(
53-
OpenTelemetry openTelemetry, OpenAIClient openAIClient) {
60+
private static void instrumentHttpClient(OpenTelemetry openTelemetry, Object openAIClient) {
5461
forAllFields(
5562
openAIClient,
5663
fieldName -> {

‎braintrust-sdk/instrumentation/openai_2_15_0/src/main/java/dev/braintrust/instrumentation/openai/v2_15_0/TracingHttpClient.java‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import io.opentelemetry.api.OpenTelemetry;
1515
import io.opentelemetry.api.trace.Span;
1616
import io.opentelemetry.api.trace.Tracer;
17+
import io.opentelemetry.context.Context;
1718
import java.io.*;
1819
import java.nio.charset.StandardCharsets;
1920
import java.util.concurrent.CompletableFuture;
@@ -28,9 +29,28 @@ class TracingHttpClient implements HttpClient {
2829
private final Tracer tracer;
2930
private final HttpClient underlying;
3031

32+
/**
33+
* OTel context captured when the client was instrumented. Newer openai-java versions dispatch
34+
* async/streaming requests through CompletableFuture continuations on the common pool, where
35+
* the caller's thread-local context is lost — which would orphan the LLM span. When the
36+
* executing thread has no active context, we fall back to the context active at wrap time.
37+
*/
38+
private final Context instrumentationContext;
39+
3140
public TracingHttpClient(OpenTelemetry openTelemetry, HttpClient underlying) {
3241
this.tracer = openTelemetry.getTracer(BraintrustBridge.INSTRUMENTATION_NAME);
3342
this.underlying = underlying;
43+
this.instrumentationContext = Context.current();
44+
}
45+
46+
private Span startLlmSpan() {
47+
Context parent = Context.current();
48+
if (parent == Context.root() && instrumentationContext != Context.root()) {
49+
parent = instrumentationContext;
50+
}
51+
return tracer.spanBuilder(InstrumentationSemConv.UNSET_LLM_SPAN_NAME)
52+
.setParent(parent)
53+
.startSpan();
3454
}
3555

3656
@Override
@@ -41,7 +61,7 @@ public void close() {
4161
@Override
4262
public @NonNull HttpResponse execute(
4363
@NonNull HttpRequest httpRequest, @NonNull RequestOptions requestOptions) {
44-
var span = tracer.spanBuilder(InstrumentationSemConv.UNSET_LLM_SPAN_NAME).startSpan();
64+
var span = startLlmSpan();
4565
try (var ignored = span.makeCurrent()) {
4666
// Buffer the request body so we can (a) read its bytes for the span attribute and
4767
// (b) supply a fresh, repeatable body to the underlying client — avoiding any
@@ -74,7 +94,7 @@ public void close() {
7494
@Override
7595
public @NonNull CompletableFuture<HttpResponse> executeAsync(
7696
@NonNull HttpRequest httpRequest, @NonNull RequestOptions requestOptions) {
77-
var span = tracer.spanBuilder(InstrumentationSemConv.UNSET_LLM_SPAN_NAME).startSpan();
97+
var span = startLlmSpan();
7898
try {
7999
var bufferedRequest = bufferRequestBody(httpRequest);
80100
String inputJson =

‎braintrust-sdk/instrumentation/springai_1_0_0/build.gradle‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ muzzle {
44
pass {
55
group = 'org.springframework.ai'
66
module = 'spring-ai-openai'
7+
// Spring AI 2.0 replaced its RestClient/WebClient HTTP layer with the official
8+
// provider SDKs, so this instrumentation only applies to 1.x.
79
versions = "[${springAiVersion},2.0.0)"
810
ignoredInstrumentation = ["dev.braintrust.instrumentation.springai.v1_0_0.auto.SpringAIAnthropicInstrumentationModule"]
911
extraDependency 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
def springAiVersion = '2.0.0'
2+
3+
muzzle {
4+
// The sibling SDK-level modules (openai_2_15_0, anthropic_2_2_0) are on this project's
5+
// classpath for client-wrapping reuse, so their instrumentation modules are discovered by
6+
// muzzle too — each pass must ignore the other provider's modules.
7+
pass {
8+
group = 'org.springframework.ai'
9+
module = 'spring-ai-openai'
10+
versions = "[${springAiVersion},)"
11+
ignoredInstrumentation = [
12+
"dev.braintrust.instrumentation.springai.v2_0_0.auto.SpringAIAnthropicInstrumentationModule",
13+
"dev.braintrust.instrumentation.anthropic.v2_2_0.auto.AnthropicInstrumentationModule",
14+
]
15+
}
16+
pass {
17+
group = 'org.springframework.ai'
18+
module = 'spring-ai-anthropic'
19+
versions = "[${springAiVersion},)"
20+
ignoredInstrumentation = [
21+
"dev.braintrust.instrumentation.springai.v2_0_0.auto.SpringAIOpenAIInstrumentationModule",
22+
"dev.braintrust.instrumentation.openai.v2_15_0.auto.OpenAIInstrumentationModule",
23+
]
24+
}
25+
}
26+
27+
dependencies {
28+
compileOnly project(':braintrust-java-agent:instrumenter')
29+
implementation "io.opentelemetry:opentelemetry-api:${otelVersion}"
30+
implementation 'com.google.code.findbugs:jsr305:3.0.2'
31+
implementation "org.slf4j:slf4j-api:${slf4jVersion}"
32+
implementation project(':braintrust-sdk')
33+
34+
// Spring AI 2.0 delegates all HTTP to the official provider SDKs (openai-java,
35+
// anthropic-java), so this module reuses the client wrapping from the SDK-level
36+
// instrumentation modules instead of instrumenting Spring's HTTP layer (which no
37+
// longer exists in 2.0).
38+
implementation project(':braintrust-sdk:instrumentation:openai_2_15_0')
39+
implementation project(':braintrust-sdk:instrumentation:anthropic_2_2_0')
40+
41+
// ByteBuddy for ElementMatcher types used in instrumentation definitions
42+
compileOnly 'net.bytebuddy:byte-buddy:1.17.5'
43+
44+
// Test dependencies
45+
testImplementation(testFixtures(project(":test-harness")))
46+
testImplementation project(':braintrust-java-agent:instrumenter')
47+
testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}"
48+
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
49+
testImplementation 'net.bytebuddy:byte-buddy-agent:1.17.5'
50+
testRuntimeOnly "org.slf4j:slf4j-simple:${slf4jVersion}"
51+
testImplementation "org.springframework.ai:spring-ai-model:${springAiVersion}"
52+
testImplementation "org.springframework.ai:spring-ai-openai:${springAiVersion}"
53+
testImplementation "org.springframework.ai:spring-ai-anthropic:${springAiVersion}"
54+
}
55+
56+
test {
57+
useJUnitPlatform()
58+
workingDir = rootProject.projectDir
59+
testLogging {
60+
events "passed", "skipped", "failed"
61+
showStandardStreams = true
62+
exceptionFormat "full"
63+
}
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package dev.braintrust.instrumentation.springai.v2_0_0;
2+
3+
import io.opentelemetry.api.OpenTelemetry;
4+
import lombok.extern.slf4j.Slf4j;
5+
6+
/**
7+
* Braintrust Spring AI 2.x instrumentation entry point for manual (non-agent) use.
8+
*
9+
* <p>Accepts a built Spring AI chat model and instruments it in place. Spring AI 2.0 delegates all
10+
* HTTP to the official provider SDKs, so instrumentation wraps the underlying {@code
11+
* com.openai.client.OpenAIClient} / {@code com.anthropic.client.AnthropicClient} (and their async
12+
* variants) rather than Spring's HTTP clients, which no longer exist in 2.0.
13+
*
14+
* <pre>{@code
15+
* var chatModel = BraintrustSpringAI.wrap(openTelemetry, OpenAiChatModel.builder()...build());
16+
* }</pre>
17+
*/
18+
@Slf4j
19+
public final class BraintrustSpringAI {
20+
21+
private static final String OPENAI_CHAT_MODEL_CLASS =
22+
"org.springframework.ai.openai.OpenAiChatModel";
23+
private static final String ANTHROPIC_CHAT_MODEL_CLASS =
24+
"org.springframework.ai.anthropic.AnthropicChatModel";
25+
26+
/** Instruments a Spring AI chat model in place and returns it. */
27+
public static <T> T wrap(OpenTelemetry openTelemetry, T chatModel) {
28+
try {
29+
String className = chatModel.getClass().getName();
30+
switch (className) {
31+
case OPENAI_CHAT_MODEL_CLASS -> SpringAIOpenAI.wrap(openTelemetry, chatModel);
32+
case ANTHROPIC_CHAT_MODEL_CLASS -> SpringAIAnthropic.wrap(openTelemetry, chatModel);
33+
default ->
34+
log.warn("BraintrustSpringAI.wrap: unrecognised chat model {}", className);
35+
}
36+
} catch (Exception e) {
37+
log.error("failed to apply spring ai instrumentation", e);
38+
}
39+
return chatModel;
40+
}
41+
42+
private BraintrustSpringAI() {}
43+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package dev.braintrust.instrumentation.springai.v2_0_0;
2+
3+
import dev.braintrust.instrumentation.anthropic.v2_2_0.BraintrustAnthropic;
4+
import io.opentelemetry.api.OpenTelemetry;
5+
import java.lang.reflect.Field;
6+
import lombok.extern.slf4j.Slf4j;
7+
8+
/**
9+
* Instruments a Spring AI 2.x {@code AnthropicChatModel} in place.
10+
*
11+
* <p>Spring AI 2.0 delegates all HTTP to the official anthropic-java SDK: the chat model holds a
12+
* {@code com.anthropic.client.AnthropicClient} (sync calls) and a {@code
13+
* com.anthropic.client.AnthropicClientAsync} (streaming). Both are wrapped with the same {@code
14+
* TracingHttpClient} used by the anthropic-java instrumentation module. All access is reflective so
15+
* this class never links against Spring AI types.
16+
*/
17+
@Slf4j
18+
public final class SpringAIAnthropic {
19+
20+
/** Wraps the official SDK clients inside an {@code AnthropicChatModel}. Idempotent. */
21+
public static <T> T wrap(OpenTelemetry openTelemetry, T chatModel) {
22+
wrapClientField(openTelemetry, chatModel, "anthropicClient");
23+
wrapClientField(openTelemetry, chatModel, "anthropicClientAsync");
24+
return chatModel;
25+
}
26+
27+
private static void wrapClientField(
28+
OpenTelemetry openTelemetry, Object chatModel, String fieldName) {
29+
try {
30+
Object client = getField(chatModel, fieldName);
31+
if (client != null) {
32+
BraintrustAnthropic.wrapClient(openTelemetry, client);
33+
}
34+
} catch (Exception e) {
35+
log.error("failed to instrument spring ai anthropic client field {}", fieldName, e);
36+
}
37+
}
38+
39+
private static Object getField(Object obj, String fieldName)
40+
throws ReflectiveOperationException {
41+
Class<?> clazz = obj.getClass();
42+
while (clazz != null) {
43+
try {
44+
Field field = clazz.getDeclaredField(fieldName);
45+
field.setAccessible(true);
46+
return field.get(obj);
47+
} catch (NoSuchFieldException e) {
48+
clazz = clazz.getSuperclass();
49+
}
50+
}
51+
throw new NoSuchFieldException(
52+
"Field '" + fieldName + "' not found on " + obj.getClass().getName());
53+
}
54+
55+
private SpringAIAnthropic() {}
56+
}

0 commit comments

Comments
 (0)