fastapi-radar 0.1.5__py3-none-any.whl → 0.1.7__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.

Potentially problematic release.


This version of fastapi-radar might be problematic. Click here for more details.

@@ -0,0 +1,258 @@
1
+ """Tracing core functionality module."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from typing import Optional, Dict, Any, List
6
+ from contextvars import ContextVar
7
+ from sqlalchemy.orm import Session
8
+
9
+ from .models import Trace, Span, SpanRelation
10
+
11
+ # Trace context for the current request
12
+ trace_context: ContextVar[Optional["TraceContext"]] = ContextVar(
13
+ "trace_context", default=None
14
+ )
15
+
16
+
17
+ class TraceContext:
18
+ """Tracing context that manages trace and span data for a request."""
19
+
20
+ def __init__(self, trace_id: str, service_name: str = "fastapi-app"):
21
+ self.trace_id = trace_id
22
+ self.service_name = service_name
23
+ self.root_span_id: Optional[str] = None
24
+ self.current_span_id: Optional[str] = None
25
+ self.spans: Dict[str, Dict[str, Any]] = {}
26
+ self.start_time = datetime.utcnow()
27
+
28
+ def create_span(
29
+ self,
30
+ operation_name: str,
31
+ parent_span_id: Optional[str] = None,
32
+ span_kind: str = "server",
33
+ tags: Optional[Dict[str, Any]] = None,
34
+ ) -> str:
35
+ """Create a new span."""
36
+ span_id = self._generate_span_id()
37
+
38
+ span_data = {
39
+ "span_id": span_id,
40
+ "trace_id": self.trace_id,
41
+ "parent_span_id": parent_span_id or self.current_span_id,
42
+ "operation_name": operation_name,
43
+ "service_name": self.service_name,
44
+ "span_kind": span_kind,
45
+ "start_time": datetime.utcnow(),
46
+ "tags": tags or {},
47
+ "logs": [],
48
+ "status": "ok",
49
+ }
50
+
51
+ self.spans[span_id] = span_data
52
+
53
+ # Set root span if not already set
54
+ if self.root_span_id is None:
55
+ self.root_span_id = span_id
56
+
57
+ return span_id
58
+
59
+ def finish_span(
60
+ self, span_id: str, status: str = "ok", tags: Optional[Dict[str, Any]] = None
61
+ ):
62
+ """Finish a span."""
63
+ if span_id not in self.spans:
64
+ return
65
+
66
+ span_data = self.spans[span_id]
67
+ span_data["end_time"] = datetime.utcnow()
68
+ span_data["duration_ms"] = (
69
+ span_data["end_time"] - span_data["start_time"]
70
+ ).total_seconds() * 1000
71
+ span_data["status"] = status
72
+
73
+ if tags:
74
+ span_data["tags"].update(tags)
75
+
76
+ def add_span_log(self, span_id: str, message: str, level: str = "info", **fields):
77
+ """Add a log entry to a span."""
78
+ if span_id not in self.spans:
79
+ return
80
+
81
+ log_entry = {
82
+ "timestamp": datetime.utcnow().isoformat(),
83
+ "level": level,
84
+ "message": message,
85
+ **fields,
86
+ }
87
+
88
+ self.spans[span_id]["logs"].append(log_entry)
89
+
90
+ def set_current_span(self, span_id: str):
91
+ """Set the current active span."""
92
+ self.current_span_id = span_id
93
+
94
+ def get_trace_summary(self) -> Dict[str, Any]:
95
+ """Return a trace summary for persistence and display."""
96
+ if not self.spans:
97
+ return {}
98
+
99
+ all_times = []
100
+ error_count = 0
101
+
102
+ for span in self.spans.values():
103
+ if span.get("start_time"):
104
+ all_times.append(span["start_time"])
105
+ if span.get("end_time"):
106
+ all_times.append(span["end_time"])
107
+ if span.get("status") == "error":
108
+ error_count += 1
109
+
110
+ start_time = min(all_times) if all_times else self.start_time
111
+ end_time = max(all_times) if all_times else datetime.utcnow()
112
+
113
+ return {
114
+ "trace_id": self.trace_id,
115
+ "service_name": self.service_name,
116
+ "operation_name": self.spans.get(self.root_span_id, {}).get(
117
+ "operation_name", "unknown"
118
+ ),
119
+ "start_time": start_time,
120
+ "end_time": end_time,
121
+ "duration_ms": (end_time - start_time).total_seconds() * 1000,
122
+ "span_count": len(self.spans),
123
+ "status": "error" if error_count > 0 else "ok",
124
+ "tags": {},
125
+ }
126
+
127
+ @staticmethod
128
+ def _generate_span_id() -> str:
129
+ """Generate a 16-character hexadecimal span ID."""
130
+ return uuid.uuid4().hex[:16]
131
+
132
+
133
+ class TracingManager:
134
+ """Tracing manager responsible for persistence and querying."""
135
+
136
+ def __init__(self, get_session):
137
+ self.get_session = get_session
138
+
139
+ def save_trace_context(self, trace_ctx: TraceContext):
140
+ """Persist the trace context into the database."""
141
+ with self.get_session() as session:
142
+ # Save trace
143
+ trace_summary = trace_ctx.get_trace_summary()
144
+ trace = Trace(**trace_summary)
145
+ session.add(trace)
146
+
147
+ # Save spans
148
+ for span_data in trace_ctx.spans.values():
149
+ span = Span(**span_data)
150
+ session.add(span)
151
+
152
+ self._save_span_relations(session, trace_ctx)
153
+
154
+ session.commit()
155
+
156
+ def _save_span_relations(self, session: Session, trace_ctx: TraceContext):
157
+ """Store parent-child span relations for optimized querying."""
158
+
159
+ def calculate_depth(
160
+ span_id: str, spans: Dict[str, Dict], depth: int = 0
161
+ ) -> List[tuple]:
162
+ """Recursively compute span depth."""
163
+ relations = []
164
+ span = spans.get(span_id)
165
+ if not span:
166
+ return relations
167
+
168
+ # Find all child spans
169
+ for sid, s in spans.items():
170
+ if s.get("parent_span_id") == span_id:
171
+ relations.append((span_id, sid, depth + 1))
172
+ relations.extend(calculate_depth(sid, spans, depth + 1))
173
+
174
+ return relations
175
+
176
+ # Start from the root span
177
+ if trace_ctx.root_span_id:
178
+ relations = calculate_depth(trace_ctx.root_span_id, trace_ctx.spans)
179
+
180
+ for parent_id, child_id, depth in relations:
181
+ relation = SpanRelation(
182
+ trace_id=trace_ctx.trace_id,
183
+ parent_span_id=parent_id,
184
+ child_span_id=child_id,
185
+ depth=depth,
186
+ )
187
+ session.add(relation)
188
+
189
+ def get_waterfall_data(self, trace_id: str) -> List[Dict[str, Any]]:
190
+ """Return data for the waterfall view."""
191
+ with self.get_session() as session:
192
+ # Query optimized for DuckDB
193
+ from sqlalchemy import text
194
+
195
+ waterfall_query = text(
196
+ """
197
+ WITH span_timeline AS (
198
+ SELECT
199
+ s.span_id,
200
+ s.parent_span_id,
201
+ s.operation_name,
202
+ s.service_name,
203
+ s.start_time,
204
+ s.end_time,
205
+ s.duration_ms,
206
+ s.status,
207
+ s.tags,
208
+ COALESCE(r.depth, 0) as depth,
209
+ -- Offset relative to trace start
210
+ EXTRACT(EPOCH FROM (
211
+ s.start_time - MIN(s.start_time)
212
+ OVER (PARTITION BY s.trace_id)
213
+ )) * 1000 as offset_ms
214
+ FROM radar_spans s
215
+ LEFT JOIN radar_span_relations r ON s.span_id = r.child_span_id
216
+ WHERE s.trace_id = :trace_id
217
+ )
218
+ SELECT * FROM span_timeline
219
+ ORDER BY offset_ms, depth
220
+ """
221
+ )
222
+
223
+ result = session.execute(waterfall_query, {"trace_id": trace_id})
224
+
225
+ return [
226
+ {
227
+ "span_id": row.span_id,
228
+ "parent_span_id": row.parent_span_id,
229
+ "operation_name": row.operation_name,
230
+ "service_name": row.service_name,
231
+ "start_time": (
232
+ row.start_time.isoformat() if row.start_time else None
233
+ ),
234
+ "end_time": row.end_time.isoformat() if row.end_time else None,
235
+ "duration_ms": row.duration_ms,
236
+ "status": row.status,
237
+ "tags": row.tags,
238
+ "depth": row.depth,
239
+ "offset_ms": float(row.offset_ms) if row.offset_ms else 0.0,
240
+ }
241
+ for row in result
242
+ ]
243
+
244
+
245
+ def get_current_trace_context() -> Optional[TraceContext]:
246
+ """Get the current trace context."""
247
+ return trace_context.get()
248
+
249
+
250
+ def set_trace_context(ctx: TraceContext):
251
+ """Set the current trace context."""
252
+ trace_context.set(ctx)
253
+
254
+
255
+ def create_trace_context(service_name: str = "fastapi-app") -> TraceContext:
256
+ """Create a new trace context."""
257
+ trace_id = uuid.uuid4().hex
258
+ return TraceContext(trace_id, service_name)
fastapi_radar/utils.py CHANGED
@@ -1,8 +1,9 @@
1
1
  """Utility functions for FastAPI Radar."""
2
2
 
3
3
  from typing import Dict, Optional
4
- from starlette.requests import Request
4
+
5
5
  from starlette.datastructures import Headers
6
+ from starlette.requests import Request
6
7
 
7
8
 
8
9
  def serialize_headers(headers: Headers) -> Dict[str, str]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-radar
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: A debugging dashboard for FastAPI applications with real-time monitoring
5
5
  Home-page: https://github.com/doganarif/fastapi-radar
6
6
  Author: Arif Dogan
@@ -13,7 +13,6 @@ Keywords: fastapi,debugging,monitoring,dashboard,development-tools
13
13
  Classifier: Development Status :: 4 - Beta
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.8
17
16
  Classifier: Programming Language :: Python :: 3.9
18
17
  Classifier: Programming Language :: Python :: 3.10
19
18
  Classifier: Programming Language :: Python :: 3.11
@@ -24,19 +23,21 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
23
  Requires-Python: >=3.8
25
24
  Description-Content-Type: text/markdown
26
25
  License-File: LICENSE
27
- Requires-Dist: fastapi>=0.68.0
28
- Requires-Dist: sqlalchemy>=1.4.0
29
- Requires-Dist: pydantic>=1.8.0
30
- Requires-Dist: starlette>=0.14.2
26
+ Requires-Dist: fastapi
27
+ Requires-Dist: sqlalchemy>=2.0
28
+ Requires-Dist: pydantic
29
+ Requires-Dist: starlette
30
+ Requires-Dist: duckdb==1.1.3
31
+ Requires-Dist: duckdb-engine==0.17.0
31
32
  Provides-Extra: dev
32
- Requires-Dist: pytest>=7.0.0; extra == "dev"
33
- Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
34
- Requires-Dist: uvicorn[standard]>=0.15.0; extra == "dev"
35
- Requires-Dist: black>=22.0.0; extra == "dev"
36
- Requires-Dist: isort>=5.10.0; extra == "dev"
37
- Requires-Dist: flake8>=4.0.0; extra == "dev"
38
- Requires-Dist: mypy>=0.950; extra == "dev"
39
- Requires-Dist: httpx>=0.28.1; extra == "dev"
33
+ Requires-Dist: pytest; extra == "dev"
34
+ Requires-Dist: pytest-asyncio; extra == "dev"
35
+ Requires-Dist: uvicorn[standard]; extra == "dev"
36
+ Requires-Dist: black; extra == "dev"
37
+ Requires-Dist: isort; extra == "dev"
38
+ Requires-Dist: flake8; extra == "dev"
39
+ Requires-Dist: mypy; extra == "dev"
40
+ Requires-Dist: httpx; extra == "dev"
40
41
  Dynamic: author
41
42
  Dynamic: home-page
42
43
  Dynamic: license-file
@@ -0,0 +1,18 @@
1
+ fastapi_radar/__init__.py,sha256=RsHUtOtkoaufEyEeY9NyVFNLVyPhUppP2RR6iz9mTyY,137
2
+ fastapi_radar/api.py,sha256=JYXYocgWdmjHbrfthpBuqXmIWH8ZNct4qA3WCbinL2o,16099
3
+ fastapi_radar/capture.py,sha256=butzGPmjR8f3gdrNqh4iOlXD2WBD8Zh6CcSTDlnYR5o,5186
4
+ fastapi_radar/middleware.py,sha256=v36cdUWRozkFs1MxG8piRzuazQZG5CHRVp_e7AWobbk,7684
5
+ fastapi_radar/models.py,sha256=jsYakaDd3HRDdAI7SfXLL5fI_fAfVBBhcYq8ktDPfmk,4865
6
+ fastapi_radar/radar.py,sha256=8bVoEY9zekgp4bU50fAftgFYc4CP_3t42J8dS4zduvQ,11218
7
+ fastapi_radar/tracing.py,sha256=m0AEX4sa-VUu5STezhpa51BZQ7R8Ax9u2zry2JSF3hQ,8743
8
+ fastapi_radar/utils.py,sha256=82cNXjbtm7oTaNRwrSy2fPWDVGi8XSk56C_8o7N7oRQ,1516
9
+ fastapi_radar/dashboard/dist/index.html,sha256=-Dx0Jw0WDyD29adrACqarkIq_XlUDo7BzqHmt3_mAkc,436
10
+ fastapi_radar/dashboard/dist/assets/index-By5DXl8Z.js,sha256=D4jEA0Ep4L7PXXo8rZ_saAQIWhl43aAfIdjI6b4eotQ,833887
11
+ fastapi_radar/dashboard/dist/assets/index-XlGcZj49.css,sha256=z2yLr4jYQoOM7KQ2aw5zrPpMlkr9dRvwBSGaxjzZNnc,35552
12
+ fastapi_radar-0.1.7.dist-info/licenses/LICENSE,sha256=0ga4BB6q-nqx6xlDRhtrgKrYs0HgX02PQyIzNFRK09Q,1067
13
+ tests/__init__.py,sha256=kAWaI50iJRZ4JlAdyt7FICgm8MsloZz0ZlsmhgLXBas,31
14
+ tests/test_radar.py,sha256=3F-_zdemPcgQnjP4kzCa7GhMxNJNYU0SgSWprctyXiA,2374
15
+ fastapi_radar-0.1.7.dist-info/METADATA,sha256=fdCi6WDOrkSyPoxQmH0OgWdFdDNjUUJ_q0E7hb7GLLc,5884
16
+ fastapi_radar-0.1.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
17
+ fastapi_radar-0.1.7.dist-info/top_level.txt,sha256=M-bALM-KDkiLcATq2aAx-BnG59Nv-GdFBzuzkUhiCa0,20
18
+ fastapi_radar-0.1.7.dist-info/RECORD,,
tests/test_radar.py CHANGED
@@ -10,9 +10,12 @@ from fastapi_radar import Radar
10
10
  def test_radar_initialization():
11
11
  """Test that Radar can be initialized with a FastAPI app."""
12
12
  app = FastAPI()
13
+ # Use in-memory SQLite for test database (not for storage)
13
14
  engine = create_engine("sqlite:///:memory:")
15
+ # Use in-memory SQLite for storage as well to avoid DuckDB requirement in tests
16
+ storage_engine = create_engine("sqlite:///:memory:")
14
17
 
15
- radar = Radar(app, db_engine=engine)
18
+ radar = Radar(app, db_engine=engine, storage_engine=storage_engine)
16
19
  assert radar is not None
17
20
  assert radar.app == app
18
21
  assert radar.db_engine == engine
@@ -22,8 +25,9 @@ def test_radar_creates_tables():
22
25
  """Test that Radar can create necessary database tables."""
23
26
  app = FastAPI()
24
27
  engine = create_engine("sqlite:///:memory:")
28
+ storage_engine = create_engine("sqlite:///:memory:")
25
29
 
26
- radar = Radar(app, db_engine=engine)
30
+ radar = Radar(app, db_engine=engine, storage_engine=storage_engine)
27
31
  radar.create_tables()
28
32
 
29
33
  # Tables should be created without errors
@@ -34,8 +38,9 @@ def test_dashboard_mounted():
34
38
  """Test that the dashboard is mounted at the correct path."""
35
39
  app = FastAPI()
36
40
  engine = create_engine("sqlite:///:memory:")
41
+ storage_engine = create_engine("sqlite:///:memory:")
37
42
 
38
- radar = Radar(app, db_engine=engine)
43
+ radar = Radar(app, db_engine=engine, storage_engine=storage_engine)
39
44
  radar.create_tables()
40
45
 
41
46
  client = TestClient(app)
@@ -50,16 +55,21 @@ def test_middleware_captures_requests():
50
55
  """Test that middleware captures HTTP requests."""
51
56
  app = FastAPI()
52
57
  engine = create_engine("sqlite:///:memory:")
58
+ # Use a file-based SQLite for storage to persist tables
59
+ import tempfile
53
60
 
54
- radar = Radar(app, db_engine=engine)
55
- radar.create_tables()
61
+ with tempfile.NamedTemporaryFile(suffix=".db") as temp_db:
62
+ storage_engine = create_engine(f"sqlite:///{temp_db.name}")
56
63
 
57
- @app.get("/test")
58
- async def test_endpoint():
59
- return {"message": "test"}
64
+ radar = Radar(app, db_engine=engine, storage_engine=storage_engine)
65
+ radar.create_tables()
60
66
 
61
- client = TestClient(app)
62
- response = client.get("/test")
67
+ @app.get("/test")
68
+ async def test_endpoint():
69
+ return {"message": "test"}
70
+
71
+ client = TestClient(app)
72
+ response = client.get("/test")
63
73
 
64
- assert response.status_code == 200
65
- assert response.json() == {"message": "test"}
74
+ assert response.status_code == 200
75
+ assert response.json() == {"message": "test"}