You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat: surface drain_timeout on the bundled Prometheus/OTel adapters (#96)
#92 added the drain_timeout recorder event but only the raw recorder seam saw it —
the bundled adapters ignored it (forward-compatible) and the docs didn't mention it,
so operators got no out-of-the-box metric. Complete the feature:
- Prometheus: faststream_outbox_drain_timeout_total counter (consume labels).
- OTel: messaging.outbox.drain_timeout meter counter (operation=process).
- Docs: observability.md recorder-event table + event set; CLAUDE.md and
architecture/metrics.md emission lists.
Added PLR0911 to the two adapters' __call__ noqa (one more event branch in the flat
event-dispatch dispatch, same shape as the existing C901/PLR0912 suppressions).
Tests: test_{prometheus,otel}_drain_timeout_* assert the counter increments.
just test -> 518 passed, 100% coverage; just lint clean; mkdocs --strict builds.
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Copy file name to clipboardExpand all lines: CLAUDE.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -174,7 +174,7 @@ Caller owns the `AsyncEngine` — the broker never disposes it. The engine lives
174
174
175
175
Two complementary seams — **don't collapse them.**
176
176
177
-
-**Recorder seam** (`OutboxBroker(..., metrics_recorder=...)`): `Callable[[str, Mapping[str, Any]], None]`. Subscriber emits `fetched`, `dispatched`, `acked`, `nacked_retried`, `nacked_terminal`, `lease_lost`, plus `dlq_written` when `dlq_table` is set. Producer emits `published`. Default `_noop_recorder` lets sites fire unconditionally. Every call site is wrapped in `try/except` + DEBUG log. **Recorder must not block** (sync `Counter.inc()` fine; HTTP/StatsD not). `dlq_written` vs `nacked_terminal` divergence detects DLQ misconfiguration.
177
+
-**Recorder seam** (`OutboxBroker(..., metrics_recorder=...)`): `Callable[[str, Mapping[str, Any]], None]`. Subscriber emits `fetched`, `dispatched`, `acked`, `nacked_retried`, `nacked_terminal`, `lease_lost`, `drain_timeout` (on a timed-out `stop()` drain), plus `dlq_written` when `dlq_table` is set. Producer emits `published`. The bundled Prometheus/OTel adapters translate every one of these (`drain_timeout` → `_outbox_drain_timeout_total` / `messaging.outbox.drain_timeout`). Default `_noop_recorder` lets sites fire unconditionally. Every call site is wrapped in `try/except` + DEBUG log. **Recorder must not block** (sync `Counter.inc()` fine; HTTP/StatsD not). `dlq_written` vs `nacked_terminal` divergence detects DLQ misconfiguration.
178
178
-**Native middleware** (`opentelemetry/`, `prometheus/`): thin subclasses of upstream's `TelemetryMiddleware[OutboxPublishCommand]` and `PrometheusMiddleware[OutboxInnerMessage, OutboxPublishCommand]`. Register via the public `OutboxBroker(..., middlewares=[...])` constructor kwarg (forwarded internally as `broker_middlewares`). Fire on `consume_scope` (via `dispatch_one → self.consume(row)`) and `publish_scope` (via `_basic_publish`).
179
179
180
180
Why two: middleware owns `consume_scope` / `publish_scope` (spans, durations, status, size). Recorder owns events **outside** the bus — `fetched` (no `StreamMessage` at fetch time), `lease_lost` (after `consume_scope` exits), `nacked_terminal(reason="max_deliveries")` (before consume opens). Each fires for events the other physically cannot observe.
`OutboxBroker(..., metrics_recorder=...)` accepts a `MetricsRecorder = Callable[[str, Mapping[str, Any]], None]`. The default (`_noop_recorder`) lets instrumentation sites call unconditionally. The recorder threads through `OutboxBrokerConfig.metrics_recorder` to two places:
8
8
9
-
-**Subscriber emission points** (`OutboxSubscriber._emit_metric`): `fetched`, `dispatched`, `acked`, `nacked_retried`, `nacked_terminal`, `lease_lost`, plus `dlq_written` when `dlq_table` is configured.
9
+
-**Subscriber emission points** (`OutboxSubscriber._emit_metric`): `fetched`, `dispatched`, `acked`, `nacked_retried`, `nacked_terminal`, `lease_lost`, `drain_timeout` (a `stop()` drain that exceeded `graceful_timeout`), plus `dlq_written` when `dlq_table` is configured.
The producer reads the recorder from its own constructor kwarg (passed in alongside the config field) so the canonical insert path doesn't have to reach through the broker config at call time.
# tags always include "queue"; subscriber-side events also include "subscriber"
33
33
print(event, tags)
34
34
@@ -82,6 +82,7 @@ broken recorder never poisons the dispatch loop.
82
82
|`lease_lost`|`queue`, `subscriber`, `phase`, `row_id`, `deliveries_count`|| Terminal or retry write found `rowcount == 0` (`phase` = `terminal`\|`retry`) |
83
83
|`published`|`queue`, `status`, `count`, `size_bytes`, `duration_seconds`|`exception_type`| Producer, after the INSERT executes (pre-commit; also fires on error with `status="error"`) |
84
84
|`dlq_written`|`queue`, `subscriber`, `deliveries_count`, `failure_reason`|`exception_type`| DLQ CTE wrote an audit row. `exception_type` is **omitted** — not set to `None` — when the terminal had no exception (`max_deliveries`, or a manual `reject()` without one) |
85
+
|`drain_timeout`|`queue`, `subscriber`, `drain_timeout_seconds`|| A `stop()` drain exceeded `graceful_timeout`; in-flight rows were abandoned to lease-expiry retry. `queue` is the subscriber's **first** queue |
85
86
86
87
`reason` on `nacked_terminal` is one of `max_deliveries`,
87
88
`retry_terminal`, `rejected`. The same value lands in the DLQ
0 commit comments