Skip to content

Commit b6e390d

Browse files
committed
refactor and simplify anthropic instrumentation
1 parent 4128af8 commit b6e390d

19 files changed

Lines changed: 721 additions & 1549 deletions

src/main/java/dev/braintrust/instrumentation/InstrumentationSemConv.java

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import static dev.braintrust.json.BraintrustJsonMapper.toJson;
44

55
import com.fasterxml.jackson.databind.JsonNode;
6+
import com.fasterxml.jackson.databind.node.ArrayNode;
67
import dev.braintrust.json.BraintrustJsonMapper;
78
import io.opentelemetry.api.trace.Span;
89
import io.opentelemetry.api.trace.StatusCode;
@@ -15,6 +16,7 @@
1516

1617
public class InstrumentationSemConv {
1718
public static final String PROVIDER_NAME_OPENAI = "openai";
19+
public static final String PROVIDER_NAME_ANTHROPIC = "anthropic";
1820
public static final String PROVIDER_NAME_OTHER = "generic-ai-provider";
1921
public static final String UNSET_LLM_SPAN_NAME = "llm";
2022

@@ -34,6 +36,9 @@ public static void tagLLMSpanRequest(
3436
case PROVIDER_NAME_OPENAI ->
3537
tagOpenAIRequest(
3638
span, providerName, baseUrl, pathSegments, method, requestBody);
39+
case PROVIDER_NAME_ANTHROPIC ->
40+
tagAnthropicRequest(
41+
span, providerName, baseUrl, pathSegments, method, requestBody);
3742
default ->
3843
tagOpenAIRequest(
3944
span, providerName, baseUrl, pathSegments, method, requestBody);
@@ -54,6 +59,8 @@ public static void tagLLMSpanResponse(
5459
switch (providerName) {
5560
case PROVIDER_NAME_OPENAI ->
5661
tagOpenAIResponse(span, responseBody, timeToFirstTokenNanoseconds);
62+
case PROVIDER_NAME_ANTHROPIC ->
63+
tagAnthropicResponse(span, responseBody, timeToFirstTokenNanoseconds);
5764
default -> tagOpenAIResponse(span, responseBody, timeToFirstTokenNanoseconds);
5865
}
5966
}
@@ -75,7 +82,7 @@ private static void tagOpenAIRequest(
7582
List<String> pathSegments,
7683
String method,
7784
@Nullable String requestBody) {
78-
span.updateName(getSpanName(pathSegments));
85+
span.updateName(getSpanName(providerName, pathSegments));
7986
span.setAttribute("braintrust.span_attributes", toJson(Map.of("type", "llm")));
8087

8188
Map<String, String> metadata = new HashMap<>();
@@ -148,19 +155,95 @@ private static void tagOpenAIResponse(
148155
}
149156
}
150157

158+
// -------------------------------------------------------------------------
159+
// Anthropic provider implementation
160+
// -------------------------------------------------------------------------
161+
162+
@SneakyThrows
163+
private static void tagAnthropicRequest(
164+
Span span,
165+
String providerName,
166+
String baseUrl,
167+
List<String> pathSegments,
168+
String method,
169+
@Nullable String requestBody) {
170+
span.updateName(getSpanName(providerName, pathSegments));
171+
span.setAttribute("braintrust.span_attributes", toJson(Map.of("type", "llm")));
172+
173+
Map<String, String> metadata = new HashMap<>();
174+
metadata.put("provider", providerName);
175+
metadata.put("request_path", String.join("/", pathSegments));
176+
metadata.put("request_base_uri", baseUrl);
177+
metadata.put("request_method", method);
178+
179+
if (requestBody != null) {
180+
JsonNode requestJson = BraintrustJsonMapper.get().readTree(requestBody);
181+
if (requestJson.has("model")) {
182+
metadata.put("model", requestJson.get("model").asText());
183+
}
184+
// Build input array: messages + system (as a synthetic system-role entry)
185+
if (requestJson.has("messages")) {
186+
ArrayNode inputArray = BraintrustJsonMapper.get().createArrayNode();
187+
// Append messages first
188+
requestJson.get("messages").forEach(inputArray::add);
189+
// Append system prompt as a {role:"system", content:"..."} entry if present
190+
if (requestJson.has("system")) {
191+
var systemNode = BraintrustJsonMapper.get().createObjectNode();
192+
systemNode.put("role", "system");
193+
systemNode.set("content", requestJson.get("system"));
194+
inputArray.add(systemNode);
195+
}
196+
span.setAttribute("braintrust.input_json", toJson(inputArray));
197+
}
198+
}
199+
200+
span.setAttribute("braintrust.metadata", toJson(metadata));
201+
}
202+
203+
@SneakyThrows
204+
private static void tagAnthropicResponse(
205+
Span span, String responseBody, @Nullable Long timeToFirstTokenNanoseconds) {
206+
JsonNode responseJson = BraintrustJsonMapper.get().readTree(responseBody);
207+
208+
// Anthropic response is the full Message object — output it whole
209+
span.setAttribute("braintrust.output_json", responseBody);
210+
211+
Map<String, Object> metrics = new HashMap<>();
212+
if (timeToFirstTokenNanoseconds != null) {
213+
metrics.put("time_to_first_token", timeToFirstTokenNanoseconds / 1_000_000_000.0);
214+
}
215+
216+
if (responseJson.has("usage")) {
217+
JsonNode usage = responseJson.get("usage");
218+
if (usage.has("input_tokens")) metrics.put("prompt_tokens", usage.get("input_tokens"));
219+
if (usage.has("output_tokens"))
220+
metrics.put("completion_tokens", usage.get("output_tokens"));
221+
if (usage.has("input_tokens") && usage.has("output_tokens")) {
222+
metrics.put(
223+
"tokens",
224+
usage.get("input_tokens").asLong() + usage.get("output_tokens").asLong());
225+
}
226+
}
227+
228+
if (!metrics.isEmpty()) {
229+
span.setAttribute("braintrust.metrics", toJson(metrics));
230+
}
231+
}
232+
151233
// -------------------------------------------------------------------------
152234
// Shared helpers
153235
// -------------------------------------------------------------------------
154236

155-
private static String getSpanName(List<String> pathSegments) {
237+
private static String getSpanName(String providerName, List<String> pathSegments) {
156238
if (pathSegments.isEmpty()) {
157239
return UNSET_LLM_SPAN_NAME;
158240
}
159-
return switch (pathSegments.get(pathSegments.size() - 1)) {
160-
case "completions" -> "Chat Completion";
161-
case "embeddings" -> "Embeddings";
162-
case "messages" -> "Messages";
163-
default -> pathSegments.get(pathSegments.size() - 1);
241+
String lastSegment = pathSegments.get(pathSegments.size() - 1);
242+
return switch (providerName + ":" + lastSegment) {
243+
case PROVIDER_NAME_OPENAI + ":completions" -> "Chat Completion";
244+
case PROVIDER_NAME_OPENAI + ":embeddings" -> "Embeddings";
245+
case PROVIDER_NAME_ANTHROPIC + ":messages" -> "anthropic.messages.create";
246+
default -> lastSegment;
164247
};
165248
}
166249
}
Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,131 @@
11
package dev.braintrust.instrumentation.anthropic;
22

33
import com.anthropic.client.AnthropicClient;
4-
import dev.braintrust.instrumentation.anthropic.otel.AnthropicTelemetry;
4+
import com.anthropic.core.ClientOptions;
5+
import com.anthropic.core.http.HttpClient;
56
import io.opentelemetry.api.OpenTelemetry;
7+
import java.lang.reflect.Field;
8+
import java.lang.reflect.Modifier;
9+
import java.util.function.BiConsumer;
10+
import java.util.function.Consumer;
11+
import kotlin.Lazy;
12+
import lombok.extern.slf4j.Slf4j;
613

714
/** Braintrust Anthropic client instrumentation. */
15+
@Slf4j
816
public final class BraintrustAnthropic {
917

10-
/** Instrument Anthropic client with braintrust traces */
11-
public static AnthropicClient wrap(OpenTelemetry otel, AnthropicClient client) {
12-
return AnthropicTelemetry.builder(otel).setCaptureMessageContent(true).build().wrap(client);
18+
/** Instrument Anthropic client with Braintrust traces. */
19+
public static AnthropicClient wrap(OpenTelemetry openTelemetry, AnthropicClient client) {
20+
try {
21+
instrumentHttpClient(openTelemetry, client);
22+
return client;
23+
} catch (Exception e) {
24+
log.error("failed to apply anthropic instrumentation", e);
25+
return client;
26+
}
27+
}
28+
29+
private static void instrumentHttpClient(OpenTelemetry openTelemetry, AnthropicClient client) {
30+
forAllFields(
31+
client,
32+
fieldName -> {
33+
try {
34+
var field = getField(client, fieldName);
35+
if (field instanceof ClientOptions clientOptions) {
36+
instrumentClientOptions(openTelemetry, clientOptions, "httpClient");
37+
} else if (field instanceof Lazy<?> lazyField) {
38+
var resolved = lazyField.getValue();
39+
forAllFieldsOfType(
40+
resolved,
41+
ClientOptions.class,
42+
(clientOptions, subfieldName) ->
43+
instrumentClientOptions(
44+
openTelemetry, clientOptions, subfieldName));
45+
} else {
46+
forAllFieldsOfType(
47+
field,
48+
ClientOptions.class,
49+
(clientOptions, subfieldName) ->
50+
instrumentClientOptions(
51+
openTelemetry, clientOptions, subfieldName));
52+
}
53+
} catch (ReflectiveOperationException e) {
54+
throw new RuntimeException(e);
55+
}
56+
});
57+
}
58+
59+
private static void instrumentClientOptions(
60+
OpenTelemetry openTelemetry, ClientOptions clientOptions, String fieldName) {
61+
try {
62+
HttpClient httpClient = getField(clientOptions, fieldName);
63+
if (!(httpClient instanceof TracingHttpClient)) {
64+
setPrivateField(
65+
clientOptions, fieldName, new TracingHttpClient(openTelemetry, httpClient));
66+
}
67+
} catch (ReflectiveOperationException e) {
68+
throw new RuntimeException(e);
69+
}
70+
}
71+
72+
private static void forAllFields(Object object, Consumer<String> consumer) {
73+
if (object == null || consumer == null) return;
74+
Class<?> clazz = object.getClass();
75+
while (clazz != null && clazz != Object.class) {
76+
for (Field field : clazz.getDeclaredFields()) {
77+
if (field.isSynthetic()) continue;
78+
if (Modifier.isStatic(field.getModifiers())) continue;
79+
consumer.accept(field.getName());
80+
}
81+
clazz = clazz.getSuperclass();
82+
}
83+
}
84+
85+
private static <T> void forAllFieldsOfType(
86+
Object object, Class<T> targetClazz, BiConsumer<T, String> consumer) {
87+
forAllFields(
88+
object,
89+
fieldName -> {
90+
try {
91+
if (targetClazz.isAssignableFrom(object.getClass())) {
92+
consumer.accept(getField(object, fieldName), fieldName);
93+
}
94+
} catch (ReflectiveOperationException e) {
95+
throw new RuntimeException(e);
96+
}
97+
});
98+
}
99+
100+
@SuppressWarnings("unchecked")
101+
private static <T> T getField(Object obj, String fieldName)
102+
throws ReflectiveOperationException {
103+
Class<?> clazz = obj.getClass();
104+
while (clazz != null) {
105+
try {
106+
Field field = clazz.getDeclaredField(fieldName);
107+
field.setAccessible(true);
108+
return (T) field.get(obj);
109+
} catch (NoSuchFieldException e) {
110+
clazz = clazz.getSuperclass();
111+
}
112+
}
113+
throw new NoSuchFieldException(fieldName);
114+
}
115+
116+
private static void setPrivateField(Object obj, String fieldName, Object value)
117+
throws ReflectiveOperationException {
118+
Class<?> clazz = obj.getClass();
119+
while (clazz != null) {
120+
try {
121+
Field field = clazz.getDeclaredField(fieldName);
122+
field.setAccessible(true);
123+
field.set(obj, value);
124+
return;
125+
} catch (NoSuchFieldException e) {
126+
clazz = clazz.getSuperclass();
127+
}
128+
}
129+
throw new NoSuchFieldException(fieldName);
13130
}
14131
}

0 commit comments

Comments
 (0)