Skip to content

Commit 8f69645

Browse files
committed
fixup
1 parent 5d0a575 commit 8f69645

9 files changed

Lines changed: 257 additions & 117 deletions

File tree

braintrust-java-agent/instrumenter/src/main/java/dev/braintrust/instrumentation/Instrumenter.java

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,12 @@ public class Instrumenter {
3737
* resolution (typically the BraintrustClassLoader)
3838
*/
3939
public static void install(Instrumentation inst, ClassLoader agentClassloader) {
40+
// NOTE: ByteBuddy's default method-ignore matcher is isSynthetic(), which silently skips
41+
// Kotlin `internal` functions (compiled with ACC_SYNTHETIC). If an instrumentation ever
42+
// needs to advise one, construct the AgentBuilder with
43+
// `new ByteBuddy().ignore(isBridge())` — no current hook targets such a method.
4044
var agentBuilder =
41-
// ByteBuddy's default method-ignore matcher is isSynthetic(), which silently
42-
// skips Kotlin `internal` functions (compiled with ACC_SYNTHETIC) — e.g.
43-
// openai-java's PrepareRequest.prepareAsync. Only ignore bridge methods, where
44-
// advice would otherwise apply twice (bridge + target).
45-
new AgentBuilder.Default(
46-
new net.bytebuddy.ByteBuddy()
47-
.ignore(net.bytebuddy.matcher.ElementMatchers.isBridge()))
45+
new AgentBuilder.Default()
4846
// Use retransformation so we can instrument classes already loaded
4947
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
5048
// Re-iterate over already-loaded classes so we can retransform them

braintrust-sdk/instrumentation/anthropic_2_2_0/src/main/java/dev/braintrust/instrumentation/anthropic/v2_2_0/auto/AnthropicInstrumentationModule.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ private static class AnthropicOkHttpClientBuilderAdvice {
6262
public static void build(
6363
@Advice.Return(readOnly = false, typing = Assigner.Typing.DYNAMIC)
6464
Object returnedObject) {
65-
AnthropicClient returnedClient = (AnthropicClient) returnedObject;
66-
returnedClient = BraintrustAnthropic.wrap(GlobalOpenTelemetry.get(), returnedClient);
65+
returnedObject =
66+
BraintrustAnthropic.wrap(
67+
GlobalOpenTelemetry.get(), (AnthropicClient) returnedObject);
6768
}
6869
}
6970
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,14 @@ public class BraintrustOpenAI {
2525
/** Instrument openai client with braintrust traces */
2626
public static OpenAIClient wrapOpenAI(OpenTelemetry openTelemetry, OpenAIClient openAIClient) {
2727
instrument(openTelemetry, openAIClient);
28-
return openAIClient;
28+
return ContextCapturingProxy.wrap(openAIClient, OpenAIClient.class);
2929
}
3030

3131
/** Instrument an async openai client with braintrust traces */
3232
public static OpenAIClientAsync wrapOpenAI(
3333
OpenTelemetry openTelemetry, OpenAIClientAsync openAIClient) {
3434
instrument(openTelemetry, openAIClient);
35-
return openAIClient;
35+
return ContextCapturingProxy.wrap(openAIClient, OpenAIClientAsync.class);
3636
}
3737

3838
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package dev.braintrust.instrumentation.openai.v2_15_0;
2+
3+
import com.openai.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 openai-java client.
16+
*
17+
* <p>openai-java dispatches async requests through CompletableFuture continuations on the common
18+
* pool, so by the time {@link TracingHttpClient#executeAsync} runs, the caller's thread-local
19+
* context (e.g. an active application span) is gone. The service-method invocation itself, however,
20+
* happens on the caller's thread — and the only data that travels from there into the HTTP layer is
21+
* the request. So this proxy rewrites any {@link Params} argument to carry the current span as an
22+
* internal {@code traceparent}-format header, which {@link TracingHttpClient} extracts (and strips
23+
* from the outgoing request) to parent the LLM span.
24+
*
25+
* <p>Purely reflective and API-shape based, so it works for every service and endpoint: methods
26+
* returning other {@code com.openai} interfaces (service accessors like {@code chat()}, {@code
27+
* async()}) return proxied instances, so context capture follows the whole call graph.
28+
*/
29+
@Slf4j
30+
final class ContextCapturingProxy implements InvocationHandler {
31+
32+
/** Internal correlation header; never sent — TracingHttpClient removes it. */
33+
static final String CONTEXT_HEADER = "x-braintrust-otel-traceparent";
34+
35+
/** Per-params-class reflection handles: [toBuilder, putAdditionalHeader, build]. */
36+
private static final Map<Class<?>, Method[]> PARAMS_METHODS = new ConcurrentHashMap<>();
37+
38+
private static final Method[] UNSUPPORTED = new Method[0];
39+
40+
private final Object delegate;
41+
42+
private ContextCapturingProxy(Object delegate) {
43+
this.delegate = delegate;
44+
}
45+
46+
/** Wraps {@code delegate} in a context-capturing proxy of {@code iface}. Idempotent. */
47+
@SuppressWarnings("unchecked")
48+
static <T> T wrap(T delegate, Class<T> iface) {
49+
if (delegate == null || isContextCapturingProxy(delegate)) {
50+
return delegate;
51+
}
52+
return (T)
53+
Proxy.newProxyInstance(
54+
iface.getClassLoader(),
55+
new Class<?>[] {iface},
56+
new ContextCapturingProxy(delegate));
57+
}
58+
59+
private static boolean isContextCapturingProxy(Object o) {
60+
return Proxy.isProxyClass(o.getClass())
61+
&& Proxy.getInvocationHandler(o) instanceof ContextCapturingProxy;
62+
}
63+
64+
@Override
65+
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
66+
Object[] invokeArgs = injectContextHeader(args);
67+
Object result;
68+
try {
69+
result = method.invoke(delegate, invokeArgs);
70+
} catch (InvocationTargetException e) {
71+
throw e.getCause();
72+
}
73+
// Follow the service graph: accessors like chat(), completions(), async() return
74+
// com.openai interfaces whose methods must also capture context.
75+
Class<?> returnType = method.getReturnType();
76+
if (result != null
77+
&& returnType.isInterface()
78+
&& returnType.getName().startsWith("com.openai.")) {
79+
return Proxy.newProxyInstance(
80+
returnType.getClassLoader(),
81+
new Class<?>[] {returnType},
82+
new ContextCapturingProxy(result));
83+
}
84+
return result;
85+
}
86+
87+
/** Rewrites any {@link Params} argument to carry the current span as an internal header. */
88+
private Object[] injectContextHeader(Object[] args) {
89+
if (args == null) {
90+
return null;
91+
}
92+
String traceparent = currentTraceparent();
93+
if (traceparent == null) {
94+
return args;
95+
}
96+
Object[] result = args;
97+
for (int i = 0; i < args.length; i++) {
98+
if (args[i] instanceof Params params) {
99+
Object rewritten = withContextHeader(params, traceparent);
100+
if (rewritten != null) {
101+
if (result == args) {
102+
result = args.clone();
103+
}
104+
result[i] = rewritten;
105+
}
106+
}
107+
}
108+
return result;
109+
}
110+
111+
private static String currentTraceparent() {
112+
SpanContext spanContext = Span.current().getSpanContext();
113+
if (!spanContext.isValid()) {
114+
return null;
115+
}
116+
return "00-"
117+
+ spanContext.getTraceId()
118+
+ "-"
119+
+ spanContext.getSpanId()
120+
+ "-"
121+
+ spanContext.getTraceFlags().asHex();
122+
}
123+
124+
/**
125+
* {@code params.toBuilder().putAdditionalHeader(CONTEXT_HEADER, traceparent).build()}, done
126+
* reflectively so it works for every generated params type. Returns {@code null} (leaving the
127+
* original untouched) when the shape doesn't match.
128+
*/
129+
private static Object withContextHeader(Params params, String traceparent) {
130+
Method[] methods =
131+
PARAMS_METHODS.computeIfAbsent(
132+
params.getClass(), ContextCapturingProxy::resolveParamsMethods);
133+
if (methods == UNSUPPORTED) {
134+
return null;
135+
}
136+
try {
137+
Object builder = methods[0].invoke(params);
138+
methods[1].invoke(builder, CONTEXT_HEADER, traceparent);
139+
return methods[2].invoke(builder);
140+
} catch (Exception e) {
141+
log.debug("failed to inject context header into {}", params.getClass().getName(), e);
142+
return null;
143+
}
144+
}
145+
146+
private static Method[] resolveParamsMethods(Class<?> paramsClass) {
147+
try {
148+
Method toBuilder = paramsClass.getMethod("toBuilder");
149+
Class<?> builderClass = toBuilder.getReturnType();
150+
Method putHeader =
151+
builderClass.getMethod("putAdditionalHeader", String.class, String.class);
152+
Method build = builderClass.getMethod("build");
153+
return new Method[] {toBuilder, putHeader, build};
154+
} catch (NoSuchMethodException e) {
155+
log.debug("params type {} has no header builder shape", paramsClass.getName());
156+
return UNSUPPORTED;
157+
}
158+
}
159+
}

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

Lines changed: 0 additions & 57 deletions
This file was deleted.

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

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@
1313
import dev.braintrust.json.BraintrustJsonMapper;
1414
import io.opentelemetry.api.OpenTelemetry;
1515
import io.opentelemetry.api.trace.Span;
16+
import io.opentelemetry.api.trace.SpanContext;
17+
import io.opentelemetry.api.trace.TraceFlags;
18+
import io.opentelemetry.api.trace.TraceState;
1619
import io.opentelemetry.api.trace.Tracer;
1720
import io.opentelemetry.context.Context;
1821
import java.io.*;
1922
import java.nio.charset.StandardCharsets;
2023
import java.util.concurrent.CompletableFuture;
2124
import java.util.concurrent.atomic.AtomicBoolean;
2225
import java.util.concurrent.atomic.AtomicLong;
26+
import javax.annotation.Nullable;
2327
import lombok.NonNull;
2428
import lombok.extern.slf4j.Slf4j;
2529

@@ -30,10 +34,11 @@ class TracingHttpClient implements HttpClient {
3034
private final HttpClient underlying;
3135

3236
/**
33-
* OTel context captured when the client was instrumented. Newer openai-java versions dispatch
37+
* OTel context captured when the client was instrumented. openai-java dispatches
3438
* 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.
39+
* the caller's thread-local context is lost — which would orphan the LLM span. The primary fix
40+
* is the context header injected by {@link ContextCapturingProxy}; when neither that header nor
41+
* an active context is present, we fall back to the context active at wrap time.
3742
*/
3843
private final Context instrumentationContext;
3944

@@ -43,8 +48,8 @@ public TracingHttpClient(OpenTelemetry openTelemetry, HttpClient underlying) {
4348
this.instrumentationContext = Context.current();
4449
}
4550

46-
private Span startLlmSpan() {
47-
Context parent = Context.current();
51+
private Span startLlmSpan(@Nullable Context headerContext) {
52+
Context parent = headerContext != null ? headerContext : Context.current();
4853
if (parent == Context.root() && instrumentationContext != Context.root()) {
4954
parent = instrumentationContext;
5055
}
@@ -53,6 +58,46 @@ private Span startLlmSpan() {
5358
.startSpan();
5459
}
5560

61+
/**
62+
* Extracts the caller context injected by {@link ContextCapturingProxy} (if any) and strips the
63+
* internal header from the outgoing request.
64+
*/
65+
private static ExtractedRequest extractCallerContext(HttpRequest request) {
66+
var values = request.headers().values(ContextCapturingProxy.CONTEXT_HEADER);
67+
if (values.isEmpty()) {
68+
return new ExtractedRequest(request, null);
69+
}
70+
Context context = contextFromTraceparent(values.get(0));
71+
HttpRequest stripped =
72+
request.toBuilder()
73+
.replaceHeaders(ContextCapturingProxy.CONTEXT_HEADER, java.util.List.of())
74+
.build();
75+
return new ExtractedRequest(stripped, context);
76+
}
77+
78+
/** Parses a W3C {@code traceparent} value ({@code 00-<traceId>-<spanId>-<flags>}). */
79+
@Nullable
80+
private static Context contextFromTraceparent(String traceparent) {
81+
try {
82+
String[] parts = traceparent.split("-");
83+
SpanContext spanContext =
84+
SpanContext.create(
85+
parts[1],
86+
parts[2],
87+
TraceFlags.fromHex(parts[3], 0),
88+
TraceState.getDefault());
89+
if (!spanContext.isValid()) {
90+
return null;
91+
}
92+
return Context.root().with(Span.wrap(spanContext));
93+
} catch (Exception e) {
94+
log.debug("invalid context header value: {}", traceparent, e);
95+
return null;
96+
}
97+
}
98+
99+
private record ExtractedRequest(HttpRequest request, @Nullable Context callerContext) {}
100+
56101
@Override
57102
public void close() {
58103
underlying.close();
@@ -61,12 +106,13 @@ public void close() {
61106
@Override
62107
public @NonNull HttpResponse execute(
63108
@NonNull HttpRequest httpRequest, @NonNull RequestOptions requestOptions) {
64-
var span = startLlmSpan();
109+
var extracted = extractCallerContext(httpRequest);
110+
var span = startLlmSpan(extracted.callerContext());
65111
try (var ignored = span.makeCurrent()) {
66112
// Buffer the request body so we can (a) read its bytes for the span attribute and
67113
// (b) supply a fresh, repeatable body to the underlying client — avoiding any
68114
// one-shot stream consumption issue.
69-
var bufferedRequest = bufferRequestBody(httpRequest);
115+
var bufferedRequest = bufferRequestBody(extracted.request());
70116

71117
String inputJson =
72118
bufferedRequest.body() != null
@@ -94,9 +140,10 @@ public void close() {
94140
@Override
95141
public @NonNull CompletableFuture<HttpResponse> executeAsync(
96142
@NonNull HttpRequest httpRequest, @NonNull RequestOptions requestOptions) {
97-
var span = startLlmSpan();
143+
var extracted = extractCallerContext(httpRequest);
144+
var span = startLlmSpan(extracted.callerContext());
98145
try {
99-
var bufferedRequest = bufferRequestBody(httpRequest);
146+
var bufferedRequest = bufferRequestBody(extracted.request());
100147
String inputJson =
101148
bufferedRequest.body() != null
102149
? readBodyAsString(bufferedRequest.body())

0 commit comments

Comments
 (0)