Skip to content

Commit 46a12e1

Browse files
committed
feat(trace): add advanced span filters
Add nested `filters` to `Trace.get_spans()` so callers can select spans by type, name, error state, tags, metadata, and duration with matching local and BTQL semantics. ```python spans = await trace.get_spans( filters={ "span_type": ["tool"], "name": ["search", "lookup"], "has_error": True, "tags": {"all": ["production"], "none": ["internal"]}, "metadata": {"model": "gpt-5"}, "duration": {"min": 0.5, "max": 10}, } ) ``` Preserve fields needed to evaluate these filters in the local span cache. Keep existing full-trace and span-type caching, but execute advanced remote filters as fresh BTQL requests so partial or empty results do not become stale snapshots: ```text local buffered spans -> in-memory filtering complete trace cache -> in-memory filtering advanced remote filter -> fresh BTQL request ``` Keep `span_type=[...]` compatible on the public `Trace` API but emit a `DeprecationWarning` directing callers to `filters={"span_type": [...]}`. Add runtime and type coverage for validation and equivalent local and server filtering.
1 parent 269eb30 commit 46a12e1

5 files changed

Lines changed: 834 additions & 317 deletions

File tree

‎py/src/braintrust/logger.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4627,6 +4627,9 @@ def log_internal(self, event: dict[str, Any] | None = None, internal_data: dict[
46274627
metadata=serializable_partial_record.get("metadata"),
46284628
span_parents=self.span_parents,
46294629
span_attributes=serializable_partial_record.get("span_attributes"),
4630+
error=serializable_partial_record.get("error"),
4631+
metrics=serializable_partial_record.get("metrics"),
4632+
tags=serializable_partial_record.get("tags"),
46304633
)
46314634
self.state.span_cache.queue_write(self.root_span_id, self.span_id, cached_span)
46324635

‎py/src/braintrust/span_cache.py‎

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from typing import Any
1515

1616
from braintrust.types import Metadata
17-
from braintrust.util import merge_dicts
17+
from braintrust.util import clean_nones, merge_dicts
1818

1919

2020
# Global registry of active span caches for process exit cleanup
@@ -23,7 +23,12 @@
2323

2424

2525
class CachedSpan:
26-
"""Cached span data structure."""
26+
"""A span held in the local cache, before it has been flushed to the server.
27+
28+
Carries the subset of span fields that scorers can filter on, so that a trace can be
29+
queried without a round-trip. Fields the server has but this does not are simply not
30+
filterable locally.
31+
"""
2732

2833
def __init__(
2934
self,
@@ -33,40 +38,40 @@ def __init__(
3338
metadata: Metadata | None = None,
3439
span_parents: list[str] | None = None,
3540
span_attributes: dict[str, Any] | None = None,
41+
error: Any | None = None,
42+
metrics: dict[str, Any] | None = None,
43+
tags: list[str] | None = None,
44+
**kwargs: Any,
3645
):
3746
self.span_id = span_id
3847
self.input = input
3948
self.output = output
4049
self.metadata = metadata
4150
self.span_parents = span_parents
4251
self.span_attributes = span_attributes
52+
self.error = error
53+
self.metrics = metrics
54+
self.tags = tags
55+
# Retain any fields written by a newer SDK so to_dict() round-trips them.
56+
for key, value in kwargs.items():
57+
setattr(self, key, value)
4358

4459
def to_dict(self) -> dict[str, Any]:
45-
"""Convert to dictionary for serialization."""
46-
result = {"span_id": self.span_id}
47-
if self.input is not None:
48-
result["input"] = self.input
49-
if self.output is not None:
50-
result["output"] = self.output
51-
if self.metadata is not None:
52-
result["metadata"] = self.metadata
53-
if self.span_parents is not None:
54-
result["span_parents"] = self.span_parents
55-
if self.span_attributes is not None:
56-
result["span_attributes"] = self.span_attributes
57-
return result
60+
"""Return the span's set fields, dropping those left as None.
61+
62+
Unset fields are omitted rather than written as null to keep the on-disk record
63+
small; span_id is always present, so it survives the stripping.
64+
"""
65+
return clean_nones(self.__dict__)
5866

5967
@classmethod
6068
def from_dict(cls, data: dict[str, Any]) -> "CachedSpan":
61-
"""Create from dictionary."""
62-
return cls(
63-
span_id=data["span_id"],
64-
input=data.get("input"),
65-
output=data.get("output"),
66-
metadata=data.get("metadata"),
67-
span_parents=data.get("span_parents"),
68-
span_attributes=data.get("span_attributes"),
69-
)
69+
"""Rebuild a span from its cached record, keeping fields this class does not name.
70+
71+
Retaining unknown keys means a cache written by a newer SDK stays readable, and
72+
round-trips back out through to_dict() intact.
73+
"""
74+
return cls(**data)
7075

7176

7277
class DiskSpanRecord:

‎py/src/braintrust/test_span_cache.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ def test_span_cache_write_and_read():
1313
span_id="span-1",
1414
input={"text": "hello"},
1515
output={"response": "world"},
16+
error={"message": "retryable"},
17+
metrics={"start": 1, "end": 3},
18+
tags=["production"],
1619
)
1720
span2 = CachedSpan(
1821
span_id="span-2",
@@ -30,6 +33,10 @@ def test_span_cache_write_and_read():
3033
span_ids = {s.span_id for s in spans}
3134
assert "span-1" in span_ids
3235
assert "span-2" in span_ids
36+
stored_span1 = next(span for span in spans if span.span_id == "span-1")
37+
assert stored_span1.error == {"message": "retryable"}
38+
assert stored_span1.metrics == {"start": 1, "end": 3}
39+
assert stored_span1.tags == ["production"]
3340

3441
cache.stop()
3542
cache.dispose()

0 commit comments

Comments
 (0)