Files
DocsGPT/docsgpt/tracing/core.py
T
arc53-machine 0591580014 Keep exception text out of stored and exported span errors
Span.fail stored the exception message in span.error, which is stored and
exported whatever the content settings; a provider's content-filter error
can quote the prompt. span.error now holds the exception type, and the
message is a capture-gated preview, like tool errors already were. A
yielded stream error on the agent span is treated the same way.
2026-09-23 22:42:05 +01:00

641 lines
21 KiB
Python

"""In-memory recording of one execution trace.
A :class:`Trace` collects spans for a single request (a chat turn, a
scheduled run, a search). Spans are recorded while the request runs and the
whole trace is written once, when its owner calls :func:`flush`.
Nesting is tracked with one span stack per thread. Only *container* spans
(agent, tool, retrieval, rerank, guardrail, step) are pushed, so a leaf such as an LLM call can
never become the parent of a sibling that starts while it is still open --
which matters because DocsGPT's agent loop is a chain of suspended
generators. Ending a span pops anything left above it (marked
``cancelled``), and :meth:`Trace.finish` closes whatever is still open, so a
generator finalized late can never corrupt the tree or the stored record.
"""
from __future__ import annotations
import contextlib
import functools
import logging
import threading
import time
import uuid
from contextvars import ContextVar
from typing import Any, Callable, Dict, Iterator, List, Optional
from docsgpt.core.settings import settings
from docsgpt.tracing.preview import make_preview
logger = logging.getLogger(__name__)
KIND_AGENT = "agent"
KIND_LLM = "llm"
KIND_TOOL = "tool"
KIND_RETRIEVAL = "retrieval"
KIND_SEARCH = "search"
KIND_EMBEDDING = "embedding"
KIND_RERANK = "rerank"
KIND_GUARDRAIL = "guardrail"
KIND_STEP = "step"
#: Kinds that become the implicit parent of spans started while they are open.
CONTAINER_KINDS = frozenset(
{KIND_AGENT, KIND_TOOL, KIND_RETRIEVAL, KIND_RERANK, KIND_GUARDRAIL, KIND_STEP}
)
STATUS_OK = "ok"
STATUS_ERROR = "error"
STATUS_CANCELLED = "cancelled"
STATUS_PAUSED = "paused"
STATUS_PENDING = "pending"
STATUS_DENIED = "denied"
STATUS_SKIPPED = "skipped"
#: Trace ids ``bind`` may set; anything else is ignored.
BINDABLE_IDS = frozenset(
{
"request_id",
"message_id",
"conversation_id",
"activity_id",
"workflow_run_id",
"user_id",
"agent_id",
"name",
}
)
_current: ContextVar[Optional["Trace"]] = ContextVar("docsgpt_trace", default=None)
def _new_id() -> str:
return uuid.uuid4().hex[:16]
class Span:
"""One timed step inside a trace.
Attribute keys follow the OTel GenAI conventions (``gen_ai.*``) where one
exists and ``docsgpt.*`` otherwise, so the stored trace and the exported
spans share a vocabulary.
"""
__slots__ = (
"trace",
"id",
"parent_id",
"kind",
"name",
"attributes",
"previews",
"status",
"error",
"start_perf_ns",
"end_perf_ns",
"_thread",
"_pushed",
)
def __init__(
self,
trace: "Trace",
kind: str,
name: str,
parent_id: Optional[str],
attributes: Optional[Dict[str, Any]] = None,
) -> None:
self.trace = trace
self.id = _new_id()
self.parent_id = parent_id
self.kind = kind
self.name = name
self.attributes: Dict[str, Any] = dict(attributes or {})
self.previews: Dict[str, Any] = {}
self.status: Optional[str] = None
self.error: Optional[str] = None
self.start_perf_ns = time.perf_counter_ns()
self.end_perf_ns: Optional[int] = None
self._thread: Optional[int] = None
self._pushed = False
@property
def ended(self) -> bool:
return self.end_perf_ns is not None
@property
def duration_ms(self) -> Optional[float]:
if self.end_perf_ns is None:
return None
return (self.end_perf_ns - self.start_perf_ns) / 1e6
def set(self, **attributes: Any) -> "Span":
"""Merge attributes; ``None`` values are ignored. Keys may contain dots via ``**{...}``."""
if not self.ended:
self.attributes.update({k: v for k, v in attributes.items() if v is not None})
return self
def preview(self, key: str, value: Any) -> "Span":
"""Attach a bounded, redacted content preview (skipped when capture is off)."""
if self.ended or value is None or not settings.TRACES_CAPTURE_CONTENT:
return self
try:
self.previews[key] = make_preview(value)
except Exception: # noqa: BLE001 - a preview must never break a request
logger.debug("trace preview failed for %s", key, exc_info=True)
return self
def fail(self, exc: BaseException) -> "Span":
"""Record ``exc`` on the span without ending it.
``error`` and ``error.type`` hold only the exception type: they are
stored and exported whatever the content settings, and exception text
can quote the prompt (a provider's content-filter error) or a tool
result. The message itself becomes the capture-gated ``error``
preview, dropped with every other preview when capture is off or a
guardrail fired.
"""
self.status = STATUS_ERROR
self.error = type(exc).__name__
self.attributes["error.type"] = type(exc).__name__
message = str(exc)
if message:
self.preview("error", message)
return self
def end(
self,
status: Optional[str] = None,
*,
error: Optional[BaseException] = None,
attributes: Optional[Dict[str, Any]] = None,
) -> None:
"""Close the span. A second end, or an end after the trace finished, is ignored."""
if self.ended:
return
if attributes:
self.set(**attributes)
if error is not None:
self.fail(error)
if status is not None:
self.status = status
elif self.status is None:
self.status = STATUS_OK
self.end_perf_ns = time.perf_counter_ns()
self.trace._on_end(self)
def __enter__(self) -> "Span":
return self
def __exit__(self, exc_type, exc, tb) -> bool:
if exc is None:
self.end()
elif isinstance(exc, GeneratorExit):
self.end(STATUS_CANCELLED)
else:
self.end(error=exc)
return False
class _NoopSpan:
"""Stand-in returned when no trace is active or the span cap is reached."""
id = None
parent_id = None
kind = None
name = ""
status = None
ended = True
duration_ms = None
@property
def attributes(self) -> Dict[str, Any]:
return {}
@property
def previews(self) -> Dict[str, Any]:
return {}
def set(self, **attributes: Any) -> "_NoopSpan":
return self
def preview(self, key: str, value: Any) -> "_NoopSpan":
return self
def fail(self, exc: BaseException) -> "_NoopSpan":
return self
def end(self, status=None, *, error=None, attributes=None) -> None:
return None
def __enter__(self) -> "_NoopSpan":
return self
def __exit__(self, exc_type, exc, tb) -> bool:
return False
def __bool__(self) -> bool:
return False
NOOP_SPAN = _NoopSpan()
class Trace:
"""All spans recorded for one execution, plus the ids that link it to logs."""
def __init__(
self,
*,
source: str,
name: Optional[str] = None,
request_id: Optional[str] = None,
message_id: Optional[str] = None,
conversation_id: Optional[str] = None,
activity_id: Optional[str] = None,
workflow_run_id: Optional[str] = None,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
otel_context: Any = None,
) -> None:
self.id = str(uuid.uuid4())
self.source = source
self.name = name or source
self.request_id = request_id
self.message_id = message_id
self.conversation_id = conversation_id
self.activity_id = activity_id
self.workflow_run_id = workflow_run_id
self.user_id = user_id
self.agent_id = agent_id
self.otel_context = otel_context
self.otel_trace_id: Optional[str] = None
self.start_ns = time.time_ns()
self.start_perf_ns = time.perf_counter_ns()
self.end_perf_ns: Optional[int] = None
self.status: Optional[str] = None
# Status the owner determined mid-run (e.g. ``paused`` at a tool
# approval); used by :meth:`finish` when no explicit status is given.
self.outcome: Optional[str] = None
self.spans: List[Span] = []
self.dropped_spans = 0
self.content_blocked = False
self.attributes: Dict[str, Any] = {}
self.finished = False
self.flushed = False
self._seen_keys: set = set()
self._lock = threading.Lock()
self._stacks: Dict[int, List[Any]] = {}
# -- span lifecycle -------------------------------------------------
def _parent_for_current_thread(self) -> Optional[str]:
stack = self._stacks.get(threading.get_ident())
return stack[-1].id if stack else None
def start_span(
self,
kind: str,
name: str,
*,
parent: Any = None,
attributes: Optional[Dict[str, Any]] = None,
) -> Any:
"""Record a new span; returns :data:`NOOP_SPAN` once finished or over the cap."""
with self._lock:
if self.finished:
return NOOP_SPAN
if len(self.spans) >= settings.TRACES_MAX_SPANS:
self.dropped_spans += 1
return NOOP_SPAN
if parent is not None:
parent_id = getattr(parent, "id", None)
else:
parent_id = self._parent_for_current_thread()
span = Span(self, kind, name, parent_id, attributes)
self.spans.append(span)
if kind in CONTAINER_KINDS:
ident = threading.get_ident()
self._stacks.setdefault(ident, []).append(span)
span._thread = ident
span._pushed = True
return span
def _on_end(self, span: Span) -> None:
if not span._pushed:
return
with self._lock:
stack = self._stacks.get(span._thread)
if not stack or span not in stack:
return
index = stack.index(span)
abandoned = stack[index + 1:]
del stack[index:]
if not stack:
self._stacks.pop(span._thread, None)
for child in reversed(abandoned):
if isinstance(child, Span) and not child.ended:
child.end(STATUS_CANCELLED)
def _seed_thread(self, parent: Optional[Span]) -> Callable[[], None]:
"""Make ``parent`` the implicit parent in this thread; returns an undo callable."""
if parent is None:
return lambda: None
ident = threading.get_ident()
marker = _Seed(parent.id)
with self._lock:
self._stacks.setdefault(ident, []).append(marker)
def _undo() -> None:
with self._lock:
stack = self._stacks.get(ident)
if stack and marker in stack:
del stack[stack.index(marker):]
if not stack:
self._stacks.pop(ident, None)
return _undo
def current_parent(self) -> Optional[Any]:
stack = self._stacks.get(threading.get_ident())
return stack[-1] if stack else None
def first_occurrence(self, key: Any) -> bool:
"""True the first time ``key`` is seen in this trace, then False."""
with self._lock:
if key in self._seen_keys:
return False
self._seen_keys.add(key)
return True
# -- ids --------------------------------------------------------------
def bind(self, *, only_if_unset: bool = False, **ids: Any) -> None:
for key, value in ids.items():
if key not in BINDABLE_IDS or value is None:
continue
if only_if_unset and getattr(self, key, None):
continue
setattr(self, key, str(value))
# -- completion ---------------------------------------------------------
def finish(self, status: Optional[str] = None) -> None:
"""Freeze the trace: close open spans as ``cancelled`` and set the status."""
with self._lock:
if self.finished:
return
self.finished = True
open_spans = [s for s in self.spans if not s.ended]
self._stacks.clear()
now = time.perf_counter_ns()
for span in open_spans:
span.status = STATUS_CANCELLED
span.end_perf_ns = now
self.end_perf_ns = now
if status is None:
status = self.outcome
if status is not None:
self.status = status
else:
failed = any(
s.parent_id is None and s.status == STATUS_ERROR for s in self.spans
)
self.status = STATUS_ERROR if failed else STATUS_OK
@property
def duration_ms(self) -> Optional[float]:
if self.end_perf_ns is None:
return None
return (self.end_perf_ns - self.start_perf_ns) / 1e6
def span_start_ns(self, span: Span) -> int:
"""Wall-clock start of ``span`` in ns, derived from the monotonic offset."""
return self.start_ns + (span.start_perf_ns - self.start_perf_ns)
def span_end_ns(self, span: Span) -> int:
end = span.end_perf_ns if span.end_perf_ns is not None else self.end_perf_ns
return self.start_ns + ((end or span.start_perf_ns) - self.start_perf_ns)
def summary(self) -> Dict[str, Any]:
"""Aggregate counts shown as chips in the Logs UI."""
by_id = {s.id: s for s in self.spans}
llm = [s for s in self.spans if s.kind == KIND_LLM]
# Outermost retrieval spans only, so nested dispatcher/retriever
# spans are not double-counted.
retrieval = [
s
for s in self.spans
if s.kind == KIND_RETRIEVAL
and not (s.parent_id in by_id and by_id[s.parent_id].kind == KIND_RETRIEVAL)
]
# Only calls that ran count: a call paused for approval is recorded
# again when it runs in the next round, and denied or skipped calls
# never ran at all.
tools = [
s for s in self.spans if s.kind == KIND_TOOL and s.status in (STATUS_OK, STATUS_ERROR)
]
def _tokens(key: str) -> int:
total = 0
for s in llm:
value = s.attributes.get(key)
if isinstance(value, (int, float)):
total += int(value)
return total
return {
"llm_calls": len(llm),
"tool_calls": len(tools),
"retrieval_calls": len(retrieval),
"retrieval_ms": round(sum(s.duration_ms or 0 for s in retrieval), 1),
"input_tokens": _tokens("gen_ai.usage.input_tokens"),
"output_tokens": _tokens("gen_ai.usage.output_tokens"),
"errors": sum(1 for s in self.spans if s.status == STATUS_ERROR),
}
def to_record(self) -> Dict[str, Any]:
"""The ``request_traces`` row for this (finished) trace."""
summary = self.summary()
# Traces without a log row of their own (searches) are listed by
# their query; keep it in the small summary so listing never reads
# ``spans``. Dropped with the other previews when content is blocked.
if not self.content_blocked and self.spans:
query = self.spans[0].previews.get("query")
if isinstance(query, str):
summary["query"] = query
spans = []
for s in self.spans:
entry: Dict[str, Any] = {
"id": s.id,
"parent_id": s.parent_id,
"kind": s.kind,
"name": s.name,
"status": s.status or STATUS_CANCELLED,
"offset_ms": round((s.start_perf_ns - self.start_perf_ns) / 1e6, 2),
"duration_ms": round(s.duration_ms or 0.0, 2),
"attributes": s.attributes,
}
if s.error:
entry["error"] = s.error
if s.previews and not self.content_blocked:
entry["preview"] = s.previews
spans.append(entry)
return {
"id": self.id,
"request_id": self.request_id,
"message_id": self.message_id,
"conversation_id": self.conversation_id,
"activity_id": self.activity_id,
"workflow_run_id": self.workflow_run_id,
"user_id": self.user_id,
"agent_id": self.agent_id,
"source": self.source,
"name": self.name,
"status": self.status or STATUS_OK,
"started_at_ns": self.start_ns,
"duration_ms": int(round(self.duration_ms or 0)),
"span_count": len(spans),
"dropped_spans": self.dropped_spans,
"summary": summary,
"spans": spans,
"otel_trace_id": self.otel_trace_id,
}
class _Seed:
"""Placeholder stack entry naming a parent span owned by another thread."""
__slots__ = ("id",)
def __init__(self, span_id: Optional[str]) -> None:
self.id = span_id
# -- module-level API --------------------------------------------------------
def current_trace() -> Optional[Trace]:
"""The trace active in this context, or ``None``."""
return _current.get()
def start_trace(*, source: str, capture_otel_context: bool = True, **ids: Any) -> Optional[Trace]:
"""Create a trace, or return ``None`` when ``TRACES_ENABLED`` is off.
The OTel context current at this point (normally the HTTP server span) is
captured so the exported GenAI spans hang off the request's own trace.
"""
if not settings.TRACES_ENABLED:
return None
otel_context = None
if capture_otel_context:
try:
from opentelemetry import context as otel_ctx
otel_context = otel_ctx.get_current()
except Exception: # noqa: BLE001
otel_context = None
known = {k: v for k, v in ids.items() if k in BINDABLE_IDS}
known = {k: (str(v) if v is not None else None) for k, v in known.items()}
return Trace(source=source, otel_context=otel_context, **known)
@contextlib.contextmanager
def activate(trace: Optional[Trace]) -> Iterator[Optional[Trace]]:
"""Make ``trace`` current for the enclosed block (a no-op for ``None``)."""
if trace is None:
yield None
return
token = _current.set(trace)
try:
yield trace
finally:
try:
_current.reset(token)
except ValueError:
# Reset from a different context (a generator finalized
# elsewhere); clearing is the closest safe equivalent.
_current.set(None)
def start_span(
kind: str, name: str, *, parent: Any = None, attributes: Optional[Dict[str, Any]] = None
) -> Any:
"""Start a span in the current trace; returns :data:`NOOP_SPAN` without one."""
trace = _current.get()
if trace is None:
return NOOP_SPAN
try:
return trace.start_span(kind, name, parent=parent, attributes=attributes)
except Exception: # noqa: BLE001 - tracing must never break a request
logger.debug("trace start_span failed", exc_info=True)
return NOOP_SPAN
def span(
kind: str, name: str, *, parent: Any = None, attributes: Optional[Dict[str, Any]] = None
) -> Any:
"""Context-manager form of :func:`start_span` (errors mark the span and re-raise)."""
return start_span(kind, name, parent=parent, attributes=attributes)
def bind(**ids: Any) -> None:
"""Set link ids (``message_id``, ``conversation_id``, ...) on the current trace."""
trace = _current.get()
if trace is not None:
trace.bind(**ids)
def bind_if_unset(**ids: Any) -> None:
"""Like :func:`bind` but keeps any value already set."""
trace = _current.get()
if trace is not None:
trace.bind(only_if_unset=True, **ids)
def first_occurrence(key: Any) -> bool:
"""True the first time ``key`` is seen in the current trace.
For steps that repeat identically many times in one request (a guardrail
re-firing on every streamed segment): record the first, skip the rest, so
they cannot use up the span cap. False without an active trace.
"""
trace = _current.get()
return trace.first_occurrence(key) if trace is not None else False
def mark_content_blocked() -> None:
"""A guardrail blocked or retracted content: drop every preview from the stored trace."""
trace = _current.get()
if trace is not None:
trace.content_blocked = True
def wrap(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Bind the current trace and parent span into ``fn`` for another thread.
Use around work handed to a thread pool or ``threading.Thread``; those do
not inherit context variables. Without an active trace ``fn`` is returned
unchanged.
"""
trace = _current.get()
if trace is None:
return fn
parent = trace.current_parent()
@functools.wraps(fn)
def _run(*args: Any, **kwargs: Any) -> Any:
token = _current.set(trace)
undo = trace._seed_thread(parent)
try:
return fn(*args, **kwargs)
finally:
undo()
try:
_current.reset(token)
except ValueError:
_current.set(None)
return _run