braintrust 0.0.3 → 0.0.4

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: 127f10c355ef8d5b0968dcb3197d9612a68455087ad704fa17e5dcb41512ad6d
4
- data.tar.gz: 0e1d31073d9d71f43a74f7d4b37cea8644b119afc282501ee4b311c6cad059ad
3
+ metadata.gz: 39d85e02bd85a931ee7f16de103d48d1184048e3ad8d791eda37bc323a653716
4
+ data.tar.gz: a0b1d5493e8ad3004007e78d608154077a33c92a436bce23eb36cfbe94c3bdd4
5
5
  SHA512:
6
- metadata.gz: 654fae04c4cf51fa32b27864b92ac832e3e37472bfaabe20871aa1899ba027ae4bff6e0a054f833fcb7afe3ef0d3870479ecb824f4c7af8180ca8ea65b21a41c
7
- data.tar.gz: c03683f9793b38477986ade0694f38178434b70c1eea7a1870c2b80a89ad45278fe54f5c7f880eec22719ca0698fab0ad8de5efba5123ee1692c18b0a258d94c
6
+ metadata.gz: a5dcbd1b2bf2c0ab2355ff36c9cfce4fe10e175c0aa8df80ea3176be4002271744ca3a9fd7ef52cec888e0b326772518554921f7d657a79ba347b26c4c93b80c
7
+ data.tar.gz: 78677bd57e6ed1778f74b87e050dd5bbfdc8390e73f919aa57ea680cd2cd4338086e5df4982274c3fd62e690d34ec81078d7c76e75a467ab2f5b0667e6d530d6
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Braintrust Ruby SDK
2
2
 
3
3
  [![Gem Version](https://img.shields.io/gem/v/braintrust.svg)](https://rubygems.org/gems/braintrust)
4
- [![Documentation](https://img.shields.io/badge/docs-rubydoc.info-blue.svg)](https://rubydoc.info/gems/braintrust)
4
+ [![Documentation](https://img.shields.io/badge/docs-gemdocs.org-blue.svg)](https://gemdocs.org/gems/braintrust/)
5
5
  ![Beta](https://img.shields.io/badge/status-beta-yellow)
6
6
 
7
7
  ## Overview
@@ -171,6 +171,56 @@ puts "View trace at: #{Braintrust::Trace.permalink(root_span)}"
171
171
  OpenTelemetry.tracer_provider.shutdown
172
172
  ```
173
173
 
174
+ ### Attachments
175
+
176
+ Attachments allow you to log binary data (images, PDFs, audio, etc.) as part of your traces. This is particularly useful for multimodal AI applications like vision models.
177
+
178
+ ```ruby
179
+ require "braintrust"
180
+ require "braintrust/trace/attachment"
181
+
182
+ Braintrust.init
183
+
184
+ tracer = OpenTelemetry.tracer_provider.tracer("vision-app")
185
+
186
+ tracer.in_span("analyze-image") do |span|
187
+ # Create attachment from file
188
+ att = Braintrust::Trace::Attachment.from_file(
189
+ Braintrust::Trace::Attachment::IMAGE_PNG,
190
+ "./photo.png"
191
+ )
192
+
193
+ # Build message with attachment (OpenAI/Anthropic format)
194
+ messages = [
195
+ {
196
+ role: "user",
197
+ content: [
198
+ {type: "text", text: "What's in this image?"},
199
+ att.to_h # Converts to {"type" => "base64_attachment", "content" => "data:..."}
200
+ ]
201
+ }
202
+ ]
203
+
204
+ # Log to trace
205
+ span.set_attribute("braintrust.input_json", JSON.generate(messages))
206
+ end
207
+
208
+ OpenTelemetry.tracer_provider.shutdown
209
+ ```
210
+
211
+ You can create attachments from bytes, files, or URLs:
212
+
213
+ ```ruby
214
+ # From bytes
215
+ att = Braintrust::Trace::Attachment.from_bytes("image/jpeg", image_data)
216
+
217
+ # From file
218
+ att = Braintrust::Trace::Attachment.from_file("application/pdf", "./doc.pdf")
219
+
220
+ # From URL
221
+ att = Braintrust::Trace::Attachment.from_url("https://example.com/image.png")
222
+ ```
223
+
174
224
  ## Features
175
225
 
176
226
  - **Evaluations**: Run systematic evaluations of your AI systems with custom scoring functions
@@ -187,13 +237,14 @@ Check out the [`examples/`](./examples/) directory for complete working examples
187
237
  - [trace.rb](./examples/trace.rb) - Manual span creation and tracing
188
238
  - [openai.rb](./examples/openai.rb) - Automatically trace OpenAI API calls
189
239
  - [anthropic.rb](./examples/anthropic.rb) - Automatically trace Anthropic API calls
240
+ - [trace/trace_attachments.rb](./examples/trace/trace_attachments.rb) - Log attachments (images, PDFs) in traces
190
241
  - [eval/dataset.rb](./examples/eval/dataset.rb) - Run evaluations using datasets stored in Braintrust
191
242
  - [eval/remote_functions.rb](./examples/eval/remote_functions.rb) - Use remote scoring functions
192
243
 
193
244
  ## Documentation
194
245
 
195
246
  - [Braintrust Documentation](https://www.braintrust.dev/docs)
196
- - [API Documentation](https://rubydoc.info/gems/braintrust)
247
+ - [API Documentation](https://gemdocs.org/gems/braintrust/)
197
248
 
198
249
  ## Contributing
199
250
 
@@ -4,14 +4,18 @@ module Braintrust
4
4
  # Configuration object that reads from environment variables
5
5
  # and allows overriding with explicit options
6
6
  class Config
7
- attr_reader :api_key, :org_name, :default_project, :app_url, :api_url
7
+ attr_reader :api_key, :org_name, :default_project, :app_url, :api_url,
8
+ :filter_ai_spans, :span_filter_funcs
8
9
 
9
- def initialize(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil)
10
+ def initialize(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil,
11
+ filter_ai_spans: nil, span_filter_funcs: nil)
10
12
  @api_key = api_key
11
13
  @org_name = org_name
12
14
  @default_project = default_project
13
15
  @app_url = app_url
14
16
  @api_url = api_url
17
+ @filter_ai_spans = filter_ai_spans
18
+ @span_filter_funcs = span_filter_funcs || []
15
19
  end
16
20
 
17
21
  # Create a Config from environment variables, with option overrides
@@ -21,14 +25,27 @@ module Braintrust
21
25
  # @param default_project [String, nil] Default project (overrides BRAINTRUST_DEFAULT_PROJECT env var)
22
26
  # @param app_url [String, nil] App URL (overrides BRAINTRUST_APP_URL env var)
23
27
  # @param api_url [String, nil] API URL (overrides BRAINTRUST_API_URL env var)
28
+ # @param filter_ai_spans [Boolean, nil] Enable AI span filtering (overrides BRAINTRUST_OTEL_FILTER_AI_SPANS env var)
29
+ # @param span_filter_funcs [Array<Proc>, nil] Custom span filter functions
24
30
  # @return [Config] the created config
25
- def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil)
31
+ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil,
32
+ filter_ai_spans: nil, span_filter_funcs: nil)
33
+ # Parse filter_ai_spans from ENV if not explicitly provided
34
+ env_filter_ai_spans = ENV["BRAINTRUST_OTEL_FILTER_AI_SPANS"]
35
+ filter_ai_spans_value = if filter_ai_spans.nil?
36
+ env_filter_ai_spans&.downcase == "true"
37
+ else
38
+ filter_ai_spans
39
+ end
40
+
26
41
  new(
27
42
  api_key: api_key || ENV["BRAINTRUST_API_KEY"],
28
43
  org_name: org_name || ENV["BRAINTRUST_ORG_NAME"],
29
44
  default_project: default_project || ENV["BRAINTRUST_DEFAULT_PROJECT"],
30
45
  app_url: app_url || ENV["BRAINTRUST_APP_URL"] || "https://www.braintrust.dev",
31
- api_url: api_url || ENV["BRAINTRUST_API_URL"] || "https://api.braintrust.dev"
46
+ api_url: api_url || ENV["BRAINTRUST_API_URL"] || "https://api.braintrust.dev",
47
+ filter_ai_spans: filter_ai_spans_value,
48
+ span_filter_funcs: span_filter_funcs
32
49
  )
33
50
  end
34
51
  end
@@ -9,6 +9,170 @@ require "opentelemetry/sdk"
9
9
  require "json"
10
10
 
11
11
  module Braintrust
12
+ # Evaluation framework for testing AI systems with custom test cases and scoring functions.
13
+ #
14
+ # The Eval module provides tools for running systematic evaluations of your AI systems. An
15
+ # evaluation consists of:
16
+ # - **Cases**: Test inputs with optional expected outputs
17
+ # - **Task**: The code/model being evaluated
18
+ # - **Scorers**: Functions that judge the quality of outputs
19
+ #
20
+ # @example Basic evaluation with inline cases
21
+ # require "braintrust"
22
+ #
23
+ # Braintrust.init
24
+ #
25
+ # # Define a simple task (the code being evaluated)
26
+ # task = ->(input) { input.include?("a") ? "fruit" : "vegetable" }
27
+ #
28
+ # # Run evaluation with inline cases
29
+ # Braintrust::Eval.run(
30
+ # project: "my-project",
31
+ # experiment: "food-classifier",
32
+ # cases: [
33
+ # {input: "apple", expected: "fruit"},
34
+ # {input: "carrot", expected: "vegetable"},
35
+ # {input: "banana", expected: "fruit"}
36
+ # ],
37
+ # task: task,
38
+ # scorers: [
39
+ # # Named scorer with Eval.scorer
40
+ # Braintrust::Eval.scorer("exact_match") do |input, expected, output|
41
+ # output == expected ? 1.0 : 0.0
42
+ # end
43
+ # ]
44
+ # )
45
+ #
46
+ # @example Different ways to define scorers (recommended patterns)
47
+ # # Method reference (auto-uses method name as scorer name)
48
+ # def exact_match(input, expected, output)
49
+ # output == expected ? 1.0 : 0.0
50
+ # end
51
+ #
52
+ # # Named scorer with Eval.scorer
53
+ # case_insensitive = Braintrust::Eval.scorer("case_insensitive") do |input, expected, output|
54
+ # output.downcase == expected.downcase ? 1.0 : 0.0
55
+ # end
56
+ #
57
+ # # Callable class with name method
58
+ # class FuzzyMatch
59
+ # def name
60
+ # "fuzzy_match"
61
+ # end
62
+ #
63
+ # def call(input, expected, output, metadata = {})
64
+ # threshold = metadata[:threshold] || 0.8
65
+ # # scoring logic here
66
+ # 1.0
67
+ # end
68
+ # end
69
+ #
70
+ # # Anonymous lambda that returns named score object
71
+ # multi_score = ->(input, expected, output) {
72
+ # [
73
+ # {name: "exact_match", score: output == expected ? 1.0 : 0.0},
74
+ # {name: "length_match", score: output.length == expected.length ? 1.0 : 0.0}
75
+ # ]
76
+ # }
77
+ #
78
+ # # All can be used together
79
+ # Braintrust::Eval.run(
80
+ # project: "my-project",
81
+ # experiment: "scorer-examples",
82
+ # cases: [{input: "test", expected: "test"}],
83
+ # task: ->(input) { input },
84
+ # scorers: [method(:exact_match), case_insensitive, FuzzyMatch.new, multi_score]
85
+ # )
86
+ #
87
+ # @example Different ways to define tasks
88
+ # # Lambda
89
+ # task_lambda = ->(input) { "result" }
90
+ #
91
+ # # Proc
92
+ # task_proc = proc { |input| "result" }
93
+ #
94
+ # # Method reference
95
+ # def my_task(input)
96
+ # "result"
97
+ # end
98
+ # task_method = method(:my_task)
99
+ #
100
+ # # Callable class
101
+ # class MyTask
102
+ # def call(input)
103
+ # "result"
104
+ # end
105
+ # end
106
+ # task_class = MyTask.new
107
+ #
108
+ # # All of these can be used as the task parameter
109
+ # Braintrust::Eval.run(
110
+ # project: "my-project",
111
+ # experiment: "task-examples",
112
+ # cases: [{input: "test"}],
113
+ # task: task_lambda, # or task_proc, task_method, task_class
114
+ # scorers: [
115
+ # Braintrust::Eval.scorer("my_scorer") { |input, expected, output| 1.0 }
116
+ # ]
117
+ # )
118
+ #
119
+ # @example Using datasets instead of inline cases
120
+ # # Fetch cases from a dataset stored in Braintrust
121
+ # Braintrust::Eval.run(
122
+ # project: "my-project",
123
+ # experiment: "with-dataset",
124
+ # dataset: "my-dataset-name", # fetches from same project
125
+ # task: ->(input) { "result" },
126
+ # scorers: [
127
+ # Braintrust::Eval.scorer("my_scorer") { |input, expected, output| 1.0 }
128
+ # ]
129
+ # )
130
+ #
131
+ # # Or with more options
132
+ # Braintrust::Eval.run(
133
+ # project: "my-project",
134
+ # experiment: "with-dataset-options",
135
+ # dataset: {
136
+ # name: "my-dataset",
137
+ # project: "other-project",
138
+ # version: "1.0",
139
+ # limit: 100
140
+ # },
141
+ # task: ->(input) { "result" },
142
+ # scorers: [
143
+ # Braintrust::Eval.scorer("my_scorer") { |input, expected, output| 1.0 }
144
+ # ]
145
+ # )
146
+ #
147
+ # @example Using metadata and tags
148
+ # Braintrust::Eval.run(
149
+ # project: "my-project",
150
+ # experiment: "with-metadata",
151
+ # cases: [
152
+ # {
153
+ # input: "apple",
154
+ # expected: "fruit",
155
+ # tags: ["tropical", "sweet"],
156
+ # metadata: {threshold: 0.9, category: "produce"}
157
+ # }
158
+ # ],
159
+ # task: ->(input) { "fruit" },
160
+ # scorers: [
161
+ # # Scorer can access case metadata
162
+ # Braintrust::Eval.scorer("threshold_match") do |input, expected, output, metadata|
163
+ # threshold = metadata[:threshold] || 0.5
164
+ # # scoring logic using threshold
165
+ # 1.0
166
+ # end
167
+ # ],
168
+ # # Experiment-level tags and metadata
169
+ # tags: ["v1", "production"],
170
+ # metadata: {
171
+ # model: "gpt-4",
172
+ # temperature: 0.7,
173
+ # version: "1.0.0"
174
+ # }
175
+ # )
12
176
  module Eval
13
177
  class << self
14
178
  # Create a scorer with a name and callable
@@ -6,7 +6,7 @@ module Braintrust
6
6
  # State object that holds Braintrust configuration
7
7
  # Thread-safe global state management
8
8
  class State
9
- attr_reader :api_key, :org_name, :org_id, :default_project, :app_url, :api_url, :proxy_url, :logged_in
9
+ attr_reader :api_key, :org_name, :org_id, :default_project, :app_url, :api_url, :proxy_url, :logged_in, :config
10
10
 
11
11
  @mutex = Mutex.new
12
12
  @global_state = nil
@@ -20,15 +20,20 @@ module Braintrust
20
20
  # @param blocking_login [Boolean] whether to block and login synchronously (default: false)
21
21
  # @param enable_tracing [Boolean] whether to enable OpenTelemetry tracing (default: true)
22
22
  # @param tracer_provider [TracerProvider, nil] Optional tracer provider to use
23
+ # @param filter_ai_spans [Boolean, nil] Enable AI span filtering
24
+ # @param span_filter_funcs [Array<Proc>, nil] Custom span filter functions
25
+ # @param exporter [Exporter, nil] Optional exporter override (for testing)
23
26
  # @return [State] the created state
24
- def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil)
27
+ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil, filter_ai_spans: nil, span_filter_funcs: nil, exporter: nil)
25
28
  require_relative "config"
26
29
  config = Config.from_env(
27
30
  api_key: api_key,
28
31
  org_name: org_name,
29
32
  default_project: default_project,
30
33
  app_url: app_url,
31
- api_url: api_url
34
+ api_url: api_url,
35
+ filter_ai_spans: filter_ai_spans,
36
+ span_filter_funcs: span_filter_funcs
32
37
  )
33
38
  new(
34
39
  api_key: config.api_key,
@@ -38,11 +43,13 @@ module Braintrust
38
43
  api_url: config.api_url,
39
44
  blocking_login: blocking_login,
40
45
  enable_tracing: enable_tracing,
41
- tracer_provider: tracer_provider
46
+ tracer_provider: tracer_provider,
47
+ config: config,
48
+ exporter: exporter
42
49
  )
43
50
  end
44
51
 
45
- def initialize(api_key: nil, org_name: nil, org_id: nil, default_project: nil, app_url: nil, api_url: nil, proxy_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil)
52
+ def initialize(api_key: nil, org_name: nil, org_id: nil, default_project: nil, app_url: nil, api_url: nil, proxy_url: nil, blocking_login: false, enable_tracing: true, tracer_provider: nil, config: nil, exporter: nil)
46
53
  # Instance-level mutex for thread-safe login
47
54
  @login_mutex = Mutex.new
48
55
  raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
@@ -55,6 +62,7 @@ module Braintrust
55
62
  @api_url = api_url
56
63
  @proxy_url = proxy_url
57
64
  @logged_in = false
65
+ @config = config
58
66
 
59
67
  # Perform login after state setup
60
68
  if blocking_login
@@ -66,7 +74,7 @@ module Braintrust
66
74
  # Setup tracing if requested
67
75
  if enable_tracing
68
76
  require_relative "trace"
69
- Trace.setup(self, tracer_provider)
77
+ Trace.setup(self, tracer_provider, exporter: exporter)
70
78
  end
71
79
  end
72
80
 
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Braintrust
8
+ module Trace
9
+ # Attachment represents binary data (images, audio, PDFs, etc.) that can be logged
10
+ # as part of traces in Braintrust. Attachments are stored securely and can be viewed
11
+ # in the Braintrust UI.
12
+ #
13
+ # Attachments are particularly useful for multimodal AI applications, such as vision
14
+ # models that process images.
15
+ #
16
+ # @example Create attachment from file
17
+ # att = Braintrust::Trace::Attachment.from_file("image/png", "./photo.png")
18
+ # data_url = att.to_data_url
19
+ # # => "data:image/png;base64,iVBORw0KGgo..."
20
+ #
21
+ # @example Create attachment from bytes
22
+ # att = Braintrust::Trace::Attachment.from_bytes("image/jpeg", image_bytes)
23
+ # message = att.to_message
24
+ # # => {"type" => "base64_attachment", "content" => "data:image/jpeg;base64,..."}
25
+ #
26
+ # @example Use in a trace span
27
+ # att = Braintrust::Trace::Attachment.from_file("image/png", "./photo.png")
28
+ # messages = [
29
+ # {
30
+ # role: "user",
31
+ # content: [
32
+ # {type: "text", text: "What's in this image?"},
33
+ # att.to_h # Converts to {"type" => "base64_attachment", "content" => "..."}
34
+ # ]
35
+ # }
36
+ # ]
37
+ # span.set_attribute("braintrust.input_json", JSON.generate(messages))
38
+ class Attachment
39
+ # Common MIME type constants for convenience
40
+ IMAGE_PNG = "image/png"
41
+ IMAGE_JPEG = "image/jpeg"
42
+ IMAGE_JPG = "image/jpg"
43
+ IMAGE_GIF = "image/gif"
44
+ IMAGE_WEBP = "image/webp"
45
+ TEXT_PLAIN = "text/plain"
46
+ APPLICATION_PDF = "application/pdf"
47
+
48
+ # @!visibility private
49
+ def initialize(content_type, data)
50
+ @content_type = content_type
51
+ @data = data
52
+ end
53
+
54
+ # Creates an attachment from raw bytes.
55
+ #
56
+ # @param content_type [String] MIME type of the data (e.g., "image/png")
57
+ # @param data [String] Binary data as a string
58
+ # @return [Attachment] New attachment instance
59
+ #
60
+ # @example
61
+ # image_data = File.binread("photo.png")
62
+ # att = Braintrust::Trace::Attachment.from_bytes("image/png", image_data)
63
+ def self.from_bytes(content_type, data)
64
+ new(content_type, data)
65
+ end
66
+
67
+ # Creates an attachment by reading from a file.
68
+ #
69
+ # @param content_type [String] MIME type of the file (e.g., "image/png")
70
+ # @param path [String] Path to the file to read
71
+ # @return [Attachment] New attachment instance
72
+ # @raise [Errno::ENOENT] If the file does not exist
73
+ #
74
+ # @example
75
+ # att = Braintrust::Trace::Attachment.from_file("image/png", "./photo.png")
76
+ def self.from_file(content_type, path)
77
+ data = File.binread(path)
78
+ new(content_type, data)
79
+ end
80
+
81
+ # Creates an attachment by fetching data from a URL.
82
+ #
83
+ # The content type is inferred from the Content-Type header in the HTTP response.
84
+ # If the header is not present, it falls back to "application/octet-stream".
85
+ #
86
+ # @param url [String] URL to fetch
87
+ # @return [Attachment] New attachment instance
88
+ # @raise [StandardError] If the HTTP request fails
89
+ #
90
+ # @example
91
+ # att = Braintrust::Trace::Attachment.from_url("https://example.com/image.png")
92
+ def self.from_url(url)
93
+ uri = URI.parse(url)
94
+ response = Net::HTTP.get_response(uri)
95
+
96
+ unless response.is_a?(Net::HTTPSuccess)
97
+ raise StandardError, "Failed to fetch URL: #{response.code} #{response.message}"
98
+ end
99
+
100
+ content_type = response.content_type || "application/octet-stream"
101
+ new(content_type, response.body)
102
+ end
103
+
104
+ # Converts the attachment to a data URL format.
105
+ #
106
+ # @return [String] Data URL in the format "data:<content-type>;base64,<encoded-data>"
107
+ #
108
+ # @example
109
+ # att = Braintrust::Trace::Attachment.from_bytes("image/png", image_data)
110
+ # att.to_data_url
111
+ # # => "data:image/png;base64,iVBORw0KGgo..."
112
+ def to_data_url
113
+ encoded = Base64.strict_encode64(@data)
114
+ "data:#{@content_type};base64,#{encoded}"
115
+ end
116
+
117
+ # Converts the attachment to a message format suitable for LLM APIs.
118
+ #
119
+ # @return [Hash] Message hash with "type" and "content" keys
120
+ #
121
+ # @example
122
+ # att = Braintrust::Trace::Attachment.from_bytes("image/png", image_data)
123
+ # att.to_message
124
+ # # => {"type" => "base64_attachment", "content" => "data:image/png;base64,..."}
125
+ def to_message
126
+ {
127
+ "type" => "base64_attachment",
128
+ "content" => to_data_url
129
+ }
130
+ end
131
+
132
+ # Alias for {#to_message}. Converts the attachment to a hash representation.
133
+ #
134
+ # @return [Hash] Same as {#to_message}
135
+ alias_method :to_h, :to_message
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Braintrust
4
+ module Trace
5
+ # Span filtering logic for Braintrust tracing
6
+ #
7
+ # Filters allow you to control which spans are exported to Braintrust.
8
+ # This is useful for reducing noise and cost by filtering out non-AI spans.
9
+ #
10
+ # Filter functions take a span and return:
11
+ # 1 = keep the span
12
+ # 0 = no influence (continue to next filter)
13
+ # -1 = drop the span
14
+ module SpanFilter
15
+ # System attributes that should be ignored when checking for AI indicators
16
+ SYSTEM_ATTRIBUTES = [
17
+ "braintrust.parent",
18
+ "braintrust.org",
19
+ "braintrust.app_url"
20
+ ].freeze
21
+
22
+ # Prefixes that indicate an AI-related span
23
+ AI_PREFIXES = [
24
+ "gen_ai.",
25
+ "braintrust.",
26
+ "llm.",
27
+ "ai.",
28
+ "traceloop."
29
+ ].freeze
30
+
31
+ # AI span filter that keeps spans with AI-related names or attributes
32
+ #
33
+ # @param span [OpenTelemetry::SDK::Trace::SpanData] The span to filter
34
+ # @return [Integer] 1 to keep, -1 to drop, 0 for no influence
35
+ def self.ai_filter(span)
36
+ # Check span name for AI prefixes
37
+ span_name = span.name
38
+ AI_PREFIXES.each do |prefix|
39
+ return 1 if span_name.start_with?(prefix)
40
+ end
41
+
42
+ # Check attributes for AI prefixes (skip system attributes)
43
+ # span.attributes returns a hash
44
+ attributes = span.attributes || {}
45
+ attributes.each do |attr_key, _attr_value|
46
+ attr_key_str = attr_key.to_s
47
+ next if SYSTEM_ATTRIBUTES.include?(attr_key_str)
48
+
49
+ AI_PREFIXES.each do |prefix|
50
+ return 1 if attr_key_str.start_with?(prefix)
51
+ end
52
+ end
53
+
54
+ # Drop non-AI spans
55
+ -1
56
+ end
57
+ end
58
+ end
59
+ end
@@ -5,14 +5,16 @@ require "opentelemetry/sdk"
5
5
  module Braintrust
6
6
  module Trace
7
7
  # Custom span processor that adds Braintrust-specific attributes to spans
8
+ # and optionally filters spans based on custom filter functions.
8
9
  class SpanProcessor
9
10
  PARENT_ATTR_KEY = "braintrust.parent"
10
11
  ORG_ATTR_KEY = "braintrust.org"
11
12
  APP_URL_ATTR_KEY = "braintrust.app_url"
12
13
 
13
- def initialize(wrapped_processor, state)
14
+ def initialize(wrapped_processor, state, filters = [])
14
15
  @wrapped = wrapped_processor
15
16
  @state = state
17
+ @filters = filters || []
16
18
  end
17
19
 
18
20
  def on_start(span, parent_context)
@@ -33,9 +35,10 @@ module Braintrust
33
35
  @wrapped.on_start(span, parent_context)
34
36
  end
35
37
 
36
- # Called when a span ends
38
+ # Called when a span ends - apply filters before forwarding
37
39
  def on_finish(span)
38
- @wrapped.on_finish(span)
40
+ # Only forward span if it passes filters
41
+ @wrapped.on_finish(span) if should_forward_span?(span)
39
42
  end
40
43
 
41
44
  # Shutdown the processor
@@ -73,6 +76,29 @@ module Braintrust
73
76
  # Return the parent attribute from the parent span
74
77
  parent_span.attributes&.[](PARENT_ATTR_KEY)
75
78
  end
79
+
80
+ # Determine if a span should be forwarded to the wrapped processor
81
+ # based on configured filters
82
+ def should_forward_span?(span)
83
+ # Always keep root spans (spans with no parent)
84
+ # Check if parent_span_id is the invalid/zero span ID
85
+ is_root = span.parent_span_id == OpenTelemetry::Trace::INVALID_SPAN_ID
86
+ return true if is_root
87
+
88
+ # If no filters, keep everything
89
+ return true if @filters.empty?
90
+
91
+ # Apply filters in order - first non-zero result wins
92
+ @filters.each do |filter|
93
+ result = filter.call(span)
94
+ return true if result > 0 # Keep span
95
+ return false if result < 0 # Drop span
96
+ # result == 0: no influence, continue to next filter
97
+ end
98
+
99
+ # All filters returned 0 (no influence), default to keep
100
+ true
101
+ end
76
102
  end
77
103
  end
78
104
  end
@@ -3,6 +3,7 @@
3
3
  require "opentelemetry/sdk"
4
4
  require "opentelemetry/exporter/otlp"
5
5
  require_relative "trace/span_processor"
6
+ require_relative "trace/span_filter"
6
7
  require_relative "logger"
7
8
 
8
9
  # OpenAI integration is optional - automatically loaded if openai gem is available
@@ -26,8 +27,9 @@ module Braintrust
26
27
  # Set up OpenTelemetry tracing with Braintrust
27
28
  # @param state [State] Braintrust state
28
29
  # @param tracer_provider [TracerProvider, nil] Optional tracer provider
30
+ # @param exporter [Exporter, nil] Optional exporter override (for testing)
29
31
  # @return [void]
30
- def self.setup(state, tracer_provider = nil)
32
+ def self.setup(state, tracer_provider = nil, exporter: nil)
31
33
  if tracer_provider
32
34
  # Use the explicitly provided tracer provider
33
35
  # DO NOT set as global - user is managing it themselves
@@ -49,13 +51,17 @@ module Braintrust
49
51
  end
50
52
 
51
53
  # Enable Braintrust tracing (adds span processor)
52
- enable(tracer_provider, state: state)
54
+ config = state.config
55
+ enable(tracer_provider, state: state, config: config, exporter: exporter)
53
56
  end
54
57
 
55
- def self.enable(tracer_provider, state: nil, exporter: nil)
58
+ def self.enable(tracer_provider, state: nil, exporter: nil, config: nil)
56
59
  state ||= Braintrust.current_state
57
60
  raise Error, "No state available" unless state
58
61
 
62
+ # Get config from state if available
63
+ config ||= state.respond_to?(:config) ? state.config : nil
64
+
59
65
  # Create OTLP HTTP exporter unless override provided
60
66
  exporter ||= OpenTelemetry::Exporter::OTLP::Exporter.new(
61
67
  endpoint: "#{state.api_url}/otel/v1/traces",
@@ -64,11 +70,18 @@ module Braintrust
64
70
  }
65
71
  )
66
72
 
67
- # Wrap in batch processor
68
- batch_processor = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
73
+ # Use SimpleSpanProcessor for InMemorySpanExporter (testing), BatchSpanProcessor for production
74
+ span_processor = if exporter.is_a?(OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter)
75
+ OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter)
76
+ else
77
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
78
+ end
79
+
80
+ # Build filters array from config
81
+ filters = build_filters(config)
69
82
 
70
- # Wrap batch processor in our custom span processor to add Braintrust attributes
71
- processor = SpanProcessor.new(batch_processor, state)
83
+ # Wrap span processor in our custom span processor to add Braintrust attributes and filters
84
+ processor = SpanProcessor.new(span_processor, state, filters)
72
85
 
73
86
  # Register with tracer provider
74
87
  tracer_provider.add_span_processor(processor)
@@ -83,6 +96,25 @@ module Braintrust
83
96
  self
84
97
  end
85
98
 
99
+ # Build filters array from config
100
+ # @param config [Config, nil] Configuration object
101
+ # @return [Array<Proc>] Array of filter functions
102
+ def self.build_filters(config)
103
+ filters = []
104
+
105
+ # Add custom filters first (they have priority)
106
+ if config&.span_filter_funcs&.any?
107
+ filters.concat(config.span_filter_funcs)
108
+ end
109
+
110
+ # Add AI filter if enabled
111
+ if config&.filter_ai_spans
112
+ filters << SpanFilter.method(:ai_filter)
113
+ end
114
+
115
+ filters
116
+ end
117
+
86
118
  # Generate a permalink URL for a span to view in the Braintrust UI
87
119
  # Returns an empty string if the permalink cannot be generated
88
120
  # @param span [OpenTelemetry::Trace::Span] The span to generate a permalink for
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Braintrust
4
- VERSION = "0.0.3"
4
+ VERSION = "0.0.4"
5
5
  end
data/lib/braintrust.rb CHANGED
@@ -37,8 +37,11 @@ module Braintrust
37
37
  # @param blocking_login [Boolean] Whether to block and login synchronously (default: false - async background login)
38
38
  # @param enable_tracing [Boolean] Whether to enable OpenTelemetry tracing (default: true)
39
39
  # @param tracer_provider [TracerProvider, nil] Optional tracer provider to use instead of creating one
40
+ # @param filter_ai_spans [Boolean, nil] Enable AI span filtering (overrides BRAINTRUST_OTEL_FILTER_AI_SPANS env var)
41
+ # @param span_filter_funcs [Array<Proc>, nil] Custom span filter functions
42
+ # @param exporter [Exporter, nil] Optional exporter override (for testing)
40
43
  # @return [State] the created state
41
- def self.init(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, set_global: true, blocking_login: false, enable_tracing: true, tracer_provider: nil)
44
+ def self.init(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil, set_global: true, blocking_login: false, enable_tracing: true, tracer_provider: nil, filter_ai_spans: nil, span_filter_funcs: nil, exporter: nil)
42
45
  state = State.from_env(
43
46
  api_key: api_key,
44
47
  org_name: org_name,
@@ -47,7 +50,10 @@ module Braintrust
47
50
  api_url: api_url,
48
51
  blocking_login: blocking_login,
49
52
  enable_tracing: enable_tracing,
50
- tracer_provider: tracer_provider
53
+ tracer_provider: tracer_provider,
54
+ filter_ai_spans: filter_ai_spans,
55
+ span_filter_funcs: span_filter_funcs,
56
+ exporter: exporter
51
57
  )
52
58
 
53
59
  State.global = state if set_global
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.0.3
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Braintrust
@@ -202,8 +202,10 @@ files:
202
202
  - lib/braintrust/logger.rb
203
203
  - lib/braintrust/state.rb
204
204
  - lib/braintrust/trace.rb
205
+ - lib/braintrust/trace/attachment.rb
205
206
  - lib/braintrust/trace/contrib/anthropic.rb
206
207
  - lib/braintrust/trace/contrib/openai.rb
208
+ - lib/braintrust/trace/span_filter.rb
207
209
  - lib/braintrust/trace/span_processor.rb
208
210
  - lib/braintrust/version.rb
209
211
  homepage: https://github.com/braintrustdata/braintrust-sdk-ruby