braintrust 0.4.2 → 0.6.0

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: 1afc7372056311aec7ccaaae11c5d7a9204cdfc826a34db1bd67126769d253e3
4
- data.tar.gz: 6492ea96b8f616c05241983baaeb935cdc3d206ced063781c44981b6d0817e64
3
+ metadata.gz: '0681cdc4f7c2a0a17a634d61d6137413012c89d0c53683ae730725385d8cd37c'
4
+ data.tar.gz: d5a143e99f067d34401d5e6eb06fbcdde51839094617428ed14a90600c2f5250
5
5
  SHA512:
6
- metadata.gz: e6471fc6f3b8beba508dfef0deebfa5f10b1044aaf44817cfb341a71396655ec034b9eca70aaf2e4efb0afa9007a5c284564b3d3471ba1dd18edfd336ea51cb8
7
- data.tar.gz: 333ad63d354f902c5e4c5d25d2d67e5ed4f4843fcbfeb7c33a6f273de9dcd7b1c77202417816dd9b834881a3d3a833c68418e81c9a1365ec2e503da3ada027ac
6
+ metadata.gz: 6fd5bdc6d41fb19d4af576a94bccd02fe0a8212d5e1c0367124bb81fa0e8079849e6eb4a5a4e0e43c170982f11318983fc65a51ff0dfad897835b7e1c40d099b
7
+ data.tar.gz: 47d6e8ca51a4046549a55acef62c35f09e6e8f303d85d4c7cf706ed13a24dd50a88cd94c40fdcce445251362b4574efa33f646322767e278d11f520fc4ca81a1
data/README.md CHANGED
@@ -17,9 +17,10 @@ This is the official Ruby SDK for [Braintrust](https://www.braintrust.dev), for
17
17
  - [Braintrust.init](#braintrustinit)
18
18
  - [Environment variables](#environment-variables)
19
19
  - [Tracing](#tracing)
20
- - [Supported providers](#supported-providers)
20
+ - [Supported integrations](#supported-integrations)
21
21
  - [Manually applying instrumentation](#manually-applying-instrumentation)
22
22
  - [Creating custom spans](#creating-custom-spans)
23
+ - [Span customizers](#span-customizers)
23
24
  - [Attachments](#attachments)
24
25
  - [Viewing traces](#viewing-traces)
25
26
  - [Evals](#evals)
@@ -120,6 +121,7 @@ Braintrust.init
120
121
  | `filter_ai_spans` | `ENV['BRAINTRUST_OTEL_FILTER_AI_SPANS']` | Only export AI-related spans |
121
122
  | `org_name` | `ENV['BRAINTRUST_ORG_NAME']` | Organization name |
122
123
  | `set_global` | `true` | Set as global state. Set to `false` for isolated instances |
124
+ | `span_customizers` | `[]` | Redact or transform spans before export (see [Span customizers](#span-customizers)) |
123
125
 
124
126
  **Example with options:**
125
127
 
@@ -148,16 +150,17 @@ Braintrust.init(
148
150
 
149
151
  ## Tracing
150
152
 
151
- ### Supported providers
153
+ ### Supported integrations
152
154
 
153
- The SDK automatically instruments these LLM libraries:
155
+ The SDK automatically instruments these libraries:
154
156
 
155
- | Provider | Gem | Versions | Integration Name | Examples |
156
- | --------- | ------------- | -------- | ---------------- | ----------------------------------------- |
157
- | Anthropic | `anthropic` | >= 0.3.0 | `:anthropic` | [Link](./examples/contrib/anthropic.rb) |
158
- | OpenAI | `openai` | >= 0.1.0 | `:openai` | [Link](./examples/contrib/openai.rb) |
159
- | | `ruby-openai` | >= 7.0.0 | `:ruby_openai` | [Link](./examples/contrib/ruby-openai.rb) |
160
- | Multiple | `ruby_llm` | >= 1.8.0 | `:ruby_llm` | [Link](./examples/contrib/ruby_llm.rb) |
157
+ | Category | Gem | Versions | Integration Name | Examples |
158
+ | --------- | ------------- | -------- | ---------------- | ----------------------------------------------- |
159
+ | Anthropic | `anthropic` | >= 0.3.0 | `:anthropic` | [Link](./examples/contrib/anthropic/basic.rb) |
160
+ | OpenAI | `openai` | >= 0.1.0 | `:openai` | [Link](./examples/contrib/openai/basic.rb) |
161
+ | | `ruby-openai` | >= 7.0.0 | `:ruby_openai` | [Link](./examples/contrib/ruby_openai/basic.rb) |
162
+ | Multiple | `ruby_llm` | >= 1.8.0 | `:ruby_llm` | [Link](./examples/contrib/ruby_llm/basic.rb) |
163
+ | Workflows | `roast-ai` | >= 1.0.0 | `:roast` | [Link](./examples/contrib/roast/basic.rb) |
161
164
 
162
165
  ### Manually applying instrumentation
163
166
 
@@ -192,6 +195,33 @@ tracer.in_span("process-request") do |span|
192
195
  end
193
196
  ```
194
197
 
198
+ ### Span customizers
199
+
200
+ Redact or transform span data before it leaves your application, e.g. to strip PII from LLM inputs and outputs:
201
+
202
+ ```ruby
203
+ # Any object that responds to #on_span_export
204
+ class RedactContent
205
+ def on_span_export(span)
206
+ %w[braintrust.input_json braintrust.output_json].each do |key|
207
+ span.attributes[key] = JSON.generate("[redacted]") if span.attributes.key?(key)
208
+ end
209
+ span.attributes.delete("user.email")
210
+ span
211
+ end
212
+ end
213
+
214
+ Braintrust.init(span_customizers: [RedactContent.new])
215
+ ```
216
+
217
+ **When configuring customizers...**
218
+
219
+ - Do not change the `trace_id`, `span_id`, or `parent_span_id` fields.
220
+ - Always return the span (or a replacement `OpenTelemetry::SDK::Trace::SpanData`).
221
+ - Keep them fast; otherwise they may slow down exporting.
222
+ - Customizers run in order on every finished span sent to Braintrust.
223
+ - If it raises or returns something invalid, the batch is dropped and an error is logged.
224
+
195
225
  ### Attachments
196
226
 
197
227
  Log binary data (images, PDFs, audio) in your traces:
@@ -567,7 +597,7 @@ The dev server requires the `rack` gem and a Rack-compatible web server.
567
597
  | [Passenger](https://www.phusionpassenger.com/) | 6.x | |
568
598
  | [WEBrick](https://github.com/ruby/webrick) | Not supported | Does not support server-sent events. |
569
599
 
570
- See examples: [server/eval.ru](./examples/server/eval.ru),
600
+ See examples: [server/eval.ru](./examples/server/eval.ru),
571
601
 
572
602
  ## Documentation
573
603
 
@@ -8,10 +8,10 @@ module Braintrust
8
8
  # and allows overriding with explicit options
9
9
  class Config
10
10
  attr_reader :api_key, :org_name, :default_project, :app_url, :api_url,
11
- :filter_ai_spans, :span_filter_funcs
11
+ :filter_ai_spans, :span_filter_funcs, :span_customizers
12
12
 
13
13
  def initialize(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil,
14
- filter_ai_spans: nil, span_filter_funcs: nil)
14
+ filter_ai_spans: nil, span_filter_funcs: nil, span_customizers: nil)
15
15
  @api_key = api_key
16
16
  @org_name = org_name
17
17
  @default_project = default_project
@@ -19,6 +19,7 @@ module Braintrust
19
19
  @api_url = api_url
20
20
  @filter_ai_spans = filter_ai_spans
21
21
  @span_filter_funcs = span_filter_funcs || []
22
+ @span_customizers = (span_customizers || []).dup.freeze
22
23
  end
23
24
 
24
25
  # Create a Config from environment variables, with option overrides
@@ -30,9 +31,10 @@ module Braintrust
30
31
  # @param api_url [String, nil] API URL (overrides BRAINTRUST_API_URL env var)
31
32
  # @param filter_ai_spans [Boolean, nil] Enable AI span filtering (overrides BRAINTRUST_OTEL_FILTER_AI_SPANS env var)
32
33
  # @param span_filter_funcs [Array<Proc>, nil] Custom span filter functions
34
+ # @param span_customizers [Array<#on_span_export>, nil] Ordered export customizers (copied and frozen)
33
35
  # @return [Config] the created config
34
36
  def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil,
35
- filter_ai_spans: nil, span_filter_funcs: nil)
37
+ filter_ai_spans: nil, span_filter_funcs: nil, span_customizers: nil)
36
38
  # Parse filter_ai_spans from ENV if not explicitly provided
37
39
  env_filter_ai_spans = ENV["BRAINTRUST_OTEL_FILTER_AI_SPANS"]
38
40
  filter_ai_spans_value = if filter_ai_spans.nil?
@@ -48,7 +50,8 @@ module Braintrust
48
50
  app_url: app_url || ENV["BRAINTRUST_APP_URL"] || "https://www.braintrust.dev",
49
51
  api_url: api_url || ENV["BRAINTRUST_API_URL"] || "https://api.braintrust.dev",
50
52
  filter_ai_spans: filter_ai_spans_value,
51
- span_filter_funcs: span_filter_funcs
53
+ span_filter_funcs: span_filter_funcs,
54
+ span_customizers: span_customizers
52
55
  )
53
56
  end
54
57
  end
@@ -0,0 +1,19 @@
1
+ require "braintrust/contrib/rails/server"
2
+
3
+ Braintrust::Contrib::Rails::Server::Engine.configure do |config|
4
+ config.evaluators = {
5
+ <% if @evaluators.empty? -%>
6
+ # Add evaluator instances here, for example:
7
+ # "food-classifier" => FoodClassifier.new
8
+ <% else -%>
9
+ <% @evaluators.each_with_index do |evaluator, index| -%>
10
+ "<%= evaluator[:slug] %>" => <%= evaluator[:class_name] %>.new<%= "," unless index == @evaluators.length - 1 %>
11
+ <% end -%>
12
+ <% end -%>
13
+ }
14
+
15
+ # Default is :clerk_token. Use :none only when developing locally without
16
+ # incoming request authentication; outgoing Braintrust API calls still need
17
+ # a valid Braintrust API key.
18
+ config.auth = :clerk_token
19
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../support/otel"
4
+ require_relative "../tracing"
5
+
6
+ module Braintrust
7
+ module Contrib
8
+ module Roast
9
+ module Instrumentation
10
+ # Wrap the asynchronous work block rather than Cog#run! itself: run! only
11
+ # schedules a task, while the cog executes later in a different fiber.
12
+ module Cog
13
+ class BarrierProxy
14
+ def initialize(barrier, &around_task)
15
+ @barrier = barrier
16
+ @around_task = around_task
17
+ end
18
+
19
+ def async(**options, &task)
20
+ @barrier.async(**options) do |async_task|
21
+ @around_task.call(async_task, task)
22
+ end
23
+ end
24
+ end
25
+
26
+ def run!(barrier, config, input_context, executor_scope_value, executor_scope_index)
27
+ parent_context = roast_parent_context
28
+ return super unless tracing_enabled? && parent_context
29
+
30
+ tracer = Braintrust::Contrib::Roast::Tracing.tracer_for(self)
31
+ proxy = BarrierProxy.new(barrier) do |async_task, task|
32
+ ::OpenTelemetry::Context.with_current(parent_context) do
33
+ span_name = "roast.cog.#{name}"
34
+ tracer.in_span(span_name) do |span|
35
+ # Braintrust uses span_attributes for type/name; Roast details go in metadata.
36
+ Support::OTel.set_json_attr(span, "braintrust.span_attributes", {type: "task", name: span_name})
37
+ Braintrust::Contrib::Context.set!(self, roast_span_context: ::OpenTelemetry::Context.current)
38
+ task.call(async_task)
39
+ rescue => e
40
+ span.record_exception(e)
41
+ span.status = ::OpenTelemetry::Trace::Status.error(e.message)
42
+ raise
43
+ ensure
44
+ record_outcome(span)
45
+ end
46
+ end
47
+ end
48
+ super(proxy, config, input_context, executor_scope_value, executor_scope_index)
49
+ end
50
+
51
+ private
52
+
53
+ def roast_parent_context
54
+ path = ::Roast::TaskContext.path
55
+ path.reverse_each do |element|
56
+ context = Braintrust::Contrib.context_for(element.cog)&.[](:roast_span_context)
57
+ return context if context
58
+ end
59
+ path.reverse_each do |element|
60
+ context = Braintrust::Contrib.context_for(element.execution_manager&.workflow_context)&.[](:roast_span_context)
61
+ return context if context
62
+ end
63
+ nil
64
+ end
65
+
66
+ def tracing_enabled?
67
+ context = Braintrust::Contrib.context_for(self)
68
+ class_context = Braintrust::Contrib.context_for(self.class)
69
+ context&.[](:enabled) != false && class_context&.[](:enabled) != false
70
+ end
71
+
72
+ def record_outcome(span)
73
+ outcome = if failed?
74
+ "failed"
75
+ elsif skipped?
76
+ "skipped"
77
+ else
78
+ "completed"
79
+ end
80
+ Support::OTel.set_json_attr(span, "braintrust.metadata", {
81
+ "contrib.roast.cog.type" => type,
82
+ "contrib.roast.cog.name" => name.to_s,
83
+ "contrib.roast.cog.outcome" => outcome
84
+ })
85
+
86
+ output_value = case output
87
+ when ::Roast::Cogs::Ruby::Output then output.value
88
+ when ::Roast::Cogs::Chat::Output then output.response
89
+ end
90
+ Support::OTel.set_json_attr(span, "braintrust.output_json", output_value) if output_value.is_a?(String)
91
+ span.status = ::OpenTelemetry::Trace::Status.error("Cog failed") if failed?
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../support/otel"
4
+ require_relative "../tracing"
5
+
6
+ module Braintrust
7
+ module Contrib
8
+ module Roast
9
+ module Instrumentation
10
+ # from_file enters a new Async fiber, so carry its caller's OTel context
11
+ # through the params object that is handed to the workflow.
12
+ module WorkflowClass
13
+ def from_file(workflow_path, params)
14
+ previous_parent = Braintrust::Contrib.context_for(params)&.[](:roast_parent_context)
15
+ Braintrust::Contrib::Context.set!(params, roast_parent_context: ::OpenTelemetry::Context.current)
16
+ super
17
+ ensure
18
+ Braintrust::Contrib::Context.set!(params, roast_parent_context: previous_parent)
19
+ end
20
+ end
21
+
22
+ module Workflow
23
+ def start!
24
+ context = Braintrust::Contrib.context_for(self)
25
+ class_context = Braintrust::Contrib.context_for(self.class)
26
+ return super if context&.[](:enabled) == false || class_context&.[](:enabled) == false
27
+
28
+ tracer = Braintrust::Contrib::Roast::Tracing.tracer_for(self)
29
+ workflow_context = @workflow_context
30
+ workflow_path = @workflow_path
31
+ params = workflow_context.params
32
+ parent_context = Braintrust::Contrib.context_for(params)&.[](:roast_parent_context)
33
+ trace_workflow = proc do
34
+ tracer.in_span("roast.workflow") do |span|
35
+ Support::OTel.set_json_attr(span, "braintrust.span_attributes", {type: "task", name: "roast.workflow"})
36
+ Support::OTel.set_json_attr(span, "braintrust.metadata", {"contrib.roast.workflow.name" => File.basename(workflow_path.to_s)})
37
+ input = {targets: params.targets, args: params.args, kwargs: params.kwargs}.reject { |_, value| value.nil? || value.empty? }
38
+ Support::OTel.set_json_attr(span, "braintrust.input_json", input) unless input.empty?
39
+ Braintrust::Contrib::Context.set!(workflow_context, roast_span_context: ::OpenTelemetry::Context.current)
40
+ super()
41
+ end
42
+ end
43
+ parent_context ? ::OpenTelemetry::Context.with_current(parent_context, &trace_workflow) : trace_workflow.call
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration"
4
+
5
+ module Braintrust
6
+ module Contrib
7
+ module Roast
8
+ # Roast workflow and cog instrumentation.
9
+ class Integration
10
+ include Braintrust::Contrib::Integration
11
+
12
+ GEM_NAMES = ["roast-ai"].freeze
13
+ REQUIRE_PATHS = ["roast"].freeze
14
+ MINIMUM_VERSION = "1.0.0"
15
+
16
+ def self.integration_name = :roast
17
+ def self.gem_names = GEM_NAMES
18
+ def self.require_paths = REQUIRE_PATHS
19
+ def self.minimum_version = MINIMUM_VERSION
20
+
21
+ def self.loaded?
22
+ (defined?(::Roast::Workflow) && defined?(::Roast::Cog)) ? true : false
23
+ end
24
+
25
+ def self.patchers
26
+ require_relative "patcher"
27
+ [WorkflowPatcher, CogPatcher]
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../patcher"
4
+ require_relative "integration"
5
+ require_relative "instrumentation/workflow"
6
+ require_relative "instrumentation/cog"
7
+
8
+ module Braintrust
9
+ module Contrib
10
+ module Roast
11
+ class WorkflowPatcher < Braintrust::Contrib::Patcher
12
+ def self.applicable? = defined?(::Roast::Workflow)
13
+
14
+ def self.patched?(**options)
15
+ klass = options[:target]&.singleton_class || ::Roast::Workflow
16
+ klass.ancestors.include?(Instrumentation::Workflow) &&
17
+ (options[:target] || ::Roast::Workflow.singleton_class.ancestors.include?(Instrumentation::WorkflowClass))
18
+ end
19
+
20
+ def self.perform_patch(**options)
21
+ target = options[:target]
22
+ raise ArgumentError, "target must be a Roast::Workflow" if target && !target.is_a?(::Roast::Workflow)
23
+
24
+ (target&.singleton_class || ::Roast::Workflow).prepend(Instrumentation::Workflow)
25
+ ::Roast::Workflow.singleton_class.prepend(Instrumentation::WorkflowClass) unless target
26
+ end
27
+ end
28
+
29
+ class CogPatcher < Braintrust::Contrib::Patcher
30
+ def self.applicable? = defined?(::Roast::Cog)
31
+
32
+ def self.patched?(**)
33
+ ::Roast::Cog.ancestors.include?(Instrumentation::Cog)
34
+ end
35
+
36
+ def self.perform_patch(**)
37
+ ::Roast::Cog.prepend(Instrumentation::Cog)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Braintrust
4
+ module Contrib
5
+ module Roast
6
+ module Tracing
7
+ INSTRUMENTATION_NAME = "braintrust.contrib.roast"
8
+
9
+ def self.tracer_for(target)
10
+ Braintrust::Contrib.tracer_for(target, name: INSTRUMENTATION_NAME)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -186,17 +186,17 @@ module Braintrust
186
186
  ::RubyLLM::Providers::OpenAI::Tools.tool_for(tool)
187
187
  elsif defined?(::RubyLLM::Providers::Anthropic) && provider.is_a?(::RubyLLM::Providers::Anthropic)
188
188
  ::RubyLLM::Providers::Anthropic::Tools.tool_for(tool)
189
- elsif tool.respond_to?(:params_schema) && tool.params_schema
189
+ elsif tool_params_schema(tool)
190
190
  build_basic_tool_schema(tool)
191
191
  else
192
192
  build_minimal_tool_schema(tool)
193
193
  end
194
194
  rescue NameError, ArgumentError => e
195
195
  Braintrust::Log.debug("Failed to extract tool schema using provider-specific method: #{e.class.name}: #{e.message}")
196
- tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
196
+ tool_schema = tool_params_schema(tool) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
197
197
  end
198
198
  else
199
- tool_schema = (tool.respond_to?(:params_schema) && tool.params_schema) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
199
+ tool_schema = tool_params_schema(tool) ? build_basic_tool_schema(tool) : build_minimal_tool_schema(tool)
200
200
  end
201
201
 
202
202
  # Strip RubyLLM-specific fields to match native OpenAI format
@@ -217,6 +217,19 @@ module Braintrust
217
217
  tool_schema
218
218
  end
219
219
 
220
+ # A tool's JSON Schema, across ruby_llm versions.
221
+ # 1.x exposes it as params_schema; 2.0 renamed it to parameters_schema.
222
+ # @param tool [Object] the RubyLLM tool
223
+ # @return [Hash, nil] the schema, or nil when the tool declares none
224
+ def tool_params_schema(tool)
225
+ %i[params_schema parameters_schema].each do |name|
226
+ next unless tool.respond_to?(name)
227
+ schema = tool.public_send(name)
228
+ return schema if schema
229
+ end
230
+ nil
231
+ end
232
+
220
233
  # Build a basic tool schema with parameters
221
234
  def build_basic_tool_schema(tool)
222
235
  {
@@ -224,7 +237,7 @@ module Braintrust
224
237
  "function" => {
225
238
  "name" => tool.name.to_s,
226
239
  "description" => tool.description,
227
- "parameters" => tool.params_schema
240
+ "parameters" => tool_params_schema(tool)
228
241
  }
229
242
  }
230
243
  end
@@ -255,10 +268,11 @@ module Braintrust
255
268
  # Handle content
256
269
  if msg.respond_to?(:content) && msg.content
257
270
  raw_content = msg.content
271
+ attachments = extract_attachments(msg)
258
272
 
259
- # Check if content is a Content object with attachments (issue #71)
260
- formatted["content"] = if raw_content.respond_to?(:text) && raw_content.respond_to?(:attachments) && raw_content.attachments&.any?
261
- format_multipart_content(raw_content)
273
+ # Include attachments alongside the text when present (issue #71)
274
+ formatted["content"] = if attachments.any?
275
+ format_multipart_content(content_text(raw_content), attachments)
262
276
  else
263
277
  format_simple_content(raw_content, msg.role.to_s)
264
278
  end
@@ -278,17 +292,43 @@ module Braintrust
278
292
  formatted
279
293
  end
280
294
 
295
+ # Collect a message's attachments.
296
+ #
297
+ # ruby_llm 1.x wraps text and attachments in a Content object hanging off
298
+ # the message; 2.0 removed Content, leaving content a plain String and
299
+ # exposing attachments on the message itself. Support both.
300
+ #
301
+ # @param msg [Object] the RubyLLM message
302
+ # @return [Array] the message's attachments, empty when there are none
303
+ def extract_attachments(msg)
304
+ content = msg.content if msg.respond_to?(:content)
305
+ if content.respond_to?(:attachments)
306
+ from_content = Array(content.attachments)
307
+ return from_content if from_content.any?
308
+ end
309
+
310
+ msg.respond_to?(:attachments) ? Array(msg.attachments) : []
311
+ end
312
+
313
+ # Extract the plain text of a message's content.
314
+ # @param raw_content [Object] String, or a 1.x Content object
315
+ # @return [Object] the text
316
+ def content_text(raw_content)
317
+ raw_content.respond_to?(:text) ? raw_content.text : raw_content
318
+ end
319
+
281
320
  # Format multipart content with text and attachments
282
- # @param content_obj [Object] Content object with text and attachments
321
+ # @param text [Object] the message text
322
+ # @param attachments [Array] the message's attachments
283
323
  # @return [Array<Hash>] array of content parts
284
- def format_multipart_content(content_obj)
324
+ def format_multipart_content(text, attachments)
285
325
  content_parts = []
286
326
 
287
327
  # Add text part
288
- content_parts << {"type" => "text", "text" => content_obj.text} if content_obj.text
328
+ content_parts << {"type" => "text", "text" => text} if text
289
329
 
290
330
  # Add attachment parts (convert to Braintrust format)
291
- content_obj.attachments.each do |attachment|
331
+ attachments.each do |attachment|
292
332
  content_parts << format_attachment_for_input(attachment)
293
333
  end
294
334
 
@@ -197,9 +197,11 @@ require_relative "contrib/openai/integration"
197
197
  require_relative "contrib/ruby_openai/integration"
198
198
  require_relative "contrib/ruby_llm/integration"
199
199
  require_relative "contrib/anthropic/integration"
200
+ require_relative "contrib/roast/integration"
200
201
 
201
202
  # Register integrations
202
203
  Braintrust::Contrib::OpenAI::Integration.register!
203
204
  Braintrust::Contrib::RubyOpenAI::Integration.register!
204
205
  Braintrust::Contrib::RubyLLM::Integration.register!
205
206
  Braintrust::Contrib::Anthropic::Integration.register!
207
+ Braintrust::Contrib::Roast::Integration.register!
@@ -27,6 +27,11 @@ module Braintrust
27
27
  @eval_context = eval_context
28
28
  @tracer = eval_context.tracer_provider.tracer("braintrust-eval")
29
29
 
30
+ # Whether any scorer/classifier can receive `trace:`. Computed once: the
31
+ # per-case flush that makes traces queryable over BTQL is a real network
32
+ # wait, so it's only worth paying when something will actually consume it.
33
+ @needs_trace = (eval_context.scorers + eval_context.classifiers).any? { |c| wants_trace?(c) }
34
+
30
35
  # Mutexes for thread-safe result collection
31
36
  @score_mutex = Mutex.new
32
37
  @classification_mutex = Mutex.new
@@ -55,6 +60,11 @@ module Braintrust
55
60
  # Convert Queue to Array after all threads complete
56
61
  error_array = [].tap { |a| a << errors.pop until errors.empty? }
57
62
 
63
+ # Deliver any spans still buffered. Callers that supply their own
64
+ # tracer_provider get no at_exit hook (see Trace.setup), so without this
65
+ # the tail of the run would wait on the processor's schedule delay.
66
+ flush_spans
67
+
58
68
  # Calculate duration
59
69
  duration = Time.now - start_time
60
70
 
@@ -108,9 +118,11 @@ module Braintrust
108
118
  next
109
119
  end
110
120
 
111
- # Flush spans so they're queryable via BTQL, then build trace
112
- eval_context.tracer_provider.force_flush if eval_context.tracer_provider.respond_to?(:force_flush)
113
- kase.trace = build_trace(eval_span)
121
+ # Build the trace, then flush spans so they're queryable via BTQL.
122
+ # Both are skipped unless a scorer/classifier declared `trace:` and the
123
+ # trace is actually resolvable (build_trace returns nil in local-only mode).
124
+ kase.trace = build_trace(eval_span) if @needs_trace
125
+ flush_spans if kase.trace
114
126
 
115
127
  # Run scorers
116
128
  begin
@@ -229,6 +241,23 @@ module Braintrust
229
241
  end
230
242
  end
231
243
 
244
+ # Whether a callable can receive `trace:`: it either declares the keyword
245
+ # or accepts arbitrary kwargs and may forward it. Reuses the #call_parameters
246
+ # introspection Internal::Callable::KeywordFilter already depends on.
247
+ # @param callable [Scorer, Classifier]
248
+ # @return [Boolean]
249
+ def wants_trace?(callable)
250
+ return true unless callable.respond_to?(:call_parameters)
251
+
252
+ callable.call_parameters.any? { |type, name| name == :trace || type == :keyrest }
253
+ end
254
+
255
+ # Force the tracer provider to export buffered spans, if it supports it.
256
+ def flush_spans
257
+ provider = eval_context.tracer_provider
258
+ provider.force_flush if provider.respond_to?(:force_flush)
259
+ end
260
+
232
261
  # Build a lazy Trace for a case, backed by BTQL.
233
262
  # Returns nil when state or experiment_id are unavailable (local-only mode).
234
263
  # @param eval_span [OpenTelemetry::Trace::Span] The eval span for this case
@@ -152,10 +152,10 @@ module Braintrust
152
152
  case block.arity
153
153
  when 3
154
154
  Log.warn_once(:scorer_positional_3, "Scorer with positional params (input, expected, output) is deprecated. Use keyword args: |input:, expected:, output:| instead.")
155
- ->(**kw) { block.call(kw[:input], kw[:expected], kw[:output]) }
155
+ ->(input: nil, expected: nil, output: nil) { block.call(input, expected, output) }
156
156
  when 4, -4, -1
157
157
  Log.warn_once(:scorer_positional_4, "Scorer with positional params (input, expected, output, metadata) is deprecated. Use keyword args: |input:, expected:, output:, metadata:| instead.")
158
- ->(**kw) { block.call(kw[:input], kw[:expected], kw[:output], kw[:metadata]) }
158
+ ->(input: nil, expected: nil, output: nil, metadata: nil) { block.call(input, expected, output, metadata) }
159
159
  else
160
160
  raise ArgumentError, "Scorer must accept keyword args or 3-4 positional params (got arity #{block.arity})"
161
161
  end
@@ -24,9 +24,10 @@ module Braintrust
24
24
  # @param tracer_provider [TracerProvider, nil] Optional tracer provider to use
25
25
  # @param filter_ai_spans [Boolean, nil] Enable AI span filtering
26
26
  # @param span_filter_funcs [Array<Proc>, nil] Custom span filter functions
27
+ # @param span_customizers [Array<#on_span_export>, nil] Ordered synchronous export customizers
27
28
  # @param exporter [Exporter, nil] Optional exporter override (for testing)
28
29
  # @return [State] the created state
29
- 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)
30
+ 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, span_customizers: nil, exporter: nil)
30
31
  require_relative "config"
31
32
  config = Config.from_env(
32
33
  api_key: api_key,
@@ -35,7 +36,8 @@ module Braintrust
35
36
  app_url: app_url,
36
37
  api_url: api_url,
37
38
  filter_ai_spans: filter_ai_spans,
38
- span_filter_funcs: span_filter_funcs
39
+ span_filter_funcs: span_filter_funcs,
40
+ span_customizers: span_customizers
39
41
  )
40
42
  new(
41
43
  api_key: config.api_key,
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "span_customizers"
4
+ require_relative "../logger"
5
+
6
+ module Braintrust
7
+ module Trace
8
+ # Span customizer behavior for exporters.
9
+ #
10
+ # Prepend it onto any exporter whose +export(span_data, timeout:)+ it can
11
+ # +super+ into, and the exporter accepts a +span_customizers:+ option on
12
+ # construction and runs those customizers on every export. Prepend it
13
+ # before SpanOrigin, so SpanOrigin runs first and hooks see origin-enriched
14
+ # data:
15
+ #
16
+ # prepend SpanCustomization
17
+ # prepend SpanOrigin
18
+ #
19
+ # Customization is fail-closed: if any hook fails, none of the batch is
20
+ # passed on to the wrapped exporter.
21
+ module SpanCustomization
22
+ # Consumes +span_customizers:+ and forwards everything else to the exporter.
23
+ def initialize(*args, span_customizers: nil, **options, &block)
24
+ @span_customizers = SpanCustomizers.new(span_customizers)
25
+ super(*args, **options, &block)
26
+ end
27
+
28
+ def export(span_data, timeout: nil)
29
+ super(span_customizers.customize(span_data), timeout: timeout)
30
+ rescue SpanCustomizers::Error => e
31
+ Log.error("Span customization failed; batch not sent: #{e.message}")
32
+ OpenTelemetry::SDK::Trace::Export::FAILURE
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :span_customizers
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "span_export_data"
4
+
5
+ module Braintrust
6
+ module Trace
7
+ # Ordered export-time transformations, independent of origin and transport.
8
+ class SpanCustomizers
9
+ # A hook failed. Messages are SDK-generated: they identify the customizer
10
+ # and the kind of failure, never span payloads or the hook's own exception
11
+ # message, either of which may contain sensitive data.
12
+ class Error < StandardError; end
13
+
14
+ ID_FIELDS = %i[trace_id span_id parent_span_id].freeze
15
+ private_constant :ID_FIELDS
16
+
17
+ def initialize(customizers = nil)
18
+ @customizers = (customizers || []).dup.freeze
19
+ @customizers.each { |customizer| validate!(customizer) }
20
+ end
21
+
22
+ # Transform the whole batch before the exporter groups or serializes it.
23
+ # Raises Error so the exporter can fail the batch without sending.
24
+ def customize(span_data)
25
+ return span_data if @customizers.empty?
26
+
27
+ # Hooks may call instrumented clients. Suppress tracing so those spans
28
+ # cannot re-enter the exporter and customize themselves forever.
29
+ OpenTelemetry::Common::Utilities.untraced do
30
+ span_data.map { |span| customize_span(span) }
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Reject objects without the hook up front, so a misspelled method name
37
+ # fails at registration instead of silently skipping redaction.
38
+ def validate!(customizer)
39
+ return if customizer.respond_to?(:on_span_export)
40
+
41
+ raise ArgumentError, "#{label(customizer)} must implement on_span_export"
42
+ end
43
+
44
+ def customize_span(span)
45
+ # Snapshot IDs so in-place mutation through to_span_data cannot slip past validation.
46
+ ids = identity(span)
47
+ drops = dropped_counts(span)
48
+ view = nil
49
+ @customizers.each do |customizer|
50
+ view ||= writable_view(span)
51
+ result = invoke(customizer, view)
52
+ span = unwrap(result, customizer)
53
+ raise Error, "#{label(customizer)} changed span IDs; trace_id, span_id and parent_span_id must be preserved" unless identity(span) == ids
54
+
55
+ view = (result if result.is_a?(SpanExportData))
56
+ end
57
+ restore_counts(span, drops)
58
+ end
59
+
60
+ def invoke(customizer, view)
61
+ customizer.on_span_export(view)
62
+ # Rescue broadly: hook errors must fail the batch, not kill the export thread.
63
+ rescue StandardError, ScriptError, SystemStackError => e
64
+ raise Error, "#{label(customizer)} raised #{e.class}"
65
+ end
66
+
67
+ def unwrap(result, customizer)
68
+ span = result.is_a?(SpanExportData) ? result.to_span_data : result
69
+ return span if span.is_a?(OpenTelemetry::SDK::Trace::SpanData)
70
+
71
+ raise Error, "#{label(customizer)} returned #{result.class}; hooks must return the span or a replacement SpanData"
72
+ end
73
+
74
+ def label(customizer)
75
+ customizer.class.name || customizer.class.inspect
76
+ end
77
+
78
+ def identity(span)
79
+ ID_FIELDS.map { |field| span.public_send(field).dup }
80
+ end
81
+
82
+ # OTel and replacement spans may carry frozen attributes. Only the hash
83
+ # needs to be writable; values and other nested objects remain shared.
84
+ def writable_view(span)
85
+ span.attributes = span.attributes&.dup || {}
86
+ SpanExportData.new(span)
87
+ end
88
+
89
+ # Preserve drops caused by SDK limits; entries a customizer deletes are not drops.
90
+ def dropped_counts(span)
91
+ {
92
+ attributes: dropped(span.total_recorded_attributes, span.attributes),
93
+ events: dropped(span.total_recorded_events, span.events),
94
+ links: dropped(span.total_recorded_links, span.links)
95
+ }
96
+ end
97
+
98
+ def restore_counts(span, drops)
99
+ span.total_recorded_attributes = span.attributes&.size.to_i + drops[:attributes]
100
+ span.total_recorded_events = span.events&.size.to_i + drops[:events]
101
+ span.total_recorded_links = span.links&.size.to_i + drops[:links]
102
+ span
103
+ end
104
+
105
+ def dropped(total, entries)
106
+ [total.to_i - entries&.size.to_i, 0].max
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require "opentelemetry/sdk"
5
+
6
+ module Braintrust
7
+ module Trace
8
+ # Mutable view of completed span data with read-only identity fields.
9
+ # Attribute values and other nested objects are shared, not deep-copied.
10
+ class SpanExportData
11
+ extend Forwardable
12
+
13
+ ID_FIELDS = [:trace_id, :span_id, :parent_span_id].freeze
14
+ MUTABLE_FIELDS = (OpenTelemetry::SDK::Trace::SpanData.members - ID_FIELDS).freeze
15
+ private_constant :ID_FIELDS, :MUTABLE_FIELDS
16
+
17
+ attr_reader(*ID_FIELDS)
18
+ def_delegators :@span_data, *MUTABLE_FIELDS, *MUTABLE_FIELDS.map { |field| :"#{field}=" }
19
+ def_delegators :@span_data, :hex_trace_id, :hex_span_id, :hex_parent_span_id, :instrumentation_library
20
+
21
+ def initialize(span_data)
22
+ @span_data = span_data
23
+ @trace_id = span_data.trace_id.dup.freeze
24
+ @span_id = span_data.span_id.dup.freeze
25
+ @parent_span_id = span_data.parent_span_id.dup.freeze
26
+ end
27
+
28
+ # Access the underlying OpenTelemetry representation, e.g. to build a replacement.
29
+ # Replacement IDs are validated by the exporter after each hook.
30
+ def to_span_data
31
+ @span_data
32
+ end
33
+ end
34
+ end
35
+ end
@@ -3,17 +3,21 @@
3
3
  require "opentelemetry/exporter/otlp"
4
4
  require_relative "../state"
5
5
  require_relative "span_origin"
6
+ require_relative "span_customization"
6
7
 
7
8
  module Braintrust
8
9
  module Trace
9
10
  # Custom OTLP exporter for the Braintrust backend. On export it:
10
11
  # - stamps span origin provenance onto each SpanData (via the prepended SpanOrigin behavior)
12
+ # - runs optional customizers (via the prepended SpanCustomization behavior)
11
13
  # - groups spans by braintrust.parent and sets the x-bt-parent header per group,
12
14
  # so the backend routes them to the correct experiment/project
13
15
  #
14
16
  # Thread safety: BatchSpanProcessor serializes export() calls via its
15
17
  # @export_mutex, so @headers mutation here is safe.
16
18
  class SpanExporter < OpenTelemetry::Exporter::OTLP::Exporter
19
+ # The last prepended runs first: origin, then customization, then export below.
20
+ prepend SpanCustomization
17
21
  prepend SpanOrigin
18
22
 
19
23
  PARENT_ATTR_KEY = SpanProcessor::PARENT_ATTR_KEY
@@ -24,11 +24,15 @@ module Braintrust
24
24
  # @param span_data [Array<OpenTelemetry::SDK::Trace::SpanData>]
25
25
  # @return [Integer] export result from the wrapped exporter
26
26
  def export(span_data, timeout: nil)
27
+ super(SpanOrigin.enrich_batch(span_data), timeout: timeout)
28
+ end
29
+
30
+ # Enrich a batch of SpanData with span origin provenance.
31
+ def self.enrich_batch(span_data)
27
32
  # Environment is process-global and stable; read it once per batch
28
33
  # rather than once per span. It is cheap (ENV reads only).
29
34
  environment = Internal::Env.detect_environment
30
- enriched = span_data.map { |sd| SpanOrigin.enrich(sd, environment: environment) }
31
- super(enriched, timeout: timeout)
35
+ span_data.map { |sd| enrich(sd, environment: environment) }
32
36
  end
33
37
 
34
38
  # Enrich a single SpanData with span origin provenance.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "opentelemetry/sdk"
4
+ require "uri"
4
5
  require "opentelemetry/exporter/otlp"
5
6
  require_relative "trace/span_processor"
6
7
  require_relative "trace/span_exporter"
@@ -88,10 +89,16 @@ module Braintrust
88
89
  # Get config from state if available
89
90
  config ||= state.respond_to?(:config) ? state.config : nil
90
91
 
92
+ # Customizers only run in SpanExporter; refuse to silently skip them (e.g. redaction).
93
+ if exporter && config&.span_customizers&.any?
94
+ raise ArgumentError, "span_customizers are not supported with a custom exporter"
95
+ end
96
+
91
97
  # Create OTLP HTTP exporter unless override provided
92
98
  exporter ||= SpanExporter.new(
93
99
  endpoint: "#{state.api_url}/otel/v1/traces",
94
- api_key: state.api_key
100
+ api_key: state.api_key,
101
+ span_customizers: config&.span_customizers
95
102
  )
96
103
 
97
104
  # Use SimpleSpanProcessor for InMemorySpanExporter (testing), BatchSpanProcessor for production
@@ -185,14 +192,18 @@ module Braintrust
185
192
  return ""
186
193
  end
187
194
 
195
+ # Encode each path segment so names with spaces or slashes remain one segment.
196
+ escaped_org_name = URI::DEFAULT_PARSER.escape(org_name, /[^A-Za-z0-9\-._~]/)
197
+ escaped_parent_id = URI::DEFAULT_PARSER.escape(parent_id, /[^A-Za-z0-9\-._~]/)
198
+
188
199
  # Build the permalink URL based on parent type
189
200
  if parent_type == "experiment_id"
190
201
  # For experiments: {app_url}/app/{org}/object?object_type=experiment&object_id={experiment_id}&r={trace_id}&s={span_id}
191
- "#{app_url}/app/#{org_name}/object?object_type=experiment&object_id=#{parent_id}&r=#{trace_id}&s=#{span_id}"
202
+ "#{app_url}/app/#{escaped_org_name}/object?object_type=experiment&object_id=#{parent_id}&r=#{trace_id}&s=#{span_id}"
192
203
  else
193
204
  # For projects: {app_url}/app/{org}/p/{project}/logs?r={trace_id}&s={span_id}
194
205
  # parent_type is typically "project_name"
195
- "#{app_url}/app/#{org_name}/p/#{parent_id}/logs?r=#{trace_id}&s=#{span_id}"
206
+ "#{app_url}/app/#{escaped_org_name}/p/#{escaped_parent_id}/logs?r=#{trace_id}&s=#{span_id}"
196
207
  end
197
208
  rescue => e
198
209
  Log.error("Failed to generate permalink: #{e.message}")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Braintrust
4
- VERSION = "0.4.2"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/braintrust.rb CHANGED
@@ -42,6 +42,7 @@ module Braintrust
42
42
  # @param tracer_provider [TracerProvider, nil] Optional tracer provider to use instead of creating one
43
43
  # @param filter_ai_spans [Boolean, nil] Enable AI span filtering (overrides BRAINTRUST_OTEL_FILTER_AI_SPANS env var)
44
44
  # @param span_filter_funcs [Array<Proc>, nil] Custom span filter functions
45
+ # @param span_customizers [Array<#on_span_export>, nil] Ordered synchronous export customizers
45
46
  # @param exporter [Exporter, nil] Optional exporter override (for testing)
46
47
  # @param auto_instrument [Boolean, Hash, nil] Auto-instrumentation config:
47
48
  # - nil (default): use BRAINTRUST_AUTO_INSTRUMENT env var, default true if not set
@@ -49,7 +50,7 @@ module Braintrust
49
50
  # - false: explicitly disable
50
51
  # - Hash with :only or :except keys for filtering
51
52
  # @return [State] the created state
52
- 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, auto_instrument: nil)
53
+ 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, span_customizers: nil, exporter: nil, auto_instrument: nil)
53
54
  state = State.from_env(
54
55
  api_key: api_key,
55
56
  org_name: org_name,
@@ -61,6 +62,7 @@ module Braintrust
61
62
  tracer_provider: tracer_provider,
62
63
  filter_ai_spans: filter_ai_spans,
63
64
  span_filter_funcs: span_filter_funcs,
65
+ span_customizers: span_customizers,
64
66
  exporter: exporter
65
67
  )
66
68
 
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.2
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Braintrust
@@ -118,7 +118,13 @@ files:
118
118
  - lib/braintrust/contrib/rails/server/health_controller.rb
119
119
  - lib/braintrust/contrib/rails/server/list_controller.rb
120
120
  - lib/braintrust/contrib/rails/server/routes.rb
121
+ - lib/braintrust/contrib/rails/server/templates/initializer.rb.tt
121
122
  - lib/braintrust/contrib/registry.rb
123
+ - lib/braintrust/contrib/roast/instrumentation/cog.rb
124
+ - lib/braintrust/contrib/roast/instrumentation/workflow.rb
125
+ - lib/braintrust/contrib/roast/integration.rb
126
+ - lib/braintrust/contrib/roast/patcher.rb
127
+ - lib/braintrust/contrib/roast/tracing.rb
122
128
  - lib/braintrust/contrib/ruby_llm/deprecated.rb
123
129
  - lib/braintrust/contrib/ruby_llm/instrumentation/chat.rb
124
130
  - lib/braintrust/contrib/ruby_llm/instrumentation/common.rb
@@ -180,6 +186,9 @@ files:
180
186
  - lib/braintrust/task.rb
181
187
  - lib/braintrust/trace.rb
182
188
  - lib/braintrust/trace/attachment.rb
189
+ - lib/braintrust/trace/span_customization.rb
190
+ - lib/braintrust/trace/span_customizers.rb
191
+ - lib/braintrust/trace/span_export_data.rb
183
192
  - lib/braintrust/trace/span_exporter.rb
184
193
  - lib/braintrust/trace/span_filter.rb
185
194
  - lib/braintrust/trace/span_origin.rb