"""Prometheus-style process metrics for the BIND9 backend, dependency-free. Reads live process state from /proc (Linux) so no psutil is required. Exposes the standard process gauges plus our per-service health/readiness and HTTP request counters, rendered in the Prometheus text format. """ import os import platform import time from collections import defaultdict SERVICE_NAME = os.environ.get("SERVICE_NAME", "bind9-webui") # ── HTTP request bookkeeping (fed by Flask before/after request hooks) ──── _HTTP_TOTAL = 0 _HTTP_ACTIVE = 0 _HTTP_ERROR = 0 _HTTP_BY_CODE = defaultdict(int) _START_TIME = time.time() _PROCESS_READY = True _PROCESS_HEALTHY = True def set_healthy(value): global _PROCESS_HEALTHY _PROCESS_HEALTHY = value def record_request_started(): global _HTTP_ACTIVE _HTTP_ACTIVE += 1 def record_request_finished(status_code): global _HTTP_TOTAL, _HTTP_ACTIVE, _HTTP_ERROR _HTTP_TOTAL += 1 _HTTP_ACTIVE = max(0, _HTTP_ACTIVE - 1) _HTTP_BY_CODE[status_code] += 1 if status_code >= 500: _HTTP_ERROR += 1 def set_ready(value): global _PROCESS_READY _PROCESS_READY = value def ready(): return _PROCESS_READY # ── /proc helpers ────────────────────────────────────────────────────────── def _clock_ticks(): try: return os.sysconf("SC_CLK_TCK") or 100 except (ValueError, OSError): return 100 def _page_size(): try: return os.sysconf("SC_PAGE_SIZE") or 4096 except (ValueError, OSError): return 4096 def _stat_self(): """Parse /proc/self/stat. comm may contain spaces, so split after ')'.""" data = open("/proc/self/stat").read() rest = data[data.rfind(")") + 2:].split() tck = _clock_ticks() return { "utime": int(rest[11]) / tck, # field 14 (user CPU ticks) "stime": int(rest[12]) / tck, # field 15 (sys CPU ticks) "start_ticks": int(rest[19]), # field 22 (start time in ticks) "vsize": int(rest[20]), # field 23 (virtual memory bytes) "rss_pages": int(rest[21]), # field 24 (resident pages) } def _proc_io(): out = {} try: for line in open("/proc/self/io"): k, _, v = line.partition(":") if k: out[k.strip()] = int(v.strip()) except FileNotFoundError: pass return out def _boot_time(): """System boot time as a unix epoch by subtracting /proc/uptime from now.""" uptime = float(open("/proc/uptime").read().split()[0]) return time.time() - uptime def process_metrics(): st = _stat_self() io = _proc_io() start = _boot_time() + st["start_ticks"] / _clock_ticks() return { "process_resident_memory_bytes": st["rss_pages"] * _page_size(), "process_virtual_memory_bytes": st["vsize"], "process_cpu_seconds_total": st["utime"] + st["stime"], "process_start_time_seconds": start, "process_uptime_seconds": max(0.0, time.time() - start), "process_io_read_bytes": io.get("read_bytes", 0), "process_io_write_bytes": io.get("write_bytes", 0), "process_io_read_syscalls": io.get("syscr", 0), "process_io_write_syscalls": io.get("syscw", 0), "process_open_fds": len(os.listdir("/proc/self/fd")), "process_threads": _thread_count(), } def _thread_count(): try: for line in open("/proc/self/status"): if line.startswith("Threads:"): return int(line.split()[1]) except FileNotFoundError: pass return 1 def metrics_text(): p = process_metrics() boot = _boot_time() lines = [ "# HELP process_resident_memory_bytes Resident memory size in bytes.", "# TYPE process_resident_memory_bytes gauge", f'process_resident_memory_bytes{{service="{SERVICE_NAME}"}} {int(p["process_resident_memory_bytes"])}', "# HELP process_virtual_memory_bytes Virtual memory size in bytes.", "# TYPE process_virtual_memory_bytes gauge", f'process_virtual_memory_bytes{{service="{SERVICE_NAME}"}} {int(p["process_virtual_memory_bytes"])}', "# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.", "# TYPE process_cpu_seconds_total counter", f'process_cpu_seconds_total{{service="{SERVICE_NAME}"}} {p["process_cpu_seconds_total"]:.6f}', "# HELP process_start_time_seconds Start time of the process since unix epoch in seconds.", "# TYPE process_start_time_seconds gauge", f'process_start_time_seconds{{service="{SERVICE_NAME}"}} {p["process_start_time_seconds"]:.3f}', "# HELP process_uptime_seconds Seconds since the process started.", "# TYPE process_uptime_seconds gauge", f'process_uptime_seconds{{service="{SERVICE_NAME}"}} {p["process_uptime_seconds"]:.3f}', "# HELP process_io_read_bytes Bytes read by the process (page-cache aware).", "# TYPE process_io_read_bytes gauge", f'process_io_read_bytes{{service="{SERVICE_NAME}"}} {int(p["process_io_read_bytes"])}', "# HELP process_io_write_bytes Bytes written by the process (page-cache aware).", "# TYPE process_io_write_bytes gauge", f'process_io_write_bytes{{service="{SERVICE_NAME}"}} {int(p["process_io_write_bytes"])}', "# HELP process_io_read_syscalls Number of read syscalls.", "# TYPE process_io_read_syscalls counter", f'process_io_read_syscalls{{service="{SERVICE_NAME}"}} {int(p["process_io_read_syscalls"])}', "# HELP process_io_write_syscalls Number of write syscalls.", "# TYPE process_io_write_syscalls counter", f'process_io_write_syscalls{{service="{SERVICE_NAME}"}} {int(p["process_io_write_syscalls"])}', "# HELP process_open_fds Number of open file descriptors.", "# TYPE process_open_fds gauge", f'process_open_fds{{service="{SERVICE_NAME}"}} {int(p["process_open_fds"])}', "# HELP process_threads Number of threads.", "# TYPE process_threads gauge", f'process_threads{{service="{SERVICE_NAME}"}} {int(p["process_threads"])}', "# HELP system_boot_time_seconds System boot time in seconds since unix epoch.", "# TYPE system_boot_time_seconds gauge", f'system_boot_time_seconds {boot:.3f}', "# HELP http_requests_total Total HTTP requests served.", "# TYPE http_requests_total counter", ] for code in sorted(_HTTP_BY_CODE): lines.append(f'http_requests_total{{service="{SERVICE_NAME}",code="{code}"}} {_HTTP_BY_CODE[code]}') lines.append(f'http_requests_total{{service="{SERVICE_NAME}",code="total"}} {_HTTP_TOTAL}') lines += [ "# HELP http_requests_active Requests currently in flight.", "# TYPE http_requests_active gauge", f'http_requests_active{{service="{SERVICE_NAME}"}} {_HTTP_ACTIVE}', "# HELP http_requests_errors_total HTTP 5xx responses.", "# TYPE http_requests_errors_total counter", f'http_requests_errors_total{{service="{SERVICE_NAME}"}} {_HTTP_ERROR}', "# HELP webui_service_healthy Liveness: 1 if the process is serving normally.", "# TYPE webui_service_healthy gauge", f'webui_service_healthy{{service="{SERVICE_NAME}"}} {1 if _PROCESS_HEALTHY else 0}', "# HELP webui_service_ready Readiness: 1 if dependencies are satisfied.", "# TYPE webui_service_ready gauge", f'webui_service_ready{{service="{SERVICE_NAME}"}} {1 if _PROCESS_READY else 0}', f'python_info{{version="{platform.python_version()}"}} 1', ] return "\n".join(lines) + "\n" def summarize(text): """Extract the nearest value for each metric name (labels ignored).""" out = {} for line in text.splitlines(): line = line.strip() if not line or line.startswith("#"): continue if "{" in line: name, _, rest = line.partition("{") tail = rest.rpartition("}")[2] else: parts = line.split() name = parts[0] tail = " ".join(parts[1:]) if name not in out: try: out[name] = float(tail.strip()) except ValueError: continue return out