Skip to content
Open
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
9 changes: 6 additions & 3 deletions lib/braintrust/config.rb
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
# 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
@app_url = app_url
@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
Expand Down Expand Up @@ -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
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
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
83 changes: 83 additions & 0 deletions lib/braintrust/trace/span_origin.rb
Original file line number Diff line number Diff line change
@@ -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
16 changes: 15 additions & 1 deletion lib/braintrust/trace/span_processor.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
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
Loading