Skip to content

Commit 4a38281

Browse files
committed
Braintrust-friendly attachments, conforming to otel genai semconv
- Most SDK users won't directly handle attachments because the instrumentation will do the right thing for them - The SDK will provide an `Attachment` util which serializes attachments in a way Braintrust will recognize - This util is used to write instrumentation or fill in missing gaps for custom situations - The SDK will send the full attachment base64-encoded and the backend will upload the file to s3 and replace the message with a pointer to s3 - In the future, we can have the SDK do upload+conversion without any breaking changes - Provide an `Attachment` util which: - has convenience functions for creating base64 data - has a json serializer idiomatic to the SDK's language - Use this utility to replace vendor-specific attachment messages with Braintrust attachment messages Attachment Examples ``` Attachment attachment = Attachment.ofFile(Attachment.ContentType.IMAGE_JPEG, testFile.toString()); JsonSerializer<Attachment> jacksonSerializer = Attachment.createSerializer(); ``` And the instrumentation will use this util to map vendor-specific message parts. For example, OAI image_url: ``` { "image_url": { "url": "data:image/jpeg;base64,SOME_BASE64", "detail": "high", "valid": true }, "type": "image_url", "valid": true } ``` ---> `{ type: "base64_attachment", content: "data:image/jpeg;base64,SOME_BASE64" }` The genai semconv does not actually define a format for attachments or images, so we have to map vendor-specific formats to something Braintrust can understand. We do this by defining our own GenericPart message as defined in the otel input/output message json schemas - https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-input-messages - https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-input-messages.json - https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-output-messages.json Braintrust recognizes three attachment types. - raw base64 data: `{ type: "base64_attachment", content: "SOME_BASE64" }` - stored in Braintrust s3: `{ type: "braintrust_attachment", filename, key, content_type }` - stored external to Braintrust: `{ type: "inline_attachment", filename, src, content_type }` Currently, the SDK will send `base64_attachment` and the backend will convert it to `braintrust_attachment` In the future, the SDK may do this conversion before sending traces. If we decide to go that route, this will _not _require any code changes for SDK users. It will happen somewhere in the implementation (probably a span processor).
1 parent 371974d commit 4a38281

6 files changed

Lines changed: 1110 additions & 253 deletions

File tree

src/main/java/dev/braintrust/instrumentation/openai/otel/ChatCompletionEventsHelper.java

Lines changed: 12 additions & 243 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
import static io.opentelemetry.api.common.AttributeKey.stringKey;
99

10-
import com.fasterxml.jackson.databind.ObjectMapper;
1110
import com.openai.models.chat.completions.ChatCompletion;
1211
import com.openai.models.chat.completions.ChatCompletionAssistantMessageParam;
1312
import com.openai.models.chat.completions.ChatCompletionContentPartText;
@@ -24,36 +23,28 @@
2423
import io.opentelemetry.api.logs.Logger;
2524
import io.opentelemetry.api.trace.Span;
2625
import io.opentelemetry.context.Context;
27-
import java.lang.invoke.MethodHandle;
28-
import java.lang.invoke.MethodHandles;
29-
import java.lang.invoke.MethodType;
3026
import java.util.HashMap;
3127
import java.util.List;
3228
import java.util.Map;
3329
import java.util.Objects;
34-
import java.util.Optional;
3530
import java.util.stream.Collectors;
36-
import javax.annotation.Nullable;
3731
import lombok.SneakyThrows;
3832
import lombok.extern.slf4j.Slf4j;
3933

4034
@Slf4j
4135
final class ChatCompletionEventsHelper {
4236

4337
private static final AttributeKey<String> EVENT_NAME = stringKey("event.name");
44-
private static final ObjectMapper JSON_MAPPER =
45-
new com.fasterxml.jackson.databind.ObjectMapper();
4638

4739
@SneakyThrows
4840
public static void emitPromptLogEvents(
4941
Context context,
5042
Logger eventLogger,
5143
ChatCompletionCreateParams request,
5244
boolean captureMessageContent) {
53-
Span.current()
54-
.setAttribute(
55-
"braintrust.input_json",
56-
JSON_MAPPER.writeValueAsString(request.messages()));
45+
String semconvJson = GenAiSemconvSerializer.serializeInputMessages(request.messages());
46+
Span span = Span.current();
47+
span.setAttribute("gen_ai.input.messages", semconvJson);
5748
}
5849

5950
private static String contentToString(ChatCompletionToolMessageParam.Content content) {
@@ -138,13 +129,12 @@ public static void emitCompletionLogEvents(
138129
} else if (completion.choices().size() > 1) {
139130
log.debug("multiple choices in OAI response: {}", completion.choices().size());
140131
} else {
141-
Span.current()
142-
.setAttribute(
143-
"braintrust.output_json",
144-
JSON_MAPPER.writeValueAsString(
145-
new ChatCompletionMessage[] {
146-
completion.choices().get(0).message()
147-
}));
132+
// Set gen_ai.output.messages attribute for single choice (most common case)
133+
ChatCompletion.Choice choice = completion.choices().get(0);
134+
String outputJson =
135+
GenAiSemconvSerializer.serializeOutputMessage(
136+
choice.message(), choice.finishReason().toString());
137+
Span.current().setAttribute("gen_ai.output.messages", outputJson);
148138
}
149139
for (ChatCompletion.Choice choice : completion.choices()) {
150140
ChatCompletionMessage choiceMsg = choice.message();
@@ -211,7 +201,8 @@ private static LogRecordBuilder newEvent(Logger eventLogger, String name) {
211201
private static Value<?> buildToolCallEventObject(
212202
ChatCompletionMessageToolCall call, boolean captureMessageContent) {
213203
Map<String, Value<?>> result = new HashMap<>();
214-
FunctionAccess functionAccess = getFunctionAccess(call);
204+
GenAiSemconvSerializer.FunctionAccess functionAccess =
205+
GenAiSemconvSerializer.getFunctionAccess(call);
215206
if (functionAccess != null) {
216207
result.put("id", Value.of(functionAccess.id()));
217208
result.put(
@@ -223,7 +214,7 @@ private static Value<?> buildToolCallEventObject(
223214
}
224215

225216
private static Value<?> buildFunctionEventObject(
226-
FunctionAccess functionAccess, boolean captureMessageContent) {
217+
GenAiSemconvSerializer.FunctionAccess functionAccess, boolean captureMessageContent) {
227218
Map<String, Value<?>> result = new HashMap<>();
228219
result.put("name", Value.of(functionAccess.name()));
229220
if (captureMessageContent) {
@@ -232,227 +223,5 @@ private static Value<?> buildFunctionEventObject(
232223
return Value.of(result);
233224
}
234225

235-
@Nullable
236-
private static FunctionAccess getFunctionAccess(ChatCompletionMessageToolCall call) {
237-
if (V1FunctionAccess.isAvailable()) {
238-
return V1FunctionAccess.create(call);
239-
}
240-
if (V3FunctionAccess.isAvailable()) {
241-
return V3FunctionAccess.create(call);
242-
}
243-
244-
return null;
245-
}
246-
247-
private interface FunctionAccess {
248-
String id();
249-
250-
String name();
251-
252-
String arguments();
253-
}
254-
255-
private static String invokeStringHandle(@Nullable MethodHandle methodHandle, Object object) {
256-
if (methodHandle == null) {
257-
return "";
258-
}
259-
260-
try {
261-
return (String) methodHandle.invoke(object);
262-
} catch (Throwable ignore) {
263-
return "";
264-
}
265-
}
266-
267-
private static class V1FunctionAccess implements FunctionAccess {
268-
@Nullable private static final MethodHandle idHandle;
269-
@Nullable private static final MethodHandle functionHandle;
270-
@Nullable private static final MethodHandle nameHandle;
271-
@Nullable private static final MethodHandle argumentsHandle;
272-
273-
static {
274-
MethodHandle id;
275-
MethodHandle function;
276-
MethodHandle name;
277-
MethodHandle arguments;
278-
279-
try {
280-
MethodHandles.Lookup lookup = MethodHandles.lookup();
281-
id =
282-
lookup.findVirtual(
283-
ChatCompletionMessageToolCall.class,
284-
"id",
285-
MethodType.methodType(String.class));
286-
Class<?> functionClass =
287-
Class.forName(
288-
"com.openai.models.chat.completions.ChatCompletionMessageToolCall$Function");
289-
function =
290-
lookup.findVirtual(
291-
ChatCompletionMessageToolCall.class,
292-
"function",
293-
MethodType.methodType(functionClass));
294-
name =
295-
lookup.findVirtual(
296-
functionClass, "name", MethodType.methodType(String.class));
297-
arguments =
298-
lookup.findVirtual(
299-
functionClass, "arguments", MethodType.methodType(String.class));
300-
} catch (Exception exception) {
301-
id = null;
302-
function = null;
303-
name = null;
304-
arguments = null;
305-
}
306-
idHandle = id;
307-
functionHandle = function;
308-
nameHandle = name;
309-
argumentsHandle = arguments;
310-
}
311-
312-
private final ChatCompletionMessageToolCall toolCall;
313-
private final Object function;
314-
315-
V1FunctionAccess(ChatCompletionMessageToolCall toolCall, Object function) {
316-
this.toolCall = toolCall;
317-
this.function = function;
318-
}
319-
320-
@Nullable
321-
static FunctionAccess create(ChatCompletionMessageToolCall toolCall) {
322-
if (functionHandle == null) {
323-
return null;
324-
}
325-
326-
try {
327-
return new V1FunctionAccess(toolCall, functionHandle.invoke(toolCall));
328-
} catch (Throwable ignore) {
329-
return null;
330-
}
331-
}
332-
333-
static boolean isAvailable() {
334-
return idHandle != null;
335-
}
336-
337-
@Override
338-
public String id() {
339-
return invokeStringHandle(idHandle, toolCall);
340-
}
341-
342-
@Override
343-
public String name() {
344-
return invokeStringHandle(nameHandle, function);
345-
}
346-
347-
@Override
348-
public String arguments() {
349-
return invokeStringHandle(argumentsHandle, function);
350-
}
351-
}
352-
353-
static class V3FunctionAccess implements FunctionAccess {
354-
@Nullable private static final MethodHandle functionToolCallHandle;
355-
@Nullable private static final MethodHandle idHandle;
356-
@Nullable private static final MethodHandle functionHandle;
357-
@Nullable private static final MethodHandle nameHandle;
358-
@Nullable private static final MethodHandle argumentsHandle;
359-
360-
static {
361-
MethodHandle functionToolCall;
362-
MethodHandle id;
363-
MethodHandle function;
364-
MethodHandle name;
365-
MethodHandle arguments;
366-
367-
try {
368-
MethodHandles.Lookup lookup = MethodHandles.lookup();
369-
functionToolCall =
370-
lookup.findVirtual(
371-
ChatCompletionMessageToolCall.class,
372-
"function",
373-
MethodType.methodType(Optional.class));
374-
Class<?> functionToolCallClass =
375-
Class.forName(
376-
"com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall");
377-
id =
378-
lookup.findVirtual(
379-
functionToolCallClass, "id", MethodType.methodType(String.class));
380-
Class<?> functionClass =
381-
Class.forName(
382-
"com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall$Function");
383-
function =
384-
lookup.findVirtual(
385-
functionToolCallClass,
386-
"function",
387-
MethodType.methodType(functionClass));
388-
name =
389-
lookup.findVirtual(
390-
functionClass, "name", MethodType.methodType(String.class));
391-
arguments =
392-
lookup.findVirtual(
393-
functionClass, "arguments", MethodType.methodType(String.class));
394-
} catch (Exception exception) {
395-
functionToolCall = null;
396-
id = null;
397-
function = null;
398-
name = null;
399-
arguments = null;
400-
}
401-
functionToolCallHandle = functionToolCall;
402-
idHandle = id;
403-
functionHandle = function;
404-
nameHandle = name;
405-
argumentsHandle = arguments;
406-
}
407-
408-
private final Object functionToolCall;
409-
private final Object function;
410-
411-
V3FunctionAccess(Object functionToolCall, Object function) {
412-
this.functionToolCall = functionToolCall;
413-
this.function = function;
414-
}
415-
416-
@Nullable
417-
@SuppressWarnings("unchecked")
418-
static FunctionAccess create(ChatCompletionMessageToolCall toolCall) {
419-
if (functionToolCallHandle == null || functionHandle == null) {
420-
return null;
421-
}
422-
423-
try {
424-
Optional<Object> optional =
425-
(Optional<Object>) functionToolCallHandle.invoke(toolCall);
426-
if (!optional.isPresent()) {
427-
return null;
428-
}
429-
Object functionToolCall = optional.get();
430-
return new V3FunctionAccess(
431-
functionToolCall, functionHandle.invoke(functionToolCall));
432-
} catch (Throwable ignore) {
433-
return null;
434-
}
435-
}
436-
437-
static boolean isAvailable() {
438-
return idHandle != null;
439-
}
440-
441-
@Override
442-
public String id() {
443-
return invokeStringHandle(idHandle, functionToolCall);
444-
}
445-
446-
@Override
447-
public String name() {
448-
return invokeStringHandle(nameHandle, function);
449-
}
450-
451-
@Override
452-
public String arguments() {
453-
return invokeStringHandle(argumentsHandle, function);
454-
}
455-
}
456-
457226
private ChatCompletionEventsHelper() {}
458227
}

0 commit comments

Comments
 (0)