From 042b212843dd41adaf3ca78f9a071874b1dbda76 Mon Sep 17 00:00:00 2001 From: Nishchay Mahor Date: Sun, 5 Jul 2026 01:22:23 -0700 Subject: [PATCH] fix: avoid duplicate spans when a full fetch follows a typed fetch CachedSpanFetcher.get_spans() with no filter re-fetches every span and _fetch_spans appends into the per-type cache, so a prior typed fetch's spans (e.g. get_spans(["llm"]) then get_spans()) were duplicated. Treat a full fetch as authoritative and reset the cache before it. Adds a regression test. --- py/src/braintrust/test_trace.py | 22 ++++++++++++++++++++++ py/src/braintrust/trace.py | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/py/src/braintrust/test_trace.py b/py/src/braintrust/test_trace.py index 346b47a1..3a572f68 100644 --- a/py/src/braintrust/test_trace.py +++ b/py/src/braintrust/test_trace.py @@ -41,6 +41,28 @@ async def fetch_fn(span_type): assert len(result) == 3 assert {s.span_id for s in result} == {"span-1", "span-2", "span-3"} + @pytest.mark.asyncio + async def test_fetch_all_after_typed_fetch_has_no_duplicates(self): + """A typed fetch followed by a full fetch must not duplicate spans.""" + all_spans = [ + make_span("fn-1", "function"), + make_span("llm-1", "llm"), + make_span("llm-2", "llm"), + ] + + async def fetch_fn(span_type): + if span_type: + return [s for s in all_spans if s.span_attributes["type"] in span_type] + return all_spans + + fetcher = CachedSpanFetcher(fetch_fn=fetch_fn) + await fetcher.get_spans(["llm"]) + result = await fetcher.get_spans() + + span_ids = [s.span_id for s in result] + assert sorted(span_ids) == ["fn-1", "llm-1", "llm-2"] + assert len(span_ids) == len(set(span_ids)), f"duplicate spans: {span_ids}" + @pytest.mark.asyncio async def test_fetch_preserves_span_result_fields(self): """Test that fetched spans preserve fields needed for full trace attachments.""" diff --git a/py/src/braintrust/trace.py b/py/src/braintrust/trace.py index ee62d671..b72b8d62 100644 --- a/py/src/braintrust/trace.py +++ b/py/src/braintrust/trace.py @@ -260,6 +260,10 @@ async def get_spans( # If no filter requested, fetch everything if not span_type or len(span_type) == 0: + # A full fetch is authoritative; reset the per-type cache first so a + # prior typed fetch's spans are not duplicated by re-fetching them + # (_fetch_spans appends). + self._span_cache = {} await self._fetch_spans(None) if self._span_cache: # Only cache if we got results self._all_fetched = True