fastapi-radar 0.1.6__py3-none-any.whl → 0.3.1__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.
fastapi_radar/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """FastAPI Radar - Debugging dashboard for FastAPI applications."""
2
2
 
3
3
  from .radar import Radar
4
+ from .background import track_background_task
4
5
 
5
- __version__ = "0.1.6"
6
- __all__ = ["Radar"]
6
+ __version__ = "0.3.1"
7
+ __all__ = ["Radar", "track_background_task"]
fastapi_radar/api.py CHANGED
@@ -1,13 +1,24 @@
1
1
  """API endpoints for FastAPI Radar dashboard."""
2
2
 
3
- from typing import Optional, List, Dict, Any
4
- from datetime import datetime, timedelta
5
- from fastapi import APIRouter, Query, Depends, HTTPException
6
- from sqlalchemy.orm import Session
7
- from sqlalchemy import desc
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import Any, Dict, List, Optional, Union
5
+ import uuid
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query
8
8
  from pydantic import BaseModel
9
+ from sqlalchemy import case, desc, func
10
+ from sqlalchemy.orm import Session
11
+ import httpx
9
12
 
10
- from .models import CapturedRequest, CapturedQuery, CapturedException
13
+ from .models import (
14
+ CapturedRequest,
15
+ CapturedQuery,
16
+ CapturedException,
17
+ Trace,
18
+ Span,
19
+ BackgroundTask,
20
+ )
21
+ from .tracing import TracingManager
11
22
 
12
23
 
13
24
  def round_float(value: Optional[float], decimals: int = 2) -> Optional[float]:
@@ -52,7 +63,7 @@ class QueryDetail(BaseModel):
52
63
  id: int
53
64
  request_id: str
54
65
  sql: str
55
- parameters: Optional[Dict[str, Any]]
66
+ parameters: Union[Dict[str, str], List[str], None]
56
67
  duration_ms: Optional[float]
57
68
  rows_affected: Optional[int]
58
69
  connection_name: Optional[str]
@@ -78,6 +89,59 @@ class DashboardStats(BaseModel):
78
89
  requests_per_minute: float
79
90
 
80
91
 
92
+ class TraceSummary(BaseModel):
93
+ trace_id: str
94
+ service_name: Optional[str]
95
+ operation_name: Optional[str]
96
+ start_time: datetime
97
+ end_time: Optional[datetime]
98
+ duration_ms: Optional[float]
99
+ span_count: int
100
+ status: str
101
+ created_at: datetime
102
+
103
+
104
+ class BackgroundTaskSummary(BaseModel):
105
+ id: int
106
+ task_id: str
107
+ request_id: Optional[str]
108
+ name: str
109
+ status: str
110
+ start_time: Optional[datetime]
111
+ end_time: Optional[datetime]
112
+ duration_ms: Optional[float]
113
+ error: Optional[str]
114
+ created_at: datetime
115
+
116
+
117
+ class WaterfallSpan(BaseModel):
118
+ span_id: str
119
+ parent_span_id: Optional[str]
120
+ operation_name: str
121
+ service_name: Optional[str]
122
+ start_time: Optional[str] # ISO 8601 string
123
+ end_time: Optional[str] # ISO 8601 string
124
+ duration_ms: Optional[float]
125
+ status: str
126
+ tags: Optional[Dict[str, Any]]
127
+ depth: int
128
+ offset_ms: float # Offset from trace start in ms
129
+
130
+
131
+ class TraceDetail(BaseModel):
132
+ trace_id: str
133
+ service_name: Optional[str]
134
+ operation_name: Optional[str]
135
+ start_time: datetime
136
+ end_time: Optional[datetime]
137
+ duration_ms: Optional[float]
138
+ span_count: int
139
+ status: str
140
+ tags: Optional[Dict[str, Any]]
141
+ created_at: datetime
142
+ spans: List[WaterfallSpan]
143
+
144
+
81
145
  def create_api_router(get_session_context) -> APIRouter:
82
146
  router = APIRouter(prefix="/__radar/api", tags=["radar"])
83
147
 
@@ -93,12 +157,17 @@ def create_api_router(get_session_context) -> APIRouter:
93
157
  status_code: Optional[int] = None,
94
158
  method: Optional[str] = None,
95
159
  search: Optional[str] = None,
160
+ start_time: Optional[datetime] = None,
161
+ end_time: Optional[datetime] = None,
96
162
  session: Session = Depends(get_db),
97
163
  ):
98
164
  query = session.query(CapturedRequest)
99
165
 
166
+ if start_time:
167
+ query = query.filter(CapturedRequest.created_at >= start_time)
168
+ if end_time:
169
+ query = query.filter(CapturedRequest.created_at <= end_time)
100
170
  if status_code:
101
- # Handle status code ranges (e.g., 200 for 2xx, 400 for 4xx)
102
171
  if status_code in [200, 300, 400, 500]:
103
172
  # Filter by status code range
104
173
  lower_bound = status_code
@@ -187,6 +256,124 @@ def create_api_router(get_session_context) -> APIRouter:
187
256
  ],
188
257
  )
189
258
 
259
+ @router.get("/requests/{request_id}/curl")
260
+ async def get_request_as_curl(request_id: str, session: Session = Depends(get_db)):
261
+ request = (
262
+ session.query(CapturedRequest)
263
+ .filter(CapturedRequest.request_id == request_id)
264
+ .first()
265
+ )
266
+
267
+ if not request:
268
+ raise HTTPException(status_code=404, detail="Request not found")
269
+
270
+ # Build cURL command
271
+ parts = [f"curl -X {request.method}"]
272
+
273
+ # Add headers
274
+ if request.headers:
275
+ for key, value in request.headers.items():
276
+ if key.lower() not in ["host", "content-length"]:
277
+ parts.append(f"-H '{key}: {value}'")
278
+
279
+ # Add body
280
+ if request.body:
281
+ parts.append(f"-d '{request.body}'")
282
+
283
+ # Add URL (use full URL if available, otherwise construct from path)
284
+ url = request.url if request.url else request.path
285
+ parts.append(f"'{url}'")
286
+
287
+ return {"curl": " ".join(parts)}
288
+
289
+ @router.post("/requests/{request_id}/replay")
290
+ async def replay_request(
291
+ request_id: str,
292
+ body: Optional[Dict[str, Any]] = None,
293
+ session: Session = Depends(get_db),
294
+ ):
295
+ """Replay a captured request with optional body override.
296
+
297
+ WARNING: This endpoint replays HTTP requests. Use with caution in production.
298
+ Consider adding authentication and rate limiting.
299
+ """
300
+ request = (
301
+ session.query(CapturedRequest)
302
+ .filter(CapturedRequest.request_id == request_id)
303
+ .first()
304
+ )
305
+
306
+ if not request:
307
+ raise HTTPException(status_code=404, detail="Request not found")
308
+
309
+ # Security: Validate URL to prevent SSRF attacks
310
+ # Note: This is basic protection. For production, consider:
311
+ # 1. Whitelist allowed domains
312
+ # 2. Add authentication to this endpoint
313
+ # 3. Add rate limiting
314
+ # For dev/testing, allow localhost. For production, consider blocking.
315
+ # Example: Uncomment below to block all internal IPs:
316
+ # from urllib.parse import urlparse
317
+ # parsed = urlparse(request.url)
318
+ # if parsed.hostname in ["localhost", "127.0.0.1", "0.0.0.0", "::1", "::ffff:127.0.0.1"]:
319
+ # raise HTTPException(status_code=403, detail="Replay to localhost is disabled")
320
+
321
+ # Build replay request
322
+ headers = dict(request.headers) if request.headers else {}
323
+ # Remove hop-by-hop headers
324
+ headers.pop("host", None)
325
+ headers.pop("content-length", None)
326
+ headers.pop("connection", None)
327
+ headers.pop("keep-alive", None)
328
+ headers.pop("transfer-encoding", None)
329
+
330
+ request_body = body if body is not None else request.body
331
+
332
+ try:
333
+ async with httpx.AsyncClient(
334
+ timeout=30.0, follow_redirects=False
335
+ ) as client:
336
+ response = await client.request(
337
+ method=request.method,
338
+ url=request.url,
339
+ headers=headers,
340
+ content=(
341
+ request_body if isinstance(request_body, (str, bytes)) else None
342
+ ),
343
+ json=request_body if isinstance(request_body, dict) else None,
344
+ )
345
+
346
+ # Store the replayed request
347
+ replayed_request = CapturedRequest(
348
+ request_id=str(uuid.uuid4()),
349
+ method=request.method,
350
+ url=request.url,
351
+ path=request.path,
352
+ query_params=request.query_params,
353
+ headers=dict(response.request.headers),
354
+ body=request_body if isinstance(request_body, str) else None,
355
+ status_code=response.status_code,
356
+ response_body=response.text[:10000] if response.text else None,
357
+ response_headers=dict(response.headers),
358
+ duration_ms=response.elapsed.total_seconds() * 1000,
359
+ client_ip="replay",
360
+ )
361
+ session.add(replayed_request)
362
+ session.commit()
363
+ session.refresh(replayed_request)
364
+
365
+ return {
366
+ "status_code": response.status_code,
367
+ "headers": dict(response.headers),
368
+ "body": response.text,
369
+ "elapsed_ms": response.elapsed.total_seconds() * 1000,
370
+ "original_status": request.status_code,
371
+ "original_duration_ms": request.duration_ms,
372
+ "new_request_id": replayed_request.request_id,
373
+ }
374
+ except httpx.RequestError as e:
375
+ raise HTTPException(status_code=500, detail=f"Replay failed: {str(e)}")
376
+
190
377
  @router.get("/queries", response_model=List[QueryDetail])
191
378
  async def get_queries(
192
379
  limit: int = Query(100, ge=1, le=1000),
@@ -261,45 +448,43 @@ def create_api_router(get_session_context) -> APIRouter:
261
448
  slow_threshold: int = Query(100),
262
449
  session: Session = Depends(get_db),
263
450
  ):
264
- since = datetime.utcnow() - timedelta(hours=hours)
451
+ since = datetime.now(timezone.utc) - timedelta(hours=hours)
265
452
 
266
453
  requests = (
267
- session.query(CapturedRequest)
454
+ session.query(
455
+ func.count().label("total_requests"),
456
+ func.avg(CapturedRequest.duration_ms).label("avg_response_time"),
457
+ )
268
458
  .filter(CapturedRequest.created_at >= since)
269
- .all()
459
+ .one()
270
460
  )
271
461
 
272
462
  queries = (
273
- session.query(CapturedQuery).filter(CapturedQuery.created_at >= since).all()
463
+ session.query(
464
+ func.count().label("total_queries"),
465
+ func.avg(CapturedQuery.duration_ms).label("avg_query_time"),
466
+ func.sum(
467
+ case((CapturedQuery.duration_ms >= slow_threshold, 1), else_=0)
468
+ ).label("slow_queries"),
469
+ )
470
+ .filter(CapturedQuery.created_at >= since)
471
+ .one()
274
472
  )
275
473
 
276
474
  exceptions = (
277
- session.query(CapturedException)
475
+ session.query(func.count().label("total_exceptions"))
278
476
  .filter(CapturedException.created_at >= since)
279
- .all()
477
+ .one()
280
478
  )
281
479
 
282
- total_requests = len(requests)
283
- avg_response_time = None
284
- if requests:
285
- valid_times = [r.duration_ms for r in requests if r.duration_ms is not None]
286
- if valid_times:
287
- avg_response_time = sum(valid_times) / len(valid_times)
288
-
289
- total_queries = len(queries)
290
- avg_query_time = None
291
- slow_queries = 0
292
- if queries:
293
- valid_times = [q.duration_ms for q in queries if q.duration_ms is not None]
294
- if valid_times:
295
- avg_query_time = sum(valid_times) / len(valid_times)
296
- slow_queries = len(
297
- [
298
- q
299
- for q in queries
300
- if q.duration_ms and q.duration_ms >= slow_threshold
301
- ]
302
- )
480
+ total_requests = requests.total_requests
481
+ avg_response_time = requests.avg_response_time
482
+
483
+ total_queries = queries.total_queries
484
+ avg_query_time = queries.avg_query_time
485
+ slow_queries = queries.slow_queries or 0
486
+
487
+ total_exceptions = exceptions.total_exceptions
303
488
 
304
489
  requests_per_minute = total_requests / (hours * 60)
305
490
 
@@ -308,7 +493,7 @@ def create_api_router(get_session_context) -> APIRouter:
308
493
  avg_response_time=round_float(avg_response_time),
309
494
  total_queries=total_queries,
310
495
  avg_query_time=round_float(avg_query_time),
311
- total_exceptions=len(exceptions),
496
+ total_exceptions=total_exceptions,
312
497
  slow_queries=slow_queries,
313
498
  requests_per_minute=round_float(requests_per_minute),
314
499
  )
@@ -318,7 +503,7 @@ def create_api_router(get_session_context) -> APIRouter:
318
503
  older_than_hours: Optional[int] = None, session: Session = Depends(get_db)
319
504
  ):
320
505
  if older_than_hours:
321
- cutoff = datetime.utcnow() - timedelta(hours=older_than_hours)
506
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=older_than_hours)
322
507
  session.query(CapturedRequest).filter(
323
508
  CapturedRequest.created_at < cutoff
324
509
  ).delete()
@@ -328,4 +513,165 @@ def create_api_router(get_session_context) -> APIRouter:
328
513
  session.commit()
329
514
  return {"message": "Data cleared successfully"}
330
515
 
516
+ # Tracing-related API endpoints
517
+
518
+ @router.get("/traces", response_model=List[TraceSummary])
519
+ async def get_traces(
520
+ limit: int = Query(100, ge=1, le=1000),
521
+ offset: int = Query(0, ge=0),
522
+ status: Optional[str] = Query(None),
523
+ service_name: Optional[str] = Query(None),
524
+ min_duration_ms: Optional[float] = Query(None),
525
+ hours: int = Query(24, ge=1, le=720),
526
+ session: Session = Depends(get_db),
527
+ ):
528
+ """List traces."""
529
+ since = datetime.now(timezone.utc) - timedelta(hours=hours)
530
+ query = session.query(Trace).filter(Trace.created_at >= since)
531
+
532
+ if status:
533
+ query = query.filter(Trace.status == status)
534
+ if service_name:
535
+ query = query.filter(Trace.service_name == service_name)
536
+ if min_duration_ms:
537
+ query = query.filter(Trace.duration_ms >= min_duration_ms)
538
+
539
+ traces = (
540
+ query.order_by(desc(Trace.start_time)).offset(offset).limit(limit).all()
541
+ )
542
+
543
+ return [
544
+ TraceSummary(
545
+ trace_id=t.trace_id,
546
+ service_name=t.service_name,
547
+ operation_name=t.operation_name,
548
+ start_time=t.start_time,
549
+ end_time=t.end_time,
550
+ duration_ms=round_float(t.duration_ms),
551
+ span_count=t.span_count,
552
+ status=t.status,
553
+ created_at=t.created_at,
554
+ )
555
+ for t in traces
556
+ ]
557
+
558
+ @router.get("/traces/{trace_id}", response_model=TraceDetail)
559
+ async def get_trace_detail(
560
+ trace_id: str,
561
+ session: Session = Depends(get_db),
562
+ ):
563
+ """Get trace details."""
564
+ trace = session.query(Trace).filter(Trace.trace_id == trace_id).first()
565
+ if not trace:
566
+ raise HTTPException(status_code=404, detail="Trace not found")
567
+
568
+ # Fetch waterfall data
569
+ tracing_manager = TracingManager(lambda: get_session_context())
570
+ waterfall_spans = tracing_manager.get_waterfall_data(trace_id)
571
+
572
+ return TraceDetail(
573
+ trace_id=trace.trace_id,
574
+ service_name=trace.service_name,
575
+ operation_name=trace.operation_name,
576
+ start_time=trace.start_time,
577
+ end_time=trace.end_time,
578
+ duration_ms=round_float(trace.duration_ms),
579
+ span_count=trace.span_count,
580
+ status=trace.status,
581
+ tags=trace.tags,
582
+ created_at=trace.created_at,
583
+ spans=[WaterfallSpan(**span) for span in waterfall_spans],
584
+ )
585
+
586
+ @router.get("/traces/{trace_id}/waterfall")
587
+ async def get_trace_waterfall(
588
+ trace_id: str,
589
+ session: Session = Depends(get_db),
590
+ ):
591
+ """Get optimized waterfall data for a trace."""
592
+ # Ensure the trace exists
593
+ trace = session.query(Trace).filter(Trace.trace_id == trace_id).first()
594
+ if not trace:
595
+ raise HTTPException(status_code=404, detail="Trace not found")
596
+
597
+ tracing_manager = TracingManager(lambda: get_session_context())
598
+ waterfall_data = tracing_manager.get_waterfall_data(trace_id)
599
+
600
+ return {
601
+ "trace_id": trace_id,
602
+ "spans": waterfall_data,
603
+ "trace_info": {
604
+ "service_name": trace.service_name,
605
+ "operation_name": trace.operation_name,
606
+ "total_duration_ms": trace.duration_ms,
607
+ "span_count": trace.span_count,
608
+ "status": trace.status,
609
+ },
610
+ }
611
+
612
+ @router.get("/spans/{span_id}")
613
+ async def get_span_detail(
614
+ span_id: str,
615
+ session: Session = Depends(get_db),
616
+ ):
617
+ """Get span details."""
618
+ span = session.query(Span).filter(Span.span_id == span_id).first()
619
+ if not span:
620
+ raise HTTPException(status_code=404, detail="Span not found")
621
+
622
+ return {
623
+ "span_id": span.span_id,
624
+ "trace_id": span.trace_id,
625
+ "parent_span_id": span.parent_span_id,
626
+ "operation_name": span.operation_name,
627
+ "service_name": span.service_name,
628
+ "span_kind": span.span_kind,
629
+ "start_time": span.start_time.isoformat() if span.start_time else None,
630
+ "end_time": span.end_time.isoformat() if span.end_time else None,
631
+ "duration_ms": span.duration_ms,
632
+ "status": span.status,
633
+ "tags": span.tags,
634
+ "logs": span.logs,
635
+ "created_at": span.created_at.isoformat(),
636
+ }
637
+
638
+ @router.get("/background-tasks", response_model=List[BackgroundTaskSummary])
639
+ async def get_background_tasks(
640
+ limit: int = Query(100, ge=1, le=1000),
641
+ offset: int = Query(0, ge=0),
642
+ status: Optional[str] = None,
643
+ request_id: Optional[str] = None,
644
+ session: Session = Depends(get_db),
645
+ ):
646
+ """Get background tasks with optional filters."""
647
+ query = session.query(BackgroundTask)
648
+
649
+ if status:
650
+ query = query.filter(BackgroundTask.status == status)
651
+ if request_id:
652
+ query = query.filter(BackgroundTask.request_id == request_id)
653
+
654
+ tasks = (
655
+ query.order_by(desc(BackgroundTask.created_at))
656
+ .offset(offset)
657
+ .limit(limit)
658
+ .all()
659
+ )
660
+
661
+ return [
662
+ BackgroundTaskSummary(
663
+ id=task.id,
664
+ task_id=task.task_id,
665
+ request_id=task.request_id,
666
+ name=task.name,
667
+ status=task.status,
668
+ start_time=task.start_time,
669
+ end_time=task.end_time,
670
+ duration_ms=round_float(task.duration_ms),
671
+ error=task.error,
672
+ created_at=task.created_at,
673
+ )
674
+ for task in tasks
675
+ ]
676
+
331
677
  return router
@@ -0,0 +1,120 @@
1
+ """Background task monitoring for FastAPI Radar."""
2
+
3
+ import inspect
4
+ import time
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from functools import wraps
8
+ from typing import Callable, Any
9
+
10
+ from .models import BackgroundTask
11
+
12
+
13
+ def track_background_task(get_session: Callable):
14
+ """Decorator to track background tasks.
15
+
16
+ Can optionally accept request_id as kwarg:
17
+ background_tasks.add_task(my_task, arg1, request_id="abc123")
18
+ """
19
+
20
+ def decorator(func: Callable) -> Callable:
21
+ @wraps(func)
22
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
23
+ task_id = str(uuid.uuid4())
24
+ # Extract request_id from kwargs if provided
25
+ req_id = kwargs.pop("_radar_request_id", None)
26
+ # Clean task name (just function name, not full module path)
27
+ task_name = func.__name__
28
+
29
+ # Create task record
30
+ with get_session() as session:
31
+ task = BackgroundTask(
32
+ task_id=task_id,
33
+ request_id=req_id,
34
+ name=task_name,
35
+ status="running",
36
+ start_time=datetime.now(timezone.utc),
37
+ )
38
+ session.add(task)
39
+ session.commit()
40
+
41
+ start_time = time.time()
42
+ error = None
43
+
44
+ try:
45
+ result = await func(*args, **kwargs)
46
+ status = "completed"
47
+ return result
48
+ except Exception as e:
49
+ status = "failed"
50
+ error = str(e)
51
+ raise
52
+ finally:
53
+ duration_ms = (time.time() - start_time) * 1000
54
+
55
+ with get_session() as session:
56
+ task = (
57
+ session.query(BackgroundTask)
58
+ .filter(BackgroundTask.task_id == task_id)
59
+ .first()
60
+ )
61
+ if task:
62
+ task.status = status
63
+ task.end_time = datetime.now(timezone.utc)
64
+ task.duration_ms = duration_ms
65
+ task.error = error
66
+ session.commit()
67
+
68
+ @wraps(func)
69
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
70
+ task_id = str(uuid.uuid4())
71
+ # Extract request_id from kwargs if provided
72
+ req_id = kwargs.pop("_radar_request_id", None)
73
+ # Clean task name (just function name, not full module path)
74
+ task_name = func.__name__
75
+
76
+ # Create task record
77
+ with get_session() as session:
78
+ task = BackgroundTask(
79
+ task_id=task_id,
80
+ request_id=req_id,
81
+ name=task_name,
82
+ status="running",
83
+ start_time=datetime.now(timezone.utc),
84
+ )
85
+ session.add(task)
86
+ session.commit()
87
+
88
+ start_time = time.time()
89
+ error = None
90
+
91
+ try:
92
+ result = func(*args, **kwargs)
93
+ status = "completed"
94
+ return result
95
+ except Exception as e:
96
+ status = "failed"
97
+ error = str(e)
98
+ raise
99
+ finally:
100
+ duration_ms = (time.time() - start_time) * 1000
101
+
102
+ with get_session() as session:
103
+ task = (
104
+ session.query(BackgroundTask)
105
+ .filter(BackgroundTask.task_id == task_id)
106
+ .first()
107
+ )
108
+ if task:
109
+ task.status = status
110
+ task.end_time = datetime.now(timezone.utc)
111
+ task.duration_ms = duration_ms
112
+ task.error = error
113
+ session.commit()
114
+
115
+ # Return appropriate wrapper based on function type
116
+ if inspect.iscoroutinefunction(func):
117
+ return async_wrapper
118
+ return sync_wrapper
119
+
120
+ return decorator