braintrust 0.4.0 → 0.4.2

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: 1afc7372056311aec7ccaaae11c5d7a9204cdfc826a34db1bd67126769d253e3
4
+ data.tar.gz: 6492ea96b8f616c05241983baaeb935cdc3d206ced063781c44981b6d0817e64
5
5
  SHA512:
6
- metadata.gz: 16e96c1f75646d2b581cb7a5c1c50ca66de3e625c75da11c6a9fd263313adea9a936e1e30f4a9733e16e56d3120737731d47ab89d858aafd7543b75011cbc9de
7
- data.tar.gz: b926449904f3dafe6803f76105ee8d85b134c777b827c93b877390e318c167e1f8212eab7f207e1fc9a8e8b77cd6a2d48c5c374f412fc4af0a0f614a6c4de94e
6
+ metadata.gz: e6471fc6f3b8beba508dfef0deebfa5f10b1044aaf44817cfb341a71396655ec034b9eca70aaf2e4efb0afa9007a5c284564b3d3471ba1dd18edfd336ea51cb8
7
+ data.tar.gz: 333ad63d354f902c5e4c5d25d2d67e5ed4f4843fcbfeb7c33a6f273de9dcd7b1c77202417816dd9b834881a3d3a833c68418e81c9a1365ec2e503da3ada027ac
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
@@ -166,14 +166,14 @@ module Braintrust
166
166
  record
167
167
  end
168
168
 
169
- # Build origin JSON for tracing/linking
169
+ # Build origin pointer for tracing/linking
170
170
  # @param raw [Hash] Raw record from API
171
171
  # @param dataset_id [String] Dataset ID (fallback if not in record)
172
- # @return [String, nil] JSON-serialized origin, or nil if record lacks required fields
172
+ # @return [Hash, nil] Origin pointer, or nil if record lacks required fields
173
173
  def build_origin(raw, dataset_id)
174
174
  return nil unless raw["id"] && raw["_xact_id"]
175
175
 
176
- Internal::Origin.to_json(
176
+ Internal::Origin.build(
177
177
  object_type: "dataset",
178
178
  object_id: raw["dataset_id"] || dataset_id,
179
179
  id: raw["id"],
@@ -91,11 +91,11 @@ 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
- eval_span.set_attribute("braintrust.origin", kase.origin) if kase.origin
98
+ set_json_attr(eval_span, "braintrust.origin", kase.origin) if kase.origin
99
99
 
100
100
  # Run task
101
101
  begin
@@ -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(
@@ -275,9 +275,7 @@ module Braintrust
275
275
  def report_progress(eval_span, kase, **fields)
276
276
  return unless eval_context.on_progress
277
277
  progress = {"id" => eval_span.context.hex_span_id}.merge(fields.transform_keys(&:to_s))
278
- if kase.origin
279
- progress["origin"] = kase.origin.is_a?(String) ? JSON.parse(kase.origin) : kase.origin
280
- end
278
+ progress["origin"] = kase.origin if kase.origin
281
279
  eval_context.on_progress.call(progress)
282
280
  rescue => e
283
281
  Braintrust.logger.warn("on_progress callback error: #{e.message}")
@@ -336,7 +336,7 @@ module Braintrust
336
336
  # Use pinned version if available, otherwise compute from max(_xact_id)
337
337
  version = dataset_obj.version
338
338
  version ||= cases
339
- .filter_map { |c| c[:origin] && JSON.parse(c[:origin])["_xact_id"] }
339
+ .filter_map { |c| c[:origin]&.dig("_xact_id") }
340
340
  .max
341
341
 
342
342
  {cases: cases, dataset_id: dataset_obj.id, dataset_version: version}
@@ -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
@@ -1,27 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
-
5
3
  module Braintrust
6
4
  module Internal
7
- # Origin provides serialization for source object pointers in Braintrust.
8
- # Used internally to link spans back to their source records (e.g., dataset rows).
5
+ # Origin builds source object pointers, which link spans back to the record
6
+ # they came from (e.g., a dataset row). Pointers stay Hashes internally and
7
+ # are serialized once at the boundary that needs them, so that inbound
8
+ # pointers from the wire and ones we build here have the same shape.
9
9
  module Origin
10
- # Serialize an origin pointer to JSON
10
+ # Build an origin pointer
11
11
  # @param object_type [String] Type of source object (e.g., "dataset", "playground_logs")
12
12
  # @param object_id [String] ID of the source object
13
13
  # @param id [String] ID of the specific record within the source
14
14
  # @param xact_id [String] Transaction ID
15
15
  # @param created [String, nil] Creation timestamp
16
- # @return [String] JSON-serialized origin
17
- def self.to_json(object_type:, object_id:, id:, xact_id:, created:)
18
- JSON.dump({
19
- object_type: object_type,
20
- object_id: object_id,
21
- id: id,
22
- _xact_id: xact_id,
23
- created: created
24
- })
16
+ # @return [Hash] Origin pointer with string keys, matching the wire format
17
+ def self.build(object_type:, object_id:, id:, xact_id:, created:)
18
+ {
19
+ "object_type" => object_type,
20
+ "object_id" => object_id,
21
+ "id" => id,
22
+ "_xact_id" => xact_id,
23
+ "created" => created
24
+ }
25
25
  end
26
26
  end
27
27
  end
@@ -2,12 +2,19 @@
2
2
 
3
3
  require "json"
4
4
 
5
+ require_relative "../../eval/case"
6
+
5
7
  module Braintrust
6
8
  module Server
7
9
  module Services
8
10
  # Framework-agnostic service for running evaluations and streaming SSE results.
9
11
  # Must be long-lived (not per-request) to preserve the @state_cache across requests.
10
12
  class Eval
13
+ # Row fields this SDK understands, derived from the Case contract so that
14
+ # adding a field there carries it through here. Anything else on an
15
+ # inbound row is a field this version has no code for, and is ignored.
16
+ CASE_FIELDS = Braintrust::Eval::Case.members.map(&:to_s).freeze
17
+
11
18
  def initialize(evaluators)
12
19
  @evaluators = evaluators
13
20
  @state_mutex = Mutex.new
@@ -177,9 +184,10 @@ module Braintrust
177
184
  # Returns [cases, dataset] where exactly one is non-nil.
178
185
  def resolve_data_source(data)
179
186
  if data.key?("data")
180
- cases = data["data"].map do |d|
181
- {input: d["input"], expected: d["expected"]}
182
- end
187
+ # Rows arrive inline from the Playground carrying tags, metadata and
188
+ # an origin pointer back to the row they came from. Carry them all:
189
+ # the Playground matches streamed results to its grid by origin.
190
+ cases = data["data"].map { |row| row.slice(*CASE_FIELDS).transform_keys(&:to_sym) }
183
191
  [cases, nil]
184
192
  elsif data.key?("dataset_id")
185
193
  [nil, Braintrust::Dataset::ID.new(id: data["dataset_id"])]
@@ -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.2"
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.2
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