diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4250f0f..0731e53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,40 +6,66 @@ on: pull_request: branches: [main] +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} + cache: pip - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e ".[all]" + pip install -e "." pip install pytest pytest-cov - name: Run tests run: | pytest tests/ -v --cov=agent_trace --cov-report=xml - name: Upload coverage - if: matrix.python-version == '3.12' - uses: codecov/codecov-action@v4 + if: matrix.python-version == '3.14' + uses: codecov/codecov-action@v7 + with: + files: ./coverage.xml + fail_ci_if_error: false + + optional-dependencies: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: - file: ./coverage.xml + python-version: "3.13" + cache: pip + - name: Install all optional dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[all]" + pip install pytest pytest-cov + - name: Run tests with all optional dependencies + run: pytest tests/ -q lint: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: - python-version: "3.12" + python-version: "3.14" + cache: pip - name: Install dependencies run: pip install ruff - name: Lint diff --git a/README.md b/README.md index 772e741..071e07b 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ traceweave/ │ ├── exporters/ # Export to JSON, Chrome Trace, etc. │ └── cli.py # CLI: traceweave tui|dashboard|demo|export ├── examples/ # Demo scripts (no API keys needed) -└── tests/ # 39 tests, 100% passing +└── tests/ # Automated test suite ``` | Technology | Purpose | @@ -378,7 +378,7 @@ MIT License — see [LICENSE](LICENSE) for details. [license-shield]: https://img.shields.io/badge/license-MIT-369eff?labelColor=black&style=flat-square [license-link]: https://opensource.org/licenses/MIT [downloads-shield]: https://img.shields.io/pypi/dm/traceweave?color=369eff&labelColor=black&style=flat-square -[test-shield]: https://img.shields.io/badge/tests-39%20passed-369eff?labelColor=black&logo=pytest&logoColor=white&style=flat-square +[test-shield]: https://github.com/weivwang/trace-wave/actions/workflows/ci.yml/badge.svg [test-link]: https://github.com/weivwang/trace-wave/actions [docs-link]: https://github.com/weivwang/trace-wave#-quick-start [issues-link]: https://github.com/weivwang/trace-wave/issues diff --git a/agent_trace/__init__.py b/agent_trace/__init__.py index 5361d24..9605ab1 100644 --- a/agent_trace/__init__.py +++ b/agent_trace/__init__.py @@ -1,19 +1,19 @@ """traceweave: Distributed tracing and observability for AI agents.""" -__version__ = "0.1.0" +__version__ = "0.1.2" +from agent_trace.core.context import get_current_span, get_current_trace +from agent_trace.core.decorators import trace_agent, trace_llm, trace_tool from agent_trace.core.models import ( + SpanData, + SpanEvent, SpanKind, SpanStatus, TokenUsage, - SpanEvent, - SpanData, TraceData, ) from agent_trace.core.span import Span -from agent_trace.core.context import get_current_span, get_current_trace from agent_trace.core.tracer import AgentTracer, tracer -from agent_trace.core.decorators import trace_agent, trace_tool, trace_llm __all__ = [ "__version__", diff --git a/agent_trace/cli.py b/agent_trace/cli.py index 8c7ca75..a371d12 100644 --- a/agent_trace/cli.py +++ b/agent_trace/cli.py @@ -11,13 +11,14 @@ traceweave export trace.json --format chrome -o trace.chrome.json traceweave demo """ + import click -import json -import sys + +from agent_trace import __version__ @click.group() -@click.version_option(version="0.1.0", prog_name="traceweave") +@click.version_option(version=__version__, prog_name="traceweave") def main(): """🔍 traceweave: Distributed tracing and observability for AI agents.""" pass diff --git a/agent_trace/core/__init__.py b/agent_trace/core/__init__.py index 1367c5a..97f3a08 100644 --- a/agent_trace/core/__init__.py +++ b/agent_trace/core/__init__.py @@ -1,20 +1,20 @@ """Core tracing components for traceweave.""" +from agent_trace.core.context import ( + get_current_span, + get_current_trace, + set_current_span, + set_current_trace, +) from agent_trace.core.models import ( + SpanData, + SpanEvent, SpanKind, SpanStatus, TokenUsage, - SpanEvent, - SpanData, TraceData, ) from agent_trace.core.span import Span -from agent_trace.core.context import ( - get_current_span, - set_current_span, - get_current_trace, - set_current_trace, -) from agent_trace.core.tracer import AgentTracer, tracer __all__ = [ diff --git a/agent_trace/core/context.py b/agent_trace/core/context.py index 478b6a2..95977cd 100644 --- a/agent_trace/core/context.py +++ b/agent_trace/core/context.py @@ -13,6 +13,7 @@ # ... nested code sees my_span via get_current_span() ... reset_current_span(token) """ + from __future__ import annotations import contextvars @@ -32,6 +33,7 @@ # ── Public helpers ─────────────────────────────────────────────────────── + def get_current_span() -> Optional[Span]: """Return the currently active span, or ``None`` if outside a trace.""" return _current_span.get() diff --git a/agent_trace/core/decorators.py b/agent_trace/core/decorators.py index dcb8da0..420d9ca 100644 --- a/agent_trace/core/decorators.py +++ b/agent_trace/core/decorators.py @@ -15,13 +15,14 @@ def search(query: str) -> list[str]: def generate(prompt: str) -> str: return call_llm(prompt) """ + import functools import inspect from contextlib import contextmanager from typing import Any, Callable, Optional, TypeVar -from agent_trace.core.models import SpanKind from agent_trace.core.context import get_current_trace +from agent_trace.core.models import SpanKind from agent_trace.core.tracer import tracer as default_tracer F = TypeVar("F", bound=Callable[..., Any]) @@ -296,9 +297,7 @@ def _serialize_output(value: Any) -> Any: if hasattr(value, "model_dump"): return value.model_dump() if hasattr(value, "__dict__"): - return { - k: str(v) for k, v in value.__dict__.items() if not k.startswith("_") - } + return {k: str(v) for k, v in value.__dict__.items() if not k.startswith("_")} except Exception: pass return str(value)[:1000] @@ -327,6 +326,4 @@ def _try_extract_usage(span: Any, result: Any) -> None: usage.get("completion_tokens", 0) if isinstance(usage, dict) else 0 ) if prompt_tokens or completion_tokens: - span.set_token_usage( - prompt_tokens=prompt_tokens, completion_tokens=completion_tokens - ) + span.set_token_usage(prompt_tokens=prompt_tokens, completion_tokens=completion_tokens) diff --git a/agent_trace/core/models.py b/agent_trace/core/models.py index c280e79..c0faca3 100644 --- a/agent_trace/core/models.py +++ b/agent_trace/core/models.py @@ -4,6 +4,7 @@ tracing system: spans, traces, token usage, and events. These models form the data layer that all other components build upon. """ + from __future__ import annotations import uuid @@ -174,4 +175,4 @@ def _count_spans(self, span: SpanData) -> int: model_config = { "arbitrary_types_allowed": True, "protected_namespaces": (), - } \ No newline at end of file + } diff --git a/agent_trace/core/span.py b/agent_trace/core/span.py index 37d91b1..a82f596 100644 --- a/agent_trace/core/span.py +++ b/agent_trace/core/span.py @@ -11,13 +11,14 @@ The span automatically records timing, captures exceptions, and notifies the parent :class:`~agent_trace.core.tracer.AgentTracer` on completion. """ + from __future__ import annotations import traceback from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Optional -from agent_trace.core.models import SpanData, SpanKind, SpanStatus, SpanEvent, TokenUsage +from agent_trace.core.models import SpanData, SpanEvent, SpanKind, SpanStatus, TokenUsage if TYPE_CHECKING: from agent_trace.core.tracer import AgentTracer diff --git a/agent_trace/core/tracer.py b/agent_trace/core/tracer.py index 2c3514b..f8eb4ce 100644 --- a/agent_trace/core/tracer.py +++ b/agent_trace/core/tracer.py @@ -15,24 +15,25 @@ result = plan() span.set_output(result) """ + from __future__ import annotations import threading import uuid +from contextlib import contextmanager from datetime import datetime, timezone from typing import Any, Callable, Optional -from contextlib import contextmanager -from agent_trace.core.models import SpanData, SpanKind, SpanStatus, TraceData -from agent_trace.core.span import Span from agent_trace.core.context import ( get_current_span, - set_current_span, - reset_current_span, get_current_trace, - set_current_trace, + reset_current_span, reset_current_trace, + set_current_span, + set_current_trace, ) +from agent_trace.core.models import SpanData, SpanKind, TraceData +from agent_trace.core.span import Span # Type alias for event listener callbacks EventListener = Callable[[str, dict[str, Any]], None] @@ -177,7 +178,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 +195,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 +238,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 ─────────────────────────────────────────── @@ -235,9 +269,7 @@ def _on_span_end(self, span: Span) -> None: "status": span._data.status.value, "duration_ms": span._data.duration_ms, "token_usage": ( - span._data.token_usage.model_dump() - if span._data.token_usage - else None + span._data.token_usage.model_dump() if span._data.token_usage else None ), "error": span._data.error, }, diff --git a/agent_trace/dashboard/__init__.py b/agent_trace/dashboard/__init__.py index 7c29ee5..0c2c868 100644 --- a/agent_trace/dashboard/__init__.py +++ b/agent_trace/dashboard/__init__.py @@ -1,7 +1,8 @@ """Dashboard components for traceweave.""" + from agent_trace.dashboard.server import run_server -from agent_trace.dashboard.tui import TraceDashboard, print_trace, run_tui from agent_trace.dashboard.trace_viewer import view_trace_file +from agent_trace.dashboard.tui import TraceDashboard, print_trace, run_tui __all__ = [ "run_server", diff --git a/agent_trace/dashboard/server.py b/agent_trace/dashboard/server.py index e53843b..f24182f 100644 --- a/agent_trace/dashboard/server.py +++ b/agent_trace/dashboard/server.py @@ -1,12 +1,12 @@ """Lightweight web dashboard server for traceweave.""" + import json -import threading from http.server import HTTPServer, SimpleHTTPRequestHandler -from typing import Optional -from agent_trace.core.tracer import tracer as default_tracer +from agent_trace import __version__ +from agent_trace.core.tracer import tracer as default_tracer -DASHBOARD_HTML = ''' +DASHBOARD_HTML = """
@@ -75,7 +75,7 @@