Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This is the official Ruby SDK for [Braintrust](https://www.braintrust.dev), for
- [Supported providers](#supported-providers)
- [Manually applying instrumentation](#manually-applying-instrumentation)
- [Creating custom spans](#creating-custom-spans)
- [Span customizers](#span-customizers)
- [Attachments](#attachments)
- [Viewing traces](#viewing-traces)
- [Evals](#evals)
Expand Down Expand Up @@ -120,6 +121,7 @@ Braintrust.init
| `filter_ai_spans` | `ENV['BRAINTRUST_OTEL_FILTER_AI_SPANS']` | Only export AI-related spans |
| `org_name` | `ENV['BRAINTRUST_ORG_NAME']` | Organization name |
| `set_global` | `true` | Set as global state. Set to `false` for isolated instances |
| `span_customizers` | `[]` | Ordered objects with optional synchronous export hooks (see [Span customizers](#span-customizers)) |

**Example with options:**

Expand Down Expand Up @@ -192,6 +194,40 @@ tracer.in_span("process-request") do |span|
end
```

### Span customizers

Register customizers before creating spans to redact or transform outgoing telemetry:

```ruby
require "braintrust"

class RedactContent < Braintrust::SpanCustomizer
def on_span_export(span)
%w[braintrust.input_json braintrust.output_json].each do |key|
span.attributes[key] = JSON.generate("[redacted]") if span.attributes.key?(key)
end
span
end
end

Braintrust.init(
default_project: "my-project",
span_customizers: [RedactContent.new]
)
```

`Braintrust::SpanCustomizer` is an extensible base class with a no-op `on_span_export`. Any object may be registered; an omitted hook is also a no-op. `Braintrust::Config.new` / `.from_env`, `Braintrust::State.from_env`, and a directly constructed `Braintrust::Trace::SpanExporter` also accept `span_customizers:`. Registration is programmatic, not environment-based. Configuration and exporters retain frozen copies of the ordered list, not copies of the customizer objects.

Hooks run synchronously in registration order, each receiving a view of its predecessor's result. The argument is a `Braintrust::Trace::SpanExportData` facade over completed OpenTelemetry span data, after Braintrust origin metadata is added but before destination grouping or OTLP serialization. Hooks apply to **all completed spans reaching the Braintrust exporter**, including manual, evaluation, and instrumented spans that pass any configured span filters. An unrelated exporter supplied through `exporter:` is not wrapped with these hooks.

Mutate and return the facade directly, or return a replacement `OpenTelemetry::SDK::Trace::SpanData`; replacement is not an implicit merge. The facade exposes span field readers and writers, except for identity writers, and `to_span_data` provides the underlying OpenTelemetry representation when constructing a replacement. The exporter provides a writable attributes hash, so assigning or deleting attribute entries does not change another exporter's hash. There is no deep copy: nested strings, arrays, events, links, and resources may still be shared with application code or other exporters. Prefer assigning replacement attribute values over mutating existing values in place. OTel resource objects retain their normal API; replace resources with `OpenTelemetry::SDK::Resources::Resource.create(...)` when changing resource attributes. Added attributes, events, and links have their recorded counts adjusted to prevent negative OTLP dropped counts.

Always return valid, serializable span data, never `nil` to drop a span. The facade exposes `trace_id`, `span_id`, and `parent_span_id` as read-only, frozen strings and does not expose indexed assignment (`[]=`). Preserve these IDs in replacements too; the exporter checks them after every hook. You may change `braintrust.parent` to route the result to another project or experiment. Existing destination/header behavior otherwise remains unchanged.

Customization is **fail-closed for the whole batch**: an exception, invalid return, changed protected ID, or serialization failure returns an export failure and sends none of that batch. The SDK logs the failure without falling back to unredacted originals. Mutations made before a failure are not rolled back.

Keep hooks fast and avoid blocking I/O; they may run on a background export thread. OTLP transport retries reuse the already customized bytes. Submitting the same span data through `export` again invokes the hooks again on its current state, including prior mutations, so do not assume one invocation per logical span. Without customizers, the existing export path is unchanged.

### Attachments

Log binary data (images, PDFs, audio) in your traces:
Expand Down Expand Up @@ -567,7 +603,7 @@ The dev server requires the `rack` gem and a Rack-compatible web server.
| [Passenger](https://www.phusionpassenger.com/) | 6.x | |
| [WEBrick](https://github.com/ruby/webrick) | Not supported | Does not support server-sent events. |

See examples: [server/eval.ru](./examples/server/eval.ru),
See examples: [server/eval.ru](./examples/server/eval.ru),

## Documentation

Expand Down
5 changes: 4 additions & 1 deletion lib/braintrust.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require_relative "braintrust/version"
require_relative "braintrust/config"
require_relative "braintrust/span_customizer"
require_relative "braintrust/state"
require_relative "braintrust/trace"
require_relative "braintrust/api"
Expand Down Expand Up @@ -42,14 +43,15 @@ class Error < StandardError; end
# @param tracer_provider [TracerProvider, nil] Optional tracer provider to use instead of creating one
# @param filter_ai_spans [Boolean, nil] Enable AI span filtering (overrides BRAINTRUST_OTEL_FILTER_AI_SPANS env var)
# @param span_filter_funcs [Array<Proc>, nil] Custom span filter functions
# @param span_customizers [Array<SpanCustomizer>, nil] Ordered synchronous export customizers
# @param exporter [Exporter, nil] Optional exporter override (for testing)
# @param auto_instrument [Boolean, Hash, nil] Auto-instrumentation config:
# - nil (default): use BRAINTRUST_AUTO_INSTRUMENT env var, default true if not set
# - true: explicitly enable
# - false: explicitly disable
# - Hash with :only or :except keys for filtering
# @return [State] the created state
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)
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)
state = State.from_env(
api_key: api_key,
org_name: org_name,
Expand All @@ -61,6 +63,7 @@ def self.init(api_key: nil, org_name: nil, default_project: nil, app_url: nil, a
tracer_provider: tracer_provider,
filter_ai_spans: filter_ai_spans,
span_filter_funcs: span_filter_funcs,
span_customizers: span_customizers,
exporter: exporter
)

Expand Down
11 changes: 7 additions & 4 deletions lib/braintrust/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,18 @@ module Braintrust
# and allows overriding with explicit options
class Config
attr_reader :api_key, :org_name, :default_project, :app_url, :api_url,
:filter_ai_spans, :span_filter_funcs
:filter_ai_spans, :span_filter_funcs, :span_customizers

def initialize(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil,
filter_ai_spans: nil, span_filter_funcs: nil)
filter_ai_spans: nil, span_filter_funcs: nil, span_customizers: nil)
@api_key = api_key
@org_name = org_name
@default_project = default_project
@app_url = app_url
@api_url = api_url
@filter_ai_spans = filter_ai_spans
@span_filter_funcs = span_filter_funcs || []
@span_customizers = (span_customizers || []).dup.freeze
end

# Create a Config from environment variables, with option overrides
Expand All @@ -30,9 +31,10 @@ def initialize(api_key: nil, org_name: nil, default_project: nil, app_url: nil,
# @param api_url [String, nil] API URL (overrides BRAINTRUST_API_URL env var)
# @param filter_ai_spans [Boolean, nil] Enable AI span filtering (overrides BRAINTRUST_OTEL_FILTER_AI_SPANS env var)
# @param span_filter_funcs [Array<Proc>, nil] Custom span filter functions
# @param span_customizers [Array<SpanCustomizer>, nil] Ordered export customizers (copied and frozen)
# @return [Config] the created config
def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: nil, api_url: nil,
filter_ai_spans: nil, span_filter_funcs: nil)
filter_ai_spans: nil, span_filter_funcs: nil, span_customizers: nil)
# Parse filter_ai_spans from ENV if not explicitly provided
env_filter_ai_spans = ENV["BRAINTRUST_OTEL_FILTER_AI_SPANS"]
filter_ai_spans_value = if filter_ai_spans.nil?
Expand All @@ -48,7 +50,8 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni
app_url: app_url || ENV["BRAINTRUST_APP_URL"] || "https://www.braintrust.dev",
api_url: api_url || ENV["BRAINTRUST_API_URL"] || "https://api.braintrust.dev",
filter_ai_spans: filter_ai_spans_value,
span_filter_funcs: span_filter_funcs
span_filter_funcs: span_filter_funcs,
span_customizers: span_customizers
)
end
end
Expand Down
18 changes: 18 additions & 0 deletions lib/braintrust/span_customizer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

module Braintrust
# Optional synchronous hooks that transform outgoing telemetry, not live spans.
# Subclass this class or supply any object implementing the desired hooks.
class SpanCustomizer
# Transform completed span data through a mutable view with read-only IDs.
# Return this view or a replacement SpanData, never nil. Nested objects are
# shared; prefer replacing attribute values over mutating them in place.
# Exceptions fail the entire batch but do not roll back mutations.
#
# @param span [Braintrust::Trace::SpanExportData]
# @return [Braintrust::Trace::SpanExportData, OpenTelemetry::SDK::Trace::SpanData]
def on_span_export(span)
span
end
end
end
6 changes: 4 additions & 2 deletions lib/braintrust/state.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ class MissingAPIKeyError < ArgumentError; end
# @param tracer_provider [TracerProvider, nil] Optional tracer provider to use
# @param filter_ai_spans [Boolean, nil] Enable AI span filtering
# @param span_filter_funcs [Array<Proc>, nil] Custom span filter functions
# @param span_customizers [Array<SpanCustomizer>, nil] Ordered synchronous export customizers
# @param exporter [Exporter, nil] Optional exporter override (for testing)
# @return [State] the created state
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)
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)
require_relative "config"
config = Config.from_env(
api_key: api_key,
Expand All @@ -35,7 +36,8 @@ def self.from_env(api_key: nil, org_name: nil, default_project: nil, app_url: ni
app_url: app_url,
api_url: api_url,
filter_ai_spans: filter_ai_spans,
span_filter_funcs: span_filter_funcs
span_filter_funcs: span_filter_funcs,
span_customizers: span_customizers
)
new(
api_key: config.api_key,
Expand Down
8 changes: 7 additions & 1 deletion lib/braintrust/trace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,16 @@ def self.enable(tracer_provider, state: nil, exporter: nil, config: nil)
# Get config from state if available
config ||= state.respond_to?(:config) ? state.config : nil

# Customizers only run in SpanExporter; refuse to silently skip them (e.g. redaction).
if exporter && config&.span_customizers&.any?
raise ArgumentError, "span_customizers are not supported with a custom exporter"
end

# Create OTLP HTTP exporter unless override provided
exporter ||= SpanExporter.new(
endpoint: "#{state.api_url}/otel/v1/traces",
api_key: state.api_key
api_key: state.api_key,
span_customizers: config&.span_customizers
)

# Use SimpleSpanProcessor for InMemorySpanExporter (testing), BatchSpanProcessor for production
Expand Down
73 changes: 73 additions & 0 deletions lib/braintrust/trace/span_customizers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# frozen_string_literal: true

require_relative "span_export_data"

module Braintrust
module Trace
# Ordered export-time transformations, independent of origin and transport.
class SpanCustomizers
def initialize(customizers = nil)
@customizers = (customizers || []).dup.freeze
end

def empty?
@customizers.empty?
end

# Transform the whole batch before the exporter groups or serializes it.
# Errors propagate to the exporter so it can fail the batch without sending.
def customize(span_data)
return span_data if empty?

# Hooks may call instrumented clients. Suppress tracing so those spans
# cannot re-enter the exporter and customize themselves forever.
OpenTelemetry::Common::Utilities.untraced do
span_data.map { |span| customize_span(span) }
end
end

private

def customize_span(span)
# Snapshot IDs so in-place mutation through to_span_data cannot slip past validation.
trace_id = span.trace_id.dup.freeze
span_id = span.span_id.dup.freeze
parent_span_id = span.parent_span_id.dup.freeze
# Preserve drops caused by SDK limits; entries a customizer deletes are not drops.
dropped_attributes = dropped(span.total_recorded_attributes, span.attributes)
dropped_events = dropped(span.total_recorded_events, span.events)
dropped_links = dropped(span.total_recorded_links, span.links)
view = nil
@customizers.each do |customizer|
next unless customizer.respond_to?(:on_span_export)
view ||= writable_view(span)

result = customizer.on_span_export(view)
span = result.is_a?(SpanExportData) ? result.to_span_data : result
unless span.is_a?(OpenTelemetry::SDK::Trace::SpanData)
raise TypeError, "SpanCustomizer#on_span_export must return SpanExportData or SpanData"
end
unless span.trace_id == trace_id && span.span_id == span_id && span.parent_span_id == parent_span_id
raise ArgumentError, "SpanCustomizer#on_span_export must preserve trace, span and parent IDs"
end
view = result.is_a?(SpanExportData) ? result : nil
end
span.total_recorded_attributes = span.attributes&.size.to_i + dropped_attributes
span.total_recorded_events = span.events&.size.to_i + dropped_events
span.total_recorded_links = span.links&.size.to_i + dropped_links
span
end

# OTel and replacement spans may carry frozen attributes. Only the hash
# needs to be writable; values and other nested objects remain shared.
def writable_view(span)
span.attributes = span.attributes&.dup || {}
SpanExportData.new(span)
end

def dropped(total, entries)
[total.to_i - entries&.size.to_i, 0].max
end
end
end
end
35 changes: 35 additions & 0 deletions lib/braintrust/trace/span_export_data.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

require "forwardable"
require "opentelemetry/sdk"

module Braintrust
module Trace
# Mutable view of completed span data with read-only identity fields.
# Attribute values and other nested objects are shared, not deep-copied.
class SpanExportData
extend Forwardable

ID_FIELDS = [:trace_id, :span_id, :parent_span_id].freeze
MUTABLE_FIELDS = (OpenTelemetry::SDK::Trace::SpanData.members - ID_FIELDS).freeze
private_constant :ID_FIELDS, :MUTABLE_FIELDS

attr_reader(*ID_FIELDS)
def_delegators :@span_data, *MUTABLE_FIELDS, *MUTABLE_FIELDS.map { |field| :"#{field}=" }
def_delegators :@span_data, :hex_trace_id, :hex_span_id, :hex_parent_span_id, :instrumentation_library

def initialize(span_data)
@span_data = span_data
@trace_id = span_data.trace_id.dup.freeze
@span_id = span_data.span_id.dup.freeze
@parent_span_id = span_data.parent_span_id.dup.freeze
end

# Access the underlying OpenTelemetry representation, e.g. to build a replacement.
# Replacement IDs are validated by the exporter after each hook.
def to_span_data
@span_data
end
end
end
end
Loading
Loading