zespan-fastapi 1.0.0__py3-none-any.whl
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.
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Zespan Trace - FastAPI Middleware
|
|
3
|
+
Automatic Agent observability for FastAPI applications
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.0"
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
import json
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any, Dict, List, Optional, Callable
|
|
12
|
+
from dataclasses import dataclass, field, asdict
|
|
13
|
+
from contextvars import ContextVar
|
|
14
|
+
|
|
15
|
+
# Context for current trace
|
|
16
|
+
current_trace: ContextVar[Optional["TraceContext"]] = ContextVar("current_trace", default=None)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TraceContext:
|
|
21
|
+
"""Context for a single trace/span"""
|
|
22
|
+
trace_id: str
|
|
23
|
+
span_id: str
|
|
24
|
+
parent_id: Optional[str] = None
|
|
25
|
+
name: str = ""
|
|
26
|
+
start_time: float = field(default_factory=time.time)
|
|
27
|
+
attributes: Dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ZespanConfig:
|
|
32
|
+
"""Configuration for Zespan middleware"""
|
|
33
|
+
api_key: str
|
|
34
|
+
base_url: str = "https://api.zespan.com"
|
|
35
|
+
enabled: bool = True
|
|
36
|
+
sample_rate: float = 1.0
|
|
37
|
+
capture_request_body: bool = False
|
|
38
|
+
capture_response_body: bool = False
|
|
39
|
+
redact_fields: List[str] = field(default_factory=lambda: ["password", "token", "api_key"])
|
|
40
|
+
debug: bool = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ZespanFastAPIMiddleware:
|
|
44
|
+
"""
|
|
45
|
+
FastAPI middleware for automatic LLM observability.
|
|
46
|
+
|
|
47
|
+
Usage:
|
|
48
|
+
from fastapi import FastAPI
|
|
49
|
+
from zespan_fastapi import ZespanFastAPIMiddleware, ZespanConfig
|
|
50
|
+
|
|
51
|
+
app = FastAPI()
|
|
52
|
+
config = ZespanConfig(api_key="your-api-key")
|
|
53
|
+
app.add_middleware(ZespanFastAPIMiddleware, config=config)
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, app, config: ZespanConfig):
|
|
57
|
+
self.app = app
|
|
58
|
+
self.config = config
|
|
59
|
+
self._queue: List[Dict[str, Any]] = []
|
|
60
|
+
self._flush_interval = 5.0
|
|
61
|
+
self._last_flush = time.time()
|
|
62
|
+
|
|
63
|
+
async def __call__(self, scope, receive, send):
|
|
64
|
+
if scope["type"] != "http" or not self.config.enabled:
|
|
65
|
+
return await self.app(scope, receive, send)
|
|
66
|
+
|
|
67
|
+
trace_id = f"trace_{uuid.uuid4().hex}"
|
|
68
|
+
span_id = f"span_{uuid.uuid4().hex[:16]}"
|
|
69
|
+
|
|
70
|
+
trace_ctx = TraceContext(
|
|
71
|
+
trace_id=trace_id,
|
|
72
|
+
span_id=span_id,
|
|
73
|
+
name=f"{scope['method']} {scope['path']}",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
token = current_trace.set(trace_ctx)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
start_time = time.time()
|
|
80
|
+
|
|
81
|
+
# Capture request info
|
|
82
|
+
request_attrs = {
|
|
83
|
+
"http.method": scope.get("method"),
|
|
84
|
+
"http.url": scope.get("path"),
|
|
85
|
+
"http.route": scope.get("path"),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Wrap send to capture response
|
|
89
|
+
response_status = None
|
|
90
|
+
async def wrapped_send(message):
|
|
91
|
+
nonlocal response_status
|
|
92
|
+
if message["type"] == "http.response.start":
|
|
93
|
+
response_status = message.get("status")
|
|
94
|
+
await send(message)
|
|
95
|
+
|
|
96
|
+
await self.app(scope, receive, wrapped_send)
|
|
97
|
+
|
|
98
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
99
|
+
|
|
100
|
+
# Build trace data
|
|
101
|
+
trace_data = {
|
|
102
|
+
"trace_id": trace_id,
|
|
103
|
+
"span_id": span_id,
|
|
104
|
+
"name": trace_ctx.name,
|
|
105
|
+
"type": "http_request",
|
|
106
|
+
"duration_ms": duration_ms,
|
|
107
|
+
"status": "success" if response_status and response_status < 400 else "error",
|
|
108
|
+
"http_status": response_status,
|
|
109
|
+
"attributes": {**request_attrs, **trace_ctx.attributes},
|
|
110
|
+
"timestamp": time.time(),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
self._queue.append(trace_data)
|
|
114
|
+
|
|
115
|
+
# Flush if needed
|
|
116
|
+
if len(self._queue) >= 10 or time.time() - self._last_flush > self._flush_interval:
|
|
117
|
+
await self._flush()
|
|
118
|
+
|
|
119
|
+
finally:
|
|
120
|
+
current_trace.reset(token)
|
|
121
|
+
|
|
122
|
+
async def _flush(self):
|
|
123
|
+
"""Send queued traces to Zespan"""
|
|
124
|
+
if not self._queue:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
traces = self._queue[:]
|
|
128
|
+
self._queue = []
|
|
129
|
+
self._last_flush = time.time()
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
import httpx
|
|
133
|
+
async with httpx.AsyncClient() as client:
|
|
134
|
+
await client.post(
|
|
135
|
+
f"{self.config.base_url}/v1/ingest/batch",
|
|
136
|
+
json={"traces": traces},
|
|
137
|
+
headers={"X-API-Key": self.config.api_key},
|
|
138
|
+
timeout=5.0
|
|
139
|
+
)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
if self.config.debug:
|
|
142
|
+
print(f"[Zespan] Failed to send traces: {e}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def observe_span(name: str, span_type: str = "custom"):
|
|
146
|
+
"""
|
|
147
|
+
Decorator to observe a function as a span.
|
|
148
|
+
|
|
149
|
+
Usage:
|
|
150
|
+
@observe_span("my_llm_call", span_type="llm")
|
|
151
|
+
async def my_llm_function(prompt: str):
|
|
152
|
+
return await call_llm(prompt)
|
|
153
|
+
"""
|
|
154
|
+
def decorator(func: Callable) -> Callable:
|
|
155
|
+
async def async_wrapper(*args, **kwargs):
|
|
156
|
+
trace_ctx = current_trace.get()
|
|
157
|
+
if not trace_ctx:
|
|
158
|
+
return await func(*args, **kwargs)
|
|
159
|
+
|
|
160
|
+
span_id = f"span_{uuid.uuid4().hex[:16]}"
|
|
161
|
+
start = time.time()
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
result = await func(*args, **kwargs)
|
|
165
|
+
duration = (time.time() - start) * 1000
|
|
166
|
+
|
|
167
|
+
# Add to parent trace
|
|
168
|
+
trace_ctx.attributes[f"span.{name}.duration_ms"] = duration
|
|
169
|
+
trace_ctx.attributes[f"span.{name}.status"] = "success"
|
|
170
|
+
|
|
171
|
+
return result
|
|
172
|
+
except Exception as e:
|
|
173
|
+
duration = (time.time() - start) * 1000
|
|
174
|
+
trace_ctx.attributes[f"span.{name}.duration_ms"] = duration
|
|
175
|
+
trace_ctx.attributes[f"span.{name}.status"] = "error"
|
|
176
|
+
trace_ctx.attributes[f"span.{name}.error"] = str(e)
|
|
177
|
+
raise
|
|
178
|
+
|
|
179
|
+
def sync_wrapper(*args, **kwargs):
|
|
180
|
+
import asyncio
|
|
181
|
+
return asyncio.run(async_wrapper(*args, **kwargs))
|
|
182
|
+
|
|
183
|
+
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
|
|
184
|
+
return decorator
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def observe_llm(model: Optional[str] = None, provider: Optional[str] = None):
|
|
188
|
+
"""
|
|
189
|
+
Decorator specifically for LLM calls with automatic token tracking.
|
|
190
|
+
|
|
191
|
+
Usage:
|
|
192
|
+
@observe_llm(model="gpt-4", provider="openai")
|
|
193
|
+
async def generate_text(prompt: str):
|
|
194
|
+
response = await openai.chat.completions.create(...)
|
|
195
|
+
return response
|
|
196
|
+
"""
|
|
197
|
+
def decorator(func: Callable) -> Callable:
|
|
198
|
+
async def wrapper(*args, **kwargs):
|
|
199
|
+
trace_ctx = current_trace.get()
|
|
200
|
+
|
|
201
|
+
span_id = f"span_{uuid.uuid4().hex[:16]}"
|
|
202
|
+
start = time.time()
|
|
203
|
+
|
|
204
|
+
# Extract prompt from args/kwargs if possible
|
|
205
|
+
prompt = kwargs.get("prompt") or (args[0] if args else None)
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
result = await func(*args, **kwargs)
|
|
209
|
+
duration = (time.time() - start) * 1000
|
|
210
|
+
|
|
211
|
+
if trace_ctx:
|
|
212
|
+
trace_ctx.attributes.update({
|
|
213
|
+
"llm.model": model or "unknown",
|
|
214
|
+
"llm.provider": provider or "unknown",
|
|
215
|
+
"llm.duration_ms": duration,
|
|
216
|
+
"llm.prompt": str(prompt)[:1000] if prompt else None,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
# Try to extract token usage from common response formats
|
|
220
|
+
if hasattr(result, "usage"):
|
|
221
|
+
usage = result.usage
|
|
222
|
+
trace_ctx.attributes["llm.tokens.input"] = getattr(usage, "prompt_tokens", None)
|
|
223
|
+
trace_ctx.attributes["llm.tokens.output"] = getattr(usage, "completion_tokens", None)
|
|
224
|
+
trace_ctx.attributes["llm.tokens.total"] = getattr(usage, "total_tokens", None)
|
|
225
|
+
elif isinstance(result, dict):
|
|
226
|
+
usage = result.get("usage", {})
|
|
227
|
+
trace_ctx.attributes["llm.tokens.input"] = usage.get("prompt_tokens")
|
|
228
|
+
trace_ctx.attributes["llm.tokens.output"] = usage.get("completion_tokens")
|
|
229
|
+
trace_ctx.attributes["llm.tokens.total"] = usage.get("total_tokens")
|
|
230
|
+
|
|
231
|
+
return result
|
|
232
|
+
except Exception as e:
|
|
233
|
+
if trace_ctx:
|
|
234
|
+
trace_ctx.attributes["llm.error"] = str(e)
|
|
235
|
+
raise
|
|
236
|
+
|
|
237
|
+
return wrapper
|
|
238
|
+
return decorator
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def set_attribute(key: str, value: Any):
|
|
242
|
+
"""Set an attribute on the current trace context"""
|
|
243
|
+
trace_ctx = current_trace.get()
|
|
244
|
+
if trace_ctx:
|
|
245
|
+
trace_ctx.attributes[key] = value
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def get_current_trace_id() -> Optional[str]:
|
|
249
|
+
"""Get the current trace ID if available"""
|
|
250
|
+
trace_ctx = current_trace.get()
|
|
251
|
+
return trace_ctx.trace_id if trace_ctx else None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# Re-exports
|
|
255
|
+
__all__ = [
|
|
256
|
+
"ZespanFastAPIMiddleware",
|
|
257
|
+
"ZespanConfig",
|
|
258
|
+
"observe_span",
|
|
259
|
+
"observe_llm",
|
|
260
|
+
"set_attribute",
|
|
261
|
+
"get_current_trace_id",
|
|
262
|
+
"TraceContext",
|
|
263
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zespan-fastapi
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Zespan observability middleware for FastAPI
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: fastapi>=0.100
|
|
7
|
+
Requires-Dist: requests>=2.28
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: httpx>=0.24; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# zespan-fastapi
|
|
15
|
+
|
|
16
|
+
Automatic LLM observability middleware for FastAPI. Traces every request, LLM call, and custom span with zero code changes for basic usage.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install zespan-fastapi
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from fastapi import FastAPI
|
|
28
|
+
from zespan_fastapi import ZespanFastAPIMiddleware, ZespanConfig
|
|
29
|
+
|
|
30
|
+
app = FastAPI()
|
|
31
|
+
|
|
32
|
+
config = ZespanConfig(api_key="zsp_your_api_key")
|
|
33
|
+
app.add_middleware(ZespanFastAPIMiddleware, config=config)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Every HTTP request is now automatically traced.
|
|
37
|
+
|
|
38
|
+
## Trace LLM Calls
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from zespan_fastapi import observe_llm
|
|
42
|
+
|
|
43
|
+
@observe_llm(model="gpt-4o", provider="openai")
|
|
44
|
+
async def generate_response(prompt: str):
|
|
45
|
+
response = await openai.chat.completions.create(
|
|
46
|
+
model="gpt-4o",
|
|
47
|
+
messages=[{"role": "user", "content": prompt}]
|
|
48
|
+
)
|
|
49
|
+
return response
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Trace Custom Spans
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from zespan_fastapi import observe_span
|
|
56
|
+
|
|
57
|
+
@observe_span("fetch_context", span_type="retrieval")
|
|
58
|
+
async def fetch_context(query: str):
|
|
59
|
+
return await vector_db.search(query)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Set Custom Attributes
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from zespan_fastapi import set_attribute
|
|
66
|
+
|
|
67
|
+
@app.post("/chat")
|
|
68
|
+
async def chat(request: ChatRequest):
|
|
69
|
+
set_attribute("user.id", request.user_id)
|
|
70
|
+
set_attribute("model", request.model)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
| Option | Default | Description |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `api_key` | required | Zespan API key (`zsp_...`) |
|
|
78
|
+
| `base_url` | `https://api.zespan.com` | API endpoint |
|
|
79
|
+
| `enabled` | `True` | Toggle tracing on/off |
|
|
80
|
+
| `sample_rate` | `1.0` | Fraction of requests to trace (0.0–1.0) |
|
|
81
|
+
| `capture_request_body` | `False` | Include request body in traces |
|
|
82
|
+
| `capture_response_body` | `False` | Include response body in traces |
|
|
83
|
+
| `redact_fields` | `["password","token","api_key"]` | Fields redacted automatically |
|
|
84
|
+
| `debug` | `False` | Print flush errors to stdout |
|
|
85
|
+
|
|
86
|
+
Get your API key at [zespan.com](https://zespan.com).
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
zespan_fastapi/__init__.py,sha256=72wOuaVVOoPFli1NOSF2R_c_s_rG6g5_gLG2AB7fs4c,9410
|
|
2
|
+
zespan_fastapi-1.0.0.dist-info/METADATA,sha256=GD_m539iqhR-dyNMiidcuJLYRZcNw_HvM--kIonoSWI,2303
|
|
3
|
+
zespan_fastapi-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
4
|
+
zespan_fastapi-1.0.0.dist-info/RECORD,,
|