diff --git a/lib/braintrust/eval/runner.rb b/lib/braintrust/eval/runner.rb index 262c389..08430ff 100644 --- a/lib/braintrust/eval/runner.rb +++ b/lib/braintrust/eval/runner.rb @@ -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 @@ -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 @@ -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 diff --git a/test/braintrust/eval/runner_test.rb b/test/braintrust/eval/runner_test.rb index d52f3ce..de7b1c7 100644 --- a/test/braintrust/eval/runner_test.rb +++ b/test/braintrust/eval/runner_test.rb @@ -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 # ============================================