diff --git a/gemfiles/anthropic.gemfile b/gemfiles/anthropic.gemfile index 44722fd..495eed1 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 69c7ced..64e7dbc 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 ba73911..7b80f41 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 6c953f9..605f058 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 d7d9a50..82983e3 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 a49d043..7593c67 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 2338030..d2a4d7b 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 764fa91..20d35e7 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 958a8ae..5513bb3 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 6c953f9..605f058 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 bc8446f..852ad2a 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 d11eca8..ab502c8 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 ddc234c..9f343fd 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 4bfa2df..814bada 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 f443120..de56954 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 6288ac8..db18132 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 69400ab..1380ea1 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 6c953f9..605f058 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 1852360..54b725e 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 582d736..76676e7 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 ce51344..6c2ed57 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 6c953f9..605f058 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 c039012..4dbdab0 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.rb b/lib/braintrust.rb index 496fc22..b0ddeae 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 48a7f80..140ab08 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,113 @@ 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 + + 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/lib/braintrust/state.rb b/lib/braintrust/state.rb index f95e5fd..defaa7e 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_filter.rb b/lib/braintrust/trace/span_filter.rb index 9388778..705838f 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 diff --git a/lib/braintrust/trace/span_processor.rb b/lib/braintrust/trace/span_processor.rb index cf2ba8a..f15fe00 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") diff --git a/test/braintrust/config_test.rb b/test/braintrust/config_test.rb index c222e82..9d4d342 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"