diff --git a/docker/Dockerfile b/docker/Dockerfile
index 7ca1debc..b7606a6f 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -40,6 +40,7 @@ RUN pacman -S --noconfirm --asdeps \
pacman -S --noconfirm --asdeps \
git \
python-aiohttp \
+ python-aiohttp-openmetrics \
python-boto3 \
python-cerberus \
python-cryptography \
@@ -112,6 +113,7 @@ RUN pacman -S --noconfirm ahriman
RUN pacman -S --noconfirm --asdeps \
python-aioauth-client \
python-aiohttp-apispec-git \
+ python-aiohttp-openmetrics \
python-aiohttp-security \
python-aiohttp-session \
python-boto3 \
diff --git a/docs/ahriman.web.middlewares.rst b/docs/ahriman.web.middlewares.rst
index 31725064..db4d1022 100644
--- a/docs/ahriman.web.middlewares.rst
+++ b/docs/ahriman.web.middlewares.rst
@@ -20,6 +20,14 @@ ahriman.web.middlewares.exception\_handler module
:no-undoc-members:
:show-inheritance:
+ahriman.web.middlewares.metrics\_handler module
+-----------------------------------------------
+
+.. automodule:: ahriman.web.middlewares.metrics_handler
+ :members:
+ :no-undoc-members:
+ :show-inheritance:
+
Module contents
---------------
diff --git a/docs/ahriman.web.schemas.rst b/docs/ahriman.web.schemas.rst
index d5349eb3..7295a7b8 100644
--- a/docs/ahriman.web.schemas.rst
+++ b/docs/ahriman.web.schemas.rst
@@ -4,6 +4,14 @@ ahriman.web.schemas package
Submodules
----------
+ahriman.web.schemas.any\_schema module
+--------------------------------------
+
+.. automodule:: ahriman.web.schemas.any_schema
+ :members:
+ :no-undoc-members:
+ :show-inheritance:
+
ahriman.web.schemas.aur\_package\_schema module
-----------------------------------------------
diff --git a/docs/ahriman.web.views.v1.status.rst b/docs/ahriman.web.views.v1.status.rst
index 694f4159..99d60893 100644
--- a/docs/ahriman.web.views.v1.status.rst
+++ b/docs/ahriman.web.views.v1.status.rst
@@ -12,6 +12,14 @@ ahriman.web.views.v1.status.info module
:no-undoc-members:
:show-inheritance:
+ahriman.web.views.v1.status.metrics module
+------------------------------------------
+
+.. automodule:: ahriman.web.views.v1.status.metrics
+ :members:
+ :no-undoc-members:
+ :show-inheritance:
+
ahriman.web.views.v1.status.repositories module
-----------------------------------------------
diff --git a/package/archlinux/PKGBUILD b/package/archlinux/PKGBUILD
index 8ba023ee..42bb1d63 100644
--- a/package/archlinux/PKGBUILD
+++ b/package/archlinux/PKGBUILD
@@ -75,6 +75,7 @@ package_ahriman-web() {
depends=("$pkgbase-core=$pkgver" 'python-aiohttp-cors' 'python-aiohttp-jinja2')
optdepends=('python-aioauth-client: OAuth2 authorization support'
'python-aiohttp-apispec>=3.0.0: autogenerated API documentation'
+ 'python-aiohttp-openmetrics: HTTP metrics support'
'python-aiohttp-security: authorization support'
'python-aiohttp-session: authorization support'
'python-cryptography: authorization support')
diff --git a/pyproject.toml b/pyproject.toml
index 91bf2970..6f359b2c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -69,6 +69,10 @@ web_auth = [
"aiohttp_security",
"cryptography",
]
+web_metrics = [
+ "ahriman[web]",
+ "aiohttp-openmetrics",
+]
web_oauth2 = [
"ahriman[web_auth]",
"aioauth-client",
diff --git a/src/ahriman/web/middlewares/metrics_handler.py b/src/ahriman/web/middlewares/metrics_handler.py
new file mode 100644
index 00000000..3cb26c56
--- /dev/null
+++ b/src/ahriman/web/middlewares/metrics_handler.py
@@ -0,0 +1,69 @@
+#
+# Copyright (c) 2021-2025 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+try:
+ import aiohttp_openmetrics
+except ImportError:
+ aiohttp_openmetrics = None # type: ignore[assignment]
+
+from aiohttp.typedefs import Middleware
+from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse, middleware
+
+from ahriman.web.middlewares import HandlerType
+
+
+__all__ = [
+ "metrics",
+ "metrics_handler",
+]
+
+
+async def metrics(request: Request) -> Response:
+ """
+ handler for returning metrics
+
+ Args:
+ request(Request): request object
+
+ Returns:
+ Response: response object
+
+ Raises:
+ HTTPNotFound: endpoint is disabled
+ """
+ if aiohttp_openmetrics is None:
+ raise HTTPNotFound
+ return await aiohttp_openmetrics.metrics(request)
+
+
+def metrics_handler() -> Middleware:
+ """
+ middleware for metrics support
+
+ Returns:
+ Middleware: middleware function to handle server metrics
+ """
+ if aiohttp_openmetrics is not None:
+ return aiohttp_openmetrics.metrics_middleware
+
+ @middleware
+ async def handle(request: Request, handler: HandlerType) -> StreamResponse:
+ return await handler(request)
+
+ return handle
diff --git a/src/ahriman/web/routes.py b/src/ahriman/web/routes.py
index 8be56b90..f2faa601 100644
--- a/src/ahriman/web/routes.py
+++ b/src/ahriman/web/routes.py
@@ -17,6 +17,8 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
+import re
+
from aiohttp.web import Application, View
from collections.abc import Generator
@@ -45,6 +47,23 @@ def _dynamic_routes(configuration: Configuration) -> Generator[tuple[str, type[V
yield route, view
+def _identifier(route: str) -> str:
+ """
+ extract valid route identifier (aka name) for the route. This method replaces curly brackets by single colon
+ and replaces other special symbols (including slashes) by underscore
+
+ Args:
+ route(str): source route
+
+ Returns:
+ str: route with special symbols being replaced
+ """
+ # replace special symbols
+ alphanum = re.sub(r"[^A-Za-z\d\-{}]", "_", route)
+ # finally replace curly brackets
+ return alphanum.replace("{", ":").replace("}", "")
+
+
def setup_routes(application: Application, configuration: Configuration) -> None:
"""
setup all defined routes
@@ -53,7 +72,8 @@ def setup_routes(application: Application, configuration: Configuration) -> None
application(Application): web application instance
configuration(Configuration): configuration instance
"""
- application.router.add_static("/static", configuration.getpath("web", "static_path"), follow_symlinks=True)
+ application.router.add_static("/static", configuration.getpath("web", "static_path"), name="_static",
+ follow_symlinks=True)
for route, view in _dynamic_routes(configuration):
- application.router.add_view(route, view)
+ application.router.add_view(route, view, name=_identifier(route))
diff --git a/src/ahriman/web/schemas/__init__.py b/src/ahriman/web/schemas/__init__.py
index 998ec790..dd440fb9 100644
--- a/src/ahriman/web/schemas/__init__.py
+++ b/src/ahriman/web/schemas/__init__.py
@@ -17,6 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
+from ahriman.web.schemas.any_schema import AnySchema
from ahriman.web.schemas.aur_package_schema import AURPackageSchema
from ahriman.web.schemas.auth_schema import AuthSchema
from ahriman.web.schemas.build_options_schema import BuildOptionsSchema
diff --git a/src/ahriman/web/schemas/any_schema.py b/src/ahriman/web/schemas/any_schema.py
new file mode 100644
index 00000000..0129f03a
--- /dev/null
+++ b/src/ahriman/web/schemas/any_schema.py
@@ -0,0 +1,26 @@
+#
+# Copyright (c) 2021-2025 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from ahriman.web.apispec import Schema
+
+
+class AnySchema(Schema):
+ """
+ response dummy schema
+ """
diff --git a/src/ahriman/web/views/base.py b/src/ahriman/web/views/base.py
index f5b4d9cf..77198cc4 100644
--- a/src/ahriman/web/views/base.py
+++ b/src/ahriman/web/views/base.py
@@ -167,6 +167,9 @@ class BaseView(View, CorsViewMixin):
"""
HEAD method implementation based on the result of GET method
+ Returns:
+ StreamResponse: generated response for the request
+
Raises:
HTTPMethodNotAllowed: in case if there is no GET method implemented
"""
diff --git a/src/ahriman/web/views/v1/packages/packages.py b/src/ahriman/web/views/v1/packages/packages.py
index 779591fe..818addec 100644
--- a/src/ahriman/web/views/v1/packages/packages.py
+++ b/src/ahriman/web/views/v1/packages/packages.py
@@ -46,7 +46,7 @@ class PackagesView(StatusViewGuard, BaseView):
ROUTES = ["/api/v1/packages"]
@apidocs(
- tags=["packages"],
+ tags=["Packages"],
summary="Get packages list",
description="Retrieve packages and their descriptors",
permission=GET_PERMISSION,
diff --git a/src/ahriman/web/views/v1/status/metrics.py b/src/ahriman/web/views/v1/status/metrics.py
new file mode 100644
index 00000000..c91f6bbd
--- /dev/null
+++ b/src/ahriman/web/views/v1/status/metrics.py
@@ -0,0 +1,56 @@
+#
+# Copyright (c) 2021-2025 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from aiohttp.web import Response
+from typing import ClassVar
+
+from ahriman.models.user_access import UserAccess
+from ahriman.web.apispec.decorators import apidocs
+from ahriman.web.middlewares.metrics_handler import metrics
+from ahriman.web.schemas import AnySchema
+from ahriman.web.views.base import BaseView
+
+
+class MetricsView(BaseView):
+ """
+ open metrics endpoints
+
+ Attributes:
+ GET_PERMISSION(UserAccess): (class attribute) get permissions of self
+ """
+
+ GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
+ ROUTES = ["/api/v1/metrics"]
+
+ @apidocs(
+ tags=["Status"],
+ summary="OpenMetrics endpoint",
+ description="Get service metrics in OpenMetrics format",
+ permission=GET_PERMISSION,
+ error_404_description="Endpoint is disabled",
+ schema=AnySchema,
+ )
+ async def get(self) -> Response:
+ """
+ get service HTTP metrics
+
+ Returns:
+ Response: 200 with service metrics as generated by the library
+ """
+ return await metrics(self.request)
diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py
index aaa99cf8..547023d3 100644
--- a/src/ahriman/web/web.py
+++ b/src/ahriman/web/web.py
@@ -37,6 +37,7 @@ from ahriman.web.apispec.info import setup_apispec
from ahriman.web.cors import setup_cors
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
from ahriman.web.middlewares.exception_handler import exception_handler
+from ahriman.web.middlewares.metrics_handler import metrics_handler
from ahriman.web.routes import setup_routes
@@ -146,6 +147,7 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
application.middlewares.append(normalize_path_middleware(append_slash=False, remove_slash=True))
application.middlewares.append(exception_handler(application.logger))
+ application.middlewares.append(metrics_handler())
application.logger.info("setup routes")
setup_routes(application, configuration)
diff --git a/tests/ahriman/web/middlewares/test_metrics_handler.py b/tests/ahriman/web/middlewares/test_metrics_handler.py
new file mode 100644
index 00000000..8bff58a6
--- /dev/null
+++ b/tests/ahriman/web/middlewares/test_metrics_handler.py
@@ -0,0 +1,59 @@
+import importlib
+import pytest
+import sys
+
+from aiohttp.web import HTTPNotFound
+from pytest_mock import MockerFixture
+from unittest.mock import AsyncMock
+
+import ahriman.web.middlewares.metrics_handler as metrics_handler
+
+
+async def test_metrics(mocker: MockerFixture) -> None:
+ """
+ must return metrics methods if library is available
+ """
+ metrics_mock = AsyncMock()
+ mocker.patch.object(metrics_handler, "aiohttp_openmetrics", metrics_mock)
+
+ await metrics_handler.metrics(42)
+ metrics_mock.metrics.assert_called_once_with(42)
+
+
+async def test_metrics_dummy(mocker: MockerFixture) -> None:
+ """
+ must raise HTTPNotFound if no module found
+ """
+ mocker.patch.object(metrics_handler, "aiohttp_openmetrics", None)
+ with pytest.raises(HTTPNotFound):
+ await metrics_handler.metrics(None)
+
+
+async def test_metrics_handler() -> None:
+ """
+ must return metrics handler if library is available
+ """
+ assert metrics_handler.metrics_handler() == metrics_handler.aiohttp_openmetrics.metrics_middleware
+
+
+async def test_metrics_handler_dummy(mocker: MockerFixture) -> None:
+ """
+ must return dummy handler if no module found
+ """
+ mocker.patch.object(metrics_handler, "aiohttp_openmetrics", None)
+ handler = metrics_handler.metrics_handler()
+
+ async def handle(result: int) -> int:
+ return result
+
+ assert await handler(42, handle) == 42
+
+
+def test_import_openmetrics_missing(mocker: MockerFixture) -> None:
+ """
+ must correctly process missing module
+ """
+ mocker.patch.dict(sys.modules, {"aiohttp_openmetrics": None})
+ importlib.reload(metrics_handler)
+
+ assert metrics_handler.aiohttp_openmetrics is None
diff --git a/tests/ahriman/web/schemas/test_any_schema.py b/tests/ahriman/web/schemas/test_any_schema.py
new file mode 100644
index 00000000..1982fb6b
--- /dev/null
+++ b/tests/ahriman/web/schemas/test_any_schema.py
@@ -0,0 +1 @@
+# schema testing goes in view class tests
diff --git a/tests/ahriman/web/test_routes.py b/tests/ahriman/web/test_routes.py
index bba6bf5e..4a07a569 100644
--- a/tests/ahriman/web/test_routes.py
+++ b/tests/ahriman/web/test_routes.py
@@ -3,7 +3,7 @@ from pathlib import Path
from ahriman.core.configuration import Configuration
from ahriman.core.utils import walk
-from ahriman.web.routes import _dynamic_routes, setup_routes
+from ahriman.web.routes import _dynamic_routes, _identifier, setup_routes
def test_dynamic_routes(resource_path_root: Path, configuration: Configuration) -> None:
@@ -22,9 +22,19 @@ def test_dynamic_routes(resource_path_root: Path, configuration: Configuration)
assert len(set(routes.values())) == len(expected_views)
+def test_identifier() -> None:
+ """
+ must correctly extract route identifiers
+ """
+ assert _identifier("/") == "_"
+ assert _identifier("/api/v1/status") == "_api_v1_status"
+ assert _identifier("/api/v1/packages/{package}") == "_api_v1_packages_:package"
+
+
def test_setup_routes(application: Application, configuration: Configuration) -> None:
"""
must generate non-empty list of routes
"""
+ application.router._named_resources = {}
setup_routes(application, configuration)
assert application.router.routes()
diff --git a/tests/ahriman/web/views/v1/status/test_view_v1_status_metrics.py b/tests/ahriman/web/views/v1/status/test_view_v1_status_metrics.py
new file mode 100644
index 00000000..c2870cc6
--- /dev/null
+++ b/tests/ahriman/web/views/v1/status/test_view_v1_status_metrics.py
@@ -0,0 +1,50 @@
+import pytest
+
+from aiohttp.test_utils import TestClient
+from aiohttp.web import Response
+from pytest_mock import MockerFixture
+
+import ahriman.web.middlewares.metrics_handler as metrics_handler
+
+from ahriman.models.user_access import UserAccess
+from ahriman.web.views.v1.status.metrics import MetricsView
+
+
+async def test_get_permission() -> None:
+ """
+ must return correct permission for the request
+ """
+ for method in ("GET",):
+ request = pytest.helpers.request("", "", method)
+ assert await MetricsView.get_permission(request) == UserAccess.Unauthorized
+
+
+def test_routes() -> None:
+ """
+ must return correct routes
+ """
+ assert MetricsView.ROUTES == ["/api/v1/metrics"]
+
+
+async def test_get(client: TestClient, mocker: MockerFixture) -> None:
+ """
+ must return service metrics
+ """
+ metrics_mock = mocker.patch("ahriman.web.views.v1.status.metrics.metrics", return_value=Response())
+
+ response = await client.get("/api/v1/metrics")
+ assert response.ok
+ # there is no response validation here, because it is free text, so we check call instead
+ metrics_mock.assert_called_once_with(pytest.helpers.anyvar(int))
+
+
+async def test_get_not_found(client: TestClient, mocker: MockerFixture) -> None:
+ """
+ must return 404 error if no module found
+ """
+ mocker.patch.object(metrics_handler, "aiohttp_openmetrics", None)
+ response_schema = pytest.helpers.schema_response(MetricsView.get, code=404)
+
+ response = await client.get("/api/v1/metrics")
+ assert response.status == 404
+ assert not response_schema.validate(await response.json())
diff --git a/tox.ini b/tox.ini
index b240f694..efe8369a 100644
--- a/tox.ini
+++ b/tox.ini
@@ -3,7 +3,7 @@ envlist = check, tests
isolated_build = true
labels =
release = version, docs, publish
-dependencies = -e .[journald,pacman,reports,s3,shell,stats,unixsocket,validator,web,web_api-docs,web_auth,web_oauth2]
+dependencies = -e .[journald,pacman,reports,s3,shell,stats,unixsocket,validator,web,web_api-docs,web_auth,web_oauth2,web_metrics]
project_name = ahriman
[mypy]