filter_ai_spans=True has been a no-op since 0.28.0 — on_end injects braintrust.context_json ahead of AISpanProcessor's prefix filter
What happens
BraintrustSpanProcessor(filter_ai_spans=True) exports every span the tracer provider produces, not just AI spans. HTTP server spans, DB client spans, health-check probes — all of it reaches Braintrust.
Two changes are individually reasonable but interact:
_with_span_origin_attributes unconditionally sets a braintrust.-prefixed attribute (braintrust.context_json) on every span.
BraintrustSpanProcessor.on_end applies that injection before handing the span to AISpanProcessor, whose filter keeps any span with an attribute matching FILTER_PREFIXES — which includes "braintrust.".
So the injected key satisfies the filter on every span, and the filter can never say no.
Affected versions
| version |
behaviour |
<= 0.27.0 |
correct — filter works |
0.28.0 |
broken |
0.29.0 – 0.31.1 (latest) |
broken |
Bisected to #570 "Add span origin provenance" (4e3911a36f, merged 2026-07-15), which shipped in 0.28.0 (tagged 2026-07-16 13:37Z). 0.27.0 (tagged 2026-07-14) is clean. That commit's diff adds both halves at once — the braintrust.-prefixed attribute and the on_end call site that injects it upstream of the filter.
Reproduction
Verified against braintrust==0.30.1; the code path is identical in 0.31.1.
"""Minimal repro: filter_ai_spans=True exports non-genAI spans on >=0.28."""
from importlib.metadata import version
from braintrust.otel import BraintrustSpanProcessor
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.trace import SpanContext, TraceFlags
class Recorder:
"""Stands in for the inner BatchSpanProcessor; records what reaches export."""
def __init__(self, _exporter=None):
self.spans = []
def on_start(self, span, parent_context=None): pass
def on_end(self, span): self.spans.append(span.name)
def shutdown(self): pass
def force_flush(self, timeout_millis=30000): return True
def span(name, attributes):
return ReadableSpan(
name=name,
context=SpanContext(trace_id=1, span_id=1, is_remote=False,
trace_flags=TraceFlags(1)),
attributes=attributes,
)
recorder = Recorder()
processor = BraintrustSpanProcessor(
api_key="sk-dummy",
parent="project_name:repro",
filter_ai_spans=True,
SpanProcessor=lambda _exporter: recorder,
)
processor.on_end(span("GET /health", {"http.route": "/health"}))
processor.on_end(span("SELECT users", {"db.system": "postgresql"}))
processor.on_end(span("chat", {"gen_ai.operation.name": "chat"}))
print(f"braintrust {version('braintrust')}")
print(f"exported: {recorder.spans}")
print(f"expected: ['chat']")
Actual:
braintrust 0.30.1
exported: ['GET /health', 'SELECT users', 'chat']
expected: ['chat']
(The SpanProcessor hook is only used to capture what reaches export without opening a network connection. The filtering behaviour is the same with the default BatchSpanProcessor.)
Root cause
BraintrustSpanProcessor.on_end — injects, then delegates to the filter:
def on_end(self, span):
"""Forward span end events to the inner processor."""
self._exporter.initialize()
self._processor.on_end(_with_span_origin_attributes(span, self._environment))
When filter_ai_spans=True, self._processor is the AISpanProcessor, so the span it filters has already been mutated.
_with_span_origin_attributes sets the key unconditionally:
attributes["braintrust.context_json"] = json.dumps(
merge_span_origin_context(existing_context, "braintrust-python-otel", environment)
)
_should_keep_filtered_span matches it via FILTER_PREFIXES:
FILTER_PREFIXES = ("gen_ai.", "braintrust.", "llm.", "ai.", "traceloop.")
Supporting evidence that the injection point is the anomaly
_on_ending forwards the raw span, with no injection:
def _on_ending(self, span):
"""Forward pre-end hook when the wrapped processor supports it."""
_forward_on_ending(self._processor, span)
So the pre-end hook filters correctly while on_end does not — the two paths disagree, which suggests the injection was added to one call site without accounting for the filter living behind it.
Impact
Anyone relying on the documented filter_ai_spans=True is silently exporting their full trace volume to Braintrust. It fails quietly: no error, no warning, just more spans and more billable volume than intended. We noticed only because non-AI span names started appearing in a project.
Suggested fix
Filter on the un-mutated span, then inject. Options, roughly in order of preference:
- Have
AISpanProcessor receive the raw span and apply the origin injection inside its kept branch — e.g. compose as AISpanProcessor(_OriginInjectingProcessor(batch_processor)) so injection happens downstream of the filter decision.
- Keep the current composition but evaluate
_should_keep_filtered_span against the pre-injection attributes.
- Exclude the SDK's own bookkeeping keys from qualifying a span (e.g. treat
braintrust.context_json as not-a-signal). Narrower, but leaves the general shape — internal attribute added ahead of a filter that matches its prefix — able to recur.
A regression test that drives a non-AI span through a real BraintrustSpanProcessor(filter_ai_spans=True) and asserts nothing reaches the exporter would catch this class of bug; a test of _should_keep_filtered_span alone passes right through it, since the defect is in what the span looks like by the time the predicate sees it.
Workaround
Pass an explicit custom_filter that always returns a hard bool. _should_keep_filtered_span consults it before any prefix matching, and an explicit False short-circuits:
_MARKERS = frozenset({
"gen_ai.conversation.id",
"gen_ai.operation.name",
"gen_ai.agent.name",
})
def only_genai(span) -> bool:
attributes = getattr(span, "attributes", None) or {}
return any(m in attributes for m in _MARKERS)
BraintrustSpanProcessor(filter_ai_spans=True, custom_filter=only_genai, parent=...)
Two notes for anyone copying this: filter_ai_spans=True must stay, since it's what installs the processor that consults custom_filter; and the predicate must return True/False and never None, because None falls through to the broken default.
Co-authored-by: Claude Opus 5 noreply@anthropic.com
filter_ai_spans=Truehas been a no-op since 0.28.0 —on_endinjectsbraintrust.context_jsonahead ofAISpanProcessor's prefix filterWhat happens
BraintrustSpanProcessor(filter_ai_spans=True)exports every span the tracer provider produces, not just AI spans. HTTP server spans, DB client spans, health-check probes — all of it reaches Braintrust.Two changes are individually reasonable but interact:
_with_span_origin_attributesunconditionally sets abraintrust.-prefixed attribute (braintrust.context_json) on every span.BraintrustSpanProcessor.on_endapplies that injection before handing the span toAISpanProcessor, whose filter keeps any span with an attribute matchingFILTER_PREFIXES— which includes"braintrust.".So the injected key satisfies the filter on every span, and the filter can never say no.
Affected versions
<= 0.27.00.28.00.29.0–0.31.1(latest)Bisected to #570 "Add span origin provenance" (
4e3911a36f, merged 2026-07-15), which shipped in 0.28.0 (tagged 2026-07-16 13:37Z). 0.27.0 (tagged 2026-07-14) is clean. That commit's diff adds both halves at once — thebraintrust.-prefixed attribute and theon_endcall site that injects it upstream of the filter.Reproduction
Verified against
braintrust==0.30.1; the code path is identical in 0.31.1.Actual:
(The
SpanProcessorhook is only used to capture what reaches export without opening a network connection. The filtering behaviour is the same with the defaultBatchSpanProcessor.)Root cause
BraintrustSpanProcessor.on_end— injects, then delegates to the filter:When
filter_ai_spans=True,self._processoris theAISpanProcessor, so the span it filters has already been mutated._with_span_origin_attributessets the key unconditionally:_should_keep_filtered_spanmatches it viaFILTER_PREFIXES:Supporting evidence that the injection point is the anomaly
_on_endingforwards the raw span, with no injection:So the pre-end hook filters correctly while
on_enddoes not — the two paths disagree, which suggests the injection was added to one call site without accounting for the filter living behind it.Impact
Anyone relying on the documented
filter_ai_spans=Trueis silently exporting their full trace volume to Braintrust. It fails quietly: no error, no warning, just more spans and more billable volume than intended. We noticed only because non-AI span names started appearing in a project.Suggested fix
Filter on the un-mutated span, then inject. Options, roughly in order of preference:
AISpanProcessorreceive the raw span and apply the origin injection inside its kept branch — e.g. compose asAISpanProcessor(_OriginInjectingProcessor(batch_processor))so injection happens downstream of the filter decision._should_keep_filtered_spanagainst the pre-injection attributes.braintrust.context_jsonas not-a-signal). Narrower, but leaves the general shape — internal attribute added ahead of a filter that matches its prefix — able to recur.A regression test that drives a non-AI span through a real
BraintrustSpanProcessor(filter_ai_spans=True)and asserts nothing reaches the exporter would catch this class of bug; a test of_should_keep_filtered_spanalone passes right through it, since the defect is in what the span looks like by the time the predicate sees it.Workaround
Pass an explicit
custom_filterthat always returns a hardbool._should_keep_filtered_spanconsults it before any prefix matching, and an explicitFalseshort-circuits:Two notes for anyone copying this:
filter_ai_spans=Truemust stay, since it's what installs the processor that consultscustom_filter; and the predicate must returnTrue/Falseand neverNone, becauseNonefalls through to the broken default.Co-authored-by: Claude Opus 5 noreply@anthropic.com