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.
Files changed (94) hide show
  1. argus/__init__.py +25 -0
  2. argus/adapters/__init__.py +17 -0
  3. argus/adapters/base.py +47 -0
  4. argus/adapters/prometheus.py +99 -0
  5. argus/cog.py +155 -0
  6. argus/config.py +161 -0
  7. argus/core/__init__.py +21 -0
  8. argus/core/collector.py +143 -0
  9. argus/core/hooks.py +98 -0
  10. argus/core/instrumentation.py +216 -0
  11. argus/core/metrics.py +291 -0
  12. argus/dashboard/__init__.py +17 -0
  13. argus/dashboard/auth.py +64 -0
  14. argus/dashboard/server.py +135 -0
  15. argus/dashboard/snapshot.py +41 -0
  16. argus/dashboard/static/assets/index-BjX7r82o.js +20 -0
  17. argus/dashboard/static/assets/index-jT3SVUL-.css +1 -0
  18. argus/dashboard/static/index.html +14 -0
  19. argus/dashboard/static/nimble/assets/aurora-dark.svg +13 -0
  20. argus/dashboard/static/nimble/assets/aurora-light.svg +13 -0
  21. argus/dashboard/static/nimble/assets/fonts/Archivo-OFL.txt +93 -0
  22. argus/dashboard/static/nimble/assets/fonts/Archivo-Variable.ttf +0 -0
  23. argus/dashboard/static/nimble/assets/fonts/Bricolage-OFL.txt +93 -0
  24. argus/dashboard/static/nimble/assets/fonts/BricolageGrotesque-Variable.ttf +0 -0
  25. argus/dashboard/static/nimble/assets/fonts/Caveat-OFL.txt +93 -0
  26. argus/dashboard/static/nimble/assets/fonts/Caveat-Variable.ttf +0 -0
  27. argus/dashboard/static/nimble/assets/fonts/Cormorant-OFL.txt +93 -0
  28. argus/dashboard/static/nimble/assets/fonts/CormorantGaramond-Variable.ttf +0 -0
  29. argus/dashboard/static/nimble/assets/fonts/DMSans-OFL.txt +93 -0
  30. argus/dashboard/static/nimble/assets/fonts/DMSans-Variable.ttf +0 -0
  31. argus/dashboard/static/nimble/assets/fonts/DMSerif-OFL.txt +93 -0
  32. argus/dashboard/static/nimble/assets/fonts/DMSerifDisplay-Regular.ttf +0 -0
  33. argus/dashboard/static/nimble/assets/fonts/FingerPaint-OFL.txt +93 -0
  34. argus/dashboard/static/nimble/assets/fonts/FingerPaint-Regular.ttf +0 -0
  35. argus/dashboard/static/nimble/assets/fonts/Fraunces-OFL.txt +93 -0
  36. argus/dashboard/static/nimble/assets/fonts/Fraunces-Variable.ttf +0 -0
  37. argus/dashboard/static/nimble/assets/fonts/Gabarito-OFL.txt +93 -0
  38. argus/dashboard/static/nimble/assets/fonts/Gabarito-Variable.ttf +0 -0
  39. argus/dashboard/static/nimble/assets/fonts/Geist-OFL.txt +93 -0
  40. argus/dashboard/static/nimble/assets/fonts/Geist-Variable.ttf +0 -0
  41. argus/dashboard/static/nimble/assets/fonts/GeistMono-OFL.txt +93 -0
  42. argus/dashboard/static/nimble/assets/fonts/GeistMono-Variable.ttf +0 -0
  43. argus/dashboard/static/nimble/assets/fonts/Hanken-OFL.txt +94 -0
  44. argus/dashboard/static/nimble/assets/fonts/HankenGrotesk-Variable.ttf +0 -0
  45. argus/dashboard/static/nimble/assets/fonts/InstrumentSerif-OFL.txt +93 -0
  46. argus/dashboard/static/nimble/assets/fonts/InstrumentSerif-Regular.ttf +0 -0
  47. argus/dashboard/static/nimble/assets/fonts/JetBrainsMono-OFL.txt +93 -0
  48. argus/dashboard/static/nimble/assets/fonts/JetBrainsMono-Variable.ttf +0 -0
  49. argus/dashboard/static/nimble/assets/fonts/Lora-OFL.txt +93 -0
  50. argus/dashboard/static/nimble/assets/fonts/Lora-Variable.ttf +0 -0
  51. argus/dashboard/static/nimble/assets/fonts/Manrope-OFL.txt +93 -0
  52. argus/dashboard/static/nimble/assets/fonts/Manrope-Variable.ttf +0 -0
  53. argus/dashboard/static/nimble/assets/fonts/Newsreader-OFL.txt +93 -0
  54. argus/dashboard/static/nimble/assets/fonts/Newsreader-Variable.ttf +0 -0
  55. argus/dashboard/static/nimble/assets/fonts/Outfit-OFL.txt +93 -0
  56. argus/dashboard/static/nimble/assets/fonts/Outfit-Variable.ttf +0 -0
  57. argus/dashboard/static/nimble/assets/fonts/Playfair-OFL.txt +93 -0
  58. argus/dashboard/static/nimble/assets/fonts/PlayfairDisplay-Variable.ttf +0 -0
  59. argus/dashboard/static/nimble/assets/fonts/PlusJakarta-OFL.txt +93 -0
  60. argus/dashboard/static/nimble/assets/fonts/PlusJakartaSans-Variable.ttf +0 -0
  61. argus/dashboard/static/nimble/assets/fonts/Sora-OFL.txt +93 -0
  62. argus/dashboard/static/nimble/assets/fonts/Sora-Variable.ttf +0 -0
  63. argus/dashboard/static/nimble/assets/fonts/SpaceGrotesk-OFL.txt +93 -0
  64. argus/dashboard/static/nimble/assets/fonts/SpaceGrotesk-Variable.ttf +0 -0
  65. argus/dashboard/static/nimble/assets/fonts/Syne-OFL.txt +93 -0
  66. argus/dashboard/static/nimble/assets/fonts/Syne-Variable.ttf +0 -0
  67. argus/dashboard/static/nimble/assets/fonts/Unbounded-OFL.txt +93 -0
  68. argus/dashboard/static/nimble/assets/fonts/Unbounded-Variable.ttf +0 -0
  69. argus/dashboard/static/nimble/assets/fonts/WorkSans-OFL.txt +93 -0
  70. argus/dashboard/static/nimble/assets/fonts/WorkSans-Variable.ttf +0 -0
  71. argus/dashboard/static/nimble/styles.css +17 -0
  72. argus/dashboard/static/nimble/tokens/backgrounds.css +76 -0
  73. argus/dashboard/static/nimble/tokens/base.css +61 -0
  74. argus/dashboard/static/nimble/tokens/colors.css +83 -0
  75. argus/dashboard/static/nimble/tokens/elevation.css +48 -0
  76. argus/dashboard/static/nimble/tokens/fonts.css +68 -0
  77. argus/dashboard/static/nimble/tokens/glass.css +34 -0
  78. argus/dashboard/static/nimble/tokens/motion.css +44 -0
  79. argus/dashboard/static/nimble/tokens/presets.css +249 -0
  80. argus/dashboard/static/nimble/tokens/responsive.css +91 -0
  81. argus/dashboard/static/nimble/tokens/spacing.css +34 -0
  82. argus/dashboard/static/nimble/tokens/typefaces.css +102 -0
  83. argus/dashboard/static/nimble/tokens/typography.css +50 -0
  84. argus/exposition/__init__.py +17 -0
  85. argus/exposition/server.py +78 -0
  86. argus/history/__init__.py +22 -0
  87. argus/history/clickhouse.py +76 -0
  88. argus/history/query.py +52 -0
  89. argus/history/sink.py +125 -0
  90. argus/py.typed +0 -0
  91. argus_dpy-0.2.0.dist-info/METADATA +200 -0
  92. argus_dpy-0.2.0.dist-info/RECORD +94 -0
  93. argus_dpy-0.2.0.dist-info/WHEEL +4 -0
  94. 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
+ """
@@ -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