From fc402c81745d624858467a5da1ea3b96b60d6daa Mon Sep 17 00:00:00 2001 From: pat Date: Fri, 20 Feb 2026 17:48:54 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20pre-launch=20audit=20=E2=80=94=20AsyncTr?= =?UTF-8?q?acer=20bugs,=20scorecard=20hardening,=20doc=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix AsyncTracer guard dispatch: use auto_check() matching sync Tracer - Add sampling_rate, metadata, watermark params to AsyncTracer for API parity - Pin all 21 GitHub Actions to SHA hashes (scorecard Pinned-Dependencies) - Replace nonexistent `agentguard view` CLI command with `agentguard report` - Fix CLAUDE.md public API list (remove unexported TraceContext) - Fix README duplicate BudgetGuard in LangChain example - Update SECURITY.md: version table (1.2.x), reporter credit policy - Add 10 async tracer tests (511 total, all passing) Co-Authored-By: Claude Opus 4.6 --- .github/actions/agentguard-eval/action.yml | 2 +- .github/workflows/ci.yml | 10 +- .github/workflows/codeql.yml | 6 +- .github/workflows/entropy.yml | 4 +- .github/workflows/ops-cadence.yml | 2 +- .github/workflows/publish.yml | 8 +- .github/workflows/release-content.yml | 2 +- .github/workflows/scorecard.yml | 6 +- CLAUDE.md | 5 +- README.md | 5 +- SECURITY.md | 7 +- docs/blog/001-why-agents-loop.md | 6 - docs/examples/langchain_example.md | 1 - docs/guides/getting-started.md | 6 - examples/cost_guardrail.py | 1 - sdk/README.md | 2 - sdk/agentguard/atracing.py | 119 ++++++++++----- sdk/agentguard/guards.py | 1 + sdk/examples/async_openai_agent.py | 2 +- sdk/examples/crewai_example.py | 2 +- sdk/examples/loop_failure_demo.py | 1 - sdk/tests/test_atracing.py | 170 +++++++++++++++++++++ 22 files changed, 286 insertions(+), 82 deletions(-) diff --git a/.github/actions/agentguard-eval/action.yml b/.github/actions/agentguard-eval/action.yml index 4d0f69e..0d978c5 100644 --- a/.github/actions/agentguard-eval/action.yml +++ b/.github/actions/agentguard-eval/action.yml @@ -34,7 +34,7 @@ runs: using: "composite" steps: - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ inputs.python-version }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b618d6..83ab4a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,8 @@ jobs: matrix: python-version: ${{ github.event_name == 'pull_request' && fromJSON('["3.9","3.12"]') || fromJSON('["3.9","3.10","3.11","3.12"]') }} steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -32,7 +32,7 @@ jobs: --cov-fail-under=80 - name: Upload coverage artifact if: matrix.python-version == '3.12' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: coverage-report path: coverage.xml @@ -40,8 +40,8 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.12" cache: 'pip' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2287c9f..e012602 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -16,8 +16,8 @@ jobs: permissions: security-events: write steps: - - uses: actions/checkout@v6 - - uses: github/codeql-action/init@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 with: languages: python - - uses: github/codeql-action/analyze@v4 + - uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 diff --git a/.github/workflows/entropy.yml b/.github/workflows/entropy.yml index 06571ca..90f07d4 100644 --- a/.github/workflows/entropy.yml +++ b/.github/workflows/entropy.yml @@ -12,8 +12,8 @@ jobs: entropy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" cache: 'pip' diff --git a/.github/workflows/ops-cadence.yml b/.github/workflows/ops-cadence.yml index fe892a2..4ab7cad 100644 --- a/.github/workflows/ops-cadence.yml +++ b/.github/workflows/ops-cadence.yml @@ -13,7 +13,7 @@ jobs: staleness: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9403d0b..8324a55 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,9 +16,9 @@ jobs: id-token: write attestations: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.12" @@ -31,12 +31,12 @@ jobs: SOURCE_DATE_EPOCH: "0" - name: Attest build provenance - uses: actions/attest-build-provenance@v3 + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 with: subject-path: sdk/dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 with: packages-dir: sdk/dist/ password: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/release-content.yml b/.github/workflows/release-content.yml index 988108b..745f9c1 100644 --- a/.github/workflows/release-content.yml +++ b/.github/workflows/release-content.yml @@ -13,7 +13,7 @@ jobs: announce: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 # full history for changelog diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 544bede..ba84f6d 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -15,14 +15,14 @@ jobs: security-events: write id-token: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - uses: ossf/scorecard-action@v2.4.3 + - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif publish_results: true - - uses: github/codeql-action/upload-sarif@v4 + - uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 with: sarif_file: results.sarif diff --git a/CLAUDE.md b/CLAUDE.md index 12196df..a4dcaf4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -242,14 +242,15 @@ Step-by-step instructions for common tasks. Follow these patterns for consistenc ### Public API Surface -**Tracing:** `Tracer`, `TraceContext`, `TraceSink`, `JsonlFileSink`, `StdoutSink`, `HttpSink` +**Tracing:** `Tracer`, `TraceSink`, `JsonlFileSink`, `StdoutSink`, `HttpSink` **Guards:** `LoopGuard`, `FuzzyLoopGuard`, `BudgetGuard`, `TimeoutGuard`, `RateLimitGuard` **Exceptions:** `LoopDetected`, `BudgetExceeded`, `BudgetWarning`, `TimeoutExceeded` **Instrumentation:** `trace_agent`, `trace_tool`, `patch_openai`, `patch_anthropic`, `unpatch_openai`, `unpatch_anthropic` **Async:** `AsyncTracer`, `AsyncTraceContext`, `async_trace_agent`, `async_trace_tool`, `patch_openai_async`, `patch_anthropic_async`, `unpatch_openai_async`, `unpatch_anthropic_async` **Cost:** `estimate_cost` **Evaluation:** `EvalSuite`, `EvalResult`, `AssertionResult` -**Integrations:** `AgentGuardCallbackHandler` (LangChain), `guarded_node`/`guard_node` (LangGraph), `AgentGuardCrewHandler` (CrewAI), `OtelTraceSink` (OpenTelemetry) +**Integrations:** `AgentGuardCallbackHandler` (LangChain), `guarded_node`/`guard_node` (LangGraph), `AgentGuardCrewHandler` (CrewAI) +**Sinks (optional import):** `OtelTraceSink` (`from agentguard.sinks.otel import OtelTraceSink`) ### Constraints diff --git a/README.md b/README.md index 8f93f79..1da1bf7 100644 --- a/README.md +++ b/README.md @@ -113,12 +113,13 @@ pip install agentguard47[langchain] ``` ```python -from agentguard import Tracer, BudgetGuard +from agentguard import Tracer, BudgetGuard, LoopGuard from agentguard.integrations.langchain import AgentGuardCallbackHandler -tracer = Tracer(guards=[BudgetGuard(max_cost_usd=5.00)]) +tracer = Tracer(service="my-agent") handler = AgentGuardCallbackHandler( tracer=tracer, + loop_guard=LoopGuard(max_repeats=3), budget_guard=BudgetGuard(max_cost_usd=5.00), ) diff --git a/SECURITY.md b/SECURITY.md index e0bad6a..9fd0840 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,9 +4,9 @@ | Version | Supported | |---------|--------------------| -| 1.1.x | Yes | -| 1.0.x | Security fixes only | -| < 1.0 | No | +| 1.2.x | Yes | +| 1.1.x | Security fixes only | +| < 1.1 | No | ## Reporting a Vulnerability @@ -27,6 +27,7 @@ Include: - **Triage:** Within 7 days - **Fix:** Target 30 days for critical, 90 days for others - **Disclosure:** Coordinated with reporter, following 90-day standard +- **Credit:** Reporters are credited in release notes and this file unless they prefer to remain anonymous ## Scope diff --git a/docs/blog/001-why-agents-loop.md b/docs/blog/001-why-agents-loop.md index 3e88e92..ab97d1d 100644 --- a/docs/blog/001-why-agents-loop.md +++ b/docs/blog/001-why-agents-loop.md @@ -69,12 +69,6 @@ AgentGuard report The guard caught the loop on the third identical call. No tokens wasted. No silent failure. -Open the trace viewer to see the full event timeline in your browser: - -```bash -agentguard view traces.jsonl -``` - ## Why this matters Without tracing, loop failures are invisible. Your agent returns after 30 seconds and you don't know if it did useful work or burned $2 in API calls repeating itself. diff --git a/docs/examples/langchain_example.md b/docs/examples/langchain_example.md index 2a7edbc..80381ce 100644 --- a/docs/examples/langchain_example.md +++ b/docs/examples/langchain_example.md @@ -40,7 +40,6 @@ print(response) ```bash agentguard report traces.jsonl -agentguard view traces.jsonl ``` ## Notes diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 66487dd..edcad32 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -42,12 +42,6 @@ AgentGuard report Approx run time: 0.3 ms ``` -Open a timeline in your browser: - -```bash -agentguard view traces.jsonl -``` - ## 3. Add a loop guard Stop agents that repeat themselves. Guards auto-check on every `span.event()` call: diff --git a/examples/cost_guardrail.py b/examples/cost_guardrail.py index bbdd6e8..6a4c1ea 100644 --- a/examples/cost_guardrail.py +++ b/examples/cost_guardrail.py @@ -155,4 +155,3 @@ def research_agent(topic: str, max_iterations: int = 50) -> str: # If using local file, show how to view the trace if not api_key: print(f"\nView trace: agentguard report cost_guardrail_traces.jsonl") - print(f"Open viewer: agentguard view cost_guardrail_traces.jsonl") diff --git a/sdk/README.md b/sdk/README.md index d6ab05d..2bd86ac 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -31,7 +31,6 @@ with tracer.trace("agent.run") as span: ```bash agentguard report traces.jsonl # summary table -agentguard view traces.jsonl # Gantt timeline in browser ``` ## Guards @@ -161,7 +160,6 @@ sink = HttpSink( ```bash agentguard report traces.jsonl # human-readable summary -agentguard view traces.jsonl # Gantt trace viewer in browser agentguard summarize traces.jsonl # event-level breakdown agentguard eval traces.jsonl # run evaluation assertions agentguard eval traces.jsonl --ci # CI mode (stricter checks, exit code) diff --git a/sdk/agentguard/atracing.py b/sdk/agentguard/atracing.py index 89dbc37..6f31565 100644 --- a/sdk/agentguard/atracing.py +++ b/sdk/agentguard/atracing.py @@ -13,6 +13,9 @@ """ from __future__ import annotations +import random +import time +import uuid from contextlib import asynccontextmanager from dataclasses import dataclass from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional @@ -20,9 +23,6 @@ if TYPE_CHECKING: from agentguard.cost import CostTracker -import time -import uuid - from agentguard.tracing import StdoutSink, TraceSink @@ -46,6 +46,7 @@ class AsyncTraceContext: data: Optional[Dict[str, Any]] _start_time: Optional[float] = None _cost_tracker: Optional[Any] = None + _sampled: bool = True @property def cost(self) -> "CostTracker": @@ -58,15 +59,16 @@ def cost(self) -> "CostTracker": async def __aenter__(self) -> "AsyncTraceContext": self._start_time = time.perf_counter() - self.tracer._emit( - kind="span", - phase="start", - trace_id=self.trace_id, - span_id=self.span_id, - parent_id=self.parent_id, - name=self.name, - data=self.data, - ) + if self._sampled: + self.tracer._emit( + kind="span", + phase="start", + trace_id=self.trace_id, + span_id=self.span_id, + parent_id=self.parent_id, + name=self.name, + data=self.data, + ) return self async def __aexit__(self, exc_type, exc, tb) -> None: @@ -84,18 +86,19 @@ async def __aexit__(self, exc_type, exc, tb) -> None: cost_usd = None if self._cost_tracker is not None and self._cost_tracker.total > 0: cost_usd = self._cost_tracker.total - self.tracer._emit( - kind="span", - phase="end", - trace_id=self.trace_id, - span_id=self.span_id, - parent_id=self.parent_id, - name=self.name, - data=self.data, - duration_ms=duration_ms, - error=error, - cost_usd=cost_usd, - ) + if self._sampled: + self.tracer._emit( + kind="span", + phase="end", + trace_id=self.trace_id, + span_id=self.span_id, + parent_id=self.parent_id, + name=self.name, + data=self.data, + duration_ms=duration_ms, + error=error, + cost_usd=cost_usd, + ) @asynccontextmanager async def span(self, name: str, data: Optional[Dict[str, Any]] = None) -> AsyncIterator["AsyncTraceContext"]: @@ -115,6 +118,7 @@ async def span(self, name: str, data: Optional[Dict[str, Any]] = None) -> AsyncI parent_id=self.span_id, name=name, data=data, + _sampled=self._sampled, ) async with ctx: yield ctx @@ -134,16 +138,20 @@ def event( data: Optional data to attach to the event. cost_usd: Optional cost in USD for this event. """ - self.tracer._emit( - kind="event", - phase="emit", - trace_id=self.trace_id, - span_id=self.span_id, - parent_id=self.parent_id, - name=name, - data=data, - cost_usd=cost_usd, - ) + if self._sampled: + self.tracer._emit( + kind="event", + phase="emit", + trace_id=self.trace_id, + span_id=self.span_id, + parent_id=self.parent_id, + name=name, + data=data, + cost_usd=cost_usd, + ) + else: + # Guards must still fire even when trace is sampled out + self.tracer._check_guards(name, data) class AsyncTracer: @@ -162,6 +170,9 @@ class AsyncTracer: sink: Where to send trace events. Defaults to StdoutSink. service: Name of the service being traced. guards: Optional list of guards to auto-check on each event. + metadata: Dict of metadata attached to every event (e.g. env, git SHA). + sampling_rate: Float 0.0-1.0. Fraction of traces to emit. 1.0 = all, 0.0 = none. + watermark: Whether to emit a one-time AgentGuard watermark event. """ def __init__( @@ -169,15 +180,28 @@ def __init__( sink: Optional[TraceSink] = None, service: str = "app", guards: Optional[List[Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + sampling_rate: float = 1.0, + watermark: bool = True, ) -> None: + if not (0.0 <= sampling_rate <= 1.0): + raise ValueError( + f"sampling_rate must be between 0.0 and 1.0, got {sampling_rate}" + ) self._sink = sink or StdoutSink() self._service = service self._guards = guards or [] + self._metadata = metadata or {} + self._sampling_rate = sampling_rate + self._watermark = watermark + self._watermark_emitted = False @asynccontextmanager async def trace(self, name: str, data: Optional[Dict[str, Any]] = None) -> AsyncIterator[AsyncTraceContext]: """Start a new top-level async trace span. + If sampling_rate < 1.0, this trace may be silently skipped. + Args: name: Name of the trace span. data: Optional data to attach. @@ -185,6 +209,7 @@ async def trace(self, name: str, data: Optional[Dict[str, Any]] = None) -> Async Yields: An AsyncTraceContext for creating child spans and events. """ + sampled = random.random() < self._sampling_rate ctx = AsyncTraceContext( tracer=self, trace_id=_new_id(), @@ -192,6 +217,7 @@ async def trace(self, name: str, data: Optional[Dict[str, Any]] = None) -> Async parent_id=None, name=name, data=data, + _sampled=sampled, ) async with ctx: yield ctx @@ -226,11 +252,32 @@ def _emit( } if cost_usd is not None: event["cost_usd"] = cost_usd + if self._metadata: + event["metadata"] = self._metadata + if self._watermark and not self._watermark_emitted: + self._watermark_emitted = True + wm: Dict[str, Any] = { + "service": self._service, + "kind": "meta", + "name": "watermark", + "message": "Traced by AgentGuard | agentguard47.com", + "ts": time.time(), + } + if self._metadata: + wm["metadata"] = self._metadata + self._sink.emit(wm) self._sink.emit(event) # Auto-check guards + if kind == "event": + self._check_guards(name, data) + + def _check_guards(self, name: str, data: Optional[Dict[str, Any]] = None) -> None: + """Run all attached guards. Called on every event, even sampled-out ones.""" for guard in self._guards: - if hasattr(guard, "check") and kind == "event": + if hasattr(guard, "auto_check"): + guard.auto_check(name, data) + elif hasattr(guard, "check"): try: guard.check(name, data) except TypeError: @@ -240,7 +287,7 @@ def _emit( pass def __repr__(self) -> str: - return f"AsyncTracer(service={self._service!r}, sink={self._sink!r})" + return f"AsyncTracer(service={self._service!r}, sink={self._sink!r}, watermark={self._watermark!r})" def _new_id() -> str: diff --git a/sdk/agentguard/guards.py b/sdk/agentguard/guards.py index 51c5af4..4157466 100644 --- a/sdk/agentguard/guards.py +++ b/sdk/agentguard/guards.py @@ -22,6 +22,7 @@ from collections import Counter, deque from dataclasses import dataclass from typing import Any, Callable, Deque, Dict, Optional, Tuple +from urllib.parse import urlparse class AgentGuardError(Exception): diff --git a/sdk/examples/async_openai_agent.py b/sdk/examples/async_openai_agent.py index 8ac93e9..84869b1 100644 --- a/sdk/examples/async_openai_agent.py +++ b/sdk/examples/async_openai_agent.py @@ -50,7 +50,7 @@ async def main(): result = await agent("how to detect agent loops") print(result) print("\nTrace written to async_traces.jsonl") - print("Run: agentguard view async_traces.jsonl") + print("Run: agentguard report async_traces.jsonl") if __name__ == "__main__": diff --git a/sdk/examples/crewai_example.py b/sdk/examples/crewai_example.py index 5d98dc8..60d24d3 100644 --- a/sdk/examples/crewai_example.py +++ b/sdk/examples/crewai_example.py @@ -99,4 +99,4 @@ print("\n" + "=" * 60) print(result) print("=" * 60) - print(f"\n[agentguard] Trace saved. Run: agentguard view {here}/crewai_traces.jsonl") + print(f"\n[agentguard] Trace saved. Run: agentguard report {here}/crewai_traces.jsonl") diff --git a/sdk/examples/loop_failure_demo.py b/sdk/examples/loop_failure_demo.py index 4f58897..46dc934 100644 --- a/sdk/examples/loop_failure_demo.py +++ b/sdk/examples/loop_failure_demo.py @@ -6,7 +6,6 @@ Then inspect the traces: agentguard report sdk/examples/loop_traces.jsonl - agentguard view sdk/examples/loop_traces.jsonl """ from agentguard.tracing import Tracer, JsonlFileSink diff --git a/sdk/tests/test_atracing.py b/sdk/tests/test_atracing.py index 2562657..25174f9 100644 --- a/sdk/tests/test_atracing.py +++ b/sdk/tests/test_atracing.py @@ -11,6 +11,8 @@ from agentguard import ( AsyncTracer, AsyncTraceContext, + BudgetExceeded, + BudgetGuard, JsonlFileSink, LoopGuard, LoopDetected, @@ -125,6 +127,174 @@ async def run(): asyncio.run(run()) + def test_guards_auto_check_uses_auto_check_method(self): + """Verify AsyncTracer calls auto_check() (not just check()) for guards that have it.""" + auto_check_calls = [] + + class MockGuard: + def auto_check(self, name, data=None): + auto_check_calls.append((name, data)) + + def check(self, name, data=None): + raise AssertionError("check() should not be called when auto_check() exists") + + async def run(): + guard = MockGuard() + tracer = AsyncTracer( + sink=JsonlFileSink(self.path), + service="test", + guards=[guard], + ) + async with tracer.trace("agent.run") as span: + span.event("tool.search", data={"q": "test"}) + + asyncio.run(run()) + self.assertEqual(len(auto_check_calls), 1) + self.assertEqual(auto_check_calls[0][0], "tool.search") + + def test_budget_guard_fires_via_consume(self): + """Verify BudgetGuard raises BudgetExceeded when used with AsyncTracer context.""" + async def run(): + guard = BudgetGuard(max_calls=2) + tracer = AsyncTracer( + sink=JsonlFileSink(self.path), + service="test", + ) + async with tracer.trace("agent.run") as span: + guard.consume(calls=1) + span.event("tool.call1") + guard.consume(calls=1) + span.event("tool.call2") + with self.assertRaises(BudgetExceeded): + guard.consume(calls=1) + + asyncio.run(run()) + + def test_sampling_rate_zero_emits_nothing(self): + """With sampling_rate=0.0, no events should be emitted to the sink.""" + async def run(): + tracer = AsyncTracer( + sink=JsonlFileSink(self.path), + service="test", + sampling_rate=0.0, + ) + async with tracer.trace("agent.run") as span: + span.event("reasoning.step", data={"step": 1}) + + asyncio.run(run()) + + with open(self.path) as f: + events = [json.loads(line) for line in f if line.strip()] + self.assertEqual(len(events), 0) + + def test_sampling_rate_one_emits_all(self): + """With sampling_rate=1.0, all events should be emitted.""" + async def run(): + tracer = AsyncTracer( + sink=JsonlFileSink(self.path), + service="test", + sampling_rate=1.0, + ) + async with tracer.trace("agent.run") as span: + span.event("reasoning.step") + + asyncio.run(run()) + + with open(self.path) as f: + events = [json.loads(line) for line in f if line.strip()] + # watermark + span start + event + span end = 4 + self.assertGreaterEqual(len(events), 3) + + def test_sampling_rate_invalid_raises(self): + """sampling_rate outside 0.0-1.0 should raise ValueError.""" + with self.assertRaises(ValueError): + AsyncTracer(sampling_rate=1.5) + with self.assertRaises(ValueError): + AsyncTracer(sampling_rate=-0.1) + + def test_guards_fire_when_sampled_out(self): + """Guards must still fire even when trace is sampled out.""" + async def run(): + guard = LoopGuard(max_repeats=3) + tracer = AsyncTracer( + sink=JsonlFileSink(self.path), + service="test", + guards=[guard], + sampling_rate=0.0, + ) + async with tracer.trace("agent.run") as span: + span.event("tool.search", data={"q": "a"}) + span.event("tool.search", data={"q": "a"}) + with self.assertRaises(LoopDetected): + span.event("tool.search", data={"q": "a"}) + + asyncio.run(run()) + + # No events emitted to sink (sampled out) + with open(self.path) as f: + events = [json.loads(line) for line in f if line.strip()] + self.assertEqual(len(events), 0) + + def test_metadata_attached_to_events(self): + """metadata dict should appear on every emitted event.""" + async def run(): + tracer = AsyncTracer( + sink=JsonlFileSink(self.path), + service="test", + metadata={"env": "test", "git_sha": "abc123"}, + ) + async with tracer.trace("agent.run") as span: + span.event("step") + + asyncio.run(run()) + + with open(self.path) as f: + events = [json.loads(line) for line in f if line.strip()] + # All non-watermark events should have metadata + for event in events: + self.assertIn("metadata", event) + self.assertEqual(event["metadata"]["env"], "test") + self.assertEqual(event["metadata"]["git_sha"], "abc123") + + def test_watermark_emitted_once(self): + """Watermark event should be emitted exactly once.""" + async def run(): + tracer = AsyncTracer( + sink=JsonlFileSink(self.path), + service="test", + watermark=True, + ) + async with tracer.trace("agent.run") as span: + span.event("step1") + span.event("step2") + + asyncio.run(run()) + + with open(self.path) as f: + events = [json.loads(line) for line in f if line.strip()] + watermarks = [e for e in events if e.get("name") == "watermark"] + self.assertEqual(len(watermarks), 1) + self.assertIn("AgentGuard", watermarks[0]["message"]) + + def test_watermark_disabled(self): + """watermark=False should suppress the watermark event.""" + async def run(): + tracer = AsyncTracer( + sink=JsonlFileSink(self.path), + service="test", + watermark=False, + ) + async with tracer.trace("agent.run") as span: + span.event("step") + + asyncio.run(run()) + + with open(self.path) as f: + events = [json.loads(line) for line in f if line.strip()] + watermarks = [e for e in events if e.get("name") == "watermark"] + self.assertEqual(len(watermarks), 0) + + class TestAsyncTracerRepr(unittest.TestCase): def test_repr(self): tracer = AsyncTracer(service="my-agent")