chore: move plugins/ to separate repo, gitignore the directory

plugins/ is now tracked at bvandeusen/fabledscryer-plugins.
Removed tracked traefik files from this repo's index (files remain on disk).
Added plugins/ to .gitignore so the directory is ignored going forward.

PLUGIN_DIR in config.yaml still points to plugins/ for local dev and Docker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 18:33:22 -04:00
parent 230b542015
commit fa318073f1
14 changed files with 4 additions and 637 deletions
-194
View File
@@ -1,194 +0,0 @@
# plugins/traefik/scraper.py
"""Prometheus text-format scraper and per-router metrics calculator for Traefik.
Traefik exposes Prometheus metrics at /metrics. This module:
1. Fetches the endpoint via httpx
2. Parses the Prometheus text format (counters + histograms)
3. Computes per-router request rate (delta/elapsed), error rates (% 4xx, % 5xx),
and latency percentiles (p50, p95, p99) via linear histogram interpolation
"""
from __future__ import annotations
import math
import re
from collections import defaultdict
import httpx
# ──────────────────────────────────────────────────────────────────────────────
# Prometheus text format parser
# ──────────────────────────────────────────────────────────────────────────────
# Parsed sample: {metric_name: {frozenset(label_items): float}}
ParsedMetrics = dict[str, dict[frozenset, float]]
_SAMPLE_RE = re.compile(
r'^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+'
r'([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?|[+-]?Inf|NaN)'
)
_LABEL_RE = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*)="([^"\\]*(?:\\.[^"\\]*)*)"')
def parse_prometheus(text: str) -> ParsedMetrics:
"""Parse Prometheus text exposition format into nested dicts.
Returns: {metric_name: {frozenset({(label, value), ...}): float}}
"""
metrics: ParsedMetrics = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
m = _SAMPLE_RE.match(line)
if not m:
continue
name, labels_str, value_str = m.groups()
try:
value = float(value_str)
except ValueError:
continue
labels = frozenset(
(lm.group(1), lm.group(2))
for lm in _LABEL_RE.finditer(labels_str or "")
)
metrics.setdefault(name, {})[labels] = value
return metrics
# ──────────────────────────────────────────────────────────────────────────────
# Per-router metric computation
# ──────────────────────────────────────────────────────────────────────────────
def compute_router_metrics(
current: ParsedMetrics,
previous: ParsedMetrics | None,
elapsed_seconds: float,
) -> dict[str, dict[str, float]]:
"""Compute per-router derived metrics from two consecutive scrapes.
Args:
current: Parsed metrics from the latest scrape.
previous: Parsed metrics from the prior scrape (None on first call).
elapsed_seconds: Time between scrapes (for rate calculation).
Returns:
{router_name: {
"request_rate": float, # requests/sec
"error_rate_4xx_pct": float, # % 4xx of total
"error_rate_5xx_pct": float, # % 5xx of total
"latency_p50_ms": float, # approx ms
"latency_p95_ms": float,
"latency_p99_ms": float,
}}
"""
prev = previous or {}
elapsed = max(elapsed_seconds, 1.0)
# ── Request counters ──────────────────────────────────────────────────────
req_samples = current.get("traefik_router_requests_total", {})
prev_req = prev.get("traefik_router_requests_total", {})
router_total: dict[str, float] = defaultdict(float)
router_4xx: dict[str, float] = defaultdict(float)
router_5xx: dict[str, float] = defaultdict(float)
for labels, value in req_samples.items():
label_dict = dict(labels)
router = label_dict.get("router", "")
if not router:
continue
code = label_dict.get("code", "")
prev_value = prev_req.get(labels, value) # no delta on first scrape
delta = max(0.0, value - prev_value)
router_total[router] += delta
if code.startswith("4"):
router_4xx[router] += delta
elif code.startswith("5"):
router_5xx[router] += delta
# ── Histogram buckets ─────────────────────────────────────────────────────
hist_buckets = current.get("traefik_router_request_duration_seconds_bucket", {})
# Collect all known routers (from both counter and histogram data)
all_routers: set[str] = set(router_total.keys())
for labels in hist_buckets:
router = dict(labels).get("router", "")
if router:
all_routers.add(router)
result: dict[str, dict[str, float]] = {}
for router in all_routers:
total = router_total.get(router, 0.0)
result[router] = {
"request_rate": total / elapsed,
"error_rate_4xx_pct": (router_4xx.get(router, 0.0) / total * 100.0)
if total > 0 else 0.0,
"error_rate_5xx_pct": (router_5xx.get(router, 0.0) / total * 100.0)
if total > 0 else 0.0,
"latency_p50_ms": _percentile_ms(hist_buckets, router, 0.50),
"latency_p95_ms": _percentile_ms(hist_buckets, router, 0.95),
"latency_p99_ms": _percentile_ms(hist_buckets, router, 0.99),
}
return result
def _percentile_ms(
buckets: dict[frozenset, float],
router: str,
pct: float,
) -> float:
"""Linear interpolation of a Prometheus histogram percentile, returned in ms."""
router_buckets: list[tuple[float, float]] = []
for labels, count in buckets.items():
label_dict = dict(labels)
if label_dict.get("router") != router:
continue
le_str = label_dict.get("le", "")
try:
le = float("inf") if le_str == "+Inf" else float(le_str)
router_buckets.append((le, count))
except ValueError:
continue
if not router_buckets:
return 0.0
router_buckets.sort(key=lambda t: t[0])
total = router_buckets[-1][1] # +Inf bucket count == total requests
if total == 0.0:
return 0.0
target = total * pct
for i, (le, count) in enumerate(router_buckets):
if count < target:
continue
# Found the bucket that first reaches target count
if i == 0:
# Interpolate from 0 to first bucket upper bound
ratio = target / count if count > 0 else 0.0
return le * ratio * 1000.0
prev_le, prev_count = router_buckets[i - 1]
if count == prev_count:
return prev_le * 1000.0
if math.isinf(le):
# Percentile falls in the open (last_finite_le, +Inf] bucket;
# cap at the last finite bucket upper bound.
return prev_le * 1000.0
fraction = (target - prev_count) / (count - prev_count)
return (prev_le + fraction * (le - prev_le)) * 1000.0
return 0.0 # unreachable: +Inf bucket always satisfies count >= target
# ──────────────────────────────────────────────────────────────────────────────
# HTTP fetch
# ──────────────────────────────────────────────────────────────────────────────
async def fetch_metrics(metrics_url: str) -> ParsedMetrics:
"""Fetch and parse Prometheus metrics from the given URL."""
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(metrics_url)
response.raise_for_status()
return parse_prometheus(response.text)