From f317d4e275f47eb68c010d9f16b8e2bb95b5b2a8 Mon Sep 17 00:00:00 2001 From: Andrew Kent Date: Tue, 10 Feb 2026 17:58:20 -0700 Subject: [PATCH] add option to configure braintrust SDK json object mapper --- .../com/google/genai/BraintrustApiClient.java | 25 ++- .../braintrust/api/BraintrustApiClient.java | 62 ++------ .../dev/braintrust/devserver/Devserver.java | 52 +++---- src/main/java/dev/braintrust/eval/Eval.java | 35 ++--- .../BraintrustAnthropicSpanAttributes.java | 12 +- .../anthropic/otel/StreamListener.java | 11 +- .../langchain/WrappedHttpClient.java | 36 ++--- .../otel/BraintrustOAISpanAttributes.java | 2 - .../openai/otel/GenAiSemconvSerializer.java | 5 +- .../braintrust/json/BraintrustJsonMapper.java | 78 ++++++++++ .../java/dev/braintrust/eval/EvalTest.java | 18 +-- .../json/BraintrustJsonMapperTest.java | 145 ++++++++++++++++++ 12 files changed, 319 insertions(+), 162 deletions(-) create mode 100644 src/main/java/dev/braintrust/json/BraintrustJsonMapper.java create mode 100644 src/test/java/dev/braintrust/json/BraintrustJsonMapperTest.java diff --git a/src/main/java/com/google/genai/BraintrustApiClient.java b/src/main/java/com/google/genai/BraintrustApiClient.java index 5a0bc87..35f4441 100644 --- a/src/main/java/com/google/genai/BraintrustApiClient.java +++ b/src/main/java/com/google/genai/BraintrustApiClient.java @@ -1,6 +1,8 @@ package com.google.genai; -import com.fasterxml.jackson.databind.ObjectMapper; +import static dev.braintrust.json.BraintrustJsonMapper.fromJson; +import static dev.braintrust.json.BraintrustJsonMapper.toJson; + import com.google.genai.types.HttpOptions; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.trace.Span; @@ -27,8 +29,6 @@ */ @Slf4j class BraintrustApiClient extends ApiClient { - private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); - private final ApiClient delegate; private final Tracer tracer; @@ -58,7 +58,7 @@ private void tagSpan( // Parse request if (requestBody != null) { - var requestJson = JSON_MAPPER.readValue(requestBody, Map.class); + var requestJson = fromJson(requestBody, Map.class); // Extract metadata fields for (String field : @@ -108,13 +108,12 @@ private void tagSpan( inputJson.put("config", requestJson.get("generationConfig")); } - span.setAttribute( - "braintrust.input_json", JSON_MAPPER.writeValueAsString(inputJson)); + span.setAttribute("braintrust.input_json", toJson(inputJson)); } // Parse response if (responseBody != null) { - var responseJson = JSON_MAPPER.readValue(responseBody, Map.class); + var responseJson = fromJson(responseBody, Map.class); // Extract model version from response if (responseJson.containsKey("modelVersion")) { @@ -122,8 +121,7 @@ private void tagSpan( } // Set full response as output_json - span.setAttribute( - "braintrust.output_json", JSON_MAPPER.writeValueAsString(responseJson)); + span.setAttribute("braintrust.output_json", toJson(responseJson)); // Parse usage metadata for metrics if (responseJson.get("usageMetadata") instanceof Map) { @@ -146,18 +144,15 @@ private void tagSpan( (Number) usage.get("cachedContentTokenCount")); } - span.setAttribute( - "braintrust.metrics", JSON_MAPPER.writeValueAsString(metrics)); + span.setAttribute("braintrust.metrics", toJson(metrics)); } } // Set metadata - span.setAttribute("braintrust.metadata", JSON_MAPPER.writeValueAsString(metadata)); + span.setAttribute("braintrust.metadata", toJson(metadata)); // Set span_attributes to mark as LLM span - span.setAttribute( - "braintrust.span_attributes", - JSON_MAPPER.writeValueAsString(Map.of("type", "llm"))); + span.setAttribute("braintrust.span_attributes", toJson(Map.of("type", "llm"))); } catch (Throwable t) { log.warn("failed to tag gemini span", t); diff --git a/src/main/java/dev/braintrust/api/BraintrustApiClient.java b/src/main/java/dev/braintrust/api/BraintrustApiClient.java index 1095f66..3c5e567 100644 --- a/src/main/java/dev/braintrust/api/BraintrustApiClient.java +++ b/src/main/java/dev/braintrust/api/BraintrustApiClient.java @@ -1,12 +1,9 @@ package dev.braintrust.api; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import static dev.braintrust.json.BraintrustJsonMapper.fromJson; +import static dev.braintrust.json.BraintrustJsonMapper.toJson; + import dev.braintrust.config.BraintrustConfig; -import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -111,7 +108,6 @@ static BraintrustApiClient of(BraintrustConfig config) { class HttpImpl implements BraintrustApiClient { private final BraintrustConfig config; private final HttpClient httpClient; - private final ObjectMapper objectMapper; HttpImpl(BraintrustConfig config) { this(config, createDefaultHttpClient(config)); @@ -120,7 +116,6 @@ class HttpImpl implements BraintrustApiClient { private HttpImpl(BraintrustConfig config, HttpClient httpClient) { this.config = config; this.httpClient = httpClient; - this.objectMapper = createObjectMapper(); } @Override @@ -422,24 +417,19 @@ private CompletableFuture getAsync(String path, Class responseType) { private CompletableFuture postAsync( String path, Object body, Class responseType) { - try { - var jsonBody = objectMapper.writeValueAsString(body); - - var request = - HttpRequest.newBuilder() - .uri(URI.create(config.apiUrl() + path)) - .header("Authorization", "Bearer " + config.apiKey()) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .timeout(config.requestTimeout()) - .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) - .build(); - - return sendAsync(request, responseType); - } catch (IOException e) { - return CompletableFuture.failedFuture( - new ApiException("Failed to serialize request body", e)); - } + var jsonBody = toJson(body); + + var request = + HttpRequest.newBuilder() + .uri(URI.create(config.apiUrl() + path)) + .header("Authorization", "Bearer " + config.apiKey()) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .timeout(config.requestTimeout()) + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) + .build(); + + return sendAsync(request, responseType); } private CompletableFuture sendAsync(HttpRequest request, Class responseType) { @@ -454,12 +444,7 @@ private T handleResponse(HttpResponse response, Class responseTyp log.debug("API Response: {} - {}", response.statusCode(), response.body()); if (response.statusCode() >= 200 && response.statusCode() < 300) { - try { - return objectMapper.readValue(response.body(), responseType); - } catch (IOException e) { - log.warn("Failed to parse response body", e); - throw new ApiException("Failed to parse response body", e); - } + return fromJson(response.body(), responseType); } else { log.warn( "API request failed with status {}: {}", @@ -488,19 +473,6 @@ private boolean isNotFound(Throwable error) { private static HttpClient createDefaultHttpClient(BraintrustConfig config) { return HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); } - - private static ObjectMapper createObjectMapper() { - return new ObjectMapper() - .registerModule(new JavaTimeModule()) - .registerModule(new Jdk8Module()) // For Optional support - .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) - .setSerializationInclusion( - JsonInclude.Include.NON_ABSENT) // Skip null and absent Optional - .configure( - com.fasterxml.jackson.databind.DeserializationFeature - .FAIL_ON_UNKNOWN_PROPERTIES, - false); // Ignore unknown fields from API - } } /** Implementation for test doubling */ diff --git a/src/main/java/dev/braintrust/devserver/Devserver.java b/src/main/java/dev/braintrust/devserver/Devserver.java index 4e1c64e..5f0f4cf 100644 --- a/src/main/java/dev/braintrust/devserver/Devserver.java +++ b/src/main/java/dev/braintrust/devserver/Devserver.java @@ -1,6 +1,8 @@ package dev.braintrust.devserver; -import com.fasterxml.jackson.databind.ObjectMapper; +import static dev.braintrust.json.BraintrustJsonMapper.fromJson; +import static dev.braintrust.json.BraintrustJsonMapper.toJson; + import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; @@ -83,11 +85,6 @@ public class Devserver { private final @Nullable Consumer traceBuilderHook; private final @Nullable Consumer configBuilderHook; - private static final ObjectMapper JSON_MAPPER = - new ObjectMapper() - .enable( - com.fasterxml.jackson.core.JsonParser.Feature - .INCLUDE_SOURCE_IN_LOCATION); // LRU cache for token -> Braintrust mappings private final LRUCache authCache = new LRUCache<>(32); @@ -219,7 +216,7 @@ private void handleList(HttpExchange exchange) throws IOException { response.put(evalName, metadata); } - String jsonResponse = JSON_MAPPER.writeValueAsString(response); + String jsonResponse = toJson(response); sendResponse(exchange, 200, "application/json", jsonResponse); } catch (Exception e) { log.error("Error generating /list response", e); @@ -245,7 +242,7 @@ private void handleEval(HttpExchange exchange) throws IOException { try { InputStream requestBody = exchange.getRequestBody(); var requestBodyString = new String(requestBody.readAllBytes(), StandardCharsets.UTF_8); - EvalRequest request = JSON_MAPPER.readValue(requestBodyString, EvalRequest.class); + EvalRequest request = fromJson(requestBodyString, EvalRequest.class); // Validate evaluator exists RemoteEval eval = evals.get(request.getName()); @@ -543,22 +540,22 @@ private void setEvalSpanAttributes( spanAttrs.put("generation", braintrustGeneration); } evalSpan.setAttribute(PARENT, braintrustParent.toParentValue()) - .setAttribute("braintrust.span_attributes", json(spanAttrs)) - .setAttribute("braintrust.input_json", json(Map.of("input", datasetCase.input()))) - .setAttribute("braintrust.expected_json", json(datasetCase.expected())); + .setAttribute("braintrust.span_attributes", toJson(spanAttrs)) + .setAttribute("braintrust.input_json", toJson(Map.of("input", datasetCase.input()))) + .setAttribute("braintrust.expected_json", toJson(datasetCase.expected())); if (datasetCase.origin().isPresent()) { - evalSpan.setAttribute("braintrust.origin", json(datasetCase.origin().get())); + evalSpan.setAttribute("braintrust.origin", toJson(datasetCase.origin().get())); } if (!datasetCase.tags().isEmpty()) { evalSpan.setAttribute( AttributeKey.stringArrayKey("braintrust.tags"), datasetCase.tags()); } if (!datasetCase.metadata().isEmpty()) { - evalSpan.setAttribute("braintrust.metadata", json(datasetCase.metadata())); + evalSpan.setAttribute("braintrust.metadata", toJson(datasetCase.metadata())); } evalSpan.setAttribute( - "braintrust.output_json", json(Map.of("output", taskResult.result()))); + "braintrust.output_json", toJson(Map.of("output", taskResult.result()))); } private void setTaskSpanAttributes( @@ -575,10 +572,10 @@ private void setTaskSpanAttributes( } taskSpan.setAttribute(PARENT, braintrustParent.toParentValue()) - .setAttribute("braintrust.span_attributes", json(taskSpanAttrs)) - .setAttribute("braintrust.input_json", json(Map.of("input", datasetCase.input()))) + .setAttribute("braintrust.span_attributes", toJson(taskSpanAttrs)) + .setAttribute("braintrust.input_json", toJson(Map.of("input", datasetCase.input()))) .setAttribute( - "braintrust.output_json", json(Map.of("output", taskResult.result()))); + "braintrust.output_json", toJson(Map.of("output", taskResult.result()))); } private void setScoreSpanAttributes( @@ -594,10 +591,10 @@ private void setScoreSpanAttributes( scoreSpanAttrs.put("generation", braintrustGeneration); } - var scoresJson = json(scorerScores); + var scoresJson = toJson(scorerScores); scoreSpan .setAttribute(PARENT, braintrustParent.toParentValue()) - .setAttribute("braintrust.span_attributes", json(scoreSpanAttrs)) + .setAttribute("braintrust.span_attributes", toJson(scoreSpanAttrs)) .setAttribute("braintrust.output_json", scoresJson) .setAttribute("braintrust.scores", scoresJson); } @@ -625,9 +622,9 @@ private void sendProgressEvent( progressData.put("format", "code"); progressData.put("output_type", "completion"); progressData.put("event", "json_delta"); - progressData.put("data", JSON_MAPPER.writeValueAsString(taskResult)); + progressData.put("data", toJson(taskResult)); - String progressJson = JSON_MAPPER.writeValueAsString(progressData); + String progressJson = toJson(progressData); sendSSEEvent(os, "progress", progressJson); } @@ -663,21 +660,13 @@ private void sendSummaryEvent( summary.put("scores", scoresWithMeta); summary.put("metrics", Map.of()); - sendSSEEvent(os, "summary", JSON_MAPPER.writeValueAsString(summary)); + sendSSEEvent(os, "summary", toJson(summary)); } private void sendDoneEvent(OutputStream os) throws IOException { sendSSEEvent(os, "done", ""); } - private String json(Object o) { - try { - return JSON_MAPPER.writeValueAsString(o); - } catch (Exception e) { - throw new RuntimeException("Failed to serialize to JSON", e); - } - } - private void sendResponse( HttpExchange exchange, int statusCode, String contentType, String body) throws IOException { @@ -934,8 +923,7 @@ private RequestContext getBraintrust(HttpExchange exchange, RequestContext conte private void sendErrorResponse(HttpExchange exchange, int statusCode, String message) throws IOException { Map error = Map.of("error", message); - String json = JSON_MAPPER.writeValueAsString(error); - sendResponse(exchange, statusCode, "application/json", json); + sendResponse(exchange, statusCode, "application/json", toJson(error)); } /** diff --git a/src/main/java/dev/braintrust/eval/Eval.java b/src/main/java/dev/braintrust/eval/Eval.java index 29f23aa..adc60c8 100644 --- a/src/main/java/dev/braintrust/eval/Eval.java +++ b/src/main/java/dev/braintrust/eval/Eval.java @@ -1,7 +1,7 @@ package dev.braintrust.eval; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import static dev.braintrust.json.BraintrustJsonMapper.toJson; + import dev.braintrust.BraintrustUtils; import dev.braintrust.api.BraintrustApiClient; import dev.braintrust.config.BraintrustConfig; @@ -25,7 +25,6 @@ public final class Eval { private static final AttributeKey PARENT = AttributeKey.stringKey(BraintrustTracing.PARENT_KEY); - static final ObjectMapper JSON_MAPPER = new com.fasterxml.jackson.databind.ObjectMapper(); private final @Nonnull String experimentName; private final @Nonnull BraintrustConfig config; private final @Nonnull BraintrustApiClient client; @@ -81,13 +80,14 @@ private void evalOne(String experimentId, DatasetCase datasetCase .setNoParent() // each eval case is its own trace .setSpanKind(SpanKind.CLIENT) .setAttribute(PARENT, "experiment_id:" + experimentId) - .setAttribute("braintrust.span_attributes", json(Map.of("type", "eval"))) + .setAttribute("braintrust.span_attributes", toJson(Map.of("type", "eval"))) .setAttribute( - "braintrust.input_json", json(Map.of("input", datasetCase.input()))) - .setAttribute("braintrust.expected", json(datasetCase.expected())) + "braintrust.input_json", + toJson(Map.of("input", datasetCase.input()))) + .setAttribute("braintrust.expected", toJson(datasetCase.expected())) .startSpan(); if (datasetCase.origin().isPresent()) { - rootSpan.setAttribute("braintrust.origin", json(datasetCase.origin().get())); + rootSpan.setAttribute("braintrust.origin", toJson(datasetCase.origin().get())); } if (!datasetCase.tags().isEmpty()) { rootSpan.setAttribute( @@ -100,7 +100,8 @@ private void evalOne(String experimentId, DatasetCase datasetCase tracer.spanBuilder("task") .setAttribute(PARENT, "experiment_id:" + experimentId) .setAttribute( - "braintrust.span_attributes", json(Map.of("type", "task"))) + "braintrust.span_attributes", + toJson(Map.of("type", "task"))) .startSpan(); try (var unused = BraintrustContext.ofExperiment(experimentId, taskSpan).makeCurrent()) { @@ -108,13 +109,8 @@ private void evalOne(String experimentId, DatasetCase datasetCase } finally { taskSpan.end(); } - try { - rootSpan.setAttribute( - "braintrust.output_json", - JSON_MAPPER.writeValueAsString(Map.of("output", taskResult.result()))); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } + rootSpan.setAttribute( + "braintrust.output_json", toJson(Map.of("output", taskResult.result()))); } // run scorers - one span per scorer for (var scorer : scorers) { @@ -139,8 +135,8 @@ private void evalOne(String experimentId, DatasetCase datasetCase Map spanAttrs = new LinkedHashMap<>(); spanAttrs.put("type", "score"); spanAttrs.put("name", scorer.getName()); - scoreSpan.setAttribute("braintrust.span_attributes", json(spanAttrs)); - var scoresJson = json(scorerScores); + scoreSpan.setAttribute("braintrust.span_attributes", toJson(spanAttrs)); + var scoresJson = toJson(scorerScores); scoreSpan.setAttribute("braintrust.output_json", scoresJson); scoreSpan.setAttribute("braintrust.scores", scoresJson); } finally { @@ -152,11 +148,6 @@ private void evalOne(String experimentId, DatasetCase datasetCase } } - @SneakyThrows - private String json(Object o) { - return JSON_MAPPER.writeValueAsString(o); - } - /** Creates a new eval builder. */ public static Builder builder() { return new Builder<>(); diff --git a/src/main/java/dev/braintrust/instrumentation/anthropic/otel/BraintrustAnthropicSpanAttributes.java b/src/main/java/dev/braintrust/instrumentation/anthropic/otel/BraintrustAnthropicSpanAttributes.java index 8bad448..885a4b8 100644 --- a/src/main/java/dev/braintrust/instrumentation/anthropic/otel/BraintrustAnthropicSpanAttributes.java +++ b/src/main/java/dev/braintrust/instrumentation/anthropic/otel/BraintrustAnthropicSpanAttributes.java @@ -1,15 +1,14 @@ package dev.braintrust.instrumentation.anthropic.otel; +import static dev.braintrust.json.BraintrustJsonMapper.toJson; + import com.anthropic.models.messages.Message; import com.anthropic.models.messages.MessageParam; -import com.fasterxml.jackson.databind.ObjectMapper; import io.opentelemetry.api.trace.Span; import java.util.List; -import lombok.SneakyThrows; /** Centralized class for setting all Anthropic-related span attributes. */ final class BraintrustAnthropicSpanAttributes { - private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); // GenAI semantic convention constants static final String OPERATION_CHAT = "chat"; @@ -21,25 +20,22 @@ private BraintrustAnthropicSpanAttributes() {} * Sets the braintrust.input_json attribute with the input messages. This captures the user's * prompt and system messages before sending to Anthropic. */ - @SneakyThrows public static void setInputMessages(Span span, List messages) { - span.setAttribute("braintrust.input_json", JSON_MAPPER.writeValueAsString(messages)); + span.setAttribute("braintrust.input_json", toJson(messages)); } /** * Sets the braintrust.output_json attribute with the output message. This captures the * assistant's response from Anthropic. */ - @SneakyThrows public static void setOutputMessage(Span span, Message message) { - span.setAttribute("braintrust.output_json", JSON_MAPPER.writeValueAsString(message)); + span.setAttribute("braintrust.output_json", toJson(message)); } /** * Sets the braintrust.output_json attribute with a JSON array. This is used for streaming * responses where the output is built incrementally. */ - @SneakyThrows public static void setOutputJson(Span span, String outputJson) { span.setAttribute("braintrust.output_json", outputJson); } diff --git a/src/main/java/dev/braintrust/instrumentation/anthropic/otel/StreamListener.java b/src/main/java/dev/braintrust/instrumentation/anthropic/otel/StreamListener.java index b504f1d..b3a3f3c 100644 --- a/src/main/java/dev/braintrust/instrumentation/anthropic/otel/StreamListener.java +++ b/src/main/java/dev/braintrust/instrumentation/anthropic/otel/StreamListener.java @@ -1,14 +1,16 @@ package dev.braintrust.instrumentation.anthropic.otel; +import static dev.braintrust.json.BraintrustJsonMapper.toJson; + import com.anthropic.models.messages.Message; import com.anthropic.models.messages.MessageCreateParams; import com.anthropic.models.messages.MessageDeltaUsage; import com.anthropic.models.messages.Model; import com.anthropic.models.messages.RawMessageStreamEvent; import com.anthropic.models.messages.Usage; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import dev.braintrust.json.BraintrustJsonMapper; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.trace.Span; import io.opentelemetry.context.Context; @@ -19,7 +21,6 @@ import lombok.SneakyThrows; final class StreamListener { - private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); private final Context context; private final MessageCreateParams request; @@ -93,14 +94,14 @@ void onEvent(RawMessageStreamEvent event) { // Handle content_block_stop - write output if (event.contentBlockStop().isPresent()) { - ArrayNode outputArray = JSON_MAPPER.createArrayNode(); - ObjectNode message = JSON_MAPPER.createObjectNode(); + ArrayNode outputArray = BraintrustJsonMapper.get().createArrayNode(); + ObjectNode message = BraintrustJsonMapper.get().createObjectNode(); message.put("role", "assistant"); message.put("content", contentBuilder.toString()); outputArray.add(message); BraintrustAnthropicSpanAttributes.setOutputJson( - Span.fromContext(context), JSON_MAPPER.writeValueAsString(outputArray)); + Span.fromContext(context), toJson(outputArray)); } } diff --git a/src/main/java/dev/braintrust/instrumentation/langchain/WrappedHttpClient.java b/src/main/java/dev/braintrust/instrumentation/langchain/WrappedHttpClient.java index 54668d7..e8063fa 100644 --- a/src/main/java/dev/braintrust/instrumentation/langchain/WrappedHttpClient.java +++ b/src/main/java/dev/braintrust/instrumentation/langchain/WrappedHttpClient.java @@ -1,7 +1,9 @@ package dev.braintrust.instrumentation.langchain; +import static dev.braintrust.json.BraintrustJsonMapper.toJson; + import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import dev.braintrust.json.BraintrustJsonMapper; import dev.braintrust.trace.BraintrustTracing; import dev.langchain4j.exception.HttpException; import dev.langchain4j.http.client.HttpClient; @@ -19,13 +21,10 @@ import io.opentelemetry.context.Scope; import java.util.HashMap; import java.util.Map; -import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; @Slf4j class WrappedHttpClient implements HttpClient { - private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); - private final Tracer tracer; private final HttpClient underlying; private final BraintrustLangchain.Options options; @@ -139,7 +138,7 @@ private Span startNewSpan(String spanName) { /** Tag span with request data: input messages, model, provider. */ private static void tagSpan(Span span, HttpRequest request, ProviderInfo providerInfo) { try { - span.setAttribute("braintrust.span_attributes", json(Map.of("type", "llm"))); + span.setAttribute("braintrust.span_attributes", toJson(Map.of("type", "llm"))); // Build metadata map Map metadata = new HashMap<>(); @@ -148,7 +147,7 @@ private static void tagSpan(Span span, HttpRequest request, ProviderInfo provide // Parse request body to extract model and messages String body = request.body(); if (body != null && !body.isEmpty()) { - JsonNode requestJson = JSON_MAPPER.readTree(body); + JsonNode requestJson = BraintrustJsonMapper.get().readTree(body); // Extract model if (requestJson.has("model")) { @@ -158,13 +157,13 @@ private static void tagSpan(Span span, HttpRequest request, ProviderInfo provide // Extract messages array for input if (requestJson.has("messages")) { - String messagesJson = json(requestJson.get("messages")); + String messagesJson = toJson(requestJson.get("messages")); span.setAttribute("braintrust.input_json", messagesJson); } } // Serialize metadata as JSON - span.setAttribute("braintrust.metadata", json(metadata)); + span.setAttribute("braintrust.metadata", toJson(metadata)); } catch (Exception e) { log.debug("Failed to parse request for span tagging", e); } @@ -183,11 +182,11 @@ private static void tagSpan( String body = response.body(); if (body != null && !body.isEmpty()) { - JsonNode responseJson = JSON_MAPPER.readTree(body); + JsonNode responseJson = BraintrustJsonMapper.get().readTree(body); // Extract choices array for output if (responseJson.has("choices")) { - String choicesJson = json(responseJson.get("choices")); + String choicesJson = toJson(responseJson.get("choices")); span.setAttribute("braintrust.output_json", choicesJson); } @@ -206,7 +205,7 @@ private static void tagSpan( } } - span.setAttribute("braintrust.metrics", json(metrics)); + span.setAttribute("braintrust.metrics", toJson(metrics)); } catch (Exception e) { log.debug("Failed to parse response for span tagging", e); } @@ -218,11 +217,6 @@ private static void tagSpan(Span span, Throwable t) { span.recordException(t); } - @SneakyThrows - private static String json(Object o) { - return JSON_MAPPER.writeValueAsString(o); - } - /** * Wraps a ServerSentEventListener to properly end the span when streaming completes or errors. * Also buffers streaming chunks to extract usage data. @@ -281,7 +275,7 @@ private void instrumentEvent(ServerSentEvent event) { // Buffer the data for final processing try { - JsonNode chunk = JSON_MAPPER.readTree(data); + JsonNode chunk = BraintrustJsonMapper.get().readTree(data); // For streaming, we accumulate deltas into the complete message // Just track if we have any content @@ -346,19 +340,19 @@ private void finalizeSpan() { if (outputBuffer.length() > 0 || finishReason != null) { try { // Create a proper choice object matching OpenAI API format - var choiceBuilder = JSON_MAPPER.createObjectNode(); + var choiceBuilder = BraintrustJsonMapper.get().createObjectNode(); choiceBuilder.put("index", 0); if (finishReason != null) { choiceBuilder.put("finish_reason", finishReason); } - var messageNode = JSON_MAPPER.createObjectNode(); + var messageNode = BraintrustJsonMapper.get().createObjectNode(); messageNode.put("role", "assistant"); messageNode.put("content", outputBuffer.toString()); choiceBuilder.set("message", messageNode); - var choicesArray = JSON_MAPPER.createArrayNode(); + var choicesArray = BraintrustJsonMapper.get().createArrayNode(); choicesArray.add(choiceBuilder); span.setAttribute("braintrust.output_json", choicesArray.toString()); @@ -388,7 +382,7 @@ private void finalizeSpan() { // Serialize metrics as JSON try { if (!metrics.isEmpty()) { - span.setAttribute("braintrust.metrics", json(metrics)); + span.setAttribute("braintrust.metrics", toJson(metrics)); } } catch (Exception e) { log.debug("Failed to serialize metrics", e); diff --git a/src/main/java/dev/braintrust/instrumentation/openai/otel/BraintrustOAISpanAttributes.java b/src/main/java/dev/braintrust/instrumentation/openai/otel/BraintrustOAISpanAttributes.java index 0586cc5..0eaed17 100644 --- a/src/main/java/dev/braintrust/instrumentation/openai/otel/BraintrustOAISpanAttributes.java +++ b/src/main/java/dev/braintrust/instrumentation/openai/otel/BraintrustOAISpanAttributes.java @@ -5,7 +5,6 @@ package dev.braintrust.instrumentation.openai.otel; -import com.fasterxml.jackson.databind.ObjectMapper; import com.openai.models.chat.completions.ChatCompletion; import io.opentelemetry.api.trace.Span; import lombok.SneakyThrows; @@ -14,7 +13,6 @@ /** Centralized class for setting all OpenAI-related span attributes. */ @Slf4j final class BraintrustOAISpanAttributes { - private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); // GenAI semantic convention constants static final String OPERATION_CHAT = "chat"; diff --git a/src/main/java/dev/braintrust/instrumentation/openai/otel/GenAiSemconvSerializer.java b/src/main/java/dev/braintrust/instrumentation/openai/otel/GenAiSemconvSerializer.java index 1a9b6c6..3f26015 100644 --- a/src/main/java/dev/braintrust/instrumentation/openai/otel/GenAiSemconvSerializer.java +++ b/src/main/java/dev/braintrust/instrumentation/openai/otel/GenAiSemconvSerializer.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.module.SimpleModule; import com.openai.models.chat.completions.*; +import dev.braintrust.json.BraintrustJsonMapper; import dev.braintrust.trace.Base64Attachment; import java.io.IOException; import java.lang.invoke.MethodHandle; @@ -30,8 +31,8 @@ final class GenAiSemconvSerializer { private static ObjectMapper createObjectMapper() { final JsonSerializer attachmentSerializer = Base64Attachment.createSerializer(); - ObjectMapper mapper = new ObjectMapper(); - mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + // Start with the base mapper configuration and add OpenAI-specific serializers + ObjectMapper mapper = BraintrustJsonMapper.get().copy(); SimpleModule module = new SimpleModule(); module.addSerializer( ChatCompletionContentPartImage.class, diff --git a/src/main/java/dev/braintrust/json/BraintrustJsonMapper.java b/src/main/java/dev/braintrust/json/BraintrustJsonMapper.java new file mode 100644 index 0000000..91952df --- /dev/null +++ b/src/main/java/dev/braintrust/json/BraintrustJsonMapper.java @@ -0,0 +1,78 @@ +package dev.braintrust.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import lombok.SneakyThrows; + +/** Centralized ObjectMapper for the Braintrust SDK. */ +public final class BraintrustJsonMapper { + + private static volatile ObjectMapper instance; + private static final List> configurers = new ArrayList<>(); + private static volatile boolean initialized = false; + + /** Default configuration applied to all ObjectMapper instances. */ + private static final Consumer DEFAULT_CONFIG = + objectMapper -> { + objectMapper + .registerModule(new JavaTimeModule()) + .registerModule(new Jdk8Module()) + .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .setDefaultPropertyInclusion(JsonInclude.Include.NON_ABSENT) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + }; + + static { + configurers.add(DEFAULT_CONFIG); + } + + private BraintrustJsonMapper() {} + + public static ObjectMapper get() { + if (instance == null) { + synchronized (BraintrustJsonMapper.class) { + if (instance == null) { + instance = new ObjectMapper(); + for (Consumer configurer : configurers) { + configurer.accept(instance); + } + initialized = true; + } + } + } + return instance; + } + + public static synchronized void configure(Consumer configurer) { + if (initialized) { + throw new IllegalStateException( + "BraintrustJsonMapper has already been initialized. " + + "configure() must be called before the first call to get()."); + } + configurers.add(configurer); + } + + @SneakyThrows + public static String toJson(Object o) { + return get().writeValueAsString(o); + } + + @SneakyThrows + public static T fromJson(String jsonString, Class targetClass) { + return get().readValue(jsonString, targetClass); + } + + static synchronized void reset() { + instance = null; + configurers.clear(); + configurers.add(DEFAULT_CONFIG); // Re-add default configuration + initialized = false; + } +} diff --git a/src/test/java/dev/braintrust/eval/EvalTest.java b/src/test/java/dev/braintrust/eval/EvalTest.java index 3e2abd1..0134795 100644 --- a/src/test/java/dev/braintrust/eval/EvalTest.java +++ b/src/test/java/dev/braintrust/eval/EvalTest.java @@ -1,8 +1,9 @@ package dev.braintrust.eval; +import static dev.braintrust.json.BraintrustJsonMapper.fromJson; +import static dev.braintrust.json.BraintrustJsonMapper.toJson; import static org.junit.jupiter.api.Assertions.*; -import com.fasterxml.jackson.databind.ObjectMapper; import dev.braintrust.Origin; import dev.braintrust.TestHarness; import dev.braintrust.api.BraintrustApiClient; @@ -19,8 +20,6 @@ import org.junit.jupiter.api.Test; public class EvalTest { - private static final ObjectMapper JSON_MAPPER = - new com.fasterxml.jackson.databind.ObjectMapper(); private TestHarness testHarness; @BeforeEach @@ -74,21 +73,21 @@ public void evalOtelTraceWithProperAttributes() { if (span.getParentSpanId().equals(SpanId.getInvalid())) { numRootSpans.incrementAndGet(); var inputJson = - Eval.JSON_MAPPER.readValue( + fromJson( span.getAttributes() .get(AttributeKey.stringKey("braintrust.input_json")), Map.class); assertNotNull(inputJson.get("input"), "invlaid input: " + inputJson); var expected = - Eval.JSON_MAPPER.readValue( + fromJson( span.getAttributes() .get(AttributeKey.stringKey("braintrust.expected")), String.class); assertTrue(isFruitOrVegetable(expected), "invalid expected: " + expected); var outputJson = - Eval.JSON_MAPPER.readValue( + fromJson( span.getAttributes() .get(AttributeKey.stringKey("braintrust.output_json")), Map.class); @@ -249,14 +248,13 @@ public void evalRootSpanPassesOriginIfPresent() { var inputJson = span.getAttributes().get(AttributeKey.stringKey("braintrust.input_json")); assertNotNull(inputJson); - JSON_MAPPER.readValue(inputJson, Map.class); - var input = (String) (JSON_MAPPER.readValue(inputJson, Map.class)).get("input"); + fromJson(inputJson, Map.class); + var input = (String) (fromJson(inputJson, Map.class)).get("input"); assertNotNull(input); var origin = span.getAttributes().get(AttributeKey.stringKey("braintrust.origin")); switch (input) { case "no-origin" -> assertNull(origin); - case "has-origin" -> - assertEquals(JSON_MAPPER.writeValueAsString(testOrigin), origin); + case "has-origin" -> assertEquals(toJson(testOrigin), origin); default -> fail("unexpected input: " + input); } numRootSpans.incrementAndGet(); diff --git a/src/test/java/dev/braintrust/json/BraintrustJsonMapperTest.java b/src/test/java/dev/braintrust/json/BraintrustJsonMapperTest.java new file mode 100644 index 0000000..30ce4b5 --- /dev/null +++ b/src/test/java/dev/braintrust/json/BraintrustJsonMapperTest.java @@ -0,0 +1,145 @@ +package dev.braintrust.json; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.SerializationFeature; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class BraintrustJsonMapperTest { + + @AfterEach + void tearDown() { + BraintrustJsonMapper.reset(); + } + + @Test + void toJson_serializesObject() { + record Person(String name, int age) {} + + String json = BraintrustJsonMapper.toJson(new Person("Alice", 30)); + + assertEquals("{\"name\":\"Alice\",\"age\":30}", json); + } + + @Test + void fromJson_deserializesObject() { + record Person(String name, int age) {} + + Person person = + BraintrustJsonMapper.fromJson("{\"name\":\"Bob\",\"age\":25}", Person.class); + + assertEquals("Bob", person.name()); + assertEquals(25, person.age()); + } + + @Test + void toJson_handlesJavaTimeModule() { + var instant = Instant.parse("2024-01-15T10:30:00Z"); + + String json = BraintrustJsonMapper.toJson(instant); + + // JavaTimeModule serializes Instant as a number by default + assertNotNull(json); + assertFalse(json.contains("Instant")); // Not toString() output + } + + @Test + void toJson_handlesJdk8ModuleWithOptional() { + record TestRecord(String name, Optional nickname) {} + + // Present Optional + var withNickname = new TestRecord("Alice", Optional.of("Ali")); + String json1 = BraintrustJsonMapper.toJson(withNickname); + assertTrue(json1.contains("\"nickname\":\"Ali\"")); + + // Empty Optional should be excluded (NON_ABSENT) + var withoutNickname = new TestRecord("Bob", Optional.empty()); + String json2 = BraintrustJsonMapper.toJson(withoutNickname); + assertFalse(json2.contains("nickname")); + } + + @Test + void toJson_excludesNullValues() { + record TestRecord(String name, String email) {} + + String json = BraintrustJsonMapper.toJson(new TestRecord("Alice", null)); + + assertFalse(json.contains("email")); + assertTrue(json.contains("\"name\":\"Alice\"")); + } + + @Test + void fromJson_ignoresUnknownProperties() { + record TestRecord(String name) {} + + var record = + BraintrustJsonMapper.fromJson( + "{\"name\":\"Alice\",\"unknownField\":123}", TestRecord.class); + + assertEquals("Alice", record.name()); + } + + @Test + void configure_appliesConfiguration() { + AtomicBoolean configurerCalled = new AtomicBoolean(false); + + BraintrustJsonMapper.configure( + mapper -> { + configurerCalled.set(true); + mapper.enable(SerializationFeature.INDENT_OUTPUT); + }); + + var mapper = BraintrustJsonMapper.get(); + + assertTrue(configurerCalled.get()); + assertTrue(mapper.isEnabled(SerializationFeature.INDENT_OUTPUT)); + } + + @Test + void configure_throwsIfCalledAfterGet() { + BraintrustJsonMapper.get(); // Initialize + + assertThrows( + IllegalStateException.class, () -> BraintrustJsonMapper.configure(mapper -> {})); + } + + @Test + void configure_allowsMultipleConfigurers() { + BraintrustJsonMapper.configure(mapper -> mapper.enable(SerializationFeature.INDENT_OUTPUT)); + + BraintrustJsonMapper.configure( + mapper -> mapper.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)); + + var mapper = BraintrustJsonMapper.get(); + + assertTrue(mapper.isEnabled(SerializationFeature.INDENT_OUTPUT)); + assertTrue(mapper.isEnabled(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)); + } + + @Test + void roundTrip_objectEqualsAfterSerializationAndDeserialization() { + record Person(String name, int age, Optional email) {} + + var original = new Person("Alice", 30, Optional.of("alice@example.com")); + + String json = BraintrustJsonMapper.toJson(original); + Person restored = BraintrustJsonMapper.fromJson(json, Person.class); + + assertEquals(original, restored); + } + + @Test + void toJson_usesSnakeCaseNaming() { + record UserProfile(String firstName, String lastName, int totalScore) {} + + String json = BraintrustJsonMapper.toJson(new UserProfile("Alice", "Smith", 100)); + + assertTrue(json.contains("\"first_name\":\"Alice\"")); + assertTrue(json.contains("\"last_name\":\"Smith\"")); + assertTrue(json.contains("\"total_score\":100")); + } +}