feat: ADD Prometheus endpoint for simple app monitoring #30
This commit is contained in:
parent
e42e55c7e4
commit
082a9f81d7
2 changed files with 131 additions and 1 deletions
32
README.md
32
README.md
|
|
@ -16,7 +16,7 @@ The goal is to keep it simple! For feature-rich solutions please check out [hedg
|
|||
## Features
|
||||
|
||||
**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)
|
||||
- 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 /system/info` — instance configuration page
|
||||
- `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
|
||||
|
||||
**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 |
|
||||
| `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 |
|
||||
| `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
100
app.py
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue