Added Prometheus metrics support with opt-in CLI flag.
Audit / Dependencies (push) Successful in 7s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 16s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-03-18 19:43:30 -04:00
parent 6aa8527adc
commit a8d3a5c421
12 changed files with 452 additions and 46 deletions
+21
View File
@@ -100,9 +100,29 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
" 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)
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:
parser.error(
"a Discord bot token is required via --token,"
@@ -129,6 +149,7 @@ def main() -> None:
database_path=args.database_path,
ingestion_workers=args.ingestion_workers,
ingest_only=args.ingest_only,
metrics_address=args.listen_metrics,
)
async def _run() -> None:
+29
View File
@@ -28,6 +28,13 @@ from discord.ext import commands
from crabstero import __version__ as crabstero_version
from crabstero.database import Database
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
logger = logging.getLogger(__name__)
@@ -46,6 +53,7 @@ class Crabstero(commands.Bot):
database_path: str,
ingestion_workers: int = 4,
ingest_only: bool = False,
metrics_address: tuple[str, int] | None = None,
) -> None:
"""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 ingestion_workers: The number of concurrent ingestion workers.
: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.guilds = True
@@ -75,6 +84,12 @@ class Crabstero(commands.Bot):
self._ingestion_workers: list[asyncio.Task[None]] = []
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"
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._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:
await interaction.setup(self)
@@ -109,6 +128,14 @@ class Crabstero(commands.Bot):
logger.info("Slash command tree has changed, syncing with Discord.")
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:
"""Log that the bot has started successfully."""
logger.info("Crabstero started!")
@@ -130,6 +157,8 @@ class Crabstero(commands.Bot):
worker.cancel()
await asyncio.gather(*self._ingestion_workers, return_exceptions=True)
self._ingestion_workers.clear()
if hasattr(self, "_metrics_server"):
await self._metrics_server.stop()
if hasattr(self, "db"):
await self.db.close()
await super().close()
+4 -1
View File
@@ -24,7 +24,7 @@ import discord
from discord import app_commands
from discord.ext import commands
from crabstero import flags
from crabstero import flags, metrics
from crabstero.flags import EntityType, Flag
if TYPE_CHECKING:
@@ -63,6 +63,7 @@ class InteractionCog(commands.Cog):
await flags.clear_flag(
self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS
)
metrics.PINGME_OPTED_OUT.inc()
await interaction.response.send_message(
"I will no longer ping you for messages which"
" mention you. If you decide to opt back in,"
@@ -73,6 +74,7 @@ class InteractionCog(commands.Cog):
await flags.set_flag(
self.bot.db, interaction.user, EntityType.USER, Flag.ALLOW_PINGS
)
metrics.PINGME_OPTED_IN.inc()
await interaction.response.send_message(
"I will now ping you for messages which mention"
" you. If you change your mind, run"
@@ -81,6 +83,7 @@ class InteractionCog(commands.Cog):
)
except Exception:
metrics.PINGME_ERRORS.inc()
logger.exception(
"An exception occurred while attempting to execute"
" slash command responder for command 'pingme'."
+3
View File
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING
import discord
from discord.ext import commands
from crabstero import metrics
from crabstero.messages import ingest_message, reply_to_message
if TYPE_CHECKING:
@@ -55,6 +56,8 @@ class MessageCog(commands.Cog):
if message.author.bot or message.author == self.bot.user:
return
metrics.MESSAGES_PROCESSED.inc()
if self.bot._ingest_only:
# In ingest-only mode, never reply — only ingest eligible messages.
if message.type == discord.MessageType.default and not isinstance(
+41 -36
View File
@@ -22,6 +22,8 @@ LIMIT 1 naturally preserves this weighting.
import re
from typing import TYPE_CHECKING
from crabstero import metrics
if TYPE_CHECKING:
from crabstero.database import Database
@@ -106,46 +108,49 @@ async def generate(
:param hard_limit: Character count to hard-cut the sentence at.
:return: A new generated sentence.
"""
word = await db.get_random_start_word(channel_id)
with metrics.GENERATION_DURATION.time():
word = await db.get_random_start_word(channel_id)
# Seed the chain with a fallback sentence if the channel has no data yet.
if word is None:
await _ingest_sentence(db, channel_id, 0, "Hello world!")
word = "Hello" # Known start word from the fallback sentence above.
parts: list[str] = []
parts.append(word)
current_length = len(word)
# The loop is skipped if the starting word already ends a sentence (e.g. "Yes.").
while not is_complete_sentence(word):
# Past the soft limit, prefer a sentence-ending word to wrap up.
if current_length >= soft_limit:
next_word = await db.get_random_completing_next_word(channel_id, word)
if next_word is None:
next_word = await db.get_random_next_word(channel_id, word)
else:
next_word = await db.get_random_next_word(channel_id, word)
if next_word is None:
break
word = next_word
# Seed the chain with a fallback sentence if the channel has no data yet.
if word is None:
await _ingest_sentence(db, channel_id, 0, "Hello world!")
word = "Hello" # Known start word from the fallback sentence above.
parts: list[str] = []
parts.append(word)
current_length += 1 + len(word) # +1 for the joining space.
current_length = len(word)
if current_length >= hard_limit:
result = " ".join(parts)[:hard_limit]
# Strip the internal sentence-end marker if it ended up at the boundary.
if result and result[-1] == DEFAULT_SENTENCE_END:
return result[:-1]
return result
# The loop is skipped if the start word already ends a sentence (e.g. "Yes.").
while not is_complete_sentence(word):
# Past the soft limit, prefer a sentence-ending word to wrap up.
if current_length >= soft_limit:
next_word = await db.get_random_completing_next_word(channel_id, word)
if next_word is None:
next_word = await db.get_random_next_word(channel_id, word)
else:
next_word = await db.get_random_next_word(channel_id, word)
result = " ".join(parts)
if next_word is None:
break
# Strip the internal sentence-end marker so it never appears in output.
if result and result[-1] == DEFAULT_SENTENCE_END:
return result[:-1]
word = next_word
return result
parts.append(word)
current_length += 1 + len(word) # +1 for the joining space.
if current_length >= hard_limit:
result = " ".join(parts)[:hard_limit]
# Strip the internal sentence-end marker if it ended up at the boundary.
if result and result[-1] == DEFAULT_SENTENCE_END:
result = result[:-1]
metrics.GENERATED_MESSAGE_LENGTH.observe(len(result))
return result
result = " ".join(parts)
# Strip the internal sentence-end marker so it never appears in output.
if result and result[-1] == DEFAULT_SENTENCE_END:
result = result[:-1]
metrics.GENERATED_MESSAGE_LENGTH.observe(len(result))
return result
+13 -5
View File
@@ -25,7 +25,7 @@ from typing import TYPE_CHECKING
import discord
from crabstero import flags, markov
from crabstero import flags, markov, metrics
from crabstero.flags import EntityType, Flag
if TYPE_CHECKING:
@@ -105,12 +105,14 @@ async def reply_to_message(db: Database, message: discord.Message) -> None:
allowed_mentions=allowed_mentions,
mention_author=False,
)
metrics.EMBEDS_GENERATED.inc()
else:
await message.reply(
content=body,
allowed_mentions=allowed_mentions,
mention_author=False,
)
metrics.REPLIES_SENT.inc()
async def ingest_message(db: Database, message: discord.Message) -> None:
@@ -136,11 +138,17 @@ async def ingest_message(db: Database, message: discord.Message) -> None:
channel_id = message.channel.id
user_id = message.author.id
if message.content:
await markov.ingest(db, channel_id, user_id, message.content)
if not message.content and not message.embeds:
return
for embed in message.embeds:
await _ingest_embed(db, channel_id, user_id, embed)
with metrics.MESSAGE_INGESTION_DURATION.time():
if message.content:
await markov.ingest(db, channel_id, user_id, message.content)
for embed in message.embeds:
await _ingest_embed(db, channel_id, user_id, embed)
metrics.MESSAGES_INGESTED.inc()
async def _ingest_embed(
+171
View File
@@ -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.")
+10 -4
View File
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING
import discord
from crabstero import metrics
from crabstero.messages import ingest_message
if TYPE_CHECKING:
@@ -74,18 +75,23 @@ async def ingest_channel(
logger.info("[%s] Starting ingestion of textable channel history.", channel.id)
count = 0
async for message in channel.history(limit=MAXIMUM_MESSAGES_PER_CHANNEL):
count += 1
await ingest_message(db, message)
with metrics.CHANNEL_INGESTION_DURATION.time():
count = 0
async for message in channel.history(limit=MAXIMUM_MESSAGES_PER_CHANNEL):
count += 1
await ingest_message(db, message)
metrics.CHANNEL_INGESTION_MESSAGES.observe(count)
logger.info(
"[%s] Ingestion of channel history complete. %d messages ingested.",
channel.id,
count,
)
metrics.CHANNELS_INGESTED.inc()
except Exception:
metrics.CHANNEL_INGESTION_ERRORS.inc()
logger.exception(
"[%s] An error occurred while ingesting textable channel history!",
channel.id,
+2
View File
@@ -10,6 +10,8 @@ requires-python = ">=3.14"
dependencies = [
"discord.py>=2.6.4",
"aiosqlite>=0.22.1",
"aiohttp>=3.13.3",
"prometheus-client>=0.24.1",
]
[project.scripts]
+32
View File
@@ -62,6 +62,38 @@ class TestParseArgs:
args = _parse_args(["--token", "test", "--ingest-only"])
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:
"""Missing token causes SystemExit."""
monkeypatch.delenv("TOKEN", raising=False)
+113
View File
@@ -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
Generated
+13
View File
@@ -254,8 +254,10 @@ wheels = [
name = "crabstero"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "aiosqlite" },
{ name = "discord-py" },
{ name = "prometheus-client" },
]
[package.dev-dependencies]
@@ -271,8 +273,10 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.3" },
{ name = "aiosqlite", specifier = ">=0.22.1" },
{ name = "discord-py", specifier = ">=2.6.4" },
{ name = "prometheus-client", specifier = ">=0.24.1" },
]
[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" },
]
[[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]]
name = "propcache"
version = "0.4.1"