From 4221cbceb5e059ff249f740f3b81a90ce2ed9c52 Mon Sep 17 00:00:00 2001 From: willwang Date: Thu, 13 Aug 2026 00:18:40 +0800 Subject: [PATCH] fix: retain standalone integration traces --- agent_trace/core/tracer.py | 35 ++++++++++++++++++++++++++++++++++- tests/test_tracer.py | 24 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/agent_trace/core/tracer.py b/agent_trace/core/tracer.py index 2c3514b..54b0413 100644 --- a/agent_trace/core/tracer.py +++ b/agent_trace/core/tracer.py @@ -177,7 +177,8 @@ def start_span( The new :class:`Span`. """ parent_span = get_current_span() - trace_id = get_current_trace() or uuid.uuid4().hex + current_trace_id = get_current_trace() + trace_id = current_trace_id or uuid.uuid4().hex span_data = SpanData( trace_id=trace_id, @@ -193,6 +194,24 @@ def start_span( if parent_span: parent_span._data.children.append(span_data) + # Auto-instrumentations call start_span() directly. When there is no + # surrounding trace, retain that operation as a one-span trace instead + # of silently discarding it when the span ends. + standalone_trace = None + trace_token = None + if parent_span is None and current_trace_id is None: + standalone_trace = TraceData( + trace_id=trace_id, + name=name, + start_time=span_data.start_time, + root_span=span_data, + metadata={}, + ) + with self._lock: + self._traces[trace_id] = standalone_trace + trace_token = set_current_trace(trace_id) + self._emit("trace_start", {"trace_id": trace_id, "name": name}) + with self._lock: self._active_spans[span_data.span_id] = span @@ -218,6 +237,20 @@ def start_span( span.end() # Restore previous span context using proper reset reset_current_span(old_span_token) + if standalone_trace is not None: + standalone_trace.end_time = span_data.end_time + if trace_token is not None: + reset_current_trace(trace_token) + self._emit( + "trace_end", + { + "trace_id": trace_id, + "duration_ms": standalone_trace.total_duration_ms, + "total_tokens": standalone_trace.total_tokens, + "total_cost": standalone_trace.total_cost, + "span_count": standalone_trace.span_count, + }, + ) # ── Internal callbacks ─────────────────────────────────────────── diff --git a/tests/test_tracer.py b/tests/test_tracer.py index 516377c..198aae9 100644 --- a/tests/test_tracer.py +++ b/tests/test_tracer.py @@ -27,6 +27,30 @@ def test_nested_spans(self): trace = self.tracer.get_all_traces()[-1] assert trace.span_count >= 3 + def test_standalone_span_creates_queryable_trace(self): + with self.tracer.start_span("openai.gpt-test", SpanKind.LLM) as span: + span.set_output({"content": "ok"}) + with self.tracer.start_span("nested-tool", SpanKind.TOOL): + pass + + traces = self.tracer.get_all_traces() + assert len(traces) == 1 + assert traces[0].name == "openai.gpt-test" + assert traces[0].root_span is span.data + assert traces[0].end_time is not None + assert traces[0].span_count == 2 + + def test_standalone_span_restores_context_and_records_errors(self): + with pytest.raises(RuntimeError, match="provider failed"): + with self.tracer.start_span("anthropic.test", SpanKind.LLM): + raise RuntimeError("provider failed") + + trace = self.tracer.get_all_traces()[-1] + assert trace.root_span.status == SpanStatus.ERROR + assert trace.end_time is not None + assert get_current_span() is None + assert get_current_trace() is None + def test_span_error_handling(self): with pytest.raises(ValueError): with self.tracer.start_trace("error-test") as root: