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/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..9eb5606f --- /dev/null +++ b/src/ahriman/web/middlewares/metrics_handler.py @@ -0,0 +1,73 @@ +# +# 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 + + Args: + request(Request): request object + handler(HandlerType): request handler as returned by application + + Returns: + StreamResponse: generated response for the request + """ + 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/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/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..ed6d976d --- /dev/null +++ b/tests/ahriman/web/middlewares/test_metrics_handler.py @@ -0,0 +1,59 @@ +import importlib +import pytest +import sys + +import ahriman.web.middlewares.metrics_handler as metrics_handler + +from aiohttp.web import HTTPNotFound +from pytest_mock import MockerFixture +from unittest.mock import AsyncMock + + +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/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..ed5ebca2 --- /dev/null +++ b/tests/ahriman/web/views/v1/status/test_view_v1_status_metrics.py @@ -0,0 +1,35 @@ +import pytest + +from aiohttp.test_utils import TestClient +from aiohttp.web import Response +from pytest_mock import MockerFixture + +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 + metrics_mock.assert_called_once_with(pytest.helpers.anyvar(int)) 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]