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

View file

@ -16,7 +16,7 @@ The goal is to keep it simple! For feature-rich solutions please check out [hedg
## Features ## Features
**Use cases:** **Use cases:**
- shared notepad across multiple machines - shared notepad across multiple machines and users
- collaboration on the same notepage with multiple people (notes, config, etc) - collaboration on the same notepage with multiple people (notes, config, etc)
- piping configs: `curl -o app.conf https://aukpad.com/{pad_id}/raw` → edit in aukpad → repeat - piping configs: `curl -o app.conf https://aukpad.com/{pad_id}/raw` → edit in aukpad → repeat
@ -33,6 +33,7 @@ The goal is to keep it simple! For feature-rich solutions please check out [hedg
- `GET /{pad_id}/raw` — raw text (auth via `?pw=…` for protected pads) - `GET /{pad_id}/raw` — raw text (auth via `?pw=…` for protected pads)
- `GET /system/info` — instance configuration page - `GET /system/info` — instance configuration page
- `GET /health` — JSON health check (200 `ok` / 503 `degraded`) - `GET /health` — JSON health check (200 `ok` / 503 `degraded`)
- `GET /system/metrics` — Prometheus metrics, disabled by default (see *Monitoring*)
- WebSocket `/ws/{pad_id}` — live collaboration - WebSocket `/ws/{pad_id}` — live collaboration
**Deployment:** **Deployment:**
@ -128,6 +129,35 @@ The following environment variables can be configured:
| `MAX_ROOMS` | `10000` | Maximum number of pads kept in memory; new pads are refused (WS close 1008) when full until cleanup reclaims space | | `MAX_ROOMS` | `10000` | Maximum number of pads kept in memory; new pads are refused (WS close 1008) when full until cleanup reclaims space |
| `TRUST_PROXY` | `false` | If `true`, read the client IP from `X-Forwarded-For` (first entry) or `X-Real-IP` for per-IP rate/connection limits. Only enable when aukpad sits behind a reverse proxy that strips/sets these headers — otherwise they can be spoofed | | `TRUST_PROXY` | `false` | If `true`, read the client IP from `X-Forwarded-For` (first entry) or `X-Real-IP` for per-IP rate/connection limits. Only enable when aukpad sits behind a reverse proxy that strips/sets these headers — otherwise they can be spoofed |
| `DESCRIPTION` | `powered by aukpad.com` | Instance description shown on info page | | `DESCRIPTION` | `powered by aukpad.com` | Instance description shown on info page |
| `ENABLE_METRICS` | `false` | Serve Prometheus metrics on `/system/metrics`. While `false` the path returns 404 |
| `METRICS_KEY` | *(empty)* | If set, scrapes must send `Authorization: Bearer <key>`. Empty means no auth — only sane if the path is blocked at the reverse proxy |
---
## Monitoring
- `/health` simple healthcheck
- `/system/metrics` optional Prometheus endpoint
| Metric | Description |
|--------|-------------|
| `aukpad_rooms` | Pads currently held in memory |
| `aukpad_rooms_active` | Pads with at least one connected peer |
| `aukpad_rooms_protected` | Pads with a password set |
| `aukpad_peers_connected` | Live WebSocket peers across all pads |
| `aukpad_peers_authenticated` | Peers past authentication; a gap vs. the above means clients are stuck at the password prompt |
| `aukpad_stored_bytes` | Total UTF-8 bytes of all pad text |
| `aukpad_pad_bytes_mean` / `aukpad_pad_bytes_max` | Mean and largest pad size in bytes |
| `aukpad_pad_versions` | Sum of all pad version counters — a proxy for edit volume. Drops when pads are evicted, so use `deriv()`, not `rate()` |
| `aukpad_room_idle_seconds_max` | Age of the least recently used pad; shows whether cleanup is keeping up |
| `aukpad_client_ips` / `aukpad_ip_connections_max` | Distinct IPs connected, and the busiest single IP |
| `aukpad_pads_created_last_hour` | Rolling pad creations via `POST /`, all IPs |
| `aukpad_auth_failures_recent` | Failed pad passwords inside the 60s window — brute-force signal |
| `aukpad_cache_enabled` / `aukpad_cache_up` | Valkey configured, and answering `PING` right now |
| `aukpad_cache_keys` | Keys in the Valkey database (all of them, if the DB is shared) |
| `aukpad_max_rooms`, `aukpad_max_text_bytes`, `aukpad_max_connections_per_ip`, `aukpad_retention_seconds` | Configured limits, so alert rules can use ratios instead of hardcoded numbers |
| `aukpad_start_time_seconds` | Unix start time; uptime is `time() - aukpad_start_time_seconds` |
| `aukpad_build_info` | Always 1, carries `DESCRIPTION` as a label |
--- ---

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")) MAX_ROOMS = int(os.getenv("MAX_ROOMS", "10000"))
TRUST_PROXY = os.getenv("TRUST_PROXY", "false").lower() == "true" TRUST_PROXY = os.getenv("TRUST_PROXY", "false").lower() == "true"
DESCRIPTION = os.getenv("DESCRIPTION", "powered by aukpad.com") 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() START_TIME = time.time()
DOC_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") DOC_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
@ -674,6 +676,104 @@ def get_system_info():
return HTMLResponse(html_content) 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) @app.get("/", include_in_schema=False)
def root(): def root():
return RedirectResponse(url=f"/{random_id()}/", status_code=307) return RedirectResponse(url=f"/{random_id()}/", status_code=307)