diff --git a/lib/braintrust/config.rb b/lib/braintrust/config.rb index 48a7f80..1aa2a1a 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 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_exporter.rb b/lib/braintrust/trace/span_exporter.rb index d0718e6..32ab270 100644 --- a/lib/braintrust/trace/span_exporter.rb +++ b/lib/braintrust/trace/span_exporter.rb @@ -2,16 +2,20 @@ require "opentelemetry/exporter/otlp" require_relative "../state" +require_relative "span_origin" module Braintrust module Trace - # Custom OTLP exporter that groups spans by braintrust.parent attribute - # and sets the x-bt-parent HTTP header per group. This is required for - # the Braintrust OTLP backend to route spans to the correct experiment/project. + # Custom OTLP exporter for the Braintrust backend. On export it: + # - stamps span origin provenance onto each SpanData (via the prepended SpanOrigin behavior) + # - groups spans by braintrust.parent and sets the x-bt-parent header per group, + # so the backend routes them to the correct experiment/project # # Thread safety: BatchSpanProcessor serializes export() calls via its # @export_mutex, so @headers mutation here is safe. class SpanExporter < OpenTelemetry::Exporter::OTLP::Exporter + prepend SpanOrigin + PARENT_ATTR_KEY = SpanProcessor::PARENT_ATTR_KEY PARENT_HEADER = "x-bt-parent" 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..177ab2f --- /dev/null +++ b/lib/braintrust/trace/span_origin.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "json" +require_relative "../version" +require_relative "../internal/env" + +module Braintrust + module Trace + # Span origin provenance decoration. + # + # This is a *behavior*, not a type. Prepend it onto any exporter whose + # +export(span_data, timeout:)+ it can +super+ into, and every exported + # SpanData gains a +braintrust.context_json+ attribute carrying span origin + # (SDK name/version, instrumentation scope, environment). + # + # Because it only ever touches the SpanData copies handed to *this* + # exporter, the enrichment is invisible to any other exporter sharing the + # same tracer provider - there is no global patch and nothing leaks onto a + # customer's other OTel traces. + module SpanOrigin + CONTEXT_JSON_ATTR_KEY = "braintrust.context_json" + + # Exporter behavior: enrich each SpanData with span origin before export. + # @param span_data [Array] + # @return [Integer] export result from the wrapped exporter + def export(span_data, timeout: nil) + # Environment is process-global and stable; read it once per batch + # rather than once per span. It is cheap (ENV reads only). + environment = Internal::Env.detect_environment + enriched = span_data.map { |sd| SpanOrigin.enrich(sd, environment: environment) } + super(enriched, timeout: timeout) + end + + # Enrich a single SpanData with span origin provenance. + # Mutates the SpanData in place (replacing its frozen attributes hash with + # a new frozen hash - it never mutates the shared hash) and returns it. + # @param span_data [OpenTelemetry::SDK::Trace::SpanData] + # @param environment [Hash, nil] process environment ({type:, name:}) or nil + # @return [OpenTelemetry::SDK::Trace::SpanData] + def self.enrich(span_data, environment:) + attributes = span_data.attributes || {} + enriched_attributes = attributes_with_origin( + attributes, + instrumentation_name: instrumentation_name(span_data), + environment: environment + ) + + 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..09e02e3 100644 --- a/lib/braintrust/trace/span_processor.rb +++ b/lib/braintrust/trace/span_processor.rb @@ -83,9 +83,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_exporter_test.rb b/test/braintrust/trace/span_exporter_test.rb index bb53a83..c1f69ee 100644 --- a/test/braintrust/trace/span_exporter_test.rb +++ b/test/braintrust/trace/span_exporter_test.rb @@ -11,8 +11,7 @@ def setup @state = get_unit_test_state end - # Build a minimal SpanData-like struct for testing - SpanStub = Struct.new(:name, :attributes, keyword_init: true) + SpanStub = Struct.new(:name, :attributes, :total_recorded_attributes, keyword_init: true) def make_span(name, parent: nil) attrs = {} diff --git a/test/braintrust/trace/span_origin_test.rb b/test/braintrust/trace/span_origin_test.rb new file mode 100644 index 0000000..78fce5a --- /dev/null +++ b/test/braintrust/trace/span_origin_test.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require "test_helper" +require "opentelemetry/sdk" + +class Braintrust::Trace::SpanOriginTest < Minitest::Test + SpanOrigin = Braintrust::Trace::SpanOrigin + CONTEXT_JSON = "braintrust.context_json" + + # --------------------------------------------------------------------------- + # Pure unit tests: attributes_with_origin (no OTel involved) + # --------------------------------------------------------------------------- + + def test_adds_origin_to_empty_attributes + result = SpanOrigin.attributes_with_origin({}, instrumentation_name: "my-instrumentation", environment: nil) + + origin = span_origin(result) + assert_equal "braintrust.sdk.ruby", origin["name"] + assert_equal Braintrust::VERSION, origin["version"] + assert_equal({"name" => "my-instrumentation"}, origin["instrumentation"]) + refute origin.key?("environment"), "environment should be omitted when nil" + end + + def test_adds_environment_when_present + result = SpanOrigin.attributes_with_origin({}, instrumentation_name: "x", environment: {type: "server", name: "prod"}) + + assert_equal({"type" => "server", "name" => "prod"}, span_origin(result)["environment"]) + end + + def test_preserves_existing_origin_values_and_merges_unrelated_context_keys + existing = JSON.generate( + "metadata" => {"source" => "user"}, + "span_origin" => { + "name" => "custom.name", + "environment" => {"type" => "server", "name" => "custom"} + } + ) + + result = SpanOrigin.attributes_with_origin( + {CONTEXT_JSON => existing}, + instrumentation_name: "x", + environment: {type: "local", name: "should-not-override"} + ) + + context = JSON.parse(result[CONTEXT_JSON]) + assert_equal "user", context.dig("metadata", "source"), "unrelated context keys must be preserved" + assert_equal "custom.name", context.dig("span_origin", "name"), "existing origin values must be preserved" + assert_equal({"type" => "server", "name" => "custom"}, context.dig("span_origin", "environment"), + "existing environment must not be overridden by detection") + assert_equal Braintrust::VERSION, context.dig("span_origin", "version"), "missing fields must be filled in" + end + + def test_returns_same_attributes_object_when_nothing_changes + attributes = SpanOrigin.attributes_with_origin({}, instrumentation_name: "x", environment: nil) + + # Second pass: every field already present -> no allocation, same object back. + again = SpanOrigin.attributes_with_origin(attributes, instrumentation_name: "x", environment: nil) + + assert_same attributes, again + end + + def test_malformed_context_json_is_treated_as_empty + result = SpanOrigin.attributes_with_origin({CONTEXT_JSON => "not json{"}, instrumentation_name: "x", environment: nil) + + assert_equal "braintrust.sdk.ruby", span_origin(result)["name"] + end + + # --------------------------------------------------------------------------- + # enrich: SpanData mutation semantics + # --------------------------------------------------------------------------- + + def test_enrich_replaces_attributes_without_mutating_the_shared_hash + span_data = build_span_data(instrumentation_name: "svc", attributes: {"existing" => "kept"}) + shared_hash = span_data.attributes + + enriched = SpanOrigin.enrich(span_data, environment: nil) + + assert_same span_data, enriched, "enrich mutates and returns the same SpanData" + assert_equal "kept", enriched.attributes["existing"] + assert enriched.attributes.key?(CONTEXT_JSON) + assert_equal enriched.attributes.length, enriched.total_recorded_attributes + refute shared_hash.key?(CONTEXT_JSON), "the original (shared) attributes hash must not be mutated" + end + + def test_enrich_uses_the_span_data_instrumentation_scope + span_data = build_span_data(instrumentation_name: "some.library") + + enriched = SpanOrigin.enrich(span_data, environment: nil) + + assert_equal "some.library", span_origin(enriched.attributes).dig("instrumentation", "name") + end + + # --------------------------------------------------------------------------- + # Integration through the Braintrust in-memory exporter (via the rig) + # --------------------------------------------------------------------------- + + def test_exported_spans_are_decorated_with_span_origin + rig = setup_otel_test_rig + tracer = rig.tracer("my-app") + + tracer.start_span("work").finish + span_data = rig.drain_one + + origin = span_origin(span_data.attributes) + assert_equal "braintrust.sdk.ruby", origin["name"] + assert_equal Braintrust::VERSION, origin["version"] + assert_equal "my-app", origin.dig("instrumentation", "name") + end + + def test_exported_spans_include_detected_environment + rig = setup_otel_test_rig + tracer = rig.tracer("my-app") + + span_data = nil + ClimateControl.modify(BRAINTRUST_ENVIRONMENT_TYPE: "server", BRAINTRUST_ENVIRONMENT_NAME: "production") do + tracer.start_span("work").finish + span_data = rig.drain_one + end + + assert_equal({"type" => "server", "name" => "production"}, span_origin(span_data.attributes)["environment"]) + end + + # --------------------------------------------------------------------------- + # Decoration is a behavior of the Braintrust exporter, NOT a global patch: + # a plain exporter on another provider must never see span origin. + # --------------------------------------------------------------------------- + + def test_plain_in_memory_exporter_is_not_decorated + exporter = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new + tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new + tracer_provider.add_span_processor(OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter)) + + tracer_provider.tracer("other").start_span("s").finish + tracer_provider.force_flush + + refute exporter.finished_spans.first.attributes.key?(CONTEXT_JSON), + "span origin must not leak onto exporters that do not wear the behavior" + end + + private + + def span_origin(attributes) + JSON.parse(attributes.fetch(CONTEXT_JSON)).fetch("span_origin") + end + + def build_span_data(instrumentation_name:, attributes: {}) + tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new + span = tracer_provider.tracer(instrumentation_name).start_span("s", attributes: attributes) + span.finish + span.to_span_data + end +end diff --git a/test/braintrust/trace/span_processor_test.rb b/test/braintrust/trace/span_processor_test.rb index 1f6fd0a..caca9bc 100644 --- a/test/braintrust/trace/span_processor_test.rb +++ b/test/braintrust/trace/span_processor_test.rb @@ -97,6 +97,29 @@ def test_adds_app_url_attribute wrapped.verify 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_processor_enables_permalink_generation # This test verifies that spans processed by SpanProcessor have all attributes needed for permalinks # Create a mock wrapped processor diff --git a/test/support/in_memory_exporter.rb b/test/support/in_memory_exporter.rb new file mode 100644 index 0000000..bd6617d --- /dev/null +++ b/test/support/in_memory_exporter.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "opentelemetry/sdk" + +module Test + module Support + # In-memory span exporter for tests that wears the same Braintrust exporter + # behaviors as the production SpanExporter - currently span origin decoration + # (SpanOrigin), prepended below. + # + # Both this and SpanExporter prepend the *same* SpanOrigin module, so the + # behavior under test cannot drift between the production and test exporters. + # Tests can therefore assert on origin-decorated SpanData without any network + # calls or a real OTLP exporter. + class InMemoryExporter < OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter + prepend Braintrust::Trace::SpanOrigin + end + end +end diff --git a/test/support/tracing_helper.rb b/test/support/tracing_helper.rb index 4be57b4..53de6f0 100644 --- a/test/support/tracing_helper.rb +++ b/test/support/tracing_helper.rb @@ -1,4 +1,5 @@ require_relative "braintrust_helper" +require_relative "in_memory_exporter" module Test module Support @@ -15,7 +16,7 @@ def self.included(base) def setup_otel_test_rig(**state_options) require "opentelemetry/sdk" - exporter = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new + exporter = Test::Support::InMemoryExporter.new tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new state = get_unit_test_state(**state_options)