fastapi-radar 0.1.6__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.

fastapi_radar/__init__.py CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  from .radar import Radar
4
4
 
5
- __version__ = "0.1.6"
5
+ __version__ = "0.1.7"
6
6
  __all__ = ["Radar"]
fastapi_radar/api.py CHANGED
@@ -1,13 +1,15 @@
1
1
  """API endpoints for FastAPI Radar dashboard."""
2
2
 
3
- from typing import Optional, List, Dict, Any
4
3
  from datetime import datetime, timedelta
5
- from fastapi import APIRouter, Query, Depends, HTTPException
6
- from sqlalchemy.orm import Session
7
- from sqlalchemy import desc
4
+ from typing import Any, Dict, List, Optional, Union
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, Query
8
7
  from pydantic import BaseModel
8
+ from sqlalchemy import desc
9
+ from sqlalchemy.orm import Session
9
10
 
10
- from .models import CapturedRequest, CapturedQuery, CapturedException
11
+ from .models import CapturedRequest, CapturedQuery, CapturedException, Trace, Span
12
+ from .tracing import TracingManager
11
13
 
12
14
 
13
15
  def round_float(value: Optional[float], decimals: int = 2) -> Optional[float]:
@@ -52,7 +54,7 @@ class QueryDetail(BaseModel):
52
54
  id: int
53
55
  request_id: str
54
56
  sql: str
55
- parameters: Optional[Dict[str, Any]]
57
+ parameters: Union[Dict[str, str], List[str], None]
56
58
  duration_ms: Optional[float]
57
59
  rows_affected: Optional[int]
58
60
  connection_name: Optional[str]
@@ -78,6 +80,46 @@ class DashboardStats(BaseModel):
78
80
  requests_per_minute: float
79
81
 
80
82
 
83
+ class TraceSummary(BaseModel):
84
+ trace_id: str
85
+ service_name: Optional[str]
86
+ operation_name: Optional[str]
87
+ start_time: datetime
88
+ end_time: Optional[datetime]
89
+ duration_ms: Optional[float]
90
+ span_count: int
91
+ status: str
92
+ created_at: datetime
93
+
94
+
95
+ class WaterfallSpan(BaseModel):
96
+ span_id: str
97
+ parent_span_id: Optional[str]
98
+ operation_name: str
99
+ service_name: Optional[str]
100
+ start_time: Optional[str] # ISO 8601 string
101
+ end_time: Optional[str] # ISO 8601 string
102
+ duration_ms: Optional[float]
103
+ status: str
104
+ tags: Optional[Dict[str, Any]]
105
+ depth: int
106
+ offset_ms: float # Offset from trace start in ms
107
+
108
+
109
+ class TraceDetail(BaseModel):
110
+ trace_id: str
111
+ service_name: Optional[str]
112
+ operation_name: Optional[str]
113
+ start_time: datetime
114
+ end_time: Optional[datetime]
115
+ duration_ms: Optional[float]
116
+ span_count: int
117
+ status: str
118
+ tags: Optional[Dict[str, Any]]
119
+ created_at: datetime
120
+ spans: List[WaterfallSpan]
121
+
122
+
81
123
  def create_api_router(get_session_context) -> APIRouter:
82
124
  router = APIRouter(prefix="/__radar/api", tags=["radar"])
83
125
 
@@ -98,7 +140,6 @@ def create_api_router(get_session_context) -> APIRouter:
98
140
  query = session.query(CapturedRequest)
99
141
 
100
142
  if status_code:
101
- # Handle status code ranges (e.g., 200 for 2xx, 400 for 4xx)
102
143
  if status_code in [200, 300, 400, 500]:
103
144
  # Filter by status code range
104
145
  lower_bound = status_code
@@ -328,4 +369,126 @@ def create_api_router(get_session_context) -> APIRouter:
328
369
  session.commit()
329
370
  return {"message": "Data cleared successfully"}
330
371
 
372
+ # Tracing-related API endpoints
373
+
374
+ @router.get("/traces", response_model=List[TraceSummary])
375
+ async def get_traces(
376
+ limit: int = Query(100, ge=1, le=1000),
377
+ offset: int = Query(0, ge=0),
378
+ status: Optional[str] = Query(None),
379
+ service_name: Optional[str] = Query(None),
380
+ min_duration_ms: Optional[float] = Query(None),
381
+ hours: int = Query(24, ge=1, le=720),
382
+ session: Session = Depends(get_db),
383
+ ):
384
+ """List traces."""
385
+ since = datetime.utcnow() - timedelta(hours=hours)
386
+ query = session.query(Trace).filter(Trace.created_at >= since)
387
+
388
+ if status:
389
+ query = query.filter(Trace.status == status)
390
+ if service_name:
391
+ query = query.filter(Trace.service_name == service_name)
392
+ if min_duration_ms:
393
+ query = query.filter(Trace.duration_ms >= min_duration_ms)
394
+
395
+ traces = (
396
+ query.order_by(desc(Trace.start_time)).offset(offset).limit(limit).all()
397
+ )
398
+
399
+ return [
400
+ TraceSummary(
401
+ trace_id=t.trace_id,
402
+ service_name=t.service_name,
403
+ operation_name=t.operation_name,
404
+ start_time=t.start_time,
405
+ end_time=t.end_time,
406
+ duration_ms=round_float(t.duration_ms),
407
+ span_count=t.span_count,
408
+ status=t.status,
409
+ created_at=t.created_at,
410
+ )
411
+ for t in traces
412
+ ]
413
+
414
+ @router.get("/traces/{trace_id}", response_model=TraceDetail)
415
+ async def get_trace_detail(
416
+ trace_id: str,
417
+ session: Session = Depends(get_db),
418
+ ):
419
+ """Get trace details."""
420
+ trace = session.query(Trace).filter(Trace.trace_id == trace_id).first()
421
+ if not trace:
422
+ raise HTTPException(status_code=404, detail="Trace not found")
423
+
424
+ # Fetch waterfall data
425
+ tracing_manager = TracingManager(lambda: get_session_context())
426
+ waterfall_spans = tracing_manager.get_waterfall_data(trace_id)
427
+
428
+ return TraceDetail(
429
+ trace_id=trace.trace_id,
430
+ service_name=trace.service_name,
431
+ operation_name=trace.operation_name,
432
+ start_time=trace.start_time,
433
+ end_time=trace.end_time,
434
+ duration_ms=round_float(trace.duration_ms),
435
+ span_count=trace.span_count,
436
+ status=trace.status,
437
+ tags=trace.tags,
438
+ created_at=trace.created_at,
439
+ spans=[WaterfallSpan(**span) for span in waterfall_spans],
440
+ )
441
+
442
+ @router.get("/traces/{trace_id}/waterfall")
443
+ async def get_trace_waterfall(
444
+ trace_id: str,
445
+ session: Session = Depends(get_db),
446
+ ):
447
+ """Get optimized waterfall data for a trace."""
448
+ # Ensure the trace exists
449
+ trace = session.query(Trace).filter(Trace.trace_id == trace_id).first()
450
+ if not trace:
451
+ raise HTTPException(status_code=404, detail="Trace not found")
452
+
453
+ tracing_manager = TracingManager(lambda: get_session_context())
454
+ waterfall_data = tracing_manager.get_waterfall_data(trace_id)
455
+
456
+ return {
457
+ "trace_id": trace_id,
458
+ "spans": waterfall_data,
459
+ "trace_info": {
460
+ "service_name": trace.service_name,
461
+ "operation_name": trace.operation_name,
462
+ "total_duration_ms": trace.duration_ms,
463
+ "span_count": trace.span_count,
464
+ "status": trace.status,
465
+ },
466
+ }
467
+
468
+ @router.get("/spans/{span_id}")
469
+ async def get_span_detail(
470
+ span_id: str,
471
+ session: Session = Depends(get_db),
472
+ ):
473
+ """Get span details."""
474
+ span = session.query(Span).filter(Span.span_id == span_id).first()
475
+ if not span:
476
+ raise HTTPException(status_code=404, detail="Span not found")
477
+
478
+ return {
479
+ "span_id": span.span_id,
480
+ "trace_id": span.trace_id,
481
+ "parent_span_id": span.parent_span_id,
482
+ "operation_name": span.operation_name,
483
+ "service_name": span.service_name,
484
+ "span_kind": span.span_kind,
485
+ "start_time": span.start_time.isoformat() if span.start_time else None,
486
+ "end_time": span.end_time.isoformat() if span.end_time else None,
487
+ "duration_ms": span.duration_ms,
488
+ "status": span.status,
489
+ "tags": span.tags,
490
+ "logs": span.logs,
491
+ "created_at": span.created_at.isoformat(),
492
+ }
493
+
331
494
  return router
fastapi_radar/capture.py CHANGED
@@ -1,13 +1,15 @@
1
1
  """SQLAlchemy query capture for FastAPI Radar."""
2
2
 
3
3
  import time
4
- from typing import Any, Optional, Callable
4
+ from typing import Any, Callable, Dict, List, Union
5
+
5
6
  from sqlalchemy import event
6
7
  from sqlalchemy.engine import Engine
7
8
 
8
9
  from .middleware import request_context
9
10
  from .models import CapturedQuery
10
11
  from .utils import format_sql
12
+ from .tracing import get_current_trace_context
11
13
 
12
14
 
13
15
  class QueryCapture:
@@ -23,12 +25,10 @@ class QueryCapture:
23
25
  self._query_start_times = {}
24
26
 
25
27
  def register(self, engine: Engine) -> None:
26
- """Register SQLAlchemy event listeners."""
27
28
  event.listen(engine, "before_cursor_execute", self._before_cursor_execute)
28
29
  event.listen(engine, "after_cursor_execute", self._after_cursor_execute)
29
30
 
30
31
  def unregister(self, engine: Engine) -> None:
31
- """Unregister SQLAlchemy event listeners."""
32
32
  event.remove(engine, "before_cursor_execute", self._before_cursor_execute)
33
33
  event.remove(engine, "after_cursor_execute", self._after_cursor_execute)
34
34
 
@@ -45,7 +45,23 @@ class QueryCapture:
45
45
  if not request_id:
46
46
  return
47
47
 
48
- self._query_start_times[id(context)] = time.time()
48
+ context_id = id(context)
49
+ self._query_start_times[context_id] = time.time()
50
+
51
+ trace_ctx = get_current_trace_context()
52
+ if trace_ctx:
53
+ formatted_sql = format_sql(statement)
54
+ operation_type = self._get_operation_type(statement)
55
+ span_id = trace_ctx.create_span(
56
+ operation_name=f"DB {operation_type}",
57
+ span_kind="client",
58
+ tags={
59
+ "db.statement": formatted_sql[:500], # limit SQL length
60
+ "db.operation_type": operation_type,
61
+ "component": "database",
62
+ },
63
+ )
64
+ setattr(context, "_radar_span_id", span_id)
49
65
 
50
66
  def _after_cursor_execute(
51
67
  self,
@@ -66,7 +82,23 @@ class QueryCapture:
66
82
 
67
83
  duration_ms = round((time.time() - start_time) * 1000, 2)
68
84
 
69
- # Skip radar's own queries
85
+ trace_ctx = get_current_trace_context()
86
+ if trace_ctx and hasattr(context, "_radar_span_id"):
87
+ span_id = getattr(context, "_radar_span_id")
88
+ additional_tags = {
89
+ "db.duration_ms": duration_ms,
90
+ "db.rows_affected": (
91
+ cursor.rowcount if hasattr(cursor, "rowcount") else None
92
+ ),
93
+ }
94
+
95
+ status = "ok"
96
+ if duration_ms >= self.slow_query_threshold:
97
+ status = "slow"
98
+ additional_tags["db.slow_query"] = True
99
+
100
+ trace_ctx.finish_span(span_id, status=status, tags=additional_tags)
101
+
70
102
  if "radar_" in statement:
71
103
  return
72
104
 
@@ -90,15 +122,40 @@ class QueryCapture:
90
122
  session.add(captured_query)
91
123
  session.commit()
92
124
  except Exception:
93
- pass # Silently ignore storage errors
94
-
95
- def _serialize_parameters(self, parameters: Any) -> Optional[list]:
125
+ pass
126
+
127
+ def _get_operation_type(self, statement: str) -> str:
128
+ if not statement:
129
+ return "unknown"
130
+
131
+ statement = statement.strip().upper()
132
+
133
+ if statement.startswith("SELECT"):
134
+ return "SELECT"
135
+ elif statement.startswith("INSERT"):
136
+ return "INSERT"
137
+ elif statement.startswith("UPDATE"):
138
+ return "UPDATE"
139
+ elif statement.startswith("DELETE"):
140
+ return "DELETE"
141
+ elif statement.startswith("CREATE"):
142
+ return "CREATE"
143
+ elif statement.startswith("DROP"):
144
+ return "DROP"
145
+ elif statement.startswith("ALTER"):
146
+ return "ALTER"
147
+ else:
148
+ return "OTHER"
149
+
150
+ def _serialize_parameters(
151
+ self, parameters: Any
152
+ ) -> Union[Dict[str, str], List[str], None]:
96
153
  """Serialize query parameters for storage."""
97
154
  if not parameters:
98
155
  return None
99
156
 
100
157
  if isinstance(parameters, (list, tuple)):
101
- return [str(p) for p in parameters[:100]] # Limit to 100 params
158
+ return [str(p) for p in parameters[:100]]
102
159
  elif isinstance(parameters, dict):
103
160
  return {k: str(v) for k, v in list(parameters.items())[:100]}
104
161