diff --git a/src/c3nav/mapdata/api/base.py b/src/c3nav/mapdata/api/base.py index f73af6b0..0c138e96 100644 --- a/src/c3nav/mapdata/api/base.py +++ b/src/c3nav/mapdata/api/base.py @@ -81,6 +81,8 @@ def api_etag(permissions=True, etag_func=AccessPermission.etag_func, base_mapdat def api_stats(stat_name): + from c3nav.mapdata.metrics import APIStatsCollector + APIStatsCollector.add_stat(stat_name, ['by', 'query']) def wrapper(func): @wraps(func) def wrapped_func(request, *args, **kwargs): diff --git a/src/c3nav/mapdata/metrics.py b/src/c3nav/mapdata/metrics.py index 01ee2aad..4c83431d 100644 --- a/src/c3nav/mapdata/metrics.py +++ b/src/c3nav/mapdata/metrics.py @@ -1,4 +1,6 @@ import re +from typing import Optional, Sequence + from django.conf import settings from django.contrib.auth import get_user_model from django.core.cache import cache @@ -18,15 +20,54 @@ if settings.METRCIS: reports_open.set_function(lambda: Report.objects.filter(open=True).count()), class APIStatsCollector(Collector): + + name_registry: dict[str, None | Sequence[str]] = dict() + def collect(self): + metrics: dict[str, CounterMetricFamily] = dict() if settings.CACHES['default']['BACKEND'] == 'django.core.cache.backends.redis.RedisCache': client = cache._cache.get_client() for key in client.keys(f"*{settings.CACHES['default'].get('KEY_PREFIX', '')}apistats__*"): - key = key.decode('utf-8').split(':', 2)[2] - key = re.sub(r'[^a-zA-Z0-9_]', '_', key) - yield CounterMetricFamily(f'c3nav_{key}', key, value=cache.get(key)) + key: str = key.decode('utf-8').split(':', 2)[2] + value = cache.get(key) + key = key[10:] # trim apistats__ from the beginning + + # some routing stats don't use double underscores to separate fields, workaround for now + if key.startswith('route_tuple_'): + key = re.sub(r'^route_tuple_(.*)_(.*)$', r'route_tuple__\1__\2', key) + if key.startswith('route_origin_') or key.startswith('route_destination_'): + key = re.sub(r'^route_(origin|destination)_(.*)$', r'route_\1__\2', key) + + name, *labels = key.split('__') + try: + label_names = self.name_registry[name] + except KeyError: + continue + + if label_names is None: + label_names = list() + + if len(label_names) != len(labels): + raise ValueError('configured labels and number of extracted labels doesn\'t match.') + + try: + counter = metrics[name] + except KeyError: + counter = metrics[name] = CounterMetricFamily(f'c3nav_{name}', f'c3nav_{name}', + labels=label_names) + counter.add_metric(labels, value) + return metrics.values() def describe(self): return list() + @classmethod + def add_stat(cls, name:str, label_names: Optional[str | Sequence[str]] = None): + if isinstance(label_names, str): + label_names = [label_names] + if name in cls.name_registry and label_names != cls.name_registry[name]: + raise KeyError(f'{name} already exists') + cls.name_registry[name] = label_names + + REGISTRY.register(APIStatsCollector()) diff --git a/src/c3nav/routing/api/positioning.py b/src/c3nav/routing/api/positioning.py index 1179de9c..8cf7137b 100644 --- a/src/c3nav/routing/api/positioning.py +++ b/src/c3nav/routing/api/positioning.py @@ -6,6 +6,7 @@ from ninja import Router as APIRouter from c3nav.api.auth import auth_responses from c3nav.api.schema import BaseSchema +from c3nav.mapdata.metrics import APIStatsCollector from c3nav.mapdata.models.access import AccessPermission from c3nav.mapdata.schemas.models import CustomLocationSchema from c3nav.mapdata.utils.cache.stats import increment_cache_key @@ -51,6 +52,9 @@ def get_position(request, parameters: LocateRequestSchema): } +APIStatsCollector.add_stat('locate', 'location') + + @positioning_api_router.get('/locate-test/', summary="debug position", description="outputs a location for debugging purposes", response={200: PositioningResult, **auth_responses}) diff --git a/src/c3nav/routing/api/routing.py b/src/c3nav/routing/api/routing.py index 70a262cc..36ce95b3 100644 --- a/src/c3nav/routing/api/routing.py +++ b/src/c3nav/routing/api/routing.py @@ -14,6 +14,7 @@ from c3nav.api.exceptions import APIRequestValidationFailed from c3nav.api.schema import BaseSchema from c3nav.api.utils import NonEmptyStr from c3nav.mapdata.api.base import api_stats_clean_location_value +from c3nav.mapdata.metrics import APIStatsCollector from c3nav.mapdata.models.access import AccessPermission from c3nav.mapdata.models.locations import Position from c3nav.mapdata.schemas.model_base import AnyLocationID, Coordinates3D @@ -251,6 +252,12 @@ def get_route(request, parameters: RouteParametersSchema): ) +APIStatsCollector.add_stat('route') +APIStatsCollector.add_stat('route_tuple', ['origin', 'destination']) +APIStatsCollector.add_stat('route_origin', ['origin']) +APIStatsCollector.add_stat('route_destination', ['destination']) + + def _new_serialize_route_options(options): # todo: RouteOptions should obviously be modernized main_options = {}