Added Prometheus metrics support with opt-in CLI flag.
This commit is contained in:
@@ -100,9 +100,29 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
" never respond."
|
" never respond."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--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."
|
||||||
|
" (default: LISTEN_METRICS environment variable)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
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.token is None:
|
if args.token is None:
|
||||||
parser.error(
|
parser.error(
|
||||||
"a Discord bot token is required via --token,"
|
"a Discord bot token is required via --token,"
|
||||||
@@ -129,6 +149,7 @@ def main() -> None:
|
|||||||
database_path=args.database_path,
|
database_path=args.database_path,
|
||||||
ingestion_workers=args.ingestion_workers,
|
ingestion_workers=args.ingestion_workers,
|
||||||
ingest_only=args.ingest_only,
|
ingest_only=args.ingest_only,
|
||||||
|
metrics_address=args.listen_metrics,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _run() -> None:
|
async def _run() -> None:
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ from discord.ext import commands
|
|||||||
from crabstero import __version__ as crabstero_version
|
from crabstero import __version__ as crabstero_version
|
||||||
from crabstero.database import Database
|
from crabstero.database import Database
|
||||||
from crabstero.listeners import interaction, message, server_events
|
from crabstero.listeners import interaction, message, server_events
|
||||||
|
from crabstero.metrics import (
|
||||||
|
DISCORD_EVENTS,
|
||||||
|
DISCORD_LATENCY,
|
||||||
|
GUILD_COUNT,
|
||||||
|
INGESTION_BACKLOG,
|
||||||
|
MetricsServer,
|
||||||
|
)
|
||||||
from crabstero.tasks.ingestion import ingest_channel
|
from crabstero.tasks.ingestion import ingest_channel
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -46,6 +53,7 @@ class Crabstero(commands.Bot):
|
|||||||
database_path: str,
|
database_path: str,
|
||||||
ingestion_workers: int = 4,
|
ingestion_workers: int = 4,
|
||||||
ingest_only: bool = False,
|
ingest_only: bool = False,
|
||||||
|
metrics_address: tuple[str, int] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Configure intents, store configuration, and prepare ingestion queue state.
|
"""Configure intents, store configuration, and prepare ingestion queue state.
|
||||||
|
|
||||||
@@ -53,6 +61,7 @@ class Crabstero(commands.Bot):
|
|||||||
:param database_path: The file path to the SQLite database.
|
:param database_path: The file path to the SQLite database.
|
||||||
:param ingestion_workers: The number of concurrent ingestion workers.
|
:param ingestion_workers: The number of concurrent ingestion workers.
|
||||||
:param ingest_only: When True, the bot only ingests data and never responds.
|
:param ingest_only: When True, the bot only ingests data and never responds.
|
||||||
|
:param metrics_address: Optional (host, port) for the Prometheus metrics server.
|
||||||
"""
|
"""
|
||||||
intents = discord.Intents.default()
|
intents = discord.Intents.default()
|
||||||
intents.guilds = True
|
intents.guilds = True
|
||||||
@@ -75,6 +84,12 @@ class Crabstero(commands.Bot):
|
|||||||
self._ingestion_workers: list[asyncio.Task[None]] = []
|
self._ingestion_workers: list[asyncio.Task[None]] = []
|
||||||
self.db: Database
|
self.db: Database
|
||||||
|
|
||||||
|
self._metrics_address = metrics_address
|
||||||
|
|
||||||
|
DISCORD_LATENCY.set_function(lambda: self.latency)
|
||||||
|
GUILD_COUNT.set_function(lambda: len(self.guilds))
|
||||||
|
INGESTION_BACKLOG.set_function(lambda: self._ingestion_queue.qsize())
|
||||||
|
|
||||||
repo_url = "https://git.logal.dev/LogalDeveloper/Crabstero"
|
repo_url = "https://git.logal.dev/LogalDeveloper/Crabstero"
|
||||||
self.http.user_agent = f"DiscordBot ({repo_url}, {crabstero_version})"
|
self.http.user_agent = f"DiscordBot ({repo_url}, {crabstero_version})"
|
||||||
|
|
||||||
@@ -83,6 +98,10 @@ class Crabstero(commands.Bot):
|
|||||||
self.db = await Database.connect(self._database_path)
|
self.db = await Database.connect(self._database_path)
|
||||||
self._start_ingestion_workers()
|
self._start_ingestion_workers()
|
||||||
|
|
||||||
|
if self._metrics_address is not None:
|
||||||
|
self._metrics_server = MetricsServer(*self._metrics_address)
|
||||||
|
await self._metrics_server.start()
|
||||||
|
|
||||||
if not self._ingest_only:
|
if not self._ingest_only:
|
||||||
await interaction.setup(self)
|
await interaction.setup(self)
|
||||||
|
|
||||||
@@ -109,6 +128,14 @@ class Crabstero(commands.Bot):
|
|||||||
logger.info("Slash command tree has changed, syncing with Discord.")
|
logger.info("Slash command tree has changed, syncing with Discord.")
|
||||||
await self.tree.sync()
|
await self.tree.sync()
|
||||||
|
|
||||||
|
def dispatch(self, event: str, /, *args: object, **kwargs: object) -> None:
|
||||||
|
"""Dispatch an event, incrementing the events counter.
|
||||||
|
|
||||||
|
:param event: The event name.
|
||||||
|
"""
|
||||||
|
DISCORD_EVENTS.labels(event=event).inc()
|
||||||
|
super().dispatch(event, *args, **kwargs)
|
||||||
|
|
||||||
async def on_ready(self) -> None:
|
async def on_ready(self) -> None:
|
||||||
"""Log that the bot has started successfully."""
|
"""Log that the bot has started successfully."""
|
||||||
logger.info("Crabstero started!")
|
logger.info("Crabstero started!")
|
||||||
@@ -130,6 +157,8 @@ class Crabstero(commands.Bot):
|
|||||||
worker.cancel()
|
worker.cancel()
|
||||||
await asyncio.gather(*self._ingestion_workers, return_exceptions=True)
|
await asyncio.gather(*self._ingestion_workers, return_exceptions=True)
|
||||||
self._ingestion_workers.clear()
|
self._ingestion_workers.clear()
|
||||||
|
if hasattr(self, "_metrics_server"):
|
||||||
|
await self._metrics_server.stop()
|
||||||
if hasattr(self, "db"):
|
if hasattr(self, "db"):
|
||||||
await self.db.close()
|
await self.db.close()
|
||||||
await super().close()
|
await super().close()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import discord
|
|||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
from crabstero import flags
|
from crabstero import flags, metrics
|
||||||
from crabstero.flags import EntityType, Flag
|
from crabstero.flags import EntityType, Flag
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -63,6 +63,7 @@ class InteractionCog(commands.Cog):
|
|||||||
await flags.clear_flag(
|
await flags.clear_flag(
|
||||||
self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS
|
self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS
|
||||||
)
|
)
|
||||||
|
metrics.PINGME_OPTED_OUT.inc()
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
"I will no longer ping you for messages which"
|
"I will no longer ping you for messages which"
|
||||||
" mention you. If you decide to opt back in,"
|
" mention you. If you decide to opt back in,"
|
||||||
@@ -73,6 +74,7 @@ class InteractionCog(commands.Cog):
|
|||||||
await flags.set_flag(
|
await flags.set_flag(
|
||||||
self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS
|
self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS
|
||||||
)
|
)
|
||||||
|
metrics.PINGME_OPTED_IN.inc()
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
"I will now ping you for messages which mention"
|
"I will now ping you for messages which mention"
|
||||||
" you. If you change your mind, run"
|
" you. If you change your mind, run"
|
||||||
@@ -81,6 +83,7 @@ class InteractionCog(commands.Cog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
metrics.PINGME_ERRORS.inc()
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"An exception occurred while attempting to execute"
|
"An exception occurred while attempting to execute"
|
||||||
" slash command responder for command 'pingme'."
|
" slash command responder for command 'pingme'."
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING
|
|||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
|
from crabstero import metrics
|
||||||
from crabstero.messages import ingest_message, reply_to_message
|
from crabstero.messages import ingest_message, reply_to_message
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -55,6 +56,8 @@ class MessageCog(commands.Cog):
|
|||||||
if message.author.bot or message.author == self.bot.user:
|
if message.author.bot or message.author == self.bot.user:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
metrics.MESSAGES_PROCESSED.inc()
|
||||||
|
|
||||||
if self.bot._ingest_only:
|
if self.bot._ingest_only:
|
||||||
# In ingest-only mode, never reply — only ingest eligible messages.
|
# In ingest-only mode, never reply — only ingest eligible messages.
|
||||||
if message.type == discord.MessageType.default and not isinstance(
|
if message.type == discord.MessageType.default and not isinstance(
|
||||||
|
|||||||
+8
-3
@@ -22,6 +22,8 @@ LIMIT 1 naturally preserves this weighting.
|
|||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from crabstero import metrics
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from crabstero.database import Database
|
from crabstero.database import Database
|
||||||
|
|
||||||
@@ -106,6 +108,7 @@ async def generate(
|
|||||||
:param hard_limit: Character count to hard-cut the sentence at.
|
:param hard_limit: Character count to hard-cut the sentence at.
|
||||||
:return: A new generated sentence.
|
:return: A new generated sentence.
|
||||||
"""
|
"""
|
||||||
|
with metrics.GENERATION_DURATION.time():
|
||||||
word = await db.get_random_start_word(channel_id)
|
word = await db.get_random_start_word(channel_id)
|
||||||
|
|
||||||
# Seed the chain with a fallback sentence if the channel has no data yet.
|
# Seed the chain with a fallback sentence if the channel has no data yet.
|
||||||
@@ -117,7 +120,7 @@ async def generate(
|
|||||||
parts.append(word)
|
parts.append(word)
|
||||||
current_length = len(word)
|
current_length = len(word)
|
||||||
|
|
||||||
# The loop is skipped if the starting word already ends a sentence (e.g. "Yes.").
|
# The loop is skipped if the start word already ends a sentence (e.g. "Yes.").
|
||||||
while not is_complete_sentence(word):
|
while not is_complete_sentence(word):
|
||||||
# Past the soft limit, prefer a sentence-ending word to wrap up.
|
# Past the soft limit, prefer a sentence-ending word to wrap up.
|
||||||
if current_length >= soft_limit:
|
if current_length >= soft_limit:
|
||||||
@@ -139,13 +142,15 @@ async def generate(
|
|||||||
result = " ".join(parts)[:hard_limit]
|
result = " ".join(parts)[:hard_limit]
|
||||||
# Strip the internal sentence-end marker if it ended up at the boundary.
|
# Strip the internal sentence-end marker if it ended up at the boundary.
|
||||||
if result and result[-1] == DEFAULT_SENTENCE_END:
|
if result and result[-1] == DEFAULT_SENTENCE_END:
|
||||||
return result[:-1]
|
result = result[:-1]
|
||||||
|
metrics.GENERATED_MESSAGE_LENGTH.observe(len(result))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
result = " ".join(parts)
|
result = " ".join(parts)
|
||||||
|
|
||||||
# Strip the internal sentence-end marker so it never appears in output.
|
# Strip the internal sentence-end marker so it never appears in output.
|
||||||
if result and result[-1] == DEFAULT_SENTENCE_END:
|
if result and result[-1] == DEFAULT_SENTENCE_END:
|
||||||
return result[:-1]
|
result = result[:-1]
|
||||||
|
|
||||||
|
metrics.GENERATED_MESSAGE_LENGTH.observe(len(result))
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
from crabstero import flags, markov
|
from crabstero import flags, markov, metrics
|
||||||
from crabstero.flags import EntityType, Flag
|
from crabstero.flags import EntityType, Flag
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -105,12 +105,14 @@ async def reply_to_message(db: Database, message: discord.Message) -> None:
|
|||||||
allowed_mentions=allowed_mentions,
|
allowed_mentions=allowed_mentions,
|
||||||
mention_author=False,
|
mention_author=False,
|
||||||
)
|
)
|
||||||
|
metrics.EMBEDS_GENERATED.inc()
|
||||||
else:
|
else:
|
||||||
await message.reply(
|
await message.reply(
|
||||||
content=body,
|
content=body,
|
||||||
allowed_mentions=allowed_mentions,
|
allowed_mentions=allowed_mentions,
|
||||||
mention_author=False,
|
mention_author=False,
|
||||||
)
|
)
|
||||||
|
metrics.REPLIES_SENT.inc()
|
||||||
|
|
||||||
|
|
||||||
async def ingest_message(db: Database, message: discord.Message) -> None:
|
async def ingest_message(db: Database, message: discord.Message) -> None:
|
||||||
@@ -136,12 +138,18 @@ async def ingest_message(db: Database, message: discord.Message) -> None:
|
|||||||
channel_id = message.channel.id
|
channel_id = message.channel.id
|
||||||
user_id = message.author.id
|
user_id = message.author.id
|
||||||
|
|
||||||
|
if not message.content and not message.embeds:
|
||||||
|
return
|
||||||
|
|
||||||
|
with metrics.MESSAGE_INGESTION_DURATION.time():
|
||||||
if message.content:
|
if message.content:
|
||||||
await markov.ingest(db, channel_id, user_id, message.content)
|
await markov.ingest(db, channel_id, user_id, message.content)
|
||||||
|
|
||||||
for embed in message.embeds:
|
for embed in message.embeds:
|
||||||
await _ingest_embed(db, channel_id, user_id, embed)
|
await _ingest_embed(db, channel_id, user_id, embed)
|
||||||
|
|
||||||
|
metrics.MESSAGES_INGESTED.inc()
|
||||||
|
|
||||||
|
|
||||||
async def _ingest_embed(
|
async def _ingest_embed(
|
||||||
db: Database, channel_id: int, user_id: int, embed: discord.Embed
|
db: Database, channel_id: int, user_id: int, embed: discord.Embed
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# 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",
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
CHANNEL_INGESTION_ERRORS = Counter(
|
||||||
|
"crabstero_channel_ingestion_errors_total",
|
||||||
|
"Channel ingestions that failed",
|
||||||
|
)
|
||||||
|
PINGME_OPTED_IN = Counter(
|
||||||
|
"crabstero_pingme_opted_in_total",
|
||||||
|
"/pingme opt-ins",
|
||||||
|
)
|
||||||
|
PINGME_OPTED_OUT = Counter(
|
||||||
|
"crabstero_pingme_opted_out_total",
|
||||||
|
"/pingme opt-outs",
|
||||||
|
)
|
||||||
|
PINGME_ERRORS = Counter(
|
||||||
|
"crabstero_pingme_errors_total",
|
||||||
|
"/pingme exceptions",
|
||||||
|
)
|
||||||
|
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_BACKLOG = Gauge(
|
||||||
|
"crabstero_ingestion_backlog_channels",
|
||||||
|
"Channels currently queued 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.")
|
||||||
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
|
from crabstero import metrics
|
||||||
from crabstero.messages import ingest_message
|
from crabstero.messages import ingest_message
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -74,18 +75,23 @@ async def ingest_channel(
|
|||||||
|
|
||||||
logger.info("[%s] Starting ingestion of textable channel history.", channel.id)
|
logger.info("[%s] Starting ingestion of textable channel history.", channel.id)
|
||||||
|
|
||||||
|
with metrics.CHANNEL_INGESTION_DURATION.time():
|
||||||
count = 0
|
count = 0
|
||||||
async for message in channel.history(limit=MAXIMUM_MESSAGES_PER_CHANNEL):
|
async for message in channel.history(limit=MAXIMUM_MESSAGES_PER_CHANNEL):
|
||||||
count += 1
|
count += 1
|
||||||
await ingest_message(db, message)
|
await ingest_message(db, message)
|
||||||
|
|
||||||
|
metrics.CHANNEL_INGESTION_MESSAGES.observe(count)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] Ingestion of channel history complete. %d messages ingested.",
|
"[%s] Ingestion of channel history complete. %d messages ingested.",
|
||||||
channel.id,
|
channel.id,
|
||||||
count,
|
count,
|
||||||
)
|
)
|
||||||
|
metrics.CHANNELS_INGESTED.inc()
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
metrics.CHANNEL_INGESTION_ERRORS.inc()
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"[%s] An error occurred while ingesting textable channel history!",
|
"[%s] An error occurred while ingesting textable channel history!",
|
||||||
channel.id,
|
channel.id,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ requires-python = ">=3.14"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"discord.py>=2.6.4",
|
"discord.py>=2.6.4",
|
||||||
"aiosqlite>=0.22.1",
|
"aiosqlite>=0.22.1",
|
||||||
|
"aiohttp>=3.13.3",
|
||||||
|
"prometheus-client>=0.24.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -62,6 +62,38 @@ class TestParseArgs:
|
|||||||
args = _parse_args(["--token", "test", "--ingest-only"])
|
args = _parse_args(["--token", "test", "--ingest-only"])
|
||||||
assert args.ingest_only is True
|
assert args.ingest_only is True
|
||||||
|
|
||||||
|
def test_listen_metrics_parses_address(self) -> None:
|
||||||
|
"""--listen-metrics HOST:PORT sets listen_metrics to (host, port)."""
|
||||||
|
args = _parse_args(["--token", "test", "--listen-metrics", "127.0.0.1:9090"])
|
||||||
|
assert args.listen_metrics == ("127.0.0.1", 9090)
|
||||||
|
|
||||||
|
def test_listen_metrics_default_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Omitting --listen-metrics defaults to None."""
|
||||||
|
monkeypatch.delenv("LISTEN_METRICS", raising=False)
|
||||||
|
args = _parse_args(["--token", "test"])
|
||||||
|
assert args.listen_metrics is None
|
||||||
|
|
||||||
|
def test_listen_metrics_ipv6(self) -> None:
|
||||||
|
"""--listen-metrics [::1]:PORT parses IPv6 address correctly."""
|
||||||
|
args = _parse_args(["--token", "test", "--listen-metrics", "[::1]:9090"])
|
||||||
|
assert args.listen_metrics == ("[::1]", 9090)
|
||||||
|
|
||||||
|
def test_listen_metrics_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""LISTEN_METRICS env var is used when --listen-metrics is omitted."""
|
||||||
|
monkeypatch.setenv("LISTEN_METRICS", "127.0.0.1:8080")
|
||||||
|
args = _parse_args(["--token", "test"])
|
||||||
|
assert args.listen_metrics == ("127.0.0.1", 8080)
|
||||||
|
|
||||||
|
def test_listen_metrics_invalid_format(self) -> None:
|
||||||
|
"""--listen-metrics with no colon raises SystemExit."""
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
_parse_args(["--token", "test", "--listen-metrics", "bad"])
|
||||||
|
|
||||||
|
def test_listen_metrics_invalid_port(self) -> None:
|
||||||
|
"""--listen-metrics with non-integer port raises SystemExit."""
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
_parse_args(["--token", "test", "--listen-metrics", "127.0.0.1:abc"])
|
||||||
|
|
||||||
def test_missing_token_exits(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_missing_token_exits(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Missing token causes SystemExit."""
|
"""Missing token causes SystemExit."""
|
||||||
monkeypatch.delenv("TOKEN", raising=False)
|
monkeypatch.delenv("TOKEN", raising=False)
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Unit tests for the Prometheus metrics module.
|
||||||
|
|
||||||
|
Tests cover metric object registration and the MetricsServer HTTP endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import pytest
|
||||||
|
from prometheus_client import generate_latest
|
||||||
|
|
||||||
|
from crabstero.metrics import MetricsServer
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetricObjects:
|
||||||
|
"""All expected metric objects exist and are registered."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name",
|
||||||
|
[
|
||||||
|
pytest.param("crabstero_build_info", id="build-info"),
|
||||||
|
pytest.param("crabstero_messages_ingested_total", id="messages-ingested"),
|
||||||
|
pytest.param("crabstero_replies_sent_total", id="replies-sent"),
|
||||||
|
pytest.param("crabstero_embeds_generated_total", id="embeds-generated"),
|
||||||
|
pytest.param("crabstero_messages_processed_total", id="messages-processed"),
|
||||||
|
pytest.param("crabstero_channels_ingested_total", id="channels-ingested"),
|
||||||
|
pytest.param(
|
||||||
|
"crabstero_channel_ingestion_errors_total",
|
||||||
|
id="channel-ingestion-errors",
|
||||||
|
),
|
||||||
|
pytest.param("crabstero_pingme_opted_in_total", id="pingme-opted-in"),
|
||||||
|
pytest.param("crabstero_pingme_opted_out_total", id="pingme-opted-out"),
|
||||||
|
pytest.param("crabstero_pingme_errors_total", id="pingme-errors"),
|
||||||
|
pytest.param("crabstero_discord_latency_seconds", id="discord-latency"),
|
||||||
|
pytest.param("crabstero_guild_count", id="guild-count"),
|
||||||
|
pytest.param(
|
||||||
|
"crabstero_ingestion_backlog_channels", id="ingestion-backlog"
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"crabstero_message_ingestion_duration_seconds",
|
||||||
|
id="message-ingestion-duration",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"crabstero_channel_ingestion_duration_seconds",
|
||||||
|
id="channel-ingestion-duration",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"crabstero_generation_duration_seconds", id="generation-duration"
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"crabstero_generated_message_length_characters",
|
||||||
|
id="generated-message-length",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"crabstero_channel_ingestion_messages", id="channel-ingestion-messages"
|
||||||
|
),
|
||||||
|
pytest.param("crabstero_discord_events_total", id="discord-events"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_metric_in_output(self, name: str) -> None:
|
||||||
|
"""Each declared metric appears in the Prometheus text output."""
|
||||||
|
output = generate_latest().decode()
|
||||||
|
assert name in output, f"{name} not found in Prometheus output"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def metrics_server() -> AsyncGenerator[MetricsServer]:
|
||||||
|
"""Start a MetricsServer on an OS-assigned port and stop it after the test."""
|
||||||
|
server = MetricsServer("127.0.0.1", 0)
|
||||||
|
await server.start()
|
||||||
|
yield server
|
||||||
|
await server.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetricsServer:
|
||||||
|
"""HTTP server serves Prometheus metrics on /metrics."""
|
||||||
|
|
||||||
|
async def test_serves_metrics_endpoint(self, metrics_server: MetricsServer) -> None:
|
||||||
|
"""GET /metrics returns 200 with metric output containing our metrics."""
|
||||||
|
async with (
|
||||||
|
aiohttp.ClientSession() as session,
|
||||||
|
session.get(f"http://127.0.0.1:{metrics_server.port}/metrics") as resp,
|
||||||
|
):
|
||||||
|
assert resp.status == 200
|
||||||
|
body = await resp.text()
|
||||||
|
assert "crabstero_build_info" in body
|
||||||
|
|
||||||
|
async def test_non_metrics_path_returns_404(
|
||||||
|
self, metrics_server: MetricsServer
|
||||||
|
) -> None:
|
||||||
|
"""GET on an unknown path returns 404."""
|
||||||
|
async with (
|
||||||
|
aiohttp.ClientSession() as session,
|
||||||
|
session.get(f"http://127.0.0.1:{metrics_server.port}/notfound") as resp,
|
||||||
|
):
|
||||||
|
assert resp.status == 404
|
||||||
@@ -254,8 +254,10 @@ wheels = [
|
|||||||
name = "crabstero"
|
name = "crabstero"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
{ name = "discord-py" },
|
{ name = "discord-py" },
|
||||||
|
{ name = "prometheus-client" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -271,8 +273,10 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "aiohttp", specifier = ">=3.13.3" },
|
||||||
{ name = "aiosqlite", specifier = ">=0.22.1" },
|
{ name = "aiosqlite", specifier = ">=0.22.1" },
|
||||||
{ name = "discord-py", specifier = ">=2.6.4" },
|
{ name = "discord-py", specifier = ">=2.6.4" },
|
||||||
|
{ name = "prometheus-client", specifier = ">=0.24.1" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
@@ -655,6 +659,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prometheus-client"
|
||||||
|
version = "0.24.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "propcache"
|
name = "propcache"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user