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 +4 -4
- data/README.md +40 -10
- data/lib/braintrust/config.rb +7 -4
- data/lib/braintrust/contrib/rails/server/templates/initializer.rb.tt +19 -0
- data/lib/braintrust/contrib/roast/instrumentation/cog.rb +97 -0
- data/lib/braintrust/contrib/roast/instrumentation/workflow.rb +49 -0
- data/lib/braintrust/contrib/roast/integration.rb +32 -0
- data/lib/braintrust/contrib/roast/patcher.rb +42 -0
- data/lib/braintrust/contrib/roast/tracing.rb +15 -0
- data/lib/braintrust/contrib/ruby_llm/instrumentation/chat.rb +51 -11
- data/lib/braintrust/contrib.rb +2 -0
- data/lib/braintrust/eval/runner.rb +32 -3
- data/lib/braintrust/scorer.rb +2 -2
- data/lib/braintrust/state.rb +4 -2
- data/lib/braintrust/trace/span_customization.rb +40 -0
- data/lib/braintrust/trace/span_customizers.rb +110 -0
- data/lib/braintrust/trace/span_export_data.rb +35 -0
- data/lib/braintrust/trace/span_exporter.rb +4 -0
- data/lib/braintrust/trace/span_origin.rb +6 -2
- data/lib/braintrust/trace.rb +14 -3
- data/lib/braintrust/version.rb +1 -1
- data/lib/braintrust.rb +3 -1
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0681cdc4f7c2a0a17a634d61d6137413012c89d0c53683ae730725385d8cd37c'
|
|
4
|
+
data.tar.gz: d5a143e99f067d34401d5e6eb06fbcdde51839094617428ed14a90600c2f5250
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
153
|
+
### Supported integrations
|
|
152
154
|
|
|
153
|
-
The SDK automatically instruments these
|
|
155
|
+
The SDK automatically instruments these libraries:
|
|
154
156
|
|
|
155
|
-
|
|
|
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/
|
|
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
|
|
data/lib/braintrust/config.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
260
|
-
formatted["content"] = if
|
|
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
|
|
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(
|
|
324
|
+
def format_multipart_content(text, attachments)
|
|
285
325
|
content_parts = []
|
|
286
326
|
|
|
287
327
|
# Add text part
|
|
288
|
-
content_parts << {"type" => "text", "text" =>
|
|
328
|
+
content_parts << {"type" => "text", "text" => text} if text
|
|
289
329
|
|
|
290
330
|
# Add attachment parts (convert to Braintrust format)
|
|
291
|
-
|
|
331
|
+
attachments.each do |attachment|
|
|
292
332
|
content_parts << format_attachment_for_input(attachment)
|
|
293
333
|
end
|
|
294
334
|
|
data/lib/braintrust/contrib.rb
CHANGED
|
@@ -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
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
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
|
data/lib/braintrust/scorer.rb
CHANGED
|
@@ -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
|
-
->(
|
|
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
|
-
->(
|
|
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
|
data/lib/braintrust/state.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
data/lib/braintrust/trace.rb
CHANGED
|
@@ -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/#{
|
|
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/#{
|
|
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}")
|
data/lib/braintrust/version.rb
CHANGED
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
|
+
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
|