diff --git a/lib/braintrust/config.rb b/lib/braintrust/config.rb index 48a7f80..9ee5343 100644 --- a/lib/braintrust/config.rb +++ b/lib/braintrust/config.rb @@ -1,16 +1,17 @@ # frozen_string_literal: true require_relative "internal/api_key_resolver" +require_relative "internal/env" module Braintrust # Configuration object that reads from environment variables # 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 +19,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 @@ -47,7 +49,8 @@ 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: Internal::Env.detect_environment ) end end diff --git a/lib/braintrust/internal/env.rb b/lib/braintrust/internal/env.rb index 8634bd3..f32cea3 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/trace/span_filter.rb b/lib/braintrust/trace/span_filter.rb index 9388778..8b17389 100644 --- a/lib/braintrust/trace/span_filter.rb +++ b/lib/braintrust/trace/span_filter.rb @@ -16,7 +16,8 @@ module SpanFilter SYSTEM_ATTRIBUTES = [ "braintrust.parent", "braintrust.org", - "braintrust.app_url" + "braintrust.app_url", + "braintrust.context_json" ].freeze # Prefixes that indicate an AI-related span diff --git a/lib/braintrust/trace/span_origin.rb b/lib/braintrust/trace/span_origin.rb new file mode 100644 index 0000000..0683920 --- /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 cf2ba8a..7ed3abd 100644 --- a/lib/braintrust/trace/span_processor.rb +++ b/lib/braintrust/trace/span_processor.rb @@ -1,15 +1,19 @@ # frozen_string_literal: true require "opentelemetry/sdk" +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 = SpanOrigin::CONTEXT_JSON_ATTR_KEY def initialize(wrapped_processor, state, filters = []) @wrapped = wrapped_processor @@ -30,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) @@ -64,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 @@ -83,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/internal/env_test.rb b/test/braintrust/internal/env_test.rb new file mode 100644 index 0000000..8411987 --- /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 1f6fd0a..7f09738 100644 --- a/test/braintrust/trace/span_processor_test.rb +++ b/test/braintrust/trace/span_processor_test.rb @@ -97,6 +97,94 @@ def test_adds_app_url_attribute wrapped.verify end + def test_span_origin_merges_with_context_json_set_after_start + wrapped = Class.new do + 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 + + 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(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_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 # This test verifies that spans processed by SpanProcessor have all attributes needed for permalinks # Create a mock wrapped processor