Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.71-brightgreen" alt="Version: 26.06.71"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.72-brightgreen" alt="Version: 26.06.72"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.71"
__version__ = "26.06.72"
3 changes: 3 additions & 0 deletions src/pyfly/observability/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions src/pyfly/observability/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
88 changes: 88 additions & 0 deletions src/pyfly/observability/ports.py
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions tests/observability/test_metrics_recorder_port.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading