Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/braintrust/config.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
81 changes: 81 additions & 0 deletions lib/braintrust/internal/env.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<Symbol>, nil] Array of symbols, or nil if not set
Expand All @@ -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
10 changes: 7 additions & 3 deletions lib/braintrust/trace/span_exporter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
3 changes: 2 additions & 1 deletion lib/braintrust/trace/span_filter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions lib/braintrust/trace/span_origin.rb
Original file line number Diff line number Diff line change
@@ -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<OpenTelemetry::SDK::Trace::SpanData>]
# @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
4 changes: 3 additions & 1 deletion lib/braintrust/trace/span_processor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions test/braintrust/internal/env_test.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 1 addition & 2 deletions test/braintrust/trace/span_exporter_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down
Loading