From c84f8e1f1ea55ba57ada6c42de89b941c4130315 Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Tue, 9 Jun 2026 16:53:56 -0300 Subject: [PATCH] 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) * Add PRIVACY.md file Signed-off-by: Alan Peixinho --- PRIVACY.md | 93 ++++ backend/kernelCI/settings.py | 3 +- backend/kernelCI_app/middleware/__init__.py | 8 + .../backendRequestMetricsMiddleware.py | 316 +++++++++++ docker-compose-next.yml | 1 + docker-compose.dev.yml | 1 + docker-compose.k6.yml | 1 + docker-compose.test.yml | 1 + docker-compose.yml | 1 + docs/monitoring.md | 33 ++ monitoring/dashboard.json | 519 ++++++++++++++++++ 11 files changed, 976 insertions(+), 1 deletion(-) create mode 100644 PRIVACY.md create mode 100644 backend/kernelCI_app/middleware/__init__.py create mode 100644 backend/kernelCI_app/middleware/backendRequestMetricsMiddleware.py diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 000000000..bc50aa5ca --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,93 @@ +# Privacy Policy + +This Privacy Policy explains what data the KernelCI Dashboard collects when you +use it, why, and how we protect it. It is written for users of the service. +Operators deploying the Dashboard are responsible for keeping this document +accurate for their own instance and for surfacing it to their users. + +## Summary + +The KernelCI Dashboard collects **anonymous, aggregate usage analytics** only. +We do **not** build user profiles, do **not** sell data, and do **not** store +any personal identifier. + +## What we collect + +When you make requests to the Dashboard API (`/api/`), the backend records +aggregate usage metrics. These are stored only as counts (Prometheus counters), +never as per-user records or per-user timestamps: + +- **Request attributes**: the API endpoint name, HTTP method, response status + class (e.g. `2xx`), and coarse client buckets derived from your browser's + User-Agent (e.g. browser family `Chrome`, operating system `Linux`, device + type `desktop`). Automated clients are bucketed as `bot`. +- **Referrer domain**: only the external domain that linked you to the + Dashboard (e.g. `example.org`). Same-site and direct visits are recorded as + `direct_or_internal`. The full referring URL is never stored. +- **Unique visitor estimates**: daily de-duplicated visit counts, in total and + per endpoint. + +## What we do NOT collect or store + +- Your raw IP address. +- Your raw, full User-Agent string. +- The full referrer URL or any query parameters. +- Any account, name, email, or other directly identifying information. +- Any cross-day or long-term tracking identifier. + +## How unique visitors are counted + +To estimate unique visitors without identifying you, the backend: + +1. Computes a one-way fingerprint as + `HMAC-SHA256(daily_salt, "|")`. +2. Uses a `daily_salt` — a random 256-bit secret generated fresh each UTC day, + held only in an ephemeral cache (Redis/memcached) with a ~25 hour lifetime. +3. Uses only the resulting hash as a short-lived de-duplication key. Your raw + IP and User-Agent are discarded immediately after the hash is computed and + are never written to any metric or to disk. + +Because the salt is secret and rotates every day, fingerprints cannot be linked +across days, and the hash cannot be reversed back to your IP/User-Agent in +practice. + +## Legal basis and retention + +- **Purpose**: understanding aggregate usage and load to operate and improve + the service. +- **Legal basis** (where GDPR applies): legitimate interest in operating and + improving the service, using the most privacy-preserving design we can. +- **Retention**: the transient daily fingerprints and salt expire automatically + within ~25 hours. Only anonymous aggregate counters are retained beyond that; + these contain no personal data. + +## Your rights + +Because we retain only anonymous, aggregate counts and hold no identifier that +can single you out beyond a single day, we cannot link any stored data to a +specific person and therefore cannot act on individual access/deletion requests +against the aggregate metrics. If you have questions about privacy on a specific +deployment, contact that deployment's operator. + +## Data controller and contact + +For the upstream instance at +[dashboard.kernelci.org](https://dashboard.kernelci.org), the data controller +is **The Linux Foundation**. For privacy inquiries contact +[privacy@linuxfoundation.org](mailto:privacy@linuxfoundation.org), or write to: + +> The Linux Foundation, Attn: Legal Department, 548 Market St, PMB 57274, +> San Francisco, California 94104-5401, USA. + +Third-party deployments are controlled by their respective operators, who are +responsible for providing their own contact details. + +## Changes + +We may update this policy as the service evolves. Changes are tracked in the +repository's git history. + +## Technical reference + +For implementation details, see +[`docs/monitoring.md`](docs/monitoring.md#client-analytics). diff --git a/backend/kernelCI/settings.py b/backend/kernelCI/settings.py index 6d348c2cf..c1b77dce8 100644 --- a/backend/kernelCI/settings.py +++ b/backend/kernelCI/settings.py @@ -88,7 +88,8 @@ def get_json_env_var(name, default): "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "kernelCI_app.middleware.logServerErrorMiddleware.LogServerErrorMiddleware", + "kernelCI_app.middleware.LogServerErrorMiddleware", + "kernelCI_app.middleware.BackendRequestMetricsMiddleware", "django_prometheus.middleware.PrometheusAfterMiddleware", ] diff --git a/backend/kernelCI_app/middleware/__init__.py b/backend/kernelCI_app/middleware/__init__.py new file mode 100644 index 000000000..f7fa568f5 --- /dev/null +++ b/backend/kernelCI_app/middleware/__init__.py @@ -0,0 +1,8 @@ +__all__ = ["LogServerErrorMiddleware", "BackendRequestMetricsMiddleware"] + +from .backendRequestMetricsMiddleware import ( + BackendRequestMetricsMiddleware as BackendRequestMetricsMiddleware, +) +from .logServerErrorMiddleware import ( + LogServerErrorMiddleware as LogServerErrorMiddleware, +) diff --git a/backend/kernelCI_app/middleware/backendRequestMetricsMiddleware.py b/backend/kernelCI_app/middleware/backendRequestMetricsMiddleware.py new file mode 100644 index 000000000..6e25f5e69 --- /dev/null +++ b/backend/kernelCI_app/middleware/backendRequestMetricsMiddleware.py @@ -0,0 +1,316 @@ +"""Privacy-preserving client analytics for ``/api/`` requests. + +This middleware records anonymous, aggregate usage metrics. No personal data +(raw IP, raw User-Agent, full referrer URL) is stored or exposed as a metric +label. + +Collected as aggregate Prometheus counters only: + * Request attributes: endpoint, method, status_class, and coarse client + buckets (browser, os, device) derived from the User-Agent. Referrer is + reduced to its external domain (or ``direct_or_internal``). + * Daily unique-visitor estimates (total and per-endpoint). + +Visitor anonymization: a fingerprint is computed as +``HMAC-SHA256(daily_salt, "|")``. The ``daily_salt`` is a +random 32-byte secret generated per UTC day, kept only in the cache with a +~25h TTL, and rotated daily so hashes cannot be linked across days. Only the +hash is used as a de-duplication cache key; raw IP/User-Agent are discarded +immediately and never persisted. + +See ``docs/monitoring.md`` ("Client Analytics & Privacy") for full details and +compliance notes. +""" + +import hashlib +import hmac +import logging +import re +import secrets +from dataclasses import dataclass +from datetime import UTC, datetime +from urllib.parse import urlparse + +from django.core.cache import cache +from django.core.exceptions import DisallowedHost +from prometheus_client import Counter + +UNKNOWN = "unknown" +DIRECT_OR_INTERNAL = "direct_or_internal" +UNIQUE_VISITOR_TTL_SECONDS = 25 * 60 * 60 # 25h +UNIQUE_VISITOR_SALT_BYTES = 32 + +logger = logging.getLogger(__name__) + +DASHBOARD_BACKEND_REQUESTS_BY_CLIENT = Counter( + "dashboard_backend_requests_by_client_total", + "Backend requests grouped by endpoint and client attributes", + [ + "endpoint", + "method", + "status_class", + "browser", + "os", + "device", + "referrer_domain", + ], +) + +DASHBOARD_UNIQUE_VISITORS_TOTAL = Counter( + "dashboard_unique_visitors_total", + "Daily unique backend visitors", +) + +DASHBOARD_UNIQUE_VISITORS_BY_ENDPOINT_TOTAL = Counter( + "dashboard_unique_visitors_by_endpoint_total", + "Daily unique backend visitors deduplicated per endpoint by rotated Redis salt", + ["endpoint"], +) + + +@dataclass(frozen=True) +class ClientInfo: + browser: str + os: str + device: str + + +class BackendRequestMetricsMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + if request.path.startswith("/api/"): + labels = get_backend_request_labels(request, response) + record_client(**labels) + record_unique_visitor(request=request, endpoint=labels["endpoint"]) + return response + + +def record_client( + *, + endpoint: str, + method: str, + status_class: str, + browser: str, + os: str, + device: str, + referrer_domain: str, +) -> None: + DASHBOARD_BACKEND_REQUESTS_BY_CLIENT.labels( + endpoint=endpoint, + method=method, + status_class=status_class, + browser=browser, + os=os, + device=device, + referrer_domain=referrer_domain, + ).inc() + + +def record_unique_visitor(*, request, endpoint: str) -> None: + try: + analytics_date = get_analytics_date() + visitor_hash = get_daily_visitor_hash(request, analytics_date=analytics_date) + if visitor_hash is None: + return + + visitor_key = f"analytics:unique-visitors:{analytics_date}:{visitor_hash}" + endpoint_visitor_key = ( + f"analytics:unique-visitors:{analytics_date}:" + f"endpoint:{endpoint}:{visitor_hash}" + ) + + if cache.add(visitor_key, "true", timeout=UNIQUE_VISITOR_TTL_SECONDS): + DASHBOARD_UNIQUE_VISITORS_TOTAL.inc() + + if cache.add(endpoint_visitor_key, "true", timeout=UNIQUE_VISITOR_TTL_SECONDS): + DASHBOARD_UNIQUE_VISITORS_BY_ENDPOINT_TOTAL.labels(endpoint=endpoint).inc() + except Exception as exc: + logger.debug("Failed to record unique visitor metric: %s", exc) + + +def get_daily_visitor_hash(request, *, analytics_date: str) -> str | None: + user_agent = request.headers.get("User-Agent", "") + client_ip = get_client_ip(request) + if not client_ip: + return None + + daily_salt = get_daily_salt(analytics_date) + if daily_salt is None: + return None + + message = f"{client_ip}|{user_agent}".encode() + return hmac.new(daily_salt.encode(), message, hashlib.sha256).hexdigest() + + +def get_daily_salt(analytics_date: str) -> str | None: + salt_key = f"analytics:unique-visitors:salt:{analytics_date}" + daily_salt = cache.get(salt_key) + if daily_salt is not None: + return daily_salt + + candidate_salt = secrets.token_hex(UNIQUE_VISITOR_SALT_BYTES) + cache.add(salt_key, candidate_salt, timeout=UNIQUE_VISITOR_TTL_SECONDS) + + daily_salt = cache.get(salt_key) + return daily_salt + + +def get_analytics_date() -> str: + return datetime.now(UTC).date().isoformat() + + +def get_client_ip(request) -> str: + forwarded_for = request.headers.get("X-Forwarded-For", "") + if forwarded_for: + return forwarded_for.split(",", maxsplit=1)[0].strip() + + forwarded = request.headers.get("Forwarded", "") + if forwarded: + forwarded_ip = parse_forwarded_for(forwarded) + if forwarded_ip: + return forwarded_ip + + return request.META.get("REMOTE_ADDR", "").strip() + + +def parse_forwarded_for(forwarded: str) -> str: + first_hop = forwarded.split(",", maxsplit=1)[0] + for part in first_hop.split(";"): + key, _, value = part.strip().partition("=") + if key.lower() == "for": + return normalize_forwarded_node(value.strip().strip('"')) + return "" + + +def normalize_forwarded_node(node: str) -> str: + if node.startswith("["): # IPV6 [2001:db8::1]:8080 + return node[1 : node.find("]")] if "]" in node else node[1:] + if node.count(":") == 1: # IPV4 192.0.2.60:8080 + return node.split(":", maxsplit=1)[0] + return node + + +def get_backend_request_labels(request, response) -> dict[str, str]: + client_info = get_client_info(request.headers.get("User-Agent", "")) + + return { + "endpoint": get_endpoint(request), + "method": request.method.upper(), + "status_class": get_status_class(response.status_code), + "browser": client_info.browser, + "os": client_info.os, + "device": client_info.device, + "referrer_domain": get_referrer_domain( + referrer=request.headers.get("Referer", ""), + request_host=get_request_host(request), + ), + } + + +def get_request_host(request) -> str: + try: + return request.get_host() + except DisallowedHost: + return UNKNOWN + + +def get_endpoint(request) -> str: + resolver_match = getattr(request, "resolver_match", None) + url_name = getattr(resolver_match, "url_name", None) + if url_name is not None: + return url_name + return UNKNOWN + + +def get_status_class(status_code: int) -> str: + if 100 <= status_code <= 599: + return f"{status_code // 100}xx" + return UNKNOWN + + +def get_referrer_domain(*, referrer: str, request_host: str) -> str: + if not referrer: + return DIRECT_OR_INTERNAL + + parsed_referrer = urlparse(referrer) + referrer_host = parsed_referrer.hostname + if referrer_host is None: + return DIRECT_OR_INTERNAL + + normalized_referrer = referrer_host.lower() + normalized_request_host = request_host.split(":", maxsplit=1)[0].lower() + if normalized_referrer == normalized_request_host: + return DIRECT_OR_INTERNAL + + if normalized_referrer.endswith(f".{normalized_request_host}"): + return DIRECT_OR_INTERNAL + + return normalized_referrer[:100] + + +def get_client_info(user_agent: str) -> ClientInfo: + normalized_user_agent = user_agent.lower() + if not normalized_user_agent: + return ClientInfo(browser=UNKNOWN, os=UNKNOWN, device=UNKNOWN) + + if is_bot(normalized_user_agent): + return ClientInfo(browser="bot", os="bot", device="bot") + + return ClientInfo( + browser=get_browser(normalized_user_agent), + os=get_os(normalized_user_agent), + device=get_device(normalized_user_agent), + ) + + +def is_bot(normalized_user_agent: str) -> bool: + return bool( + re.search( + r"bot|crawler|spider|slurp|duckduckbot|bingpreview|facebookexternalhit", + normalized_user_agent, + ) + ) + + +def get_browser(normalized_user_agent: str) -> str: + if "edg/" in normalized_user_agent: + return "Edge" + if "firefox/" in normalized_user_agent: + return "Firefox" + if any(s in normalized_user_agent for s in ["opr/", "opera"]): + return "Opera" + if any(s in normalized_user_agent for s in ["chrome/", "crios/"]): + return "Chrome" + if "safari/" in normalized_user_agent: + return "Safari" + if any(s in normalized_user_agent for s in ["msie", "trident/"]): + return "Internet Explorer" + if any(s in normalized_user_agent for s in ["curl/", "wget/", "python-requests/"]): + return "HTTP Client" + return UNKNOWN + + +def get_os(normalized_user_agent: str) -> str: + if "windows nt" in normalized_user_agent: + return "Windows" + if "android" in normalized_user_agent: + return "Android" + if "iphone" in normalized_user_agent or "ipad" in normalized_user_agent: + return "iOS" + if "mac os x" in normalized_user_agent: + return "macOS" + if "cros" in normalized_user_agent: + return "Chrome OS" + if "linux" in normalized_user_agent: + return "Linux" + return UNKNOWN + + +def get_device(normalized_user_agent: str) -> str: + if any(s in normalized_user_agent for s in ["ipad", "tablet"]): + return "tablet" + if any(s in normalized_user_agent for s in ["mobile", "iphone", "android"]): + return "mobile" + return "desktop" diff --git a/docker-compose-next.yml b/docker-compose-next.yml index 267a68ed8..ccfd96898 100644 --- a/docker-compose-next.yml +++ b/docker-compose-next.yml @@ -36,6 +36,7 @@ services: redis: image: redis:8.0-M04-alpine restart: always + command: ["redis-server", "--maxmemory-policy", "noeviction"] networks: - private diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2ee86d6e6..5f08d2fea 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -50,6 +50,7 @@ services: redis: image: redis:8.0-M04-alpine + command: ["redis-server", "--maxmemory-policy", "noeviction"] networks: [private] dashboard_dev: diff --git a/docker-compose.k6.yml b/docker-compose.k6.yml index 37cb56cb9..c8b5e2a51 100644 --- a/docker-compose.k6.yml +++ b/docker-compose.k6.yml @@ -30,6 +30,7 @@ services: redis: image: redis:8.0-M04-alpine restart: always + command: ["redis-server", "--maxmemory-policy", "noeviction"] networks: - private ports: diff --git a/docker-compose.test.yml b/docker-compose.test.yml index e24eca5f3..d476bd6e3 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -23,6 +23,7 @@ services: redis: image: redis:8.0-M04-alpine restart: always + command: ["redis-server", "--maxmemory-policy", "noeviction"] networks: - private ports: diff --git a/docker-compose.yml b/docker-compose.yml index 84c7a7905..d7d07b1de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -107,6 +107,7 @@ services: redis: image: redis:8.0-M04-alpine restart: always + command: ["redis-server", "--maxmemory-policy", "noeviction"] networks: - private diff --git a/docs/monitoring.md b/docs/monitoring.md index d134ea1f0..30efc17b4 100644 --- a/docs/monitoring.md +++ b/docs/monitoring.md @@ -118,3 +118,36 @@ Configure these variables in `.env.backend`: - **Target**: `host.docker.internal:8001` (backend running locally) - **Metrics Path**: `/metrics/` - **Scrape Interval**: 15 seconds + +## Client Analytics + +The `BackendRequestMetricsMiddleware` records anonymous, aggregate usage +analytics for requests to `/api/`. For the user-facing privacy statement (what +is collected and why), see [`PRIVACY.md`](../PRIVACY.md). This section is the +technical/operator reference. + +### Metrics emitted + +- `dashboard_backend_requests_by_client_total` — labels: `endpoint` (Django URL + name), `method`, `status_class` (e.g. `2xx`), `browser`, `os`, `device` + (coarse User-Agent buckets; bots bucketed as `bot`), `referrer_domain` (the + external `Referer` domain truncated to 100 chars; same-host/direct becomes + `direct_or_internal`). +- `dashboard_unique_visitors_total`, `dashboard_unique_visitors_by_endpoint_total` + — daily de-duplicated visitor counts. + +### Anonymization mechanism + +Unique visitors are de-duplicated without storing any identifier: + +- Fingerprint = `HMAC-SHA256(daily_salt, "|")`. +- `daily_salt` is a random 32-byte secret generated per UTC day, kept only in + the cache (Redis) with a ~25h TTL, never persisted to disk, rotated daily. +- Only the hash is used as a `cache.add` de-duplication key (~25h TTL); raw + IP/User-Agent are discarded immediately and never written to a metric or disk. + +### Operator notes + +- **Cache access is sensitive**: while a day's salt lives in the cache, an + attacker with cache access could brute-force the limited IP+UA space for that + day. Secret salt + daily rotation mitigates cross-day linkage. diff --git a/monitoring/dashboard.json b/monitoring/dashboard.json index 64005f822..f74b67938 100644 --- a/monitoring/dashboard.json +++ b/monitoring/dashboard.json @@ -512,6 +512,525 @@ } ], "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 16 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "round(sum(increase(dashboard_unique_visitors_total[$__range:])))", + "instant": true, + "legendFormat": "Daily Unique Visitors", + "range": false, + "refId": "A" + } + ], + "title": "Unique Visitors (Selected Range)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 16, + "x": 8, + "y": 16 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum by (endpoint) (rate(dashboard_unique_visitors_by_endpoint_total[5m]))", + "instant": false, + "legendFormat": "{{endpoint}}", + "range": true, + "refId": "A" + } + ], + "title": "Unique Visitors Rate by Endpoint", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + } + }, + "mappings": [], + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 24 + }, + "id": 6, + "options": { + "displayLabels": ["percent"], + "legend": { + "displayMode": "list", + "placement": "right", + "values": ["value"] + }, + "pieType": "donut", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "desc" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "round(sum by (browser) (increase(dashboard_backend_requests_by_client_total[$__range:])))", + "instant": true, + "legendFormat": "{{browser}}", + "range": false, + "refId": "A" + } + ], + "title": "Requests by Browser", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + } + }, + "mappings": [], + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 24 + }, + "id": 7, + "options": { + "displayLabels": ["percent"], + "legend": { + "displayMode": "list", + "placement": "right", + "values": ["value"] + }, + "pieType": "donut", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "desc" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "round(sum by (os) (increase(dashboard_backend_requests_by_client_total[$__range:])))", + "instant": true, + "legendFormat": "{{os}}", + "range": false, + "refId": "A" + } + ], + "title": "Requests by OS", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + } + }, + "mappings": [], + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 24 + }, + "id": 8, + "options": { + "displayLabels": ["percent"], + "legend": { + "displayMode": "list", + "placement": "right", + "values": ["value"] + }, + "pieType": "donut", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "desc" + } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "round(sum by (device) (increase(dashboard_backend_requests_by_client_total[$__range:])))", + "instant": true, + "legendFormat": "{{device}}", + "range": false, + "refId": "A" + } + ], + "title": "Requests by Device", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 9, + "options": { + "barRadius": 0, + "barWidth": 0.7, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "topk(10, round(sum by (referrer_domain) (increase(dashboard_backend_requests_by_client_total[$__range:]))))", + "format": "time_series", + "instant": true, + "legendFormat": "{{referrer_domain}}", + "range": false, + "refId": "A" + } + ], + "title": "Top Referrer Domains", + "type": "barchart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum by (status_class) (rate(dashboard_backend_requests_by_client_total[5m]))", + "instant": false, + "legendFormat": "{{status_class}}", + "range": true, + "refId": "A" + } + ], + "title": "Request Rate by Status Class", + "type": "timeseries" } ], "refresh": "5s",