Files
Crabstero/crabstero/metrics.py
T
LogalDeveloper 093426ac61
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 22s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 5s
Refactored ingestion to semaphore-bounded tasks, centralized error handling, and consolidated metrics.
2026-03-22 22:45:31 -04:00

172 lines
5.4 KiB
Python

# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Prometheus metrics definitions and HTTP server for Crabstero.
All metric objects are defined at module level using the default global
registry. The MetricsServer class wraps an aiohttp application that serves
the ``/metrics`` scrape endpoint.
"""
import logging
from aiohttp import web
from prometheus_client import Counter, Gauge, Histogram, Info
from prometheus_client.aiohttp import make_aiohttp_handler
from crabstero import __version__
logger = logging.getLogger(__name__)
_FAST_BUCKETS = (0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
_SLOW_BUCKETS = (0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0)
_LENGTH_BUCKETS = (10, 50, 100, 200, 500, 750, 1000, 1500, 2000)
_MESSAGES_BUCKETS = (100, 500, 1000, 5000, 10000, 25000, 50000)
BUILD_INFO = Info("crabstero_build", "Build information")
BUILD_INFO.info({"version": __version__})
MESSAGES_INGESTED = Counter(
"crabstero_messages_ingested_total",
"Messages ingested into a Markov chain",
)
MESSAGES_UNINGESTED = Counter(
"crabstero_messages_uningested_total",
"Messages uningested from a Markov chain after deletion",
)
REPLIES_SENT = Counter(
"crabstero_replies_sent_total",
"Markov replies sent",
)
EMBEDS_GENERATED = Counter(
"crabstero_embeds_generated_total",
"Embeds included in replies",
)
MESSAGES_PROCESSED = Counter(
"crabstero_messages_processed_total",
"Messages seen by the listener",
)
CHANNELS_INGESTED = Counter(
"crabstero_channels_ingested_total",
"Channels fully ingested successfully",
)
PINGME = Counter(
"crabstero_pingme_total",
"/pingme command outcomes",
["outcome"],
)
ERRORS = Counter(
"crabstero_errors_total",
"Unhandled exceptions by source",
["source"],
)
DISCORD_EVENTS = Counter(
"crabstero_discord_events_total",
"Discord gateway events received",
["event"],
)
DISCORD_LATENCY = Gauge(
"crabstero_discord_latency_seconds",
"Discord WebSocket heartbeat latency",
)
GUILD_COUNT = Gauge(
"crabstero_guild_count",
"Guilds the bot is currently in",
)
INGESTION_ACTIVE = Gauge(
"crabstero_ingestion_active_channels",
"Channels pending or in-progress for ingestion",
)
MESSAGE_INGESTION_DURATION = Histogram(
"crabstero_message_ingestion_duration_seconds",
"Time to ingest one message",
buckets=_FAST_BUCKETS,
)
CHANNEL_INGESTION_DURATION = Histogram(
"crabstero_channel_ingestion_duration_seconds",
"Time to fully ingest a channel",
buckets=_SLOW_BUCKETS,
)
GENERATION_DURATION = Histogram(
"crabstero_generation_duration_seconds",
"Time to generate a Markov response",
buckets=_FAST_BUCKETS,
)
GENERATED_MESSAGE_LENGTH = Histogram(
"crabstero_generated_message_length_characters",
"Length of generated reply bodies in characters",
buckets=_LENGTH_BUCKETS,
)
CHANNEL_INGESTION_MESSAGES = Histogram(
"crabstero_channel_ingestion_messages",
"Messages ingested per channel run",
buckets=_MESSAGES_BUCKETS,
)
class MetricsServer:
"""HTTP server that exposes a Prometheus ``/metrics`` scrape endpoint.
Uses ``aiohttp.web.AppRunner`` and ``TCPSite`` 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")
def __init__(self, host: str, port: int) -> 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.
"""
self._host = host
self._port = port
self._runner: web.AppRunner | None = None
@property
def port(self) -> int:
"""The TCP port the server is bound to.
After ``start()``, this reflects the actual port (useful when
the constructor received port 0 for OS assignment).
"""
return self._port
async def start(self) -> None:
"""Create the aiohttp application and start listening."""
if self._runner is not None:
raise RuntimeError("Metrics server is already running")
app = web.Application()
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()
# Resolve the actual bound port when the OS assigned one.
if self._port == 0:
self._port = self._runner.addresses[0][1]
logger.info("Metrics server listening on %s:%d.", self._host, self._port)
async def stop(self) -> None:
"""Shut down the HTTP server and release resources."""
if self._runner is not None:
await self._runner.cleanup()
self._runner = None
logger.info("Metrics server stopped.")