From 098cb94797fefb2374103bbdb208e1b12c8e9377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Sun, 7 Jun 2026 21:33:01 +0200 Subject: [PATCH] feat(observability): MetricsRecorder port + NoOpMetricsRecorder adapter + bump v26.06.72 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit follow-up (observability had no port abstraction). Added MetricsRecorder Protocol (the counter/histogram/gauge recording contract) in observability/ports.py + NoOpMetricsRecorder (inert handles) for tests / metrics-disabled deployments. MetricsRegistry now nominally implements the port (Prometheus adapter). Tracing exporters were already config-swappable (otlp|console|none), so a full multi-backend metrics abstraction would be YAGNI — kept this a lightweight port + one real alt adapter. Tests: tests/observability/test_metrics_recorder_port.py (2). Gates: mypy --strict (638), ruff + format, full suite green. --- CHANGELOG.md | 12 +++ README.md | 2 +- pyproject.toml | 2 +- src/pyfly/__init__.py | 2 +- src/pyfly/observability/__init__.py | 3 + src/pyfly/observability/metrics.py | 6 +- src/pyfly/observability/ports.py | 88 +++++++++++++++++++ .../test_metrics_recorder_port.py | 44 ++++++++++ uv.lock | 2 +- 9 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 src/pyfly/observability/ports.py create mode 100644 tests/observability/test_metrics_recorder_port.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e83c2aa..13b6ced3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.72 (2026-06-07) + +### Added (observability — metrics recording port) + +- **`MetricsRecorder`** protocol port (`pyfly.observability`) — the abstraction instrumentation + depends on, so code is not hard-coupled to Prometheus. `MetricsRegistry` is now a nominal + `MetricsRecorder` (the Prometheus adapter), and **`NoOpMetricsRecorder`** is a dependency-free + adapter for tests and metrics-disabled deployments (every metric op is an inert no-op handle). + (Tracing exporters were already config-swappable via `pyfly.observability.tracing.exporter`; + a full multi-backend metrics abstraction would be YAGNI, so this stays a lightweight port + a + real second adapter.) + ## v26.06.71 (2026-06-07) ### Added (tests — behavior coverage for external adapters) diff --git a/README.md b/README.md index 24876604..16313608 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.71 + Version: 26.06.72 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index 5d26b13d..9e1b158b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.6.71" +version = "26.6.72" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 24fe5d08..830a4b94 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.06.71" +__version__ = "26.06.72" diff --git a/src/pyfly/observability/__init__.py b/src/pyfly/observability/__init__.py index d495dd80..b8df0ce7 100644 --- a/src/pyfly/observability/__init__.py +++ b/src/pyfly/observability/__init__.py @@ -33,10 +33,13 @@ set_tracestate, unbind_correlation_context, ) +from pyfly.observability.ports import MetricsRecorder, NoOpMetricsRecorder from pyfly.observability.tracing import span __all__ = [ "CORRELATION_ID_HEADER", + "MetricsRecorder", + "NoOpMetricsRecorder", "REQUEST_ID_HEADER", "TENANT_ID_HEADER", "TRACEPARENT_HEADER", diff --git a/src/pyfly/observability/metrics.py b/src/pyfly/observability/metrics.py index 651e612f..513b813f 100644 --- a/src/pyfly/observability/metrics.py +++ b/src/pyfly/observability/metrics.py @@ -21,6 +21,8 @@ from collections.abc import Callable from typing import Any, TypeVar +from pyfly.observability.ports import MetricsRecorder + try: from prometheus_client import Counter, Gauge, Histogram @@ -34,8 +36,8 @@ F = TypeVar("F", bound=Callable[..., Any]) -class MetricsRegistry: - """Registry for application metrics. +class MetricsRegistry(MetricsRecorder): + """Registry for application metrics — the Prometheus :class:`MetricsRecorder` adapter. Wraps prometheus_client to provide a clean API for creating and managing metrics. Ensures each metric name is registered only once. diff --git a/src/pyfly/observability/ports.py b/src/pyfly/observability/ports.py new file mode 100644 index 00000000..21f4c8af --- /dev/null +++ b/src/pyfly/observability/ports.py @@ -0,0 +1,88 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Metrics recording port + a no-op adapter. + +:class:`MetricsRecorder` is the abstraction application/framework instrumentation depends on, +so it is not hard-coupled to Prometheus. :class:`~pyfly.observability.metrics.MetricsRegistry` +is the default (Prometheus) adapter; :class:`NoOpMetricsRecorder` is a dependency-free adapter +for tests and for deployments that disable metrics — instrumentation code can always hold a +recorder instead of guarding ``None``. +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class MetricsRecorder(Protocol): + """Port for creating counter/histogram/gauge metrics (returns backend metric handles).""" + + def counter(self, name: str, description: str, labels: list[str] | None = None) -> Any: ... + + def histogram( + self, + name: str, + description: str, + labels: list[str] | None = None, + buckets: tuple[float, ...] | None = None, + ) -> Any: ... + + def gauge(self, name: str, description: str, labels: list[str] | None = None) -> Any: ... + + +class _NoOpMetric: + """A metric handle that accepts every Prometheus-style operation and does nothing.""" + + def labels(self, *args: Any, **kwargs: Any) -> _NoOpMetric: + return self # chainable, like prometheus_client's .labels(...) + + def inc(self, *args: Any, **kwargs: Any) -> None: ... + + def dec(self, *args: Any, **kwargs: Any) -> None: ... + + def set(self, *args: Any, **kwargs: Any) -> None: ... + + def observe(self, *args: Any, **kwargs: Any) -> None: ... + + def time(self, *args: Any, **kwargs: Any) -> _NoOpMetric: + return self + + async def __aenter__(self) -> _NoOpMetric: + return self + + async def __aexit__(self, *exc: Any) -> bool: + return False + + +class NoOpMetricsRecorder: + """Dependency-free :class:`MetricsRecorder` — every metric is a shared no-op handle.""" + + def __init__(self) -> None: + self._metric = _NoOpMetric() + + def counter(self, name: str, description: str, labels: list[str] | None = None) -> Any: + return self._metric + + def histogram( + self, + name: str, + description: str, + labels: list[str] | None = None, + buckets: tuple[float, ...] | None = None, + ) -> Any: + return self._metric + + def gauge(self, name: str, description: str, labels: list[str] | None = None) -> Any: + return self._metric diff --git a/tests/observability/test_metrics_recorder_port.py b/tests/observability/test_metrics_recorder_port.py new file mode 100644 index 00000000..574b019a --- /dev/null +++ b/tests/observability/test_metrics_recorder_port.py @@ -0,0 +1,44 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""MetricsRecorder port + NoOp adapter (v26.06.72).""" + +from __future__ import annotations + +from pyfly.observability import MetricsRecorder, NoOpMetricsRecorder + + +def test_noop_recorder_satisfies_port_and_is_inert() -> None: + recorder: MetricsRecorder = NoOpMetricsRecorder() + assert isinstance(recorder, MetricsRecorder) + + # Every create + every Prometheus-style op is a no-op that never raises. + counter = recorder.counter("reqs", "requests", ["route"]) + counter.labels(route="/x").inc() + counter.inc(3) + recorder.gauge("inflight", "in flight").set(5) + hist = recorder.histogram("latency", "latency seconds", buckets=(0.1, 0.5)) + hist.labels().observe(0.2) + + +def test_metrics_registry_is_a_metrics_recorder() -> None: + # MetricsRegistry (Prometheus adapter) is a nominal MetricsRecorder, so application code can + # depend on the port. Skip if prometheus_client isn't installed. + try: + from pyfly.observability.metrics import MetricsRegistry + except ImportError: + import pytest + + pytest.skip("prometheus_client not installed") + assert issubclass(MetricsRegistry, MetricsRecorder) + assert isinstance(MetricsRegistry(), MetricsRecorder) diff --git a/uv.lock b/uv.lock index 993476c5..17e18364 100644 --- a/uv.lock +++ b/uv.lock @@ -1981,7 +1981,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.71" +version = "26.6.72" source = { editable = "." } dependencies = [ { name = "pydantic" },