|
| 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-otel-traceparent"; |
| 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 | + private static boolean isContextCapturingProxy(Object o) { |
| 61 | + return Proxy.isProxyClass(o.getClass()) |
| 62 | + && Proxy.getInvocationHandler(o) instanceof ContextCapturingProxy; |
| 63 | + } |
| 64 | + |
| 65 | + @Override |
| 66 | + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { |
| 67 | + Object[] invokeArgs = injectContextHeader(args); |
| 68 | + Object result; |
| 69 | + try { |
| 70 | + result = method.invoke(delegate, invokeArgs); |
| 71 | + } catch (InvocationTargetException e) { |
| 72 | + throw e.getCause(); |
| 73 | + } |
| 74 | + // Follow the service graph: accessors like messages(), async() return com.anthropic |
| 75 | + // interfaces whose methods must also capture context. |
| 76 | + Class<?> returnType = method.getReturnType(); |
| 77 | + if (result != null |
| 78 | + && returnType.isInterface() |
| 79 | + && returnType.getName().startsWith("com.anthropic.")) { |
| 80 | + return Proxy.newProxyInstance( |
| 81 | + returnType.getClassLoader(), |
| 82 | + new Class<?>[] {returnType}, |
| 83 | + new ContextCapturingProxy(result)); |
| 84 | + } |
| 85 | + return result; |
| 86 | + } |
| 87 | + |
| 88 | + /** Rewrites any {@link Params} argument to carry the current span as an internal header. */ |
| 89 | + private Object[] injectContextHeader(Object[] args) { |
| 90 | + if (args == null) { |
| 91 | + return null; |
| 92 | + } |
| 93 | + String traceparent = currentTraceparent(); |
| 94 | + if (traceparent == null) { |
| 95 | + return args; |
| 96 | + } |
| 97 | + Object[] result = args; |
| 98 | + for (int i = 0; i < args.length; i++) { |
| 99 | + if (args[i] instanceof Params params) { |
| 100 | + Object rewritten = withContextHeader(params, traceparent); |
| 101 | + if (rewritten != null) { |
| 102 | + if (result == args) { |
| 103 | + result = args.clone(); |
| 104 | + } |
| 105 | + result[i] = rewritten; |
| 106 | + } |
| 107 | + } |
| 108 | + } |
| 109 | + return result; |
| 110 | + } |
| 111 | + |
| 112 | + private static String currentTraceparent() { |
| 113 | + SpanContext spanContext = Span.current().getSpanContext(); |
| 114 | + if (!spanContext.isValid()) { |
| 115 | + return null; |
| 116 | + } |
| 117 | + return "00-" |
| 118 | + + spanContext.getTraceId() |
| 119 | + + "-" |
| 120 | + + spanContext.getSpanId() |
| 121 | + + "-" |
| 122 | + + spanContext.getTraceFlags().asHex(); |
| 123 | + } |
| 124 | + |
| 125 | + /** |
| 126 | + * {@code params.toBuilder().putAdditionalHeader(CONTEXT_HEADER, traceparent).build()}, done |
| 127 | + * reflectively so it works for every generated params type. Returns {@code null} (leaving the |
| 128 | + * original untouched) when the shape doesn't match. |
| 129 | + */ |
| 130 | + private static Object withContextHeader(Params params, String traceparent) { |
| 131 | + Method[] methods = |
| 132 | + PARAMS_METHODS.computeIfAbsent( |
| 133 | + params.getClass(), ContextCapturingProxy::resolveParamsMethods); |
| 134 | + if (methods == UNSUPPORTED) { |
| 135 | + return null; |
| 136 | + } |
| 137 | + try { |
| 138 | + Object builder = methods[0].invoke(params); |
| 139 | + methods[1].invoke(builder, CONTEXT_HEADER, traceparent); |
| 140 | + return methods[2].invoke(builder); |
| 141 | + } catch (Exception e) { |
| 142 | + log.debug("failed to inject context header into {}", params.getClass().getName(), e); |
| 143 | + return null; |
| 144 | + } |
| 145 | + } |
| 146 | + |
| 147 | + private static Method[] resolveParamsMethods(Class<?> paramsClass) { |
| 148 | + try { |
| 149 | + Method toBuilder = paramsClass.getMethod("toBuilder"); |
| 150 | + Class<?> builderClass = toBuilder.getReturnType(); |
| 151 | + Method putHeader = |
| 152 | + builderClass.getMethod("putAdditionalHeader", String.class, String.class); |
| 153 | + Method build = builderClass.getMethod("build"); |
| 154 | + return new Method[] {toBuilder, putHeader, build}; |
| 155 | + } catch (NoSuchMethodException e) { |
| 156 | + log.debug("params type {} has no header builder shape", paramsClass.getName()); |
| 157 | + return UNSUPPORTED; |
| 158 | + } |
| 159 | + } |
| 160 | +} |
0 commit comments