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 +4 -4
- data/README.md +53 -2
- data/lib/braintrust/config.rb +21 -4
- data/lib/braintrust/eval.rb +164 -0
- data/lib/braintrust/state.rb +14 -6
- data/lib/braintrust/trace/attachment.rb +138 -0
- data/lib/braintrust/trace/span_filter.rb +59 -0
- data/lib/braintrust/trace/span_processor.rb +29 -3
- data/lib/braintrust/trace.rb +39 -7
- data/lib/braintrust/version.rb +1 -1
- data/lib/braintrust.rb +8 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 39d85e02bd85a931ee7f16de103d48d1184048e3ad8d791eda37bc323a653716
|
|
4
|
+
data.tar.gz: a0b1d5493e8ad3004007e78d608154077a33c92a436bce23eb36cfbe94c3bdd4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://rubygems.org/gems/braintrust)
|
|
4
|
-
[](https://gemdocs.org/gems/braintrust/)
|
|
5
5
|

|
|
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://
|
|
247
|
+
- [API Documentation](https://gemdocs.org/gems/braintrust/)
|
|
197
248
|
|
|
198
249
|
## Contributing
|
|
199
250
|
|
data/lib/braintrust/config.rb
CHANGED
|
@@ -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
|
data/lib/braintrust/eval.rb
CHANGED
|
@@ -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
|
data/lib/braintrust/state.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/braintrust/trace.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
68
|
-
|
|
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
|
|
71
|
-
processor = SpanProcessor.new(
|
|
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
|
data/lib/braintrust/version.rb
CHANGED
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.
|
|
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
|