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 +3 -2
- fastapi_radar/api.py +383 -37
- fastapi_radar/background.py +120 -0
- fastapi_radar/capture.py +101 -16
- fastapi_radar/dashboard/dist/assets/index-8Om0PGu6.js +326 -0
- fastapi_radar/dashboard/dist/assets/index-D51YrvFG.css +1 -0
- fastapi_radar/dashboard/dist/assets/index-p3czTzXB.js +361 -0
- fastapi_radar/dashboard/dist/index.html +2 -2
- fastapi_radar/dashboard/node_modules/flatted/python/flatted.py +149 -0
- fastapi_radar/middleware.py +115 -17
- fastapi_radar/models.py +143 -19
- fastapi_radar/radar.py +138 -44
- fastapi_radar/tracing.py +258 -0
- fastapi_radar/utils.py +26 -1
- {fastapi_radar-0.1.6.dist-info → fastapi_radar-0.3.1.dist-info}/METADATA +58 -15
- fastapi_radar-0.3.1.dist-info/RECORD +19 -0
- {fastapi_radar-0.1.6.dist-info → fastapi_radar-0.3.1.dist-info}/top_level.txt +0 -1
- fastapi_radar/dashboard/dist/assets/index-BJa0l2JD.js +0 -313
- fastapi_radar/dashboard/dist/assets/index-DCxkDBhr.css +0 -1
- fastapi_radar-0.1.6.dist-info/RECORD +0 -17
- tests/__init__.py +0 -1
- tests/test_radar.py +0 -65
- {fastapi_radar-0.1.6.dist-info → fastapi_radar-0.3.1.dist-info}/WHEEL +0 -0
- {fastapi_radar-0.1.6.dist-info → fastapi_radar-0.3.1.dist-info}/licenses/LICENSE +0 -0
fastapi_radar/__init__.py
CHANGED
fastapi_radar/api.py
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
"""API endpoints for FastAPI Radar dashboard."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
from
|
|
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
|
|
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:
|
|
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.
|
|
451
|
+
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
265
452
|
|
|
266
453
|
requests = (
|
|
267
|
-
session.query(
|
|
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
|
-
.
|
|
459
|
+
.one()
|
|
270
460
|
)
|
|
271
461
|
|
|
272
462
|
queries = (
|
|
273
|
-
session.query(
|
|
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(
|
|
475
|
+
session.query(func.count().label("total_exceptions"))
|
|
278
476
|
.filter(CapturedException.created_at >= since)
|
|
279
|
-
.
|
|
477
|
+
.one()
|
|
280
478
|
)
|
|
281
479
|
|
|
282
|
-
total_requests =
|
|
283
|
-
avg_response_time =
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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=
|
|
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.
|
|
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
|