Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion agent_trace/core/tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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 ───────────────────────────────────────────

Expand Down
24 changes: 24 additions & 0 deletions tests/test_tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading