diff --git a/battleship/server/app.py b/battleship/server/app.py index ab6aca0..c2122da 100644 --- a/battleship/server/app.py +++ b/battleship/server/app.py @@ -18,6 +18,10 @@ PlayerSubscriptionHandler, SessionSubscriptionHandler, ) +from battleship.server.metrics import ( + MetricsMiddleware, + MetricsScraperAuthenticationHandler, +) from battleship.server.pubsub import ( IncomingChannel, IncomingRedisChannel, @@ -82,7 +86,11 @@ async def sentry_context_middleware( if identity and identity.is_authenticated(): sentry_sdk.set_user( - {"id": identity.sub, "username": identity["nickname"], "email": identity["email"]} + { + "id": identity.sub, + "username": identity.get("nickname"), + "email": identity.get("email"), + } ) return await handler(request) @@ -115,6 +123,10 @@ def create_app() -> Any: ) ) + app.use_authentication().add( + MetricsScraperAuthenticationHandler(scraper_secret=config.METRICS_SCRAPER_SECRET) + ) + app.use_authorization().with_default_policy( Policy("authenticated", AuthenticatedRequirement()), ) @@ -124,7 +136,16 @@ def create_app() -> Any: app.middlewares.append(client_version_middleware) app.middlewares.append(sentry_context_middleware) + app_router = app.router if config.SENTRY_DSN: - return configure_sentry(app, config.SENTRY_DSN, config.SERVER_VERSION) + app = configure_sentry( + app, + config.SENTRY_DSN, + config.SERVER_VERSION, + ) # type: ignore[assignment] + + if config.METRICS_SCRAPER_SECRET: + app = MetricsMiddleware(app, router=app_router) # type: ignore[assignment] + return app diff --git a/battleship/server/config.py b/battleship/server/config.py index 865a7e4..04dc053 100644 --- a/battleship/server/config.py +++ b/battleship/server/config.py @@ -12,6 +12,7 @@ class Config(BaseSettings): BROKER_URL: RedisDsn SERVER_VERSION: str SENTRY_DSN: str + METRICS_SCRAPER_SECRET: str @property def auth0_audience(self) -> str: diff --git a/battleship/server/metrics.py b/battleship/server/metrics.py new file mode 100644 index 0000000..70fbffa --- /dev/null +++ b/battleship/server/metrics.py @@ -0,0 +1,72 @@ +from typing import Any + +from aioprometheus.asgi.middleware import MetricsMiddleware as _MetricsMiddleware +from aioprometheus.asgi.middleware import Receive, Scope, Send +from aioprometheus.collectors import REGISTRY +from aioprometheus.renderer import render +from blacksheep import Request, Router +from guardpost import AuthenticationHandler, Identity + +__all__ = [ + "MetricsMiddleware", + "MetricsScraperAuthenticationHandler", + "render_metrics", +] + + +class MetricsScraperAuthenticationHandler(AuthenticationHandler): + def __init__(self, scraper_secret: str): + self._secret = scraper_secret.encode() + + def authenticate(self, context: Request) -> Identity | None: + header_value = context.get_first_header(b"Authorization") + + try: + type_, secret = header_value.split() # type: ignore[union-attr] + except Exception: + context.identity = None + else: + if type_ == b"Bearer" and secret == self._secret: + context.identity = Identity({"id": "scraper"}, authentication_mode="Bearer") + else: + context.identity = None + + return context.identity + + +class MetricsMiddleware(_MetricsMiddleware): + """ + ioprometheus.MetricsMiddleware that doesn't fail on WebSocket connections + and extracts template paths from Blacksheep. + """ + + def __init__(self, *args: Any, router: Router, **kwargs: Any): + super().__init__(*args, **kwargs) + self.router = router + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "websocket": + await self.asgi_callable(scope, receive, send) + return + + await super().__call__(scope, receive, send) + + def get_full_or_template_path(self, scope: Scope) -> str: + root_path = scope.get("root_path", "") + path = scope.get("path", "") + full_path = f"{root_path}{path}" + method = scope.get("method", "").upper() + + if self.use_template_urls: + match = self.router.get_match_by_method_and_path(method, path) + + if match is not None: + return match.pattern.decode() + return full_path + + +def render_metrics(accept_headers: list[bytes]) -> tuple[str, dict[bytes, bytes]]: + accept_headers_decoded = [value.decode() for value in accept_headers] + content, headers = render(REGISTRY, accept_headers_decoded) + headers = {k.encode(): v.encode() for k, v in headers.items()} + return content.decode(), headers diff --git a/battleship/server/routes.py b/battleship/server/routes.py index 6a48e32..2625058 100644 --- a/battleship/server/routes.py +++ b/battleship/server/routes.py @@ -2,6 +2,7 @@ from blacksheep import ( FromJSON, + Request, Response, Router, WebSocket, @@ -22,6 +23,7 @@ PlayerSubscriptionHandler, SessionSubscriptionHandler, ) +from battleship.server.metrics import render_metrics from battleship.server.pubsub import IncomingChannel, OutgoingChannel from battleship.server.repositories import ( ClientRepository, @@ -41,7 +43,7 @@ SignupCredentials, ) -router = Router() # type: ignore[no-untyped-call] +router = Router() @router.ws("/ws") @@ -231,3 +233,12 @@ async def get_player_statistics( statistics = await statistics_repository.create(user_id) return ok(statistics.to_dict()) + + +@router.get("/metrics") +async def get_metrics(request: Request) -> Response: + accept_headers = request.get_headers(b"Accept") + content, headers = render_metrics(accept_headers) + response = ok(content) + response.headers.add_many(headers) + return response diff --git a/poetry.lock b/poetry.lock index 732197b..74bf84c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -97,6 +97,26 @@ yarl = ">=1.0,<2.0" [package.extras] speedups = ["Brotli", "aiodns", "brotlicffi"] +[[package]] +name = "aioprometheus" +version = "23.3.0" +description = "A Prometheus Python client library for asyncio-based applications" +category = "main" +optional = true +python-versions = ">=3.8.0" +files = [ + {file = "aioprometheus-23.3.0-py3-none-any.whl", hash = "sha256:142cd23d00b31e7c498bb4d03f390b93fcae0225aa6dbb97f3c731fc337561b8"}, +] + +[package.dependencies] +orjson = "*" +quantile-python = ">=1.1" + +[package.extras] +aiohttp = ["aiohttp (>=3.3.2)"] +quart = ["quart (>=0.15.1)"] +starlette = ["starlette (>=0.14.2)"] + [[package]] name = "aiosignal" version = "1.3.1" @@ -1283,6 +1303,66 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "orjson" +version = "3.9.10" +description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" +category = "main" +optional = true +python-versions = ">=3.8" +files = [ + {file = "orjson-3.9.10-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c18a4da2f50050a03d1da5317388ef84a16013302a5281d6f64e4a3f406aabc4"}, + {file = "orjson-3.9.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5148bab4d71f58948c7c39d12b14a9005b6ab35a0bdf317a8ade9a9e4d9d0bd5"}, + {file = "orjson-3.9.10-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cf7837c3b11a2dfb589f8530b3cff2bd0307ace4c301e8997e95c7468c1378e"}, + {file = "orjson-3.9.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c62b6fa2961a1dcc51ebe88771be5319a93fd89bd247c9ddf732bc250507bc2b"}, + {file = "orjson-3.9.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb3922a7a804755bbe6b5be9b312e746137a03600f488290318936c1a2d4dc"}, + {file = "orjson-3.9.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1234dc92d011d3554d929b6cf058ac4a24d188d97be5e04355f1b9223e98bbe9"}, + {file = "orjson-3.9.10-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:06ad5543217e0e46fd7ab7ea45d506c76f878b87b1b4e369006bdb01acc05a83"}, + {file = "orjson-3.9.10-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4fd72fab7bddce46c6826994ce1e7de145ae1e9e106ebb8eb9ce1393ca01444d"}, + {file = "orjson-3.9.10-cp310-none-win32.whl", hash = "sha256:b5b7d4a44cc0e6ff98da5d56cde794385bdd212a86563ac321ca64d7f80c80d1"}, + {file = "orjson-3.9.10-cp310-none-win_amd64.whl", hash = "sha256:61804231099214e2f84998316f3238c4c2c4aaec302df12b21a64d72e2a135c7"}, + {file = "orjson-3.9.10-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cff7570d492bcf4b64cc862a6e2fb77edd5e5748ad715f487628f102815165e9"}, + {file = "orjson-3.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8bc367f725dfc5cabeed1ae079d00369900231fbb5a5280cf0736c30e2adf7"}, + {file = "orjson-3.9.10-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c812312847867b6335cfb264772f2a7e85b3b502d3a6b0586aa35e1858528ab1"}, + {file = "orjson-3.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9edd2856611e5050004f4722922b7b1cd6268da34102667bd49d2a2b18bafb81"}, + {file = "orjson-3.9.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:674eb520f02422546c40401f4efaf8207b5e29e420c17051cddf6c02783ff5ca"}, + {file = "orjson-3.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d0dc4310da8b5f6415949bd5ef937e60aeb0eb6b16f95041b5e43e6200821fb"}, + {file = "orjson-3.9.10-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e99c625b8c95d7741fe057585176b1b8783d46ed4b8932cf98ee145c4facf499"}, + {file = "orjson-3.9.10-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ec6f18f96b47299c11203edfbdc34e1b69085070d9a3d1f302810cc23ad36bf3"}, + {file = "orjson-3.9.10-cp311-none-win32.whl", hash = "sha256:ce0a29c28dfb8eccd0f16219360530bc3cfdf6bf70ca384dacd36e6c650ef8e8"}, + {file = "orjson-3.9.10-cp311-none-win_amd64.whl", hash = "sha256:cf80b550092cc480a0cbd0750e8189247ff45457e5a023305f7ef1bcec811616"}, + {file = "orjson-3.9.10-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:602a8001bdf60e1a7d544be29c82560a7b49319a0b31d62586548835bbe2c862"}, + {file = "orjson-3.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f295efcd47b6124b01255d1491f9e46f17ef40d3d7eabf7364099e463fb45f0f"}, + {file = "orjson-3.9.10-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92af0d00091e744587221e79f68d617b432425a7e59328ca4c496f774a356071"}, + {file = "orjson-3.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c5a02360e73e7208a872bf65a7554c9f15df5fe063dc047f79738998b0506a14"}, + {file = "orjson-3.9.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:858379cbb08d84fe7583231077d9a36a1a20eb72f8c9076a45df8b083724ad1d"}, + {file = "orjson-3.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666c6fdcaac1f13eb982b649e1c311c08d7097cbda24f32612dae43648d8db8d"}, + {file = "orjson-3.9.10-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3fb205ab52a2e30354640780ce4587157a9563a68c9beaf52153e1cea9aa0921"}, + {file = "orjson-3.9.10-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7ec960b1b942ee3c69323b8721df2a3ce28ff40e7ca47873ae35bfafeb4555ca"}, + {file = "orjson-3.9.10-cp312-none-win_amd64.whl", hash = "sha256:3e892621434392199efb54e69edfff9f699f6cc36dd9553c5bf796058b14b20d"}, + {file = "orjson-3.9.10-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8b9ba0ccd5a7f4219e67fbbe25e6b4a46ceef783c42af7dbc1da548eb28b6531"}, + {file = "orjson-3.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e2ecd1d349e62e3960695214f40939bbfdcaeaaa62ccc638f8e651cf0970e5f"}, + {file = "orjson-3.9.10-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f433be3b3f4c66016d5a20e5b4444ef833a1f802ced13a2d852c637f69729c1"}, + {file = "orjson-3.9.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4689270c35d4bb3102e103ac43c3f0b76b169760aff8bcf2d401a3e0e58cdb7f"}, + {file = "orjson-3.9.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bd176f528a8151a6efc5359b853ba3cc0e82d4cd1fab9c1300c5d957dc8f48c"}, + {file = "orjson-3.9.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a2ce5ea4f71681623f04e2b7dadede3c7435dfb5e5e2d1d0ec25b35530e277b"}, + {file = "orjson-3.9.10-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:49f8ad582da6e8d2cf663c4ba5bf9f83cc052570a3a767487fec6af839b0e777"}, + {file = "orjson-3.9.10-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2a11b4b1a8415f105d989876a19b173f6cdc89ca13855ccc67c18efbd7cbd1f8"}, + {file = "orjson-3.9.10-cp38-none-win32.whl", hash = "sha256:a353bf1f565ed27ba71a419b2cd3db9d6151da426b61b289b6ba1422a702e643"}, + {file = "orjson-3.9.10-cp38-none-win_amd64.whl", hash = "sha256:e28a50b5be854e18d54f75ef1bb13e1abf4bc650ab9d635e4258c58e71eb6ad5"}, + {file = "orjson-3.9.10-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ee5926746232f627a3be1cc175b2cfad24d0170d520361f4ce3fa2fd83f09e1d"}, + {file = "orjson-3.9.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a73160e823151f33cdc05fe2cea557c5ef12fdf276ce29bb4f1c571c8368a60"}, + {file = "orjson-3.9.10-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c338ed69ad0b8f8f8920c13f529889fe0771abbb46550013e3c3d01e5174deef"}, + {file = "orjson-3.9.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5869e8e130e99687d9e4be835116c4ebd83ca92e52e55810962446d841aba8de"}, + {file = "orjson-3.9.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2c1e559d96a7f94a4f581e2a32d6d610df5840881a8cba8f25e446f4d792df3"}, + {file = "orjson-3.9.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a3a3a72c9811b56adf8bcc829b010163bb2fc308877e50e9910c9357e78521"}, + {file = "orjson-3.9.10-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7f8fb7f5ecf4f6355683ac6881fd64b5bb2b8a60e3ccde6ff799e48791d8f864"}, + {file = "orjson-3.9.10-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c943b35ecdf7123b2d81d225397efddf0bce2e81db2f3ae633ead38e85cd5ade"}, + {file = "orjson-3.9.10-cp39-none-win32.whl", hash = "sha256:fb0b361d73f6b8eeceba47cd37070b5e6c9de5beaeaa63a1cb35c7e1a73ef088"}, + {file = "orjson-3.9.10-cp39-none-win_amd64.whl", hash = "sha256:b90f340cb6397ec7a854157fac03f0c82b744abdd1c0941a024c3c29d1340aff"}, + {file = "orjson-3.9.10.tar.gz", hash = "sha256:9ebbdbd6a046c304b1845e96fbcc5559cd296b4dfd3ad2509e33c4d9ce07d6a1"}, +] + [[package]] name = "packaging" version = "23.2" @@ -1654,6 +1734,17 @@ files = [ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] +[[package]] +name = "quantile-python" +version = "1.1" +description = "Python Implementation of Graham Cormode and S. Muthukrishnan's Effective Computation of Biased Quantiles over Data Streams in ICDE'05" +category = "main" +optional = true +python-versions = "*" +files = [ + {file = "quantile-python-1.1.tar.gz", hash = "sha256:558629e88c497ef3b9b1081349c1ae6a61b53590e317724298ff54c674db7969"}, +] + [[package]] name = "redis" version = "5.0.1" @@ -2254,9 +2345,9 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [extras] client = ["async-timeout", "pyee", "textual", "typer"] dev = ["textual-dev"] -server = ["auth0-python", "blacksheep", "pyjwt", "redis", "sentry-sdk", "uvicorn", "uvloop"] +server = ["aioprometheus", "auth0-python", "blacksheep", "pyjwt", "redis", "sentry-sdk", "uvicorn", "uvloop"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "cff19e611e738e003378fd5b4f87dc7fa3872a54f6a47cf0e14bda86e446a6f7" +content-hash = "00100114d22e6e4d5d017bbcdded58e5f21d99f126963f1c5512dd8edc7a415c" diff --git a/pyproject.toml b/pyproject.toml index 59a1639..c7e65c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,10 +47,11 @@ textual-dev = { version = "^1.2.1", optional = true } async-timeout = { version = "^4.0.3", optional = true, markers = "python_version < '3.11'" } backports-strenum = { version = "^1.2.8", markers = "python_version < '3.11'" } sentry-sdk = { version = "^1.38.0", optional = true } +aioprometheus = {version = "^23.3.0", optional = true} [tool.poetry.extras] -server = ["blacksheep", "uvicorn", "auth0-python", "pyjwt", "uvloop", "redis", "sentry-sdk"] +server = ["blacksheep", "uvicorn", "auth0-python", "pyjwt", "uvloop", "redis", "sentry-sdk", "aioprometheus"] client = ["textual", "typer", "pyee", "async-timeout"] dev = ["textual-dev"] diff --git a/requirements-server.txt b/requirements-server.txt index c42a6b1..304367d 100644 --- a/requirements-server.txt +++ b/requirements-server.txt @@ -1,4 +1,5 @@ aiohttp==3.9.1 ; python_version >= "3.10" and python_version < "4.0" +aioprometheus==23.3.0 ; python_version >= "3.10" and python_version < "4.0" aiosignal==1.3.1 ; python_version >= "3.10" and python_version < "4.0" annotated-types==0.6.0 ; python_version >= "3.10" and python_version < "4.0" anyio==4.1.0 ; python_version >= "3.10" and python_version < "4.0" @@ -30,6 +31,7 @@ itsdangerous==2.1.2 ; python_version >= "3.10" and python_version < "4.0" loguru==0.7.2 ; python_version >= "3.10" and python_version < "4.0" markupsafe==2.1.3 ; python_version >= "3.10" and python_version < "4.0" multidict==6.0.4 ; python_version >= "3.10" and python_version < "4.0" +orjson==3.9.10 ; python_version >= "3.10" and python_version < "4.0" pycparser==2.21 ; python_version >= "3.10" and python_version < "4.0" pydantic-core==2.14.5 ; python_version >= "3.10" and python_version < "4.0" pydantic-settings==2.1.0 ; python_version >= "3.10" and python_version < "4.0" @@ -40,6 +42,7 @@ pyopenssl==23.3.0 ; python_version >= "3.10" and python_version < "4.0" python-dateutil==2.8.2 ; python_version >= "3.10" and python_version < "4.0" python-dotenv==1.0.0 ; python_version >= "3.10" and python_version < "4.0" pyyaml==6.0.1 ; python_version >= "3.10" and python_version < "4.0" +quantile-python==1.1 ; python_version >= "3.10" and python_version < "4.0" redis==5.0.1 ; python_version >= "3.10" and python_version < "4.0" requests==2.31.0 ; python_version >= "3.10" and python_version < "4.0" rodi==2.0.6 ; python_version >= "3.10" and python_version < "4.0"