rabbitkit 0.9.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.
- rabbitkit/__init__.py +201 -0
- rabbitkit/_version.py +3 -0
- rabbitkit/aio/__init__.py +31 -0
- rabbitkit/async_/__init__.py +9 -0
- rabbitkit/async_/batch.py +213 -0
- rabbitkit/async_/broker.py +1123 -0
- rabbitkit/async_/connection.py +274 -0
- rabbitkit/async_/pool.py +363 -0
- rabbitkit/async_/transport.py +877 -0
- rabbitkit/asyncapi/__init__.py +5 -0
- rabbitkit/asyncapi/generator.py +219 -0
- rabbitkit/asyncapi/schema.py +98 -0
- rabbitkit/cli/__init__.py +77 -0
- rabbitkit/cli/_utils.py +38 -0
- rabbitkit/cli/commands/__init__.py +0 -0
- rabbitkit/cli/commands/dlq.py +190 -0
- rabbitkit/cli/commands/health.py +34 -0
- rabbitkit/cli/commands/migrate.py +570 -0
- rabbitkit/cli/commands/routes.py +88 -0
- rabbitkit/cli/commands/run.py +144 -0
- rabbitkit/cli/commands/shell.py +72 -0
- rabbitkit/cli/commands/topology.py +346 -0
- rabbitkit/concurrency.py +451 -0
- rabbitkit/core/__init__.py +5 -0
- rabbitkit/core/app.py +323 -0
- rabbitkit/core/config.py +849 -0
- rabbitkit/core/env_config.py +251 -0
- rabbitkit/core/errors.py +199 -0
- rabbitkit/core/logging.py +261 -0
- rabbitkit/core/message.py +235 -0
- rabbitkit/core/path.py +53 -0
- rabbitkit/core/pipeline.py +1289 -0
- rabbitkit/core/protocols.py +349 -0
- rabbitkit/core/registry.py +284 -0
- rabbitkit/core/route.py +329 -0
- rabbitkit/core/router.py +142 -0
- rabbitkit/core/topology.py +261 -0
- rabbitkit/core/topology_dispatch.py +74 -0
- rabbitkit/core/types.py +324 -0
- rabbitkit/dashboard/__init__.py +5 -0
- rabbitkit/dashboard/app.py +212 -0
- rabbitkit/di/__init__.py +19 -0
- rabbitkit/di/context.py +193 -0
- rabbitkit/di/depends.py +42 -0
- rabbitkit/di/resolver.py +503 -0
- rabbitkit/dlq.py +320 -0
- rabbitkit/experimental/__init__.py +50 -0
- rabbitkit/fastapi.py +91 -0
- rabbitkit/health.py +654 -0
- rabbitkit/highload/__init__.py +10 -0
- rabbitkit/highload/backpressure.py +514 -0
- rabbitkit/highload/batch.py +448 -0
- rabbitkit/locking.py +277 -0
- rabbitkit/management.py +470 -0
- rabbitkit/middleware/__init__.py +27 -0
- rabbitkit/middleware/base.py +125 -0
- rabbitkit/middleware/circuit_breaker.py +131 -0
- rabbitkit/middleware/compression.py +267 -0
- rabbitkit/middleware/deduplication.py +651 -0
- rabbitkit/middleware/error_classifier.py +43 -0
- rabbitkit/middleware/exception.py +105 -0
- rabbitkit/middleware/metrics.py +440 -0
- rabbitkit/middleware/otel.py +203 -0
- rabbitkit/middleware/rate_limit.py +247 -0
- rabbitkit/middleware/retry.py +540 -0
- rabbitkit/middleware/signing.py +682 -0
- rabbitkit/middleware/timeout.py +291 -0
- rabbitkit/py.typed +0 -0
- rabbitkit/queue_metrics.py +174 -0
- rabbitkit/results/__init__.py +6 -0
- rabbitkit/results/backend.py +102 -0
- rabbitkit/results/middleware.py +123 -0
- rabbitkit/rpc.py +632 -0
- rabbitkit/serialization/__init__.py +25 -0
- rabbitkit/serialization/base.py +35 -0
- rabbitkit/serialization/json.py +122 -0
- rabbitkit/serialization/msgspec.py +136 -0
- rabbitkit/serialization/pipeline.py +255 -0
- rabbitkit/streams.py +139 -0
- rabbitkit/sync/__init__.py +11 -0
- rabbitkit/sync/batch.py +595 -0
- rabbitkit/sync/broker.py +996 -0
- rabbitkit/sync/connection.py +209 -0
- rabbitkit/sync/pool.py +262 -0
- rabbitkit/sync/transport.py +1085 -0
- rabbitkit/testing/__init__.py +20 -0
- rabbitkit/testing/app.py +99 -0
- rabbitkit/testing/broker.py +540 -0
- rabbitkit/testing/fixtures.py +56 -0
- rabbitkit-0.9.0.dist-info/METADATA +575 -0
- rabbitkit-0.9.0.dist-info/RECORD +95 -0
- rabbitkit-0.9.0.dist-info/WHEEL +5 -0
- rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
- rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
- rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""ExceptionMiddleware — outermost middleware for exception handling.
|
|
2
|
+
|
|
3
|
+
Catches exceptions AFTER retry gives up.
|
|
4
|
+
Provides fallback values for error recovery.
|
|
5
|
+
|
|
6
|
+
See Contract 1 for terminal vs non-terminal behavior.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from rabbitkit.core.message import RabbitMessage
|
|
16
|
+
from rabbitkit.middleware.base import BaseMiddleware
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExceptionMiddleware(BaseMiddleware):
|
|
22
|
+
"""Outermost middleware. Catches exceptions after retry gives up.
|
|
23
|
+
|
|
24
|
+
Features:
|
|
25
|
+
- Register exception handlers with fallback values
|
|
26
|
+
- Terminal exceptions (from retry exhaustion) are re-raised by default
|
|
27
|
+
- swallow_permanent=True opts in to swallowing permanent/exhausted failures
|
|
28
|
+
|
|
29
|
+
MANUAL mode restriction:
|
|
30
|
+
- MAY log the error
|
|
31
|
+
- MUST NOT auto-publish fallback to result_publisher unless msg.is_settled
|
|
32
|
+
- MUST NOT settle the message
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, *, swallow_permanent: bool = False) -> None:
|
|
36
|
+
self._handlers: dict[type[BaseException], Callable[[BaseException], Any]] = {}
|
|
37
|
+
self._swallow_permanent = swallow_permanent
|
|
38
|
+
|
|
39
|
+
def add_handler(
|
|
40
|
+
self,
|
|
41
|
+
exc_type: type[BaseException],
|
|
42
|
+
handler: Callable[[BaseException], Any],
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Register an exception handler with a fallback return value."""
|
|
45
|
+
self._handlers[exc_type] = handler
|
|
46
|
+
|
|
47
|
+
def consume_scope(
|
|
48
|
+
self,
|
|
49
|
+
call_next: Callable[[RabbitMessage], Any],
|
|
50
|
+
message: RabbitMessage,
|
|
51
|
+
) -> Any:
|
|
52
|
+
"""Wrap handler — catch exceptions, provide fallback values."""
|
|
53
|
+
try:
|
|
54
|
+
return call_next(message)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
return self._handle_exception(exc, message)
|
|
57
|
+
|
|
58
|
+
async def consume_scope_async(
|
|
59
|
+
self,
|
|
60
|
+
call_next: Callable[[RabbitMessage], Awaitable[Any]],
|
|
61
|
+
message: RabbitMessage,
|
|
62
|
+
) -> Any:
|
|
63
|
+
"""Async variant — catch exceptions, provide fallback values."""
|
|
64
|
+
try:
|
|
65
|
+
return await call_next(message)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
return self._handle_exception(exc, message)
|
|
68
|
+
|
|
69
|
+
def _handle_exception(self, exc: Exception, message: RabbitMessage) -> Any:
|
|
70
|
+
"""Handle an exception with registered handlers or re-raise.
|
|
71
|
+
|
|
72
|
+
Terminal exceptions (tagged with _rabbitkit_terminal=True by RetryMiddleware)
|
|
73
|
+
are only swallowed if swallow_permanent=True.
|
|
74
|
+
"""
|
|
75
|
+
is_terminal = getattr(exc, "_rabbitkit_terminal", False)
|
|
76
|
+
|
|
77
|
+
if is_terminal and not self._swallow_permanent:
|
|
78
|
+
logger.error(
|
|
79
|
+
"Terminal exception (permanent/exhausted): %s: %s",
|
|
80
|
+
type(exc).__name__,
|
|
81
|
+
exc,
|
|
82
|
+
)
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
# Try registered handlers
|
|
86
|
+
for exc_type, handler in self._handlers.items():
|
|
87
|
+
if isinstance(exc, exc_type):
|
|
88
|
+
logger.warning(
|
|
89
|
+
"Exception handled by %s handler: %s",
|
|
90
|
+
exc_type.__name__,
|
|
91
|
+
exc,
|
|
92
|
+
)
|
|
93
|
+
return handler(exc)
|
|
94
|
+
|
|
95
|
+
# No handler found
|
|
96
|
+
if is_terminal and self._swallow_permanent:
|
|
97
|
+
logger.warning(
|
|
98
|
+
"Swallowing terminal exception (swallow_permanent=True): %s: %s",
|
|
99
|
+
type(exc).__name__,
|
|
100
|
+
exc,
|
|
101
|
+
)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
# Re-raise unhandled non-terminal exceptions
|
|
105
|
+
raise
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Prometheus metrics middleware — tracks consume and publish operations.
|
|
2
|
+
|
|
3
|
+
Protocol-based approach: works with any Prometheus-compatible client
|
|
4
|
+
(prometheus_client, StatsD, custom implementations, etc.).
|
|
5
|
+
|
|
6
|
+
Metric names:
|
|
7
|
+
- rabbitkit_messages_consumed_total Counter(queue, status)
|
|
8
|
+
- rabbitkit_message_processing_seconds Histogram(queue)
|
|
9
|
+
- rabbitkit_messages_published_total Counter(exchange, status)
|
|
10
|
+
- rabbitkit_message_publish_seconds Histogram(exchange)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Awaitable, Callable
|
|
18
|
+
from typing import Any, Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from rabbitkit.core.config import MetricsConfig
|
|
21
|
+
from rabbitkit.core.message import RabbitMessage
|
|
22
|
+
from rabbitkit.core.types import MessageEnvelope
|
|
23
|
+
from rabbitkit.middleware.base import BaseMiddleware
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# ── Metric names (backwards-compat aliases pointing at the default config) ─
|
|
28
|
+
|
|
29
|
+
MESSAGES_CONSUMED_TOTAL = MetricsConfig().consumed_total
|
|
30
|
+
MESSAGE_PROCESSING_SECONDS = MetricsConfig().processing_seconds
|
|
31
|
+
MESSAGES_PUBLISHED_TOTAL = MetricsConfig().published_total
|
|
32
|
+
MESSAGE_PUBLISH_SECONDS = MetricsConfig().publish_seconds
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _queue_label(message: RabbitMessage) -> str:
|
|
36
|
+
"""Return the ``queue`` label value for a consumed message (M3).
|
|
37
|
+
|
|
38
|
+
Prefers the BOUND queue name (``x-rabbitkit-original-queue``, set by
|
|
39
|
+
the broker's ``on_message`` wrapper before any middleware runs) over the
|
|
40
|
+
message's routing key. A topic/``Path()`` routing key that embeds an ID,
|
|
41
|
+
tenant, or other per-message value is unbounded — using it directly as a
|
|
42
|
+
Prometheus label creates one time series per distinct value ever seen
|
|
43
|
+
(cardinality explosion), and the label was misnamed besides (it's a
|
|
44
|
+
routing key, not a queue). Falls back to ``routing_key`` only when the
|
|
45
|
+
header is absent — e.g. a message built directly in a test rather than
|
|
46
|
+
delivered through a real broker/``TestBroker`` consume path.
|
|
47
|
+
"""
|
|
48
|
+
original_queue = message.headers.get("x-rabbitkit-original-queue")
|
|
49
|
+
if original_queue:
|
|
50
|
+
return str(original_queue)
|
|
51
|
+
return message.routing_key or "unknown"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── Protocol ─────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@runtime_checkable
|
|
58
|
+
class MetricsCollector(Protocol):
|
|
59
|
+
"""Protocol for metrics collection — works with Prometheus, StatsD, etc."""
|
|
60
|
+
|
|
61
|
+
def inc_counter(self, name: str, labels: dict[str, str], value: float = 1.0) -> None:
|
|
62
|
+
"""Increment a counter metric."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
def observe_histogram(self, name: str, labels: dict[str, str], value: float) -> None:
|
|
66
|
+
"""Observe a value on a histogram metric."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
def set_gauge(self, name: str, labels: dict[str, str], value: float) -> None:
|
|
70
|
+
"""Set a gauge metric to an absolute value (e.g. queue depth)."""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── Prometheus implementation (optional import) ──────────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PrometheusCollector:
|
|
78
|
+
"""Concrete MetricsCollector that wraps the ``prometheus_client`` library.
|
|
79
|
+
|
|
80
|
+
The ``prometheus_client`` import is lazy — the library is only required
|
|
81
|
+
when this class is instantiated, not when the module is imported.
|
|
82
|
+
|
|
83
|
+
Usage::
|
|
84
|
+
|
|
85
|
+
collector = PrometheusCollector()
|
|
86
|
+
middleware = MetricsMiddleware(collector)
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self) -> None:
|
|
90
|
+
try:
|
|
91
|
+
import prometheus_client
|
|
92
|
+
except ImportError as exc:
|
|
93
|
+
msg = (
|
|
94
|
+
"PrometheusCollector requires the 'prometheus_client' package. "
|
|
95
|
+
"Install it with: pip install prometheus-client"
|
|
96
|
+
)
|
|
97
|
+
raise ImportError(msg) from exc
|
|
98
|
+
|
|
99
|
+
self._counters: dict[str, Any] = {}
|
|
100
|
+
self._histograms: dict[str, Any] = {}
|
|
101
|
+
self._gauges: dict[str, Any] = {}
|
|
102
|
+
self._prometheus_client = prometheus_client
|
|
103
|
+
|
|
104
|
+
def _get_counter(self, name: str, label_names: tuple[str, ...]) -> Any:
|
|
105
|
+
if name not in self._counters:
|
|
106
|
+
self._counters[name] = self._prometheus_client.Counter(
|
|
107
|
+
name,
|
|
108
|
+
f"rabbitkit {name}",
|
|
109
|
+
label_names,
|
|
110
|
+
)
|
|
111
|
+
return self._counters[name]
|
|
112
|
+
|
|
113
|
+
def _get_histogram(self, name: str, label_names: tuple[str, ...]) -> Any:
|
|
114
|
+
if name not in self._histograms:
|
|
115
|
+
self._histograms[name] = self._prometheus_client.Histogram(
|
|
116
|
+
name,
|
|
117
|
+
f"rabbitkit {name}",
|
|
118
|
+
label_names,
|
|
119
|
+
)
|
|
120
|
+
return self._histograms[name]
|
|
121
|
+
|
|
122
|
+
def _get_gauge(self, name: str, label_names: tuple[str, ...]) -> Any:
|
|
123
|
+
if name not in self._gauges:
|
|
124
|
+
self._gauges[name] = self._prometheus_client.Gauge(
|
|
125
|
+
name,
|
|
126
|
+
f"rabbitkit {name}",
|
|
127
|
+
label_names,
|
|
128
|
+
)
|
|
129
|
+
return self._gauges[name]
|
|
130
|
+
|
|
131
|
+
def inc_counter(self, name: str, labels: dict[str, str], value: float = 1.0) -> None:
|
|
132
|
+
"""Increment a Prometheus counter."""
|
|
133
|
+
label_names = tuple(sorted(labels.keys()))
|
|
134
|
+
counter = self._get_counter(name, label_names)
|
|
135
|
+
counter.labels(**labels).inc(value)
|
|
136
|
+
|
|
137
|
+
def observe_histogram(self, name: str, labels: dict[str, str], value: float) -> None:
|
|
138
|
+
"""Observe a value on a Prometheus histogram."""
|
|
139
|
+
label_names = tuple(sorted(labels.keys()))
|
|
140
|
+
histogram = self._get_histogram(name, label_names)
|
|
141
|
+
histogram.labels(**labels).observe(value)
|
|
142
|
+
|
|
143
|
+
def set_gauge(self, name: str, labels: dict[str, str], value: float) -> None:
|
|
144
|
+
"""Set a Prometheus gauge to an absolute value."""
|
|
145
|
+
label_names = tuple(sorted(labels.keys()))
|
|
146
|
+
gauge = self._get_gauge(name, label_names)
|
|
147
|
+
gauge.labels(**labels).set(value)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ── Middleware ────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class MetricsMiddleware(BaseMiddleware):
|
|
154
|
+
"""Tracks consume and publish metrics via a pluggable MetricsCollector.
|
|
155
|
+
|
|
156
|
+
If ``collector`` is None, all operations pass through without
|
|
157
|
+
any overhead (no-op mode).
|
|
158
|
+
|
|
159
|
+
Usage::
|
|
160
|
+
|
|
161
|
+
collector = PrometheusCollector()
|
|
162
|
+
middleware = MetricsMiddleware(collector)
|
|
163
|
+
|
|
164
|
+
Or with a custom collector::
|
|
165
|
+
|
|
166
|
+
class MyCollector:
|
|
167
|
+
def inc_counter(self, name, labels, value=1.0): ...
|
|
168
|
+
def observe_histogram(self, name, labels, value): ...
|
|
169
|
+
|
|
170
|
+
middleware = MetricsMiddleware(MyCollector())
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
collector: Any object satisfying the MetricsCollector protocol.
|
|
174
|
+
None for no-op (passthrough) mode.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
collector: MetricsCollector | None = None,
|
|
180
|
+
config: MetricsConfig | None = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
self._collector = collector
|
|
183
|
+
self._cfg = config or MetricsConfig()
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def collector(self) -> MetricsCollector | None:
|
|
187
|
+
"""The configured collector (None in no-op mode). Read by
|
|
188
|
+
``HandlerPipeline``/``RetryMiddleware`` to emit settlement/retry
|
|
189
|
+
metrics they observe but this middleware itself cannot (M2) --
|
|
190
|
+
settlement happens in the pipeline's own ack-orchestration code,
|
|
191
|
+
outside this middleware's ``consume_scope``."""
|
|
192
|
+
return self._collector
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def config(self) -> MetricsConfig:
|
|
196
|
+
return self._cfg
|
|
197
|
+
|
|
198
|
+
def record_settlement(self, message: RabbitMessage, disposition: str) -> None:
|
|
199
|
+
"""Emit the ack/nack/reject counter for a settled message (M2).
|
|
200
|
+
|
|
201
|
+
``consume_scope``/``consume_scope_async`` only wrap handler
|
|
202
|
+
execution -- final settlement (ack/nack/reject per AckPolicy) is
|
|
203
|
+
decided by the pipeline's own ack-orchestration code, which runs
|
|
204
|
+
AFTER this middleware's wrapped call returns. ``HandlerPipeline``
|
|
205
|
+
calls this directly once a route's message is settled, if a
|
|
206
|
+
``MetricsMiddleware`` is present on that route.
|
|
207
|
+
"""
|
|
208
|
+
if self._collector is None:
|
|
209
|
+
return
|
|
210
|
+
name = {
|
|
211
|
+
"acked": self._cfg.messages_acked_total,
|
|
212
|
+
"nacked": self._cfg.messages_nacked_total,
|
|
213
|
+
"rejected": self._cfg.messages_rejected_total,
|
|
214
|
+
}.get(disposition)
|
|
215
|
+
if name is None:
|
|
216
|
+
return # pragma: no cover - defensive, disposition is always one of the three
|
|
217
|
+
self._collector.inc_counter(name, {"queue": _queue_label(message)})
|
|
218
|
+
|
|
219
|
+
# ── Consume-side ──────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
def consume_scope(
|
|
222
|
+
self,
|
|
223
|
+
call_next: Callable[[RabbitMessage], Any],
|
|
224
|
+
message: RabbitMessage,
|
|
225
|
+
) -> Any:
|
|
226
|
+
"""Wrap handler execution with metrics tracking (sync)."""
|
|
227
|
+
if self._collector is None:
|
|
228
|
+
return call_next(message)
|
|
229
|
+
|
|
230
|
+
queue = _queue_label(message)
|
|
231
|
+
if message.redelivered:
|
|
232
|
+
# Broker-redelivery rate: a sustained rise means handlers are
|
|
233
|
+
# dying/timing out before acking, which the success/error
|
|
234
|
+
# consume counters alone can't distinguish from normal traffic.
|
|
235
|
+
self._collector.inc_counter(
|
|
236
|
+
self._cfg.messages_redelivered_total,
|
|
237
|
+
{"queue": queue},
|
|
238
|
+
)
|
|
239
|
+
start = time.monotonic()
|
|
240
|
+
try:
|
|
241
|
+
result = call_next(message)
|
|
242
|
+
except BaseException:
|
|
243
|
+
self._collector.inc_counter(
|
|
244
|
+
self._cfg.consumed_total,
|
|
245
|
+
{"queue": queue, "status": "error"},
|
|
246
|
+
)
|
|
247
|
+
self._collector.observe_histogram(
|
|
248
|
+
self._cfg.processing_seconds,
|
|
249
|
+
{"queue": queue},
|
|
250
|
+
time.monotonic() - start,
|
|
251
|
+
)
|
|
252
|
+
raise
|
|
253
|
+
else:
|
|
254
|
+
self._collector.inc_counter(
|
|
255
|
+
self._cfg.consumed_total,
|
|
256
|
+
{"queue": queue, "status": "success"},
|
|
257
|
+
)
|
|
258
|
+
self._collector.observe_histogram(
|
|
259
|
+
self._cfg.processing_seconds,
|
|
260
|
+
{"queue": queue},
|
|
261
|
+
time.monotonic() - start,
|
|
262
|
+
)
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
async def consume_scope_async(
|
|
266
|
+
self,
|
|
267
|
+
call_next: Callable[[RabbitMessage], Awaitable[Any]],
|
|
268
|
+
message: RabbitMessage,
|
|
269
|
+
) -> Any:
|
|
270
|
+
"""Wrap handler execution with metrics tracking (async)."""
|
|
271
|
+
if self._collector is None:
|
|
272
|
+
return await call_next(message)
|
|
273
|
+
|
|
274
|
+
queue = _queue_label(message)
|
|
275
|
+
if message.redelivered:
|
|
276
|
+
# Broker-redelivery rate — see consume_scope.
|
|
277
|
+
self._collector.inc_counter(
|
|
278
|
+
self._cfg.messages_redelivered_total,
|
|
279
|
+
{"queue": queue},
|
|
280
|
+
)
|
|
281
|
+
start = time.monotonic()
|
|
282
|
+
try:
|
|
283
|
+
result = await call_next(message)
|
|
284
|
+
except BaseException:
|
|
285
|
+
self._collector.inc_counter(
|
|
286
|
+
self._cfg.consumed_total,
|
|
287
|
+
{"queue": queue, "status": "error"},
|
|
288
|
+
)
|
|
289
|
+
self._collector.observe_histogram(
|
|
290
|
+
self._cfg.processing_seconds,
|
|
291
|
+
{"queue": queue},
|
|
292
|
+
time.monotonic() - start,
|
|
293
|
+
)
|
|
294
|
+
raise
|
|
295
|
+
else:
|
|
296
|
+
self._collector.inc_counter(
|
|
297
|
+
self._cfg.consumed_total,
|
|
298
|
+
{"queue": queue, "status": "success"},
|
|
299
|
+
)
|
|
300
|
+
self._collector.observe_histogram(
|
|
301
|
+
self._cfg.processing_seconds,
|
|
302
|
+
{"queue": queue},
|
|
303
|
+
time.monotonic() - start,
|
|
304
|
+
)
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
# ── Publish-side ──────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
def publish_scope(
|
|
310
|
+
self,
|
|
311
|
+
call_next: Callable[[MessageEnvelope], Any],
|
|
312
|
+
envelope: MessageEnvelope,
|
|
313
|
+
) -> Any:
|
|
314
|
+
"""Wrap outgoing publish with metrics tracking (sync)."""
|
|
315
|
+
if self._collector is None:
|
|
316
|
+
return call_next(envelope)
|
|
317
|
+
|
|
318
|
+
exchange = envelope.exchange or "default"
|
|
319
|
+
start = time.monotonic()
|
|
320
|
+
try:
|
|
321
|
+
result = call_next(envelope)
|
|
322
|
+
except BaseException:
|
|
323
|
+
self._collector.inc_counter(
|
|
324
|
+
self._cfg.published_total,
|
|
325
|
+
{"exchange": exchange, "status": "error"},
|
|
326
|
+
)
|
|
327
|
+
self._collector.observe_histogram(
|
|
328
|
+
self._cfg.publish_seconds,
|
|
329
|
+
{"exchange": exchange},
|
|
330
|
+
time.monotonic() - start,
|
|
331
|
+
)
|
|
332
|
+
raise
|
|
333
|
+
else:
|
|
334
|
+
self._collector.inc_counter(
|
|
335
|
+
self._cfg.published_total,
|
|
336
|
+
{"exchange": exchange, "status": "success"},
|
|
337
|
+
)
|
|
338
|
+
self._collector.observe_histogram(
|
|
339
|
+
self._cfg.publish_seconds,
|
|
340
|
+
{"exchange": exchange},
|
|
341
|
+
time.monotonic() - start,
|
|
342
|
+
)
|
|
343
|
+
return result
|
|
344
|
+
|
|
345
|
+
async def publish_scope_async(
|
|
346
|
+
self,
|
|
347
|
+
call_next: Callable[[MessageEnvelope], Awaitable[Any]],
|
|
348
|
+
envelope: MessageEnvelope,
|
|
349
|
+
) -> Any:
|
|
350
|
+
"""Wrap outgoing publish with metrics tracking (async)."""
|
|
351
|
+
if self._collector is None:
|
|
352
|
+
return await call_next(envelope)
|
|
353
|
+
|
|
354
|
+
exchange = envelope.exchange or "default"
|
|
355
|
+
start = time.monotonic()
|
|
356
|
+
try:
|
|
357
|
+
result = await call_next(envelope)
|
|
358
|
+
except BaseException:
|
|
359
|
+
self._collector.inc_counter(
|
|
360
|
+
self._cfg.published_total,
|
|
361
|
+
{"exchange": exchange, "status": "error"},
|
|
362
|
+
)
|
|
363
|
+
self._collector.observe_histogram(
|
|
364
|
+
self._cfg.publish_seconds,
|
|
365
|
+
{"exchange": exchange},
|
|
366
|
+
time.monotonic() - start,
|
|
367
|
+
)
|
|
368
|
+
raise
|
|
369
|
+
else:
|
|
370
|
+
self._collector.inc_counter(
|
|
371
|
+
self._cfg.published_total,
|
|
372
|
+
{"exchange": exchange, "status": "success"},
|
|
373
|
+
)
|
|
374
|
+
self._collector.observe_histogram(
|
|
375
|
+
self._cfg.publish_seconds,
|
|
376
|
+
{"exchange": exchange},
|
|
377
|
+
time.monotonic() - start,
|
|
378
|
+
)
|
|
379
|
+
return result
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ── Prometheus exposition (M-SRE1) ────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def metrics_app() -> Callable[[Any, Any, Any], Awaitable[None]]:
|
|
386
|
+
"""Return a minimal ASGI app that exposes ``/metrics`` in Prometheus text format.
|
|
387
|
+
|
|
388
|
+
Requires ``prometheus_client`` (lazy-imported on first request). Mount it
|
|
389
|
+
behind your existing ASGI server (uvicorn, hypercorn) or the dashboard::
|
|
390
|
+
|
|
391
|
+
from rabbitkit.middleware.metrics import metrics_app
|
|
392
|
+
app = metrics_app()
|
|
393
|
+
# uvicorn rabbitkit.middleware.metrics:metrics_app (after binding the factory)
|
|
394
|
+
|
|
395
|
+
For a stdlib one-liner without an ASGI server, see :func:`start_metrics_server`.
|
|
396
|
+
"""
|
|
397
|
+
try:
|
|
398
|
+
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
|
399
|
+
except ImportError as exc: # pragma: no cover
|
|
400
|
+
raise ImportError(
|
|
401
|
+
"metrics_app() requires the 'prometheus_client' package. Install it with: pip install prometheus-client"
|
|
402
|
+
) from exc
|
|
403
|
+
|
|
404
|
+
async def app(scope: Any, receive: Any, send: Any) -> None:
|
|
405
|
+
if scope["type"] != "http" or scope.get("path") != "/metrics":
|
|
406
|
+
await send({"type": "http.response.start", "status": 404, "headers": []})
|
|
407
|
+
await send({"type": "http.response.body", "body": b"not found", "more_body": False})
|
|
408
|
+
return
|
|
409
|
+
body = generate_latest()
|
|
410
|
+
headers = [[b"content-type", CONTENT_TYPE_LATEST.encode()]]
|
|
411
|
+
await send({"type": "http.response.start", "status": 200, "headers": headers})
|
|
412
|
+
await send({"type": "http.response.body", "body": body, "more_body": False})
|
|
413
|
+
|
|
414
|
+
return app
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def start_metrics_server(port: int = 8000, host: str = "127.0.0.1") -> None:
|
|
418
|
+
"""Start a background HTTP server exposing ``/metrics`` on ``port``.
|
|
419
|
+
|
|
420
|
+
Thin wrapper around ``prometheus_client.start_http_server``. Call once at
|
|
421
|
+
process startup (e.g. in a ``RabbitApp.on_startup`` hook). For k8s, scrape
|
|
422
|
+
this port with a ``ServiceMonitor`` / ``PodMonitor``.
|
|
423
|
+
|
|
424
|
+
The default ``host`` is ``127.0.0.1`` (loopback only) so the metrics
|
|
425
|
+
endpoint is not exposed to the network by default. For k8s / multi-host
|
|
426
|
+
scrapers pass ``host="0.0.0.0"`` explicitly and restrict access with a
|
|
427
|
+
NetworkPolicy (the metrics endpoint is unauthenticated and exposes broker
|
|
428
|
+
topology/throughput — never expose it publicly without authn in front).
|
|
429
|
+
|
|
430
|
+
Requires ``prometheus_client``.
|
|
431
|
+
"""
|
|
432
|
+
try:
|
|
433
|
+
from prometheus_client import start_http_server
|
|
434
|
+
except ImportError as exc: # pragma: no cover
|
|
435
|
+
raise ImportError(
|
|
436
|
+
"start_metrics_server() requires the 'prometheus_client' package. "
|
|
437
|
+
"Install it with: pip install prometheus-client"
|
|
438
|
+
) from exc
|
|
439
|
+
start_http_server(port, host)
|
|
440
|
+
logger.info("Prometheus metrics server on http://%s:%d/metrics", host, port)
|