Skip to content

Commit 616004a

Browse files
authored
fix: instrument every FastStream broker, with per-type middleware classes (#299)
1 parent 8dae606 commit 616004a

4 files changed

Lines changed: 208 additions & 25 deletions

File tree

‎docs/integrations/faststream.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,41 @@ application = bootstrapper.bootstrap()
5151
```
5252

5353
Read more about available configuration options [here](../introduction/configuration.md).
54+
55+
## Multiple brokers
56+
57+
Tracing, metrics and logging cover every broker the application has when `bootstrap()` runs. Add
58+
brokers with `add_broker` before calling `bootstrap()`: a broker added later is not instrumented.
59+
60+
Each FastStream middleware class handles one broker type. A single class in
61+
`opentelemetry_middleware_cls` or `prometheus_middleware_cls` goes on every broker of the first
62+
broker's type. For an application with brokers of different types, pass a mapping from broker class
63+
to middleware class:
64+
65+
```python
66+
from faststream.kafka import KafkaBroker
67+
from faststream.kafka.opentelemetry import KafkaTelemetryMiddleware
68+
from faststream.kafka.prometheus import KafkaPrometheusMiddleware
69+
from faststream.redis import RedisBroker
70+
from faststream.redis.opentelemetry import RedisTelemetryMiddleware
71+
from faststream.redis.prometheus import RedisPrometheusMiddleware
72+
73+
74+
application = AsgiFastStream(KafkaBroker(), RedisBroker())
75+
bootstrapper_config = FastStreamConfig(
76+
service_name="microservice",
77+
opentelemetry_endpoint="otl",
78+
opentelemetry_middleware_cls={
79+
KafkaBroker: KafkaTelemetryMiddleware,
80+
RedisBroker: RedisTelemetryMiddleware,
81+
},
82+
prometheus_middleware_cls={
83+
KafkaBroker: KafkaPrometheusMiddleware,
84+
RedisBroker: RedisPrometheusMiddleware,
85+
},
86+
application=application,
87+
)
88+
```
89+
90+
A broker whose type has no middleware class is left uninstrumented, and `bootstrap()` warns with its
91+
type name. All brokers share one Prometheus registry; each metric's `broker` label tells them apart.

‎docs/introduction/configuration.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ To bootstrap Prometheus for FastStream, you must provide at least one of:
7676

7777
- `prometheus_middleware_cls` - the broker metrics middleware, e.g.
7878
`faststream.redis.prometheus.RedisPrometheusMiddleware`. It is constructed with the instrument's
79-
registry and added to the broker, when the application has one.
79+
registry and added to every broker of the first broker's type. For an application with brokers of
80+
different types, pass a mapping from broker class to middleware class instead; see
81+
[multiple brokers](../integrations/faststream.md#multiple-brokers).
8082
- `prometheus_collector_registry` - a `prometheus_client.CollectorRegistry` of your own, used in
8183
place of the fresh one the instrument would otherwise build.
8284

@@ -183,8 +185,9 @@ For FastAPI and Litestar there is additionally:
183185
For FastStream there is additionally:
184186

185187
- `opentelemetry_middleware_cls` - the broker telemetry middleware, e.g.
186-
`faststream.redis.opentelemetry.RedisTelemetryMiddleware`. You must provide it to trace broker
187-
messages. Without it the rest of the configuration above still applies: the exporter is built, the
188+
`faststream.redis.opentelemetry.RedisTelemetryMiddleware`, or a mapping from broker class to
189+
middleware class (see [multiple brokers](../integrations/faststream.md#multiple-brokers)). You must
190+
provide it to trace broker messages. Without it the rest of the configuration above still applies: the exporter is built, the
188191
health-check span is recorded unless `opentelemetry_generate_health_check_spans` is False, and any
189192
`opentelemetry_instrumentors` are applied.
190193

‎lite_bootstrap/bootstrappers/faststream_bootstrapper.py‎

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import json
44
import logging
55
import typing
6+
from collections.abc import Mapping, Sequence
67

78
from lite_bootstrap import import_checker
89
from lite_bootstrap.bootstrappers.base import BaseBootstrapper
10+
from lite_bootstrap.helpers.warn import warn_at_caller
911
from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksConfig, HealthChecksInstrument
1012
from lite_bootstrap.instruments.logging_instrument import LoggingConfig, LoggingInstrument
1113
from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryConfig, OpenTelemetryInstrument
@@ -65,6 +67,34 @@ def __init__(
6567
def __call__(self, msg: typing.Any, /, *, context: typing.Any) -> typing.Any: ... # noqa: ANN401
6668

6769

70+
_MiddlewareT = typing.TypeVar("_MiddlewareT")
71+
72+
73+
def _middleware_targets(
74+
brokers: Sequence[typing.Any],
75+
configured: "type[_MiddlewareT] | Mapping[type, type[_MiddlewareT]]",
76+
field_name: str,
77+
) -> list[tuple[typing.Any, "type[_MiddlewareT]"]]:
78+
by_broker_type = configured if isinstance(configured, Mapping) else {type(brokers[0]): configured}
79+
targets: list[tuple[typing.Any, type[_MiddlewareT]]] = []
80+
skipped: dict[str, None] = {}
81+
for broker in brokers:
82+
middleware_cls = next(
83+
(by_broker_type[one] for one in type(broker).__mro__ if one in by_broker_type),
84+
None,
85+
)
86+
if middleware_cls is None:
87+
skipped[repr(type(broker).__name__)] = None
88+
else:
89+
targets.append((broker, middleware_cls))
90+
if skipped:
91+
warn_at_caller(
92+
f"FastStream brokers {', '.join(skipped)} are not instrumented: {field_name} has no middleware "
93+
"class for their type. Pass a mapping from broker class to middleware class to cover them."
94+
)
95+
return targets
96+
97+
6898
def _make_asgi_faststream() -> "AsgiFastStream":
6999
return AsgiFastStream()
70100

@@ -74,8 +104,14 @@ class FastStreamConfig(
74104
HealthChecksConfig, LoggingConfig, OpenTelemetryConfig, PrometheusConfig, PyroscopeConfig, SentryConfig
75105
):
76106
application: "AsgiFastStream" = dataclasses.field(default_factory=_make_asgi_faststream)
77-
opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None
78-
prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None
107+
opentelemetry_middleware_cls: (
108+
type[FastStreamTelemetryMiddlewareProtocol] | Mapping[type, type[FastStreamTelemetryMiddlewareProtocol]] | None
109+
) = None
110+
prometheus_middleware_cls: (
111+
type[FastStreamPrometheusMiddlewareProtocol]
112+
| Mapping[type, type[FastStreamPrometheusMiddlewareProtocol]]
113+
| None
114+
) = None
79115
prometheus_collector_registry: "prometheus_client.CollectorRegistry | None" = None
80116
faststream_log_level: int = logging.WARNING
81117
faststream_health_check_broker_timeout: float = 5.0
@@ -112,27 +148,25 @@ async def _define_framework_health_status(self) -> bool:
112148

113149
@dataclasses.dataclass(kw_only=True)
114150
class FastStreamLoggingInstrument(LoggingInstrument[FastStreamConfig]):
115-
_prior_broker_params_storage: typing.Any = dataclasses.field(default=None, init=False, repr=False, compare=False)
116-
_broker_logger_replaced: bool = dataclasses.field(default=False, init=False, repr=False, compare=False)
151+
_prior_broker_params_storages: list[tuple[typing.Any, typing.Any]] = dataclasses.field(
152+
default_factory=list, init=False, repr=False, compare=False
153+
)
117154

118155
def bootstrap(self) -> None:
119156
super().bootstrap()
120-
broker = self.bootstrap_config.application.broker
121-
if broker is not None and import_checker.is_structlog_installed and import_checker.is_faststream_installed:
157+
brokers = self.bootstrap_config.application.brokers
158+
if brokers and import_checker.is_structlog_installed and import_checker.is_faststream_installed:
122159
logger = structlog.get_logger("faststream")
123160
logger.setLevel(self.bootstrap_config.faststream_log_level)
124-
self._prior_broker_params_storage = broker.config.logger.params_storage
125-
broker.config.logger.params_storage = ManualLoggerStorage(logger)
126-
self._broker_logger_replaced = True
161+
for broker in brokers:
162+
self._prior_broker_params_storages.append((broker, broker.config.logger.params_storage))
163+
broker.config.logger.params_storage = ManualLoggerStorage(logger)
127164

128165
def teardown(self) -> None:
129166
try:
130-
if self._broker_logger_replaced:
131-
broker = self.bootstrap_config.application.broker
132-
if broker is not None:
133-
broker.config.logger.params_storage = self._prior_broker_params_storage
134-
self._broker_logger_replaced = False
135-
self._prior_broker_params_storage = None
167+
while self._prior_broker_params_storages:
168+
broker, prior_storage = self._prior_broker_params_storages.pop()
169+
broker.config.logger.params_storage = prior_storage
136170
finally:
137171
super().teardown()
138172

@@ -142,12 +176,13 @@ class FastStreamOpenTelemetryInstrument(OpenTelemetryInstrument[FastStreamConfig
142176
def bootstrap(self) -> None:
143177
super().bootstrap()
144178
config = self.bootstrap_config
145-
if config.opentelemetry_middleware_cls and config.application.broker:
146-
config.application.broker.add_middleware(
147-
config.opentelemetry_middleware_cls(
148-
tracer_provider=get_tracer_provider(), meter_provider=get_meter_provider()
179+
if config.opentelemetry_middleware_cls and config.application.brokers:
180+
for broker, middleware_cls in _middleware_targets(
181+
config.application.brokers, config.opentelemetry_middleware_cls, "opentelemetry_middleware_cls"
182+
):
183+
broker.add_middleware(
184+
middleware_cls(tracer_provider=get_tracer_provider(), meter_provider=get_meter_provider())
149185
)
150-
)
151186

152187

153188
def _make_collector_registry() -> "prometheus_client.CollectorRegistry":
@@ -183,8 +218,11 @@ def bootstrap(self) -> None:
183218
config.application.mount(
184219
config.prometheus_metrics_path, prometheus_client.make_asgi_app(self.collector_registry)
185220
)
186-
if config.prometheus_middleware_cls and config.application.broker:
187-
config.application.broker.add_middleware(config.prometheus_middleware_cls(registry=self.collector_registry))
221+
if config.prometheus_middleware_cls and config.application.brokers:
222+
for broker, middleware_cls in _middleware_targets(
223+
config.application.brokers, config.prometheus_middleware_cls, "prometheus_middleware_cls"
224+
):
225+
broker.add_middleware(middleware_cls(registry=self.collector_registry))
188226

189227

190228
class FastStreamBootstrapper(BaseBootstrapper["AsgiFastStream"]):

‎tests/test_faststream_bootstrap.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,3 +511,107 @@ async def test_faststream_health_check_fails_on_broker_ping_despite_passing_prob
511511

512512
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
513513
assert response.json()["health_status"] is False
514+
515+
516+
class OtherRedisBroker(RedisBroker): ...
517+
518+
519+
class OtherTelemetryMiddleware(RedisTelemetryMiddleware): ...
520+
521+
522+
class OtherPrometheusMiddleware(RedisPrometheusMiddleware): ...
523+
524+
525+
def _middleware_types(broker: BrokerUsecase[typing.Any, typing.Any, typing.Any]) -> list[type]:
526+
return [type(middleware) for middleware in broker.config.broker_middlewares]
527+
528+
529+
def _bootstrap_with_brokers(
530+
brokers: list[BrokerUsecase[typing.Any, typing.Any, typing.Any]], **overrides: object
531+
) -> FastStreamBootstrapper:
532+
config = dataclasses.replace(build_faststream_config(broker=brokers[0]), **overrides)
533+
for extra in brokers[1:]:
534+
config.application.add_broker(extra)
535+
bootstrapper = FastStreamBootstrapper(bootstrap_config=config)
536+
bootstrapper.bootstrap()
537+
return bootstrapper
538+
539+
540+
def test_faststream_middlewares_are_added_to_every_broker(
541+
broker: RedisBroker, recwarn: pytest.WarningsRecorder
542+
) -> None:
543+
second_broker = RedisBroker()
544+
bootstrapper = _bootstrap_with_brokers([broker, second_broker])
545+
try:
546+
for one in (broker, second_broker):
547+
assert _middleware_types(one) == [RedisTelemetryMiddleware, RedisPrometheusMiddleware]
548+
assert not [one for one in recwarn if "not instrumented" in str(one.message)]
549+
finally:
550+
bootstrapper.teardown()
551+
552+
553+
def test_faststream_single_middleware_class_skips_brokers_of_another_type_with_a_warning(broker: RedisBroker) -> None:
554+
first_broker = OtherRedisBroker()
555+
with pytest.warns(UserWarning, match=r"not instrumented") as recorded:
556+
bootstrapper = _bootstrap_with_brokers(
557+
[first_broker, broker],
558+
opentelemetry_middleware_cls=OtherTelemetryMiddleware,
559+
prometheus_middleware_cls=OtherPrometheusMiddleware,
560+
)
561+
try:
562+
assert _middleware_types(first_broker) == [OtherTelemetryMiddleware, OtherPrometheusMiddleware]
563+
assert _middleware_types(broker) == []
564+
skipped_messages = [str(one.message) for one in recorded if "not instrumented" in str(one.message)]
565+
expected_warning_count = 2
566+
assert len(skipped_messages) == expected_warning_count
567+
assert all("'RedisBroker'" in message for message in skipped_messages)
568+
finally:
569+
bootstrapper.teardown()
570+
571+
572+
def test_faststream_middleware_mapping_picks_the_most_specific_class_per_broker(broker: RedisBroker) -> None:
573+
other_broker = OtherRedisBroker()
574+
bootstrapper = _bootstrap_with_brokers(
575+
[broker, other_broker],
576+
opentelemetry_middleware_cls={
577+
RedisBroker: RedisTelemetryMiddleware,
578+
OtherRedisBroker: OtherTelemetryMiddleware,
579+
},
580+
prometheus_middleware_cls={
581+
RedisBroker: RedisPrometheusMiddleware,
582+
OtherRedisBroker: OtherPrometheusMiddleware,
583+
},
584+
)
585+
try:
586+
assert _middleware_types(broker) == [RedisTelemetryMiddleware, RedisPrometheusMiddleware]
587+
assert _middleware_types(other_broker) == [OtherTelemetryMiddleware, OtherPrometheusMiddleware]
588+
finally:
589+
bootstrapper.teardown()
590+
591+
592+
def test_faststream_middleware_mapping_without_an_entry_skips_the_broker_with_a_warning(broker: RedisBroker) -> None:
593+
other_broker = OtherRedisBroker()
594+
with pytest.warns(UserWarning, match=r"'RedisBroker'.*not instrumented"):
595+
bootstrapper = _bootstrap_with_brokers(
596+
[other_broker, broker],
597+
opentelemetry_middleware_cls={OtherRedisBroker: OtherTelemetryMiddleware},
598+
prometheus_middleware_cls=None,
599+
)
600+
try:
601+
assert _middleware_types(other_broker) == [OtherTelemetryMiddleware]
602+
assert _middleware_types(broker) == []
603+
finally:
604+
bootstrapper.teardown()
605+
606+
607+
def test_faststream_logging_covers_every_broker_and_teardown_restores_each(broker: RedisBroker) -> None:
608+
second_broker = RedisBroker()
609+
original_storages = [broker.config.logger.params_storage, second_broker.config.logger.params_storage]
610+
bootstrapper = _bootstrap_with_brokers([broker, second_broker])
611+
try:
612+
for one in (broker, second_broker):
613+
assert isinstance(one.config.logger.params_storage, ManualLoggerStorage)
614+
finally:
615+
bootstrapper.teardown()
616+
617+
assert [broker.config.logger.params_storage, second_broker.config.logger.params_storage] == original_storages

0 commit comments

Comments
 (0)