feat: add openmetrics support & endpoint

This commit is contained in:
2025-06-16 15:26:04 +03:00
parent e5d824b03f
commit 32f99f7f36
13 changed files with 264 additions and 1 deletions

View File

@ -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 \

View File

@ -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')

View File

@ -69,6 +69,10 @@ web_auth = [
"aiohttp_security",
"cryptography",
]
web_metrics = [
"ahriman[web]",
"aiohttp-openmetrics",
]
web_oauth2 = [
"ahriman[web_auth]",
"aioauth-client",

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -17,6 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
from ahriman.web.apispec import Schema
class AnySchema(Schema):
"""
response dummy schema
"""

View File

@ -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
"""

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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)

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1 @@
# schema testing goes in view class tests

View File

@ -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))

View File

@ -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]