Skip to content

Commit ee0f4ce

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 ee0f4ce

9 files changed

Lines changed: 870 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: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
"""Privacy-preserving client analytics for ``/api/`` requests.
2+
3+
This middleware records anonymous, aggregate usage metrics. No personal data
4+
(raw IP, raw User-Agent, full referrer URL) is stored or exposed as a metric
5+
label.
6+
7+
Collected as aggregate Prometheus counters only:
8+
* Request attributes: endpoint, method, status_class, and coarse client
9+
buckets (browser, os, device) derived from the User-Agent. Referrer is
10+
reduced to its external domain (or ``direct_or_internal``).
11+
* Daily unique-visitor estimates (total and per-endpoint).
12+
13+
Visitor anonymization: a fingerprint is computed as
14+
``HMAC-SHA256(daily_salt, "<ip>|<user_agent>")``. The ``daily_salt`` is a
15+
random 32-byte secret generated per UTC day, kept only in the cache with a
16+
~25h TTL, and rotated daily so hashes cannot be linked across days. Only the
17+
hash is used as a de-duplication cache key; raw IP/User-Agent are discarded
18+
immediately and never persisted.
19+
20+
See ``docs/monitoring.md`` ("Client Analytics & Privacy") for full details and
21+
compliance notes.
22+
"""
23+
24+
import hashlib
25+
import hmac
26+
import logging
27+
import re
28+
import secrets
29+
from dataclasses import dataclass
30+
from datetime import UTC, datetime
31+
from urllib.parse import urlparse
32+
33+
from django.core.cache import cache
34+
from django.core.exceptions import DisallowedHost
35+
from prometheus_client import Counter
36+
37+
UNKNOWN = "unknown"
38+
DIRECT_OR_INTERNAL = "direct_or_internal"
39+
UNIQUE_VISITOR_TTL_SECONDS = 25 * 60 * 60 # 25h
40+
UNIQUE_VISITOR_SALT_BYTES = 32
41+
42+
logger = logging.getLogger(__name__)
43+
44+
DASHBOARD_BACKEND_REQUESTS_BY_CLIENT = Counter(
45+
"dashboard_backend_requests_by_client_total",
46+
"Backend requests grouped by endpoint and client attributes",
47+
[
48+
"endpoint",
49+
"method",
50+
"status_class",
51+
"browser",
52+
"os",
53+
"device",
54+
"referrer_domain",
55+
],
56+
)
57+
58+
DASHBOARD_UNIQUE_VISITORS_TOTAL = Counter(
59+
"dashboard_unique_visitors_total",
60+
"Daily unique backend visitors",
61+
)
62+
63+
DASHBOARD_UNIQUE_VISITORS_BY_ENDPOINT_TOTAL = Counter(
64+
"dashboard_unique_visitors_by_endpoint_total",
65+
"Daily unique backend visitors deduplicated per endpoint by rotated Redis salt",
66+
["endpoint"],
67+
)
68+
69+
70+
@dataclass(frozen=True)
71+
class ClientInfo:
72+
browser: str
73+
os: str
74+
device: str
75+
76+
77+
class BackendRequestMetricsMiddleware:
78+
def __init__(self, get_response):
79+
self.get_response = get_response
80+
81+
def __call__(self, request):
82+
response = self.get_response(request)
83+
if request.path.startswith("/api/"):
84+
labels = get_backend_request_labels(request, response)
85+
record_client(**labels)
86+
record_unique_visitor(request=request, endpoint=labels["endpoint"])
87+
return response
88+
89+
90+
def record_client(
91+
*,
92+
endpoint: str,
93+
method: str,
94+
status_class: str,
95+
browser: str,
96+
os: str,
97+
device: str,
98+
referrer_domain: str,
99+
) -> None:
100+
DASHBOARD_BACKEND_REQUESTS_BY_CLIENT.labels(
101+
endpoint=endpoint,
102+
method=method,
103+
status_class=status_class,
104+
browser=browser,
105+
os=os,
106+
device=device,
107+
referrer_domain=referrer_domain,
108+
).inc()
109+
110+
111+
def record_unique_visitor(*, request, endpoint: str) -> None:
112+
try:
113+
analytics_date = get_analytics_date()
114+
visitor_hash = get_daily_visitor_hash(request, analytics_date=analytics_date)
115+
if visitor_hash is None:
116+
return
117+
118+
visitor_key = f"analytics:unique-visitors:{analytics_date}:{visitor_hash}"
119+
endpoint_visitor_key = (
120+
f"analytics:unique-visitors:{analytics_date}:"
121+
f"endpoint:{endpoint}:{visitor_hash}"
122+
)
123+
124+
if cache.add(visitor_key, "true", timeout=UNIQUE_VISITOR_TTL_SECONDS):
125+
DASHBOARD_UNIQUE_VISITORS_TOTAL.inc()
126+
127+
if cache.add(endpoint_visitor_key, "true", timeout=UNIQUE_VISITOR_TTL_SECONDS):
128+
DASHBOARD_UNIQUE_VISITORS_BY_ENDPOINT_TOTAL.labels(endpoint=endpoint).inc()
129+
except Exception as exc:
130+
logger.debug("Failed to record unique visitor metric: %s", exc)
131+
132+
133+
def get_daily_visitor_hash(request, *, analytics_date: str) -> str | None:
134+
user_agent = request.headers.get("User-Agent", "")
135+
client_ip = get_client_ip(request)
136+
if not client_ip:
137+
return None
138+
139+
daily_salt = get_daily_salt(analytics_date)
140+
if daily_salt is None:
141+
return None
142+
143+
message = f"{client_ip}|{user_agent}".encode()
144+
return hmac.new(daily_salt.encode(), message, hashlib.sha256).hexdigest()
145+
146+
147+
def get_daily_salt(analytics_date: str) -> str | None:
148+
salt_key = f"analytics:unique-visitors:salt:{analytics_date}"
149+
daily_salt = cache.get(salt_key)
150+
if daily_salt is not None:
151+
return daily_salt
152+
153+
candidate_salt = secrets.token_hex(UNIQUE_VISITOR_SALT_BYTES)
154+
cache.add(salt_key, candidate_salt, timeout=UNIQUE_VISITOR_TTL_SECONDS)
155+
156+
daily_salt = cache.get(salt_key)
157+
return daily_salt
158+
159+
160+
def get_analytics_date() -> str:
161+
return datetime.now(UTC).date().isoformat()
162+
163+
164+
def get_client_ip(request) -> str:
165+
forwarded_for = request.headers.get("X-Forwarded-For", "")
166+
if forwarded_for:
167+
return forwarded_for.split(",", maxsplit=1)[0].strip()
168+
169+
return request.META.get("REMOTE_ADDR", "").strip()
170+
171+
172+
def get_backend_request_labels(request, response) -> dict[str, str]:
173+
client_info = get_client_info(request.headers.get("User-Agent", ""))
174+
175+
return {
176+
"endpoint": get_endpoint(request),
177+
"method": request.method.upper(),
178+
"status_class": get_status_class(response.status_code),
179+
"browser": client_info.browser,
180+
"os": client_info.os,
181+
"device": client_info.device,
182+
"referrer_domain": get_referrer_domain(
183+
referrer=request.headers.get("Referer", ""),
184+
request_host=get_request_host(request),
185+
),
186+
}
187+
188+
189+
def get_request_host(request) -> str:
190+
try:
191+
return request.get_host()
192+
except DisallowedHost:
193+
return ""
194+
195+
196+
def get_endpoint(request) -> str:
197+
resolver_match = getattr(request, "resolver_match", None)
198+
url_name = getattr(resolver_match, "url_name", None)
199+
if url_name is not None:
200+
return url_name
201+
return UNKNOWN
202+
203+
204+
def get_status_class(status_code: int) -> str:
205+
if 100 <= status_code <= 599:
206+
return f"{status_code // 100}xx"
207+
return UNKNOWN
208+
209+
210+
def get_referrer_domain(*, referrer: str, request_host: str) -> str:
211+
if not referrer:
212+
return DIRECT_OR_INTERNAL
213+
214+
parsed_referrer = urlparse(referrer)
215+
referrer_host = parsed_referrer.hostname
216+
if referrer_host is None:
217+
return DIRECT_OR_INTERNAL
218+
219+
normalized_referrer = referrer_host.lower()
220+
normalized_request_host = request_host.split(":", maxsplit=1)[0].lower()
221+
if normalized_referrer == normalized_request_host:
222+
return DIRECT_OR_INTERNAL
223+
224+
if normalized_referrer.endswith(f".{normalized_request_host}"):
225+
return DIRECT_OR_INTERNAL
226+
227+
return normalized_referrer[:100]
228+
229+
230+
def get_client_info(user_agent: str) -> ClientInfo:
231+
normalized_user_agent = user_agent.lower()
232+
if not normalized_user_agent:
233+
return ClientInfo(browser=UNKNOWN, os=UNKNOWN, device=UNKNOWN)
234+
235+
if is_bot(normalized_user_agent):
236+
return ClientInfo(browser="bot", os="bot", device="bot")
237+
238+
return ClientInfo(
239+
browser=get_browser(normalized_user_agent),
240+
os=get_os(normalized_user_agent),
241+
device=get_device(normalized_user_agent),
242+
)
243+
244+
245+
def is_bot(normalized_user_agent: str) -> bool:
246+
return bool(
247+
re.search(
248+
r"bot|crawler|spider|slurp|duckduckbot|bingpreview|facebookexternalhit",
249+
normalized_user_agent,
250+
)
251+
)
252+
253+
254+
def get_browser(normalized_user_agent: str) -> str:
255+
if "edg/" in normalized_user_agent:
256+
return "Edge"
257+
if "firefox/" in normalized_user_agent:
258+
return "Firefox"
259+
if "opr/" in normalized_user_agent or "opera" in normalized_user_agent:
260+
return "Opera"
261+
if "chrome/" in normalized_user_agent or "crios/" in normalized_user_agent:
262+
return "Chrome"
263+
if "safari/" in normalized_user_agent:
264+
return "Safari"
265+
if "msie" in normalized_user_agent or "trident/" in normalized_user_agent:
266+
return "Internet Explorer"
267+
if "curl/" in normalized_user_agent or "wget/" in normalized_user_agent:
268+
return "HTTP Client"
269+
if "python-requests/" in normalized_user_agent:
270+
return "HTTP Client"
271+
return UNKNOWN
272+
273+
274+
def get_os(normalized_user_agent: str) -> str:
275+
if "windows nt" in normalized_user_agent:
276+
return "Windows"
277+
if "android" in normalized_user_agent:
278+
return "Android"
279+
if "iphone" in normalized_user_agent or "ipad" in normalized_user_agent:
280+
return "iOS"
281+
if "mac os x" in normalized_user_agent:
282+
return "macOS"
283+
if "cros" in normalized_user_agent:
284+
return "Chrome OS"
285+
if "linux" in normalized_user_agent:
286+
return "Linux"
287+
return UNKNOWN
288+
289+
290+
def get_device(normalized_user_agent: str) -> str:
291+
if "ipad" in normalized_user_agent or "tablet" in normalized_user_agent:
292+
return "tablet"
293+
if "mobile" in normalized_user_agent or "iphone" in normalized_user_agent:
294+
return "mobile"
295+
if "android" in normalized_user_agent:
296+
return "mobile"
297+
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

docs/monitoring.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,51 @@ Configure these variables in `.env.backend`:
118118
- **Target**: `host.docker.internal:8001` (backend running locally)
119119
- **Metrics Path**: `/metrics/`
120120
- **Scrape Interval**: 15 seconds
121+
122+
## Client Analytics & Privacy
123+
124+
The `BackendRequestMetricsMiddleware` records anonymous, aggregate usage
125+
analytics for requests to `/api/`. It is designed to be privacy-preserving:
126+
**no personal data (raw IP, raw User-Agent, full referrer URL) is ever stored
127+
or exposed as a metric.**
128+
129+
### What is collected
130+
131+
All data below is exposed only as aggregate Prometheus counters (no per-user
132+
records, no timestamps per user):
133+
134+
- **Request attributes** (`dashboard_backend_requests_by_client_total`):
135+
- `endpoint` (Django URL name), `method`, `status_class` (e.g. `2xx`)
136+
- `browser`, `os`, `device` — coarse buckets derived from the User-Agent
137+
(e.g. `Chrome`, `Linux`, `desktop`). Bots are bucketed as `bot`.
138+
- `referrer_domain` — only the external domain of the `Referer` header,
139+
truncated to 100 chars. Internal/same-host referrers become
140+
`direct_or_internal`.
141+
- **Unique visitor estimates** (`dashboard_unique_visitors_total`,
142+
`dashboard_unique_visitors_by_endpoint_total`) — daily de-duplicated counts.
143+
144+
### How visitors are anonymized
145+
146+
Unique visitors are estimated **without storing any identifier**:
147+
148+
- A visitor fingerprint is computed as
149+
`HMAC-SHA256(daily_salt, "<client_ip>|<user_agent>")`.
150+
- The `daily_salt` is a random 32-byte secret generated per UTC day and stored
151+
only in the cache (Redis) with a ~25h TTL. **It is never persisted to disk
152+
and rotates daily**, so hashes from different days cannot be linked.
153+
- Only the resulting hash is used as a cache key for de-duplication
154+
(`cache.add` with ~25h TTL). The raw IP and User-Agent are never written to
155+
the cache or to any metric label.
156+
- Because the salt is secret and rotated, the hashes are not reversible to an
157+
IP/UA across days, and the raw inputs are discarded immediately after hashing.
158+
159+
### Compliance notes
160+
161+
- No raw personal data is stored; only irreversible daily-rotated hashes are
162+
used transiently for counting, and only aggregate counters are persisted.
163+
- Retention is bounded by the cache TTL (~25h); hashes and salts expire
164+
automatically.
165+
- **Operational note**: while the daily salt lives in the cache, an attacker
166+
with cache access could in principle brute-force the limited IP+UA space for
167+
that day. The salt being secret + daily rotation mitigates cross-day linkage.
168+
Treat cache access as sensitive.

0 commit comments

Comments
 (0)