django-api-profiler 0.1.0__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.
- django_api_profiler-0.1.0.dist-info/METADATA +118 -0
- django_api_profiler-0.1.0.dist-info/RECORD +32 -0
- django_api_profiler-0.1.0.dist-info/WHEEL +4 -0
- django_api_profiler-0.1.0.dist-info/licenses/LICENSE +21 -0
- profiler/__init__.py +0 -0
- profiler/admin.py +80 -0
- profiler/apps.py +5 -0
- profiler/conf.py +31 -0
- profiler/db_wrapper.py +33 -0
- profiler/middleware.py +58 -0
- profiler/migrations/0001_initial.py +25 -0
- profiler/migrations/0002_requestmetric_query_count_and_more.py +23 -0
- profiler/migrations/0003_requestmetric_exception_message_and_more.py +28 -0
- profiler/migrations/0004_requestmetric_is_slow.py +19 -0
- profiler/migrations/0005_requestmetric_ip_address_requestmetric_request_id_and_more.py +58 -0
- profiler/migrations/0006_requestmetric_has_n_plus_one_and_more.py +23 -0
- profiler/migrations/0007_endpointsummary.py +35 -0
- profiler/migrations/__init__.py +0 -0
- profiler/models/__init__.py +2 -0
- profiler/models/endpoint_summary.py +28 -0
- profiler/models/request_metric.py +39 -0
- profiler/services/__init__.py +10 -0
- profiler/services/analytics.py +88 -0
- profiler/services/n_plus_one.py +31 -0
- profiler/services/regression.py +61 -0
- profiler/services/request_metric.py +50 -0
- profiler/tasks.py +16 -0
- profiler/tests/__init__.py +5 -0
- profiler/tests/build_metric_payload.py +14 -0
- profiler/tests/n_plus_one.py +51 -0
- profiler/utils/__init__.py +1 -0
- profiler/utils/ingestion.py +33 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-api-profiler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight API profiling and endpoint analytics for Django
|
|
5
|
+
Author: Ahmed
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: analytics,django,monitoring,performance,profiling
|
|
9
|
+
Classifier: Framework :: Django
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: django>=4.2
|
|
14
|
+
Provides-Extra: async
|
|
15
|
+
Requires-Dist: celery>=5.6.3; extra == 'async'
|
|
16
|
+
Requires-Dist: redis>=7.4.0; extra == 'async'
|
|
17
|
+
Provides-Extra: beat
|
|
18
|
+
Requires-Dist: django-celery-beat>=2.9.0; extra == 'beat'
|
|
19
|
+
Provides-Extra: postgres
|
|
20
|
+
Requires-Dist: psycopg2-binary>=2.9.12; extra == 'postgres'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Django API Profiler
|
|
24
|
+
|
|
25
|
+
Lightweight API profiling and endpoint analytics for Django.
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- Request latency tracking
|
|
30
|
+
- Endpoint summaries
|
|
31
|
+
- p95 response metrics
|
|
32
|
+
- Error rate monitoring
|
|
33
|
+
- Slow request detection
|
|
34
|
+
- N+1 query detection
|
|
35
|
+
- Django admin integration
|
|
36
|
+
- Optional async ingestion with Celery
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install django-api-profiler
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Quickstart
|
|
49
|
+
|
|
50
|
+
Add the app:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
INSTALLED_APPS = [
|
|
54
|
+
...
|
|
55
|
+
"django_api_profiler",
|
|
56
|
+
]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Add the middleware:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
MIDDLEWARE = [
|
|
63
|
+
...
|
|
64
|
+
"django_api_profiler.middleware.ApiProfilerMiddleware",
|
|
65
|
+
]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Run migrations:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
python manage.py migrate
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
PROFILER = {
|
|
80
|
+
"ASYNC": False,
|
|
81
|
+
"SLOW_REQUEST_THRESHOLD_MS": 1000,
|
|
82
|
+
"AGGREGATION_WINDOW_MINUTES": 60,
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Admin Dashboard
|
|
89
|
+
|
|
90
|
+
The package provides Django admin integration for:
|
|
91
|
+
- endpoint summaries
|
|
92
|
+
- latency metrics
|
|
93
|
+
- error tracking
|
|
94
|
+
- regression detection
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Optional Async Support
|
|
99
|
+
|
|
100
|
+
Install async dependencies:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pip install django-api-profiler[async]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Then enable:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
PROFILER = {
|
|
110
|
+
"ASYNC": True,
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
profiler/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
profiler/admin.py,sha256=kargCQz4r6qsEp8eATzUQjdOY32OfdncePyNt-R031s,1729
|
|
3
|
+
profiler/apps.py,sha256=JCzwLf8FhZAwfjC_HqkrzkJVK4B9Va_YJz2Byxw907w,91
|
|
4
|
+
profiler/conf.py,sha256=43xeeREtn6KT95I-_N-hKiFyxIM-gcpME8R9u7Oo3Pc,788
|
|
5
|
+
profiler/db_wrapper.py,sha256=SrLy4_SB3cyw4HssNtfi_8b7tpGL7f8GBWr9Hc3P_e4,1045
|
|
6
|
+
profiler/middleware.py,sha256=D_8vUkuVIoTixKv97RnGGPCZ4rWeVbV9_h8o79Ay2Ds,1768
|
|
7
|
+
profiler/tasks.py,sha256=IExwnEC3I-eTDk6zhnAzIiTMhIKiHMrIdjTN7uuWnsk,358
|
|
8
|
+
profiler/migrations/0001_initial.py,sha256=3dQM7KQTqFwLwgg1xlc2Ftu0a1PG0IVSLp6dJXk1o7A,745
|
|
9
|
+
profiler/migrations/0002_requestmetric_query_count_and_more.py,sha256=c-mY-v3G8zciWvss-AomwN0iq6zCliXpQeWGXPKoUlk,553
|
|
10
|
+
profiler/migrations/0003_requestmetric_exception_message_and_more.py,sha256=sjhnYWokzJaTh4ZWAAOXGaNp6WlMGDXHFEVntWw3xEA,785
|
|
11
|
+
profiler/migrations/0004_requestmetric_is_slow.py,sha256=Nbypm6BumwM9ijJyDrCrbi7bQARtrWnphyqyWcEoCxE,454
|
|
12
|
+
profiler/migrations/0005_requestmetric_ip_address_requestmetric_request_id_and_more.py,sha256=UY6Bt8aheH_LvMDcsH36e96QRMFh9DxcHATAQDtebCM,1989
|
|
13
|
+
profiler/migrations/0006_requestmetric_has_n_plus_one_and_more.py,sha256=wlqGk_YKivo1gKb3cKw9D1g1x9RPfTyommfIKelEAaI,621
|
|
14
|
+
profiler/migrations/0007_endpointsummary.py,sha256=fwgLx730z6SzKpralFxCJFPNEHWJNO7CIq70kCHHa0o,1559
|
|
15
|
+
profiler/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
profiler/models/__init__.py,sha256=dbdflC7cbquUytO_XNQF07H9LNaFarzvcyiF3zAr5uo,87
|
|
17
|
+
profiler/models/endpoint_summary.py,sha256=7LyMvOmU0Q4rO40Hnr15RGnFUkB-q4M-XzA1YNKvvk8,952
|
|
18
|
+
profiler/models/request_metric.py,sha256=IsEYlPIzvHXLU2d0MZZRYG6sUlT7Kv95FESBT1ig-Vw,1458
|
|
19
|
+
profiler/services/__init__.py,sha256=2PsAKTW9kSlKswJVeWFXu--Dy8re8MzZ8IAWCLw1fa8,201
|
|
20
|
+
profiler/services/analytics.py,sha256=KcMp7XRA6KhLYrl3cVrfV28cy66ST0Cleapn82mqR-Q,2568
|
|
21
|
+
profiler/services/n_plus_one.py,sha256=sufELbmhkDFt4vkuINGnVDTrf1ddrfdfRTf0ttbm4Lc,711
|
|
22
|
+
profiler/services/regression.py,sha256=qcmilFm1xGgK0TNqQpsw4pydA7nToWGzqn4w98_wFXc,2053
|
|
23
|
+
profiler/services/request_metric.py,sha256=S4d8JEYs_GvYCzNhfkae1e4Q5_u0ZT9KoZDwMHO5ydA,1414
|
|
24
|
+
profiler/tests/__init__.py,sha256=T_ZL3igsTtCRfEOSnxLhSj_Uaw7tq2tAP2Qp8NxyYL4,124
|
|
25
|
+
profiler/tests/build_metric_payload.py,sha256=xQ5fkyG5fbAySrPszDvjO3_vd0zFXXiibWFhUin-Zjc,362
|
|
26
|
+
profiler/tests/n_plus_one.py,sha256=jy9zRJya11idhwqemgXfjYhrkK58qE9DOJcFmEcpF1g,1521
|
|
27
|
+
profiler/utils/__init__.py,sha256=9lWmF1LLmeeWIgpUoWm2FzJxBnpe6gelscLKNO9fSQ8,36
|
|
28
|
+
profiler/utils/ingestion.py,sha256=0amZE5OR37DVAKNHz9VgsQpGGevI0FvGP_DZEZhitW8,746
|
|
29
|
+
django_api_profiler-0.1.0.dist-info/METADATA,sha256=oZ0_1Wgx3gV02F7uXZwcrIXpxhTAhnWIxZZn-ZKPUig,1900
|
|
30
|
+
django_api_profiler-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
31
|
+
django_api_profiler-0.1.0.dist-info/licenses/LICENSE,sha256=OBnxegkHQTCwyfUgvoG2eNmvWpG9I6FYJTkwfbMtbEU,1061
|
|
32
|
+
django_api_profiler-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ahmed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
profiler/__init__.py
ADDED
|
File without changes
|
profiler/admin.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from django.utils.html import format_html
|
|
3
|
+
from .models import RequestMetric , EndpointSummary
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@admin.register(RequestMetric)
|
|
7
|
+
class RequestMetricAdmin(admin.ModelAdmin):
|
|
8
|
+
list_display = (
|
|
9
|
+
"path",
|
|
10
|
+
"method",
|
|
11
|
+
"status_code",
|
|
12
|
+
"colored_response_time",
|
|
13
|
+
"query_count",
|
|
14
|
+
"total_query_time_ms",
|
|
15
|
+
"exception_type",
|
|
16
|
+
"exception_message",
|
|
17
|
+
"created_at",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
list_filter = (
|
|
21
|
+
"method",
|
|
22
|
+
"status_code",
|
|
23
|
+
"created_at"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
readonly_fields = list_display
|
|
27
|
+
|
|
28
|
+
ordering = ("-created_at",)
|
|
29
|
+
|
|
30
|
+
def colored_response_time(self, obj):
|
|
31
|
+
|
|
32
|
+
if obj.response_time_ms > 1000:
|
|
33
|
+
color = "red"
|
|
34
|
+
|
|
35
|
+
elif obj.response_time_ms > 500:
|
|
36
|
+
color = "orange"
|
|
37
|
+
|
|
38
|
+
else:
|
|
39
|
+
color = "lightgreen"
|
|
40
|
+
|
|
41
|
+
formatted_time = f"{obj.response_time_ms:.2f} ms"
|
|
42
|
+
|
|
43
|
+
return format_html(
|
|
44
|
+
'<span style="color: {}; font-weight: bold;">{}</span>',
|
|
45
|
+
color,
|
|
46
|
+
formatted_time
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@admin.register(EndpointSummary)
|
|
51
|
+
class EndpointSummaryAdmin(admin.ModelAdmin):
|
|
52
|
+
|
|
53
|
+
list_display = (
|
|
54
|
+
"route",
|
|
55
|
+
"window_start",
|
|
56
|
+
"total_requests",
|
|
57
|
+
"avg_response_ms",
|
|
58
|
+
"p95_response_ms",
|
|
59
|
+
"error_count",
|
|
60
|
+
"slow_count",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
list_filter = (
|
|
64
|
+
"window_start",
|
|
65
|
+
"computed_at",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
search_fields = ("route",)
|
|
69
|
+
|
|
70
|
+
ordering = ("-window_start",)
|
|
71
|
+
|
|
72
|
+
readonly_fields = [field.name for field in EndpointSummary._meta.fields]
|
|
73
|
+
|
|
74
|
+
date_hierarchy = "window_start"
|
|
75
|
+
|
|
76
|
+
def has_add_permission(self, request):
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
def has_delete_permission(self, request, obj=None):
|
|
80
|
+
return False
|
profiler/apps.py
ADDED
profiler/conf.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
|
|
3
|
+
_DEFAULTS = {
|
|
4
|
+
"ASYNC": False,
|
|
5
|
+
"SLOW_REQUEST_THRESHOLD_MS": 1000,
|
|
6
|
+
"AGGREGATION_WINDOW_MINUTES": 60,
|
|
7
|
+
"IGNORED_PATHS": ["/admin", "/static", "/favicon.ico"],
|
|
8
|
+
"N_PLUS_ONE_THRESHOLD": 3,
|
|
9
|
+
"SLOW_QUERY_THRESHOLD_MS": 100,
|
|
10
|
+
"REGRESSION_RESPONSE_TIME_FACTOR": 2.0,
|
|
11
|
+
"REGRESSION_ERROR_RATE_DELTA": 0.1,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProfilerSettings:
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self._user_settings = getattr(settings, "PROFILER", {})
|
|
19
|
+
|
|
20
|
+
def __getattr__(self, name):
|
|
21
|
+
if name not in _DEFAULTS:
|
|
22
|
+
raise AttributeError(f"Invalid profiler setting: '{name}'")
|
|
23
|
+
|
|
24
|
+
default = _DEFAULTS[name]
|
|
25
|
+
user_value = self._user_settings.get(name, default)
|
|
26
|
+
|
|
27
|
+
return user_value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
profiler_settings = ProfilerSettings()
|
profiler/db_wrapper.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from django.db.backends.utils import CursorWrapper
|
|
2
|
+
import threading
|
|
3
|
+
|
|
4
|
+
_local = threading.local()
|
|
5
|
+
|
|
6
|
+
def reset_query_log():
|
|
7
|
+
_local.queries = []
|
|
8
|
+
|
|
9
|
+
def get_query_log():
|
|
10
|
+
return list(getattr(_local, "queries", []))
|
|
11
|
+
|
|
12
|
+
class ProfilingCursorWrapper(CursorWrapper):
|
|
13
|
+
def execute(self, sql, params=None):
|
|
14
|
+
import time
|
|
15
|
+
start = time.perf_counter()
|
|
16
|
+
try:
|
|
17
|
+
return super().execute(sql, params)
|
|
18
|
+
finally:
|
|
19
|
+
duration = (time.perf_counter() - start) * 1000
|
|
20
|
+
if not hasattr(_local, "queries"):
|
|
21
|
+
_local.queries = []
|
|
22
|
+
_local.queries.append({"sql": sql, "time_ms": duration})
|
|
23
|
+
|
|
24
|
+
def executemany(self, sql, param_list):
|
|
25
|
+
import time
|
|
26
|
+
start = time.perf_counter()
|
|
27
|
+
try:
|
|
28
|
+
return super().executemany(sql, param_list)
|
|
29
|
+
finally:
|
|
30
|
+
duration = (time.perf_counter() - start) * 1000
|
|
31
|
+
if not hasattr(_local, "queries"):
|
|
32
|
+
_local.queries = []
|
|
33
|
+
_local.queries.append({"sql": sql, "time_ms": duration})
|
profiler/middleware.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from django.urls import resolve, Resolver404
|
|
3
|
+
from .db_wrapper import reset_query_log ,get_query_log
|
|
4
|
+
from .services import build_metric_payload
|
|
5
|
+
from .tasks import ingest_request_metric
|
|
6
|
+
from .conf import profiler_settings
|
|
7
|
+
from .utils import ingest_metric
|
|
8
|
+
|
|
9
|
+
class ApiProfilerMiddleware:
|
|
10
|
+
|
|
11
|
+
def __init__(self, get_response):
|
|
12
|
+
self.get_response = get_response
|
|
13
|
+
|
|
14
|
+
def __call__(self, request):
|
|
15
|
+
|
|
16
|
+
if any(request.path.startswith(path) for path in profiler_settings.IGNORED_PATHS):
|
|
17
|
+
return self.get_response(request)
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
url_match = resolve(request.path)
|
|
21
|
+
route = url_match.route
|
|
22
|
+
view_name = url_match.view_name
|
|
23
|
+
except Resolver404:
|
|
24
|
+
route = request.path
|
|
25
|
+
view_name = None
|
|
26
|
+
|
|
27
|
+
reset_query_log()
|
|
28
|
+
start_time = time.perf_counter()
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
response = self.get_response(request)
|
|
32
|
+
has_exception = False
|
|
33
|
+
exception = None
|
|
34
|
+
|
|
35
|
+
except Exception as e:
|
|
36
|
+
response = None
|
|
37
|
+
has_exception = True
|
|
38
|
+
exception = e
|
|
39
|
+
raise
|
|
40
|
+
|
|
41
|
+
finally:
|
|
42
|
+
|
|
43
|
+
payload = build_metric_payload(
|
|
44
|
+
path=request.path,
|
|
45
|
+
method=request.method,
|
|
46
|
+
status_code=response.status_code if response else 500,
|
|
47
|
+
duration_ms= (time.perf_counter() - start_time) * 1000,
|
|
48
|
+
route=route,
|
|
49
|
+
view_name=view_name,
|
|
50
|
+
has_exception=has_exception,
|
|
51
|
+
exception_type=type(exception).__name__ if exception else None,
|
|
52
|
+
exception_message=str(exception) if exception else None,
|
|
53
|
+
queries=get_query_log(),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
ingest_metric(payload)
|
|
57
|
+
|
|
58
|
+
return response
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Generated by Django 6.0.5 on 2026-05-09 13:00
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
initial = True
|
|
9
|
+
|
|
10
|
+
dependencies = [
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.CreateModel(
|
|
15
|
+
name='RequestMetric',
|
|
16
|
+
fields=[
|
|
17
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
18
|
+
('path', models.CharField(max_length=255)),
|
|
19
|
+
('method', models.CharField(max_length=10)),
|
|
20
|
+
('status_code', models.IntegerField()),
|
|
21
|
+
('response_time_ms', models.FloatField()),
|
|
22
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
23
|
+
],
|
|
24
|
+
),
|
|
25
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Generated by Django 6.0.5 on 2026-05-09 13:07
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('profiler', '0001_initial'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='requestmetric',
|
|
15
|
+
name='query_count',
|
|
16
|
+
field=models.IntegerField(default=0),
|
|
17
|
+
),
|
|
18
|
+
migrations.AddField(
|
|
19
|
+
model_name='requestmetric',
|
|
20
|
+
name='total_query_time_ms',
|
|
21
|
+
field=models.FloatField(default=0),
|
|
22
|
+
),
|
|
23
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Generated by Django 6.0.5 on 2026-05-09 13:50
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('profiler', '0002_requestmetric_query_count_and_more'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='requestmetric',
|
|
15
|
+
name='exception_message',
|
|
16
|
+
field=models.TextField(blank=True, null=True),
|
|
17
|
+
),
|
|
18
|
+
migrations.AddField(
|
|
19
|
+
model_name='requestmetric',
|
|
20
|
+
name='exception_type',
|
|
21
|
+
field=models.CharField(blank=True, max_length=255, null=True),
|
|
22
|
+
),
|
|
23
|
+
migrations.AddField(
|
|
24
|
+
model_name='requestmetric',
|
|
25
|
+
name='has_exception',
|
|
26
|
+
field=models.BooleanField(default=False),
|
|
27
|
+
),
|
|
28
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Generated by Django 6.0.5 on 2026-05-09 13:57
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('profiler', '0003_requestmetric_exception_message_and_more'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='requestmetric',
|
|
15
|
+
name='is_slow',
|
|
16
|
+
field=models.BooleanField(default=False),
|
|
17
|
+
preserve_default=False,
|
|
18
|
+
),
|
|
19
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Generated by Django 6.0.5 on 2026-05-10 07:07
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('profiler', '0004_requestmetric_is_slow'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='requestmetric',
|
|
15
|
+
name='ip_address',
|
|
16
|
+
field=models.GenericIPAddressField(blank=True, null=True),
|
|
17
|
+
),
|
|
18
|
+
migrations.AddField(
|
|
19
|
+
model_name='requestmetric',
|
|
20
|
+
name='request_id',
|
|
21
|
+
field=models.UUIDField(blank=True, null=True),
|
|
22
|
+
),
|
|
23
|
+
migrations.AddField(
|
|
24
|
+
model_name='requestmetric',
|
|
25
|
+
name='route',
|
|
26
|
+
field=models.CharField(blank=True, max_length=500, null=True),
|
|
27
|
+
),
|
|
28
|
+
migrations.AddField(
|
|
29
|
+
model_name='requestmetric',
|
|
30
|
+
name='user_id',
|
|
31
|
+
field=models.IntegerField(blank=True, null=True),
|
|
32
|
+
),
|
|
33
|
+
migrations.AddField(
|
|
34
|
+
model_name='requestmetric',
|
|
35
|
+
name='view_name',
|
|
36
|
+
field=models.CharField(blank=True, max_length=255, null=True),
|
|
37
|
+
),
|
|
38
|
+
migrations.AddIndex(
|
|
39
|
+
model_name='requestmetric',
|
|
40
|
+
index=models.Index(fields=['route'], name='profiler_re_route_f743d1_idx'),
|
|
41
|
+
),
|
|
42
|
+
migrations.AddIndex(
|
|
43
|
+
model_name='requestmetric',
|
|
44
|
+
index=models.Index(fields=['status_code'], name='profiler_re_status__151b6c_idx'),
|
|
45
|
+
),
|
|
46
|
+
migrations.AddIndex(
|
|
47
|
+
model_name='requestmetric',
|
|
48
|
+
index=models.Index(fields=['created_at'], name='profiler_re_created_fc9098_idx'),
|
|
49
|
+
),
|
|
50
|
+
migrations.AddIndex(
|
|
51
|
+
model_name='requestmetric',
|
|
52
|
+
index=models.Index(fields=['is_slow'], name='profiler_re_is_slow_878488_idx'),
|
|
53
|
+
),
|
|
54
|
+
migrations.AddIndex(
|
|
55
|
+
model_name='requestmetric',
|
|
56
|
+
index=models.Index(fields=['has_exception'], name='profiler_re_has_exc_5742cb_idx'),
|
|
57
|
+
),
|
|
58
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Generated by Django 6.0.5 on 2026-05-10 08:57
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('profiler', '0005_requestmetric_ip_address_requestmetric_request_id_and_more'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='requestmetric',
|
|
15
|
+
name='has_n_plus_one',
|
|
16
|
+
field=models.BooleanField(default=False),
|
|
17
|
+
),
|
|
18
|
+
migrations.AddField(
|
|
19
|
+
model_name='requestmetric',
|
|
20
|
+
name='n_plus_one_details',
|
|
21
|
+
field=models.JSONField(blank=True, null=True),
|
|
22
|
+
),
|
|
23
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Generated by Django 6.0.5 on 2026-05-10 11:23
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('profiler', '0006_requestmetric_has_n_plus_one_and_more'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.CreateModel(
|
|
14
|
+
name='EndpointSummary',
|
|
15
|
+
fields=[
|
|
16
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
17
|
+
('route', models.CharField(max_length=500)),
|
|
18
|
+
('window_start', models.DateTimeField()),
|
|
19
|
+
('window_end', models.DateTimeField()),
|
|
20
|
+
('total_requests', models.IntegerField(default=0)),
|
|
21
|
+
('avg_response_ms', models.FloatField(default=0)),
|
|
22
|
+
('p95_response_ms', models.FloatField(default=0)),
|
|
23
|
+
('max_response_ms', models.FloatField(default=0)),
|
|
24
|
+
('min_response_ms', models.FloatField(default=0)),
|
|
25
|
+
('error_count', models.IntegerField(default=0)),
|
|
26
|
+
('slow_count', models.IntegerField(default=0)),
|
|
27
|
+
('n_plus_one_count', models.IntegerField(default=0)),
|
|
28
|
+
('computed_at', models.DateTimeField(auto_now=True)),
|
|
29
|
+
],
|
|
30
|
+
options={
|
|
31
|
+
'indexes': [models.Index(fields=['route', 'window_start'], name='profiler_en_route_97ec83_idx'), models.Index(fields=['window_start'], name='profiler_en_window__25116c_idx')],
|
|
32
|
+
'unique_together': {('route', 'window_start')},
|
|
33
|
+
},
|
|
34
|
+
),
|
|
35
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
|
|
3
|
+
class EndpointSummary(models.Model):
|
|
4
|
+
route = models.CharField(max_length=500)
|
|
5
|
+
window_start = models.DateTimeField()
|
|
6
|
+
window_end = models.DateTimeField()
|
|
7
|
+
|
|
8
|
+
total_requests = models.IntegerField(default=0)
|
|
9
|
+
avg_response_ms = models.FloatField(default=0)
|
|
10
|
+
p95_response_ms = models.FloatField(default=0)
|
|
11
|
+
max_response_ms = models.FloatField(default=0)
|
|
12
|
+
min_response_ms = models.FloatField(default=0)
|
|
13
|
+
|
|
14
|
+
error_count = models.IntegerField(default=0)
|
|
15
|
+
slow_count = models.IntegerField(default=0)
|
|
16
|
+
n_plus_one_count = models.IntegerField(default=0)
|
|
17
|
+
|
|
18
|
+
computed_at = models.DateTimeField(auto_now=True)
|
|
19
|
+
|
|
20
|
+
class Meta:
|
|
21
|
+
unique_together = ("route", "window_start")
|
|
22
|
+
indexes = [
|
|
23
|
+
models.Index(fields=["route", "window_start"]),
|
|
24
|
+
models.Index(fields=["window_start"]),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
def __str__(self):
|
|
28
|
+
return f"{self.route} | {self.window_start}"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RequestMetric(models.Model):
|
|
6
|
+
path = models.CharField(max_length=255)
|
|
7
|
+
method = models.CharField(max_length=10)
|
|
8
|
+
status_code = models.IntegerField()
|
|
9
|
+
response_time_ms = models.FloatField()
|
|
10
|
+
query_count = models.IntegerField(default=0)
|
|
11
|
+
total_query_time_ms = models.FloatField(default=0)
|
|
12
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
13
|
+
is_slow = models.BooleanField()
|
|
14
|
+
has_exception = models.BooleanField(default=False)
|
|
15
|
+
exception_type = models.CharField(max_length=255,null=True,blank=True)
|
|
16
|
+
exception_message = models.TextField(null=True,blank=True)
|
|
17
|
+
route = models.CharField(max_length=500, null=True, blank=True)
|
|
18
|
+
view_name = models.CharField(max_length=255, null=True, blank=True)
|
|
19
|
+
user_id = models.IntegerField(null=True, blank=True)
|
|
20
|
+
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
21
|
+
request_id = models.UUIDField(null=True, blank=True)
|
|
22
|
+
has_n_plus_one = models.BooleanField(default=False)
|
|
23
|
+
n_plus_one_details = models.JSONField(null=True, blank=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def __str__(self):
|
|
27
|
+
return f"{self.method} {self.path}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Meta:
|
|
31
|
+
indexes = [
|
|
32
|
+
models.Index(fields=['route']),
|
|
33
|
+
models.Index(fields=['status_code']),
|
|
34
|
+
models.Index(fields=['created_at']),
|
|
35
|
+
models.Index(fields=['is_slow']),
|
|
36
|
+
models.Index(fields=['has_exception']),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from django.utils import timezone
|
|
3
|
+
from django.db.models import Count, Avg, Max, Min, Q
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from ..models import RequestMetric, EndpointSummary
|
|
6
|
+
from ..conf import profiler_settings
|
|
7
|
+
from .regression import detect_regression
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_last_completed_window() -> tuple:
|
|
11
|
+
now = timezone.now()
|
|
12
|
+
window_minutes = profiler_settings.AGGREGATION_WINDOW_MINUTES
|
|
13
|
+
|
|
14
|
+
minutes_since_epoch = int(now.timestamp() // 60)
|
|
15
|
+
window_start_minutes = (minutes_since_epoch // window_minutes) * window_minutes
|
|
16
|
+
window_start = timezone.datetime.fromtimestamp(
|
|
17
|
+
window_start_minutes * 60,
|
|
18
|
+
tz=timezone.utc
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
window_end = window_start
|
|
22
|
+
window_start = window_end - timedelta(minutes=window_minutes)
|
|
23
|
+
|
|
24
|
+
return window_start, window_end
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _calculate_p95(values: list[float]) -> float:
|
|
28
|
+
if not values:
|
|
29
|
+
return 0.0
|
|
30
|
+
sorted_values = sorted(values)
|
|
31
|
+
index = int(len(sorted_values) * 0.95)
|
|
32
|
+
return sorted_values[min(index, len(sorted_values) - 1)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def compute_endpoint_summaries() -> int:
|
|
36
|
+
|
|
37
|
+
window_start, window_end = _get_last_completed_window()
|
|
38
|
+
|
|
39
|
+
metrics_in_window = RequestMetric.objects.filter(
|
|
40
|
+
created_at__gte=window_start,
|
|
41
|
+
created_at__lt=window_end,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if not metrics_in_window.exists():
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
routes = metrics_in_window.values_list("route", flat=True).distinct()
|
|
48
|
+
|
|
49
|
+
count = 0
|
|
50
|
+
|
|
51
|
+
for route in routes:
|
|
52
|
+
route_metrics = metrics_in_window.filter(route=route)
|
|
53
|
+
|
|
54
|
+
response_times = list(
|
|
55
|
+
route_metrics.values_list("response_time_ms", flat=True)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
aggregates = route_metrics.aggregate(
|
|
60
|
+
total_requests=Count("id"),
|
|
61
|
+
avg_response_ms=Avg("response_time_ms"),
|
|
62
|
+
max_response_ms=Max("response_time_ms"),
|
|
63
|
+
min_response_ms=Min("response_time_ms"),
|
|
64
|
+
error_count=Count("id", filter=Q(status_code__gte=400)),
|
|
65
|
+
slow_count=Count("id", filter=Q(is_slow=True)),
|
|
66
|
+
n_plus_one_count=Count("id", filter=Q(has_n_plus_one=True)),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
EndpointSummary.objects.update_or_create(
|
|
70
|
+
route=route,
|
|
71
|
+
window_start=window_start,
|
|
72
|
+
defaults={
|
|
73
|
+
"window_end": window_end,
|
|
74
|
+
"p95_response_ms": _calculate_p95(response_times),
|
|
75
|
+
**aggregates,
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
regressions = detect_regression(
|
|
80
|
+
route=route,
|
|
81
|
+
window_start=window_start,
|
|
82
|
+
window_end=window_end
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
count += 1
|
|
87
|
+
|
|
88
|
+
return count
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from collections import Counter
|
|
2
|
+
from ..conf import profiler_settings
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
def normalize_sql(sql: str) -> str:
|
|
6
|
+
|
|
7
|
+
sql = re.sub(r'\b\d+\b', '?', sql)
|
|
8
|
+
|
|
9
|
+
sql = re.sub(r"'[^']*'", '?', sql)
|
|
10
|
+
|
|
11
|
+
sql = ' '.join(sql.split())
|
|
12
|
+
|
|
13
|
+
return sql.strip().lower()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def detect_n_plus_one(queries: list[dict]) -> list[dict]:
|
|
19
|
+
|
|
20
|
+
normalized = [normalize_sql(q["sql"]) for q in queries]
|
|
21
|
+
counts = Counter(normalized)
|
|
22
|
+
|
|
23
|
+
duplicates = []
|
|
24
|
+
for pattern, count in counts.items():
|
|
25
|
+
if count >= profiler_settings.N_PLUS_ONE_THRESHOLD:
|
|
26
|
+
duplicates.append({
|
|
27
|
+
"query_pattern": pattern,
|
|
28
|
+
"count": count,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
return duplicates
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from ..models import EndpointSummary
|
|
4
|
+
from ..conf import profiler_settings
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def detect_regression(route: str, window_start, window_end) -> list[dict]:
|
|
10
|
+
previous_window_start = window_start - timedelta(
|
|
11
|
+
minutes=profiler_settings.AGGREGATION_WINDOW_MINUTES
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
previous_summary = EndpointSummary.objects.filter(
|
|
15
|
+
route=route,
|
|
16
|
+
window_start=previous_window_start,
|
|
17
|
+
).first()
|
|
18
|
+
|
|
19
|
+
current_summary = EndpointSummary.objects.filter(
|
|
20
|
+
route=route,
|
|
21
|
+
window_start=window_start,
|
|
22
|
+
).first()
|
|
23
|
+
|
|
24
|
+
if not previous_summary or not current_summary:
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
if current_summary.total_requests == 0 or previous_summary.total_requests == 0:
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
regressions = []
|
|
31
|
+
|
|
32
|
+
if current_summary.avg_response_ms > previous_summary.avg_response_ms * profiler_settings.REGRESSION_RESPONSE_TIME_FACTOR:
|
|
33
|
+
regressions.append({
|
|
34
|
+
"type": "response_time",
|
|
35
|
+
"previous_avg_ms": previous_summary.avg_response_ms,
|
|
36
|
+
"current_avg_ms": current_summary.avg_response_ms,
|
|
37
|
+
})
|
|
38
|
+
logger.warning(
|
|
39
|
+
"Response time regression detected on %s: %.2fms → %.2fms",
|
|
40
|
+
route,
|
|
41
|
+
previous_summary.avg_response_ms,
|
|
42
|
+
current_summary.avg_response_ms,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
current_error_rate = current_summary.error_count / current_summary.total_requests
|
|
46
|
+
previous_error_rate = previous_summary.error_count / previous_summary.total_requests
|
|
47
|
+
|
|
48
|
+
if current_error_rate > previous_error_rate + profiler_settings.REGRESSION_ERROR_RATE_DELTA:
|
|
49
|
+
regressions.append({
|
|
50
|
+
"type": "error_rate",
|
|
51
|
+
"previous_rate": round(previous_error_rate, 4),
|
|
52
|
+
"current_rate": round(current_error_rate, 4),
|
|
53
|
+
})
|
|
54
|
+
logger.warning(
|
|
55
|
+
"Error rate regression detected on %s: %.1f%% → %.1f%%",
|
|
56
|
+
route,
|
|
57
|
+
previous_error_rate * 100,
|
|
58
|
+
current_error_rate * 100,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return regressions
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from django.http import HttpRequest, HttpResponse
|
|
2
|
+
from ..models.request_metric import RequestMetric
|
|
3
|
+
from ..db_wrapper import get_query_log
|
|
4
|
+
from .n_plus_one import detect_n_plus_one
|
|
5
|
+
from ..conf import profiler_settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_metric_payload(
|
|
12
|
+
path: str,
|
|
13
|
+
method: str,
|
|
14
|
+
status_code: int,
|
|
15
|
+
duration_ms: float,
|
|
16
|
+
route: str | None = None,
|
|
17
|
+
view_name: str | None = None,
|
|
18
|
+
has_exception: bool = False,
|
|
19
|
+
exception_type: str | None = None,
|
|
20
|
+
exception_message: str | None = None,
|
|
21
|
+
queries: list[dict] | None = None,
|
|
22
|
+
) -> dict:
|
|
23
|
+
|
|
24
|
+
queries = queries or []
|
|
25
|
+
|
|
26
|
+
n_plus_one_results = detect_n_plus_one(queries)
|
|
27
|
+
has_n_plus_one = len(n_plus_one_results) > 0
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
"path": path,
|
|
31
|
+
"method": method,
|
|
32
|
+
"status_code": status_code,
|
|
33
|
+
"response_time_ms": duration_ms,
|
|
34
|
+
"route": route,
|
|
35
|
+
"view_name": view_name,
|
|
36
|
+
"query_count": len(queries),
|
|
37
|
+
"total_query_time_ms": sum(q["time_ms"] for q in queries),
|
|
38
|
+
"has_exception": has_exception,
|
|
39
|
+
"is_slow": duration_ms > profiler_settings.SLOW_REQUEST_THRESHOLD_MS,
|
|
40
|
+
"exception_type": exception_type,
|
|
41
|
+
"exception_message": exception_message,
|
|
42
|
+
"has_n_plus_one": has_n_plus_one,
|
|
43
|
+
"n_plus_one_details": n_plus_one_results if has_n_plus_one else None,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save_metric_payload(payload: dict) -> None:
|
|
49
|
+
|
|
50
|
+
RequestMetric.objects.create(**payload)
|
profiler/tasks.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from celery import shared_task
|
|
2
|
+
from .services import (
|
|
3
|
+
compute_endpoint_summaries,
|
|
4
|
+
save_metric_payload
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
@shared_task
|
|
8
|
+
def run_aggregation() -> str:
|
|
9
|
+
count = compute_endpoint_summaries()
|
|
10
|
+
return f"Computed summaries for {count} endpoint(s)."
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@shared_task
|
|
14
|
+
def ingest_request_metric(payload: dict) -> None:
|
|
15
|
+
save_metric_payload(payload)
|
|
16
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from django.test import TestCase
|
|
2
|
+
from ..services import build_metric_payload
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BuildMetricTest(TestCase):
|
|
6
|
+
|
|
7
|
+
def test_marks_slow_request(self):
|
|
8
|
+
payload = build_metric_payload(
|
|
9
|
+
path="/api/users/",
|
|
10
|
+
method="GET",
|
|
11
|
+
status_code=200,
|
|
12
|
+
duration_ms=2000,
|
|
13
|
+
)
|
|
14
|
+
self.assertTrue(payload["is_slow"])
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from django.test import TestCase
|
|
2
|
+
from ..services import detect_n_plus_one, normalize_sql
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NormalizeSQLTests(TestCase):
|
|
7
|
+
|
|
8
|
+
def test_strips_numbers(self):
|
|
9
|
+
sql_query = "SELECT * FROM users WHERE id = 433"
|
|
10
|
+
self.assertEqual(
|
|
11
|
+
normalize_sql(sql_query),
|
|
12
|
+
"select * from users where id = ?"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
def test_strips_string(self):
|
|
16
|
+
sql_query = "SELECT * FROM users WHERE name = 'Ahmed'"
|
|
17
|
+
|
|
18
|
+
self.assertEqual(
|
|
19
|
+
normalize_sql(sql_query),
|
|
20
|
+
"select * from users where name = ?"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def test_collapses_whitespace(self):
|
|
24
|
+
|
|
25
|
+
sql_query = "SELECT * from users"
|
|
26
|
+
|
|
27
|
+
self.assertEqual(
|
|
28
|
+
normalize_sql(sql_query),
|
|
29
|
+
"select * from users"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DetectNPlusOneTests(TestCase):
|
|
34
|
+
|
|
35
|
+
def test_detects_repeated_queries(self):
|
|
36
|
+
queries = [
|
|
37
|
+
{"sql": "SELECT * FROM users WHERE id = 1", "time_ms": 1},
|
|
38
|
+
{"sql": "SELECT * FROM users WHERE id = 2", "time_ms": 1},
|
|
39
|
+
{"sql": "SELECT * FROM users WHERE id = 3", "time_ms": 1},
|
|
40
|
+
]
|
|
41
|
+
results = detect_n_plus_one(queries)
|
|
42
|
+
self.assertEqual(len(results), 1)
|
|
43
|
+
self.assertEqual(results[0]["count"], 3)
|
|
44
|
+
|
|
45
|
+
def test_no_false_positive_on_unique_queries(self):
|
|
46
|
+
queries = [
|
|
47
|
+
{"sql": "SELECT * FROM users", "time_ms": 1},
|
|
48
|
+
{"sql": "SELECT * FROM posts", "time_ms": 1},
|
|
49
|
+
]
|
|
50
|
+
results = detect_n_plus_one(queries)
|
|
51
|
+
self.assertEqual(results, [])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .ingestion import ingest_metric
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from ..conf import profiler_settings
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def ingest_metric(payload: dict) -> None:
|
|
8
|
+
use_async = profiler_settings.ASYNC
|
|
9
|
+
|
|
10
|
+
if use_async:
|
|
11
|
+
_ingest_async(payload)
|
|
12
|
+
else:
|
|
13
|
+
_ingest_sync(payload)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ingest_async(payload: dict) -> None:
|
|
19
|
+
try:
|
|
20
|
+
from ..tasks import ingest_request_metric
|
|
21
|
+
ingest_request_metric.delay(payload)
|
|
22
|
+
except Exception as e:
|
|
23
|
+
logger.error(
|
|
24
|
+
"Failed to queue metric asynchronously, falling back to sync. "
|
|
25
|
+
"Error: %s",
|
|
26
|
+
str(e),
|
|
27
|
+
)
|
|
28
|
+
_ingest_sync(payload)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ingest_sync(payload: dict) -> None:
|
|
32
|
+
from ..services import save_metric_payload
|
|
33
|
+
save_metric_payload(payload)
|