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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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
@@ -0,0 +1,5 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class ProfilerConfig(AppConfig):
5
+ name = 'profiler'
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,2 @@
1
+ from .request_metric import RequestMetric
2
+ from .endpoint_summary import EndpointSummary
@@ -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,10 @@
1
+ from .request_metric import (
2
+ save_metric_payload,
3
+ build_metric_payload
4
+ )
5
+ from .n_plus_one import (
6
+ detect_n_plus_one,
7
+ normalize_sql
8
+ )
9
+
10
+ from .analytics import compute_endpoint_summaries
@@ -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,5 @@
1
+ from .n_plus_one import (
2
+ NormalizeSQLTests,
3
+ DetectNPlusOneTests
4
+ )
5
+ from .build_metric_payload import BuildMetricTest
@@ -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)