From 46c66425bad12c7c5d83d649379a3964eef73c5b Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Mon, 13 Jul 2026 23:27:57 +0800 Subject: [PATCH 1/6] Add span origin provenance --- .../braintrust/config/BraintrustConfig.java | 135 ++++++++++++++++++ .../config/SpanOriginEnvironment.java | 5 + .../braintrust/trace/BraintrustSampler.java | 5 +- .../trace/BraintrustSpanProcessor.java | 53 +++++++ .../braintrust/trace/BraintrustTracing.java | 10 ++ 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 braintrust-sdk/src/main/java/dev/braintrust/config/SpanOriginEnvironment.java diff --git a/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java b/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java index 86b9bb67..d271160b 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java @@ -3,10 +3,14 @@ import dev.braintrust.Braintrust; import dev.braintrust.api.BraintrustOpenApiClient; import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.Optional; +import javax.annotation.Nullable; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; @@ -39,6 +43,8 @@ public final class BraintrustConfig extends BaseConfig { private final Duration requestTimeout = Duration.ofSeconds(getConfig("BRAINTRUST_REQUEST_TIMEOUT", 30)); private final Boolean filterAISpans = getConfig("BRAINTRUST_FILTER_AI_SPANS", false); + private final Optional spanOriginEnvironment = + Optional.ofNullable(detectSpanOriginEnvironment()); /** compress otel data before exporting to braintrust */ private final Boolean compressOtelPayload = getConfig("BRAINTRUST_COMPRESS_OTEL_PAYLOAD", true); @@ -206,6 +212,29 @@ public Builder filterAISpans(boolean value) { return this; } + public Builder environment(String type) { + return environment(type, null); + } + + public Builder environment(String type, String name) { + envOverrides.put("BRAINTRUST_ENVIRONMENT_TYPE", type); + if (name != null) { + envOverrides.put("BRAINTRUST_ENVIRONMENT_NAME", name); + } else { + envOverrides.put("BRAINTRUST_ENVIRONMENT_NAME", NULL_OVERRIDE); + } + return this; + } + + public Builder spanOriginEnvironment(SpanOriginEnvironment value) { + if (value != null) { + return environment(value.type(), value.name()); + } + envOverrides.put("BRAINTRUST_ENVIRONMENT_TYPE", NULL_OVERRIDE); + envOverrides.put("BRAINTRUST_ENVIRONMENT_NAME", NULL_OVERRIDE); + return this; + } + public Builder compressOtelPayload(boolean value) { envOverrides.put("BRAINTRUST_COMPRESS_OTEL_PAYLOAD", String.valueOf(value)); return this; @@ -235,4 +264,110 @@ public BraintrustConfig build() { return new BraintrustConfig(envOverrides, sslContext, x509TrustManager); } } + + private SpanOriginEnvironment detectSpanOriginEnvironment() { + var environmentType = + firstNonBlank( + getEnvValue("BRAINTRUST_ENVIRONMENT_TYPE"), + getBraintrustEnvFileValue("BRAINTRUST_ENVIRONMENT_TYPE")); + if (environmentType != null) { + return new SpanOriginEnvironment( + environmentType, + firstNonBlank( + getEnvValue("BRAINTRUST_ENVIRONMENT_NAME"), + getBraintrustEnvFileValue("BRAINTRUST_ENVIRONMENT_NAME"))); + } + + var ciName = + detectFirstPresent( + Map.ofEntries( + Map.entry("GITHUB_ACTIONS", "github_actions"), + Map.entry("GITLAB_CI", "gitlab_ci"), + Map.entry("CIRCLECI", "circleci"), + Map.entry("BUILDKITE", "buildkite"), + Map.entry("JENKINS_URL", "jenkins"), + Map.entry("JENKINS_HOME", "jenkins"), + Map.entry("TF_BUILD", "azure_pipelines"), + Map.entry("TEAMCITY_VERSION", "teamcity"), + Map.entry("TRAVIS", "travis"), + Map.entry("BITBUCKET_BUILD_NUMBER", "bitbucket"))); + if (ciName != null) { + return new SpanOriginEnvironment("ci", ciName); + } + if (isPresent("CI")) { + return new SpanOriginEnvironment("ci", "ci"); + } + + var serverName = + detectFirstPresent( + Map.ofEntries( + Map.entry("VERCEL", "vercel"), + Map.entry("NETLIFY", "netlify"), + Map.entry("AWS_LAMBDA_FUNCTION_NAME", "aws_lambda"), + Map.entry("AWS_EXECUTION_ENV", "aws_lambda"), + Map.entry("K_SERVICE", "cloud_run"), + Map.entry("FUNCTION_TARGET", "gcp_functions"), + Map.entry("KUBERNETES_SERVICE_HOST", "kubernetes"), + Map.entry("ECS_CONTAINER_METADATA_URI", "ecs"), + Map.entry("ECS_CONTAINER_METADATA_URI_V4", "ecs"), + Map.entry("DYNO", "heroku"), + Map.entry("FLY_APP_NAME", "fly"), + Map.entry("RAILWAY_ENVIRONMENT", "railway"), + Map.entry("RENDER_SERVICE_NAME", "render"))); + if (serverName != null) { + return new SpanOriginEnvironment("server", serverName); + } + + return null; + } + + private @Nullable String detectFirstPresent(Map vars) { + return vars.entrySet().stream() + .filter(entry -> isPresent(entry.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + } + + private boolean isPresent(String key) { + return firstNonBlank(getEnvValue(key)) != null; + } + + private static @Nullable String firstNonBlank(String... values) { + return Arrays.stream(values) + .filter(value -> value != null && !value.isBlank()) + .map(String::trim) + .findFirst() + .orElse(null); + } + + private static @Nullable String getBraintrustEnvFileValue(String key) { + for (var dir = Path.of("").toAbsolutePath(); dir != null; dir = dir.getParent()) { + var path = dir.resolve(".env.braintrust"); + if (!Files.isRegularFile(path)) { + continue; + } + try { + for (var line : Files.readAllLines(path)) { + var trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + var equals = trimmed.indexOf('='); + if (equals <= 0 || !trimmed.substring(0, equals).trim().equals(key)) { + continue; + } + var value = trimmed.substring(equals + 1).trim(); + if ((value.startsWith("\"") && value.endsWith("\"")) + || (value.startsWith("'") && value.endsWith("'"))) { + value = value.substring(1, value.length() - 1); + } + return value; + } + } catch (Exception ignored) { + return null; + } + } + return null; + } } diff --git a/braintrust-sdk/src/main/java/dev/braintrust/config/SpanOriginEnvironment.java b/braintrust-sdk/src/main/java/dev/braintrust/config/SpanOriginEnvironment.java new file mode 100644 index 00000000..9a15ed0e --- /dev/null +++ b/braintrust-sdk/src/main/java/dev/braintrust/config/SpanOriginEnvironment.java @@ -0,0 +1,5 @@ +package dev.braintrust.config; + +import javax.annotation.Nullable; + +public record SpanOriginEnvironment(String type, @Nullable String name) {} diff --git a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSampler.java b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSampler.java index aa0efb04..c7cddcf3 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSampler.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSampler.java @@ -21,7 +21,10 @@ public boolean sample(ReadableSpan span) { for (var key : span.getAttributes().asMap().keySet()) { var keyName = key.getKey(); // Skip internal attributes injected by the span processor itself - if (keyName.equals(BraintrustTracing.PARENT_KEY)) { + if (keyName.equals(BraintrustTracing.PARENT_KEY) + || keyName.equals(BraintrustSpanProcessor.CONTEXT_JSON.getKey()) + || keyName.equals(BraintrustSpanProcessor.ENVIRONMENT_TYPE.getKey()) + || keyName.equals(BraintrustSpanProcessor.ENVIRONMENT_NAME.getKey())) { continue; } for (var prefix : AI_ATTR_PREFIXES) { diff --git a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java index 813a12c8..4538a24b 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java @@ -2,6 +2,8 @@ import dev.braintrust.api.BraintrustOpenApiClient; import dev.braintrust.config.BraintrustConfig; +import dev.braintrust.config.SpanOriginEnvironment; +import dev.braintrust.json.BraintrustJsonMapper; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.context.Context; @@ -12,7 +14,9 @@ import io.opentelemetry.sdk.trace.SpanProcessor; import io.opentelemetry.sdk.trace.data.DelegatingSpanData; import io.opentelemetry.sdk.trace.data.SpanData; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -33,6 +37,12 @@ public class BraintrustSpanProcessor implements SpanProcessor { public static final AttributeKey PARENT = AttributeKey.stringKey(BraintrustTracing.PARENT_KEY); + static final AttributeKey CONTEXT_JSON = + AttributeKey.stringKey("braintrust.context_json"); + static final AttributeKey ENVIRONMENT_TYPE = + AttributeKey.stringKey("braintrust.environment.type"); + static final AttributeKey ENVIRONMENT_NAME = + AttributeKey.stringKey("braintrust.environment.name"); private final BraintrustConfig config; private final SpanProcessor delegate; @@ -106,9 +116,52 @@ public void onStart(@Nonnull Context parentContext, ReadWriteSpan span) { } } + var spanOriginEnvironment = config.spanOriginEnvironment().orElse(null); + span.setAttribute( + CONTEXT_JSON, mergedContextJson(span.getAttribute(CONTEXT_JSON), spanOriginEnvironment)); + if (spanOriginEnvironment != null) { + span.setAttribute(ENVIRONMENT_TYPE, spanOriginEnvironment.type()); + if (spanOriginEnvironment.name() != null && !spanOriginEnvironment.name().isBlank()) { + span.setAttribute(ENVIRONMENT_NAME, spanOriginEnvironment.name()); + } + } + delegate.onStart(parentContext, span); } + @SuppressWarnings("unchecked") + private static String mergedContextJson( + @Nullable String existingContextJson, @Nullable SpanOriginEnvironment environment) { + Map context = new HashMap<>(); + if (existingContextJson != null && !existingContextJson.isBlank()) { + try { + context.putAll(BraintrustJsonMapper.get().readValue(existingContextJson, Map.class)); + } catch (Exception ignored) { + // Invalid user-provided context should not prevent adding provenance. + } + } + + var existingOrigin = context.get("span_origin"); + Map spanOrigin = + existingOrigin instanceof Map map + ? new HashMap<>((Map) map) + : new HashMap<>(); + spanOrigin.putIfAbsent("name", "braintrust.sdk.java"); + spanOrigin.putIfAbsent("version", BraintrustTracing.INSTRUMENTATION_VERSION); + spanOrigin.putIfAbsent( + "instrumentation", Map.of("name", BraintrustTracing.INSTRUMENTATION_NAME)); + if (!spanOrigin.containsKey("environment") && environment != null) { + var env = new HashMap(); + env.put("type", environment.type()); + if (environment.name() != null && !environment.name().isBlank()) { + env.put("name", environment.name()); + } + spanOrigin.put("environment", env); + } + context.put("span_origin", spanOrigin); + return BraintrustJsonMapper.toJson(context); + } + @Override public boolean isStartRequired() { return true; diff --git a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustTracing.java b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustTracing.java index c3574038..060991f5 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustTracing.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustTracing.java @@ -41,6 +41,8 @@ public final class BraintrustTracing { static final String OTEL_SERVICE_NAME = "braintrust-app"; static final String INSTRUMENTATION_NAME = "braintrust-java"; static final String INSTRUMENTATION_VERSION = loadVersionFromProperties(); + private static final String ENVIRONMENT_TYPE_KEY = "braintrust.environment.type"; + private static final String ENVIRONMENT_NAME_KEY = "braintrust.environment.name"; /** * Quick start method that sets up global OpenTelemetry with Braintrust defaults.
@@ -134,6 +136,14 @@ static void enable( Resource.getDefault().toBuilder() .put(ServiceAttributes.SERVICE_NAME, OTEL_SERVICE_NAME) .put(ServiceAttributes.SERVICE_VERSION, INSTRUMENTATION_VERSION); + config.spanOriginEnvironment() + .ifPresent( + environment -> { + resourceBuilder.put(ENVIRONMENT_TYPE_KEY, environment.type()); + if (environment.name() != null && !environment.name().isBlank()) { + resourceBuilder.put(ENVIRONMENT_NAME_KEY, environment.name()); + } + }); var resource = resourceBuilder.build(); // spans From c1f49a358b6c9fd67178c8ac0956e11bd31b4c94 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 15:22:03 +0800 Subject: [PATCH 2/6] Format Java span processor --- .../java/dev/braintrust/trace/BraintrustSpanProcessor.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java index 4538a24b..4fd40bc3 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java @@ -118,7 +118,8 @@ public void onStart(@Nonnull Context parentContext, ReadWriteSpan span) { var spanOriginEnvironment = config.spanOriginEnvironment().orElse(null); span.setAttribute( - CONTEXT_JSON, mergedContextJson(span.getAttribute(CONTEXT_JSON), spanOriginEnvironment)); + CONTEXT_JSON, + mergedContextJson(span.getAttribute(CONTEXT_JSON), spanOriginEnvironment)); if (spanOriginEnvironment != null) { span.setAttribute(ENVIRONMENT_TYPE, spanOriginEnvironment.type()); if (spanOriginEnvironment.name() != null && !spanOriginEnvironment.name().isBlank()) { @@ -135,7 +136,8 @@ private static String mergedContextJson( Map context = new HashMap<>(); if (existingContextJson != null && !existingContextJson.isBlank()) { try { - context.putAll(BraintrustJsonMapper.get().readValue(existingContextJson, Map.class)); + context.putAll( + BraintrustJsonMapper.get().readValue(existingContextJson, Map.class)); } catch (Exception ignored) { // Invalid user-provided context should not prevent adding provenance. } From bb4d6ba0a401fb3a6195043437e082ad67e7c6c1 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 18:10:02 +0800 Subject: [PATCH 3/6] Address span origin review feedback --- .../braintrust/config/BraintrustConfig.java | 72 ++++++++++++------- .../trace/BraintrustSpanProcessor.java | 60 +++++++++++----- .../config/BraintrustConfigTest.java | 34 +++++++++ .../trace/BraintrustTracingTest.java | 37 ++++++++++ 4 files changed, 162 insertions(+), 41 deletions(-) diff --git a/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java b/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java index d271160b..b45ce175 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java @@ -266,16 +266,13 @@ public BraintrustConfig build() { } private SpanOriginEnvironment detectSpanOriginEnvironment() { - var environmentType = - firstNonBlank( - getEnvValue("BRAINTRUST_ENVIRONMENT_TYPE"), - getBraintrustEnvFileValue("BRAINTRUST_ENVIRONMENT_TYPE")); + var environmentType = getEnvironmentConfigValue("BRAINTRUST_ENVIRONMENT_TYPE"); if (environmentType != null) { return new SpanOriginEnvironment( - environmentType, - firstNonBlank( - getEnvValue("BRAINTRUST_ENVIRONMENT_NAME"), - getBraintrustEnvFileValue("BRAINTRUST_ENVIRONMENT_NAME"))); + environmentType, getEnvironmentConfigValue("BRAINTRUST_ENVIRONMENT_NAME")); + } + if (envOverrides.containsKey("BRAINTRUST_ENVIRONMENT_TYPE")) { + return null; } var ciName = @@ -298,22 +295,7 @@ private SpanOriginEnvironment detectSpanOriginEnvironment() { return new SpanOriginEnvironment("ci", "ci"); } - var serverName = - detectFirstPresent( - Map.ofEntries( - Map.entry("VERCEL", "vercel"), - Map.entry("NETLIFY", "netlify"), - Map.entry("AWS_LAMBDA_FUNCTION_NAME", "aws_lambda"), - Map.entry("AWS_EXECUTION_ENV", "aws_lambda"), - Map.entry("K_SERVICE", "cloud_run"), - Map.entry("FUNCTION_TARGET", "gcp_functions"), - Map.entry("KUBERNETES_SERVICE_HOST", "kubernetes"), - Map.entry("ECS_CONTAINER_METADATA_URI", "ecs"), - Map.entry("ECS_CONTAINER_METADATA_URI_V4", "ecs"), - Map.entry("DYNO", "heroku"), - Map.entry("FLY_APP_NAME", "fly"), - Map.entry("RAILWAY_ENVIRONMENT", "railway"), - Map.entry("RENDER_SERVICE_NAME", "render"))); + var serverName = detectServerEnvironmentName(); if (serverName != null) { return new SpanOriginEnvironment("server", serverName); } @@ -321,6 +303,48 @@ private SpanOriginEnvironment detectSpanOriginEnvironment() { return null; } + private @Nullable String getEnvironmentConfigValue(String key) { + var value = getEnvValue(key); + if (envOverrides.containsKey(key)) { + return firstNonBlank(value); + } + return firstNonBlank(value, getBraintrustEnvFileValue(key)); + } + + private @Nullable String detectServerEnvironmentName() { + var serverName = + detectFirstPresent( + Map.ofEntries( + Map.entry("VERCEL", "vercel"), Map.entry("NETLIFY", "netlify"))); + if (serverName != null) { + return serverName; + } + if (isPresent("ECS_CONTAINER_METADATA_URI") || isPresent("ECS_CONTAINER_METADATA_URI_V4")) { + return "ecs"; + } + var awsExecutionEnv = firstNonBlank(getEnvValue("AWS_EXECUTION_ENV")); + if (awsExecutionEnv != null) { + if (awsExecutionEnv.startsWith("AWS_ECS_")) { + return "ecs"; + } + if (awsExecutionEnv.startsWith("AWS_Lambda_")) { + return "aws_lambda"; + } + } + if (isPresent("AWS_LAMBDA_FUNCTION_NAME")) { + return "aws_lambda"; + } + return detectFirstPresent( + Map.ofEntries( + Map.entry("K_SERVICE", "cloud_run"), + Map.entry("FUNCTION_TARGET", "gcp_functions"), + Map.entry("KUBERNETES_SERVICE_HOST", "kubernetes"), + Map.entry("DYNO", "heroku"), + Map.entry("FLY_APP_NAME", "fly"), + Map.entry("RAILWAY_ENVIRONMENT", "railway"), + Map.entry("RENDER_SERVICE_NAME", "render"))); + } + private @Nullable String detectFirstPresent(Map vars) { return vars.entrySet().stream() .filter(entry -> isPresent(entry.getKey())) diff --git a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java index 4fd40bc3..29a186d6 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java @@ -116,17 +116,6 @@ public void onStart(@Nonnull Context parentContext, ReadWriteSpan span) { } } - var spanOriginEnvironment = config.spanOriginEnvironment().orElse(null); - span.setAttribute( - CONTEXT_JSON, - mergedContextJson(span.getAttribute(CONTEXT_JSON), spanOriginEnvironment)); - if (spanOriginEnvironment != null) { - span.setAttribute(ENVIRONMENT_TYPE, spanOriginEnvironment.type()); - if (spanOriginEnvironment.name() != null && !spanOriginEnvironment.name().isBlank()) { - span.setAttribute(ENVIRONMENT_NAME, spanOriginEnvironment.name()); - } - } - delegate.onStart(parentContext, span); } @@ -190,18 +179,30 @@ public void onEnd(ReadableSpan span) { @Nullable String newInputJson = attachmentProcessor.processAndUpload(inputJson); @Nullable String newOutputJson = attachmentProcessor.processAndUpload(outputJson); + var spanOriginEnvironment = config.spanOriginEnvironment().orElse(null); + var newContextJson = + mergedContextJson( + spanData.getAttributes().get(CONTEXT_JSON), spanOriginEnvironment); if (!Objects.equals(newInputJson, inputJson) || !Objects.equals(newOutputJson, outputJson)) { - delegate.onEnd(new TransformedReadableSpan(span, newInputJson, newOutputJson)); + delegate.onEnd( + new TransformedReadableSpan( + span, + newInputJson, + newOutputJson, + newContextJson, + spanOriginEnvironment)); } else { - delegate.onEnd(span); + delegate.onEnd( + new TransformedReadableSpan( + span, inputJson, outputJson, newContextJson, spanOriginEnvironment)); } } @Override public boolean isEndRequired() { - return config.autoConvertAIAttachments() || !samplers.isEmpty() || delegate.isEndRequired(); + return true; } @Override @@ -262,11 +263,27 @@ private static class TransformedReadableSpan implements ReadableSpan { private final ReadableSpan delegate; private final Attributes attributes; - TransformedReadableSpan(ReadableSpan delegate, String inputJson, String outputJson) { + TransformedReadableSpan( + ReadableSpan delegate, + String inputJson, + String outputJson, + String contextJson, + @Nullable SpanOriginEnvironment environment) { this.delegate = delegate; var builder = delegate.getAttributes().toBuilder(); - builder.put(INPUT_JSON, inputJson); - builder.put(OUTPUT_JSON, outputJson); + if (inputJson != null) { + builder.put(INPUT_JSON, inputJson); + } + if (outputJson != null) { + builder.put(OUTPUT_JSON, outputJson); + } + builder.put(CONTEXT_JSON, contextJson); + if (environment != null) { + builder.put(ENVIRONMENT_TYPE, environment.type()); + if (environment.name() != null && !environment.name().isBlank()) { + builder.put(ENVIRONMENT_NAME, environment.name()); + } + } attributes = builder.build(); } @@ -284,6 +301,15 @@ public T getAttribute(AttributeKey key) { if (key.equals(OUTPUT_JSON)) { return (T) attributes.get(OUTPUT_JSON); } + if (key.equals(CONTEXT_JSON)) { + return (T) attributes.get(CONTEXT_JSON); + } + if (key.equals(ENVIRONMENT_TYPE)) { + return (T) attributes.get(ENVIRONMENT_TYPE); + } + if (key.equals(ENVIRONMENT_NAME)) { + return (T) attributes.get(ENVIRONMENT_NAME); + } return delegate.getAttribute(key); } diff --git a/braintrust-sdk/src/test/java/dev/braintrust/config/BraintrustConfigTest.java b/braintrust-sdk/src/test/java/dev/braintrust/config/BraintrustConfigTest.java index 0ebe306f..c4d5b3ee 100644 --- a/braintrust-sdk/src/test/java/dev/braintrust/config/BraintrustConfigTest.java +++ b/braintrust-sdk/src/test/java/dev/braintrust/config/BraintrustConfigTest.java @@ -91,4 +91,38 @@ void testDefaultSslContextWhenNotProvided() throws Exception { // Should be the system defaults assertEquals(SSLContext.getDefault(), config.sslContext()); } + + @Test + void explicitNullSpanOriginEnvironmentDisablesDetection() { + var config = + BraintrustConfig.of( + "BRAINTRUST_ENVIRONMENT_TYPE", + BaseConfig.NULL_OVERRIDE, + "BRAINTRUST_ENVIRONMENT_NAME", + BaseConfig.NULL_OVERRIDE, + "CI", + "true", + "AWS_EXECUTION_ENV", + "AWS_ECS_FARGATE"); + + assertTrue(config.spanOriginEnvironment().isEmpty()); + } + + @Test + void awsExecutionEnvClassifiesEcsBeforeLambda() { + var config = BraintrustConfig.of("AWS_EXECUTION_ENV", "AWS_ECS_FARGATE"); + + var environment = config.spanOriginEnvironment().orElseThrow(); + assertEquals("server", environment.type()); + assertEquals("ecs", environment.name()); + } + + @Test + void awsExecutionEnvClassifiesLambdaWhenLambdaSpecific() { + var config = BraintrustConfig.of("AWS_EXECUTION_ENV", "AWS_Lambda_java17"); + + var environment = config.spanOriginEnvironment().orElseThrow(); + assertEquals("server", environment.type()); + assertEquals("aws_lambda", environment.name()); + } } diff --git a/braintrust-sdk/src/test/java/dev/braintrust/trace/BraintrustTracingTest.java b/braintrust-sdk/src/test/java/dev/braintrust/trace/BraintrustTracingTest.java index 6b3bda06..a3437458 100644 --- a/braintrust-sdk/src/test/java/dev/braintrust/trace/BraintrustTracingTest.java +++ b/braintrust-sdk/src/test/java/dev/braintrust/trace/BraintrustTracingTest.java @@ -5,10 +5,12 @@ import dev.braintrust.TestHarness; import dev.braintrust.UnitTestSpanExporter; import dev.braintrust.config.BraintrustConfig; +import dev.braintrust.json.BraintrustJsonMapper; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.util.Map; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -112,6 +114,41 @@ void filterAISpansDropsNonAISpans() { assertEquals("llm-call", spans.get(0).getName()); } + @Test + void spanOriginMergesWithContextJsonSetAfterSpanStart() throws Exception { + var config = + BraintrustConfig.builder() + .apiKey("test-key") + .apiUrl("http://localhost:1234") + .build(); + + var spanExporter = new UnitTestSpanExporter(); + var processor = + new BraintrustSpanProcessor(config, SimpleSpanProcessor.create(spanExporter)); + var tracerProvider = SdkTracerProvider.builder().addSpanProcessor(processor).build(); + var tracer = tracerProvider.get("test"); + + var span = tracer.spanBuilder("late-context").startSpan(); + span.setAttribute( + "braintrust.context_json", "{\"metadata\":{\"source\":\"late-attribute\"}}"); + span.end(); + + tracerProvider.forceFlush().join(5, TimeUnit.SECONDS); + var spans = spanExporter.getFinishedSpanItems(); + assertEquals(1, spans.size()); + + var contextJson = + spans.get(0).getAttributes().get(AttributeKey.stringKey("braintrust.context_json")); + assertNotNull(contextJson); + var context = BraintrustJsonMapper.get().readValue(contextJson, Map.class); + assertEquals("late-attribute", ((Map) context.get("metadata")).get("source")); + var spanOrigin = (Map) context.get("span_origin"); + assertEquals("braintrust.sdk.java", spanOrigin.get("name")); + assertEquals( + BraintrustTracing.INSTRUMENTATION_NAME, + ((Map) spanOrigin.get("instrumentation")).get("name")); + } + private void doSimpleOtelTrace(Tracer tracer) { // use tracer to create a simple trace with a root span and a child span var span = tracer.spanBuilder("unit-test-root").startSpan(); From e8b31e1a9f39fd667ef17fe8d3fbc26ebedd084e Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 18:53:44 +0800 Subject: [PATCH 4/6] Prefer server span origin over CI --- .../java/dev/braintrust/config/BraintrustConfig.java | 10 +++++----- .../dev/braintrust/config/BraintrustConfigTest.java | 8 ++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java b/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java index b45ce175..bd58817e 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java @@ -275,6 +275,11 @@ private SpanOriginEnvironment detectSpanOriginEnvironment() { return null; } + var serverName = detectServerEnvironmentName(); + if (serverName != null) { + return new SpanOriginEnvironment("server", serverName); + } + var ciName = detectFirstPresent( Map.ofEntries( @@ -295,11 +300,6 @@ private SpanOriginEnvironment detectSpanOriginEnvironment() { return new SpanOriginEnvironment("ci", "ci"); } - var serverName = detectServerEnvironmentName(); - if (serverName != null) { - return new SpanOriginEnvironment("server", serverName); - } - return null; } diff --git a/braintrust-sdk/src/test/java/dev/braintrust/config/BraintrustConfigTest.java b/braintrust-sdk/src/test/java/dev/braintrust/config/BraintrustConfigTest.java index c4d5b3ee..4fb9cda5 100644 --- a/braintrust-sdk/src/test/java/dev/braintrust/config/BraintrustConfigTest.java +++ b/braintrust-sdk/src/test/java/dev/braintrust/config/BraintrustConfigTest.java @@ -110,7 +110,9 @@ void explicitNullSpanOriginEnvironmentDisablesDetection() { @Test void awsExecutionEnvClassifiesEcsBeforeLambda() { - var config = BraintrustConfig.of("AWS_EXECUTION_ENV", "AWS_ECS_FARGATE"); + var config = + BraintrustConfig.of( + "GITHUB_ACTIONS", "true", "AWS_EXECUTION_ENV", "AWS_ECS_FARGATE"); var environment = config.spanOriginEnvironment().orElseThrow(); assertEquals("server", environment.type()); @@ -119,7 +121,9 @@ void awsExecutionEnvClassifiesEcsBeforeLambda() { @Test void awsExecutionEnvClassifiesLambdaWhenLambdaSpecific() { - var config = BraintrustConfig.of("AWS_EXECUTION_ENV", "AWS_Lambda_java17"); + var config = + BraintrustConfig.of( + "GITHUB_ACTIONS", "true", "AWS_EXECUTION_ENV", "AWS_Lambda_java17"); var environment = config.spanOriginEnvironment().orElseThrow(); assertEquals("server", environment.type()); From 584dbd34b7883c00689e593fa0452be389768666 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 20:08:39 +0800 Subject: [PATCH 5/6] fix: keep span origin environment in context --- .../dev/braintrust/trace/BraintrustSampler.java | 4 +--- .../trace/BraintrustSpanProcessor.java | 16 ---------------- .../dev/braintrust/trace/BraintrustTracing.java | 10 ---------- 3 files changed, 1 insertion(+), 29 deletions(-) diff --git a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSampler.java b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSampler.java index c7cddcf3..450bb407 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSampler.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSampler.java @@ -22,9 +22,7 @@ public boolean sample(ReadableSpan span) { var keyName = key.getKey(); // Skip internal attributes injected by the span processor itself if (keyName.equals(BraintrustTracing.PARENT_KEY) - || keyName.equals(BraintrustSpanProcessor.CONTEXT_JSON.getKey()) - || keyName.equals(BraintrustSpanProcessor.ENVIRONMENT_TYPE.getKey()) - || keyName.equals(BraintrustSpanProcessor.ENVIRONMENT_NAME.getKey())) { + || keyName.equals(BraintrustSpanProcessor.CONTEXT_JSON.getKey())) { continue; } for (var prefix : AI_ATTR_PREFIXES) { diff --git a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java index 29a186d6..26e9db72 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java @@ -39,10 +39,6 @@ public class BraintrustSpanProcessor implements SpanProcessor { AttributeKey.stringKey(BraintrustTracing.PARENT_KEY); static final AttributeKey CONTEXT_JSON = AttributeKey.stringKey("braintrust.context_json"); - static final AttributeKey ENVIRONMENT_TYPE = - AttributeKey.stringKey("braintrust.environment.type"); - static final AttributeKey ENVIRONMENT_NAME = - AttributeKey.stringKey("braintrust.environment.name"); private final BraintrustConfig config; private final SpanProcessor delegate; @@ -278,12 +274,6 @@ private static class TransformedReadableSpan implements ReadableSpan { builder.put(OUTPUT_JSON, outputJson); } builder.put(CONTEXT_JSON, contextJson); - if (environment != null) { - builder.put(ENVIRONMENT_TYPE, environment.type()); - if (environment.name() != null && !environment.name().isBlank()) { - builder.put(ENVIRONMENT_NAME, environment.name()); - } - } attributes = builder.build(); } @@ -304,12 +294,6 @@ public T getAttribute(AttributeKey key) { if (key.equals(CONTEXT_JSON)) { return (T) attributes.get(CONTEXT_JSON); } - if (key.equals(ENVIRONMENT_TYPE)) { - return (T) attributes.get(ENVIRONMENT_TYPE); - } - if (key.equals(ENVIRONMENT_NAME)) { - return (T) attributes.get(ENVIRONMENT_NAME); - } return delegate.getAttribute(key); } diff --git a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustTracing.java b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustTracing.java index 060991f5..c3574038 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustTracing.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustTracing.java @@ -41,8 +41,6 @@ public final class BraintrustTracing { static final String OTEL_SERVICE_NAME = "braintrust-app"; static final String INSTRUMENTATION_NAME = "braintrust-java"; static final String INSTRUMENTATION_VERSION = loadVersionFromProperties(); - private static final String ENVIRONMENT_TYPE_KEY = "braintrust.environment.type"; - private static final String ENVIRONMENT_NAME_KEY = "braintrust.environment.name"; /** * Quick start method that sets up global OpenTelemetry with Braintrust defaults.
@@ -136,14 +134,6 @@ static void enable( Resource.getDefault().toBuilder() .put(ServiceAttributes.SERVICE_NAME, OTEL_SERVICE_NAME) .put(ServiceAttributes.SERVICE_VERSION, INSTRUMENTATION_VERSION); - config.spanOriginEnvironment() - .ifPresent( - environment -> { - resourceBuilder.put(ENVIRONMENT_TYPE_KEY, environment.type()); - if (environment.name() != null && !environment.name().isBlank()) { - resourceBuilder.put(ENVIRONMENT_NAME_KEY, environment.name()); - } - }); var resource = resourceBuilder.build(); // spans From 8a213944fc552628afcb144385c5abe91fb3aca9 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 22:50:43 +0800 Subject: [PATCH 6/6] fix: preserve span origin environment name --- .../dev/braintrust/config/BraintrustConfig.java | 9 +++++---- .../braintrust/config/SpanOriginEnvironment.java | 2 +- .../trace/BraintrustSpanProcessor.java | 4 +++- .../braintrust/config/BraintrustConfigTest.java | 16 ++++++++++++++++ 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java b/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java index bd58817e..5a34ff31 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/config/BraintrustConfig.java @@ -267,11 +267,12 @@ public BraintrustConfig build() { private SpanOriginEnvironment detectSpanOriginEnvironment() { var environmentType = getEnvironmentConfigValue("BRAINTRUST_ENVIRONMENT_TYPE"); - if (environmentType != null) { - return new SpanOriginEnvironment( - environmentType, getEnvironmentConfigValue("BRAINTRUST_ENVIRONMENT_NAME")); + var environmentName = getEnvironmentConfigValue("BRAINTRUST_ENVIRONMENT_NAME"); + if (environmentType != null || environmentName != null) { + return new SpanOriginEnvironment(environmentType, environmentName); } - if (envOverrides.containsKey("BRAINTRUST_ENVIRONMENT_TYPE")) { + if (envOverrides.containsKey("BRAINTRUST_ENVIRONMENT_TYPE") + || envOverrides.containsKey("BRAINTRUST_ENVIRONMENT_NAME")) { return null; } diff --git a/braintrust-sdk/src/main/java/dev/braintrust/config/SpanOriginEnvironment.java b/braintrust-sdk/src/main/java/dev/braintrust/config/SpanOriginEnvironment.java index 9a15ed0e..77a5901b 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/config/SpanOriginEnvironment.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/config/SpanOriginEnvironment.java @@ -2,4 +2,4 @@ import javax.annotation.Nullable; -public record SpanOriginEnvironment(String type, @Nullable String name) {} +public record SpanOriginEnvironment(@Nullable String type, @Nullable String name) {} diff --git a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java index 26e9db72..c243bb36 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/trace/BraintrustSpanProcessor.java @@ -139,7 +139,9 @@ private static String mergedContextJson( "instrumentation", Map.of("name", BraintrustTracing.INSTRUMENTATION_NAME)); if (!spanOrigin.containsKey("environment") && environment != null) { var env = new HashMap(); - env.put("type", environment.type()); + if (environment.type() != null && !environment.type().isBlank()) { + env.put("type", environment.type()); + } if (environment.name() != null && !environment.name().isBlank()) { env.put("name", environment.name()); } diff --git a/braintrust-sdk/src/test/java/dev/braintrust/config/BraintrustConfigTest.java b/braintrust-sdk/src/test/java/dev/braintrust/config/BraintrustConfigTest.java index 4fb9cda5..d395ef5c 100644 --- a/braintrust-sdk/src/test/java/dev/braintrust/config/BraintrustConfigTest.java +++ b/braintrust-sdk/src/test/java/dev/braintrust/config/BraintrustConfigTest.java @@ -108,6 +108,22 @@ void explicitNullSpanOriginEnvironmentDisablesDetection() { assertTrue(config.spanOriginEnvironment().isEmpty()); } + @Test + void explicitEnvironmentNameWithoutTypeIsPreserved() { + var config = + BraintrustConfig.of( + "BRAINTRUST_ENVIRONMENT_TYPE", + BaseConfig.NULL_OVERRIDE, + "BRAINTRUST_ENVIRONMENT_NAME", + "staging", + "CI", + "true"); + + var environment = config.spanOriginEnvironment().orElseThrow(); + assertNull(environment.type()); + assertEquals("staging", environment.name()); + } + @Test void awsExecutionEnvClassifiesEcsBeforeLambda() { var config =