Skip to content
Draft
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
23 changes: 20 additions & 3 deletions lib/braintrust/eval/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ def initialize(eval_context)
# Mutexes for thread-safe result collection
@score_mutex = Mutex.new
@classification_mutex = Mutex.new

@needs_trace = (eval_context.scorers + eval_context.classifiers).any? { |c| callable_wants_trace?(c) }
end

# Run evaluation and return Result
Expand Down Expand Up @@ -108,9 +110,13 @@ def run_eval_case(kase, errors)
next
end

# Flush spans so they're queryable via BTQL, then build trace
eval_context.tracer_provider.force_flush if eval_context.tracer_provider.respond_to?(:force_flush)
kase.trace = build_trace(eval_span)
# Flush spans so they're queryable via BTQL, then build trace — only when a
# scorer/classifier actually declared `trace:`, since the synchronous flush
# is a real per-case network wait (see #210).
if @needs_trace
eval_context.tracer_provider.force_flush if eval_context.tracer_provider.respond_to?(:force_flush)
kase.trace = build_trace(eval_span)
end

# Run scorers
begin
Expand Down Expand Up @@ -229,6 +235,17 @@ def run_scorer(scorer, scorer_kwargs, scorer_input)
end
end

# Whether a callable's declared parameters include `trace:`, or accept
# arbitrary kwargs (**kwargs) and so might use it. KeywordFilter already
# relies on #call_parameters for this same introspection.
# @param callable [Scorer, Classifier] a scorer or classifier instance
# @return [Boolean]
def callable_wants_trace?(callable)
return true unless callable.respond_to?(:call_parameters)

callable.call_parameters.any? { |type, name| name == :trace || type == :keyrest }
end

# Build a lazy Trace for a case, backed by BTQL.
# Returns nil when state or experiment_id are unavailable (local-only mode).
# @param eval_span [OpenTelemetry::Trace::Span] The eval span for this case
Expand Down
52 changes: 52 additions & 0 deletions test/braintrust/eval/runner_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1301,6 +1301,58 @@ def test_trace_works_with_parallelism
end
end

def test_force_flush_skipped_when_no_scorer_wants_trace
rig = setup_otel_test_rig
flush_calls = 0
rig.tracer_provider.stub(:force_flush, -> { flush_calls += 1 }) do
scorer = Braintrust::Scorer.new("simple") { |output:, expected:| (output == expected) ? 1.0 : 0.0 }

context = Braintrust::Eval::Context.build(
task: ->(input:) { input.upcase },
scorers: [scorer],
cases: [{input: "hello", expected: "HELLO"}, {input: "world", expected: "WORLD"}],
experiment_id: "exp-123",
experiment_name: "test-experiment",
project_id: "proj-456",
project_name: "test-project",
state: rig.state,
tracer_provider: rig.tracer_provider
)
runner = Braintrust::Eval::Runner.new(context)
result = runner.run

assert result.success?
end

assert_equal 0, flush_calls
end

def test_force_flush_still_happens_when_a_scorer_wants_trace
rig = setup_otel_test_rig
flush_calls = 0
rig.tracer_provider.stub(:force_flush, -> { flush_calls += 1 }) do
scorer = Braintrust::Scorer.new("trace_reader") { |output:, trace:| 1.0 }

context = Braintrust::Eval::Context.build(
task: ->(input:) { input.upcase },
scorers: [scorer],
cases: [{input: "hello"}, {input: "world"}],
experiment_id: "exp-123",
experiment_name: "test-experiment",
project_id: "proj-456",
project_name: "test-project",
state: rig.state,
tracer_provider: rig.tracer_provider
)
runner = Braintrust::Eval::Runner.new(context)
result = runner.run

assert result.success?
end

assert_equal 2, flush_calls
end

# ============================================
# Runner#run tests - structured scorer returns
# ============================================
Expand Down