tracify-sdk 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tracify_sdk-0.2.0/PKG-INFO +96 -0
- tracify_sdk-0.2.0/README.md +80 -0
- tracify_sdk-0.2.0/pyproject.toml +34 -0
- tracify_sdk-0.2.0/setup.cfg +4 -0
- tracify_sdk-0.2.0/src/tracify/__init__.py +596 -0
- tracify_sdk-0.2.0/src/tracify_sdk.egg-info/PKG-INFO +96 -0
- tracify_sdk-0.2.0/src/tracify_sdk.egg-info/SOURCES.txt +9 -0
- tracify_sdk-0.2.0/src/tracify_sdk.egg-info/dependency_links.txt +1 -0
- tracify_sdk-0.2.0/src/tracify_sdk.egg-info/requires.txt +2 -0
- tracify_sdk-0.2.0/src/tracify_sdk.egg-info/top_level.txt +1 -0
- tracify_sdk-0.2.0/tests/test_orchestrate.py +313 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tracify-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Observability, runtime enforcement, and tracing for AI agents.
|
|
5
|
+
Author-email: Tracify Team <hello@tracify.tech>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://tracify.tech
|
|
8
|
+
Project-URL: Documentation, https://docs.tracify.tech
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/tracify/python-sdk/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.7
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: requests>=2.25.0
|
|
15
|
+
Requires-Dist: httpx>=0.24.0
|
|
16
|
+
|
|
17
|
+
# Tracify Python SDK
|
|
18
|
+
|
|
19
|
+
Observability and tracing for AI agents. Gain full visibility into your agent's decision-making process, tool usage, and LLM costs.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install tracify-sdk
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
1. Get your API Key from the [Tracify Dashboard](https://tracify.tech).
|
|
30
|
+
2. Set it as an environment variable:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
export TRACIFY_API_KEY=your_sk_live_...
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
3. Instrument your agent:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from tracify import trace_agent, llm_call, tool_call
|
|
40
|
+
|
|
41
|
+
@trace_agent()
|
|
42
|
+
async def my_agent(query: str):
|
|
43
|
+
# LLM Call
|
|
44
|
+
llm_call(
|
|
45
|
+
input_data=query,
|
|
46
|
+
output_data="Thinking...",
|
|
47
|
+
model_id="gpt-4o",
|
|
48
|
+
cost_usd=0.002,
|
|
49
|
+
latency_ms=800
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Tool Use
|
|
53
|
+
tool_call(
|
|
54
|
+
tool_name="web_search",
|
|
55
|
+
input_data={"q": query},
|
|
56
|
+
output_data="Found results...",
|
|
57
|
+
latency_ms=1200
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return "Final Answer"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Features
|
|
64
|
+
|
|
65
|
+
- **@trace_agent Decorator:** Automatically captures run lifecycle and errors.
|
|
66
|
+
- **Granular Spans:** Manually record LLMs, tools, and custom decisions.
|
|
67
|
+
- **Cost Tracking:** Monitor your infrastructure spend in real-time.
|
|
68
|
+
- **Asynchronous & Synchronous Support:** Works with `async/await` or standard functions.
|
|
69
|
+
- **Session context:** Attach `session_id`, `end_user_id`, `environment`, `release`, and `tags` to connect related traces.
|
|
70
|
+
|
|
71
|
+
## Feedback and evaluation scores
|
|
72
|
+
|
|
73
|
+
Pass `project_id` when constructing the client to attach end-user feedback and typed scores to traces:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
client = TracifyClient(project_id="your-project-id")
|
|
77
|
+
client.feedback(run_id, True, kind="thumb", comment="Helpful answer")
|
|
78
|
+
client.score(run_id, "answer_quality", 0.92, data_type="numeric")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Feedback and scores use the same API key as ingestion and do not interrupt the agent when delivery fails.
|
|
82
|
+
|
|
83
|
+
## Prompt deployment
|
|
84
|
+
|
|
85
|
+
Resolve a deployed prompt without shipping a new application build:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
deployed = client.get_prompt("support-agent", "production")
|
|
89
|
+
client.ingest(spanType="llm", input=question, output=answer, promptVersionId=deployed["version"]["id"])
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Prompt responses are cached for 60 seconds by default. Use `cache_ttl_seconds=0` or provide a `fallback` for development and guaranteed-availability behavior.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Tracify Python SDK
|
|
2
|
+
|
|
3
|
+
Observability and tracing for AI agents. Gain full visibility into your agent's decision-making process, tool usage, and LLM costs.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install tracify-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
1. Get your API Key from the [Tracify Dashboard](https://tracify.tech).
|
|
14
|
+
2. Set it as an environment variable:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
export TRACIFY_API_KEY=your_sk_live_...
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
3. Instrument your agent:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from tracify import trace_agent, llm_call, tool_call
|
|
24
|
+
|
|
25
|
+
@trace_agent()
|
|
26
|
+
async def my_agent(query: str):
|
|
27
|
+
# LLM Call
|
|
28
|
+
llm_call(
|
|
29
|
+
input_data=query,
|
|
30
|
+
output_data="Thinking...",
|
|
31
|
+
model_id="gpt-4o",
|
|
32
|
+
cost_usd=0.002,
|
|
33
|
+
latency_ms=800
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Tool Use
|
|
37
|
+
tool_call(
|
|
38
|
+
tool_name="web_search",
|
|
39
|
+
input_data={"q": query},
|
|
40
|
+
output_data="Found results...",
|
|
41
|
+
latency_ms=1200
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return "Final Answer"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Features
|
|
48
|
+
|
|
49
|
+
- **@trace_agent Decorator:** Automatically captures run lifecycle and errors.
|
|
50
|
+
- **Granular Spans:** Manually record LLMs, tools, and custom decisions.
|
|
51
|
+
- **Cost Tracking:** Monitor your infrastructure spend in real-time.
|
|
52
|
+
- **Asynchronous & Synchronous Support:** Works with `async/await` or standard functions.
|
|
53
|
+
- **Session context:** Attach `session_id`, `end_user_id`, `environment`, `release`, and `tags` to connect related traces.
|
|
54
|
+
|
|
55
|
+
## Feedback and evaluation scores
|
|
56
|
+
|
|
57
|
+
Pass `project_id` when constructing the client to attach end-user feedback and typed scores to traces:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
client = TracifyClient(project_id="your-project-id")
|
|
61
|
+
client.feedback(run_id, True, kind="thumb", comment="Helpful answer")
|
|
62
|
+
client.score(run_id, "answer_quality", 0.92, data_type="numeric")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Feedback and scores use the same API key as ingestion and do not interrupt the agent when delivery fails.
|
|
66
|
+
|
|
67
|
+
## Prompt deployment
|
|
68
|
+
|
|
69
|
+
Resolve a deployed prompt without shipping a new application build:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
deployed = client.get_prompt("support-agent", "production")
|
|
73
|
+
client.ingest(spanType="llm", input=question, output=answer, promptVersionId=deployed["version"]["id"])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Prompt responses are cached for 60 seconds by default. Use `cache_ttl_seconds=0` or provide a `fallback` for development and guaranteed-availability behavior.
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tracify-sdk"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Tracify Team", email="hello@tracify.tech" },
|
|
10
|
+
]
|
|
11
|
+
description = "Observability, runtime enforcement, and tracing for AI agents."
|
|
12
|
+
license = "MIT"
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
requires-python = ">=3.7"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"requests>=2.25.0",
|
|
21
|
+
"httpx>=0.24.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
"Homepage" = "https://tracify.tech"
|
|
29
|
+
"Documentation" = "https://docs.tracify.tech"
|
|
30
|
+
"Bug Tracker" = "https://github.com/tracify/python-sdk/issues"
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
import functools
|
|
6
|
+
import asyncio
|
|
7
|
+
import threading
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import httpx
|
|
15
|
+
_HAS_HTTPX = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
_HAS_HTTPX = False
|
|
18
|
+
|
|
19
|
+
import requests as _requests_fallback
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LatencyBudgetExceeded(Exception):
|
|
23
|
+
"""Raised when a call exceeds the latency budget and is aborted."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CancellationToken:
|
|
28
|
+
"""
|
|
29
|
+
Cooperative cancellation token passed to the user's call function.
|
|
30
|
+
|
|
31
|
+
When the latency budget expires, the SDK sets this token as cancelled.
|
|
32
|
+
The user's call function should check ``token.is_cancelled`` periodically
|
|
33
|
+
and raise or return early if true.
|
|
34
|
+
|
|
35
|
+
Example::
|
|
36
|
+
|
|
37
|
+
def my_llm_call(model: str, token: CancellationToken) -> dict:
|
|
38
|
+
# Check before making the HTTP call
|
|
39
|
+
if token.is_cancelled:
|
|
40
|
+
raise LatencyBudgetExceeded("Cancelled")
|
|
41
|
+
response = httpx.post(..., timeout=30)
|
|
42
|
+
return response.json()
|
|
43
|
+
|
|
44
|
+
For true cancellation of in-flight HTTP requests, the user should use
|
|
45
|
+
``token`` with an httpx client::
|
|
46
|
+
|
|
47
|
+
def my_llm_call(model: str, token: CancellationToken) -> dict:
|
|
48
|
+
with httpx.Client(timeout=30) as client:
|
|
49
|
+
# httpx will close the connection if the client is garbage collected
|
|
50
|
+
# after the timeout fires and this function returns
|
|
51
|
+
return client.post(...).json()
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self._cancelled = threading.Event()
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def is_cancelled(self) -> bool:
|
|
59
|
+
return self._cancelled.is_set()
|
|
60
|
+
|
|
61
|
+
def cancel(self):
|
|
62
|
+
self._cancelled.set()
|
|
63
|
+
|
|
64
|
+
def check(self):
|
|
65
|
+
"""Raise LatencyBudgetExceeded if cancelled. Call this in long-running loops."""
|
|
66
|
+
if self._cancelled.is_set():
|
|
67
|
+
raise LatencyBudgetExceeded("Call cancelled by latency budget")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class RuntimePolicy:
|
|
72
|
+
enforcement_mode: str = "observe" # "observe" | "enforce"
|
|
73
|
+
max_cost_per_run: Optional[float] = None
|
|
74
|
+
max_cost_per_day: Optional[float] = None
|
|
75
|
+
fallback_chain: List[str] = field(default_factory=list)
|
|
76
|
+
max_attempts: int = 3
|
|
77
|
+
backoff_ms: float = 1000
|
|
78
|
+
backoff_multiplier: float = 2
|
|
79
|
+
retryable_errors: List[str] = field(default_factory=lambda: ["timeout", "5xx"])
|
|
80
|
+
latency_budget_ms: Optional[int] = None
|
|
81
|
+
|
|
82
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
83
|
+
return {
|
|
84
|
+
"enforcementMode": self.enforcement_mode,
|
|
85
|
+
"maxCostPerRun": self.max_cost_per_run,
|
|
86
|
+
"maxCostPerDay": self.max_cost_per_day,
|
|
87
|
+
"fallbackChain": self.fallback_chain,
|
|
88
|
+
"retryPolicy": {
|
|
89
|
+
"maxAttempts": self.max_attempts,
|
|
90
|
+
"backoffMs": self.backoff_ms,
|
|
91
|
+
"backoffMultiplier": self.backoff_multiplier,
|
|
92
|
+
"retryableErrors": self.retryable_errors,
|
|
93
|
+
},
|
|
94
|
+
"latencyBudgetMs": self.latency_budget_ms,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_retryable_error(error: Exception, retryable_errors: List[str]) -> bool:
|
|
99
|
+
if isinstance(error, LatencyBudgetExceeded):
|
|
100
|
+
return False
|
|
101
|
+
msg = str(error).lower()
|
|
102
|
+
status = getattr(error, "status_code", None) or getattr(error, "status", None) or 0
|
|
103
|
+
|
|
104
|
+
for code in retryable_errors:
|
|
105
|
+
if code == "timeout" and ("timeout" in msg or "timed out" in msg):
|
|
106
|
+
return True
|
|
107
|
+
if code == "5xx" and isinstance(status, int) and 500 <= status < 600:
|
|
108
|
+
return True
|
|
109
|
+
if code == "429" and (status == 429 or "rate limit" in msg or "too many requests" in msg):
|
|
110
|
+
return True
|
|
111
|
+
if code == "rate_limit" and (status == 429 or "rate limit" in msg or "throttl" in msg):
|
|
112
|
+
return True
|
|
113
|
+
if code == "overloaded" and ("overloaded" in msg or "capacity" in msg or "busy" in msg):
|
|
114
|
+
return True
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _is_latency_abort(error: Exception) -> bool:
|
|
119
|
+
return isinstance(error, LatencyBudgetExceeded)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix="tracify-latency")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _run_with_timeout(
|
|
126
|
+
func: Callable[[CancellationToken], Any],
|
|
127
|
+
timeout_ms: Optional[int],
|
|
128
|
+
) -> Any:
|
|
129
|
+
"""
|
|
130
|
+
Run func(token) in a thread with a cooperative timeout.
|
|
131
|
+
|
|
132
|
+
The function receives a CancellationToken. When the timeout fires,
|
|
133
|
+
the token is set to cancelled. The function is responsible for checking
|
|
134
|
+
token.is_cancelled (or calling token.check()) and exiting cleanly.
|
|
135
|
+
|
|
136
|
+
If the function does not check the token, it will continue running in
|
|
137
|
+
the background after LatencyBudgetExceeded is raised. This is the same
|
|
138
|
+
behavior as TS's AbortController when the user's call ignores the signal,
|
|
139
|
+
but in Python the "background" thread is more visible.
|
|
140
|
+
|
|
141
|
+
Returns the function's result, or raises LatencyBudgetExceeded on timeout.
|
|
142
|
+
"""
|
|
143
|
+
if timeout_ms is None:
|
|
144
|
+
token = CancellationToken()
|
|
145
|
+
return func(token)
|
|
146
|
+
|
|
147
|
+
token = CancellationToken()
|
|
148
|
+
result = [None]
|
|
149
|
+
exception = [None]
|
|
150
|
+
|
|
151
|
+
def target():
|
|
152
|
+
try:
|
|
153
|
+
result[0] = func(token)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
exception[0] = e
|
|
156
|
+
|
|
157
|
+
future = _EXECUTOR.submit(target)
|
|
158
|
+
try:
|
|
159
|
+
future.result(timeout=timeout_ms / 1000)
|
|
160
|
+
except FuturesTimeout:
|
|
161
|
+
token.cancel()
|
|
162
|
+
# Give the function a brief window to notice the cancellation
|
|
163
|
+
# and clean up (e.g., close HTTP connections).
|
|
164
|
+
try:
|
|
165
|
+
future.result(timeout=0.5)
|
|
166
|
+
except (FuturesTimeout, Exception):
|
|
167
|
+
pass
|
|
168
|
+
raise LatencyBudgetExceeded(
|
|
169
|
+
f"Call exceeded latency budget of {timeout_ms}ms. "
|
|
170
|
+
"The underlying call may still be running in the background."
|
|
171
|
+
)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
if exception[0] is not None:
|
|
174
|
+
raise exception[0]
|
|
175
|
+
raise
|
|
176
|
+
|
|
177
|
+
if exception[0] is not None:
|
|
178
|
+
raise exception[0]
|
|
179
|
+
|
|
180
|
+
return result[0]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class TracifyClient:
|
|
184
|
+
REGION_HOSTS = {
|
|
185
|
+
"eu": "https://eu.cloud.tracify.tech",
|
|
186
|
+
"us": "https://us.cloud.tracify.tech",
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
def __init__(self, api_key: Optional[str] = None, host: Optional[str] = None, project_id: Optional[str] = None, region: Optional[str] = None):
|
|
190
|
+
self.api_key = api_key or os.environ.get("TRACIFY_API_KEY")
|
|
191
|
+
if not self.api_key:
|
|
192
|
+
raise ValueError("TRACIFY_API_KEY or TRACIFY_API_KEY must be provided or set as an environment variable")
|
|
193
|
+
selected_region = (region or os.environ.get("TRACIFY_REGION") or "eu").strip().lower()
|
|
194
|
+
if selected_region not in self.REGION_HOSTS:
|
|
195
|
+
raise ValueError("TRACIFY_REGION must be 'eu' or 'us'")
|
|
196
|
+
self.host = (host or os.environ.get("TRACIFY_HOST") or self.REGION_HOSTS[selected_region]).rstrip("/")
|
|
197
|
+
host_region = next((key for key, value in self.REGION_HOSTS.items() if value == self.host), None)
|
|
198
|
+
key_region = self._api_key_region(self.api_key)
|
|
199
|
+
if key_region and host_region and key_region != host_region:
|
|
200
|
+
raise ValueError(f"Tracify API key region mismatch: this key belongs to {key_region.upper()}, but the client is configured for {host_region.upper()}.")
|
|
201
|
+
self.project_id = project_id
|
|
202
|
+
self.ingest_url = f"{self.host}/api/ingest"
|
|
203
|
+
self.check_cost_url = f"{self.host}/api/orchestration/check-cost"
|
|
204
|
+
self._http = httpx.Client(timeout=10) if _HAS_HTTPX else None
|
|
205
|
+
self._last_fail_open = False
|
|
206
|
+
self._prompt_cache: Dict[str, Any] = {}
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _api_key_region(api_key: str) -> Optional[str]:
|
|
210
|
+
if api_key.startswith("tracify_sk_live_eu_"):
|
|
211
|
+
return "eu"
|
|
212
|
+
if api_key.startswith("tracify_sk_live_us_"):
|
|
213
|
+
return "us"
|
|
214
|
+
if api_key.startswith("tracify_sk_live_"):
|
|
215
|
+
return "eu"
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
def _send_span(self, span: Dict[str, Any]):
|
|
219
|
+
try:
|
|
220
|
+
headers = {
|
|
221
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
222
|
+
"Content-Type": "application/json"
|
|
223
|
+
}
|
|
224
|
+
if self._http:
|
|
225
|
+
self._http.post(self.ingest_url, headers=headers, json=span)
|
|
226
|
+
else:
|
|
227
|
+
_requests_fallback.post(self.ingest_url, headers=headers, json=span, timeout=2)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
print(f"Tracify Warning: Failed to ingest span: {e}")
|
|
230
|
+
|
|
231
|
+
def ingest(self, **kwargs):
|
|
232
|
+
span = {
|
|
233
|
+
"spanId": str(uuid.uuid4()),
|
|
234
|
+
"createdAt": datetime.utcnow().isoformat() + "Z",
|
|
235
|
+
"metadata": {},
|
|
236
|
+
**kwargs
|
|
237
|
+
}
|
|
238
|
+
self._send_span(span)
|
|
239
|
+
|
|
240
|
+
def get_prompt(self, name: str, environment: str = "production", cache_ttl_seconds: float = 60, fallback: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
241
|
+
"""Resolve a deployed prompt, using a short-lived cache and stale/fallback recovery."""
|
|
242
|
+
key = f"{name}:{environment}"
|
|
243
|
+
now = time.time()
|
|
244
|
+
cached = self._prompt_cache.get(key)
|
|
245
|
+
if cached and cached["expires_at"] > now:
|
|
246
|
+
return cached["value"]
|
|
247
|
+
try:
|
|
248
|
+
response = (self._http.get if self._http else _requests_fallback.get)(f"{self.host}/api/prompts/{name}", headers={"Authorization": f"Bearer {self.api_key}"}, params={"environment": environment}, timeout=10)
|
|
249
|
+
if response.status_code >= 400:
|
|
250
|
+
raise RuntimeError(f"Prompt resolution failed ({response.status_code}): {response.text[:300]}")
|
|
251
|
+
value = response.json()
|
|
252
|
+
self._prompt_cache[key] = {"value": value, "expires_at": now + cache_ttl_seconds}
|
|
253
|
+
return value
|
|
254
|
+
except Exception:
|
|
255
|
+
if cached:
|
|
256
|
+
return cached["value"]
|
|
257
|
+
if fallback is not None:
|
|
258
|
+
return fallback
|
|
259
|
+
raise
|
|
260
|
+
|
|
261
|
+
def _send_feedback(self, payload: Dict[str, Any]):
|
|
262
|
+
if not self.project_id:
|
|
263
|
+
print("Tracify Warning: project_id is required for feedback and score helpers")
|
|
264
|
+
return
|
|
265
|
+
try:
|
|
266
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
|
267
|
+
if self._http:
|
|
268
|
+
self._http.post(f"{self.host}/api/feedback", headers=headers, json={"projectId": self.project_id, **payload}, timeout=5)
|
|
269
|
+
else:
|
|
270
|
+
_requests_fallback.post(f"{self.host}/api/feedback", headers=headers, json={"projectId": self.project_id, **payload}, timeout=5)
|
|
271
|
+
except Exception as e:
|
|
272
|
+
print(f"Tracify Warning: Failed to record evaluation signal: {e}")
|
|
273
|
+
|
|
274
|
+
def feedback(self, trace_id: str, value: Union[bool, float, int, str], kind: Optional[str] = None, span_id: Optional[str] = None, reason: Optional[str] = None, comment: Optional[str] = None, end_user_id: Optional[str] = None, dedupe_key: Optional[str] = None):
|
|
275
|
+
inferred_kind = kind or ("star" if isinstance(value, (int, float)) and not isinstance(value, bool) else "thumb" if isinstance(value, bool) else "text")
|
|
276
|
+
self._send_feedback({"traceId": trace_id, "spanId": span_id, "kind": inferred_kind, "value": value, "reason": reason, "comment": comment, "endUserId": end_user_id, "dedupeKey": dedupe_key})
|
|
277
|
+
|
|
278
|
+
def score(self, trace_id: str, name: str, value: Union[bool, float, int, str], data_type: Optional[str] = None, span_id: Optional[str] = None, comment: Optional[str] = None):
|
|
279
|
+
self._send_feedback({"traceId": trace_id, "spanId": span_id, "kind": "text", "name": name, "value": value, "dataType": data_type, "comment": comment})
|
|
280
|
+
|
|
281
|
+
def _check_cost(self, run_id: str, increment_usd: float) -> Dict[str, Any]:
|
|
282
|
+
"""Server-side cost check. Returns the server's decision."""
|
|
283
|
+
try:
|
|
284
|
+
headers = {
|
|
285
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
286
|
+
"Content-Type": "application/json"
|
|
287
|
+
}
|
|
288
|
+
if self._http:
|
|
289
|
+
resp = self._http.post(
|
|
290
|
+
self.check_cost_url,
|
|
291
|
+
headers=headers,
|
|
292
|
+
json={"runId": run_id, "incrementUsd": increment_usd},
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
resp = _requests_fallback.post(
|
|
296
|
+
self.check_cost_url,
|
|
297
|
+
headers=headers,
|
|
298
|
+
json={"runId": run_id, "incrementUsd": increment_usd},
|
|
299
|
+
timeout=5,
|
|
300
|
+
)
|
|
301
|
+
if resp.status_code != 200:
|
|
302
|
+
self._last_fail_open = True
|
|
303
|
+
return {"allowed": True, "reason": "check_failed"}
|
|
304
|
+
self._last_fail_open = False
|
|
305
|
+
return resp.json()
|
|
306
|
+
except Exception:
|
|
307
|
+
self._last_fail_open = True
|
|
308
|
+
return {"allowed": True, "reason": "network_error"}
|
|
309
|
+
|
|
310
|
+
def orchestrate(
|
|
311
|
+
self,
|
|
312
|
+
policy: RuntimePolicy,
|
|
313
|
+
call: Callable[[str, CancellationToken], Any],
|
|
314
|
+
model: str,
|
|
315
|
+
input_data: Any = None,
|
|
316
|
+
calculate_cost: Optional[Callable[[Any, str], float]] = None,
|
|
317
|
+
run_id: Optional[str] = None,
|
|
318
|
+
) -> Any:
|
|
319
|
+
run_id = run_id or os.environ.get("TRACIFY_CURRENT_RUN_ID") or os.environ.get("TRACIFY_CURRENT_RUN_ID") or str(uuid.uuid4())
|
|
320
|
+
all_models = [model] + [m for m in policy.fallback_chain if m != model]
|
|
321
|
+
last_error: Optional[Exception] = None
|
|
322
|
+
|
|
323
|
+
for model_index, current_model in enumerate(all_models):
|
|
324
|
+
is_fallback = model_index > 0
|
|
325
|
+
fallback_reason = None
|
|
326
|
+
if is_fallback:
|
|
327
|
+
if last_error and _is_latency_abort(last_error):
|
|
328
|
+
fallback_reason = "latency_budget"
|
|
329
|
+
elif last_error:
|
|
330
|
+
fallback_reason = "provider_error"
|
|
331
|
+
else:
|
|
332
|
+
fallback_reason = "cost_ceiling"
|
|
333
|
+
|
|
334
|
+
max_attempts = 1 if is_fallback else (policy.max_attempts + 1)
|
|
335
|
+
|
|
336
|
+
for attempt in range(1, max_attempts + 1):
|
|
337
|
+
span_id = str(uuid.uuid4())
|
|
338
|
+
start_time = time.time()
|
|
339
|
+
|
|
340
|
+
# Server-side cost ceiling enforcement
|
|
341
|
+
if policy.enforcement_mode == "enforce":
|
|
342
|
+
estimated_cost = 0.01 if calculate_cost else 0
|
|
343
|
+
|
|
344
|
+
if policy.max_cost_per_run is not None:
|
|
345
|
+
check = self._check_cost(run_id, estimated_cost)
|
|
346
|
+
if not check.get("allowed") and check.get("reason") == "maxCostPerRun":
|
|
347
|
+
self.ingest(
|
|
348
|
+
spanId=span_id,
|
|
349
|
+
runId=run_id,
|
|
350
|
+
spanType="llm_call",
|
|
351
|
+
input=json.dumps(input_data) if input_data is not None else "",
|
|
352
|
+
output=json.dumps({"blocked": True, "reason": "cost_ceiling"}),
|
|
353
|
+
modelId=current_model,
|
|
354
|
+
costUsd=0,
|
|
355
|
+
latencyMs=0,
|
|
356
|
+
metadata={
|
|
357
|
+
"orchestrationBlocked": True,
|
|
358
|
+
"orchestrationReason": "cost_ceiling",
|
|
359
|
+
"orchestrationAttempt": attempt,
|
|
360
|
+
"orchestrationModel": current_model,
|
|
361
|
+
"orchestrationOriginalModel": model,
|
|
362
|
+
"orchestrationServerCost": check.get("currentCost"),
|
|
363
|
+
"orchestrationCeiling": check.get("ceiling"),
|
|
364
|
+
"orchestrationFailOpen": self._last_fail_open,
|
|
365
|
+
"orchestrationPolicy": json.dumps(policy.to_dict()),
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
if model_index < len(all_models) - 1:
|
|
369
|
+
last_error = Exception(f"Cost ceiling reached: ${check.get('currentCost', 0):.4f} >= ${check.get('ceiling')}")
|
|
370
|
+
break
|
|
371
|
+
raise Exception(f"Cost ceiling reached: ${check.get('currentCost', 0):.4f} >= ${check.get('ceiling')}")
|
|
372
|
+
|
|
373
|
+
if policy.max_cost_per_day is not None:
|
|
374
|
+
check = self._check_cost(run_id, estimated_cost)
|
|
375
|
+
if not check.get("allowed") and check.get("reason") == "maxCostPerDay":
|
|
376
|
+
self.ingest(
|
|
377
|
+
spanId=span_id,
|
|
378
|
+
runId=run_id,
|
|
379
|
+
spanType="llm_call",
|
|
380
|
+
input=json.dumps(input_data) if input_data is not None else "",
|
|
381
|
+
output=json.dumps({"blocked": True, "reason": "daily_cost_ceiling"}),
|
|
382
|
+
modelId=current_model,
|
|
383
|
+
costUsd=0,
|
|
384
|
+
latencyMs=0,
|
|
385
|
+
metadata={
|
|
386
|
+
"orchestrationBlocked": True,
|
|
387
|
+
"orchestrationReason": "daily_cost_ceiling",
|
|
388
|
+
"orchestrationAttempt": attempt,
|
|
389
|
+
"orchestrationModel": current_model,
|
|
390
|
+
"orchestrationOriginalModel": model,
|
|
391
|
+
"orchestrationServerCost": check.get("currentCost"),
|
|
392
|
+
"orchestrationCeiling": check.get("ceiling"),
|
|
393
|
+
"orchestrationFailOpen": self._last_fail_open,
|
|
394
|
+
"orchestrationPolicy": json.dumps(policy.to_dict()),
|
|
395
|
+
},
|
|
396
|
+
)
|
|
397
|
+
if model_index < len(all_models) - 1:
|
|
398
|
+
last_error = Exception(f"Daily cost ceiling reached: ${check.get('currentCost', 0):.4f} >= ${check.get('ceiling')}")
|
|
399
|
+
break
|
|
400
|
+
raise Exception(f"Daily cost ceiling reached: ${check.get('currentCost', 0):.4f} >= ${check.get('ceiling')}")
|
|
401
|
+
|
|
402
|
+
# Latency budget enforcement via cooperative cancellation
|
|
403
|
+
try:
|
|
404
|
+
timeout = policy.latency_budget_ms if policy.enforcement_mode == "enforce" else None
|
|
405
|
+
result = _run_with_timeout(
|
|
406
|
+
lambda token, m=current_model: call(m, token),
|
|
407
|
+
timeout,
|
|
408
|
+
)
|
|
409
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
410
|
+
cost_usd = calculate_cost(result, current_model) if calculate_cost else 0
|
|
411
|
+
|
|
412
|
+
# Report actual cost to server
|
|
413
|
+
if policy.enforcement_mode == "enforce" and cost_usd > 0:
|
|
414
|
+
self._check_cost(run_id, cost_usd)
|
|
415
|
+
|
|
416
|
+
self.ingest(
|
|
417
|
+
spanId=span_id,
|
|
418
|
+
runId=run_id,
|
|
419
|
+
spanType="llm_call",
|
|
420
|
+
input=json.dumps(input_data) if input_data is not None else "",
|
|
421
|
+
output=json.dumps(result) if not isinstance(result, str) else result,
|
|
422
|
+
modelId=current_model,
|
|
423
|
+
costUsd=cost_usd,
|
|
424
|
+
latencyMs=latency_ms,
|
|
425
|
+
metadata={
|
|
426
|
+
"orchestrationAttempt": attempt,
|
|
427
|
+
"orchestrationModel": current_model,
|
|
428
|
+
"orchestrationOriginalModel": model,
|
|
429
|
+
"orchestrationIsFallback": is_fallback,
|
|
430
|
+
"orchestrationFallbackReason": fallback_reason,
|
|
431
|
+
"orchestrationFinal": True,
|
|
432
|
+
"orchestrationFailOpen": self._last_fail_open,
|
|
433
|
+
"orchestrationPolicy": json.dumps(policy.to_dict()),
|
|
434
|
+
},
|
|
435
|
+
)
|
|
436
|
+
return result
|
|
437
|
+
|
|
438
|
+
except Exception as error:
|
|
439
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
440
|
+
last_error = error
|
|
441
|
+
|
|
442
|
+
retryable = _is_retryable_error(error, policy.retryable_errors)
|
|
443
|
+
is_last_attempt = attempt == max_attempts
|
|
444
|
+
was_latency_abort = _is_latency_abort(error)
|
|
445
|
+
|
|
446
|
+
self.ingest(
|
|
447
|
+
spanId=span_id,
|
|
448
|
+
runId=run_id,
|
|
449
|
+
spanType="llm_call" if (retryable and not is_last_attempt) else "error",
|
|
450
|
+
input=json.dumps(input_data) if input_data is not None else "",
|
|
451
|
+
output=str(error),
|
|
452
|
+
modelId=current_model,
|
|
453
|
+
costUsd=0,
|
|
454
|
+
latencyMs=latency_ms,
|
|
455
|
+
metadata={
|
|
456
|
+
"orchestrationAttempt": attempt,
|
|
457
|
+
"orchestrationModel": current_model,
|
|
458
|
+
"orchestrationOriginalModel": model,
|
|
459
|
+
"orchestrationIsFallback": is_fallback,
|
|
460
|
+
"orchestrationFallbackReason": fallback_reason,
|
|
461
|
+
"orchestrationError": True,
|
|
462
|
+
"orchestrationLatencyAbort": was_latency_abort,
|
|
463
|
+
"orchestrationRetryable": retryable,
|
|
464
|
+
"orchestrationWillRetry": retryable and not is_last_attempt,
|
|
465
|
+
"orchestrationFailOpen": self._last_fail_open,
|
|
466
|
+
"orchestrationPolicy": json.dumps(policy.to_dict()),
|
|
467
|
+
},
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
if retryable and not is_last_attempt:
|
|
471
|
+
delay = policy.backoff_ms * (policy.backoff_multiplier ** (attempt - 1))
|
|
472
|
+
time.sleep(delay / 1000)
|
|
473
|
+
continue
|
|
474
|
+
|
|
475
|
+
break
|
|
476
|
+
|
|
477
|
+
raise last_error or Exception("All fallback models exhausted")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def trace_agent(client: Optional[TracifyClient] = None):
|
|
481
|
+
"""
|
|
482
|
+
Decorator to wrap an agent's main entry point.
|
|
483
|
+
Automatically handles runId generation and run_end span.
|
|
484
|
+
"""
|
|
485
|
+
def decorator(func: Callable):
|
|
486
|
+
@functools.wraps(func)
|
|
487
|
+
async def async_wrapper(*args, **kwargs):
|
|
488
|
+
nonlocal client
|
|
489
|
+
if client is None:
|
|
490
|
+
client = TracifyClient()
|
|
491
|
+
|
|
492
|
+
run_id = str(uuid.uuid4())
|
|
493
|
+
start_time = time.time()
|
|
494
|
+
|
|
495
|
+
os.environ["TRACIFY_CURRENT_RUN_ID"] = run_id
|
|
496
|
+
os.environ["TRACIFY_CURRENT_RUN_ID"] = run_id
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
result = await func(*args, **kwargs)
|
|
500
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
501
|
+
client.ingest(
|
|
502
|
+
runId=run_id,
|
|
503
|
+
spanType="run_end",
|
|
504
|
+
output=json.dumps(result) if result else "",
|
|
505
|
+
latencyMs=latency_ms,
|
|
506
|
+
costUsd=0,
|
|
507
|
+
)
|
|
508
|
+
return result
|
|
509
|
+
except Exception as e:
|
|
510
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
511
|
+
client.ingest(
|
|
512
|
+
runId=run_id,
|
|
513
|
+
spanType="error",
|
|
514
|
+
output=str(e),
|
|
515
|
+
latencyMs=latency_ms,
|
|
516
|
+
costUsd=0,
|
|
517
|
+
)
|
|
518
|
+
raise e
|
|
519
|
+
|
|
520
|
+
@functools.wraps(func)
|
|
521
|
+
def sync_wrapper(*args, **kwargs):
|
|
522
|
+
nonlocal client
|
|
523
|
+
if client is None:
|
|
524
|
+
client = TracifyClient()
|
|
525
|
+
|
|
526
|
+
run_id = str(uuid.uuid4())
|
|
527
|
+
start_time = time.time()
|
|
528
|
+
os.environ["TRACIFY_CURRENT_RUN_ID"] = run_id
|
|
529
|
+
os.environ["TRACIFY_CURRENT_RUN_ID"] = run_id
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
result = func(*args, **kwargs)
|
|
533
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
534
|
+
client.ingest(
|
|
535
|
+
runId=run_id,
|
|
536
|
+
spanType="run_end",
|
|
537
|
+
output=json.dumps(result) if result else "",
|
|
538
|
+
latencyMs=latency_ms,
|
|
539
|
+
costUsd=0,
|
|
540
|
+
)
|
|
541
|
+
return result
|
|
542
|
+
except Exception as e:
|
|
543
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
544
|
+
client.ingest(
|
|
545
|
+
runId=run_id,
|
|
546
|
+
spanType="error",
|
|
547
|
+
output=str(e),
|
|
548
|
+
latencyMs=latency_ms,
|
|
549
|
+
costUsd=0,
|
|
550
|
+
)
|
|
551
|
+
raise e
|
|
552
|
+
|
|
553
|
+
if asyncio.iscoroutinefunction(func):
|
|
554
|
+
return async_wrapper
|
|
555
|
+
return sync_wrapper
|
|
556
|
+
return decorator
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def llm_call(input_data: Any, output_data: Any, model_id: str, cost_usd: float, latency_ms: int, client: Optional[TracifyClient] = None, session_id: Optional[str] = None, end_user_id: Optional[str] = None, environment: Optional[str] = None, release: Optional[str] = None, tags: Optional[List[str]] = None, trace_name: Optional[str] = None):
|
|
560
|
+
if client is None: client = TracifyClient()
|
|
561
|
+
client.ingest(
|
|
562
|
+
runId=os.environ.get("TRACIFY_CURRENT_RUN_ID") or os.environ.get("TRACIFY_CURRENT_RUN_ID", "unknown"),
|
|
563
|
+
spanType="llm_call",
|
|
564
|
+
input=json.dumps(input_data) if not isinstance(input_data, str) else input_data,
|
|
565
|
+
output=json.dumps(output_data) if not isinstance(output_data, str) else output_data,
|
|
566
|
+
modelId=model_id,
|
|
567
|
+
costUsd=cost_usd,
|
|
568
|
+
latencyMs=latency_ms,
|
|
569
|
+
sessionId=session_id or "",
|
|
570
|
+
endUserId=end_user_id or "",
|
|
571
|
+
environment=environment or "",
|
|
572
|
+
release=release or "",
|
|
573
|
+
tags=tags or [],
|
|
574
|
+
traceName=trace_name or "",
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def tool_call(tool_name: str, input_data: Any, output_data: Any, latency_ms: int, client: Optional[TracifyClient] = None, session_id: Optional[str] = None, end_user_id: Optional[str] = None, environment: Optional[str] = None, release: Optional[str] = None, tags: Optional[List[str]] = None, trace_name: Optional[str] = None):
|
|
579
|
+
if client is None: client = TracifyClient()
|
|
580
|
+
client.ingest(
|
|
581
|
+
runId=os.environ.get("TRACIFY_CURRENT_RUN_ID") or os.environ.get("TRACIFY_CURRENT_RUN_ID", "unknown"),
|
|
582
|
+
spanType="tool_call",
|
|
583
|
+
toolName=tool_name,
|
|
584
|
+
input=json.dumps(input_data) if not isinstance(input_data, str) else input_data,
|
|
585
|
+
output=json.dumps(output_data) if not isinstance(output_data, str) else output_data,
|
|
586
|
+
latencyMs=latency_ms,
|
|
587
|
+
costUsd=0,
|
|
588
|
+
sessionId=session_id or "",
|
|
589
|
+
endUserId=end_user_id or "",
|
|
590
|
+
environment=environment or "",
|
|
591
|
+
release=release or "",
|
|
592
|
+
tags=tags or [],
|
|
593
|
+
traceName=trace_name or "",
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tracify-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Observability, runtime enforcement, and tracing for AI agents.
|
|
5
|
+
Author-email: Tracify Team <hello@tracify.tech>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://tracify.tech
|
|
8
|
+
Project-URL: Documentation, https://docs.tracify.tech
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/tracify/python-sdk/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.7
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: requests>=2.25.0
|
|
15
|
+
Requires-Dist: httpx>=0.24.0
|
|
16
|
+
|
|
17
|
+
# Tracify Python SDK
|
|
18
|
+
|
|
19
|
+
Observability and tracing for AI agents. Gain full visibility into your agent's decision-making process, tool usage, and LLM costs.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install tracify-sdk
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
1. Get your API Key from the [Tracify Dashboard](https://tracify.tech).
|
|
30
|
+
2. Set it as an environment variable:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
export TRACIFY_API_KEY=your_sk_live_...
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
3. Instrument your agent:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from tracify import trace_agent, llm_call, tool_call
|
|
40
|
+
|
|
41
|
+
@trace_agent()
|
|
42
|
+
async def my_agent(query: str):
|
|
43
|
+
# LLM Call
|
|
44
|
+
llm_call(
|
|
45
|
+
input_data=query,
|
|
46
|
+
output_data="Thinking...",
|
|
47
|
+
model_id="gpt-4o",
|
|
48
|
+
cost_usd=0.002,
|
|
49
|
+
latency_ms=800
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Tool Use
|
|
53
|
+
tool_call(
|
|
54
|
+
tool_name="web_search",
|
|
55
|
+
input_data={"q": query},
|
|
56
|
+
output_data="Found results...",
|
|
57
|
+
latency_ms=1200
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return "Final Answer"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Features
|
|
64
|
+
|
|
65
|
+
- **@trace_agent Decorator:** Automatically captures run lifecycle and errors.
|
|
66
|
+
- **Granular Spans:** Manually record LLMs, tools, and custom decisions.
|
|
67
|
+
- **Cost Tracking:** Monitor your infrastructure spend in real-time.
|
|
68
|
+
- **Asynchronous & Synchronous Support:** Works with `async/await` or standard functions.
|
|
69
|
+
- **Session context:** Attach `session_id`, `end_user_id`, `environment`, `release`, and `tags` to connect related traces.
|
|
70
|
+
|
|
71
|
+
## Feedback and evaluation scores
|
|
72
|
+
|
|
73
|
+
Pass `project_id` when constructing the client to attach end-user feedback and typed scores to traces:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
client = TracifyClient(project_id="your-project-id")
|
|
77
|
+
client.feedback(run_id, True, kind="thumb", comment="Helpful answer")
|
|
78
|
+
client.score(run_id, "answer_quality", 0.92, data_type="numeric")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Feedback and scores use the same API key as ingestion and do not interrupt the agent when delivery fails.
|
|
82
|
+
|
|
83
|
+
## Prompt deployment
|
|
84
|
+
|
|
85
|
+
Resolve a deployed prompt without shipping a new application build:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
deployed = client.get_prompt("support-agent", "production")
|
|
89
|
+
client.ingest(spanType="llm", input=question, output=answer, promptVersionId=deployed["version"]["id"])
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Prompt responses are cached for 60 seconds by default. Use `cache_ttl_seconds=0` or provide a `fallback` for development and guaranteed-availability behavior.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/tracify/__init__.py
|
|
4
|
+
src/tracify_sdk.egg-info/PKG-INFO
|
|
5
|
+
src/tracify_sdk.egg-info/SOURCES.txt
|
|
6
|
+
src/tracify_sdk.egg-info/dependency_links.txt
|
|
7
|
+
src/tracify_sdk.egg-info/requires.txt
|
|
8
|
+
src/tracify_sdk.egg-info/top_level.txt
|
|
9
|
+
tests/test_orchestrate.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tracify
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import json
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import patch, MagicMock, PropertyMock
|
|
5
|
+
from tracify import (
|
|
6
|
+
TracifyClient,
|
|
7
|
+
RuntimePolicy,
|
|
8
|
+
CancellationToken,
|
|
9
|
+
LatencyBudgetExceeded,
|
|
10
|
+
_is_retryable_error,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
HTTP_TARGET = "tracify.httpx.Client.post" if True else "tracify._requests_fallback.post"
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import httpx
|
|
17
|
+
_HAS_HTTPX = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
_HAS_HTTPX = False
|
|
20
|
+
|
|
21
|
+
MOCK_TARGET = "tracify.httpx.Client.post" if _HAS_HTTPX else "tracify._requests_fallback.post"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestRuntimePolicy:
|
|
25
|
+
def test_to_dict_minimal(self):
|
|
26
|
+
p = RuntimePolicy(fallback_chain=["gpt-4o"])
|
|
27
|
+
d = p.to_dict()
|
|
28
|
+
assert d["enforcementMode"] == "observe"
|
|
29
|
+
assert d["fallbackChain"] == ["gpt-4o"]
|
|
30
|
+
assert d["maxCostPerRun"] is None
|
|
31
|
+
assert d["maxCostPerDay"] is None
|
|
32
|
+
assert d["retryPolicy"]["maxAttempts"] == 3
|
|
33
|
+
assert d["retryPolicy"]["backoffMs"] == 1000
|
|
34
|
+
assert d["retryPolicy"]["backoffMultiplier"] == 2.0
|
|
35
|
+
assert d["retryPolicy"]["retryableErrors"] == ["timeout", "5xx"]
|
|
36
|
+
assert d["latencyBudgetMs"] is None
|
|
37
|
+
|
|
38
|
+
def test_to_dict_full(self):
|
|
39
|
+
p = RuntimePolicy(
|
|
40
|
+
enforcement_mode="enforce",
|
|
41
|
+
max_attempts=5,
|
|
42
|
+
backoff_ms=200,
|
|
43
|
+
backoff_multiplier=1.5,
|
|
44
|
+
fallback_chain=["gpt-4o", "claude-sonnet-4-20250514"],
|
|
45
|
+
retryable_errors=["rate_limit"],
|
|
46
|
+
max_cost_per_run=0.1,
|
|
47
|
+
max_cost_per_day=5.0,
|
|
48
|
+
latency_budget_ms=10000,
|
|
49
|
+
)
|
|
50
|
+
d = p.to_dict()
|
|
51
|
+
assert d["enforcementMode"] == "enforce"
|
|
52
|
+
assert d["retryPolicy"]["maxAttempts"] == 5
|
|
53
|
+
assert d["retryPolicy"]["backoffMs"] == 200
|
|
54
|
+
assert d["retryPolicy"]["backoffMultiplier"] == 1.5
|
|
55
|
+
assert d["fallbackChain"] == ["gpt-4o", "claude-sonnet-4-20250514"]
|
|
56
|
+
assert d["retryPolicy"]["retryableErrors"] == ["rate_limit"]
|
|
57
|
+
assert d["maxCostPerRun"] == 0.1
|
|
58
|
+
assert d["maxCostPerDay"] == 5.0
|
|
59
|
+
assert d["latencyBudgetMs"] == 10000
|
|
60
|
+
|
|
61
|
+
def test_to_dict_strips_non_serializable(self):
|
|
62
|
+
p = RuntimePolicy(fallback_chain=["gpt-4o"])
|
|
63
|
+
d = p.to_dict()
|
|
64
|
+
assert "call" not in str(d)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestCancellationToken:
|
|
68
|
+
def test_initial_state(self):
|
|
69
|
+
t = CancellationToken()
|
|
70
|
+
assert t.is_cancelled is False
|
|
71
|
+
|
|
72
|
+
def test_cancel(self):
|
|
73
|
+
t = CancellationToken()
|
|
74
|
+
t.cancel()
|
|
75
|
+
assert t.is_cancelled is True
|
|
76
|
+
|
|
77
|
+
def test_check_raises_when_cancelled(self):
|
|
78
|
+
t = CancellationToken()
|
|
79
|
+
t.cancel()
|
|
80
|
+
with pytest.raises(LatencyBudgetExceeded):
|
|
81
|
+
t.check()
|
|
82
|
+
|
|
83
|
+
def test_check_noop_when_not_cancelled(self):
|
|
84
|
+
t = CancellationToken()
|
|
85
|
+
t.check()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestTracifyClient:
|
|
89
|
+
def test_selects_requested_region(self):
|
|
90
|
+
client = TracifyClient(api_key="test-key", region="us")
|
|
91
|
+
assert client.host == "https://us.cloud.tracify.tech"
|
|
92
|
+
|
|
93
|
+
def test_rejects_wrong_region_key(self):
|
|
94
|
+
with pytest.raises(ValueError, match="belongs to EU"):
|
|
95
|
+
TracifyClient(api_key="tracify_sk_live_eu_example", region="us")
|
|
96
|
+
|
|
97
|
+
def test_legacy_keys_belong_to_eu(self):
|
|
98
|
+
with pytest.raises(ValueError, match="belongs to EU"):
|
|
99
|
+
TracifyClient(api_key="tracify_sk_live_legacy", region="us")
|
|
100
|
+
|
|
101
|
+
@patch(MOCK_TARGET)
|
|
102
|
+
def test_ingest_sends_correct_payload(self, mock_http):
|
|
103
|
+
mock_http.return_value = MagicMock(status_code=202)
|
|
104
|
+
|
|
105
|
+
client = TracifyClient(api_key="test-key")
|
|
106
|
+
client.ingest(
|
|
107
|
+
runId="run-1",
|
|
108
|
+
spanType="llm_call",
|
|
109
|
+
input="hello",
|
|
110
|
+
output="world",
|
|
111
|
+
modelId="gpt-4o",
|
|
112
|
+
costUsd=0.001,
|
|
113
|
+
latencyMs=100,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
mock_http.assert_called_once()
|
|
117
|
+
payload = mock_http.call_args.kwargs.get("json") or mock_http.call_args[1].get("json")
|
|
118
|
+
assert payload["runId"] == "run-1"
|
|
119
|
+
assert payload["spanType"] == "llm_call"
|
|
120
|
+
assert payload["modelId"] == "gpt-4o"
|
|
121
|
+
assert payload["costUsd"] == 0.001
|
|
122
|
+
assert payload["latencyMs"] == 100
|
|
123
|
+
|
|
124
|
+
@patch(MOCK_TARGET)
|
|
125
|
+
def test_ingest_preserves_custom_metadata(self, mock_http):
|
|
126
|
+
mock_http.return_value = MagicMock(status_code=202)
|
|
127
|
+
|
|
128
|
+
client = TracifyClient(api_key="test-key")
|
|
129
|
+
client.ingest(
|
|
130
|
+
runId="run-1",
|
|
131
|
+
spanType="llm_call",
|
|
132
|
+
input="hello",
|
|
133
|
+
output="world",
|
|
134
|
+
modelId="gpt-4o",
|
|
135
|
+
costUsd=0.001,
|
|
136
|
+
latencyMs=100,
|
|
137
|
+
metadata={"customKey": "customValue"},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
mock_http.assert_called_once()
|
|
141
|
+
payload = mock_http.call_args.kwargs.get("json") or mock_http.call_args[1].get("json")
|
|
142
|
+
assert payload["metadata"]["customKey"] == "customValue"
|
|
143
|
+
|
|
144
|
+
@patch(MOCK_TARGET)
|
|
145
|
+
def test_orchestrate_success_on_first_model(self, mock_http):
|
|
146
|
+
mock_http.return_value = MagicMock(status_code=202)
|
|
147
|
+
client = TracifyClient(api_key="test-key")
|
|
148
|
+
|
|
149
|
+
def mock_call(model, token):
|
|
150
|
+
return {"result": "ok", "model": model}
|
|
151
|
+
|
|
152
|
+
policy = RuntimePolicy(fallback_chain=["gpt-4o", "claude-sonnet-4-20250514"])
|
|
153
|
+
result = client.orchestrate(
|
|
154
|
+
input_data={"prompt": "hello"},
|
|
155
|
+
policy=policy,
|
|
156
|
+
call=mock_call,
|
|
157
|
+
model="gpt-4o",
|
|
158
|
+
)
|
|
159
|
+
assert result == {"result": "ok", "model": "gpt-4o"}
|
|
160
|
+
|
|
161
|
+
@patch(MOCK_TARGET)
|
|
162
|
+
def test_orchestrate_fallback_on_error(self, mock_http):
|
|
163
|
+
mock_http.return_value = MagicMock(status_code=202)
|
|
164
|
+
client = TracifyClient(api_key="test-key")
|
|
165
|
+
|
|
166
|
+
def mock_call(model, token):
|
|
167
|
+
if model == "gpt-4o":
|
|
168
|
+
raise Exception("rate limit exceeded")
|
|
169
|
+
return {"result": "ok", "model": model}
|
|
170
|
+
|
|
171
|
+
policy = RuntimePolicy(
|
|
172
|
+
fallback_chain=["gpt-4o", "claude-sonnet-4-20250514"],
|
|
173
|
+
retryable_errors=["rate_limit"],
|
|
174
|
+
max_attempts=1,
|
|
175
|
+
)
|
|
176
|
+
result = client.orchestrate(
|
|
177
|
+
input_data={"prompt": "hello"},
|
|
178
|
+
policy=policy,
|
|
179
|
+
call=mock_call,
|
|
180
|
+
model="gpt-4o",
|
|
181
|
+
)
|
|
182
|
+
assert result == {"result": "ok", "model": "claude-sonnet-4-20250514"}
|
|
183
|
+
|
|
184
|
+
@patch(MOCK_TARGET)
|
|
185
|
+
def test_orchestrate_terminal_error_when_all_fail(self, mock_http):
|
|
186
|
+
mock_http.return_value = MagicMock(status_code=202)
|
|
187
|
+
client = TracifyClient(api_key="test-key")
|
|
188
|
+
|
|
189
|
+
def mock_call(model, token):
|
|
190
|
+
raise Exception(f"{model} failed")
|
|
191
|
+
|
|
192
|
+
policy = RuntimePolicy(
|
|
193
|
+
fallback_chain=["gpt-4o", "claude-sonnet-4-20250514"],
|
|
194
|
+
max_attempts=1,
|
|
195
|
+
)
|
|
196
|
+
with pytest.raises(Exception, match="failed"):
|
|
197
|
+
client.orchestrate(
|
|
198
|
+
input_data={"prompt": "hello"},
|
|
199
|
+
policy=policy,
|
|
200
|
+
call=mock_call,
|
|
201
|
+
model="gpt-4o",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
@patch(MOCK_TARGET)
|
|
205
|
+
def test_orchestrate_passes_cancellation_token(self, mock_http):
|
|
206
|
+
mock_http.return_value = MagicMock(status_code=202)
|
|
207
|
+
client = TracifyClient(api_key="test-key")
|
|
208
|
+
received_tokens = []
|
|
209
|
+
|
|
210
|
+
def mock_call(model, token):
|
|
211
|
+
received_tokens.append(token)
|
|
212
|
+
return "ok"
|
|
213
|
+
|
|
214
|
+
policy = RuntimePolicy(fallback_chain=["gpt-4o"])
|
|
215
|
+
client.orchestrate(
|
|
216
|
+
input_data={"prompt": "hello"},
|
|
217
|
+
policy=policy,
|
|
218
|
+
call=mock_call,
|
|
219
|
+
model="gpt-4o",
|
|
220
|
+
)
|
|
221
|
+
assert len(received_tokens) == 1
|
|
222
|
+
assert isinstance(received_tokens[0], CancellationToken)
|
|
223
|
+
assert received_tokens[0].is_cancelled is False
|
|
224
|
+
|
|
225
|
+
@patch(MOCK_TARGET)
|
|
226
|
+
def test_orchestrate_latency_abort_falls_back(self, mock_http):
|
|
227
|
+
mock_http.return_value = MagicMock(status_code=202)
|
|
228
|
+
client = TracifyClient(api_key="test-key")
|
|
229
|
+
|
|
230
|
+
def mock_call(model, token):
|
|
231
|
+
if model == "gpt-4o":
|
|
232
|
+
raise LatencyBudgetExceeded("timeout")
|
|
233
|
+
return {"result": "fallback-ok", "model": model}
|
|
234
|
+
|
|
235
|
+
policy = RuntimePolicy(
|
|
236
|
+
fallback_chain=["gpt-4o", "claude-sonnet-4-20250514"],
|
|
237
|
+
latency_budget_ms=1,
|
|
238
|
+
)
|
|
239
|
+
result = client.orchestrate(
|
|
240
|
+
input_data={"prompt": "hello"},
|
|
241
|
+
policy=policy,
|
|
242
|
+
call=mock_call,
|
|
243
|
+
model="gpt-4o",
|
|
244
|
+
)
|
|
245
|
+
assert result == {"result": "fallback-ok", "model": "claude-sonnet-4-20250514"}
|
|
246
|
+
|
|
247
|
+
@patch(MOCK_TARGET)
|
|
248
|
+
def test_orchestrate_failopen_on_check_cost_failure(self, mock_http):
|
|
249
|
+
mock_http.return_value = MagicMock(status_code=500)
|
|
250
|
+
client = TracifyClient(api_key="test-key")
|
|
251
|
+
policy = RuntimePolicy(
|
|
252
|
+
enforcement_mode="enforce",
|
|
253
|
+
fallback_chain=["gpt-4o"],
|
|
254
|
+
max_cost_per_run=0.01,
|
|
255
|
+
)
|
|
256
|
+
result = client.orchestrate(
|
|
257
|
+
input_data={"prompt": "hello"},
|
|
258
|
+
policy=policy,
|
|
259
|
+
call=lambda m, t: {"result": "ok"},
|
|
260
|
+
model="gpt-4o",
|
|
261
|
+
)
|
|
262
|
+
assert result == {"result": "ok"}
|
|
263
|
+
assert client._last_fail_open is True
|
|
264
|
+
|
|
265
|
+
@patch(MOCK_TARGET)
|
|
266
|
+
def test_orchestrate_success_resets_failopen_flag(self, mock_http):
|
|
267
|
+
call_count = {"n": 0}
|
|
268
|
+
|
|
269
|
+
def side_effect(*args, **kwargs):
|
|
270
|
+
call_count["n"] += 1
|
|
271
|
+
if call_count["n"] <= 2:
|
|
272
|
+
fail_resp = MagicMock()
|
|
273
|
+
fail_resp.status_code = 500
|
|
274
|
+
return fail_resp
|
|
275
|
+
ok = MagicMock()
|
|
276
|
+
ok.status_code = 200
|
|
277
|
+
ok.json.return_value = {"allowed": True}
|
|
278
|
+
return ok
|
|
279
|
+
|
|
280
|
+
mock_http.side_effect = side_effect
|
|
281
|
+
client = TracifyClient(api_key="test-key")
|
|
282
|
+
policy = RuntimePolicy(
|
|
283
|
+
enforcement_mode="enforce",
|
|
284
|
+
fallback_chain=["gpt-4o"],
|
|
285
|
+
max_cost_per_run=0.01,
|
|
286
|
+
max_cost_per_day=0.01,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
client.orchestrate(
|
|
290
|
+
input_data={"prompt": "hello"},
|
|
291
|
+
policy=policy,
|
|
292
|
+
call=lambda m, t: {"result": "ok"},
|
|
293
|
+
model="gpt-4o",
|
|
294
|
+
)
|
|
295
|
+
assert client._last_fail_open is True
|
|
296
|
+
|
|
297
|
+
client.orchestrate(
|
|
298
|
+
input_data={"prompt": "hello"},
|
|
299
|
+
policy=policy,
|
|
300
|
+
call=lambda m, t: {"result": "ok"},
|
|
301
|
+
model="gpt-4o",
|
|
302
|
+
)
|
|
303
|
+
assert client._last_fail_open is False
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class TestLatencyBudgetExceeded:
|
|
307
|
+
def test_is_exception(self):
|
|
308
|
+
e = LatencyBudgetExceeded("timeout")
|
|
309
|
+
assert isinstance(e, Exception)
|
|
310
|
+
assert str(e) == "timeout"
|
|
311
|
+
|
|
312
|
+
def test_not_retryable(self):
|
|
313
|
+
assert _is_retryable_error(LatencyBudgetExceeded("timeout"), ["timeout"]) is False
|