|
1 | 1 | package dev.braintrust.instrumentation.langchain; |
2 | 2 |
|
3 | | -import dev.langchain4j.http.client.HttpClientBuilder; |
4 | | -import dev.langchain4j.http.client.HttpClientBuilderLoader; |
5 | 3 | import dev.langchain4j.model.openai.OpenAiChatModel; |
6 | 4 | import dev.langchain4j.model.openai.OpenAiStreamingChatModel; |
| 5 | +import dev.langchain4j.service.AiServiceContext; |
| 6 | +import dev.langchain4j.service.AiServices; |
| 7 | +import dev.langchain4j.service.tool.ToolExecutor; |
7 | 8 | import io.opentelemetry.api.OpenTelemetry; |
| 9 | +import io.opentelemetry.api.trace.Tracer; |
| 10 | +import java.util.Map; |
8 | 11 | import lombok.extern.slf4j.Slf4j; |
9 | 12 |
|
10 | 13 | /** Braintrust LangChain4j client instrumentation. */ |
11 | 14 | @Slf4j |
12 | 15 | public final class BraintrustLangchain { |
| 16 | + |
| 17 | + private static final String INSTRUMENTATION_NAME = "braintrust-langchain4j"; |
| 18 | + |
| 19 | + @SuppressWarnings("unchecked") |
| 20 | + public static <T> T wrap(OpenTelemetry openTelemetry, AiServices<T> aiServices) { |
| 21 | + try { |
| 22 | + AiServiceContext context = getPrivateField(aiServices, "context"); |
| 23 | + Tracer tracer = openTelemetry.getTracer(INSTRUMENTATION_NAME); |
| 24 | + |
| 25 | + // ////// CREATE A LLM SPAN FOR EACH CALL TO AI PROVIDER |
| 26 | + var chatModel = context.chatModel; |
| 27 | + var streamingChatModel = context.streamingChatModel; |
| 28 | + if (chatModel != null) { |
| 29 | + if (chatModel instanceof OpenAiChatModel oaiModel) { |
| 30 | + aiServices.chatModel(wrap(openTelemetry, oaiModel)); |
| 31 | + } else { |
| 32 | + log.warn( |
| 33 | + "unsupported model: {}. LLM calls will not be instrumented", |
| 34 | + chatModel.getClass().getName()); |
| 35 | + } |
| 36 | + // intentional fall-through |
| 37 | + } else if (streamingChatModel != null) { |
| 38 | + if (streamingChatModel instanceof OpenAiStreamingChatModel oaiModel) { |
| 39 | + aiServices.streamingChatModel(wrap(openTelemetry, oaiModel)); |
| 40 | + } else { |
| 41 | + log.warn( |
| 42 | + "unsupported model: {}. LLM calls will not be instrumented", |
| 43 | + streamingChatModel.getClass().getName()); |
| 44 | + } |
| 45 | + // intentional fall-through |
| 46 | + } else { |
| 47 | + // langchain is going to fail to build. don't apply instrumentation. |
| 48 | + throw new RuntimeException("model or chat model must be set"); |
| 49 | + } |
| 50 | + |
| 51 | + if (context.toolService != null) { |
| 52 | + // ////// CREATE A SPAN FOR EACH TOOL CALL |
| 53 | + for (Map.Entry<String, ToolExecutor> entry : |
| 54 | + context.toolService.toolExecutors().entrySet()) { |
| 55 | + String toolName = entry.getKey(); |
| 56 | + ToolExecutor original = entry.getValue(); |
| 57 | + entry.setValue(new TracingToolExecutor(original, toolName, tracer)); |
| 58 | + } |
| 59 | + |
| 60 | + // ////// LINK SPANS ACROSS CONCURRENT TOOL CALLS |
| 61 | + var underlyingExecutor = context.toolService.executor(); |
| 62 | + if (underlyingExecutor != null) { |
| 63 | + aiServices.executeToolsConcurrently( |
| 64 | + new OtelContextPassingExecutor(underlyingExecutor)); |
| 65 | + } |
| 66 | + } |
| 67 | + |
| 68 | + // ////// CREATE A SPAN ON SERVICE METHOD INVOKE |
| 69 | + T service = aiServices.build(); |
| 70 | + Class<T> serviceInterface = (Class<T>) context.aiServiceClass; |
| 71 | + return TracingProxy.create(serviceInterface, service, tracer); |
| 72 | + } catch (Exception e) { |
| 73 | + log.warn("failed to apply langchain AI services instrumentation", e); |
| 74 | + return aiServices.build(); |
| 75 | + } |
| 76 | + } |
| 77 | + |
13 | 78 | /** Instrument langchain openai chat model with braintrust traces */ |
14 | 79 | public static OpenAiChatModel wrap( |
15 | 80 | OpenTelemetry otel, OpenAiChatModel.OpenAiChatModelBuilder builder) { |
| 81 | + return wrap(otel, builder.build()); |
| 82 | + } |
| 83 | + |
| 84 | + private static OpenAiChatModel wrap(OpenTelemetry otel, OpenAiChatModel model) { |
16 | 85 | try { |
17 | | - HttpClientBuilder underlyingHttpClient = getPrivateField(builder, "httpClientBuilder"); |
18 | | - if (underlyingHttpClient == null) { |
19 | | - underlyingHttpClient = HttpClientBuilderLoader.loadHttpClientBuilder(); |
| 86 | + // Get the internal OpenAiClient from the chat model |
| 87 | + Object internalClient = getPrivateField(model, "client"); |
| 88 | + |
| 89 | + // Get the HttpClient from the internal client |
| 90 | + dev.langchain4j.http.client.HttpClient httpClient = |
| 91 | + getPrivateField(internalClient, "httpClient"); |
| 92 | + |
| 93 | + if (httpClient instanceof WrappedHttpClient) { |
| 94 | + log.debug("model already instrumented. skipping: {}", httpClient.getClass()); |
| 95 | + return model; |
20 | 96 | } |
21 | | - HttpClientBuilder wrappedHttpClient = |
22 | | - wrap(otel, underlyingHttpClient, new Options("openai")); |
23 | | - return builder.httpClientBuilder(wrappedHttpClient).build(); |
| 97 | + |
| 98 | + // Wrap the HttpClient with our instrumented version |
| 99 | + dev.langchain4j.http.client.HttpClient wrappedHttpClient = |
| 100 | + new WrappedHttpClient(otel, httpClient, new Options("openai")); |
| 101 | + |
| 102 | + setPrivateField(internalClient, "httpClient", wrappedHttpClient); |
| 103 | + |
| 104 | + return model; |
24 | 105 | } catch (Exception e) { |
25 | | - log.warn( |
26 | | - "Braintrust instrumentation could not be applied to OpenAiChatModel builder", |
27 | | - e); |
28 | | - return builder.build(); |
| 106 | + log.warn("failed to instrument OpenAiChatModel", e); |
| 107 | + return model; |
29 | 108 | } |
30 | 109 | } |
31 | 110 |
|
32 | 111 | /** Instrument langchain openai chat model with braintrust traces */ |
33 | 112 | public static OpenAiStreamingChatModel wrap( |
34 | 113 | OpenTelemetry otel, OpenAiStreamingChatModel.OpenAiStreamingChatModelBuilder builder) { |
| 114 | + return wrap(otel, builder.build()); |
| 115 | + } |
| 116 | + |
| 117 | + public static OpenAiStreamingChatModel wrap( |
| 118 | + OpenTelemetry otel, OpenAiStreamingChatModel model) { |
35 | 119 | try { |
36 | | - HttpClientBuilder underlyingHttpClient = getPrivateField(builder, "httpClientBuilder"); |
37 | | - if (underlyingHttpClient == null) { |
38 | | - underlyingHttpClient = HttpClientBuilderLoader.loadHttpClientBuilder(); |
| 120 | + // Get the internal OpenAiClient from the streaming chat model |
| 121 | + Object internalClient = getPrivateField(model, "client"); |
| 122 | + |
| 123 | + // Get the HttpClient from the internal client |
| 124 | + dev.langchain4j.http.client.HttpClient httpClient = |
| 125 | + getPrivateField(internalClient, "httpClient"); |
| 126 | + |
| 127 | + if (httpClient instanceof WrappedHttpClient) { |
| 128 | + log.debug("model already instrumented. skipping: {}", httpClient.getClass()); |
| 129 | + return model; |
39 | 130 | } |
40 | | - HttpClientBuilder wrappedHttpClient = |
41 | | - wrap(otel, underlyingHttpClient, new Options("openai")); |
42 | | - return builder.httpClientBuilder(wrappedHttpClient).build(); |
| 131 | + |
| 132 | + // Wrap the HttpClient with our instrumented version |
| 133 | + dev.langchain4j.http.client.HttpClient wrappedHttpClient = |
| 134 | + new WrappedHttpClient(otel, httpClient, new Options("openai")); |
| 135 | + |
| 136 | + setPrivateField(internalClient, "httpClient", wrappedHttpClient); |
| 137 | + |
| 138 | + return model; |
43 | 139 | } catch (Exception e) { |
44 | | - log.warn( |
45 | | - "Braintrust instrumentation could not be applied to OpenAiStreamingChatModel" |
46 | | - + " builder", |
47 | | - e); |
48 | | - return builder.build(); |
| 140 | + log.warn("failed to instrument OpenAiStreamingChatModel", e); |
| 141 | + return model; |
49 | 142 | } |
50 | 143 | } |
51 | 144 |
|
52 | | - private static HttpClientBuilder wrap( |
53 | | - OpenTelemetry otel, HttpClientBuilder builder, Options options) { |
54 | | - return new WrappedHttpClientBuilder(otel, builder, options); |
55 | | - } |
56 | | - |
57 | 145 | public record Options(String providerName) {} |
58 | 146 |
|
59 | 147 | @SuppressWarnings("unchecked") |
60 | 148 | private static <T> T getPrivateField(Object obj, String fieldName) |
61 | 149 | throws ReflectiveOperationException { |
62 | | - java.lang.reflect.Field field = obj.getClass().getDeclaredField(fieldName); |
63 | | - field.setAccessible(true); |
64 | | - return (T) field.get(obj); |
| 150 | + Class<?> clazz = obj.getClass(); |
| 151 | + while (clazz != null) { |
| 152 | + try { |
| 153 | + java.lang.reflect.Field field = clazz.getDeclaredField(fieldName); |
| 154 | + field.setAccessible(true); |
| 155 | + return (T) field.get(obj); |
| 156 | + } catch (NoSuchFieldException e) { |
| 157 | + clazz = clazz.getSuperclass(); |
| 158 | + } |
| 159 | + } |
| 160 | + throw new NoSuchFieldException(fieldName); |
| 161 | + } |
| 162 | + |
| 163 | + private static void setPrivateField(Object obj, String fieldName, Object value) |
| 164 | + throws ReflectiveOperationException { |
| 165 | + Class<?> clazz = obj.getClass(); |
| 166 | + while (clazz != null) { |
| 167 | + try { |
| 168 | + java.lang.reflect.Field field = clazz.getDeclaredField(fieldName); |
| 169 | + field.setAccessible(true); |
| 170 | + field.set(obj, value); |
| 171 | + return; |
| 172 | + } catch (NoSuchFieldException e) { |
| 173 | + clazz = clazz.getSuperclass(); |
| 174 | + } |
| 175 | + } |
| 176 | + throw new NoSuchFieldException(fieldName); |
65 | 177 | } |
66 | 178 | } |
0 commit comments