fastapi-radar 0.1.6__tar.gz → 0.1.8__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.
Potentially problematic release.
This version of fastapi-radar might be problematic. Click here for more details.
- {fastapi_radar-0.1.6/fastapi_radar.egg-info → fastapi_radar-0.1.8}/PKG-INFO +36 -14
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/README.md +21 -0
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/fastapi_radar/__init__.py +1 -1
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/fastapi_radar/api.py +170 -7
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/fastapi_radar/capture.py +66 -9
- fastapi_radar-0.1.8/fastapi_radar/dashboard/dist/assets/index-By5DXl8Z.js +318 -0
- fastapi_radar-0.1.8/fastapi_radar/dashboard/dist/assets/index-XlGcZj49.css +1 -0
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/fastapi_radar/dashboard/dist/index.html +2 -2
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/fastapi_radar/middleware.py +96 -15
- fastapi_radar-0.1.8/fastapi_radar/models.py +157 -0
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/fastapi_radar/radar.py +82 -33
- fastapi_radar-0.1.8/fastapi_radar/tracing.py +258 -0
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/fastapi_radar/utils.py +2 -1
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8/fastapi_radar.egg-info}/PKG-INFO +36 -14
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/fastapi_radar.egg-info/SOURCES.txt +3 -2
- fastapi_radar-0.1.8/fastapi_radar.egg-info/requires.txt +16 -0
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/pyproject.toml +19 -16
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/setup.py +8 -3
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/tests/test_radar.py +22 -12
- fastapi_radar-0.1.6/fastapi_radar/dashboard/dist/assets/index-BJa0l2JD.js +0 -313
- fastapi_radar-0.1.6/fastapi_radar/dashboard/dist/assets/index-DCxkDBhr.css +0 -1
- fastapi_radar-0.1.6/fastapi_radar/models.py +0 -66
- fastapi_radar-0.1.6/fastapi_radar.egg-info/requires.txt +0 -14
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/CONTRIBUTING.md +0 -0
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/LICENSE +0 -0
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/MANIFEST.in +0 -0
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/fastapi_radar.egg-info/dependency_links.txt +0 -0
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/fastapi_radar.egg-info/not-zip-safe +0 -0
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/fastapi_radar.egg-info/top_level.txt +0 -0
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/setup.cfg +0 -0
- {fastapi_radar-0.1.6 → fastapi_radar-0.1.8}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-radar
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
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
|
|
28
|
-
Requires-Dist: sqlalchemy>=
|
|
29
|
-
Requires-Dist: pydantic
|
|
30
|
-
Requires-Dist: starlette
|
|
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
|
|
33
|
-
Requires-Dist: pytest-asyncio
|
|
34
|
-
Requires-Dist: uvicorn[standard]
|
|
35
|
-
Requires-Dist: black
|
|
36
|
-
Requires-Dist: isort
|
|
37
|
-
Requires-Dist: flake8
|
|
38
|
-
Requires-Dist: mypy
|
|
39
|
-
Requires-Dist: httpx
|
|
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
|
|
@@ -138,9 +139,30 @@ radar = Radar(
|
|
|
138
139
|
capture_sql_bindings=True, # Capture SQL query parameters
|
|
139
140
|
exclude_paths=["/health"], # Paths to exclude from monitoring
|
|
140
141
|
theme="auto", # Dashboard theme: "light", "dark", or "auto"
|
|
142
|
+
db_path="/path/to/db", # Custom path for radar.duckdb file (default: current directory)
|
|
141
143
|
)
|
|
142
144
|
```
|
|
143
145
|
|
|
146
|
+
### Custom Database Location
|
|
147
|
+
|
|
148
|
+
By default, FastAPI Radar stores its monitoring data in a `radar.duckdb` file in your current working directory. You can customize this location using the `db_path` parameter:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
# Store in a specific directory
|
|
152
|
+
radar = Radar(app, db_path="/var/data/monitoring")
|
|
153
|
+
# Creates: /var/data/monitoring/radar.duckdb
|
|
154
|
+
|
|
155
|
+
# Store with a specific filename
|
|
156
|
+
radar = Radar(app, db_path="/var/data/my_app_monitoring.duckdb")
|
|
157
|
+
# Creates: /var/data/my_app_monitoring.duckdb
|
|
158
|
+
|
|
159
|
+
# Use a relative path
|
|
160
|
+
radar = Radar(app, db_path="./data")
|
|
161
|
+
# Creates: ./data/radar.duckdb
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
If the specified path cannot be created, FastAPI Radar will fallback to using the current directory with a warning.
|
|
165
|
+
|
|
144
166
|
## What Gets Captured?
|
|
145
167
|
|
|
146
168
|
- ✅ HTTP requests and responses
|
|
@@ -94,9 +94,30 @@ radar = Radar(
|
|
|
94
94
|
capture_sql_bindings=True, # Capture SQL query parameters
|
|
95
95
|
exclude_paths=["/health"], # Paths to exclude from monitoring
|
|
96
96
|
theme="auto", # Dashboard theme: "light", "dark", or "auto"
|
|
97
|
+
db_path="/path/to/db", # Custom path for radar.duckdb file (default: current directory)
|
|
97
98
|
)
|
|
98
99
|
```
|
|
99
100
|
|
|
101
|
+
### Custom Database Location
|
|
102
|
+
|
|
103
|
+
By default, FastAPI Radar stores its monitoring data in a `radar.duckdb` file in your current working directory. You can customize this location using the `db_path` parameter:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
# Store in a specific directory
|
|
107
|
+
radar = Radar(app, db_path="/var/data/monitoring")
|
|
108
|
+
# Creates: /var/data/monitoring/radar.duckdb
|
|
109
|
+
|
|
110
|
+
# Store with a specific filename
|
|
111
|
+
radar = Radar(app, db_path="/var/data/my_app_monitoring.duckdb")
|
|
112
|
+
# Creates: /var/data/my_app_monitoring.duckdb
|
|
113
|
+
|
|
114
|
+
# Use a relative path
|
|
115
|
+
radar = Radar(app, db_path="./data")
|
|
116
|
+
# Creates: ./data/radar.duckdb
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
If the specified path cannot be created, FastAPI Radar will fallback to using the current directory with a warning.
|
|
120
|
+
|
|
100
121
|
## What Gets Captured?
|
|
101
122
|
|
|
102
123
|
- ✅ HTTP requests and responses
|
|
@@ -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
|
|
6
|
-
|
|
7
|
-
from
|
|
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:
|
|
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
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"""SQLAlchemy query capture for FastAPI Radar."""
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
|
-
from typing import Any,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
def
|
|
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]]
|
|
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
|
|