diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e83c2a..13b6ced 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 2487660..1631360 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
diff --git a/pyproject.toml b/pyproject.toml
index 5d26b13..9e1b158 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 24fe5d0..830a4b9 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 d495dd8..b8df0ce 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 651e612..513b813 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 0000000..21f4c8a
--- /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 0000000..574b019
--- /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 993476c..17e1836 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" },