Skip to content

Commit 809be54

Browse files
committed
springai 2 instrumentation
- enhance vanilla openai / anthropic instrumentation - add module for springai 2 - add multi version support to btx
1 parent b0a9c1a commit 809be54

46 files changed

Lines changed: 3760 additions & 806 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 56 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
package dev.braintrust.instrumentation.anthropic.v2_2_0;
22

33
import com.anthropic.client.AnthropicClient;
4+
import com.anthropic.client.AnthropicClientAsync;
45
import com.anthropic.core.ClientOptions;
56
import com.anthropic.core.http.HttpClient;
67
import io.opentelemetry.api.OpenTelemetry;
78
import java.lang.reflect.Field;
89
import java.lang.reflect.Modifier;
9-
import java.util.function.BiConsumer;
1010
import java.util.function.Consumer;
11-
import kotlin.Lazy;
1211
import lombok.extern.slf4j.Slf4j;
1312

1413
/** Braintrust Anthropic client instrumentation. */
@@ -17,48 +16,79 @@ public final class BraintrustAnthropic {
1716

1817
/** Instrument Anthropic client with Braintrust traces. */
1918
public static AnthropicClient wrap(OpenTelemetry openTelemetry, AnthropicClient client) {
19+
if (!instrument(openTelemetry, client)) {
20+
return client;
21+
}
22+
return ContextCapturingProxy.wrap(client, AnthropicClient.class);
23+
}
24+
25+
/** Instrument an async Anthropic client with Braintrust traces. */
26+
public static AnthropicClientAsync wrap(
27+
OpenTelemetry openTelemetry, AnthropicClientAsync client) {
28+
if (!instrument(openTelemetry, client)) {
29+
return client;
30+
}
31+
return ContextCapturingProxy.wrap(client, AnthropicClientAsync.class);
32+
}
33+
34+
/**
35+
* Swaps the client's {@code ClientOptions.httpClient} for a {@link TracingHttpClient} in place;
36+
* wrapping is idempotent.
37+
*
38+
* @return whether the HTTP layer is instrumented. When false (custom client implementations or
39+
* changed SDK internals), the caller must NOT install {@link ContextCapturingProxy}: the
40+
* proxy's internal context header is only stripped by {@link TracingHttpClient}, so
41+
* installing it without one would leak trace/span IDs to the provider.
42+
*/
43+
private static boolean instrument(OpenTelemetry openTelemetry, Object client) {
44+
if (ContextCapturingProxy.isContextCapturingProxy(client)) {
45+
// already instrumented
46+
return true;
47+
}
2048
try {
2149
instrumentHttpClient(openTelemetry, client);
22-
return client;
50+
return true;
2351
} catch (Exception e) {
24-
log.error("failed to apply anthropic instrumentation", e);
25-
return client;
52+
log.error(
53+
"failed to apply anthropic instrumentation to {} — leaving client untouched",
54+
client.getClass().getName(),
55+
e);
56+
return false;
2657
}
2758
}
2859

29-
private static void instrumentHttpClient(OpenTelemetry openTelemetry, AnthropicClient client) {
60+
private static void instrumentHttpClient(OpenTelemetry openTelemetry, Object client) {
61+
int[] instrumented = {0};
3062
forAllFields(
3163
client,
3264
fieldName -> {
3365
try {
34-
var field = getField(client, fieldName);
35-
if (field instanceof ClientOptions clientOptions) {
36-
instrumentClientOptions(
37-
openTelemetry, clientOptions, "originalHttpClient");
38-
instrumentClientOptions(openTelemetry, clientOptions, "httpClient");
39-
} else if (field instanceof Lazy<?> lazyField) {
40-
var resolved = lazyField.getValue();
41-
forAllFieldsOfType(
42-
resolved,
43-
ClientOptions.class,
44-
(clientOptions, subfieldName) ->
45-
instrumentClientOptions(
46-
openTelemetry, clientOptions, subfieldName));
47-
} else {
48-
forAllFieldsOfType(
49-
field,
50-
ClientOptions.class,
51-
(clientOptions, subfieldName) ->
52-
instrumentClientOptions(
53-
openTelemetry, clientOptions, subfieldName));
66+
if (getField(client, fieldName) instanceof ClientOptions clientOptions) {
67+
instrumentClientOptions(openTelemetry, clientOptions);
68+
instrumented[0]++;
5469
}
5570
} catch (ReflectiveOperationException e) {
5671
throw new RuntimeException(e);
5772
}
5873
});
74+
if (instrumented[0] == 0) {
75+
// Finding nothing is as much a failure as a reflection error: the request path
76+
// would bypass TracingHttpClient entirely.
77+
throw new IllegalStateException(
78+
"no ClientOptions field found on "
79+
+ client.getClass().getName()
80+
+ " — unrecognized client shape");
81+
}
5982
}
6083

84+
/** Swaps both HTTP client fields on a {@link ClientOptions} for tracing wrappers. */
6185
private static void instrumentClientOptions(
86+
OpenTelemetry openTelemetry, ClientOptions clientOptions) {
87+
swapHttpClient(openTelemetry, clientOptions, "originalHttpClient");
88+
swapHttpClient(openTelemetry, clientOptions, "httpClient");
89+
}
90+
91+
private static void swapHttpClient(
6292
OpenTelemetry openTelemetry, ClientOptions clientOptions, String fieldName) {
6393
try {
6494
HttpClient httpClient = getField(clientOptions, fieldName);
@@ -84,21 +114,6 @@ private static void forAllFields(Object object, Consumer<String> consumer) {
84114
}
85115
}
86116

87-
private static <T> void forAllFieldsOfType(
88-
Object object, Class<T> targetClazz, BiConsumer<T, String> consumer) {
89-
forAllFields(
90-
object,
91-
fieldName -> {
92-
try {
93-
if (targetClazz.isAssignableFrom(object.getClass())) {
94-
consumer.accept(getField(object, fieldName), fieldName);
95-
}
96-
} catch (ReflectiveOperationException e) {
97-
throw new RuntimeException(e);
98-
}
99-
});
100-
}
101-
102117
@SuppressWarnings("unchecked")
103118
private static <T> T getField(Object obj, String fieldName)
104119
throws ReflectiveOperationException {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package dev.braintrust.instrumentation.anthropic.v2_2_0;
2+
3+
import com.anthropic.core.Params;
4+
import io.opentelemetry.api.trace.Span;
5+
import io.opentelemetry.api.trace.SpanContext;
6+
import java.lang.reflect.InvocationHandler;
7+
import java.lang.reflect.InvocationTargetException;
8+
import java.lang.reflect.Method;
9+
import java.lang.reflect.Proxy;
10+
import java.util.Map;
11+
import java.util.concurrent.ConcurrentHashMap;
12+
import lombok.extern.slf4j.Slf4j;
13+
14+
/**
15+
* Captures the caller's OTel context at the service-call boundary of a wrapped anthropic-java
16+
* client.
17+
*
18+
* <p>anthropic-java dispatches async requests through CompletableFuture continuations on the common
19+
* pool, so by the time {@link TracingHttpClient#executeAsync} runs, the caller's thread-local
20+
* context (e.g. an active application span) is gone. The service-method invocation itself, however,
21+
* happens on the caller's thread — and the only data that travels from there into the HTTP layer is
22+
* the request. So this proxy rewrites any {@link Params} argument to carry the current span as an
23+
* internal {@code traceparent}-format header, which {@link TracingHttpClient} extracts (and strips
24+
* from the outgoing request) to parent the LLM span.
25+
*
26+
* <p>Purely reflective and API-shape based, so it works for every service and endpoint: methods
27+
* returning other {@code com.anthropic} interfaces (service accessors like {@code messages()},
28+
* {@code async()}) return proxied instances, so context capture follows the whole call graph.
29+
*/
30+
@Slf4j
31+
final class ContextCapturingProxy implements InvocationHandler {
32+
33+
/** Internal correlation header; never sent — TracingHttpClient removes it. */
34+
static final String CONTEXT_HEADER = "x-braintrust-instrumentation-context";
35+
36+
/** Per-params-class reflection handles: [toBuilder, putAdditionalHeader, build]. */
37+
private static final Map<Class<?>, Method[]> PARAMS_METHODS = new ConcurrentHashMap<>();
38+
39+
private static final Method[] UNSUPPORTED = new Method[0];
40+
41+
private final Object delegate;
42+
43+
private ContextCapturingProxy(Object delegate) {
44+
this.delegate = delegate;
45+
}
46+
47+
/** Wraps {@code delegate} in a context-capturing proxy of {@code iface}. Idempotent. */
48+
@SuppressWarnings("unchecked")
49+
static <T> T wrap(T delegate, Class<T> iface) {
50+
if (delegate == null || isContextCapturingProxy(delegate)) {
51+
return delegate;
52+
}
53+
return (T)
54+
Proxy.newProxyInstance(
55+
iface.getClassLoader(),
56+
new Class<?>[] {iface},
57+
new ContextCapturingProxy(delegate));
58+
}
59+
60+
static boolean isContextCapturingProxy(Object o) {
61+
return o != null
62+
&& Proxy.isProxyClass(o.getClass())
63+
&& Proxy.getInvocationHandler(o) instanceof ContextCapturingProxy;
64+
}
65+
66+
@Override
67+
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
68+
// equals/hashCode/toString are the only Object methods routed to an InvocationHandler.
69+
// They must not be forwarded like service methods: delegate.equals(proxy) is false, so a
70+
// forwarded equals would make proxy.equals(proxy) false — violating reflexivity and
71+
// breaking map/set usage of wrapped clients.
72+
if (method.getDeclaringClass() == Object.class) {
73+
return switch (method.getName()) {
74+
case "equals" -> proxy == args[0] || delegateEquals(args[0]);
75+
case "hashCode" -> delegate.hashCode();
76+
default -> "ContextCapturingProxy(" + delegate + ")"; // toString
77+
};
78+
}
79+
Object[] invokeArgs = injectContextHeader(args);
80+
Object result;
81+
try {
82+
result = method.invoke(delegate, invokeArgs);
83+
} catch (InvocationTargetException e) {
84+
throw e.getCause();
85+
}
86+
// Follow the service graph: accessors like messages(), async() return com.anthropic
87+
// interfaces whose methods must also capture context.
88+
Class<?> returnType = method.getReturnType();
89+
if (result != null
90+
&& returnType.isInterface()
91+
&& returnType.getName().startsWith("com.anthropic.")) {
92+
return Proxy.newProxyInstance(
93+
returnType.getClassLoader(),
94+
new Class<?>[] {returnType},
95+
new ContextCapturingProxy(result));
96+
}
97+
return result;
98+
}
99+
100+
/** Two proxies are equal iff their delegates are (consistent with delegate-based hashCode). */
101+
private boolean delegateEquals(Object other) {
102+
return isContextCapturingProxy(other)
103+
&& delegate.equals(
104+
((ContextCapturingProxy) Proxy.getInvocationHandler(other)).delegate);
105+
}
106+
107+
/** Rewrites any {@link Params} argument to carry the current span as an internal header. */
108+
private Object[] injectContextHeader(Object[] args) {
109+
if (args == null) {
110+
return null;
111+
}
112+
String traceparent = currentTraceparent();
113+
if (traceparent == null) {
114+
return args;
115+
}
116+
Object[] result = args;
117+
for (int i = 0; i < args.length; i++) {
118+
if (args[i] instanceof Params params) {
119+
Object rewritten = withContextHeader(params, traceparent);
120+
if (rewritten != null) {
121+
if (result == args) {
122+
result = args.clone();
123+
}
124+
result[i] = rewritten;
125+
}
126+
}
127+
}
128+
return result;
129+
}
130+
131+
private static String currentTraceparent() {
132+
SpanContext spanContext = Span.current().getSpanContext();
133+
if (!spanContext.isValid()) {
134+
return null;
135+
}
136+
return "00-"
137+
+ spanContext.getTraceId()
138+
+ "-"
139+
+ spanContext.getSpanId()
140+
+ "-"
141+
+ spanContext.getTraceFlags().asHex();
142+
}
143+
144+
/**
145+
* {@code params.toBuilder().putAdditionalHeader(CONTEXT_HEADER, traceparent).build()}, done
146+
* reflectively so it works for every generated params type. Returns {@code null} (leaving the
147+
* original untouched) when the shape doesn't match.
148+
*/
149+
private static Object withContextHeader(Params params, String traceparent) {
150+
Method[] methods =
151+
PARAMS_METHODS.computeIfAbsent(
152+
params.getClass(), ContextCapturingProxy::resolveParamsMethods);
153+
if (methods == UNSUPPORTED) {
154+
return null;
155+
}
156+
try {
157+
Object builder = methods[0].invoke(params);
158+
methods[1].invoke(builder, CONTEXT_HEADER, traceparent);
159+
return methods[2].invoke(builder);
160+
} catch (Exception e) {
161+
log.debug("failed to inject context header into {}", params.getClass().getName(), e);
162+
return null;
163+
}
164+
}
165+
166+
private static Method[] resolveParamsMethods(Class<?> paramsClass) {
167+
try {
168+
Method toBuilder = paramsClass.getMethod("toBuilder");
169+
Class<?> builderClass = toBuilder.getReturnType();
170+
Method putHeader =
171+
builderClass.getMethod("putAdditionalHeader", String.class, String.class);
172+
Method build = builderClass.getMethod("build");
173+
return new Method[] {toBuilder, putHeader, build};
174+
} catch (NoSuchMethodException e) {
175+
log.debug("params type {} has no header builder shape", paramsClass.getName());
176+
return UNSUPPORTED;
177+
}
178+
}
179+
}

0 commit comments

Comments
 (0)