Skip to content

Commit efc3239

Browse files
committed
fix: auto-wire MetricsFilter, add actuator/admin to FastAPI, fix Windows crash
- MetricsFilter (http_requests_total, http_request_duration_seconds, http_active_requests) is now auto-wired via MetricsAutoConfiguration when prometheus_client is installed. Previously it existed as dead code that was never registered as a bean. - FastAPI adapter now mounts actuator endpoints, admin dashboard, WebSocket, SSE, and OAuth2 login routes (previously Starlette-only). - RuntimeProvider no longer crashes on Windows (resource module import moved inside try/except with graceful fallback). - MetricsFilter Prometheus collectors moved to module-level singletons to prevent duplicate registration errors across instances.
1 parent bcd756d commit efc3239

5 files changed

Lines changed: 231 additions & 21 deletions

File tree

src/pyfly/admin/providers/runtime_provider.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
import gc
1919
import os
2020
import platform
21-
import resource
2221
import threading
2322
import time
2423
from typing import Any
@@ -42,11 +41,17 @@ async def get_runtime(self) -> dict[str, Any]:
4241

4342
@staticmethod
4443
def _get_memory() -> dict[str, Any]:
45-
usage = resource.getrusage(resource.RUSAGE_SELF)
46-
rss_bytes = usage.ru_maxrss
47-
# macOS reports in bytes, Linux in KB
48-
rss_mb = rss_bytes / (1024 * 1024) if platform.system() == "Darwin" else rss_bytes / 1024
49-
result: dict[str, Any] = {"rss_mb": round(rss_mb, 2)}
44+
result: dict[str, Any] = {"rss_mb": 0.0}
45+
try:
46+
import resource
47+
48+
usage = resource.getrusage(resource.RUSAGE_SELF)
49+
rss_bytes = usage.ru_maxrss
50+
# macOS reports in bytes, Linux in KB
51+
rss_mb = rss_bytes / (1024 * 1024) if platform.system() == "Darwin" else rss_bytes / 1024
52+
result["rss_mb"] = round(rss_mb, 2)
53+
except ImportError:
54+
pass
5055
try:
5156
import psutil # type: ignore[import-untyped]
5257

src/pyfly/observability/auto_configuration.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
except ImportError:
2727
TracerProvider = object # type: ignore[misc,assignment]
2828

29+
try:
30+
from pyfly.web.ports.filter import WebFilter
31+
except ImportError:
32+
WebFilter = object # type: ignore[misc,assignment]
33+
2934
from pyfly.container.bean import bean
3035
from pyfly.context.conditions import auto_configuration, conditional_on_class
3136
from pyfly.core.config import Config
@@ -40,6 +45,13 @@ class MetricsAutoConfiguration:
4045
def metrics_registry(self) -> MetricsRegistry:
4146
return MetricsRegistry()
4247

48+
@bean
49+
@conditional_on_class("starlette")
50+
def metrics_filter(self) -> WebFilter:
51+
from pyfly.web.adapters.starlette.filters.metrics_filter import MetricsFilter
52+
53+
return MetricsFilter()
54+
4355

4456
@auto_configuration
4557
@conditional_on_class("opentelemetry")

src/pyfly/web/adapters/fastapi/app.py

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515

1616
from __future__ import annotations
1717

18+
import contextlib
1819
from typing import TYPE_CHECKING
1920

2021
from fastapi import FastAPI
2122
from starlette.middleware import Middleware
23+
from starlette.routing import Route
2224

2325
from pyfly.container.ordering import get_order
2426
from pyfly.web.adapters.fastapi.controller import FastAPIControllerRegistrar
@@ -43,13 +45,16 @@ def create_app(
4345
debug: bool = False,
4446
context: ApplicationContext | None = None,
4547
docs_enabled: bool = True,
48+
extra_routes: list[Route] | None = None,
49+
actuator_enabled: bool = False,
4650
cors: CORSConfig | None = None,
4751
lifespan: object | None = None,
4852
) -> FastAPI:
4953
"""Create a FastAPI application with PyFly enterprise middleware.
5054
5155
When ``context`` is provided, auto-discovers all ``@rest_controller`` beans
52-
and mounts their routes. Also auto-discovers user ``WebFilter`` beans.
56+
and mounts their routes. Also auto-discovers user ``WebFilter``,
57+
``ActuatorEndpoint``, ``@websocket_mapping``, and ``@sse_mapping`` beans.
5358
5459
FastAPI provides built-in OpenAPI docs (Swagger UI at ``/docs``, ReDoc at
5560
``/redoc``), so no custom OpenAPI generator is needed.
@@ -58,7 +63,11 @@ def create_app(
5863
- WebFilter chain (transaction ID, request logging, security headers, + user filters)
5964
- Global exception handler (RFC 7807 style)
6065
- Built-in Swagger UI and ReDoc (when docs_enabled)
66+
- Actuator endpoints (when actuator_enabled)
67+
- Admin dashboard (when pyfly.admin.enabled config is set)
6168
- CORS support (when cors is provided)
69+
- WebSocket routes (auto-discovered from @websocket_mapping)
70+
- SSE routes (auto-discovered from @sse_mapping)
6271
"""
6372
# --- Build the WebFilter chain ---
6473
filters: list[WebFilter] = [
@@ -124,6 +133,169 @@ def create_app(
124133
registrar = FastAPIControllerRegistrar()
125134
registrar.register_controllers(app, context)
126135

136+
# Auto-discover WebSocket routes from ApplicationContext
137+
if context is not None:
138+
from pyfly.websocket.adapters.starlette import WebSocketRegistrar
139+
140+
ws_registrar = WebSocketRegistrar()
141+
app.routes.extend(ws_registrar.collect_routes(context))
142+
143+
# Auto-discover SSE routes from ApplicationContext
144+
if context is not None:
145+
from pyfly.web.sse.adapters.starlette import SSERegistrar
146+
147+
sse_registrar = SSERegistrar()
148+
app.routes.extend(sse_registrar.collect_routes(context))
149+
150+
# Mount OAuth2 login routes when an OAuth2LoginHandler bean exists
151+
if context is not None:
152+
from pyfly.security.oauth2.login import OAuth2LoginHandler
153+
154+
for _cls, reg in context.container._registrations.items():
155+
if reg.instance is not None and isinstance(reg.instance, OAuth2LoginHandler):
156+
app.routes.extend(reg.instance.routes())
157+
break
158+
159+
# Append caller-supplied routes (e.g. test helpers)
160+
if extra_routes:
161+
app.routes.extend(extra_routes)
162+
163+
# Mount actuator endpoints when enabled
164+
agg = None
165+
if actuator_enabled:
166+
from pyfly.actuator.adapters.starlette import make_starlette_actuator_routes
167+
from pyfly.actuator.endpoints.beans_endpoint import BeansEndpoint
168+
from pyfly.actuator.endpoints.env_endpoint import EnvEndpoint
169+
from pyfly.actuator.endpoints.health_endpoint import HealthEndpoint
170+
from pyfly.actuator.endpoints.info_endpoint import InfoEndpoint
171+
from pyfly.actuator.endpoints.loggers_endpoint import LoggersEndpoint
172+
from pyfly.actuator.endpoints.metrics_endpoint import MetricsEndpoint
173+
from pyfly.actuator.health import HealthAggregator, HealthIndicator
174+
from pyfly.actuator.registry import ActuatorRegistry
175+
176+
agg = HealthAggregator()
177+
178+
# Auto-discover HealthIndicator beans from context
179+
if context is not None:
180+
for cls, reg in context.container._registrations.items():
181+
if reg.instance is not None and isinstance(reg.instance, HealthIndicator):
182+
indicator_name = reg.name or cls.__name__
183+
agg.add_indicator(indicator_name, reg.instance)
184+
185+
config = context.config if context is not None else None
186+
registry = ActuatorRegistry(config=config)
187+
188+
# Register built-in endpoints
189+
registry.register(HealthEndpoint(agg))
190+
if context is not None:
191+
registry.register(BeansEndpoint(context))
192+
registry.register(EnvEndpoint(context))
193+
registry.register(InfoEndpoint(context))
194+
registry.register(LoggersEndpoint())
195+
registry.register(MetricsEndpoint())
196+
197+
# Auto-discover custom ActuatorEndpoint beans from context
198+
if context is not None:
199+
registry.discover_from_context(context)
200+
201+
app.routes.extend(make_starlette_actuator_routes(registry))
202+
203+
# Mount admin dashboard when enabled
204+
admin_enabled = False
205+
if context is not None:
206+
admin_enabled = str(context.config.get("pyfly.admin.enabled", "false")).lower() in (
207+
"true",
208+
"1",
209+
"yes",
210+
)
211+
212+
if admin_enabled and context is not None:
213+
from pyfly.admin.adapters.starlette import AdminRouteBuilder
214+
from pyfly.admin.config import AdminProperties
215+
from pyfly.admin.middleware.trace_collector import TraceCollectorFilter
216+
from pyfly.admin.providers.beans_provider import BeansProvider
217+
from pyfly.admin.providers.cache_provider import CacheProvider
218+
from pyfly.admin.providers.config_provider import ConfigProvider
219+
from pyfly.admin.providers.cqrs_provider import CqrsProvider
220+
from pyfly.admin.providers.env_provider import EnvProvider
221+
from pyfly.admin.providers.health_provider import HealthProvider
222+
from pyfly.admin.providers.logfile_provider import LogfileProvider
223+
from pyfly.admin.providers.loggers_provider import LoggersProvider
224+
from pyfly.admin.providers.mappings_provider import MappingsProvider
225+
from pyfly.admin.providers.metrics_provider import MetricsProvider
226+
from pyfly.admin.providers.overview_provider import OverviewProvider
227+
from pyfly.admin.providers.runtime_provider import RuntimeProvider
228+
from pyfly.admin.providers.scheduled_provider import ScheduledProvider
229+
from pyfly.admin.providers.server_provider import ServerProvider
230+
from pyfly.admin.providers.traces_provider import TracesProvider
231+
from pyfly.admin.providers.transactions_provider import TransactionsProvider
232+
from pyfly.admin.registry import AdminViewRegistry
233+
234+
admin_props = AdminProperties()
235+
with contextlib.suppress(Exception):
236+
admin_props = context.config.bind(AdminProperties)
237+
238+
# Find trace collector from context (registered by auto-config)
239+
trace_collector = None
240+
for _cls, reg in context.container._registrations.items():
241+
if reg.instance is not None and isinstance(reg.instance, TraceCollectorFilter):
242+
trace_collector = reg.instance
243+
break
244+
245+
# Find view registry from context
246+
view_registry = AdminViewRegistry()
247+
for _cls, reg in context.container._registrations.items():
248+
if reg.instance is not None and isinstance(reg.instance, AdminViewRegistry):
249+
view_registry = reg.instance
250+
view_registry.discover_from_context(context)
251+
break
252+
253+
# Reuse health aggregator from actuator, or create one for admin
254+
health_agg = agg
255+
if health_agg is None:
256+
from pyfly.actuator.health import HealthAggregator, HealthIndicator
257+
258+
health_agg = HealthAggregator()
259+
for cls, reg in context.container._registrations.items():
260+
if reg.instance is not None and isinstance(reg.instance, HealthIndicator):
261+
indicator_name = reg.name or cls.__name__
262+
health_agg.add_indicator(indicator_name, reg.instance)
263+
264+
# Find server adapter from context for admin dashboard
265+
server_adapter = None
266+
try:
267+
from pyfly.server.ports.outbound import ApplicationServerPort
268+
269+
for _cls, reg in context.container._registrations.items():
270+
if reg.instance is not None and isinstance(reg.instance, ApplicationServerPort):
271+
server_adapter = reg.instance
272+
break
273+
except ImportError:
274+
pass
275+
276+
admin_builder = AdminRouteBuilder(
277+
properties=admin_props,
278+
overview=OverviewProvider(context, health_agg),
279+
beans=BeansProvider(context),
280+
health=HealthProvider(health_agg),
281+
env=EnvProvider(context),
282+
config=ConfigProvider(context),
283+
loggers=LoggersProvider(),
284+
metrics=MetricsProvider(),
285+
scheduled=ScheduledProvider(context),
286+
mappings=MappingsProvider(context),
287+
caches=CacheProvider(context),
288+
cqrs=CqrsProvider(context),
289+
transactions=TransactionsProvider(context),
290+
traces=TracesProvider(trace_collector),
291+
view_registry=view_registry,
292+
trace_collector=trace_collector,
293+
logfile=LogfileProvider(context),
294+
runtime=RuntimeProvider(),
295+
server=ServerProvider(server_adapter),
296+
)
297+
app.routes.extend(admin_builder.build_routes())
298+
127299
# Register global exception handler
128300
register_exception_handlers(app)
129301

src/pyfly/web/adapters/starlette/filters/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,10 @@
3838
__all__ += ["OAuth2ResourceServerFilter"]
3939
except ImportError:
4040
pass
41+
42+
try:
43+
from pyfly.web.adapters.starlette.filters.metrics_filter import MetricsFilter
44+
45+
__all__ += ["MetricsFilter"]
46+
except ImportError:
47+
pass

src/pyfly/web/adapters/starlette/filters/metrics_filter.py

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,28 @@
2828
from pyfly.web.filters import OncePerRequestFilter
2929
from pyfly.web.ports.filter import CallNext
3030

31+
# Module-level singletons — Prometheus collectors are registered globally,
32+
# so they must only be created once per process.
33+
_REQUESTS_TOTAL: Counter | None = None
34+
_REQUEST_DURATION: Histogram | None = None
35+
_ACTIVE_REQUESTS: Gauge | None = None
36+
37+
if Counter is not None:
38+
_REQUESTS_TOTAL = Counter(
39+
"http_requests_total",
40+
"Total HTTP requests",
41+
["method", "path", "status"],
42+
)
43+
_REQUEST_DURATION = Histogram(
44+
"http_request_duration_seconds",
45+
"HTTP request duration in seconds",
46+
["method", "path"],
47+
)
48+
_ACTIVE_REQUESTS = Gauge(
49+
"http_active_requests",
50+
"Number of in-flight HTTP requests",
51+
)
52+
3153

3254
class MetricsFilter(OncePerRequestFilter):
3355
"""Collects HTTP auto-instrumentation metrics.
@@ -43,20 +65,12 @@ class MetricsFilter(OncePerRequestFilter):
4365
exclude_patterns = ["/actuator/*", "/health", "/ready"]
4466

4567
def __init__(self) -> None:
46-
self._requests_total = Counter(
47-
"http_requests_total",
48-
"Total HTTP requests",
49-
["method", "path", "status"],
50-
)
51-
self._request_duration = Histogram(
52-
"http_request_duration_seconds",
53-
"HTTP request duration in seconds",
54-
["method", "path"],
55-
)
56-
self._active_requests = Gauge(
57-
"http_active_requests",
58-
"Number of in-flight HTTP requests",
59-
)
68+
assert _REQUESTS_TOTAL is not None, "prometheus_client is required for MetricsFilter"
69+
assert _REQUEST_DURATION is not None, "prometheus_client is required for MetricsFilter"
70+
assert _ACTIVE_REQUESTS is not None, "prometheus_client is required for MetricsFilter"
71+
self._requests_total: Counter = _REQUESTS_TOTAL
72+
self._request_duration: Histogram = _REQUEST_DURATION
73+
self._active_requests: Gauge = _ACTIVE_REQUESTS
6074

6175
async def do_filter(self, request: Any, call_next: CallNext) -> Any:
6276
method = request.method

0 commit comments

Comments
 (0)