From 358685406a57ebb099af808541c47da0d7a88d12 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Mon, 13 Jul 2026 23:28:39 +0800 Subject: [PATCH 1/8] Add span origin provenance --- lib/braintrust.rb | 4 +- lib/braintrust/config.rb | 112 ++++++++++++++++++++++++- lib/braintrust/state.rb | 6 +- lib/braintrust/trace/span_processor.rb | 43 ++++++++++ 4 files changed, 158 insertions(+), 7 deletions(-) diff --git a/lib/braintrust.rb b/lib/braintrust.rb index 496fc226..b0ddeae5 100644 --- a/lib/braintrust.rb +++ b/lib/braintrust.rb @@ -43,13 +43,14 @@ class Error < StandardError; end # @param filter_ai_spans [Boolean, nil] Enable AI span filtering (overrides BRAINTRUST_OTEL_FILTER_AI_SPANS env var) # @param span_filter_funcs [Array, nil] Custom span filter functions # @param exporter [Exporter, nil] Optional exporter override (for testing) + # @param environment [Hash, nil] Span-origin environment override, e.g. { type: "ci", name: "github_actions" } # @param auto_instrument [Boolean, Hash, nil] Auto-instrumentation config: # - nil (default): use BRAINTRUST_AUTO_INSTRUMENT env var, default true if not set # - true: explicitly enable # - false: explicitly disable # - Hash with :only or :except keys for filtering # @return [State] the created state - def self.init(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, set_global: true, blocking_login: false, enable_tracing: true, tracer_provider: nil, filter_ai_spans: nil, span_filter_funcs: nil, exporter: nil, auto_instrument: nil) + def self.init(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, set_global: true, blocking_login: false, enable_tracing: true, tracer_provider: nil, filter_ai_spans: nil, span_filter_funcs: nil, exporter: nil, auto_instrument: nil, environment: nil) state = State.from_env( api_key: api_key, org_name: org_name, @@ -61,6 +62,7 @@ def self.init(api_key: nil, org_name: nil, default_project: nil, app_url: nil, a tracer_provider: tracer_provider, filter_ai_spans: filter_ai_spans, span_filter_funcs: span_filter_funcs, + environment: environment, exporter: exporter ) diff --git a/lib/braintrust/config.rb b/lib/braintrust/config.rb index 48a7f803..75ea1232 100644 --- a/lib/braintrust/config.rb +++ b/lib/braintrust/config.rb @@ -7,10 +7,10 @@ module Braintrust # and allows overriding with explicit options class Config attr_reader :api_key, :org_name, :default_project, :app_url, :api_url, - :filter_ai_spans, :span_filter_funcs + :filter_ai_spans, :span_filter_funcs, :environment def initialize(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, - filter_ai_spans: nil, span_filter_funcs: nil) + filter_ai_spans: nil, span_filter_funcs: nil, environment: nil) @api_key = api_key @org_name = org_name @default_project = default_project @@ -18,6 +18,7 @@ def initialize(api_key: nil, org_name: nil, default_project: nil, app_url: nil, @api_url = api_url @filter_ai_spans = filter_ai_spans @span_filter_funcs = span_filter_funcs || [] + @environment = environment end # Create a Config from environment variables, with option overrides @@ -29,9 +30,10 @@ def initialize(api_key: nil, org_name: nil, default_project: nil, app_url: nil, # @param api_url [String, nil] API URL (overrides BRAINTRUST_API_URL env var) # @param filter_ai_spans [Boolean, nil] Enable AI span filtering (overrides BRAINTRUST_OTEL_FILTER_AI_SPANS env var) # @param span_filter_funcs [Array, nil] Custom span filter functions + # @param environment [Hash, nil] Span-origin environment override, e.g. { type: "ci", name: "github_actions" } # @return [Config] the created config def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, - filter_ai_spans: nil, span_filter_funcs: nil) + filter_ai_spans: nil, span_filter_funcs: nil, environment: nil) # Parse filter_ai_spans from ENV if not explicitly provided env_filter_ai_spans = ENV["BRAINTRUST_OTEL_FILTER_AI_SPANS"] filter_ai_spans_value = if filter_ai_spans.nil? @@ -47,8 +49,110 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni app_url: app_url || ENV["BRAINTRUST_APP_URL"] || "https://www.braintrust.dev", api_url: api_url || ENV["BRAINTRUST_API_URL"] || "https://api.braintrust.dev", filter_ai_spans: filter_ai_spans_value, - span_filter_funcs: span_filter_funcs + span_filter_funcs: span_filter_funcs, + environment: detect_environment(environment) ) end + + def self.detect_environment(explicit = nil) + return normalize_environment(explicit) if explicit + + env_type = env_value("BRAINTRUST_ENVIRONMENT_TYPE") + if env_type && !env_type.empty? + env_name = env_value("BRAINTRUST_ENVIRONMENT_NAME") + return { type: env_type, name: env_name }.compact + end + + { + "GITHUB_ACTIONS" => "github_actions", + "GITLAB_CI" => "gitlab_ci", + "CIRCLECI" => "circleci", + "BUILDKITE" => "buildkite", + "JENKINS_URL" => "jenkins", + "JENKINS_HOME" => "jenkins", + "TF_BUILD" => "azure_pipelines", + "TEAMCITY_VERSION" => "teamcity", + "TRAVIS" => "travis", + "BITBUCKET_BUILD_NUMBER" => "bitbucket" + }.each do |key, name| + return { type: "ci", name: name } if process_env_value(key) + end + return { type: "ci", name: "ci" } if process_env_value("CI") + + { + "VERCEL" => "vercel", + "NETLIFY" => "netlify", + "AWS_LAMBDA_FUNCTION_NAME" => "aws_lambda", + "AWS_EXECUTION_ENV" => "aws_lambda", + "K_SERVICE" => "cloud_run", + "FUNCTION_TARGET" => "gcp_functions", + "KUBERNETES_SERVICE_HOST" => "kubernetes", + "ECS_CONTAINER_METADATA_URI" => "ecs", + "ECS_CONTAINER_METADATA_URI_V4" => "ecs", + "DYNO" => "heroku", + "FLY_APP_NAME" => "fly", + "RAILWAY_ENVIRONMENT" => "railway", + "RENDER_SERVICE_NAME" => "render" + }.each do |key, name| + return { type: "server", name: name } if process_env_value(key) + end + + deployment_mode_environment(process_env_value("RAILS_ENV")) || + deployment_mode_environment(process_env_value("RACK_ENV")) + end + + def self.normalize_environment(environment) + type = environment[:type] || environment["type"] + name = environment[:name] || environment["name"] + { type: type, name: name }.compact + end + + def self.deployment_mode_environment(value) + return nil if value.nil? || value.empty? + + normalized = value.downcase + return { type: "server", name: normalized } if ["production", "staging"].include?(normalized) + return { type: "local", name: normalized } if ["development", "local"].include?(normalized) + + nil + end + + def self.env_value(key) + value = ENV[key] + value = read_braintrust_env_file_value(key) if value.nil? || value.strip.empty? + value&.strip + end + + def self.process_env_value(key) + value = ENV[key] + value&.strip unless value.nil? || value.strip.empty? + end + + def self.read_braintrust_env_file_value(key) + dir = Dir.pwd + 65.times do + path = File.join(dir, ".env.braintrust") + if File.file?(path) + File.foreach(path) do |line| + stripped = line.strip + next if stripped.empty? || stripped.start_with?("#") + + name, value = stripped.split("=", 2) + next unless name&.strip == key + + return value&.strip&.delete_prefix('"')&.delete_suffix('"')&.delete_prefix("'")&.delete_suffix("'") + end + return nil + end + + parent = File.dirname(dir) + return nil if parent == dir + + dir = parent + end + nil + rescue + nil + end end end diff --git a/lib/braintrust/state.rb b/lib/braintrust/state.rb index f95e5fd9..defaa7e1 100644 --- a/lib/braintrust/state.rb +++ b/lib/braintrust/state.rb @@ -25,8 +25,9 @@ class MissingAPIKeyError < ArgumentError; end # @param filter_ai_spans [Boolean, nil] Enable AI span filtering # @param span_filter_funcs [Array, nil] Custom span filter functions # @param exporter [Exporter, nil] Optional exporter override (for testing) + # @param environment [Hash, nil] Span-origin environment override # @return [State] the created state - def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil, filter_ai_spans: nil, span_filter_funcs: nil, exporter: nil) + def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil, filter_ai_spans: nil, span_filter_funcs: nil, exporter: nil, environment: nil) require_relative "config" config = Config.from_env( api_key: api_key, @@ -35,7 +36,8 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni app_url: app_url, api_url: api_url, filter_ai_spans: filter_ai_spans, - span_filter_funcs: span_filter_funcs + span_filter_funcs: span_filter_funcs, + environment: environment ) new( api_key: config.api_key, diff --git a/lib/braintrust/trace/span_processor.rb b/lib/braintrust/trace/span_processor.rb index cf2ba8a9..f15fe001 100644 --- a/lib/braintrust/trace/span_processor.rb +++ b/lib/braintrust/trace/span_processor.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true +require "json" require "opentelemetry/sdk" +require_relative "../version" module Braintrust module Trace @@ -10,6 +12,9 @@ class SpanProcessor PARENT_ATTR_KEY = "braintrust.parent" ORG_ATTR_KEY = "braintrust.org" APP_URL_ATTR_KEY = "braintrust.app_url" + CONTEXT_JSON_ATTR_KEY = "braintrust.context_json" + ENVIRONMENT_TYPE_ATTR_KEY = "braintrust.environment.type" + ENVIRONMENT_NAME_ATTR_KEY = "braintrust.environment.name" def initialize(wrapped_processor, state, filters = []) @wrapped = wrapped_processor @@ -18,6 +23,8 @@ def initialize(wrapped_processor, state, filters = []) end def on_start(span, parent_context) + add_span_origin(span) + # Add default parent if span doesn't already have one has_parent = span.respond_to?(:attributes) && span.attributes&.key?(PARENT_ATTR_KEY) @@ -53,6 +60,42 @@ def force_flush(timeout: nil) private + def add_span_origin(span) + context = parse_context_json(span.respond_to?(:attributes) ? span.attributes&.[](CONTEXT_JSON_ATTR_KEY) : nil) + span_origin = context["span_origin"].is_a?(Hash) ? context["span_origin"] : {} + span_origin["name"] ||= "braintrust.sdk.ruby" + span_origin["version"] ||= Braintrust::VERSION + span_origin["instrumentation"] ||= {"name" => instrumentation_name(span)} + if @state.config&.environment && !span_origin.key?("environment") + environment = {"type" => @state.config.environment[:type]} + environment["name"] = @state.config.environment[:name] if @state.config.environment[:name] + span_origin["environment"] = environment + end + context["span_origin"] = span_origin + span.set_attribute(CONTEXT_JSON_ATTR_KEY, JSON.generate(context)) + + return unless @state.config&.environment + + span.set_attribute(ENVIRONMENT_TYPE_ATTR_KEY, @state.config.environment[:type]) + span.set_attribute(ENVIRONMENT_NAME_ATTR_KEY, @state.config.environment[:name]) if @state.config.environment[:name] + end + + def parse_context_json(raw) + return {} unless raw.is_a?(String) && !raw.strip.empty? + + parsed = JSON.parse(raw) + parsed.is_a?(Hash) ? parsed : {} + rescue JSON::ParserError + {} + end + + def instrumentation_name(span) + return span.instrumentation_scope.name if span.respond_to?(:instrumentation_scope) && span.instrumentation_scope&.respond_to?(:name) + return span.instrumentation_library.name if span.respond_to?(:instrumentation_library) && span.instrumentation_library&.respond_to?(:name) + + "braintrust-ruby" + end + def default_parent # If default_project is set, format it as "project_name:value" # The default_project should be a plain project name (e.g., "my-project") From fd65489654ab901d4bebf8c758c260cfbf7bb753 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 15:22:03 +0800 Subject: [PATCH 2/8] Fix Ruby span filter provenance handling --- gemfiles/anthropic.gemfile | 1 + gemfiles/anthropic_1_11.gemfile | 1 + gemfiles/anthropic_1_12.gemfile | 1 + gemfiles/anthropic_uninstalled.gemfile | 1 + gemfiles/contrib.gemfile | 1 + gemfiles/openai.gemfile | 1 + gemfiles/openai_0_33.gemfile | 1 + gemfiles/openai_0_34.gemfile | 1 + gemfiles/openai_ruby_openai.gemfile | 1 + gemfiles/openai_uninstalled.gemfile | 1 + gemfiles/opentelemetry_latest.gemfile | 1 + gemfiles/opentelemetry_min.gemfile | 1 + gemfiles/rails.gemfile | 1 + gemfiles/rails_server.gemfile | 1 + gemfiles/ruby_llm.gemfile | 1 + gemfiles/ruby_llm_1_8.gemfile | 1 + gemfiles/ruby_llm_1_9.gemfile | 1 + gemfiles/ruby_llm_uninstalled.gemfile | 1 + gemfiles/ruby_openai.gemfile | 1 + gemfiles/ruby_openai_7_0.gemfile | 1 + gemfiles/ruby_openai_8_0.gemfile | 1 + gemfiles/ruby_openai_uninstalled.gemfile | 1 + gemfiles/server.gemfile | 1 + lib/braintrust/config.rb | 14 +++++++------- lib/braintrust/trace/span_filter.rb | 5 ++++- 25 files changed, 34 insertions(+), 8 deletions(-) diff --git a/gemfiles/anthropic.gemfile b/gemfiles/anthropic.gemfile index 44722fda..495eed11 100644 --- a/gemfiles/anthropic.gemfile +++ b/gemfiles/anthropic.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/anthropic_1_11.gemfile b/gemfiles/anthropic_1_11.gemfile index 69c7ced7..64e7dbc3 100644 --- a/gemfiles/anthropic_1_11.gemfile +++ b/gemfiles/anthropic_1_11.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/anthropic_1_12.gemfile b/gemfiles/anthropic_1_12.gemfile index ba739117..7b80f41e 100644 --- a/gemfiles/anthropic_1_12.gemfile +++ b/gemfiles/anthropic_1_12.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/anthropic_uninstalled.gemfile b/gemfiles/anthropic_uninstalled.gemfile index 6c953f98..605f0583 100644 --- a/gemfiles/anthropic_uninstalled.gemfile +++ b/gemfiles/anthropic_uninstalled.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/contrib.gemfile b/gemfiles/contrib.gemfile index d7d9a50f..82983e3c 100644 --- a/gemfiles/contrib.gemfile +++ b/gemfiles/contrib.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/openai.gemfile b/gemfiles/openai.gemfile index a49d0436..7593c671 100644 --- a/gemfiles/openai.gemfile +++ b/gemfiles/openai.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/openai_0_33.gemfile b/gemfiles/openai_0_33.gemfile index 23380307..d2a4d7ba 100644 --- a/gemfiles/openai_0_33.gemfile +++ b/gemfiles/openai_0_33.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/openai_0_34.gemfile b/gemfiles/openai_0_34.gemfile index 764fa916..20d35e76 100644 --- a/gemfiles/openai_0_34.gemfile +++ b/gemfiles/openai_0_34.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/openai_ruby_openai.gemfile b/gemfiles/openai_ruby_openai.gemfile index 958a8aeb..5513bb33 100644 --- a/gemfiles/openai_ruby_openai.gemfile +++ b/gemfiles/openai_ruby_openai.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/openai_uninstalled.gemfile b/gemfiles/openai_uninstalled.gemfile index 6c953f98..605f0583 100644 --- a/gemfiles/openai_uninstalled.gemfile +++ b/gemfiles/openai_uninstalled.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/opentelemetry_latest.gemfile b/gemfiles/opentelemetry_latest.gemfile index bc8446f1..852ad2a5 100644 --- a/gemfiles/opentelemetry_latest.gemfile +++ b/gemfiles/opentelemetry_latest.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/opentelemetry_min.gemfile b/gemfiles/opentelemetry_min.gemfile index d11eca8b..ab502c8d 100644 --- a/gemfiles/opentelemetry_min.gemfile +++ b/gemfiles/opentelemetry_min.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/rails.gemfile b/gemfiles/rails.gemfile index ddc234c9..9f343fdd 100644 --- a/gemfiles/rails.gemfile +++ b/gemfiles/rails.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/rails_server.gemfile b/gemfiles/rails_server.gemfile index 4bfa2df0..814badaf 100644 --- a/gemfiles/rails_server.gemfile +++ b/gemfiles/rails_server.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_llm.gemfile b/gemfiles/ruby_llm.gemfile index f4431206..de569541 100644 --- a/gemfiles/ruby_llm.gemfile +++ b/gemfiles/ruby_llm.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_llm_1_8.gemfile b/gemfiles/ruby_llm_1_8.gemfile index 6288ac89..db181329 100644 --- a/gemfiles/ruby_llm_1_8.gemfile +++ b/gemfiles/ruby_llm_1_8.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_llm_1_9.gemfile b/gemfiles/ruby_llm_1_9.gemfile index 69400abc..1380ea1e 100644 --- a/gemfiles/ruby_llm_1_9.gemfile +++ b/gemfiles/ruby_llm_1_9.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_llm_uninstalled.gemfile b/gemfiles/ruby_llm_uninstalled.gemfile index 6c953f98..605f0583 100644 --- a/gemfiles/ruby_llm_uninstalled.gemfile +++ b/gemfiles/ruby_llm_uninstalled.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_openai.gemfile b/gemfiles/ruby_openai.gemfile index 18523602..54b725e1 100644 --- a/gemfiles/ruby_openai.gemfile +++ b/gemfiles/ruby_openai.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_openai_7_0.gemfile b/gemfiles/ruby_openai_7_0.gemfile index 582d736d..76676e7f 100644 --- a/gemfiles/ruby_openai_7_0.gemfile +++ b/gemfiles/ruby_openai_7_0.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_openai_8_0.gemfile b/gemfiles/ruby_openai_8_0.gemfile index ce51344f..6c2ed570 100644 --- a/gemfiles/ruby_openai_8_0.gemfile +++ b/gemfiles/ruby_openai_8_0.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_openai_uninstalled.gemfile b/gemfiles/ruby_openai_uninstalled.gemfile index 6c953f98..605f0583 100644 --- a/gemfiles/ruby_openai_uninstalled.gemfile +++ b/gemfiles/ruby_openai_uninstalled.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/server.gemfile b/gemfiles/server.gemfile index c0390122..4dbdab0c 100644 --- a/gemfiles/server.gemfile +++ b/gemfiles/server.gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" +gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/lib/braintrust/config.rb b/lib/braintrust/config.rb index 75ea1232..d431e01f 100644 --- a/lib/braintrust/config.rb +++ b/lib/braintrust/config.rb @@ -60,7 +60,7 @@ def self.detect_environment(explicit = nil) env_type = env_value("BRAINTRUST_ENVIRONMENT_TYPE") if env_type && !env_type.empty? env_name = env_value("BRAINTRUST_ENVIRONMENT_NAME") - return { type: env_type, name: env_name }.compact + return {type: env_type, name: env_name}.compact end { @@ -75,9 +75,9 @@ def self.detect_environment(explicit = nil) "TRAVIS" => "travis", "BITBUCKET_BUILD_NUMBER" => "bitbucket" }.each do |key, name| - return { type: "ci", name: name } if process_env_value(key) + return {type: "ci", name: name} if process_env_value(key) end - return { type: "ci", name: "ci" } if process_env_value("CI") + return {type: "ci", name: "ci"} if process_env_value("CI") { "VERCEL" => "vercel", @@ -94,7 +94,7 @@ def self.detect_environment(explicit = nil) "RAILWAY_ENVIRONMENT" => "railway", "RENDER_SERVICE_NAME" => "render" }.each do |key, name| - return { type: "server", name: name } if process_env_value(key) + return {type: "server", name: name} if process_env_value(key) end deployment_mode_environment(process_env_value("RAILS_ENV")) || @@ -104,15 +104,15 @@ def self.detect_environment(explicit = nil) def self.normalize_environment(environment) type = environment[:type] || environment["type"] name = environment[:name] || environment["name"] - { type: type, name: name }.compact + {type: type, name: name}.compact end def self.deployment_mode_environment(value) return nil if value.nil? || value.empty? normalized = value.downcase - return { type: "server", name: normalized } if ["production", "staging"].include?(normalized) - return { type: "local", name: normalized } if ["development", "local"].include?(normalized) + return {type: "server", name: normalized} if ["production", "staging"].include?(normalized) + return {type: "local", name: normalized} if ["development", "local"].include?(normalized) nil end diff --git a/lib/braintrust/trace/span_filter.rb b/lib/braintrust/trace/span_filter.rb index 93887787..705838fa 100644 --- a/lib/braintrust/trace/span_filter.rb +++ b/lib/braintrust/trace/span_filter.rb @@ -16,7 +16,10 @@ module SpanFilter SYSTEM_ATTRIBUTES = [ "braintrust.parent", "braintrust.org", - "braintrust.app_url" + "braintrust.app_url", + "braintrust.context_json", + "braintrust.environment.type", + "braintrust.environment.name" ].freeze # Prefixes that indicate an AI-related span From 4c2566618aecf00cc9958b10e4e837bf2b7cb9da Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 16:36:48 +0800 Subject: [PATCH 3/8] Make Ruby config environment helpers private --- lib/braintrust/config.rb | 3 +++ test/braintrust/config_test.rb | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/lib/braintrust/config.rb b/lib/braintrust/config.rb index d431e01f..140ab082 100644 --- a/lib/braintrust/config.rb +++ b/lib/braintrust/config.rb @@ -154,5 +154,8 @@ def self.read_braintrust_env_file_value(key) rescue nil end + + private_class_method :detect_environment, :normalize_environment, :deployment_mode_environment, + :env_value, :process_env_value, :read_braintrust_env_file_value end end diff --git a/test/braintrust/config_test.rb b/test/braintrust/config_test.rb index c222e822..9d4d3423 100644 --- a/test/braintrust/config_test.rb +++ b/test/braintrust/config_test.rb @@ -49,6 +49,22 @@ def test_provides_default_values assert_equal "https://api.braintrust.dev", config.api_url end + def test_environment_helpers_are_private_class_methods + assert_includes Braintrust::Config.methods, :from_env + + [ + :detect_environment, + :normalize_environment, + :deployment_mode_environment, + :env_value, + :process_env_value, + :read_braintrust_env_file_value + ].each do |helper| + refute_includes Braintrust::Config.methods, helper + assert_includes Braintrust::Config.private_methods, helper + end + end + def test_passed_options_override_env_vars ENV["BRAINTRUST_API_KEY"] = "env-key" ENV["BRAINTRUST_ORG_NAME"] = "env-org" From a256968412a5a5266e976764f4188408df2a5790 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 17:49:40 +0800 Subject: [PATCH 4/8] Fix Ruby span origin environment handling --- gemfiles/anthropic.gemfile | 1 - gemfiles/anthropic_1_11.gemfile | 1 - gemfiles/anthropic_1_12.gemfile | 1 - gemfiles/anthropic_uninstalled.gemfile | 1 - gemfiles/contrib.gemfile | 1 - gemfiles/openai.gemfile | 1 - gemfiles/openai_0_33.gemfile | 1 - gemfiles/openai_0_34.gemfile | 1 - gemfiles/openai_ruby_openai.gemfile | 1 - gemfiles/openai_uninstalled.gemfile | 1 - gemfiles/opentelemetry_latest.gemfile | 1 - gemfiles/opentelemetry_min.gemfile | 1 - gemfiles/rails.gemfile | 1 - gemfiles/rails_server.gemfile | 1 - gemfiles/ruby_llm.gemfile | 1 - gemfiles/ruby_llm_1_8.gemfile | 1 - gemfiles/ruby_llm_1_9.gemfile | 1 - gemfiles/ruby_llm_uninstalled.gemfile | 1 - gemfiles/ruby_openai.gemfile | 1 - gemfiles/ruby_openai_7_0.gemfile | 1 - gemfiles/ruby_openai_8_0.gemfile | 1 - gemfiles/ruby_openai_uninstalled.gemfile | 1 - gemfiles/server.gemfile | 1 - lib/braintrust/config.rb | 47 ++++++++++++-------- lib/braintrust/trace/span_processor.rb | 4 +- test/braintrust/config_test.rb | 37 +++++++++++++++ test/braintrust/trace/span_processor_test.rb | 22 +++++++++ 27 files changed, 90 insertions(+), 43 deletions(-) diff --git a/gemfiles/anthropic.gemfile b/gemfiles/anthropic.gemfile index 495eed11..44722fda 100644 --- a/gemfiles/anthropic.gemfile +++ b/gemfiles/anthropic.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/anthropic_1_11.gemfile b/gemfiles/anthropic_1_11.gemfile index 64e7dbc3..69c7ced7 100644 --- a/gemfiles/anthropic_1_11.gemfile +++ b/gemfiles/anthropic_1_11.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/anthropic_1_12.gemfile b/gemfiles/anthropic_1_12.gemfile index 7b80f41e..ba739117 100644 --- a/gemfiles/anthropic_1_12.gemfile +++ b/gemfiles/anthropic_1_12.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/anthropic_uninstalled.gemfile b/gemfiles/anthropic_uninstalled.gemfile index 605f0583..6c953f98 100644 --- a/gemfiles/anthropic_uninstalled.gemfile +++ b/gemfiles/anthropic_uninstalled.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/contrib.gemfile b/gemfiles/contrib.gemfile index 82983e3c..d7d9a50f 100644 --- a/gemfiles/contrib.gemfile +++ b/gemfiles/contrib.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/openai.gemfile b/gemfiles/openai.gemfile index 7593c671..a49d0436 100644 --- a/gemfiles/openai.gemfile +++ b/gemfiles/openai.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/openai_0_33.gemfile b/gemfiles/openai_0_33.gemfile index d2a4d7ba..23380307 100644 --- a/gemfiles/openai_0_33.gemfile +++ b/gemfiles/openai_0_33.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/openai_0_34.gemfile b/gemfiles/openai_0_34.gemfile index 20d35e76..764fa916 100644 --- a/gemfiles/openai_0_34.gemfile +++ b/gemfiles/openai_0_34.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/openai_ruby_openai.gemfile b/gemfiles/openai_ruby_openai.gemfile index 5513bb33..958a8aeb 100644 --- a/gemfiles/openai_ruby_openai.gemfile +++ b/gemfiles/openai_ruby_openai.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/openai_uninstalled.gemfile b/gemfiles/openai_uninstalled.gemfile index 605f0583..6c953f98 100644 --- a/gemfiles/openai_uninstalled.gemfile +++ b/gemfiles/openai_uninstalled.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/opentelemetry_latest.gemfile b/gemfiles/opentelemetry_latest.gemfile index 852ad2a5..bc8446f1 100644 --- a/gemfiles/opentelemetry_latest.gemfile +++ b/gemfiles/opentelemetry_latest.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/opentelemetry_min.gemfile b/gemfiles/opentelemetry_min.gemfile index ab502c8d..d11eca8b 100644 --- a/gemfiles/opentelemetry_min.gemfile +++ b/gemfiles/opentelemetry_min.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/rails.gemfile b/gemfiles/rails.gemfile index 9f343fdd..ddc234c9 100644 --- a/gemfiles/rails.gemfile +++ b/gemfiles/rails.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/rails_server.gemfile b/gemfiles/rails_server.gemfile index 814badaf..4bfa2df0 100644 --- a/gemfiles/rails_server.gemfile +++ b/gemfiles/rails_server.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_llm.gemfile b/gemfiles/ruby_llm.gemfile index de569541..f4431206 100644 --- a/gemfiles/ruby_llm.gemfile +++ b/gemfiles/ruby_llm.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_llm_1_8.gemfile b/gemfiles/ruby_llm_1_8.gemfile index db181329..6288ac89 100644 --- a/gemfiles/ruby_llm_1_8.gemfile +++ b/gemfiles/ruby_llm_1_8.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_llm_1_9.gemfile b/gemfiles/ruby_llm_1_9.gemfile index 1380ea1e..69400abc 100644 --- a/gemfiles/ruby_llm_1_9.gemfile +++ b/gemfiles/ruby_llm_1_9.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_llm_uninstalled.gemfile b/gemfiles/ruby_llm_uninstalled.gemfile index 605f0583..6c953f98 100644 --- a/gemfiles/ruby_llm_uninstalled.gemfile +++ b/gemfiles/ruby_llm_uninstalled.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_openai.gemfile b/gemfiles/ruby_openai.gemfile index 54b725e1..18523602 100644 --- a/gemfiles/ruby_openai.gemfile +++ b/gemfiles/ruby_openai.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_openai_7_0.gemfile b/gemfiles/ruby_openai_7_0.gemfile index 76676e7f..582d736d 100644 --- a/gemfiles/ruby_openai_7_0.gemfile +++ b/gemfiles/ruby_openai_7_0.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_openai_8_0.gemfile b/gemfiles/ruby_openai_8_0.gemfile index 6c2ed570..ce51344f 100644 --- a/gemfiles/ruby_openai_8_0.gemfile +++ b/gemfiles/ruby_openai_8_0.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/ruby_openai_uninstalled.gemfile b/gemfiles/ruby_openai_uninstalled.gemfile index 605f0583..6c953f98 100644 --- a/gemfiles/ruby_openai_uninstalled.gemfile +++ b/gemfiles/ruby_openai_uninstalled.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/gemfiles/server.gemfile b/gemfiles/server.gemfile index 4dbdab0c..c0390122 100644 --- a/gemfiles/server.gemfile +++ b/gemfiles/server.gemfile @@ -2,7 +2,6 @@ source "https://rubygems.org" -gem "openssl", "4.0.0" gem "appraisal", "~> 2.5" gem "climate_control", "~> 1.2" gem "kramdown", "~> 2.0" diff --git a/lib/braintrust/config.rb b/lib/braintrust/config.rb index 140ab082..193b5521 100644 --- a/lib/braintrust/config.rb +++ b/lib/braintrust/config.rb @@ -79,23 +79,8 @@ def self.detect_environment(explicit = nil) end return {type: "ci", name: "ci"} if process_env_value("CI") - { - "VERCEL" => "vercel", - "NETLIFY" => "netlify", - "AWS_LAMBDA_FUNCTION_NAME" => "aws_lambda", - "AWS_EXECUTION_ENV" => "aws_lambda", - "K_SERVICE" => "cloud_run", - "FUNCTION_TARGET" => "gcp_functions", - "KUBERNETES_SERVICE_HOST" => "kubernetes", - "ECS_CONTAINER_METADATA_URI" => "ecs", - "ECS_CONTAINER_METADATA_URI_V4" => "ecs", - "DYNO" => "heroku", - "FLY_APP_NAME" => "fly", - "RAILWAY_ENVIRONMENT" => "railway", - "RENDER_SERVICE_NAME" => "render" - }.each do |key, name| - return {type: "server", name: name} if process_env_value(key) - end + server_name = detect_server_environment_name + return {type: "server", name: server_name} if server_name deployment_mode_environment(process_env_value("RAILS_ENV")) || deployment_mode_environment(process_env_value("RACK_ENV")) @@ -117,6 +102,31 @@ def self.deployment_mode_environment(value) nil end + def self.detect_server_environment_name + {"VERCEL" => "vercel", "NETLIFY" => "netlify"}.each do |key, name| + return name if process_env_value(key) + end + return "ecs" if process_env_value("ECS_CONTAINER_METADATA_URI") || process_env_value("ECS_CONTAINER_METADATA_URI_V4") + + aws_execution_env = process_env_value("AWS_EXECUTION_ENV") + return "ecs" if aws_execution_env&.start_with?("AWS_ECS_") + return "aws_lambda" if aws_execution_env&.start_with?("AWS_Lambda_") + return "aws_lambda" if process_env_value("AWS_LAMBDA_FUNCTION_NAME") + + { + "K_SERVICE" => "cloud_run", + "FUNCTION_TARGET" => "gcp_functions", + "KUBERNETES_SERVICE_HOST" => "kubernetes", + "DYNO" => "heroku", + "FLY_APP_NAME" => "fly", + "RAILWAY_ENVIRONMENT" => "railway", + "RENDER_SERVICE_NAME" => "render" + }.each do |key, name| + return name if process_env_value(key) + end + nil + end + def self.env_value(key) value = ENV[key] value = read_braintrust_env_file_value(key) if value.nil? || value.strip.empty? @@ -156,6 +166,7 @@ def self.read_braintrust_env_file_value(key) end private_class_method :detect_environment, :normalize_environment, :deployment_mode_environment, - :env_value, :process_env_value, :read_braintrust_env_file_value + :detect_server_environment_name, :env_value, :process_env_value, + :read_braintrust_env_file_value end end diff --git a/lib/braintrust/trace/span_processor.rb b/lib/braintrust/trace/span_processor.rb index f15fe001..7c9309f6 100644 --- a/lib/braintrust/trace/span_processor.rb +++ b/lib/braintrust/trace/span_processor.rb @@ -23,8 +23,6 @@ def initialize(wrapped_processor, state, filters = []) end def on_start(span, parent_context) - add_span_origin(span) - # Add default parent if span doesn't already have one has_parent = span.respond_to?(:attributes) && span.attributes&.key?(PARENT_ATTR_KEY) @@ -44,6 +42,8 @@ def on_start(span, parent_context) # Called when a span ends - apply filters before forwarding def on_finish(span) + add_span_origin(span) + # Only forward span if it passes filters @wrapped.on_finish(span) if should_forward_span?(span) end diff --git a/test/braintrust/config_test.rb b/test/braintrust/config_test.rb index 9d4d3423..b8504ef8 100644 --- a/test/braintrust/config_test.rb +++ b/test/braintrust/config_test.rb @@ -56,6 +56,7 @@ def test_environment_helpers_are_private_class_methods :detect_environment, :normalize_environment, :deployment_mode_environment, + :detect_server_environment_name, :env_value, :process_env_value, :read_braintrust_env_file_value @@ -86,6 +87,30 @@ def test_env_vars_override_defaults assert_equal "https://custom.braintrust.dev", config.app_url end + def test_aws_execution_env_classifies_ecs_before_lambda + with_env( + "CI" => nil, + "GITHUB_ACTIONS" => nil, + "AWS_EXECUTION_ENV" => "AWS_ECS_FARGATE" + ) do + config = Braintrust::Config.from_env + + assert_equal({type: "server", name: "ecs"}, config.environment) + end + end + + def test_aws_execution_env_classifies_lambda_when_lambda_specific + with_env( + "CI" => nil, + "GITHUB_ACTIONS" => nil, + "AWS_EXECUTION_ENV" => "AWS_Lambda_ruby3.2" + ) do + config = Braintrust::Config.from_env + + assert_equal({type: "server", name: "aws_lambda"}, config.environment) + end + end + def test_falls_back_to_braintrust_json_file write_braintrust_config({"BRAINTRUST_API_KEY" => "file-key"}) @@ -230,6 +255,18 @@ def test_does_not_mutate_process_env private + def with_env(values) + original = values.keys.to_h { |key| [key, ENV[key]] } + values.each do |key, value| + value.nil? ? ENV.delete(key) : ENV[key] = value + end + yield + ensure + original.each do |key, value| + value.nil? ? ENV.delete(key) : ENV[key] = value + end + end + def write_braintrust_config(contents, dir: @tmpdir) File.write(File.join(dir, ".braintrust.json"), JSON.generate(contents)) end diff --git a/test/braintrust/trace/span_processor_test.rb b/test/braintrust/trace/span_processor_test.rb index 1f6fd0a3..94f8205c 100644 --- a/test/braintrust/trace/span_processor_test.rb +++ b/test/braintrust/trace/span_processor_test.rb @@ -97,6 +97,28 @@ def test_adds_app_url_attribute wrapped.verify end + def test_span_origin_merges_with_context_json_set_after_start + wrapped = Minitest::Mock.new + wrapped.expect(:on_start, nil, [Object, Object]) + wrapped.expect(:on_finish, nil, [Object]) + + processor = Braintrust::Trace::SpanProcessor.new(wrapped, @state) + + tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new + tracer = tracer_provider.tracer("test") + span = tracer.start_span("test-span") + + processor.on_start(span, OpenTelemetry::Context.empty) + span.set_attribute("braintrust.context_json", JSON.generate({"metadata" => {"source" => "late-attribute"}})) + processor.on_finish(span) + + context = JSON.parse(span.attributes["braintrust.context_json"]) + assert_equal "late-attribute", context.dig("metadata", "source") + assert_equal "braintrust.sdk.ruby", context.dig("span_origin", "name") + + wrapped.verify + end + def test_span_processor_enables_permalink_generation # This test verifies that spans processed by SpanProcessor have all attributes needed for permalinks # Create a mock wrapped processor From 458297d256f6c9119a08bb1adf5f4cba01be7fa0 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 18:22:14 +0800 Subject: [PATCH 5/8] Preserve Ruby span origin at export --- lib/braintrust/trace/span_processor.rb | 45 +++++++++++++++++--- test/braintrust/trace/span_processor_test.rb | 17 +++++--- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/lib/braintrust/trace/span_processor.rb b/lib/braintrust/trace/span_processor.rb index 7c9309f6..4d6c823c 100644 --- a/lib/braintrust/trace/span_processor.rb +++ b/lib/braintrust/trace/span_processor.rb @@ -42,7 +42,7 @@ def on_start(span, parent_context) # Called when a span ends - apply filters before forwarding def on_finish(span) - add_span_origin(span) + span = span_with_origin(span) # Only forward span if it passes filters @wrapped.on_finish(span) if should_forward_span?(span) @@ -60,8 +60,37 @@ def force_flush(timeout: nil) private - def add_span_origin(span) - context = parse_context_json(span.respond_to?(:attributes) ? span.attributes&.[](CONTEXT_JSON_ATTR_KEY) : nil) + class FinishedSpanWithAttributes + def initialize(span, attributes) + @span = span + @attributes = attributes.freeze + end + + attr_reader :attributes + + def to_span_data + span_data = @span.to_span_data.dup + span_data.attributes = @attributes + span_data.total_recorded_attributes = @attributes.length + span_data + end + + def respond_to_missing?(name, include_private = false) + @span.respond_to?(name, include_private) || super + end + + def method_missing(name, *args, &block) + return @span.public_send(name, *args, &block) if @span.respond_to?(name) + + super + end + end + + private_constant :FinishedSpanWithAttributes + + def span_with_origin(span) + attributes = span.respond_to?(:attributes) ? (span.attributes || {}).dup : {} + context = parse_context_json(attributes[CONTEXT_JSON_ATTR_KEY]) span_origin = context["span_origin"].is_a?(Hash) ? context["span_origin"] : {} span_origin["name"] ||= "braintrust.sdk.ruby" span_origin["version"] ||= Braintrust::VERSION @@ -72,12 +101,14 @@ def add_span_origin(span) span_origin["environment"] = environment end context["span_origin"] = span_origin - span.set_attribute(CONTEXT_JSON_ATTR_KEY, JSON.generate(context)) + attributes[CONTEXT_JSON_ATTR_KEY] = JSON.generate(context) - return unless @state.config&.environment + if @state.config&.environment + attributes[ENVIRONMENT_TYPE_ATTR_KEY] = @state.config.environment[:type] + attributes[ENVIRONMENT_NAME_ATTR_KEY] = @state.config.environment[:name] if @state.config.environment[:name] + end - span.set_attribute(ENVIRONMENT_TYPE_ATTR_KEY, @state.config.environment[:type]) - span.set_attribute(ENVIRONMENT_NAME_ATTR_KEY, @state.config.environment[:name]) if @state.config.environment[:name] + FinishedSpanWithAttributes.new(span, attributes) end def parse_context_json(raw) diff --git a/test/braintrust/trace/span_processor_test.rb b/test/braintrust/trace/span_processor_test.rb index 94f8205c..81c33700 100644 --- a/test/braintrust/trace/span_processor_test.rb +++ b/test/braintrust/trace/span_processor_test.rb @@ -98,9 +98,16 @@ def test_adds_app_url_attribute end def test_span_origin_merges_with_context_json_set_after_start - wrapped = Minitest::Mock.new - wrapped.expect(:on_start, nil, [Object, Object]) - wrapped.expect(:on_finish, nil, [Object]) + wrapped = Class.new do + attr_reader :finished_span + + def on_start(_span, _parent_context) + end + + def on_finish(span) + @finished_span = span + end + end.new processor = Braintrust::Trace::SpanProcessor.new(wrapped, @state) @@ -112,11 +119,9 @@ def test_span_origin_merges_with_context_json_set_after_start span.set_attribute("braintrust.context_json", JSON.generate({"metadata" => {"source" => "late-attribute"}})) processor.on_finish(span) - context = JSON.parse(span.attributes["braintrust.context_json"]) + context = JSON.parse(wrapped.finished_span.attributes["braintrust.context_json"]) assert_equal "late-attribute", context.dig("metadata", "source") assert_equal "braintrust.sdk.ruby", context.dig("span_origin", "name") - - wrapped.verify end def test_span_processor_enables_permalink_generation From cef18a76ceb1af4f5173ff4faf060b7de3e35a35 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 20:08:39 +0800 Subject: [PATCH 6/8] fix: keep span origin environment in context --- lib/braintrust/trace/span_filter.rb | 4 +--- lib/braintrust/trace/span_processor.rb | 7 ------- test/braintrust/trace/span_processor_test.rb | 2 ++ 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/lib/braintrust/trace/span_filter.rb b/lib/braintrust/trace/span_filter.rb index 705838fa..8b173891 100644 --- a/lib/braintrust/trace/span_filter.rb +++ b/lib/braintrust/trace/span_filter.rb @@ -17,9 +17,7 @@ module SpanFilter "braintrust.parent", "braintrust.org", "braintrust.app_url", - "braintrust.context_json", - "braintrust.environment.type", - "braintrust.environment.name" + "braintrust.context_json" ].freeze # Prefixes that indicate an AI-related span diff --git a/lib/braintrust/trace/span_processor.rb b/lib/braintrust/trace/span_processor.rb index 4d6c823c..c4e45452 100644 --- a/lib/braintrust/trace/span_processor.rb +++ b/lib/braintrust/trace/span_processor.rb @@ -13,8 +13,6 @@ class SpanProcessor ORG_ATTR_KEY = "braintrust.org" APP_URL_ATTR_KEY = "braintrust.app_url" CONTEXT_JSON_ATTR_KEY = "braintrust.context_json" - ENVIRONMENT_TYPE_ATTR_KEY = "braintrust.environment.type" - ENVIRONMENT_NAME_ATTR_KEY = "braintrust.environment.name" def initialize(wrapped_processor, state, filters = []) @wrapped = wrapped_processor @@ -103,11 +101,6 @@ def span_with_origin(span) context["span_origin"] = span_origin attributes[CONTEXT_JSON_ATTR_KEY] = JSON.generate(context) - if @state.config&.environment - attributes[ENVIRONMENT_TYPE_ATTR_KEY] = @state.config.environment[:type] - attributes[ENVIRONMENT_NAME_ATTR_KEY] = @state.config.environment[:name] if @state.config.environment[:name] - end - FinishedSpanWithAttributes.new(span, attributes) end diff --git a/test/braintrust/trace/span_processor_test.rb b/test/braintrust/trace/span_processor_test.rb index 81c33700..e784cb18 100644 --- a/test/braintrust/trace/span_processor_test.rb +++ b/test/braintrust/trace/span_processor_test.rb @@ -122,6 +122,8 @@ def on_finish(span) context = JSON.parse(wrapped.finished_span.attributes["braintrust.context_json"]) assert_equal "late-attribute", context.dig("metadata", "source") assert_equal "braintrust.sdk.ruby", context.dig("span_origin", "name") + refute_includes wrapped.finished_span.attributes, "braintrust.environment.type" + refute_includes wrapped.finished_span.attributes, "braintrust.environment.name" end def test_span_processor_enables_permalink_generation From ecdb7e40e0be3290108bf9d12350f6330d65a5f0 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 22:50:43 +0800 Subject: [PATCH 7/8] fix: preserve span origin environment name --- lib/braintrust/config.rb | 4 ++-- test/braintrust/config_test.rb | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/braintrust/config.rb b/lib/braintrust/config.rb index 193b5521..f95728a5 100644 --- a/lib/braintrust/config.rb +++ b/lib/braintrust/config.rb @@ -58,8 +58,8 @@ def self.detect_environment(explicit = nil) return normalize_environment(explicit) if explicit env_type = env_value("BRAINTRUST_ENVIRONMENT_TYPE") - if env_type && !env_type.empty? - env_name = env_value("BRAINTRUST_ENVIRONMENT_NAME") + env_name = env_value("BRAINTRUST_ENVIRONMENT_NAME") + if (env_type && !env_type.empty?) || (env_name && !env_name.empty?) return {type: env_type, name: env_name}.compact end diff --git a/test/braintrust/config_test.rb b/test/braintrust/config_test.rb index b8504ef8..bf79eb39 100644 --- a/test/braintrust/config_test.rb +++ b/test/braintrust/config_test.rb @@ -99,6 +99,19 @@ def test_aws_execution_env_classifies_ecs_before_lambda end end + def test_explicit_environment_name_without_type_is_preserved + with_env( + "CI" => nil, + "GITHUB_ACTIONS" => nil, + "BRAINTRUST_ENVIRONMENT_TYPE" => nil, + "BRAINTRUST_ENVIRONMENT_NAME" => "staging" + ) do + config = Braintrust::Config.from_env + + assert_equal({name: "staging"}, config.environment) + end + end + def test_aws_execution_env_classifies_lambda_when_lambda_specific with_env( "CI" => nil, From 56ff070cd0a21713f0b1a163cd2573d8858a2b22 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 21 Jul 2026 23:31:13 +0800 Subject: [PATCH 8/8] Address span origin review feedback --- lib/braintrust.rb | 4 +- lib/braintrust/config.rb | 121 +------------------ lib/braintrust/internal/env.rb | 81 +++++++++++++ lib/braintrust/state.rb | 6 +- lib/braintrust/trace/span_origin.rb | 83 +++++++++++++ lib/braintrust/trace/span_processor.rb | 83 +++---------- test/braintrust/config_test.rb | 66 ---------- test/braintrust/internal/env_test.rb | 79 ++++++++++++ test/braintrust/trace/span_processor_test.rb | 67 +++++++++- 9 files changed, 327 insertions(+), 263 deletions(-) create mode 100644 lib/braintrust/trace/span_origin.rb create mode 100644 test/braintrust/internal/env_test.rb diff --git a/lib/braintrust.rb b/lib/braintrust.rb index b0ddeae5..496fc226 100644 --- a/lib/braintrust.rb +++ b/lib/braintrust.rb @@ -43,14 +43,13 @@ class Error < StandardError; end # @param filter_ai_spans [Boolean, nil] Enable AI span filtering (overrides BRAINTRUST_OTEL_FILTER_AI_SPANS env var) # @param span_filter_funcs [Array, nil] Custom span filter functions # @param exporter [Exporter, nil] Optional exporter override (for testing) - # @param environment [Hash, nil] Span-origin environment override, e.g. { type: "ci", name: "github_actions" } # @param auto_instrument [Boolean, Hash, nil] Auto-instrumentation config: # - nil (default): use BRAINTRUST_AUTO_INSTRUMENT env var, default true if not set # - true: explicitly enable # - false: explicitly disable # - Hash with :only or :except keys for filtering # @return [State] the created state - def self.init(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, set_global: true, blocking_login: false, enable_tracing: true, tracer_provider: nil, filter_ai_spans: nil, span_filter_funcs: nil, exporter: nil, auto_instrument: nil, environment: nil) + def self.init(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, set_global: true, blocking_login: false, enable_tracing: true, tracer_provider: nil, filter_ai_spans: nil, span_filter_funcs: nil, exporter: nil, auto_instrument: nil) state = State.from_env( api_key: api_key, org_name: org_name, @@ -62,7 +61,6 @@ def self.init(api_key: nil, org_name: nil, default_project: nil, app_url: nil, a tracer_provider: tracer_provider, filter_ai_spans: filter_ai_spans, span_filter_funcs: span_filter_funcs, - environment: environment, exporter: exporter ) diff --git a/lib/braintrust/config.rb b/lib/braintrust/config.rb index f95728a5..9ee53439 100644 --- a/lib/braintrust/config.rb +++ b/lib/braintrust/config.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "internal/api_key_resolver" +require_relative "internal/env" module Braintrust # Configuration object that reads from environment variables @@ -30,10 +31,9 @@ def initialize(api_key: nil, org_name: nil, default_project: nil, app_url: nil, # @param api_url [String, nil] API URL (overrides BRAINTRUST_API_URL env var) # @param filter_ai_spans [Boolean, nil] Enable AI span filtering (overrides BRAINTRUST_OTEL_FILTER_AI_SPANS env var) # @param span_filter_funcs [Array, nil] Custom span filter functions - # @param environment [Hash, nil] Span-origin environment override, e.g. { type: "ci", name: "github_actions" } # @return [Config] the created config def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, - filter_ai_spans: nil, span_filter_funcs: nil, environment: nil) + filter_ai_spans: nil, span_filter_funcs: nil) # Parse filter_ai_spans from ENV if not explicitly provided env_filter_ai_spans = ENV["BRAINTRUST_OTEL_FILTER_AI_SPANS"] filter_ai_spans_value = if filter_ai_spans.nil? @@ -50,123 +50,8 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni api_url: api_url || ENV["BRAINTRUST_API_URL"] || "https://api.braintrust.dev", filter_ai_spans: filter_ai_spans_value, span_filter_funcs: span_filter_funcs, - environment: detect_environment(environment) + environment: Internal::Env.detect_environment ) end - - def self.detect_environment(explicit = nil) - return normalize_environment(explicit) if explicit - - env_type = env_value("BRAINTRUST_ENVIRONMENT_TYPE") - env_name = env_value("BRAINTRUST_ENVIRONMENT_NAME") - if (env_type && !env_type.empty?) || (env_name && !env_name.empty?) - return {type: env_type, name: env_name}.compact - end - - { - "GITHUB_ACTIONS" => "github_actions", - "GITLAB_CI" => "gitlab_ci", - "CIRCLECI" => "circleci", - "BUILDKITE" => "buildkite", - "JENKINS_URL" => "jenkins", - "JENKINS_HOME" => "jenkins", - "TF_BUILD" => "azure_pipelines", - "TEAMCITY_VERSION" => "teamcity", - "TRAVIS" => "travis", - "BITBUCKET_BUILD_NUMBER" => "bitbucket" - }.each do |key, name| - return {type: "ci", name: name} if process_env_value(key) - end - return {type: "ci", name: "ci"} if process_env_value("CI") - - server_name = detect_server_environment_name - return {type: "server", name: server_name} if server_name - - deployment_mode_environment(process_env_value("RAILS_ENV")) || - deployment_mode_environment(process_env_value("RACK_ENV")) - end - - def self.normalize_environment(environment) - type = environment[:type] || environment["type"] - name = environment[:name] || environment["name"] - {type: type, name: name}.compact - end - - def self.deployment_mode_environment(value) - return nil if value.nil? || value.empty? - - normalized = value.downcase - return {type: "server", name: normalized} if ["production", "staging"].include?(normalized) - return {type: "local", name: normalized} if ["development", "local"].include?(normalized) - - nil - end - - def self.detect_server_environment_name - {"VERCEL" => "vercel", "NETLIFY" => "netlify"}.each do |key, name| - return name if process_env_value(key) - end - return "ecs" if process_env_value("ECS_CONTAINER_METADATA_URI") || process_env_value("ECS_CONTAINER_METADATA_URI_V4") - - aws_execution_env = process_env_value("AWS_EXECUTION_ENV") - return "ecs" if aws_execution_env&.start_with?("AWS_ECS_") - return "aws_lambda" if aws_execution_env&.start_with?("AWS_Lambda_") - return "aws_lambda" if process_env_value("AWS_LAMBDA_FUNCTION_NAME") - - { - "K_SERVICE" => "cloud_run", - "FUNCTION_TARGET" => "gcp_functions", - "KUBERNETES_SERVICE_HOST" => "kubernetes", - "DYNO" => "heroku", - "FLY_APP_NAME" => "fly", - "RAILWAY_ENVIRONMENT" => "railway", - "RENDER_SERVICE_NAME" => "render" - }.each do |key, name| - return name if process_env_value(key) - end - nil - end - - def self.env_value(key) - value = ENV[key] - value = read_braintrust_env_file_value(key) if value.nil? || value.strip.empty? - value&.strip - end - - def self.process_env_value(key) - value = ENV[key] - value&.strip unless value.nil? || value.strip.empty? - end - - def self.read_braintrust_env_file_value(key) - dir = Dir.pwd - 65.times do - path = File.join(dir, ".env.braintrust") - if File.file?(path) - File.foreach(path) do |line| - stripped = line.strip - next if stripped.empty? || stripped.start_with?("#") - - name, value = stripped.split("=", 2) - next unless name&.strip == key - - return value&.strip&.delete_prefix('"')&.delete_suffix('"')&.delete_prefix("'")&.delete_suffix("'") - end - return nil - end - - parent = File.dirname(dir) - return nil if parent == dir - - dir = parent - end - nil - rescue - nil - end - - private_class_method :detect_environment, :normalize_environment, :deployment_mode_environment, - :detect_server_environment_name, :env_value, :process_env_value, - :read_braintrust_env_file_value end end diff --git a/lib/braintrust/internal/env.rb b/lib/braintrust/internal/env.rb index 8634bd37..f32cea31 100644 --- a/lib/braintrust/internal/env.rb +++ b/lib/braintrust/internal/env.rb @@ -5,6 +5,8 @@ module Internal # Environment variable utilities. module Env ENV_AUTO_INSTRUMENT = "BRAINTRUST_AUTO_INSTRUMENT" + ENV_ENVIRONMENT_NAME = "BRAINTRUST_ENVIRONMENT_NAME" + ENV_ENVIRONMENT_TYPE = "BRAINTRUST_ENVIRONMENT_TYPE" ENV_INSTRUMENT_EXCEPT = "BRAINTRUST_INSTRUMENT_EXCEPT" ENV_INSTRUMENT_ONLY = "BRAINTRUST_INSTRUMENT_ONLY" ENV_FLUSH_ON_EXIT = "BRAINTRUST_FLUSH_ON_EXIT" @@ -26,6 +28,36 @@ def self.instrument_only parse_list(ENV_INSTRUMENT_ONLY) end + def self.detect_environment + env_type = env_value(ENV_ENVIRONMENT_TYPE) + env_name = env_value(ENV_ENVIRONMENT_NAME) + if present?(env_type) || present?(env_name) + return {type: env_type, name: env_name}.compact + end + + { + "GITHUB_ACTIONS" => "github_actions", + "GITLAB_CI" => "gitlab_ci", + "CIRCLECI" => "circleci", + "BUILDKITE" => "buildkite", + "JENKINS_URL" => "jenkins", + "JENKINS_HOME" => "jenkins", + "TF_BUILD" => "azure_pipelines", + "TEAMCITY_VERSION" => "teamcity", + "TRAVIS" => "travis", + "BITBUCKET_BUILD_NUMBER" => "bitbucket" + }.each do |key, name| + return {type: "ci", name: name} if present?(ENV[key]) + end + return {type: "ci", name: "ci"} if present?(ENV["CI"]) + + server_name = detect_server_environment_name + return {type: "server", name: server_name} if server_name + + deployment_mode_environment(ENV["RAILS_ENV"]) || + deployment_mode_environment(ENV["RACK_ENV"]) + end + # Parse a comma-separated environment variable into an array of symbols. # @param key [String] The environment variable name # @return [Array, nil] Array of symbols, or nil if not set @@ -34,6 +66,55 @@ def self.parse_list(key) return nil unless value value.split(",").map(&:strip).map(&:to_sym) end + + def self.deployment_mode_environment(value) + return nil unless present?(value) + + normalized = value.strip.downcase + return {type: "server", name: normalized} if ["production", "staging"].include?(normalized) + return {type: "local", name: normalized} if ["development", "local"].include?(normalized) + + nil + end + private_class_method :deployment_mode_environment + + def self.detect_server_environment_name + {"VERCEL" => "vercel", "NETLIFY" => "netlify"}.each do |key, name| + return name if present?(ENV[key]) + end + return "ecs" if present?(ENV["ECS_CONTAINER_METADATA_URI"]) || present?(ENV["ECS_CONTAINER_METADATA_URI_V4"]) + + aws_execution_env = env_value("AWS_EXECUTION_ENV") + return "ecs" if aws_execution_env&.start_with?("AWS_ECS_") + return "aws_lambda" if aws_execution_env&.start_with?("AWS_Lambda_") + return "aws_lambda" if present?(ENV["AWS_LAMBDA_FUNCTION_NAME"]) + + { + "K_SERVICE" => "cloud_run", + "FUNCTION_TARGET" => "gcp_functions", + "KUBERNETES_SERVICE_HOST" => "kubernetes", + "DYNO" => "heroku", + "FLY_APP_NAME" => "fly", + "RAILWAY_ENVIRONMENT" => "railway", + "RENDER_SERVICE_NAME" => "render" + }.each do |key, name| + return name if present?(ENV[key]) + end + + nil + end + private_class_method :detect_server_environment_name + + def self.env_value(key) + value = ENV[key] + value&.strip unless value.nil? || value.strip.empty? + end + private_class_method :env_value + + def self.present?(value) + !value.nil? && !value.strip.empty? + end + private_class_method :present? end end end diff --git a/lib/braintrust/state.rb b/lib/braintrust/state.rb index defaa7e1..f95e5fd9 100644 --- a/lib/braintrust/state.rb +++ b/lib/braintrust/state.rb @@ -25,9 +25,8 @@ class MissingAPIKeyError < ArgumentError; end # @param filter_ai_spans [Boolean, nil] Enable AI span filtering # @param span_filter_funcs [Array, nil] Custom span filter functions # @param exporter [Exporter, nil] Optional exporter override (for testing) - # @param environment [Hash, nil] Span-origin environment override # @return [State] the created state - def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil, filter_ai_spans: nil, span_filter_funcs: nil, exporter: nil, environment: nil) + def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil, filter_ai_spans: nil, span_filter_funcs: nil, exporter: nil) require_relative "config" config = Config.from_env( api_key: api_key, @@ -36,8 +35,7 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni app_url: app_url, api_url: api_url, filter_ai_spans: filter_ai_spans, - span_filter_funcs: span_filter_funcs, - environment: environment + span_filter_funcs: span_filter_funcs ) new( api_key: config.api_key, diff --git a/lib/braintrust/trace/span_origin.rb b/lib/braintrust/trace/span_origin.rb new file mode 100644 index 00000000..06839205 --- /dev/null +++ b/lib/braintrust/trace/span_origin.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "json" +require_relative "../version" + +module Braintrust + module Trace + module SpanOrigin + CONTEXT_JSON_ATTR_KEY = "braintrust.context_json" + ENVIRONMENT_IVAR = :@braintrust_span_origin_environment + + def self.install! + span_class = OpenTelemetry::SDK::Trace::Span + span_class.prepend(self) unless span_class < self + end + + def to_span_data + span_data = super + attributes = span_data.attributes || {} + enriched_attributes = SpanOrigin.attributes_with_origin( + attributes, + instrumentation_name: SpanOrigin.instrumentation_name(self), + environment: instance_variable_get(ENVIRONMENT_IVAR) + ) + + return span_data if enriched_attributes.equal?(attributes) + + span_data.attributes = enriched_attributes.freeze + span_data.total_recorded_attributes = enriched_attributes.length + span_data + end + + def self.attributes_with_origin(attributes, instrumentation_name:, environment:) + context = parse_context_json(attributes[CONTEXT_JSON_ATTR_KEY]) + span_origin = context["span_origin"].is_a?(Hash) ? context["span_origin"] : {} + + span_origin_changed = false + unless span_origin.key?("name") + span_origin["name"] = "braintrust.sdk.ruby" + span_origin_changed = true + end + unless span_origin.key?("version") + span_origin["version"] = Braintrust::VERSION + span_origin_changed = true + end + unless span_origin.key?("instrumentation") + span_origin["instrumentation"] = {"name" => instrumentation_name} + span_origin_changed = true + end + if environment && !span_origin.key?("environment") + span_origin["environment"] = environment + span_origin_changed = true + end + + context_changed = context["span_origin"] != span_origin || span_origin_changed + return attributes unless context_changed + + context["span_origin"] = span_origin + attributes.merge(CONTEXT_JSON_ATTR_KEY => JSON.generate(context)) + end + + def self.parse_context_json(raw) + return {} unless raw.is_a?(String) && !raw.strip.empty? + + parsed = JSON.parse(raw) + parsed.is_a?(Hash) ? parsed : {} + rescue JSON::ParserError + {} + end + + def self.instrumentation_name(span) + if span.respond_to?(:instrumentation_scope) && span.instrumentation_scope&.respond_to?(:name) + return span.instrumentation_scope.name + end + if span.respond_to?(:instrumentation_library) && span.instrumentation_library&.respond_to?(:name) + return span.instrumentation_library.name + end + + "braintrust-ruby" + end + end + end +end diff --git a/lib/braintrust/trace/span_processor.rb b/lib/braintrust/trace/span_processor.rb index c4e45452..7ed3abde 100644 --- a/lib/braintrust/trace/span_processor.rb +++ b/lib/braintrust/trace/span_processor.rb @@ -1,18 +1,19 @@ # frozen_string_literal: true -require "json" require "opentelemetry/sdk" -require_relative "../version" +require_relative "span_origin" module Braintrust module Trace + SpanOrigin.install! + # Custom span processor that adds Braintrust-specific attributes to spans # and optionally filters spans based on custom filter functions. class SpanProcessor PARENT_ATTR_KEY = "braintrust.parent" ORG_ATTR_KEY = "braintrust.org" APP_URL_ATTR_KEY = "braintrust.app_url" - CONTEXT_JSON_ATTR_KEY = "braintrust.context_json" + CONTEXT_JSON_ATTR_KEY = SpanOrigin::CONTEXT_JSON_ATTR_KEY def initialize(wrapped_processor, state, filters = []) @wrapped = wrapped_processor @@ -33,6 +34,7 @@ def on_start(span, parent_context) # Always add org and app_url span.set_attribute(ORG_ATTR_KEY, @state.org_name) if @state.org_name span.set_attribute(APP_URL_ATTR_KEY, @state.app_url) if @state.app_url + span.instance_variable_set(SpanOrigin::ENVIRONMENT_IVAR, span_origin_environment) # Delegate to wrapped processor @wrapped.on_start(span, parent_context) @@ -40,8 +42,6 @@ def on_start(span, parent_context) # Called when a span ends - apply filters before forwarding def on_finish(span) - span = span_with_origin(span) - # Only forward span if it passes filters @wrapped.on_finish(span) if should_forward_span?(span) end @@ -58,68 +58,6 @@ def force_flush(timeout: nil) private - class FinishedSpanWithAttributes - def initialize(span, attributes) - @span = span - @attributes = attributes.freeze - end - - attr_reader :attributes - - def to_span_data - span_data = @span.to_span_data.dup - span_data.attributes = @attributes - span_data.total_recorded_attributes = @attributes.length - span_data - end - - def respond_to_missing?(name, include_private = false) - @span.respond_to?(name, include_private) || super - end - - def method_missing(name, *args, &block) - return @span.public_send(name, *args, &block) if @span.respond_to?(name) - - super - end - end - - private_constant :FinishedSpanWithAttributes - - def span_with_origin(span) - attributes = span.respond_to?(:attributes) ? (span.attributes || {}).dup : {} - context = parse_context_json(attributes[CONTEXT_JSON_ATTR_KEY]) - span_origin = context["span_origin"].is_a?(Hash) ? context["span_origin"] : {} - span_origin["name"] ||= "braintrust.sdk.ruby" - span_origin["version"] ||= Braintrust::VERSION - span_origin["instrumentation"] ||= {"name" => instrumentation_name(span)} - if @state.config&.environment && !span_origin.key?("environment") - environment = {"type" => @state.config.environment[:type]} - environment["name"] = @state.config.environment[:name] if @state.config.environment[:name] - span_origin["environment"] = environment - end - context["span_origin"] = span_origin - attributes[CONTEXT_JSON_ATTR_KEY] = JSON.generate(context) - - FinishedSpanWithAttributes.new(span, attributes) - end - - def parse_context_json(raw) - return {} unless raw.is_a?(String) && !raw.strip.empty? - - parsed = JSON.parse(raw) - parsed.is_a?(Hash) ? parsed : {} - rescue JSON::ParserError - {} - end - - def instrumentation_name(span) - return span.instrumentation_scope.name if span.respond_to?(:instrumentation_scope) && span.instrumentation_scope&.respond_to?(:name) - return span.instrumentation_library.name if span.respond_to?(:instrumentation_library) && span.instrumentation_library&.respond_to?(:name) - - "braintrust-ruby" - end - def default_parent # If default_project is set, format it as "project_name:value" # The default_project should be a plain project name (e.g., "my-project") @@ -131,6 +69,13 @@ def default_parent end end + def span_origin_environment + environment = @state.config&.environment + return nil unless environment + + {"type" => environment[:type], "name" => environment[:name]}.compact + end + # Get parent attribute from parent span in context def get_parent_from_context(parent_context) return nil unless parent_context @@ -150,9 +95,11 @@ def should_forward_span?(span) # If no filters, keep everything return true if @filters.empty? + span_data = span.respond_to?(:to_span_data) ? span.to_span_data : span + # Apply filters in order - first non-zero result wins @filters.each do |filter| - result = filter.call(span) + result = filter.call(span_data) return true if result > 0 # Keep span return false if result < 0 # Drop span # result == 0: no influence, continue to next filter diff --git a/test/braintrust/config_test.rb b/test/braintrust/config_test.rb index bf79eb39..c222e822 100644 --- a/test/braintrust/config_test.rb +++ b/test/braintrust/config_test.rb @@ -49,23 +49,6 @@ def test_provides_default_values assert_equal "https://api.braintrust.dev", config.api_url end - def test_environment_helpers_are_private_class_methods - assert_includes Braintrust::Config.methods, :from_env - - [ - :detect_environment, - :normalize_environment, - :deployment_mode_environment, - :detect_server_environment_name, - :env_value, - :process_env_value, - :read_braintrust_env_file_value - ].each do |helper| - refute_includes Braintrust::Config.methods, helper - assert_includes Braintrust::Config.private_methods, helper - end - end - def test_passed_options_override_env_vars ENV["BRAINTRUST_API_KEY"] = "env-key" ENV["BRAINTRUST_ORG_NAME"] = "env-org" @@ -87,43 +70,6 @@ def test_env_vars_override_defaults assert_equal "https://custom.braintrust.dev", config.app_url end - def test_aws_execution_env_classifies_ecs_before_lambda - with_env( - "CI" => nil, - "GITHUB_ACTIONS" => nil, - "AWS_EXECUTION_ENV" => "AWS_ECS_FARGATE" - ) do - config = Braintrust::Config.from_env - - assert_equal({type: "server", name: "ecs"}, config.environment) - end - end - - def test_explicit_environment_name_without_type_is_preserved - with_env( - "CI" => nil, - "GITHUB_ACTIONS" => nil, - "BRAINTRUST_ENVIRONMENT_TYPE" => nil, - "BRAINTRUST_ENVIRONMENT_NAME" => "staging" - ) do - config = Braintrust::Config.from_env - - assert_equal({name: "staging"}, config.environment) - end - end - - def test_aws_execution_env_classifies_lambda_when_lambda_specific - with_env( - "CI" => nil, - "GITHUB_ACTIONS" => nil, - "AWS_EXECUTION_ENV" => "AWS_Lambda_ruby3.2" - ) do - config = Braintrust::Config.from_env - - assert_equal({type: "server", name: "aws_lambda"}, config.environment) - end - end - def test_falls_back_to_braintrust_json_file write_braintrust_config({"BRAINTRUST_API_KEY" => "file-key"}) @@ -268,18 +214,6 @@ def test_does_not_mutate_process_env private - def with_env(values) - original = values.keys.to_h { |key| [key, ENV[key]] } - values.each do |key, value| - value.nil? ? ENV.delete(key) : ENV[key] = value - end - yield - ensure - original.each do |key, value| - value.nil? ? ENV.delete(key) : ENV[key] = value - end - end - def write_braintrust_config(contents, dir: @tmpdir) File.write(File.join(dir, ".braintrust.json"), JSON.generate(contents)) end diff --git a/test/braintrust/internal/env_test.rb b/test/braintrust/internal/env_test.rb new file mode 100644 index 00000000..84119879 --- /dev/null +++ b/test/braintrust/internal/env_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "test_helper" + +class Braintrust::Internal::EnvTest < Minitest::Test + DETECTION_ENV_KEYS = [ + "AWS_EXECUTION_ENV", + "AWS_LAMBDA_FUNCTION_NAME", + "BITBUCKET_BUILD_NUMBER", + "BRAINTRUST_ENVIRONMENT_NAME", + "BRAINTRUST_ENVIRONMENT_TYPE", + "BUILDKITE", + "CI", + "CIRCLECI", + "DYNO", + "ECS_CONTAINER_METADATA_URI", + "ECS_CONTAINER_METADATA_URI_V4", + "FLY_APP_NAME", + "FUNCTION_TARGET", + "GITHUB_ACTIONS", + "GITLAB_CI", + "JENKINS_HOME", + "JENKINS_URL", + "K_SERVICE", + "KUBERNETES_SERVICE_HOST", + "NETLIFY", + "RACK_ENV", + "RAILS_ENV", + "RAILWAY_ENVIRONMENT", + "RENDER_SERVICE_NAME", + "TEAMCITY_VERSION", + "TF_BUILD", + "TRAVIS", + "VERCEL" + ].freeze + + def test_environment_type_and_name_override_auto_detection + with_detection_env( + "BRAINTRUST_ENVIRONMENT_TYPE" => "ci", + "BRAINTRUST_ENVIRONMENT_NAME" => "github_actions", + "GITHUB_ACTIONS" => "true" + ) do + assert_equal({type: "ci", name: "github_actions"}, Braintrust::Internal::Env.detect_environment) + end + end + + def test_environment_name_without_type_is_preserved + with_detection_env("BRAINTRUST_ENVIRONMENT_NAME" => "staging") do + assert_equal({name: "staging"}, Braintrust::Internal::Env.detect_environment) + end + end + + def test_aws_execution_env_classifies_ecs_before_lambda + with_detection_env("AWS_EXECUTION_ENV" => "AWS_ECS_FARGATE") do + assert_equal({type: "server", name: "ecs"}, Braintrust::Internal::Env.detect_environment) + end + end + + def test_aws_execution_env_classifies_lambda_when_lambda_specific + with_detection_env("AWS_EXECUTION_ENV" => "AWS_Lambda_ruby3.2") do + assert_equal({type: "server", name: "aws_lambda"}, Braintrust::Internal::Env.detect_environment) + end + end + + private + + def with_detection_env(values) + original = DETECTION_ENV_KEYS.to_h { |key| [key, ENV[key]] } + DETECTION_ENV_KEYS.each { |key| ENV.delete(key) } + values.each do |key, value| + value.nil? ? ENV.delete(key) : ENV[key] = value + end + yield + ensure + original.each do |key, value| + value.nil? ? ENV.delete(key) : ENV[key] = value + end + end +end diff --git a/test/braintrust/trace/span_processor_test.rb b/test/braintrust/trace/span_processor_test.rb index e784cb18..7f09738a 100644 --- a/test/braintrust/trace/span_processor_test.rb +++ b/test/braintrust/trace/span_processor_test.rb @@ -99,13 +99,14 @@ def test_adds_app_url_attribute def test_span_origin_merges_with_context_json_set_after_start wrapped = Class.new do - attr_reader :finished_span + attr_reader :finished_span_data def on_start(_span, _parent_context) end def on_finish(span) @finished_span = span + @finished_span_data = span.to_span_data end end.new @@ -119,11 +120,69 @@ def on_finish(span) span.set_attribute("braintrust.context_json", JSON.generate({"metadata" => {"source" => "late-attribute"}})) processor.on_finish(span) - context = JSON.parse(wrapped.finished_span.attributes["braintrust.context_json"]) + context = JSON.parse(wrapped.finished_span_data.attributes["braintrust.context_json"]) assert_equal "late-attribute", context.dig("metadata", "source") assert_equal "braintrust.sdk.ruby", context.dig("span_origin", "name") - refute_includes wrapped.finished_span.attributes, "braintrust.environment.type" - refute_includes wrapped.finished_span.attributes, "braintrust.environment.name" + refute_includes wrapped.finished_span_data.attributes, "braintrust.environment.type" + refute_includes wrapped.finished_span_data.attributes, "braintrust.environment.name" + end + + def test_span_processor_forwards_original_span_without_wrapper + wrapped = Class.new do + attr_reader :finished_span + + def on_start(_span, _parent_context) + end + + def on_finish(span) + @finished_span = span + end + end.new + + processor = Braintrust::Trace::SpanProcessor.new(wrapped, @state) + tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new + tracer = tracer_provider.tracer("test") + span = tracer.start_span("test-span") + + processor.on_start(span, OpenTelemetry::Context.empty) + processor.on_finish(span) + + assert_same span, wrapped.finished_span + end + + def test_span_origin_preserves_existing_values_and_environment + wrapped = Class.new do + attr_reader :finished_span_data + + def on_start(_span, _parent_context) + end + + def on_finish(span) + @finished_span_data = span.to_span_data + end + end.new + + processor = Braintrust::Trace::SpanProcessor.new(wrapped, @state) + tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new + tracer = tracer_provider.tracer("test") + span = tracer.start_span("test-span") + span.set_attribute( + "braintrust.context_json", + JSON.generate( + "span_origin" => { + "name" => "custom.name", + "environment" => {"type" => "server", "name" => "custom"} + } + ) + ) + + processor.on_start(span, OpenTelemetry::Context.empty) + processor.on_finish(span) + + context = JSON.parse(wrapped.finished_span_data.attributes["braintrust.context_json"]) + assert_equal "custom.name", context.dig("span_origin", "name") + assert_equal({"type" => "server", "name" => "custom"}, context.dig("span_origin", "environment")) + assert_equal Braintrust::VERSION, context.dig("span_origin", "version") end def test_span_processor_enables_permalink_generation