Skip to content

Commit 70a06a7

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 70a06a7

9 files changed

Lines changed: 800 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: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
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 = 25 * 60 * 60 # 25h
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(
68+
*,
69+
endpoint: str,
70+
method: str,
71+
status_class: str,
72+
browser: str,
73+
os: str,
74+
device: str,
75+
referrer_domain: str,
76+
) -> None:
77+
DASHBOARD_BACKEND_REQUESTS_BY_CLIENT.labels(
78+
endpoint=endpoint,
79+
method=method,
80+
status_class=status_class,
81+
browser=browser,
82+
os=os,
83+
device=device,
84+
referrer_domain=referrer_domain,
85+
).inc()
86+
87+
88+
def record_unique_visitor(*, request, endpoint: str) -> None:
89+
try:
90+
analytics_date = get_analytics_date()
91+
visitor_hash = get_daily_visitor_hash(request, analytics_date=analytics_date)
92+
if visitor_hash is None:
93+
return
94+
95+
visitor_key = f"analytics:unique-visitors:{analytics_date}:{visitor_hash}"
96+
endpoint_visitor_key = (
97+
f"analytics:unique-visitors:{analytics_date}:"
98+
f"endpoint:{endpoint}:{visitor_hash}"
99+
)
100+
101+
if cache.add(visitor_key, "true", timeout=UNIQUE_VISITOR_TTL_SECONDS):
102+
DASHBOARD_UNIQUE_VISITORS_TOTAL.inc()
103+
104+
if cache.add(endpoint_visitor_key, "true", timeout=UNIQUE_VISITOR_TTL_SECONDS):
105+
DASHBOARD_UNIQUE_VISITORS_BY_ENDPOINT_TOTAL.labels(endpoint=endpoint).inc()
106+
except Exception as exc:
107+
logger.debug("Failed to record unique visitor metric: %s", exc)
108+
109+
110+
def get_daily_visitor_hash(request, *, analytics_date: str) -> str | None:
111+
user_agent = request.headers.get("User-Agent", "")
112+
client_ip = get_client_ip(request)
113+
if not client_ip:
114+
return None
115+
116+
daily_salt = get_daily_salt(analytics_date)
117+
if daily_salt is None:
118+
return None
119+
120+
message = f"{client_ip}|{user_agent}".encode()
121+
return hmac.new(daily_salt.encode(), message, hashlib.sha256).hexdigest()
122+
123+
124+
def get_daily_salt(analytics_date: str) -> str | None:
125+
salt_key = f"analytics:unique-visitors:salt:{analytics_date}"
126+
daily_salt = cache.get(salt_key)
127+
if daily_salt is not None:
128+
return daily_salt
129+
130+
candidate_salt = secrets.token_hex(UNIQUE_VISITOR_SALT_BYTES)
131+
cache.add(salt_key, candidate_salt, timeout=UNIQUE_VISITOR_TTL_SECONDS)
132+
133+
daily_salt = cache.get(salt_key)
134+
return daily_salt
135+
136+
137+
def get_analytics_date() -> str:
138+
return datetime.now(UTC).date().isoformat()
139+
140+
141+
def get_client_ip(request) -> str:
142+
forwarded_for = request.headers.get("X-Forwarded-For", "")
143+
if forwarded_for:
144+
return forwarded_for.split(",", maxsplit=1)[0].strip()
145+
146+
return request.META.get("REMOTE_ADDR", "").strip()
147+
148+
149+
def get_backend_request_labels(request, response) -> dict[str, str]:
150+
client_info = get_client_info(request.headers.get("User-Agent", ""))
151+
152+
return {
153+
"endpoint": get_endpoint(request),
154+
"method": request.method.upper(),
155+
"status_class": get_status_class(response.status_code),
156+
"browser": client_info.browser,
157+
"os": client_info.os,
158+
"device": client_info.device,
159+
"referrer_domain": get_referrer_domain(
160+
referrer=request.headers.get("Referer", ""),
161+
request_host=get_request_host(request),
162+
),
163+
}
164+
165+
166+
def get_request_host(request) -> str:
167+
try:
168+
return request.get_host()
169+
except DisallowedHost:
170+
return ""
171+
172+
173+
def get_endpoint(request) -> str:
174+
resolver_match = getattr(request, "resolver_match", None)
175+
url_name = getattr(resolver_match, "url_name", None)
176+
if url_name is not None:
177+
return url_name
178+
return UNKNOWN
179+
180+
181+
def get_status_class(status_code: int) -> str:
182+
if 100 <= status_code <= 599:
183+
return f"{status_code // 100}xx"
184+
return UNKNOWN
185+
186+
187+
def get_referrer_domain(*, referrer: str, request_host: str) -> str:
188+
if not referrer:
189+
return DIRECT_OR_INTERNAL
190+
191+
parsed_referrer = urlparse(referrer)
192+
referrer_host = parsed_referrer.hostname
193+
if referrer_host is None:
194+
return DIRECT_OR_INTERNAL
195+
196+
normalized_referrer = referrer_host.lower()
197+
normalized_request_host = request_host.split(":", maxsplit=1)[0].lower()
198+
if normalized_referrer == normalized_request_host:
199+
return DIRECT_OR_INTERNAL
200+
201+
if normalized_referrer.endswith(f".{normalized_request_host}"):
202+
return DIRECT_OR_INTERNAL
203+
204+
return normalized_referrer[:100]
205+
206+
207+
def get_client_info(user_agent: str) -> ClientInfo:
208+
normalized_user_agent = user_agent.lower()
209+
if not normalized_user_agent:
210+
return ClientInfo(browser=UNKNOWN, os=UNKNOWN, device=UNKNOWN)
211+
212+
if is_bot(normalized_user_agent):
213+
return ClientInfo(browser="bot", os="bot", device="bot")
214+
215+
return ClientInfo(
216+
browser=get_browser(normalized_user_agent),
217+
os=get_os(normalized_user_agent),
218+
device=get_device(normalized_user_agent),
219+
)
220+
221+
222+
def is_bot(normalized_user_agent: str) -> bool:
223+
return bool(
224+
re.search(
225+
r"bot|crawler|spider|slurp|duckduckbot|bingpreview|facebookexternalhit",
226+
normalized_user_agent,
227+
)
228+
)
229+
230+
231+
def get_browser(normalized_user_agent: str) -> str:
232+
if "edg/" in normalized_user_agent:
233+
return "Edge"
234+
if "firefox/" in normalized_user_agent:
235+
return "Firefox"
236+
if "opr/" in normalized_user_agent or "opera" in normalized_user_agent:
237+
return "Opera"
238+
if "chrome/" in normalized_user_agent or "crios/" in normalized_user_agent:
239+
return "Chrome"
240+
if "safari/" in normalized_user_agent:
241+
return "Safari"
242+
if "msie" in normalized_user_agent or "trident/" in normalized_user_agent:
243+
return "Internet Explorer"
244+
if "curl/" in normalized_user_agent or "wget/" in normalized_user_agent:
245+
return "HTTP Client"
246+
if "python-requests/" in normalized_user_agent:
247+
return "HTTP Client"
248+
return UNKNOWN
249+
250+
251+
def get_os(normalized_user_agent: str) -> str:
252+
if "windows nt" in normalized_user_agent:
253+
return "Windows"
254+
if "android" in normalized_user_agent:
255+
return "Android"
256+
if "iphone" in normalized_user_agent or "ipad" in normalized_user_agent:
257+
return "iOS"
258+
if "mac os x" in normalized_user_agent:
259+
return "macOS"
260+
if "cros" in normalized_user_agent:
261+
return "Chrome OS"
262+
if "linux" in normalized_user_agent:
263+
return "Linux"
264+
return UNKNOWN
265+
266+
267+
def get_device(normalized_user_agent: str) -> str:
268+
if "ipad" in normalized_user_agent or "tablet" in normalized_user_agent:
269+
return "tablet"
270+
if "mobile" in normalized_user_agent or "iphone" in normalized_user_agent:
271+
return "mobile"
272+
if "android" in normalized_user_agent:
273+
return "mobile"
274+
return "desktop"

docker-compose-next.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ services:
3636
redis:
3737
image: redis:8.0-M04-alpine
3838
restart: always
39+
command: ["redis-server", "--maxmemory-policy", "noeviction"]
3940
networks:
4041
- private
4142

docker-compose.dev.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ services:
5050

5151
redis:
5252
image: redis:8.0-M04-alpine
53+
command: ["redis-server", "--maxmemory-policy", "noeviction"]
5354
networks: [private]
5455

5556
dashboard_dev:

docker-compose.k6.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ services:
3030
redis:
3131
image: redis:8.0-M04-alpine
3232
restart: always
33+
command: ["redis-server", "--maxmemory-policy", "noeviction"]
3334
networks:
3435
- private
3536
ports:

docker-compose.test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ services:
2323
redis:
2424
image: redis:8.0-M04-alpine
2525
restart: always
26+
command: ["redis-server", "--maxmemory-policy", "noeviction"]
2627
networks:
2728
- private
2829
ports:

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ services:
107107
redis:
108108
image: redis:8.0-M04-alpine
109109
restart: always
110+
command: ["redis-server", "--maxmemory-policy", "noeviction"]
110111
networks:
111112
- private
112113

0 commit comments

Comments
 (0)