argus-dpy 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- argus/__init__.py +25 -0
- argus/adapters/__init__.py +17 -0
- argus/adapters/base.py +47 -0
- argus/adapters/prometheus.py +99 -0
- argus/cog.py +155 -0
- argus/config.py +161 -0
- argus/core/__init__.py +21 -0
- argus/core/collector.py +143 -0
- argus/core/hooks.py +98 -0
- argus/core/instrumentation.py +216 -0
- argus/core/metrics.py +291 -0
- argus/dashboard/__init__.py +17 -0
- argus/dashboard/auth.py +64 -0
- argus/dashboard/server.py +135 -0
- argus/dashboard/snapshot.py +41 -0
- argus/dashboard/static/assets/index-BjX7r82o.js +20 -0
- argus/dashboard/static/assets/index-jT3SVUL-.css +1 -0
- argus/dashboard/static/index.html +14 -0
- argus/dashboard/static/nimble/assets/aurora-dark.svg +13 -0
- argus/dashboard/static/nimble/assets/aurora-light.svg +13 -0
- argus/dashboard/static/nimble/assets/fonts/Archivo-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/Archivo-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Bricolage-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/BricolageGrotesque-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Caveat-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/Caveat-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Cormorant-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/CormorantGaramond-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/DMSans-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/DMSans-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/DMSerif-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/DMSerifDisplay-Regular.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/FingerPaint-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/FingerPaint-Regular.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Fraunces-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/Fraunces-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Gabarito-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/Gabarito-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Geist-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/Geist-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/GeistMono-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/GeistMono-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Hanken-OFL.txt +94 -0
- argus/dashboard/static/nimble/assets/fonts/HankenGrotesk-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/InstrumentSerif-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/InstrumentSerif-Regular.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/JetBrainsMono-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/JetBrainsMono-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Lora-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/Lora-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Manrope-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/Manrope-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Newsreader-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/Newsreader-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Outfit-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/Outfit-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Playfair-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/PlayfairDisplay-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/PlusJakarta-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/PlusJakartaSans-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Sora-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/Sora-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/SpaceGrotesk-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/SpaceGrotesk-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Syne-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/Syne-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/Unbounded-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/Unbounded-Variable.ttf +0 -0
- argus/dashboard/static/nimble/assets/fonts/WorkSans-OFL.txt +93 -0
- argus/dashboard/static/nimble/assets/fonts/WorkSans-Variable.ttf +0 -0
- argus/dashboard/static/nimble/styles.css +17 -0
- argus/dashboard/static/nimble/tokens/backgrounds.css +76 -0
- argus/dashboard/static/nimble/tokens/base.css +61 -0
- argus/dashboard/static/nimble/tokens/colors.css +83 -0
- argus/dashboard/static/nimble/tokens/elevation.css +48 -0
- argus/dashboard/static/nimble/tokens/fonts.css +68 -0
- argus/dashboard/static/nimble/tokens/glass.css +34 -0
- argus/dashboard/static/nimble/tokens/motion.css +44 -0
- argus/dashboard/static/nimble/tokens/presets.css +249 -0
- argus/dashboard/static/nimble/tokens/responsive.css +91 -0
- argus/dashboard/static/nimble/tokens/spacing.css +34 -0
- argus/dashboard/static/nimble/tokens/typefaces.css +102 -0
- argus/dashboard/static/nimble/tokens/typography.css +50 -0
- argus/exposition/__init__.py +17 -0
- argus/exposition/server.py +78 -0
- argus/history/__init__.py +22 -0
- argus/history/clickhouse.py +76 -0
- argus/history/query.py +52 -0
- argus/history/sink.py +125 -0
- argus/py.typed +0 -0
- argus_dpy-0.2.0.dist-info/METADATA +200 -0
- argus_dpy-0.2.0.dist-info/RECORD +94 -0
- argus_dpy-0.2.0.dist-info/WHEEL +4 -0
- argus_dpy-0.2.0.dist-info/licenses/LICENSE +661 -0
argus/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Argus — discord.py observability SDK
|
|
2
|
+
# Copyright (C) 2026 AstorisTheBrave
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU Affero General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Argus: operational Prometheus/OpenTelemetry metrics for discord.py bots."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
__version__ = "0.2.0" # x-release-please-version
|
|
22
|
+
|
|
23
|
+
from argus.cog import Argus, ArgusCog
|
|
24
|
+
|
|
25
|
+
__all__ = ["Argus", "ArgusCog", "__version__"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Argus — discord.py observability SDK
|
|
2
|
+
# Copyright (C) 2026 AstorisTheBrave
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU Affero General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Backend adapters. Each depends on ``core``; ``core`` never depends on these."""
|
argus/adapters/base.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Argus — discord.py observability SDK
|
|
2
|
+
# Copyright (C) 2026 AstorisTheBrave
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU Affero General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Adapter ABC: the backend-side contract.
|
|
18
|
+
|
|
19
|
+
An adapter consumes the neutral registry (it satisfies the core
|
|
20
|
+
:class:`~argus.core.collector.MetricBackend` protocol) and renders it into a
|
|
21
|
+
concrete backend. Defined here so every adapter shares one shape.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from abc import ABC, abstractmethod
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
|
|
29
|
+
from argus.core.collector import MetricDef
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Adapter(ABC):
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def add_metric(self, metric: MetricDef) -> None:
|
|
35
|
+
"""Register a metric definition with the backend."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def inc(self, name: str, labels: Mapping[str, str] | None = None, amount: float = 1.0) -> None:
|
|
39
|
+
"""Increment a counter."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def observe(self, name: str, value: float, labels: Mapping[str, str] | None = None) -> None:
|
|
43
|
+
"""Record a histogram observation."""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def set_info(self, name: str, info: Mapping[str, str]) -> None:
|
|
47
|
+
"""Set the static info labels."""
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Argus — discord.py observability SDK
|
|
2
|
+
# Copyright (C) 2026 AstorisTheBrave
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU Affero General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Prometheus adapter: one CollectorRegistry, hybrid mechanism (grounding sec.2).
|
|
18
|
+
|
|
19
|
+
Scrape-time gauges are served by a custom collector that reads the neutral
|
|
20
|
+
gauge callbacks live each scrape (invariant 4). Counters, the duration
|
|
21
|
+
histogram and the info metric are held ``prometheus_client`` objects mutated by
|
|
22
|
+
the event hooks. ``generate_latest`` serialises both together.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Iterator, Mapping
|
|
28
|
+
|
|
29
|
+
from prometheus_client import CONTENT_TYPE_LATEST, CollectorRegistry, Counter, Histogram, Info
|
|
30
|
+
from prometheus_client.core import GaugeMetricFamily
|
|
31
|
+
from prometheus_client.registry import Collector
|
|
32
|
+
|
|
33
|
+
from argus.adapters.base import Adapter
|
|
34
|
+
from argus.core.collector import MetricDef, MetricKind
|
|
35
|
+
|
|
36
|
+
__all__ = ["CONTENT_TYPE_LATEST", "PrometheusAdapter"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _GaugeCollector(Collector):
|
|
40
|
+
"""Reads neutral gauge callbacks at scrape time (invariant 4)."""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self._gauges: list[MetricDef] = []
|
|
44
|
+
|
|
45
|
+
def add(self, metric: MetricDef) -> None:
|
|
46
|
+
self._gauges.append(metric)
|
|
47
|
+
|
|
48
|
+
def collect(self) -> Iterator[GaugeMetricFamily]:
|
|
49
|
+
for metric in self._gauges:
|
|
50
|
+
family = GaugeMetricFamily(
|
|
51
|
+
metric.name, metric.documentation, labels=list(metric.labelnames)
|
|
52
|
+
)
|
|
53
|
+
if metric.callback is not None:
|
|
54
|
+
for sample in metric.callback():
|
|
55
|
+
family.add_metric(list(sample.labels), sample.value)
|
|
56
|
+
yield family
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PrometheusAdapter(Adapter):
|
|
60
|
+
def __init__(self, registry: CollectorRegistry | None = None) -> None:
|
|
61
|
+
self.registry = registry if registry is not None else CollectorRegistry()
|
|
62
|
+
self._counters: dict[str, Counter] = {}
|
|
63
|
+
self._histograms: dict[str, Histogram] = {}
|
|
64
|
+
self._infos: dict[str, Info] = {}
|
|
65
|
+
self._gauge_collector = _GaugeCollector()
|
|
66
|
+
self.registry.register(self._gauge_collector)
|
|
67
|
+
|
|
68
|
+
def add_metric(self, metric: MetricDef) -> None:
|
|
69
|
+
labels = list(metric.labelnames)
|
|
70
|
+
if metric.kind is MetricKind.GAUGE:
|
|
71
|
+
self._gauge_collector.add(metric)
|
|
72
|
+
elif metric.kind is MetricKind.COUNTER:
|
|
73
|
+
self._counters[metric.name] = Counter(
|
|
74
|
+
metric.name, metric.documentation, labels, registry=self.registry
|
|
75
|
+
)
|
|
76
|
+
elif metric.kind is MetricKind.HISTOGRAM:
|
|
77
|
+
assert metric.buckets is not None # guaranteed by MetricDef
|
|
78
|
+
self._histograms[metric.name] = Histogram(
|
|
79
|
+
metric.name,
|
|
80
|
+
metric.documentation,
|
|
81
|
+
labels,
|
|
82
|
+
buckets=metric.buckets,
|
|
83
|
+
registry=self.registry,
|
|
84
|
+
)
|
|
85
|
+
elif metric.kind is MetricKind.INFO:
|
|
86
|
+
self._infos[metric.name] = Info(
|
|
87
|
+
metric.name, metric.documentation, registry=self.registry
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def inc(self, name: str, labels: Mapping[str, str] | None = None, amount: float = 1.0) -> None:
|
|
91
|
+
counter = self._counters[name]
|
|
92
|
+
(counter.labels(**labels) if labels else counter).inc(amount)
|
|
93
|
+
|
|
94
|
+
def observe(self, name: str, value: float, labels: Mapping[str, str] | None = None) -> None:
|
|
95
|
+
histogram = self._histograms[name]
|
|
96
|
+
(histogram.labels(**labels) if labels else histogram).observe(value)
|
|
97
|
+
|
|
98
|
+
def set_info(self, name: str, info: Mapping[str, str]) -> None:
|
|
99
|
+
self._infos[name].info(dict(info))
|
argus/cog.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Argus — discord.py observability SDK
|
|
2
|
+
# Copyright (C) 2026 AstorisTheBrave
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU Affero General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""The public surface: ``ArgusCog`` and the one-line ``Argus(bot)`` convenience.
|
|
18
|
+
|
|
19
|
+
``ArgusCog`` owns the registry, the Prometheus adapter, the instrumentation and
|
|
20
|
+
the exposition server lifecycle. Listeners are registered synchronously at
|
|
21
|
+
construction (safe before login); ``cog_load`` starts the server on the bot's
|
|
22
|
+
loop and ``cog_unload`` tears everything down (grounding sec.1).
|
|
23
|
+
|
|
24
|
+
``Argus(bot)`` constructs the cog and chains the bot's ``setup_hook`` so the cog
|
|
25
|
+
is added (and the server started) once the loop is running. The only line a
|
|
26
|
+
user writes is ``Argus(bot)``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from discord.ext import commands
|
|
35
|
+
|
|
36
|
+
from argus.adapters.prometheus import PrometheusAdapter
|
|
37
|
+
from argus.config import ArgusConfig
|
|
38
|
+
from argus.core.collector import MetricRegistry
|
|
39
|
+
from argus.core.hooks import Registration, register
|
|
40
|
+
from argus.core.instrumentation import Instrumentation
|
|
41
|
+
from argus.core.metrics import bot_info_values, define_metrics
|
|
42
|
+
from argus.history.sink import EventSink, NullSink
|
|
43
|
+
|
|
44
|
+
log = logging.getLogger("argus")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ArgusCog(commands.Cog):
|
|
48
|
+
"""Holds Argus' state and the exposition server lifecycle."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, bot: Any, config: ArgusConfig | None = None) -> None:
|
|
51
|
+
self.bot = bot
|
|
52
|
+
self.config = config if config is not None else ArgusConfig.resolve()
|
|
53
|
+
self.registry = MetricRegistry()
|
|
54
|
+
self.names = define_metrics(self.registry, bot, self.config)
|
|
55
|
+
self.adapter = PrometheusAdapter()
|
|
56
|
+
self.registry.attach(self.adapter)
|
|
57
|
+
self.sink: EventSink = self._build_sink()
|
|
58
|
+
self.instrumentation = Instrumentation(
|
|
59
|
+
self.registry, self.names, self.config, sink=self.sink
|
|
60
|
+
)
|
|
61
|
+
self.registry.set_info(self.names.bot_info, bot_info_values())
|
|
62
|
+
self._runner: Any = None
|
|
63
|
+
self._analytics_client: Any = None
|
|
64
|
+
# Register listeners synchronously; additive, safe before the bot logs in.
|
|
65
|
+
self._registration: Registration = register(bot, self.instrumentation)
|
|
66
|
+
|
|
67
|
+
def _build_sink(self) -> EventSink:
|
|
68
|
+
"""Select the analytical sink. NullSink unless per-guild analytics is on."""
|
|
69
|
+
if self.config.enable_per_guild and self.config.clickhouse_dsn:
|
|
70
|
+
from argus.history.clickhouse import ClickHouseSink
|
|
71
|
+
|
|
72
|
+
return ClickHouseSink(self.config.clickhouse_dsn)
|
|
73
|
+
return NullSink()
|
|
74
|
+
|
|
75
|
+
async def _build_analytics(self) -> Any:
|
|
76
|
+
"""Build the analytics read layer when per-guild analytics is configured."""
|
|
77
|
+
if not (self.config.enable_per_guild and self.config.clickhouse_dsn):
|
|
78
|
+
return None
|
|
79
|
+
import clickhouse_connect # type: ignore[import-not-found]
|
|
80
|
+
|
|
81
|
+
from argus.history.query import AnalyticsQuery
|
|
82
|
+
|
|
83
|
+
self._analytics_client = await clickhouse_connect.get_async_client(
|
|
84
|
+
dsn=self.config.clickhouse_dsn
|
|
85
|
+
)
|
|
86
|
+
return AnalyticsQuery(self._analytics_client)
|
|
87
|
+
|
|
88
|
+
async def cog_load(self) -> None:
|
|
89
|
+
from argus import __version__
|
|
90
|
+
from argus.dashboard.auth import make_auth_middleware
|
|
91
|
+
from argus.dashboard.server import register_dashboard
|
|
92
|
+
from argus.exposition.server import build_app, start_server
|
|
93
|
+
|
|
94
|
+
dashboard = None
|
|
95
|
+
middlewares = []
|
|
96
|
+
if self.config.dashboard:
|
|
97
|
+
analytics = await self._build_analytics()
|
|
98
|
+
dashboard = register_dashboard(
|
|
99
|
+
self.config,
|
|
100
|
+
registry=self.adapter.registry,
|
|
101
|
+
version=__version__,
|
|
102
|
+
analytics=analytics,
|
|
103
|
+
)
|
|
104
|
+
if self.config.dashboard_auth_token is not None:
|
|
105
|
+
middlewares.append(
|
|
106
|
+
make_auth_middleware(
|
|
107
|
+
self.config.dashboard_auth_token,
|
|
108
|
+
frozenset({"/healthz", self.config.metrics_path}),
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
app = build_app(
|
|
113
|
+
self.adapter.registry,
|
|
114
|
+
self.config.metrics_path,
|
|
115
|
+
dashboard=dashboard,
|
|
116
|
+
middlewares=middlewares,
|
|
117
|
+
)
|
|
118
|
+
self._runner = await start_server(app, self.config.host, self.config.port)
|
|
119
|
+
log.info(
|
|
120
|
+
"argus serving metrics on %s:%d%s",
|
|
121
|
+
self.config.host,
|
|
122
|
+
self.config.port,
|
|
123
|
+
self.config.metrics_path,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
async def cog_unload(self) -> None:
|
|
127
|
+
self._registration.remove()
|
|
128
|
+
if self._runner is not None:
|
|
129
|
+
await self._runner.cleanup()
|
|
130
|
+
self._runner = None
|
|
131
|
+
await self.sink.aclose()
|
|
132
|
+
if self._analytics_client is not None:
|
|
133
|
+
await self._analytics_client.close()
|
|
134
|
+
self._analytics_client = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Argus:
|
|
138
|
+
"""One-line integration: ``Argus(bot)``.
|
|
139
|
+
|
|
140
|
+
Constructs an :class:`ArgusCog` (registering listeners now) and chains the
|
|
141
|
+
bot's ``setup_hook`` so the cog is added and the metrics server starts once
|
|
142
|
+
the event loop is running.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(self, bot: Any, **kwargs: Any) -> None:
|
|
146
|
+
self.config = ArgusConfig.resolve(**kwargs)
|
|
147
|
+
self.cog = ArgusCog(bot, self.config)
|
|
148
|
+
|
|
149
|
+
original_setup_hook = bot.setup_hook
|
|
150
|
+
|
|
151
|
+
async def setup_hook(_original: Any = original_setup_hook) -> None:
|
|
152
|
+
await _original()
|
|
153
|
+
await bot.add_cog(self.cog)
|
|
154
|
+
|
|
155
|
+
bot.setup_hook = setup_hook
|
argus/config.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Argus — discord.py observability SDK
|
|
2
|
+
# Copyright (C) 2026 AstorisTheBrave
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU Affero General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""ArgusConfig: the single source of truth for configuration (invariant 6).
|
|
18
|
+
|
|
19
|
+
Precedence is constructor kwargs over environment variables over defaults. All
|
|
20
|
+
modules read from one resolved :class:`ArgusConfig` instance; no module reads the
|
|
21
|
+
environment or defaults independently.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
# Defaults live here once, so the public API and the env path agree.
|
|
30
|
+
DEFAULT_PORT = 9191
|
|
31
|
+
DEFAULT_HOST = "0.0.0.0"
|
|
32
|
+
DEFAULT_METRICS_PATH = "/metrics"
|
|
33
|
+
DEFAULT_NAMESPACE = "discord"
|
|
34
|
+
DEFAULT_DASHBOARD_PATH = "/"
|
|
35
|
+
DEFAULT_DASHBOARD_INTERVAL = 5
|
|
36
|
+
|
|
37
|
+
_TRUE = frozenset({"1", "true", "yes", "on"})
|
|
38
|
+
_FALSE = frozenset({"0", "false", "no", "off", ""})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_bool(value: str) -> bool:
|
|
42
|
+
lowered = value.strip().lower()
|
|
43
|
+
if lowered in _TRUE:
|
|
44
|
+
return True
|
|
45
|
+
if lowered in _FALSE:
|
|
46
|
+
return False
|
|
47
|
+
raise ValueError(f"cannot parse {value!r} as a boolean")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _normalize_path(path: str) -> str:
|
|
51
|
+
return path if path.startswith("/") else "/" + path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class ArgusConfig:
|
|
56
|
+
"""Resolved, immutable configuration.
|
|
57
|
+
|
|
58
|
+
Construct via :meth:`resolve`, which applies the kwargs > env > defaults
|
|
59
|
+
precedence. The dataclass fields are the already-resolved values.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
port: int = DEFAULT_PORT
|
|
63
|
+
host: str = DEFAULT_HOST
|
|
64
|
+
metrics_path: str = DEFAULT_METRICS_PATH
|
|
65
|
+
cluster_id: str | None = None
|
|
66
|
+
namespace: str = DEFAULT_NAMESPACE
|
|
67
|
+
enable_per_guild: bool = False
|
|
68
|
+
otlp_endpoint: str | None = None
|
|
69
|
+
dashboard: bool = True
|
|
70
|
+
dashboard_path: str = DEFAULT_DASHBOARD_PATH
|
|
71
|
+
dashboard_interval: int = DEFAULT_DASHBOARD_INTERVAL
|
|
72
|
+
dashboard_auth_token: str | None = None
|
|
73
|
+
grafana_url: str | None = None
|
|
74
|
+
clickhouse_dsn: str | None = None
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def resolve(
|
|
78
|
+
cls,
|
|
79
|
+
*,
|
|
80
|
+
port: int | None = None,
|
|
81
|
+
host: str | None = None,
|
|
82
|
+
metrics_path: str | None = None,
|
|
83
|
+
cluster_id: str | None = None,
|
|
84
|
+
namespace: str | None = None,
|
|
85
|
+
enable_per_guild: bool | None = None,
|
|
86
|
+
otlp_endpoint: str | None = None,
|
|
87
|
+
dashboard: bool | None = None,
|
|
88
|
+
dashboard_path: str | None = None,
|
|
89
|
+
dashboard_interval: int | None = None,
|
|
90
|
+
dashboard_auth_token: str | None = None,
|
|
91
|
+
grafana_url: str | None = None,
|
|
92
|
+
clickhouse_dsn: str | None = None,
|
|
93
|
+
environ: dict[str, str] | None = None,
|
|
94
|
+
) -> ArgusConfig:
|
|
95
|
+
"""Build a config from kwargs, falling back to env, then defaults.
|
|
96
|
+
|
|
97
|
+
``None`` for a kwarg means "not provided"; the value is then taken from
|
|
98
|
+
the matching ``ARGUS_*`` environment variable, and finally the default.
|
|
99
|
+
``environ`` is injectable for testing.
|
|
100
|
+
"""
|
|
101
|
+
env = os.environ if environ is None else environ
|
|
102
|
+
|
|
103
|
+
return cls(
|
|
104
|
+
port=cls._pick_int(port, env.get("ARGUS_PORT"), DEFAULT_PORT),
|
|
105
|
+
host=cls._pick_str(host, env.get("ARGUS_HOST"), DEFAULT_HOST),
|
|
106
|
+
metrics_path=_normalize_path(
|
|
107
|
+
cls._pick_str(metrics_path, env.get("ARGUS_METRICS_PATH"), DEFAULT_METRICS_PATH)
|
|
108
|
+
),
|
|
109
|
+
cluster_id=cls._pick_optional(cluster_id, env.get("ARGUS_CLUSTER_ID")),
|
|
110
|
+
namespace=cls._pick_str(namespace, env.get("ARGUS_NAMESPACE"), DEFAULT_NAMESPACE),
|
|
111
|
+
enable_per_guild=cls._pick_bool(
|
|
112
|
+
enable_per_guild, env.get("ARGUS_ENABLE_PER_GUILD"), False
|
|
113
|
+
),
|
|
114
|
+
otlp_endpoint=cls._pick_optional(otlp_endpoint, env.get("ARGUS_OTLP_ENDPOINT")),
|
|
115
|
+
dashboard=cls._pick_bool(dashboard, env.get("ARGUS_DASHBOARD"), True),
|
|
116
|
+
dashboard_path=_normalize_path(
|
|
117
|
+
cls._pick_str(
|
|
118
|
+
dashboard_path, env.get("ARGUS_DASHBOARD_PATH"), DEFAULT_DASHBOARD_PATH
|
|
119
|
+
)
|
|
120
|
+
),
|
|
121
|
+
dashboard_interval=cls._pick_int(
|
|
122
|
+
dashboard_interval,
|
|
123
|
+
env.get("ARGUS_DASHBOARD_INTERVAL"),
|
|
124
|
+
DEFAULT_DASHBOARD_INTERVAL,
|
|
125
|
+
),
|
|
126
|
+
dashboard_auth_token=cls._pick_optional(
|
|
127
|
+
dashboard_auth_token, env.get("ARGUS_DASHBOARD_AUTH_TOKEN")
|
|
128
|
+
),
|
|
129
|
+
grafana_url=cls._pick_optional(grafana_url, env.get("ARGUS_GRAFANA_URL")),
|
|
130
|
+
clickhouse_dsn=cls._pick_optional(clickhouse_dsn, env.get("ARGUS_CLICKHOUSE_DSN")),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _pick_str(kwarg: str | None, env_value: str | None, default: str) -> str:
|
|
135
|
+
if kwarg is not None:
|
|
136
|
+
return kwarg
|
|
137
|
+
if env_value is not None:
|
|
138
|
+
return env_value
|
|
139
|
+
return default
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _pick_optional(kwarg: str | None, env_value: str | None) -> str | None:
|
|
143
|
+
if kwarg is not None:
|
|
144
|
+
return kwarg
|
|
145
|
+
return env_value
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _pick_int(kwarg: int | None, env_value: str | None, default: int) -> int:
|
|
149
|
+
if kwarg is not None:
|
|
150
|
+
return kwarg
|
|
151
|
+
if env_value is not None:
|
|
152
|
+
return int(env_value)
|
|
153
|
+
return default
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _pick_bool(kwarg: bool | None, env_value: str | None, default: bool) -> bool:
|
|
157
|
+
if kwarg is not None:
|
|
158
|
+
return kwarg
|
|
159
|
+
if env_value is not None:
|
|
160
|
+
return _parse_bool(env_value)
|
|
161
|
+
return default
|
argus/core/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Argus — discord.py observability SDK
|
|
2
|
+
# Copyright (C) 2026 AstorisTheBrave
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU Affero General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Argus core: the neutral, backend-agnostic collection layer (invariant 1).
|
|
18
|
+
|
|
19
|
+
Nothing in this package imports an adapter or a backend client library. Adapters
|
|
20
|
+
depend on ``core``; never the reverse.
|
|
21
|
+
"""
|
argus/core/collector.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Argus — discord.py observability SDK
|
|
2
|
+
# Copyright (C) 2026 AstorisTheBrave
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU Affero General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Neutral metric model and registry (invariant 1).
|
|
18
|
+
|
|
19
|
+
The registry knows metric *definitions* and dispatches mutations to attached
|
|
20
|
+
backends through the :class:`MetricBackend` protocol. It imports no backend
|
|
21
|
+
client library, so adding or removing an adapter can never touch this module.
|
|
22
|
+
|
|
23
|
+
Three kinds carry all of Argus' signal (grounding sec.2 and sec.4):
|
|
24
|
+
|
|
25
|
+
* ``GAUGE`` live state, read at scrape time via a callback (invariant 4).
|
|
26
|
+
* ``COUNTER`` monotonic, mutated by event hooks with :meth:`MetricRegistry.inc`.
|
|
27
|
+
* ``HISTOGRAM`` distributions, fed by :meth:`MetricRegistry.observe`.
|
|
28
|
+
* ``INFO`` a single static-label series whose value is always 1.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from enum import Enum
|
|
36
|
+
from types import MappingProxyType
|
|
37
|
+
from typing import Protocol, runtime_checkable
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MetricKind(Enum):
|
|
41
|
+
GAUGE = "gauge"
|
|
42
|
+
COUNTER = "counter"
|
|
43
|
+
HISTOGRAM = "histogram"
|
|
44
|
+
INFO = "info"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class GaugeSample:
|
|
49
|
+
"""One scrape-time gauge reading: label values (in ``labelnames`` order)."""
|
|
50
|
+
|
|
51
|
+
labels: tuple[str, ...]
|
|
52
|
+
value: float
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# A gauge callback is invoked at scrape time and yields current samples.
|
|
56
|
+
GaugeCallback = Callable[[], Iterable[GaugeSample]]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class MetricDef:
|
|
61
|
+
"""A backend-neutral metric definition."""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
documentation: str
|
|
65
|
+
kind: MetricKind
|
|
66
|
+
labelnames: tuple[str, ...] = ()
|
|
67
|
+
buckets: tuple[float, ...] | None = None # HISTOGRAM only
|
|
68
|
+
callback: GaugeCallback | None = None # GAUGE only
|
|
69
|
+
|
|
70
|
+
def __post_init__(self) -> None:
|
|
71
|
+
if self.kind is MetricKind.HISTOGRAM and self.buckets is None:
|
|
72
|
+
raise ValueError(f"histogram {self.name!r} requires buckets")
|
|
73
|
+
if self.kind is MetricKind.GAUGE and self.callback is None:
|
|
74
|
+
raise ValueError(f"gauge {self.name!r} requires a scrape-time callback")
|
|
75
|
+
if self.kind is not MetricKind.HISTOGRAM and self.buckets is not None:
|
|
76
|
+
raise ValueError(f"buckets only valid for a histogram, not {self.name!r}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@runtime_checkable
|
|
80
|
+
class MetricBackend(Protocol):
|
|
81
|
+
"""The seam adapters implement. Defined in core so core imports no adapter."""
|
|
82
|
+
|
|
83
|
+
def add_metric(self, metric: MetricDef) -> None:
|
|
84
|
+
"""Register a metric definition with the backend."""
|
|
85
|
+
|
|
86
|
+
def inc(self, name: str, labels: Mapping[str, str] | None = None, amount: float = 1.0) -> None:
|
|
87
|
+
"""Increment a counter."""
|
|
88
|
+
|
|
89
|
+
def observe(self, name: str, value: float, labels: Mapping[str, str] | None = None) -> None:
|
|
90
|
+
"""Record a histogram observation."""
|
|
91
|
+
|
|
92
|
+
def set_info(self, name: str, info: Mapping[str, str]) -> None:
|
|
93
|
+
"""Set the static info labels."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(slots=True)
|
|
97
|
+
class MetricRegistry:
|
|
98
|
+
"""Holds metric definitions and fans mutations out to attached backends."""
|
|
99
|
+
|
|
100
|
+
_metrics: dict[str, MetricDef] = field(default_factory=dict)
|
|
101
|
+
_backends: list[MetricBackend] = field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def metrics(self) -> Mapping[str, MetricDef]:
|
|
105
|
+
return MappingProxyType(self._metrics)
|
|
106
|
+
|
|
107
|
+
def define(self, metric: MetricDef) -> MetricDef:
|
|
108
|
+
if metric.name in self._metrics:
|
|
109
|
+
raise ValueError(f"metric {metric.name!r} already defined")
|
|
110
|
+
self._metrics[metric.name] = metric
|
|
111
|
+
for backend in self._backends:
|
|
112
|
+
backend.add_metric(metric)
|
|
113
|
+
return metric
|
|
114
|
+
|
|
115
|
+
def attach(self, backend: MetricBackend) -> None:
|
|
116
|
+
"""Attach a backend and replay every metric already defined."""
|
|
117
|
+
self._backends.append(backend)
|
|
118
|
+
for metric in self._metrics.values():
|
|
119
|
+
backend.add_metric(metric)
|
|
120
|
+
|
|
121
|
+
def inc(self, name: str, labels: Mapping[str, str] | None = None, amount: float = 1.0) -> None:
|
|
122
|
+
self._require(name, MetricKind.COUNTER)
|
|
123
|
+
for backend in self._backends:
|
|
124
|
+
backend.inc(name, labels, amount)
|
|
125
|
+
|
|
126
|
+
def observe(self, name: str, value: float, labels: Mapping[str, str] | None = None) -> None:
|
|
127
|
+
self._require(name, MetricKind.HISTOGRAM)
|
|
128
|
+
for backend in self._backends:
|
|
129
|
+
backend.observe(name, value, labels)
|
|
130
|
+
|
|
131
|
+
def set_info(self, name: str, info: Mapping[str, str]) -> None:
|
|
132
|
+
self._require(name, MetricKind.INFO)
|
|
133
|
+
for backend in self._backends:
|
|
134
|
+
backend.set_info(name, info)
|
|
135
|
+
|
|
136
|
+
def _require(self, name: str, kind: MetricKind) -> MetricDef:
|
|
137
|
+
try:
|
|
138
|
+
metric = self._metrics[name]
|
|
139
|
+
except KeyError:
|
|
140
|
+
raise KeyError(f"unknown metric {name!r}") from None
|
|
141
|
+
if metric.kind is not kind:
|
|
142
|
+
raise TypeError(f"metric {name!r} is {metric.kind.value}, not {kind.value}")
|
|
143
|
+
return metric
|