Added Unix socket support for the Prometheus metrics server.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Failing after 5s
CI / Tests (push) Successful in 8s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-06-11 15:07:44 -04:00
parent 7b395e1834
commit 446c0cd9ca
5 changed files with 255 additions and 45 deletions
+22 -11
View File
@@ -31,6 +31,7 @@ import uvloop
from crabstero import __version__ as crabstero_version
from crabstero.bot import Crabstero
from crabstero.metrics import TcpMetricsAddress, UnixMetricsAddress
logger = logging.getLogger("crabstero")
@@ -145,8 +146,9 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"--listen-metrics",
default=os.environ.get("LISTEN_METRICS"),
help=(
"Enable Prometheus metrics endpoint on HOST:PORT"
" (e.g. 127.0.0.1:9090). Disabled by default."
"Enable Prometheus metrics endpoint on HOST:PORT or unix:/path.sock"
" (e.g. 127.0.0.1:9090 or unix:/run/crabstero.sock)."
" Disabled by default."
" (default: LISTEN_METRICS environment variable)."
),
)
@@ -154,15 +156,24 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
args = parser.parse_args(argv)
if args.listen_metrics is not None:
host, sep, port_str = args.listen_metrics.rpartition(":")
if not sep or not host:
parser.error(
"--listen-metrics must be in HOST:PORT format (e.g. 127.0.0.1:9090)",
)
try:
args.listen_metrics = (host, int(port_str))
except ValueError:
parser.error(f"--listen-metrics port must be an integer, got '{port_str}'")
if args.listen_metrics.startswith("unix:"):
path = args.listen_metrics.removeprefix("unix:")
if not path:
parser.error("--listen-metrics Unix socket path must not be empty")
args.listen_metrics = UnixMetricsAddress(path)
else:
host, sep, port_str = args.listen_metrics.rpartition(":")
if not sep or not host:
parser.error(
"--listen-metrics must be in HOST:PORT or unix:/path.sock format "
"(e.g. 127.0.0.1:9090)",
)
try:
args.listen_metrics = TcpMetricsAddress(host, int(port_str))
except ValueError:
parser.error(
f"--listen-metrics port must be an integer, got '{port_str}'",
)
if args.token is None:
parser.error(
+4 -3
View File
@@ -36,6 +36,7 @@ from crabstero.metrics import (
ERRORS,
GUILD_COUNT,
INGESTION_ACTIVE,
MetricsAddress,
MetricsServer,
)
from crabstero.tasks.ingestion import ingest_channel
@@ -60,14 +61,14 @@ class Crabstero(commands.Bot):
database_path: str,
*,
ingest_only: bool = False,
metrics_address: tuple[str, int] | None = None,
metrics_address: MetricsAddress | None = None,
) -> None:
"""Configure intents, store configuration, and prepare ingestion state.
:param token: The Discord bot token.
:param database_path: The file path to the SQLite database.
:param ingest_only: When True, the bot only ingests data and never responds.
:param metrics_address: Optional (host, port) for the Prometheus metrics server.
:param metrics_address: Optional address for the Prometheus metrics server.
"""
intents = discord.Intents.default()
intents.guilds = True
@@ -121,7 +122,7 @@ class Crabstero(commands.Bot):
self.ingest_cache.start()
if self._metrics_address is not None:
server = MetricsServer(*self._metrics_address)
server = MetricsServer(self._metrics_address)
await server.start()
self._metrics_server = server
+92 -13
View File
@@ -19,7 +19,13 @@ registry. The MetricsServer class wraps an aiohttp application that serves
the ``/metrics`` scrape endpoint.
"""
import contextlib
import errno
import logging
import socket
import stat
from dataclasses import dataclass
from pathlib import Path
from aiohttp import web
from prometheus_client import Counter, Gauge, Histogram, Info
@@ -33,6 +39,25 @@ _FAST_BUCKETS = (0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0,
_SLOW_BUCKETS = (0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0)
_MESSAGES_BUCKETS = (100, 500, 1000, 5000, 10000, 25000, 50000)
@dataclass(frozen=True, slots=True)
class TcpMetricsAddress:
"""TCP listen address for the metrics server."""
host: str
port: int
@dataclass(frozen=True, slots=True)
class UnixMetricsAddress:
"""Unix socket listen address for the metrics server."""
path: str
type MetricsAddress = TcpMetricsAddress | UnixMetricsAddress
BUILD_INFO = Info("crabstero_build", "Build information")
BUILD_INFO.info({"version": __version__})
@@ -119,21 +144,21 @@ CHANNEL_INGESTION_MESSAGES = Histogram(
class MetricsServer:
"""HTTP server that exposes a Prometheus ``/metrics`` scrape endpoint.
Uses ``aiohttp.web.AppRunner`` and ``TCPSite`` for async-native serving.
Uses ``aiohttp.web.AppRunner`` with TCP or Unix socket sites for
async-native serving.
The handler is provided by ``prometheus_client.aiohttp.make_aiohttp_handler``,
which handles compression and content negotiation automatically.
"""
__slots__ = ("_host", "_port", "_runner")
__slots__ = ("_address", "_port", "_runner")
def __init__(self, host: str, port: int) -> None:
def __init__(self, address: MetricsAddress) -> None:
"""Store the listen address.
:param host: The hostname or IP to bind to.
:param port: The TCP port to bind to. Use 0 for OS-assigned.
:param address: TCP or Unix socket address to bind to.
"""
self._host = host
self._port = port
self._address = address
self._port = address.port if isinstance(address, TcpMetricsAddress) else None
self._runner: web.AppRunner | None = None
@property
@@ -143,6 +168,9 @@ class MetricsServer:
After ``start()``, this reflects the actual port (useful when
the constructor received port 0 for OS assignment).
"""
if self._port is None:
msg = "Unix socket metrics server does not have a TCP port"
raise RuntimeError(msg)
return self._port
async def start(self) -> None:
@@ -154,14 +182,65 @@ class MetricsServer:
app.router.add_get("/metrics", make_aiohttp_handler())
self._runner = web.AppRunner(app)
await self._runner.setup()
site = web.TCPSite(self._runner, self._host, self._port)
await site.start()
try:
if isinstance(self._address, TcpMetricsAddress):
site: web.BaseSite = web.TCPSite(
self._runner,
self._address.host,
self._address.port,
)
await site.start()
# Resolve the actual bound port when the OS assigned one.
if self._port == 0:
self._port = self._runner.addresses[0][1]
# Resolve the actual bound port when the OS assigned one.
if self._address.port == 0:
self._port = self._runner.addresses[0][1]
logger.info("Metrics server listening on %s:%d.", self._host, self._port)
logger.info(
"Metrics server listening on %s:%d.",
self._address.host,
self.port,
)
else:
socket_path = Path(self._address.path)
try:
mode = socket_path.stat(follow_symlinks=False).st_mode
except FileNotFoundError:
pass
else:
if not stat.S_ISSOCK(mode):
msg = (
"Metrics Unix socket path already exists and is not a "
f"socket: {self._address.path}"
)
raise FileExistsError(msg)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
probe.settimeout(0.1)
try:
probe.connect(self._address.path)
except OSError as exc:
if exc.errno not in {errno.ECONNREFUSED, errno.ENOENT}:
raise
else:
msg = (
"Metrics Unix socket path is already in use: "
f"{self._address.path}"
)
raise OSError(errno.EADDRINUSE, msg)
with contextlib.suppress(FileNotFoundError):
socket_path.unlink()
site = web.UnixSite(self._runner, self._address.path)
await site.start()
logger.info(
"Metrics server listening on Unix socket %s.",
self._address.path,
)
except Exception:
await self._runner.cleanup()
self._runner = None
raise
async def stop(self) -> None:
"""Shut down the HTTP server and release resources."""