Skip to content

Commit 205eb09

Browse files
committed
feat(core): implement prometheus metrics
see #231
1 parent c968eb6 commit 205eb09

File tree

9 files changed

+192
-34
lines changed

9 files changed

+192
-34
lines changed

Diff for: abrechnung/application/groups.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
from datetime import datetime
1+
from datetime import datetime, timedelta
22

33
import asyncpg
44
from sftkit.database import Connection
55
from sftkit.error import InvalidArgument
6-
from sftkit.service import Service, with_db_transaction
6+
from sftkit.service import Service, with_db_connection, with_db_transaction
77

88
from abrechnung.config import Config
99
from abrechnung.core.auth import create_group_log
@@ -20,6 +20,7 @@
2020
GroupPreview,
2121
)
2222
from abrechnung.domain.users import User
23+
from abrechnung.util import timed_cache
2324

2425

2526
class GroupService(Service[Config]):
@@ -461,3 +462,8 @@ async def unarchive_group(self, *, conn: Connection, user: User, group_id: int):
461462
"update grp set archived = false where id = $1",
462463
group_id,
463464
)
465+
466+
@with_db_connection
467+
@timed_cache(timedelta(minutes=5))
468+
async def total_number_of_groups(self, conn: Connection):
469+
return await conn.fetchval("select count(*) from grp")

Diff for: abrechnung/application/transactions.py

+20-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import base64
2-
from datetime import datetime
2+
from datetime import datetime, timedelta
33
from typing import Optional, Union
44

55
import asyncpg
66
from sftkit.database import Connection
77
from sftkit.error import InvalidArgument
8-
from sftkit.service import Service, with_db_transaction
8+
from sftkit.service import Service, with_db_connection, with_db_transaction
99

1010
from abrechnung.application.common import _get_or_create_tag_ids
1111
from abrechnung.config import Config
@@ -25,6 +25,7 @@
2525
UpdateTransaction,
2626
)
2727
from abrechnung.domain.users import User
28+
from abrechnung.util import timed_cache
2829

2930

3031
class TransactionService(Service[Config]):
@@ -721,3 +722,20 @@ async def _create_pending_transaction_change(
721722
)
722723

723724
return revision_id
725+
726+
@with_db_connection
727+
@timed_cache(timedelta(minutes=5))
728+
async def total_number_of_transactions(self, conn: Connection):
729+
return await conn.fetchval("select count(*) from transaction_state_valid_at(now()) where not deleted")
730+
731+
@with_db_connection
732+
@timed_cache(timedelta(minutes=5))
733+
async def total_amount_of_money_per_currency(self, conn: Connection) -> dict[str, float]:
734+
result = await conn.fetch(
735+
"select t.currency_symbol, sum(t.value) as total_value "
736+
"from transaction_state_valid_at(now()) as t where not t.deleted group by t.currency_symbol"
737+
)
738+
aggregated = {}
739+
for row in result:
740+
aggregated[row["currency_symbol"]] = row["total_value"]
741+
return aggregated

Diff for: abrechnung/config.py

+6
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ class AuthConfig(BaseModel):
6767
auth: Optional[AuthConfig] = None
6868

6969

70+
class MetricsConfig(BaseModel):
71+
enabled: bool = False
72+
expose_money_amounts: bool = False
73+
74+
7075
class Config(BaseSettings):
7176
model_config = SettingsConfigDict(env_prefix="ABRECHNUNG_", env_nested_delimiter="__")
7277

@@ -77,6 +82,7 @@ class Config(BaseSettings):
7782
# in case all params are optional this is needed to make the whole section optional
7883
demo: DemoConfig = DemoConfig()
7984
registration: RegistrationConfig = RegistrationConfig()
85+
metrics: MetricsConfig = MetricsConfig()
8086

8187
@classmethod
8288
def settings_customise_sources(

Diff for: abrechnung/http/api.py

+13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import logging
33

4+
from prometheus_fastapi_instrumentator import Instrumentator
45
from sftkit.http import Server
56

67
from abrechnung import __version__
@@ -11,6 +12,7 @@
1112
from abrechnung.config import Config
1213
from abrechnung.database.migrations import check_revision_version, get_database
1314

15+
from . import metrics
1416
from .context import Context
1517
from .routers import accounts, auth, common, groups, transactions
1618

@@ -64,9 +66,20 @@ async def _setup(self):
6466
async def _teardown(self):
6567
await self.db_pool.close()
6668

69+
def _instrument_api(self):
70+
if not self.cfg.metrics.enabled:
71+
return
72+
instrumentor = Instrumentator()
73+
instrumentor.add(metrics.abrechnung_number_of_groups_total(self.group_service))
74+
instrumentor.add(metrics.abrechnung_number_of_transactions_total(self.transaction_service))
75+
if self.cfg.metrics.expose_money_amounts:
76+
instrumentor.add(metrics.abrechnung_total_amount_of_money(self.transaction_service))
77+
instrumentor.instrument(self.server.api).expose(self.server.api, endpoint="/api/metrics")
78+
6779
async def run(self):
6880
await self._setup()
6981
try:
82+
self._instrument_api()
7083
await self.server.run(self.context)
7184
finally:
7285
await self._teardown()

Diff for: abrechnung/http/metrics.py

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from prometheus_client import Gauge
2+
from prometheus_fastapi_instrumentator.metrics import Info
3+
4+
from abrechnung.application.groups import GroupService
5+
from abrechnung.application.transactions import TransactionService
6+
7+
8+
def abrechnung_number_of_groups_total(group_service: GroupService):
9+
metric = Gauge(
10+
"abrechnung_number_of_groups_total",
11+
"Number of groups created on this Abrechnung instance..",
12+
)
13+
14+
async def instrumentation(info: Info) -> None:
15+
total = await group_service.total_number_of_groups()
16+
metric.set(total)
17+
18+
return instrumentation
19+
20+
21+
def abrechnung_number_of_transactions_total(transaction_service: TransactionService):
22+
metric = Gauge(
23+
"abrechnung_number_of_transactions_total",
24+
"Number of transactions created on this Abrechnung instance..",
25+
)
26+
27+
async def instrumentation(info: Info) -> None:
28+
total = await transaction_service.total_number_of_transactions()
29+
metric.set(total)
30+
31+
return instrumentation
32+
33+
34+
def abrechnung_total_amount_of_money(transaction_service: TransactionService):
35+
metric = Gauge(
36+
"abrechnung_total_amount_of_money",
37+
"Total amount of money per currency cleared via thisthis Abrechnung instance..",
38+
labelnames=("currency_symbol",),
39+
)
40+
41+
async def instrumentation(info: Info) -> None:
42+
total = await transaction_service.total_amount_of_money_per_currency()
43+
for currency, value in total.items():
44+
metric.labels(currency).set(value)
45+
46+
return instrumentation

Diff for: abrechnung/util.py

+21
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import functools
12
import logging
23
import re
34
import uuid
@@ -72,3 +73,23 @@ def is_valid_uuid(val: str):
7273
return True
7374
except ValueError:
7475
return False
76+
77+
78+
def timed_cache(ttl: timedelta):
79+
def wrapper(func):
80+
last_execution: datetime | None = None
81+
last_value = None
82+
83+
@functools.wraps(func)
84+
async def wrapped(*args, **kwargs):
85+
nonlocal last_execution, last_value
86+
current_execution = datetime.now()
87+
if last_execution is None or last_value is None or current_execution - last_execution > ttl:
88+
last_value = await func(*args, **kwargs)
89+
last_execution = current_execution
90+
91+
return last_value
92+
93+
return wrapped
94+
95+
return wrapper

Diff for: docs/development/setup.rst

+41-24
Original file line numberDiff line numberDiff line change
@@ -11,32 +11,28 @@ Setup
1111
Installation
1212
------------
1313

14-
Fork and clone the repository ::
14+
Fork and clone the repository
15+
16+
.. code-block:: shell
1517
1618
git clone https://github.com/SFTtech/abrechnung.git
1719
cd abrechnung
1820
19-
Then install the package in local development mode as well as all required dependencies. Make sure to have
20-
`flit <https://github.com/pypa/flit>`_ installed first. Installing the dependencies can be two ways:
21+
Then install the package in local development mode as well as all required dependencies.
2122

22-
Setup a virtual environment and install the packages via pip (straightforward way) ::
23+
Setup a virtual environment and install the packages via pip
2324

24-
virtualenv -p python3 venv
25-
source venv/bin/activate
26-
pip install flit
27-
flit install -s --deps develop
25+
.. code-block:: shell
2826
29-
Or install the dependencies through your package manager (useful for distribution packaging)
27+
virtualenv -p python3 .venv
28+
source .venv/bin/activate
29+
pip install -e . '[dev,test]'
3030
31-
* arch linux (slight chance some dependencies may be missing here)
31+
Additionally you probably will want to activate the git pre-commit hooks (for formatting and linting) by running
3232

3333
.. code-block:: shell
3434
35-
sudo pacman -S python-flit python-yaml python-aiohttp python-aiohttp-cors python-asyncpg python-sphinx python-schema python-email-validator python-bcrypt python-pyjwt python-aiosmtpd python-pytest python-pytest-cov python-black python-mypy python-pylint python-apispec python-marshmallow python-webargs
36-
37-
Afterwards install the package without dependencies ::
38-
39-
flit install -s --deps none
35+
pre-commit install
4036
4137
Database Setup
4238
--------------
@@ -60,7 +56,9 @@ Create the database (in a psql prompt):
6056
* Launch ``abrechnung -c abrechnung.yaml api``
6157
* Launch ``abrechnung -c abrechnung.yaml mailer`` to get mail delivery (working mail server in config file required!)
6258

63-
It is always possible wipe and rebuild the database with ::
59+
It is always possible wipe and rebuild the database with
60+
61+
.. code-block:: shell
6462
6563
abrechnung -c abrechnung.yaml db rebuild
6664
@@ -71,7 +69,7 @@ In case a new features requires changes to the database schema create a new migr
7169

7270
.. code-block:: shell
7371
74-
./tools/create_revision.py <revision_name>
72+
sftkit create-migration <revision_name>
7573
7674
In case you did not install the abrechnung in development mode it might be necessary to add the project root folder
7775
to your ``PYTHONPATH``.
@@ -100,30 +98,49 @@ is used as a means to wipe and repopulate the database between tests.
10098
10199
alter schema public owner to "<your user>"
102100
103-
Finally run the tests via ::
101+
Finally run the tests via
102+
103+
.. code-block:: shell
104104
105105
make test
106106
107-
Run the linters via ::
107+
Run the linters via
108+
109+
.. code-block:: shell
108110
109111
make lint
110112
113+
Run the formatters via
114+
115+
.. code-block:: shell
116+
117+
make format
118+
111119
Frontend Development
112120
--------------------
113121

114-
Working on the frontend is quite easy, simply ::
122+
Working on the frontend is quite easy, simply
123+
124+
.. code-block:: shell
115125
116126
cd web
117-
yarn install
118-
yarn start
127+
npm install
128+
npx nx serve web
119129
120130
and you are good to go!
121131

122132
Documentation
123133
-------------
124134

125-
To build the documentation locally simply run ::
135+
To build the documentation locally simply run
126136

137+
.. code-block:: shell
138+
139+
pip install -r docs/requires.txt
127140
make docs
128141
129-
The html docs can then be found in ``docs/_build``.
142+
The html docs can then be found in ``docs/_build`` or served locally with
143+
144+
.. code-block:: shell
145+
146+
make serve-docs

Diff for: docs/usage/configuration.rst

+36-6
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ Database
1515
The first step after installing the **abrechnung** is to setup the database. Due to the use of database specific features
1616
we only support **PostgreSQL** with versions >= 13. Other versions might work but are untested.
1717

18-
First create a database with an associated user ::
18+
First create a database with an associated user
1919

20-
$ sudo -u postgres psql
21-
> create user abrechnung with password '<some secure password>';
22-
> create database abrechnung owner abrechnung;
20+
.. code-block:: shell
21+
22+
sudo -u postgres psql
23+
create user abrechnung with password '<some secure password>';
24+
create database abrechnung owner abrechnung;
2325
2426
Enter the information into the config file in ``/etc/abrechnung/abrechnung.yaml`` under the section database as
2527

@@ -31,7 +33,9 @@ Enter the information into the config file in ``/etc/abrechnung/abrechnung.yaml`
3133
dbname: "abrechnung"
3234
password: "<password>"
3335
34-
Apply all database migrations with ::
36+
Apply all database migrations with
37+
38+
.. code-block:: shell
3539
3640
abrechnung db migrate
3741
@@ -48,7 +52,9 @@ The ``name`` is used to populate the email subjects as ``[<name>] <subject>``.
4852
API Config
4953
---------------
5054
Typically the config for the http API does not need to be changed much apart from two important settings!
51-
In the ``api`` section make sure to insert a newly generated secret key, e.g. with ::
55+
In the ``api`` section make sure to insert a newly generated secret key, e.g. with
56+
57+
.. code-block:: shell
5258
5359
pwgen -S 64 1
5460
@@ -124,6 +130,30 @@ Guest users will not be able to create new groups themselves but can take part i
124130
valid_email_domains: ["some-domain.com"]
125131
allow_guest_users: true
126132
133+
Prometheus Metrics
134+
------------------
135+
136+
Abrechnung also provides prometheus metrics which are disabled by default.
137+
This includes some general metrics about the abrechnung instance such as
138+
139+
- http request durations and groupings of error codes
140+
- general python environment metrics such as process utilization and garbage collection performance
141+
142+
Additionally it currently includes the following set of abrechnung specific metrics
143+
144+
- number of groups created on the instance
145+
- number of transactions created on the instance
146+
- total amount of money by currency which was cleared via the instance, i.e. the total sum of transaction values per currency over all groups.
147+
This is disabled by default as it may expose private data on very small abrechnung instances.
148+
149+
To enable metrics under the api endpoint ``/api/metrics`` simply add the following to the config file
150+
151+
.. code-block:: yaml
152+
153+
metrics:
154+
enabled: true
155+
expose_money_amounts: false # disabled by default
156+
127157
Configuration via Environment Variables
128158
---------------------------------------
129159

0 commit comments

Comments
 (0)