zespan-fastapi 1.0.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.
@@ -0,0 +1,55 @@
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # Dependencies
4
+ node_modules
5
+ .pnp
6
+ .pnp.js
7
+
8
+
9
+
10
+ langfuse-main/
11
+ langwatch-main/
12
+ .claude
13
+ .gemini
14
+
15
+
16
+ # Testing
17
+ coverage
18
+
19
+ # Turbo
20
+ .turbo
21
+
22
+ # Vercel
23
+ .vercel
24
+
25
+ # Wrangler (Cloudflare dev artifacts — not used in VPS deploy)
26
+ .wrangler/
27
+
28
+ # Build Outputs
29
+ .next/
30
+ .open-next/
31
+ out/
32
+ build
33
+ dist
34
+
35
+
36
+ # Debug
37
+ npm-debug.log*
38
+ yarn-debug.log*
39
+ yarn-error.log*
40
+
41
+ # Misc
42
+ .DS_Store
43
+ *.pem
44
+
45
+ .hypothesis
46
+ .kiro
47
+ .vscode
48
+
49
+ lumiqtrace-landing
50
+ # Production secrets — never commit
51
+ ops/cloudflared/creds.json
52
+
53
+ # Added by code-review-graph
54
+ .code-review-graph/
55
+ .gitnexus
@@ -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,73 @@
1
+ # zespan-fastapi
2
+
3
+ Automatic LLM observability middleware for FastAPI. Traces every request, LLM call, and custom span with zero code changes for basic usage.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install zespan-fastapi
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from fastapi import FastAPI
15
+ from zespan_fastapi import ZespanFastAPIMiddleware, ZespanConfig
16
+
17
+ app = FastAPI()
18
+
19
+ config = ZespanConfig(api_key="zsp_your_api_key")
20
+ app.add_middleware(ZespanFastAPIMiddleware, config=config)
21
+ ```
22
+
23
+ Every HTTP request is now automatically traced.
24
+
25
+ ## Trace LLM Calls
26
+
27
+ ```python
28
+ from zespan_fastapi import observe_llm
29
+
30
+ @observe_llm(model="gpt-4o", provider="openai")
31
+ async def generate_response(prompt: str):
32
+ response = await openai.chat.completions.create(
33
+ model="gpt-4o",
34
+ messages=[{"role": "user", "content": prompt}]
35
+ )
36
+ return response
37
+ ```
38
+
39
+ ## Trace Custom Spans
40
+
41
+ ```python
42
+ from zespan_fastapi import observe_span
43
+
44
+ @observe_span("fetch_context", span_type="retrieval")
45
+ async def fetch_context(query: str):
46
+ return await vector_db.search(query)
47
+ ```
48
+
49
+ ## Set Custom Attributes
50
+
51
+ ```python
52
+ from zespan_fastapi import set_attribute
53
+
54
+ @app.post("/chat")
55
+ async def chat(request: ChatRequest):
56
+ set_attribute("user.id", request.user_id)
57
+ set_attribute("model", request.model)
58
+ ```
59
+
60
+ ## Configuration
61
+
62
+ | Option | Default | Description |
63
+ |---|---|---|
64
+ | `api_key` | required | Zespan API key (`zsp_...`) |
65
+ | `base_url` | `https://api.zespan.com` | API endpoint |
66
+ | `enabled` | `True` | Toggle tracing on/off |
67
+ | `sample_rate` | `1.0` | Fraction of requests to trace (0.0–1.0) |
68
+ | `capture_request_body` | `False` | Include request body in traces |
69
+ | `capture_response_body` | `False` | Include response body in traces |
70
+ | `redact_fields` | `["password","token","api_key"]` | Fields redacted automatically |
71
+ | `debug` | `False` | Print flush errors to stdout |
72
+
73
+ Get your API key at [zespan.com](https://zespan.com).
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@zespan/middleware-fastapi",
3
+ "version": "0.1.0",
4
+ "description": "Zespan trace middleware for FastAPI - Automatic LLM observability",
5
+ "main": "src/zespan_fastapi/__init__.py",
6
+ "scripts": {
7
+ "build": "echo 'Python package - no build step required'",
8
+ "dev": "echo 'Python package - no dev server'"
9
+ },
10
+ "keywords": ["zespan", "fastapi", "middleware", "llm", "observability", "tracing"],
11
+ "license": "MIT"
12
+ }
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "zespan-fastapi"
7
+ dynamic = ["version"]
8
+ description = "Zespan observability middleware for FastAPI"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = [
12
+ "fastapi>=0.100",
13
+ "requests>=2.28",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "pytest>=8.0",
19
+ "pytest-asyncio>=0.23",
20
+ "httpx>=0.24",
21
+ ]
22
+
23
+ [tool.hatch.version]
24
+ path = "src/zespan_fastapi/__init__.py"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/zespan_fastapi"]
@@ -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
+ ]