Skip to content

Commit 601b37d

Browse files
hamzaimran08j-awada
authored andcommitted
SS-1322 monitor connection pool (#546)
1 parent 82f5286 commit 601b37d

8 files changed

Lines changed: 281 additions & 4 deletions

File tree

.env.template

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ APP_PROBE_STATUSES=Running,Deleted
5757
# Rate limiting configuration for the auth endpoint
5858
AUTH_RATE_LIMIT_VALUE=
5959
AUTH_RATE_LIMIT_WHITELIST=
60+
DB_POOL_STATS_LOGGING_ENABLED=false
61+
PROMETHEUS_METRICS_ENABLED=false
6062

6163
# Caching/logging configuration.
6264
# SESSION_CACHE_ENABLED uses Django's cached_db session backend and requires Redis.

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,4 @@ repos:
5252
hooks:
5353
- id: mypy
5454
args: [--config-file, pyproject.toml]
55-
additional_dependencies: [types-requests==2.25.9, pydantic==2.12.5]
55+
additional_dependencies: [types-requests==2.25.9, pydantic==2.12.5, prometheus-client==0.24.1]

poetry.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ wiki = "==0.11.2"
3737

3838
# Other Python and project-related libraries
3939
flower = "^2.0.1"
40+
prometheus-client = "==0.24.1"
4041
requests = "==2.31.0"
4142
amqp = "==5.1.1"
4243
psycopg = {extras = ["binary", "pool", "c"], version = "^3.2.1"}

studio/metrics.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import os
2+
from typing import Any
3+
4+
from django.conf import settings
5+
from django.http import HttpRequest, HttpResponse
6+
from prometheus_client import (
7+
CONTENT_TYPE_LATEST,
8+
Counter,
9+
Gauge,
10+
Histogram,
11+
generate_latest,
12+
)
13+
14+
from studio.middleware import get_db_pool_stats
15+
16+
DB_POOL_ENABLED = Gauge(
17+
"django_db_pool_enabled",
18+
"Whether Django database connection pooling is enabled.",
19+
["alias", "pod", "pid", "pool_id"],
20+
)
21+
DB_POOL_OPENED = Gauge(
22+
"django_db_pool_opened",
23+
"Whether the Django database connection pool has been opened in this process.",
24+
["alias", "pod", "pid", "pool_id"],
25+
)
26+
DB_POOL_SIZE = Gauge(
27+
"django_db_pool_size",
28+
"Current number of connections in the Django database connection pool.",
29+
["alias", "pod", "pid", "pool_id"],
30+
)
31+
DB_POOL_AVAILABLE = Gauge(
32+
"django_db_pool_available",
33+
"Current number of available connections in the Django database connection pool.",
34+
["alias", "pod", "pid", "pool_id"],
35+
)
36+
DB_POOL_MIN = Gauge(
37+
"django_db_pool_min",
38+
"Configured minimum number of connections in the Django database connection pool.",
39+
["alias", "pod", "pid", "pool_id"],
40+
)
41+
DB_POOL_MAX = Gauge(
42+
"django_db_pool_max",
43+
"Configured maximum number of connections in the Django database connection pool.",
44+
["alias", "pod", "pid", "pool_id"],
45+
)
46+
DB_POOL_REQUESTS_WAITING = Gauge(
47+
"django_db_pool_requests_waiting",
48+
"Current number of requests waiting for a Django database connection pool slot.",
49+
["alias", "pod", "pid", "pool_id"],
50+
)
51+
DB_POOL_REQUESTS_NUM = Gauge(
52+
"django_db_pool_requests_total",
53+
"Total number of requests served by the Django database connection pool in this process.",
54+
["alias", "pod", "pid", "pool_id"],
55+
)
56+
DB_POOL_REQUESTS_ERRORS = Gauge(
57+
"django_db_pool_requests_errors_total",
58+
"Total number of Django database connection pool request errors in this process.",
59+
["alias", "pod", "pid", "pool_id"],
60+
)
61+
HTTP_REQUESTS_TOTAL = Counter(
62+
"django_http_requests_total",
63+
"Total number of Django HTTP requests handled by this process.",
64+
["method", "route", "status_code", "pod"],
65+
)
66+
HTTP_REQUEST_DURATION_SECONDS = Histogram(
67+
"django_http_request_duration_seconds",
68+
"Django HTTP request duration in seconds for this process.",
69+
["method", "route", "status_code", "pod"],
70+
)
71+
HTTP_REQUEST_EXCEPTIONS_TOTAL = Counter(
72+
"django_http_request_exceptions_total",
73+
"Total number of Django HTTP requests that raised an exception in this process.",
74+
["method", "route", "exception", "pod"],
75+
)
76+
77+
78+
def _metric_value(stats: dict[str, Any], key: str) -> float:
79+
value = stats.get(key)
80+
if isinstance(value, bool):
81+
return 1.0 if value else 0.0
82+
if isinstance(value, int | float):
83+
return float(value)
84+
return 0.0
85+
86+
87+
def update_db_pool_metrics() -> None:
88+
stats = get_db_pool_stats()
89+
labels = {
90+
"alias": str(stats.get("alias", "")),
91+
"pod": str(stats.get("pod", "")),
92+
"pid": str(stats.get("pid", "")),
93+
"pool_id": str(stats.get("pool_id", "")),
94+
}
95+
96+
DB_POOL_ENABLED.labels(**labels).set(_metric_value(stats, "enabled"))
97+
DB_POOL_OPENED.labels(**labels).set(_metric_value(stats, "opened"))
98+
DB_POOL_SIZE.labels(**labels).set(_metric_value(stats, "pool_size"))
99+
DB_POOL_AVAILABLE.labels(**labels).set(_metric_value(stats, "pool_available"))
100+
DB_POOL_MIN.labels(**labels).set(_metric_value(stats, "pool_min"))
101+
DB_POOL_MAX.labels(**labels).set(_metric_value(stats, "pool_max"))
102+
DB_POOL_REQUESTS_WAITING.labels(**labels).set(_metric_value(stats, "requests_waiting"))
103+
DB_POOL_REQUESTS_NUM.labels(**labels).set(_metric_value(stats, "requests_num"))
104+
DB_POOL_REQUESTS_ERRORS.labels(**labels).set(_metric_value(stats, "requests_errors"))
105+
106+
107+
def get_http_route_label(request: HttpRequest) -> str:
108+
resolver_match = getattr(request, "resolver_match", None)
109+
if resolver_match is None:
110+
return "unknown"
111+
view_name = getattr(resolver_match, "view_name", "")
112+
if isinstance(view_name, str) and view_name:
113+
return view_name
114+
route = getattr(resolver_match, "route", "")
115+
if isinstance(route, str) and route:
116+
return route
117+
return "unknown"
118+
119+
120+
def _pod_label() -> str:
121+
return os.environ.get("HOSTNAME", "")
122+
123+
124+
def record_http_request_metrics(request: HttpRequest, response: HttpResponse, duration_seconds: float) -> None:
125+
labels = {
126+
"method": request.method,
127+
"route": get_http_route_label(request),
128+
"status_code": str(response.status_code),
129+
"pod": _pod_label(),
130+
}
131+
HTTP_REQUESTS_TOTAL.labels(**labels).inc()
132+
HTTP_REQUEST_DURATION_SECONDS.labels(**labels).observe(duration_seconds)
133+
134+
135+
def record_http_exception_metrics(request: HttpRequest, exception: Exception, duration_seconds: float) -> None:
136+
HTTP_REQUEST_EXCEPTIONS_TOTAL.labels(
137+
method=request.method,
138+
route=get_http_route_label(request),
139+
exception=exception.__class__.__name__,
140+
pod=_pod_label(),
141+
).inc()
142+
labels = {
143+
"method": request.method,
144+
"route": get_http_route_label(request),
145+
"status_code": "exception",
146+
"pod": _pod_label(),
147+
}
148+
HTTP_REQUEST_DURATION_SECONDS.labels(**labels).observe(duration_seconds)
149+
150+
151+
def metrics_view(request: HttpRequest) -> HttpResponse:
152+
if not settings.PROMETHEUS_METRICS_ENABLED:
153+
return HttpResponse(status=404)
154+
155+
update_db_pool_metrics()
156+
return HttpResponse(generate_latest(), content_type=CONTENT_TYPE_LATEST)

studio/middleware.py

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,47 @@
1-
import logging
1+
import os
22
import sys
3+
import time
34
import traceback
45
from typing import Any, Callable
56

7+
from django.conf import settings
8+
from django.db import connections
69
from django.http import HttpRequest, HttpResponse
710

811
from studio.utils import get_logger
912

1013
logger = get_logger(__name__)
14+
_last_db_pool_stats: dict[str, Any] | None = None
15+
DB_POOL_STATS_CHANGE_KEYS = (
16+
"enabled",
17+
"opened",
18+
"pool_size",
19+
"pool_available",
20+
"requests_waiting",
21+
"requests_errors",
22+
)
23+
METRICS_PATHS = ("/metrics", "/metrics/")
24+
25+
26+
def get_db_pool_stats(alias: str = "default") -> dict[str, Any]:
27+
connection = connections[alias]
28+
pool_enabled = bool(connection.settings_dict["OPTIONS"].get("pool"))
29+
pools = getattr(connection, "_connection_pools", {})
30+
pool = pools.get(alias)
31+
pod_name = os.environ.get("HOSTNAME", "")
32+
pid = os.getpid()
33+
34+
stats = {
35+
"alias": alias,
36+
"enabled": pool_enabled,
37+
"opened": pool is not None,
38+
"pid": pid,
39+
"pod": pod_name,
40+
"pool_id": f"{pod_name}:{pid}:{alias}",
41+
}
42+
if pool is not None:
43+
stats.update(pool.get_stats())
44+
return stats
1145

1246

1347
class ExceptionLoggingMiddleware:
@@ -33,3 +67,73 @@ def process_exception(self, request: HttpRequest, exception: Exception) -> None:
3367
msg += "".join(traceback.format_tb(stacktrace)).replace("\n", "\\n")
3468
logger.error(msg)
3569
return None
70+
71+
72+
class PrometheusHttpMetricsMiddleware:
73+
"""
74+
Records Django HTTP request metrics for the Prometheus endpoint.
75+
"""
76+
77+
def __init__(self, get_response: Callable[[HttpRequest], Any]):
78+
self.get_response = get_response
79+
80+
def __call__(self, request: HttpRequest) -> HttpResponse:
81+
if not settings.PROMETHEUS_METRICS_ENABLED or request.path in METRICS_PATHS:
82+
return self.get_response(request)
83+
84+
start = time.perf_counter()
85+
try:
86+
response = self.get_response(request)
87+
except Exception as exception:
88+
from studio.metrics import record_http_exception_metrics
89+
90+
record_http_exception_metrics(request, exception, time.perf_counter() - start)
91+
raise
92+
93+
from studio.metrics import record_http_request_metrics
94+
95+
record_http_request_metrics(request, response, time.perf_counter() - start)
96+
return response
97+
98+
99+
class DatabasePoolStatsLoggingMiddleware:
100+
"""
101+
Logs psycopg pool stats from the Django worker process that handled the request.
102+
"""
103+
104+
def __init__(self, get_response: Callable[[HttpRequest], Any]):
105+
self.get_response = get_response
106+
107+
def __call__(self, request: HttpRequest) -> HttpResponse:
108+
response = self.get_response(request)
109+
110+
if settings.DB_POOL_STATS_LOGGING_ENABLED:
111+
pool_stats = get_db_pool_stats()
112+
if _db_pool_stats_changed(pool_stats):
113+
logger.info(
114+
"Django DB pool stats path=%s status=%s pod=%s pid=%s pool_id=%s opened=%s "
115+
"pool_size=%s pool_available=%s requests_waiting=%s requests_num=%s requests_errors=%s stats=%s",
116+
request.path,
117+
response.status_code,
118+
pool_stats.get("pod"),
119+
pool_stats.get("pid"),
120+
pool_stats.get("pool_id"),
121+
pool_stats.get("opened"),
122+
pool_stats.get("pool_size"),
123+
pool_stats.get("pool_available"),
124+
pool_stats.get("requests_waiting"),
125+
pool_stats.get("requests_num"),
126+
pool_stats.get("requests_errors"),
127+
pool_stats,
128+
)
129+
130+
return response
131+
132+
133+
def _db_pool_stats_changed(stats: dict[str, Any]) -> bool:
134+
global _last_db_pool_stats
135+
136+
current_stats = {key: stats.get(key) for key in DB_POOL_STATS_CHANGE_KEYS}
137+
previous_stats = _last_db_pool_stats
138+
_last_db_pool_stats = current_stats
139+
return previous_stats != current_stats

studio/settings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@
122122
"corsheaders.middleware.CorsMiddleware",
123123
"axes.middleware.AxesMiddleware",
124124
"studio.middleware.ExceptionLoggingMiddleware",
125+
"studio.middleware.PrometheusHttpMetricsMiddleware",
126+
"studio.middleware.DatabasePoolStatsLoggingMiddleware",
125127
"django_htmx.middleware.HtmxMiddleware",
126128
"waffle.middleware.WaffleMiddleware",
127129
]
@@ -378,6 +380,16 @@ def postgres_session_options() -> str:
378380
AUTH_RATE_LIMIT_VALUE = os.environ.get("AUTH_RATE_LIMIT_VALUE", None)
379381
AUTH_RATE_LIMIT_WHITELIST_RAW = os.environ.get("AUTH_RATE_LIMIT_WHITELIST", "")
380382
AUTH_RATE_LIMIT_WHITELIST = [ip.strip() for ip in AUTH_RATE_LIMIT_WHITELIST_RAW.split(",")]
383+
DB_POOL_STATS_LOGGING_ENABLED = os.environ.get("DB_POOL_STATS_LOGGING_ENABLED", "false").lower() in (
384+
"1",
385+
"true",
386+
"yes",
387+
)
388+
PROMETHEUS_METRICS_ENABLED = os.environ.get("PROMETHEUS_METRICS_ENABLED", "false").lower() in (
389+
"1",
390+
"true",
391+
"yes",
392+
)
381393

382394
AUTH_PERMISSION_CACHE_ENABLED = os.environ.get("AUTH_PERMISSION_CACHE_ENABLED", "false").lower() in ("1", "true", "yes")
383395
AUTH_PERMISSION_CACHE_TIMEOUT = int(os.environ.get("AUTH_PERMISSION_CACHE_TIMEOUT", "30"))

studio/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@
2020
from django.conf.urls.static import static
2121
from django.contrib import admin
2222
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
23-
from django.urls import include, path
23+
from django.urls import include, path, re_path
2424
from django_altcha import AltchaChallengeView
2525

2626
from apps.views import record_lookup
27+
from studio.metrics import metrics_view
2728

2829
from . import views
2930

@@ -41,6 +42,7 @@
4142
name="delete_account_post_handler",
4243
),
4344
path("status", views.status_view, name="status"),
45+
re_path(r"^metrics/?$", metrics_view, name="metrics"),
4446
path("user/account-deleted/<int:user_id>", views.account_deleted, name="account_deleted"),
4547
path("auth/", views.AuthView.as_view()),
4648
# API paths using NamespaceVersioning

0 commit comments

Comments
 (0)