feat: ADD Prometheus endpoint for simple app monitoring #30

This commit is contained in:
Caffeine Fueled 2026-07-24 23:10:27 +02:00
parent e42e55c7e4
commit 082a9f81d7
Signed by: cf7
GPG key ID: CA295D643074C68C
2 changed files with 131 additions and 1 deletions

100
app.py
View file

@ -17,6 +17,8 @@ RETENTION_HOURS = int(os.getenv("RETENTION_HOURS", "48")) # Default 48 hours
MAX_ROOMS = int(os.getenv("MAX_ROOMS", "10000"))
TRUST_PROXY = os.getenv("TRUST_PROXY", "false").lower() == "true"
DESCRIPTION = os.getenv("DESCRIPTION", "powered by aukpad.com")
ENABLE_METRICS = os.getenv("ENABLE_METRICS", "false").lower() == "true"
METRICS_KEY = os.getenv("METRICS_KEY", "") # empty = no auth on /system/metrics
START_TIME = time.time()
DOC_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
@ -674,6 +676,104 @@ def get_system_info():
return HTMLResponse(html_content)
def _metric(out: list, name: str, value, help_text: str, labels: str = ""):
out.append(f"# HELP {name} {help_text}")
out.append(f"# TYPE {name} gauge")
out.append(f"{name}{labels} {value}")
def _esc(v: str) -> str:
# Prometheus label values escape backslash, double quote and newline.
return v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
@app.get("/system/metrics", include_in_schema=False)
def metrics(request: Request):
# Off by default: these numbers are usage data, not for public instances.
if not ENABLE_METRICS:
raise HTTPException(status_code=404, detail="Not Found")
if METRICS_KEY:
scheme, _, token = request.headers.get("authorization", "").partition(" ")
# encode() both sides: compare_digest rejects non-ASCII str, and the
# header is attacker-controlled.
if scheme.lower() != "bearer" or not secrets.compare_digest(token.encode(), METRICS_KEY.encode()):
return PlainTextResponse("Unauthorized", status_code=401,
headers={"WWW-Authenticate": "Bearer"})
now = time.time()
# Snapshot: the cleanup thread mutates rooms without a lock.
snapshot = list(rooms.values())
peers = authed = active = protected = versions = 0
total_bytes = max_bytes = 0
idle_max = 0.0
for room in snapshot:
n = len(room.get("peers", ()))
peers += n
authed += len(room.get("authed_peers", ()))
active += 1 if n else 0
protected += 1 if room.get("pw_hash") else 0
versions += room.get("ver", 0)
size = len(room.get("text", "").encode("utf-8"))
total_bytes += size
max_bytes = max(max_bytes, size)
idle_max = max(idle_max, now - room.get("last_access", now))
ip_counts = [c for c in list(connections_per_ip.values()) if c > 0]
hour_ago = now - 3600
auth_cutoff = now - AUTH_FAILURE_WINDOW
# Both dicts prune lazily (only when that IP acts), so filter by age here.
creations = sum(len([t for t in ts if t > hour_ago]) for ts in list(rate_limits.values()))
auth_fails = sum(len([t for t in ts if t > auth_cutoff]) for ts in list(failed_auth_attempts.values()))
out: list[str] = []
_metric(out, "aukpad_rooms", len(snapshot), "Pads currently held in memory")
_metric(out, "aukpad_rooms_active", active, "Pads with at least one connected peer")
_metric(out, "aukpad_rooms_protected", protected, "Pads with a password set")
_metric(out, "aukpad_peers_connected", peers, "Live WebSocket peers across all pads")
_metric(out, "aukpad_peers_authenticated", authed, "Peers that passed pad authentication")
_metric(out, "aukpad_stored_bytes", total_bytes, "Total UTF-8 bytes of all pad text")
_metric(out, "aukpad_pad_bytes_mean", total_bytes // len(snapshot) if snapshot else 0,
"Mean pad size in UTF-8 bytes")
_metric(out, "aukpad_pad_bytes_max", max_bytes, "Largest single pad in UTF-8 bytes")
_metric(out, "aukpad_pad_versions", versions,
"Sum of all pad version counters; drops when pads are evicted")
_metric(out, "aukpad_room_idle_seconds_max", round(idle_max, 1),
"Seconds since last access of the least recently used pad")
_metric(out, "aukpad_client_ips", len(ip_counts), "Distinct IPs holding WebSocket connections")
_metric(out, "aukpad_ip_connections_max", max(ip_counts) if ip_counts else 0,
"Connection count of the busiest single IP")
_metric(out, "aukpad_pads_created_last_hour", creations,
"Pad creations via POST in the last hour, across all IPs")
_metric(out, "aukpad_auth_failures_recent", auth_fails,
f"Failed pad password attempts in the last {AUTH_FAILURE_WINDOW}s")
_metric(out, "aukpad_cache_enabled", 1 if USE_VALKEY else 0, "Valkey/Redis cache configured")
cache_up, cache_keys = 0, None
if redis_client:
try:
redis_client.ping()
cache_up = 1
cache_keys = redis_client.dbsize()
except Exception:
cache_up = 0
_metric(out, "aukpad_cache_up", cache_up, "Valkey/Redis answered PING")
if cache_keys is not None:
_metric(out, "aukpad_cache_keys", cache_keys,
"Keys in the Valkey/Redis database (all keys, if the DB is shared)")
_metric(out, "aukpad_max_rooms", MAX_ROOMS, "Configured pad limit")
_metric(out, "aukpad_max_text_bytes", MAX_TEXT_SIZE, "Configured per-pad size limit in bytes")
_metric(out, "aukpad_max_connections_per_ip", MAX_CONNECTIONS_PER_IP,
"Configured WebSocket connection limit per IP")
_metric(out, "aukpad_retention_seconds", RETENTION_HOURS * 3600, "Configured pad retention")
_metric(out, "aukpad_start_time_seconds", round(START_TIME, 3),
"Unix start time; uptime is time() minus this")
_metric(out, "aukpad_build_info", 1, "Instance description as a label",
labels=f'{{description="{_esc(DESCRIPTION)}"}}')
return PlainTextResponse("\n".join(out) + "\n",
media_type="text/plain; version=0.0.4; charset=utf-8")
@app.get("/", include_in_schema=False)
def root():
return RedirectResponse(url=f"/{random_id()}/", status_code=307)