Skip to content

Commit 3f808e6

Browse files
committed
feat: add backend middleware analytics for prometheus
* Add custom metrics to estimate unique visitors. * Add custom metrics to estimate usefull client information (browser, os, device) Signed-off-by: Alan Peixinho <alan.peixinho@profusion.mobi>
1 parent 3c9ac93 commit 3f808e6

3 files changed

Lines changed: 259 additions & 0 deletions

File tree

backend/kernelCI/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def get_json_env_var(name, default):
8989
"django.contrib.messages.middleware.MessageMiddleware",
9090
"django.middleware.clickjacking.XFrameOptionsMiddleware",
9191
"kernelCI_app.middleware.logServerErrorMiddleware.LogServerErrorMiddleware",
92+
"kernelCI_app.middleware.backendRequestMetricsMiddleware.BackendRequestMetricsMiddleware",
9293
"django_prometheus.middleware.PrometheusAfterMiddleware",
9394
]
9495

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import hashlib
2+
import hmac
3+
import logging
4+
import re
5+
import secrets
6+
from dataclasses import dataclass
7+
from datetime import UTC, datetime
8+
from urllib.parse import urlparse
9+
10+
from django.core.cache import cache
11+
from django.core.exceptions import DisallowedHost
12+
from prometheus_client import Counter
13+
14+
UNKNOWN = "unknown"
15+
DIRECT_OR_INTERNAL = "direct_or_internal"
16+
UNIQUE_VISITOR_TTL_SECONDS = 48 * 60 * 60
17+
UNIQUE_VISITOR_SALT_BYTES = 32
18+
19+
logger = logging.getLogger(__name__)
20+
21+
DASHBOARD_BACKEND_REQUESTS_BY_CLIENT = Counter(
22+
"dashboard_backend_requests_by_client_total",
23+
"Backend requests grouped by endpoint and client attributes",
24+
[
25+
"endpoint",
26+
"method",
27+
"status_class",
28+
"browser",
29+
"os",
30+
"device",
31+
"referrer_domain",
32+
],
33+
)
34+
35+
DASHBOARD_UNIQUE_VISITORS_TOTAL = Counter(
36+
"dashboard_unique_visitors_total",
37+
"Daily unique backend visitors",
38+
)
39+
40+
DASHBOARD_UNIQUE_VISITORS_BY_ENDPOINT_TOTAL = Counter(
41+
"dashboard_unique_visitors_by_endpoint_total",
42+
"Daily unique backend visitors deduplicated per endpoint by rotated Redis salt",
43+
["endpoint"],
44+
)
45+
46+
47+
@dataclass(frozen=True)
48+
class ClientInfo:
49+
browser: str
50+
os: str
51+
device: str
52+
53+
54+
class BackendRequestMetricsMiddleware:
55+
def __init__(self, get_response):
56+
self.get_response = get_response
57+
58+
def __call__(self, request):
59+
response = self.get_response(request)
60+
if request.path.startswith("/api/"):
61+
labels = get_backend_request_labels(request, response)
62+
record_client(**labels)
63+
record_unique_visitor(request=request, endpoint=labels["endpoint"])
64+
return response
65+
66+
67+
def record_client(**kwargs) -> None:
68+
DASHBOARD_BACKEND_REQUESTS_BY_CLIENT.labels(**kwargs).inc()
69+
70+
71+
def record_unique_visitor(*, request, endpoint: str) -> None:
72+
try:
73+
analytics_date = get_analytics_date()
74+
visitor_hash = get_daily_visitor_hash(request, analytics_date=analytics_date)
75+
if visitor_hash is None:
76+
return
77+
78+
visitor_key = f"analytics:unique-visitors:{analytics_date}:{visitor_hash}"
79+
endpoint_visitor_key = (
80+
f"analytics:unique-visitors:{analytics_date}:"
81+
f"endpoint:{endpoint}:{visitor_hash}"
82+
)
83+
84+
if cache.add(visitor_key, "true", timeout=UNIQUE_VISITOR_TTL_SECONDS):
85+
DASHBOARD_UNIQUE_VISITORS_TOTAL.inc()
86+
87+
if cache.add(endpoint_visitor_key, "true", timeout=UNIQUE_VISITOR_TTL_SECONDS):
88+
DASHBOARD_UNIQUE_VISITORS_BY_ENDPOINT_TOTAL.labels(endpoint=endpoint).inc()
89+
except Exception as exc:
90+
logger.debug("Failed to record unique visitor metric: %s", exc)
91+
92+
93+
def get_daily_visitor_hash(request, *, analytics_date: str) -> str | None:
94+
user_agent = request.headers.get("User-Agent", "")
95+
client_ip = get_client_ip(request)
96+
if not client_ip:
97+
return None
98+
99+
daily_salt = get_daily_salt(analytics_date)
100+
if daily_salt is None:
101+
return None
102+
103+
message = f"{client_ip}|{user_agent}".encode()
104+
return hmac.new(daily_salt.encode(), message, hashlib.sha256).hexdigest()
105+
106+
107+
def get_daily_salt(analytics_date: str) -> str | None:
108+
salt_key = f"analytics:unique-visitors:salt:{analytics_date}"
109+
daily_salt = cache.get(salt_key)
110+
if daily_salt is not None:
111+
return daily_salt
112+
113+
candidate_salt = secrets.token_hex(UNIQUE_VISITOR_SALT_BYTES)
114+
cache.add(salt_key, candidate_salt, timeout=UNIQUE_VISITOR_TTL_SECONDS)
115+
116+
daily_salt = cache.get(salt_key)
117+
return daily_salt
118+
119+
120+
def get_analytics_date() -> str:
121+
return datetime.now(UTC).date().isoformat()
122+
123+
124+
def get_client_ip(request) -> str:
125+
forwarded_for = request.headers.get("X-Forwarded-For", "")
126+
if forwarded_for:
127+
return forwarded_for.split(",", maxsplit=1)[0].strip()
128+
129+
return request.META.get("REMOTE_ADDR", "").strip()
130+
131+
132+
def get_backend_request_labels(request, response) -> dict[str, str]:
133+
client_info = get_client_info(request.headers.get("User-Agent", ""))
134+
135+
return {
136+
"endpoint": get_endpoint(request),
137+
"method": request.method.upper(),
138+
"status_class": get_status_class(response.status_code),
139+
"browser": client_info.browser,
140+
"os": client_info.os,
141+
"device": client_info.device,
142+
"referrer_domain": get_referrer_domain(
143+
referrer=request.headers.get("Referer", ""),
144+
request_host=get_request_host(request),
145+
),
146+
}
147+
148+
149+
def get_request_host(request) -> str:
150+
try:
151+
return request.get_host()
152+
except DisallowedHost:
153+
return ""
154+
155+
156+
def get_endpoint(request) -> str:
157+
resolver_match = getattr(request, "resolver_match", None)
158+
url_name = getattr(resolver_match, "url_name", None)
159+
if url_name is not None:
160+
return url_name
161+
return UNKNOWN
162+
163+
164+
def get_status_class(status_code: int) -> str:
165+
if 100 <= status_code <= 599:
166+
return f"{status_code // 100}xx"
167+
return UNKNOWN
168+
169+
170+
def get_referrer_domain(*, referrer: str, request_host: str) -> str:
171+
if not referrer:
172+
return DIRECT_OR_INTERNAL
173+
174+
parsed_referrer = urlparse(referrer)
175+
referrer_host = parsed_referrer.hostname
176+
if referrer_host is None:
177+
return DIRECT_OR_INTERNAL
178+
179+
normalized_referrer = referrer_host.lower()
180+
normalized_request_host = request_host.split(":", maxsplit=1)[0].lower()
181+
if normalized_referrer == normalized_request_host:
182+
return DIRECT_OR_INTERNAL
183+
184+
if normalized_referrer.endswith(f".{normalized_request_host}"):
185+
return DIRECT_OR_INTERNAL
186+
187+
return normalized_referrer[:100]
188+
189+
190+
def get_client_info(user_agent: str) -> ClientInfo:
191+
normalized_user_agent = user_agent.lower()
192+
if not normalized_user_agent:
193+
return ClientInfo(browser=UNKNOWN, os=UNKNOWN, device=UNKNOWN)
194+
195+
if is_bot(normalized_user_agent):
196+
return ClientInfo(browser="bot", os="bot", device="bot")
197+
198+
return ClientInfo(
199+
browser=get_browser(normalized_user_agent),
200+
os=get_os(normalized_user_agent),
201+
device=get_device(normalized_user_agent),
202+
)
203+
204+
205+
def is_bot(normalized_user_agent: str) -> bool:
206+
return bool(
207+
re.search(
208+
r"bot|crawler|spider|slurp|duckduckbot|bingpreview|facebookexternalhit",
209+
normalized_user_agent,
210+
)
211+
)
212+
213+
214+
def get_browser(normalized_user_agent: str) -> str:
215+
if "edg/" in normalized_user_agent:
216+
return "Edge"
217+
if "firefox/" in normalized_user_agent:
218+
return "Firefox"
219+
if "opr/" in normalized_user_agent or "opera" in normalized_user_agent:
220+
return "Opera"
221+
if "chrome/" in normalized_user_agent or "crios/" in normalized_user_agent:
222+
return "Chrome"
223+
if "safari/" in normalized_user_agent:
224+
return "Safari"
225+
if "msie" in normalized_user_agent or "trident/" in normalized_user_agent:
226+
return "Internet Explorer"
227+
if "curl/" in normalized_user_agent or "wget/" in normalized_user_agent:
228+
return "HTTP Client"
229+
if "python-requests/" in normalized_user_agent:
230+
return "HTTP Client"
231+
return UNKNOWN
232+
233+
234+
def get_os(normalized_user_agent: str) -> str:
235+
if "windows nt" in normalized_user_agent:
236+
return "Windows"
237+
if "android" in normalized_user_agent:
238+
return "Android"
239+
if "iphone" in normalized_user_agent or "ipad" in normalized_user_agent:
240+
return "iOS"
241+
if "mac os x" in normalized_user_agent:
242+
return "macOS"
243+
if "cros" in normalized_user_agent:
244+
return "Chrome OS"
245+
if "linux" in normalized_user_agent:
246+
return "Linux"
247+
return UNKNOWN
248+
249+
250+
def get_device(normalized_user_agent: str) -> str:
251+
if "ipad" in normalized_user_agent or "tablet" in normalized_user_agent:
252+
return "tablet"
253+
if "mobile" in normalized_user_agent or "iphone" in normalized_user_agent:
254+
return "mobile"
255+
if "android" in normalized_user_agent:
256+
return "mobile"
257+
return "desktop"

proxy/etc/nginx/templates/default.conf.template

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ server {
66
proxy_send_timeout 240s;
77
send_timeout 240s;
88
}
9+
910
location / {
1011
root /data/static;
1112
try_files $uri /index.html;

0 commit comments

Comments
 (0)