braintrust 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f30760b63f57dfa236f8f8f74c60aabad6e693f86a57bf8699b028eb00e8639
4
- data.tar.gz: fcae112dc4175b2248a853405587921f16eb2c67d2b8930e2a3877cc09b9e9d1
3
+ metadata.gz: 1047e872fe3a0acf40117bd9a0fcbf4e5024edbe3c1eb3d3bd1e32f60e5ffb18
4
+ data.tar.gz: e4c412abf3cf748ff157248feb821724ba4ffb76b67aecd8d1735179572afa93
5
5
  SHA512:
6
- metadata.gz: 16e96c1f75646d2b581cb7a5c1c50ca66de3e625c75da11c6a9fd263313adea9a936e1e30f4a9733e16e56d3120737731d47ab89d858aafd7543b75011cbc9de
7
- data.tar.gz: b926449904f3dafe6803f76105ee8d85b134c777b827c93b877390e318c167e1f8212eab7f207e1fc9a8e8b77cd6a2d48c5c374f412fc4af0a0f614a6c4de94e
6
+ metadata.gz: 41f183ac2bb20f64bca5c576f0decabdea9b7a4bcb46cbcf0c66292eb34f7e75a3b91516cd5520b4a3e207669eff0fa212afd1f75c1d6b172a44ea27bbc91e4b
7
+ data.tar.gz: 8a98b9ea710b69ac0b4e22cbdbed2b55bfab0d25db5fda2afc872931ce27a4eb9183433db03be6cedcb295dfca2f1fa43583620d848c55e9fa8b19629ecbfb8e
data/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ [![Braintrust](./braintrust-logo.svg)](https://www.braintrust.dev/)
2
+
1
3
  # Braintrust Ruby SDK
2
4
 
3
5
  [![Gem Version](https://img.shields.io/gem/v/braintrust.svg)](https://rubygems.org/gems/braintrust)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "internal/api_key_resolver"
4
+ require_relative "internal/env"
4
5
 
5
6
  module Braintrust
6
7
  # Configuration object that reads from environment variables
@@ -91,8 +91,8 @@ module Braintrust
91
91
  # Set attributes known before task execution
92
92
  eval_span.set_attribute("braintrust.parent", eval_context.parent_span_attr) if eval_context.parent_span_attr
93
93
  set_json_attr(eval_span, "braintrust.span_attributes", build_span_attributes("eval"))
94
- set_json_attr(eval_span, "braintrust.input_json", {input: kase.input})
95
- set_json_attr(eval_span, "braintrust.expected", kase.expected) if kase.expected
94
+ set_json_attr(eval_span, "braintrust.input_json", kase.input)
95
+ set_json_attr(eval_span, "braintrust.expected_json", kase.expected) unless kase.expected.nil?
96
96
  set_json_attr(eval_span, "braintrust.metadata", kase.metadata) if kase.metadata
97
97
  eval_span.set_attribute("braintrust.tags", kase.tags) if kase.tags
98
98
  eval_span.set_attribute("braintrust.origin", kase.origin) if kase.origin
@@ -103,7 +103,6 @@ module Braintrust
103
103
  rescue => e
104
104
  # Error already recorded on task span, set eval span status
105
105
  eval_span.status = OpenTelemetry::Trace::Status.error(e.message)
106
- set_json_attr(eval_span, "braintrust.output_json", {output: nil})
107
106
  errors << "Task failed for input '#{kase.input}': #{e.message}"
108
107
  report_progress(eval_span, kase, error: e.message)
109
108
  next
@@ -134,7 +133,7 @@ module Braintrust
134
133
  end
135
134
 
136
135
  # Set output after task completes
137
- set_json_attr(eval_span, "braintrust.output_json", {output: kase.output})
136
+ set_json_attr(eval_span, "braintrust.output_json", kase.output)
138
137
 
139
138
  report_progress(eval_span, kase, data: kase.output)
140
139
  end
@@ -151,6 +150,7 @@ module Braintrust
151
150
  task_span.set_attribute("braintrust.parent", eval_context.parent_span_attr) if eval_context.parent_span_attr
152
151
  set_json_attr(task_span, "braintrust.span_attributes", build_span_attributes("task"))
153
152
  set_json_attr(task_span, "braintrust.input_json", kase.input)
153
+ set_json_attr(task_span, "braintrust.expected_json", kase.expected) unless kase.expected.nil?
154
154
 
155
155
  begin
156
156
  output = eval_context.task.call(
@@ -5,6 +5,8 @@ module Braintrust
5
5
  # Environment variable utilities.
6
6
  module Env
7
7
  ENV_AUTO_INSTRUMENT = "BRAINTRUST_AUTO_INSTRUMENT"
8
+ ENV_ENVIRONMENT_NAME = "BRAINTRUST_ENVIRONMENT_NAME"
9
+ ENV_ENVIRONMENT_TYPE = "BRAINTRUST_ENVIRONMENT_TYPE"
8
10
  ENV_INSTRUMENT_EXCEPT = "BRAINTRUST_INSTRUMENT_EXCEPT"
9
11
  ENV_INSTRUMENT_ONLY = "BRAINTRUST_INSTRUMENT_ONLY"
10
12
  ENV_FLUSH_ON_EXIT = "BRAINTRUST_FLUSH_ON_EXIT"
@@ -26,6 +28,36 @@ module Braintrust
26
28
  parse_list(ENV_INSTRUMENT_ONLY)
27
29
  end
28
30
 
31
+ def self.detect_environment
32
+ env_type = env_value(ENV_ENVIRONMENT_TYPE)
33
+ env_name = env_value(ENV_ENVIRONMENT_NAME)
34
+ if present?(env_type) || present?(env_name)
35
+ return {type: env_type, name: env_name}.compact
36
+ end
37
+
38
+ {
39
+ "GITHUB_ACTIONS" => "github_actions",
40
+ "GITLAB_CI" => "gitlab_ci",
41
+ "CIRCLECI" => "circleci",
42
+ "BUILDKITE" => "buildkite",
43
+ "JENKINS_URL" => "jenkins",
44
+ "JENKINS_HOME" => "jenkins",
45
+ "TF_BUILD" => "azure_pipelines",
46
+ "TEAMCITY_VERSION" => "teamcity",
47
+ "TRAVIS" => "travis",
48
+ "BITBUCKET_BUILD_NUMBER" => "bitbucket"
49
+ }.each do |key, name|
50
+ return {type: "ci", name: name} if present?(ENV[key])
51
+ end
52
+ return {type: "ci", name: "ci"} if present?(ENV["CI"])
53
+
54
+ server_name = detect_server_environment_name
55
+ return {type: "server", name: server_name} if server_name
56
+
57
+ deployment_mode_environment(ENV["RAILS_ENV"]) ||
58
+ deployment_mode_environment(ENV["RACK_ENV"])
59
+ end
60
+
29
61
  # Parse a comma-separated environment variable into an array of symbols.
30
62
  # @param key [String] The environment variable name
31
63
  # @return [Array<Symbol>, nil] Array of symbols, or nil if not set
@@ -34,6 +66,55 @@ module Braintrust
34
66
  return nil unless value
35
67
  value.split(",").map(&:strip).map(&:to_sym)
36
68
  end
69
+
70
+ def self.deployment_mode_environment(value)
71
+ return nil unless present?(value)
72
+
73
+ normalized = value.strip.downcase
74
+ return {type: "server", name: normalized} if ["production", "staging"].include?(normalized)
75
+ return {type: "local", name: normalized} if ["development", "local"].include?(normalized)
76
+
77
+ nil
78
+ end
79
+ private_class_method :deployment_mode_environment
80
+
81
+ def self.detect_server_environment_name
82
+ {"VERCEL" => "vercel", "NETLIFY" => "netlify"}.each do |key, name|
83
+ return name if present?(ENV[key])
84
+ end
85
+ return "ecs" if present?(ENV["ECS_CONTAINER_METADATA_URI"]) || present?(ENV["ECS_CONTAINER_METADATA_URI_V4"])
86
+
87
+ aws_execution_env = env_value("AWS_EXECUTION_ENV")
88
+ return "ecs" if aws_execution_env&.start_with?("AWS_ECS_")
89
+ return "aws_lambda" if aws_execution_env&.start_with?("AWS_Lambda_")
90
+ return "aws_lambda" if present?(ENV["AWS_LAMBDA_FUNCTION_NAME"])
91
+
92
+ {
93
+ "K_SERVICE" => "cloud_run",
94
+ "FUNCTION_TARGET" => "gcp_functions",
95
+ "KUBERNETES_SERVICE_HOST" => "kubernetes",
96
+ "DYNO" => "heroku",
97
+ "FLY_APP_NAME" => "fly",
98
+ "RAILWAY_ENVIRONMENT" => "railway",
99
+ "RENDER_SERVICE_NAME" => "render"
100
+ }.each do |key, name|
101
+ return name if present?(ENV[key])
102
+ end
103
+
104
+ nil
105
+ end
106
+ private_class_method :detect_server_environment_name
107
+
108
+ def self.env_value(key)
109
+ value = ENV[key]
110
+ value&.strip unless value.nil? || value.strip.empty?
111
+ end
112
+ private_class_method :env_value
113
+
114
+ def self.present?(value)
115
+ !value.nil? && !value.strip.empty?
116
+ end
117
+ private_class_method :present?
37
118
  end
38
119
  end
39
120
  end
@@ -2,16 +2,20 @@
2
2
 
3
3
  require "opentelemetry/exporter/otlp"
4
4
  require_relative "../state"
5
+ require_relative "span_origin"
5
6
 
6
7
  module Braintrust
7
8
  module Trace
8
- # Custom OTLP exporter that groups spans by braintrust.parent attribute
9
- # and sets the x-bt-parent HTTP header per group. This is required for
10
- # the Braintrust OTLP backend to route spans to the correct experiment/project.
9
+ # Custom OTLP exporter for the Braintrust backend. On export it:
10
+ # - stamps span origin provenance onto each SpanData (via the prepended SpanOrigin behavior)
11
+ # - groups spans by braintrust.parent and sets the x-bt-parent header per group,
12
+ # so the backend routes them to the correct experiment/project
11
13
  #
12
14
  # Thread safety: BatchSpanProcessor serializes export() calls via its
13
15
  # @export_mutex, so @headers mutation here is safe.
14
16
  class SpanExporter < OpenTelemetry::Exporter::OTLP::Exporter
17
+ prepend SpanOrigin
18
+
15
19
  PARENT_ATTR_KEY = SpanProcessor::PARENT_ATTR_KEY
16
20
  PARENT_HEADER = "x-bt-parent"
17
21
 
@@ -16,7 +16,8 @@ module Braintrust
16
16
  SYSTEM_ATTRIBUTES = [
17
17
  "braintrust.parent",
18
18
  "braintrust.org",
19
- "braintrust.app_url"
19
+ "braintrust.app_url",
20
+ "braintrust.context_json"
20
21
  ].freeze
21
22
 
22
23
  # Prefixes that indicate an AI-related span
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../version"
5
+ require_relative "../internal/env"
6
+
7
+ module Braintrust
8
+ module Trace
9
+ # Span origin provenance decoration.
10
+ #
11
+ # This is a *behavior*, not a type. Prepend it onto any exporter whose
12
+ # +export(span_data, timeout:)+ it can +super+ into, and every exported
13
+ # SpanData gains a +braintrust.context_json+ attribute carrying span origin
14
+ # (SDK name/version, instrumentation scope, environment).
15
+ #
16
+ # Because it only ever touches the SpanData copies handed to *this*
17
+ # exporter, the enrichment is invisible to any other exporter sharing the
18
+ # same tracer provider - there is no global patch and nothing leaks onto a
19
+ # customer's other OTel traces.
20
+ module SpanOrigin
21
+ CONTEXT_JSON_ATTR_KEY = "braintrust.context_json"
22
+
23
+ # Exporter behavior: enrich each SpanData with span origin before export.
24
+ # @param span_data [Array<OpenTelemetry::SDK::Trace::SpanData>]
25
+ # @return [Integer] export result from the wrapped exporter
26
+ def export(span_data, timeout: nil)
27
+ # Environment is process-global and stable; read it once per batch
28
+ # rather than once per span. It is cheap (ENV reads only).
29
+ environment = Internal::Env.detect_environment
30
+ enriched = span_data.map { |sd| SpanOrigin.enrich(sd, environment: environment) }
31
+ super(enriched, timeout: timeout)
32
+ end
33
+
34
+ # Enrich a single SpanData with span origin provenance.
35
+ # Mutates the SpanData in place (replacing its frozen attributes hash with
36
+ # a new frozen hash - it never mutates the shared hash) and returns it.
37
+ # @param span_data [OpenTelemetry::SDK::Trace::SpanData]
38
+ # @param environment [Hash, nil] process environment ({type:, name:}) or nil
39
+ # @return [OpenTelemetry::SDK::Trace::SpanData]
40
+ def self.enrich(span_data, environment:)
41
+ attributes = span_data.attributes || {}
42
+ enriched_attributes = attributes_with_origin(
43
+ attributes,
44
+ instrumentation_name: instrumentation_name(span_data),
45
+ environment: environment
46
+ )
47
+
48
+ return span_data if enriched_attributes.equal?(attributes)
49
+
50
+ span_data.attributes = enriched_attributes.freeze
51
+ span_data.total_recorded_attributes = enriched_attributes.length
52
+ span_data
53
+ end
54
+
55
+ def self.attributes_with_origin(attributes, instrumentation_name:, environment:)
56
+ context = parse_context_json(attributes[CONTEXT_JSON_ATTR_KEY])
57
+ span_origin = context["span_origin"].is_a?(Hash) ? context["span_origin"] : {}
58
+
59
+ span_origin_changed = false
60
+ unless span_origin.key?("name")
61
+ span_origin["name"] = "braintrust.sdk.ruby"
62
+ span_origin_changed = true
63
+ end
64
+ unless span_origin.key?("version")
65
+ span_origin["version"] = Braintrust::VERSION
66
+ span_origin_changed = true
67
+ end
68
+ unless span_origin.key?("instrumentation")
69
+ span_origin["instrumentation"] = {"name" => instrumentation_name}
70
+ span_origin_changed = true
71
+ end
72
+ if environment && !span_origin.key?("environment")
73
+ span_origin["environment"] = environment
74
+ span_origin_changed = true
75
+ end
76
+
77
+ context_changed = context["span_origin"] != span_origin || span_origin_changed
78
+ return attributes unless context_changed
79
+
80
+ context["span_origin"] = span_origin
81
+ attributes.merge(CONTEXT_JSON_ATTR_KEY => JSON.generate(context))
82
+ end
83
+
84
+ def self.parse_context_json(raw)
85
+ return {} unless raw.is_a?(String) && !raw.strip.empty?
86
+
87
+ parsed = JSON.parse(raw)
88
+ parsed.is_a?(Hash) ? parsed : {}
89
+ rescue JSON::ParserError
90
+ {}
91
+ end
92
+
93
+ def self.instrumentation_name(span)
94
+ if span.respond_to?(:instrumentation_scope) && span.instrumentation_scope&.respond_to?(:name)
95
+ return span.instrumentation_scope.name
96
+ end
97
+ if span.respond_to?(:instrumentation_library) && span.instrumentation_library&.respond_to?(:name)
98
+ return span.instrumentation_library.name
99
+ end
100
+
101
+ "braintrust-ruby"
102
+ end
103
+ end
104
+ end
105
+ end
@@ -83,9 +83,11 @@ module Braintrust
83
83
  # If no filters, keep everything
84
84
  return true if @filters.empty?
85
85
 
86
+ span_data = span.respond_to?(:to_span_data) ? span.to_span_data : span
87
+
86
88
  # Apply filters in order - first non-zero result wins
87
89
  @filters.each do |filter|
88
- result = filter.call(span)
90
+ result = filter.call(span_data)
89
91
  return true if result > 0 # Keep span
90
92
  return false if result < 0 # Drop span
91
93
  # result == 0: no influence, continue to next filter
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Braintrust
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: braintrust
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Braintrust
@@ -182,6 +182,7 @@ files:
182
182
  - lib/braintrust/trace/attachment.rb
183
183
  - lib/braintrust/trace/span_exporter.rb
184
184
  - lib/braintrust/trace/span_filter.rb
185
+ - lib/braintrust/trace/span_origin.rb
185
186
  - lib/braintrust/trace/span_processor.rb
186
187
  - lib/braintrust/vendor/mustache.rb
187
188
  - lib/braintrust/vendor/mustache/context.rb