Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ async def get_response(self, path: str, scope): # type: ignore[override]
_register_db_collector,
check_metrics_auth,
normalize_http_labels,
record_user_seen,
should_track_path,
)
from backend.server.server_context import ServerContextMiddleware
Expand Down Expand Up @@ -263,6 +264,13 @@ async def dispatch(self, request: Request, call_next): # type: ignore[override]
if response.status_code >= 500:
APP_ERRORS.labels(source=label_path).inc()

# Feed the fixed-cardinality active_users gauge — the user id
# stays in app memory, never becomes a metric label.
if request.method == "GET" and response.status_code < 400:
user = request.session.get("user")
if user:
record_user_seen(str(user.get("id") or user.get("username") or "?"))

return response


Expand Down
42 changes: 42 additions & 0 deletions backend/server/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import os
import re
import sqlite3
import time

from prometheus_client import (
REGISTRY,
Expand Down Expand Up @@ -59,6 +60,46 @@
buckets=(0.01, 0.05, 0.1, 0.25, 1.0, 2.5),
)

# ── Active users ──────────────────────────────────────────────────────────────
# Bounded replacement for the removed user_page_views_total (which cost
# username × path series — 12.9k at peak). The distinct-user count is computed
# app-side from in-memory last-seen timestamps and exported as ONE series per
# window by _ActiveUsersCollector, so cardinality is fixed regardless of how
# many users exist. Resets on deploy (in-memory), like every other gauge here.

_ACTIVE_WINDOWS: dict[str, float] = {"1h": 3600.0, "24h": 86400.0}
_user_last_seen: dict[str, float] = {}


def record_user_seen(user_id: str) -> None:
"""Stamp an authenticated user as active now (called from the metrics
middleware). Dict ops are atomic under the GIL; scrape-side reads snapshot."""
now = time.time()
_user_last_seen[user_id] = now
# Prune only if the dict somehow grows far past the real user count so a
# long-lived process can't accumulate unboundedly.
if len(_user_last_seen) > 2048:
cutoff = now - max(_ACTIVE_WINDOWS.values())
for key in [k for k, ts in _user_last_seen.items() if ts < cutoff]:
_user_last_seen.pop(key, None)


class _ActiveUsersCollector(Collector):
"""Emit active_users{window=} at scrape time from the last-seen map."""

def collect(self): # type: ignore[override]
g = GaugeMetricFamily(
"active_users",
"Distinct authenticated users seen within the trailing window",
labels=["window"],
)
now = time.time()
stamps = list(_user_last_seen.values())
for label, span in _ACTIVE_WINDOWS.items():
g.add_metric([label], float(sum(1 for ts in stamps if now - ts <= span)))
yield g


# ── Cache metrics ─────────────────────────────────────────────────────────────
# Labels: cache = character | guild | claim

Expand Down Expand Up @@ -328,6 +369,7 @@ def _register_db_collector() -> None:
REGISTRY.register(_DBCollector())
REGISTRY.register(_DBFileSizeCollector())
REGISTRY.register(_CensusHealthCollector())
REGISTRY.register(_ActiveUsersCollector())
_db_collector_registered = True


Expand Down
15 changes: 15 additions & 0 deletions ops/grafana/dashboards/frontend.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@
"legendFormat": "p95", "refId": "A" }]
},

{
"id": 4, "type": "stat", "title": "Active users (last 1 h)",
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 },
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"description": "Distinct authenticated users seen in the last hour. Computed app-side (active_users gauge, one series per window) — never re-add per-user metric labels.",
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"colorMode": "value", "graphMode": "none", "textMode": "auto"
},
"fieldConfig": { "defaults": { "unit": "short", "decimals": 0, "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] } }, "overrides": [] },
"targets": [{ "datasource": { "type": "prometheus", "uid": "${datasource}" },
"expr": "active_users{job=\"eq2companion\",window=\"1h\"}",
"legendFormat": "users", "refId": "A" }]
},

{
"id": 5, "type": "timeseries", "title": "HTTP requests / sec by status code",
"gridPos": { "h": 9, "w": 12, "x": 0, "y": 5 },
Expand Down
34 changes: 34 additions & 0 deletions tests/server/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,37 @@ def test_should_track_path_skips_static_mounts():
for skipped in ("/assets/app.js", "/class-icons/13.png", "/spell-icons/1.png", "/metrics"):
assert not metrics.should_track_path(skipped)
assert metrics.should_track_path("/api/character/Foo")


def _active_counts() -> dict[str, float]:
(family,) = metrics._ActiveUsersCollector().collect()
return {sample.labels["window"]: sample.value for sample in family.samples}


def test_active_users_counts_per_window(monkeypatch):
"""Distinct-user counts are computed app-side from last-seen stamps —
fixed 2-series cardinality regardless of user count (the whole point of
replacing user_page_views_total)."""
monkeypatch.setattr(metrics, "_user_last_seen", {})
now = 1_800_000_000.0
monkeypatch.setattr(metrics.time, "time", lambda: now)

assert _active_counts() == {"1h": 0.0, "24h": 0.0}

metrics.record_user_seen("111")
metrics.record_user_seen("222")
metrics.record_user_seen("111") # repeat visits stay one user
metrics._user_last_seen["333"] = now - 7200 # 2h ago: out of 1h, in 24h
metrics._user_last_seen["444"] = now - 100_000 # out of both windows

assert _active_counts() == {"1h": 2.0, "24h": 3.0}


def test_record_user_seen_prunes_stale_entries(monkeypatch):
monkeypatch.setattr(metrics, "_user_last_seen", {})
now = 1_800_000_000.0
monkeypatch.setattr(metrics.time, "time", lambda: now)
for i in range(2049):
metrics._user_last_seen[str(i)] = now - 100_000 # all stale
metrics.record_user_seen("fresh")
assert metrics._user_last_seen == {"fresh": now}
Loading