"""metrica — a tiny push-metrics collector. Scripts push arbitrary content (JSON or raw text) to /api/{script}/ — or /api/{group}/{script}/ to group them — using a bearer token. Each push is appended to a per-script JSONL file (static files, no database). A minimal dashboard at / shows every script, grouped, with its latest value and history. """ import html import json import os import re import secrets from datetime import datetime, timezone from pathlib import Path from threading import Lock import uvicorn from fastapi import FastAPI, Header, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse # --------------------------------------------------------------------------- # # Config # --------------------------------------------------------------------------- # def load_dotenv(path: str = ".env") -> None: """Minimal .env loader so we don't need an extra dependency.""" p = Path(path) if not p.exists(): return for line in p.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, val = line.partition("=") os.environ.setdefault(key.strip(), val.strip().strip('"').strip("'")) load_dotenv() DATA_DIR = Path(os.environ.get("METRICA_DATA_DIR", "data")) DATA_DIR.mkdir(parents=True, exist_ok=True) # How many recent points the dashboard reads/plots per script. MAX_HISTORY = int(os.environ.get("METRICA_MAX_HISTORY", "100")) # Script names become file names, so keep them strictly safe (no path traversal). SCRIPT_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") def load_tokens() -> set[str]: """Any one of these tokens authorizes a push (not tied to a script).""" tokens: set[str] = set() for chunk in re.split(r"[,\s]+", os.environ.get("METRICA_TOKENS", "")): if chunk.strip(): tokens.add(chunk.strip()) token_file = os.environ.get("METRICA_TOKENS_FILE") if token_file and Path(token_file).exists(): for line in Path(token_file).read_text(encoding="utf-8").splitlines(): line = line.strip() if line and not line.startswith("#"): tokens.add(line) return tokens TOKENS = load_tokens() if not TOKENS: _generated = secrets.token_urlsafe(24) TOKENS.add(_generated) print("=" * 72) print(" metrica: no METRICA_TOKENS configured — generated a temporary token") print(f" METRICA_TOKENS={_generated}") print(" (set METRICA_TOKENS in .env for a stable token across restarts)") print("=" * 72) # --------------------------------------------------------------------------- # # Storage (append-only JSONL, one file per script) # --------------------------------------------------------------------------- # _locks: dict[str, Lock] = {} _locks_guard = Lock() def _lock_for(name: str) -> Lock: with _locks_guard: return _locks.setdefault(name, Lock()) def _path_for(group: str | None, script: str) -> Path: """A group is an optional sub-directory; ungrouped scripts sit at the top.""" if group: return DATA_DIR / group / f"{script}.jsonl" return DATA_DIR / f"{script}.jsonl" def append_point(group: str | None, script: str, payload) -> dict: entry = {"ts": _now_iso(), "data": payload} line = json.dumps(entry, ensure_ascii=False) path = _path_for(group, script) with _lock_for(str(path)): path.parent.mkdir(parents=True, exist_ok=True) with path.open("a", encoding="utf-8") as f: f.write(line + "\n") return entry def read_points(group: str | None, script: str, limit: int = MAX_HISTORY) -> tuple[list[dict], int]: """Return (recent_points, total_count).""" path = _path_for(group, script) if not path.exists(): return [], 0 lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()] points = [] for ln in lines[-limit:]: try: points.append(json.loads(ln)) except json.JSONDecodeError: continue return points, len(lines) def list_entries() -> list[tuple[str | None, str]]: """All (group, script) pairs. group is None for top-level (ungrouped) scripts.""" entries: list[tuple[str | None, str]] = [(None, p.stem) for p in sorted(DATA_DIR.glob("*.jsonl"))] entries += [(p.parent.name, p.stem) for p in sorted(DATA_DIR.glob("*/*.jsonl"))] return entries def delete_script(group: str | None, script: str) -> bool: """Remove a script's data file (and its group dir if it becomes empty).""" path = _path_for(group, script) with _lock_for(str(path)): existed = path.exists() if existed: path.unlink() if group: # tidy up an empty group directory try: if path.parent.is_dir() and not any(path.parent.iterdir()): path.parent.rmdir() except OSError: pass return existed # --------------------------------------------------------------------------- # # Helpers # --------------------------------------------------------------------------- # def _now_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") def token_valid(supplied: str) -> bool: return any(secrets.compare_digest(supplied, t) for t in TOKENS) def require_token(authorization: str | None, x_api_key: str | None) -> None: supplied = None if authorization and authorization.lower().startswith("bearer "): supplied = authorization[7:].strip() elif x_api_key: supplied = x_api_key.strip() if not supplied or not token_valid(supplied): raise HTTPException(status_code=401, detail="invalid or missing token") def extract_number(data) -> float | None: """Pick a single numeric value from a payload for the sparkline.""" if isinstance(data, bool): return None if isinstance(data, (int, float)): return float(data) if isinstance(data, dict): for v in data.values(): if isinstance(v, (int, float)) and not isinstance(v, bool): return float(v) return None def humanize(ts_iso: str) -> str: try: dt = datetime.fromisoformat(ts_iso.replace("Z", "+00:00")) except (ValueError, AttributeError): return ts_iso secs = int((datetime.now(timezone.utc) - dt).total_seconds()) if secs < 0: return "just now" if secs < 60: return f"{secs}s ago" if secs < 3600: return f"{secs // 60}m ago" if secs < 86400: return f"{secs // 3600}h ago" return f"{secs // 86400}d ago" def sparkline(values: list[float], width: int = 260, height: int = 44) -> str: if len(values) < 2: return "" lo, hi = min(values), max(values) span = (hi - lo) or 1.0 n = len(values) pts = [] for i, v in enumerate(values): x = i / (n - 1) * width y = height - (v - lo) / span * (height - 6) - 3 pts.append(f"{x:.1f},{y:.1f}") return ( f'' f'' ) # --------------------------------------------------------------------------- # # App # --------------------------------------------------------------------------- # app = FastAPI(title="metrica", docs_url="/docs") @app.get("/health") def health(): return {"ok": True, "scripts": len(list_entries())} def _validate(*names: str) -> None: for n in names: if not SCRIPT_RE.match(n): raise HTTPException(400, "invalid name; use [A-Za-z0-9_-], max 64 chars") async def _parse_body(request: Request): body = await request.body() if not body: return None text = body.decode("utf-8", errors="replace") try: # Valid JSON (number, string, object, array...) is stored as-is. return json.loads(text) except json.JSONDecodeError: # Anything else (command output, file contents, a bare word) is stored # as raw text — unless the caller explicitly claimed JSON. if "application/json" in (request.headers.get("content-type") or "").lower(): raise HTTPException(400, "Content-Type is application/json but body is not valid JSON") return text def _read_response(group: str | None, script: str, limit: int) -> JSONResponse: points, total = read_points(group, script, max(1, min(limit, 10_000))) out = {"script": script, "total": total, "points": points} if group: out = {"group": group, **out} return JSONResponse(out) @app.post("/api/{group}/{script}/") @app.post("/api/{group}/{script}") async def push_grouped( group: str, script: str, request: Request, authorization: str | None = Header(default=None), x_api_key: str | None = Header(default=None), ): require_token(authorization, x_api_key) _validate(group, script) entry = append_point(group, script, await _parse_body(request)) return {"ok": True, "group": group, "script": script, "stored": entry} @app.post("/api/{script}/") @app.post("/api/{script}") async def push( script: str, request: Request, authorization: str | None = Header(default=None), x_api_key: str | None = Header(default=None), ): require_token(authorization, x_api_key) _validate(script) entry = append_point(None, script, await _parse_body(request)) return {"ok": True, "script": script, "stored": entry} @app.get("/api/{group}/{script}/") @app.get("/api/{group}/{script}") def get_script_grouped( group: str, script: str, limit: int = MAX_HISTORY, authorization: str | None = Header(default=None), x_api_key: str | None = Header(default=None), ): require_token(authorization, x_api_key) _validate(group, script) return _read_response(group, script, limit) @app.get("/api/{script}/") @app.get("/api/{script}") def get_script( script: str, limit: int = MAX_HISTORY, authorization: str | None = Header(default=None), x_api_key: str | None = Header(default=None), ): require_token(authorization, x_api_key) _validate(script) return _read_response(None, script, limit) @app.delete("/api/{group}/{script}/") @app.delete("/api/{group}/{script}") def delete_grouped( group: str, script: str, authorization: str | None = Header(default=None), x_api_key: str | None = Header(default=None), ): require_token(authorization, x_api_key) _validate(group, script) return {"ok": True, "group": group, "script": script, "existed": delete_script(group, script)} @app.delete("/api/{script}/") @app.delete("/api/{script}") def delete_ungrouped( script: str, authorization: str | None = Header(default=None), x_api_key: str | None = Header(default=None), ): require_token(authorization, x_api_key) _validate(script) return {"ok": True, "script": script, "existed": delete_script(None, script)} def _render_card(script: str, points: list[dict], total: int, latest: dict) -> str: numbers = [n for n in (extract_number(p.get("data")) for p in points) if n is not None] spark = sparkline(numbers) data_latest = latest.get("data") if isinstance(data_latest, str): latest_disp = html.escape(data_latest) # preserve raw text / newlines else: latest_disp = html.escape(json.dumps(data_latest, indent=2, ensure_ascii=False)) num = extract_number(data_latest) big = "" if num is not None: big = f'
{num:g}
' elif isinstance(data_latest, str) and "\n" not in data_latest and len(data_latest) <= 48: big = f'
{html.escape(data_latest)}
' rows = "".join( f'{html.escape(humanize(p.get("ts", "")))}' f'{html.escape(_compact(p.get("data")))}' for p in reversed(points[-8:]) ) return f"""

{html.escape(script)}

{html.escape(humanize(latest.get("ts", "")))} · {total} points
{big} {spark}
latest payload
{latest_disp}
{rows}
""" @app.get("/", response_class=HTMLResponse) def dashboard(): groups: dict[str, list] = {} for group, script in list_entries(): points, total = read_points(group, script) if not points: continue groups.setdefault(group or "", []).append((script, points, total, points[-1])) # Named groups first (alphabetical), ungrouped ("") last. ordered = sorted(groups, key=lambda g: (g == "", g.lower())) sections = [] for gkey in ordered: items = groups[gkey] items.sort(key=lambda s: s[3].get("ts", ""), reverse=True) # newest first cards = "".join(_render_card(*item) for item in items) title = html.escape(gkey) if gkey else "ungrouped" sections.append( f'

{title}' f'{len(items)}

' f'
{cards}
' ) body = "".join(sections) or ( '

No data yet. Push to /api/<script>/ ' 'or /api/<group>/<script>/.

' ) return HTMLResponse(_PAGE.replace("{{BODY}}", body)) def _compact(data) -> str: if data is None: return "—" if isinstance(data, str): s = data elif isinstance(data, (int, float, bool)): s = str(data) else: s = json.dumps(data, ensure_ascii=False) s = " ".join(s.split()) # collapse newlines/whitespace for the one-line table return s if len(s) <= 80 else s[:79] + "…" _PAGE = """ metrica

metrica

Pushed metrics · auto-refreshes every 15s

{{BODY}}
""" if __name__ == "__main__": uvicorn.run( "app:app", host=os.environ.get("METRICA_HOST", "127.0.0.1"), port=int(os.environ.get("METRICA_PORT", "8000")), reload=bool(os.environ.get("METRICA_RELOAD")), )