Skip to content

Commit 254e95e

Browse files
authored
feat: Spring Boot parity for observability, actuator & configuration (v26.06.00) (#25)
Spring Boot parity for metrics (Micrometer names + auto-instrumentation), the full /actuator/* surface, Spring-style YAML config + admin display, health-indicator wiring, FastAPI/Starlette adapter parity, plus mongo/installer test fixes. CI green (3.12 + 3.13). See CHANGELOG v26.06.00.
1 parent 917a59b commit 254e95e

63 files changed

Lines changed: 3906 additions & 742 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ venv/
1818
.worktrees/
1919
.idea/
2020
docs/plans/
21+
22+
# Playwright MCP verification artifacts
23+
.playwright-mcp/

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
---
88

9+
## v26.06.00 (2026-06-04)
10+
11+
### Spring Boot parity — observability, actuator & configuration
12+
13+
Brings pyfly's observability, actuator, and configuration to drop-in Spring Boot
14+
parity: identical Micrometer metric names, the full `/actuator/*` endpoint surface,
15+
and Spring-style YAML config semantics (Spring's `management.*` key structure under
16+
the `pyfly.*` namespace; legacy keys still work).
17+
18+
- **Observability.** HTTP auto-instrumentation now emits Micrometer's
19+
`http_server_requests_seconds` (count/sum + `_max` gauge; optional histogram)
20+
tagged `method`/`uri` (templated, cardinality-safe)/`status`/`outcome`/`exception`.
21+
The metrics filter is wired directly in `create_app` (it was previously a bean
22+
built too late to ever join the chain, so HTTP metrics were silently never
23+
collected). Process/system meters use Micrometer names (`process_uptime_seconds`,
24+
`process_cpu_usage`, `system_cpu_count`, …). `@timed`/`@counted` follow Micrometer
25+
naming + tags (`class`/`method`/`exception`, `result` for counters).
26+
- **Actuator.** On by default with Spring-exact secure exposure
27+
(`pyfly.management.endpoints.web.exposure.include`, default `health,info`),
28+
configurable base-path, and a now-registered `/actuator/prometheus`
29+
(pinned to `version=0.0.4`). `/actuator/metrics` returns Micrometer JSON
30+
(dot names, `COUNT`/`TOTAL_TIME`/`MAX`, `availableTags`, `?tag=` drill-down).
31+
New endpoints: `configprops`, `mappings`, `scheduledtasks`, `threaddump`,
32+
`caches`, `conditions`, `httpexchanges`; `/actuator/beans` uses the `contexts`
33+
envelope; loggers use the Spring shape (`WARN`/`OFF`, `GET`/`POST /loggers/{name}`,
34+
groups). The Starlette and FastAPI adapters share one wiring path.
35+
- **Health.** Severity-based status aggregation (`UP`/`UNKNOWN`/`OUT_OF_SERVICE`/`DOWN`),
36+
503 for down states, `show-details`/`show-components` config, `/health/{component|group}`.
37+
The DB and CQRS health indicators are now registered and conform to the protocol.
38+
- **Configuration.** Relaxed binding (kebab→snake), env-var overrides visible to
39+
`@config_properties` binding and type-coerced, ordered property sources, and secret
40+
masking (incl. URI userinfo passwords). The admin Configuration/Environment views
41+
are sorted, grouped, source-attributed, and masked; the admin Metrics view uses the
42+
Prometheus names and is now SSE-push driven.
43+
- **Fixes.** mongomock + beanie 2.x test compatibility shim; `install.sh` now probes
44+
version-suffixed interpreters (`python3.13`/`3.12`) so it no longer aborts when the
45+
bare `python3` is an older build.
46+
947
## v26.05.12 (2026-05-31)
1048

1149
### Admin dashboard — responsive cards, fullscreen, navbar polish

install.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,10 @@ uninstall_pyfly() {
164164
# ── Prerequisite checks ───────────────────────────────────────────────────────
165165

166166
find_python() {
167-
for cmd in python3 python; do
167+
# Probe version-suffixed interpreters first: on macOS/Homebrew/uv setups the
168+
# bare `python3` is often an old system build (e.g. 3.9) while a newer
169+
# `python3.12`/`python3.13` is installed alongside it.
170+
for cmd in python3.14 python3.13 python3.12 python3 python; do
168171
if command -v "$cmd" &>/dev/null; then
169172
local version
170173
version=$("$cmd" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null) || continue

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ name = "pyfly"
77
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
88
# git tag, GitHub release and human-readable display use leading-zero form
99
# (v26.05.04) to match the Java/.NET/Go siblings.
10-
version = "26.5.12"
10+
version = "26.6.0"
1111
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
1212
readme = "README.md"
1313
license = "Apache-2.0"

src/pyfly/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@
1313
# limitations under the License.
1414
"""PyFly — Enterprise Python Framework."""
1515

16-
__version__ = "26.05.12"
16+
__version__ = "26.06.00"

src/pyfly/actuator/adapters/starlette.py

Lines changed: 84 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -29,37 +29,49 @@
2929

3030
def make_starlette_actuator_routes(
3131
registry: ActuatorRegistry,
32+
exposed_ids: set[str] | None = None,
33+
base_path: str = "/actuator",
3234
) -> list[Route]:
33-
"""Build Starlette ``Route`` objects from all enabled endpoints in *registry*."""
35+
"""Build Starlette ``Route`` objects from all enabled endpoints in *registry*.
36+
37+
*exposed_ids* — if provided, only endpoints whose id is in the set are mounted
38+
over HTTP (Spring Boot ``management.endpoints.web.exposure``). ``None`` exposes
39+
every enabled endpoint (used by low-level callers/tests).
40+
*base_path* — the actuator base path (Spring ``management.endpoints.web.base-path``).
41+
"""
3442
enabled = registry.get_enabled_endpoints()
43+
if exposed_ids is not None:
44+
enabled = {eid: ep for eid, ep in enabled.items() if eid in exposed_ids}
45+
46+
bp = base_path.rstrip("/")
3547
routes: list[Route] = []
3648

37-
# Index endpoint: /actuator — lists all enabled endpoints with _links
49+
# Index endpoint: /actuator — lists all exposed endpoints with _links
3850
async def index_endpoint(request: Request) -> JSONResponse:
39-
links: dict[str, dict[str, str]] = {"self": {"href": "/actuator"}}
51+
links: dict[str, dict[str, str]] = {"self": {"href": bp or "/"}}
4052
for eid in enabled:
41-
links[eid] = {"href": f"/actuator/{eid}"}
53+
links[eid] = {"href": f"{bp}/{eid}"}
4254
if "health" in enabled:
43-
links["health/liveness"] = {"href": "/actuator/health/liveness"}
44-
links["health/readiness"] = {"href": "/actuator/health/readiness"}
55+
links["health/liveness"] = {"href": f"{bp}/health/liveness"}
56+
links["health/readiness"] = {"href": f"{bp}/health/readiness"}
4557
return JSONResponse({"_links": links})
4658

47-
routes.append(Route("/actuator", index_endpoint, methods=["GET"]))
59+
routes.append(Route(bp or "/", index_endpoint, methods=["GET"]))
4860

4961
for eid, ep in enabled.items():
5062
if isinstance(ep, HealthEndpoint):
51-
routes.extend(_make_health_routes(ep))
63+
routes.extend(_make_health_routes(ep, bp))
5264
elif isinstance(ep, LoggersEndpoint):
53-
routes.extend(_make_loggers_routes(ep))
65+
routes.extend(_make_loggers_routes(ep, bp))
5466
elif isinstance(ep, PrometheusEndpoint):
55-
routes.append(_make_prometheus_route(ep))
67+
routes.append(_make_prometheus_route(ep, bp))
5668
else:
57-
routes.append(_make_generic_route(eid, ep))
69+
routes.extend(_make_generic_routes(eid, ep, bp))
5870

5971
return routes
6072

6173

62-
def _make_prometheus_route(ep: PrometheusEndpoint) -> Route:
74+
def _make_prometheus_route(ep: PrometheusEndpoint, bp: str) -> Route:
6375
"""Prometheus scrape endpoint — must serve the raw text exposition format
6476
(``text/plain; version=0.0.4``), not a JSON wrapper."""
6577

@@ -70,11 +82,15 @@ async def handler(request: Request) -> Response:
7082
media_type=data.get("content_type") or "text/plain; version=0.0.4; charset=utf-8",
7183
)
7284

73-
return Route("/actuator/prometheus", handler, methods=["GET"])
85+
return Route(f"{bp}/prometheus", handler, methods=["GET"])
86+
7487

88+
def _make_health_routes(ep: HealthEndpoint, bp: str) -> list[Route]:
89+
"""Health endpoint returns dynamic status codes (200/503).
7590
76-
def _make_health_routes(ep: HealthEndpoint) -> list[Route]:
77-
"""Health endpoint returns dynamic status codes (200/503)."""
91+
Supports the built-in ``liveness``/``readiness`` probe groups plus a generic
92+
``/health/{path}`` selector for any other group or single component.
93+
"""
7894

7995
async def handler(request: Request) -> JSONResponse:
8096
data = await ep.handle()
@@ -91,41 +107,77 @@ async def readiness_handler(request: Request) -> JSONResponse:
91107
status_code = await ep.get_readiness_status_code()
92108
return JSONResponse(data, status_code=status_code)
93109

110+
async def selector_handler(request: Request) -> JSONResponse:
111+
# /actuator/health/{path} — a configured group, or a single component.
112+
path = request.path_params["path"]
113+
data, status_code = await ep.handle_path(path)
114+
if data is None:
115+
return JSONResponse({"error": f"No such health component or group: {path}"}, status_code=404)
116+
return JSONResponse(data, status_code=status_code)
117+
94118
return [
95-
Route("/actuator/health", handler, methods=["GET"]),
96-
Route("/actuator/health/liveness", liveness_handler, methods=["GET"]),
97-
Route("/actuator/health/readiness", readiness_handler, methods=["GET"]),
119+
Route(f"{bp}/health", handler, methods=["GET"]),
120+
Route(f"{bp}/health/liveness", liveness_handler, methods=["GET"]),
121+
Route(f"{bp}/health/readiness", readiness_handler, methods=["GET"]),
122+
Route(f"{bp}/health/{{path:path}}", selector_handler, methods=["GET"]),
98123
]
99124

100125

101-
def _make_loggers_routes(ep: LoggersEndpoint) -> list[Route]:
102-
"""Loggers endpoint supports GET (list) and POST (change level)."""
126+
def _make_loggers_routes(ep: LoggersEndpoint, bp: str) -> list[Route]:
127+
"""Loggers endpoint — Spring Boot shape.
128+
129+
GET /loggers -> levels + loggers + groups
130+
GET /loggers/{name} -> {configuredLevel, effectiveLevel}
131+
POST /loggers/{name} -> body {"configuredLevel": "DEBUG"} (or null to reset), 204
132+
"""
103133

104134
async def get_handler(request: Request) -> JSONResponse:
105135
data = await ep.handle()
106136
return JSONResponse(data)
107137

108-
async def post_handler(request: Request) -> JSONResponse:
138+
async def get_named_handler(request: Request) -> JSONResponse:
139+
name = request.path_params["name"]
140+
return JSONResponse(await ep.get_logger(name))
141+
142+
async def post_named_handler(request: Request) -> Response:
143+
name = request.path_params["name"]
109144
body = await request.body()
110145
payload = json.loads(body) if body else {}
111-
logger_name = payload.get("logger", "ROOT")
112-
level = payload.get("level", "INFO")
113-
result = await ep.set_logger_level(logger_name, level)
114-
if "error" in result:
146+
level = payload.get("configuredLevel")
147+
result = await ep.set_logger_level(name, level)
148+
if isinstance(result, dict) and "error" in result:
115149
return JSONResponse(result, status_code=400)
116-
return JSONResponse(result)
150+
return Response(status_code=204)
117151

118152
return [
119-
Route("/actuator/loggers", get_handler, methods=["GET"]),
120-
Route("/actuator/loggers", post_handler, methods=["POST"]),
153+
Route(f"{bp}/loggers", get_handler, methods=["GET"]),
154+
Route(f"{bp}/loggers/{{name}}", get_named_handler, methods=["GET"]),
155+
Route(f"{bp}/loggers/{{name}}", post_named_handler, methods=["POST"]),
121156
]
122157

123158

124-
def _make_generic_route(eid: str, ep: object) -> Route:
125-
"""Generic endpoint — calls ``handle()`` and returns 200 JSON."""
159+
def _make_generic_routes(eid: str, ep: object, bp: str) -> list[Route]:
160+
"""Generic endpoint — ``GET /actuator/{id}`` plus an optional
161+
``GET /actuator/{id}/{selector}`` drill-down when the endpoint opts in via
162+
``supports_selector = True``."""
126163

127164
async def handler(request: Request) -> JSONResponse:
128-
data = await ep.handle() # type: ignore[attr-defined]
165+
data = await ep.handle({"query": dict(request.query_params)}) # type: ignore[attr-defined]
129166
return JSONResponse(data)
130167

131-
return Route(f"/actuator/{eid}", handler, methods=["GET"])
168+
routes = [Route(f"{bp}/{eid}", handler, methods=["GET"])]
169+
170+
if getattr(ep, "supports_selector", False):
171+
172+
async def selector_handler(request: Request) -> JSONResponse:
173+
selector = request.path_params["selector"]
174+
data = await ep.handle( # type: ignore[attr-defined]
175+
{"selector": selector, "query": dict(request.query_params)}
176+
)
177+
if data is None:
178+
return JSONResponse({"error": f"No such {eid}: {selector}"}, status_code=404)
179+
return JSONResponse(data)
180+
181+
routes.append(Route(f"{bp}/{eid}/{{selector:path}}", selector_handler, methods=["GET"]))
182+
183+
return routes

src/pyfly/actuator/endpoints/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,33 @@
1414
"""Built-in actuator endpoint implementations."""
1515

1616
from pyfly.actuator.endpoints.beans_endpoint import BeansEndpoint
17+
from pyfly.actuator.endpoints.caches_endpoint import CachesEndpoint
18+
from pyfly.actuator.endpoints.conditions_endpoint import ConditionsEndpoint
19+
from pyfly.actuator.endpoints.configprops_endpoint import ConfigPropsEndpoint
1720
from pyfly.actuator.endpoints.env_endpoint import EnvEndpoint
1821
from pyfly.actuator.endpoints.health_endpoint import HealthEndpoint
22+
from pyfly.actuator.endpoints.httpexchanges_endpoint import HttpExchangesEndpoint
1923
from pyfly.actuator.endpoints.info_endpoint import InfoEndpoint
2024
from pyfly.actuator.endpoints.loggers_endpoint import LoggersEndpoint
25+
from pyfly.actuator.endpoints.mappings_endpoint import MappingsEndpoint
2126
from pyfly.actuator.endpoints.metrics_endpoint import MetricsEndpoint
2227
from pyfly.actuator.endpoints.prometheus_endpoint import PrometheusEndpoint
28+
from pyfly.actuator.endpoints.scheduledtasks_endpoint import ScheduledTasksEndpoint
29+
from pyfly.actuator.endpoints.threaddump_endpoint import ThreadDumpEndpoint
2330

2431
__all__ = [
2532
"BeansEndpoint",
33+
"CachesEndpoint",
34+
"ConditionsEndpoint",
35+
"ConfigPropsEndpoint",
2636
"EnvEndpoint",
2737
"HealthEndpoint",
38+
"HttpExchangesEndpoint",
2839
"InfoEndpoint",
2940
"LoggersEndpoint",
41+
"MappingsEndpoint",
3042
"MetricsEndpoint",
3143
"PrometheusEndpoint",
44+
"ScheduledTasksEndpoint",
45+
"ThreadDumpEndpoint",
3246
]

src/pyfly/actuator/endpoints/beans_endpoint.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,19 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
"""Beans actuator endpoint — lists all registered DI beans."""
14+
"""Beans actuator endpoint — Spring Boot ``/actuator/beans`` parity."""
1515

1616
from __future__ import annotations
1717

18+
import inspect
1819
from typing import TYPE_CHECKING, Any
1920

2021
if TYPE_CHECKING:
2122
from pyfly.context.application_context import ApplicationContext
2223

2324

2425
class BeansEndpoint:
25-
"""Exposes DI bean registry at ``/actuator/beans``."""
26+
"""Exposes the DI bean registry at ``/actuator/beans`` (contexts envelope)."""
2627

2728
def __init__(self, context: ApplicationContext) -> None:
2829
self._context = context
@@ -36,12 +37,39 @@ def enabled(self) -> bool:
3637
return True
3738

3839
async def handle(self, context: Any = None) -> dict[str, Any]:
40+
registrations = self._context.container._registrations
41+
type_to_name = {cls: (reg.name or cls.__name__) for cls, reg in registrations.items()}
42+
3943
beans: dict[str, Any] = {}
40-
for cls, reg in self._context.container._registrations.items():
44+
for cls, reg in registrations.items():
4145
bean_name = reg.name or cls.__name__
4246
beans[bean_name] = {
47+
"aliases": [],
48+
"scope": reg.scope.name.lower(),
4349
"type": f"{cls.__module__}.{cls.__qualname__}",
44-
"scope": reg.scope.name,
50+
"resource": getattr(cls, "__module__", ""),
51+
"dependencies": self._dependencies(cls, type_to_name),
52+
# pyfly extension — handy in the admin/UI, ignored by Spring tooling.
4553
"stereotype": getattr(cls, "__pyfly_stereotype__", "none"),
4654
}
47-
return {"beans": beans}
55+
56+
return {"contexts": {"application": {"beans": beans}}}
57+
58+
@staticmethod
59+
def _dependencies(cls: type, type_to_name: dict[type, str]) -> list[str]:
60+
"""Resolve constructor dependency bean names from ``__init__`` type hints."""
61+
deps: list[str] = []
62+
try:
63+
signature = inspect.signature(cls)
64+
except (ValueError, TypeError):
65+
return deps
66+
for name, param in signature.parameters.items():
67+
if name == "self":
68+
continue
69+
annotation = param.annotation
70+
dep_name = type_to_name.get(annotation)
71+
if dep_name:
72+
deps.append(dep_name)
73+
elif annotation is not inspect.Parameter.empty:
74+
deps.append(getattr(annotation, "__name__", str(annotation)))
75+
return deps

0 commit comments

Comments
 (0)