From 10ba19e9e8c810c5cac64ab00c5595ffb7681c95 Mon Sep 17 00:00:00 2001 From: Jaimyn Mayer Date: Tue, 28 May 2024 23:52:27 +1000 Subject: [PATCH] added access device metrics --- memberportal/access/metrics.py | 103 +++++++++++++++++++++++++++++ memberportal/access/models.py | 53 ++++++++++++++- memberportal/api_access/metrics.py | 25 +++++++ memberportal/api_access/views.py | 59 ++++++++++++++++- memberportal/requirements.txt | 1 + 5 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 memberportal/access/metrics.py create mode 100644 memberportal/api_access/metrics.py diff --git a/memberportal/access/metrics.py b/memberportal/access/metrics.py new file mode 100644 index 00000000..b5d6f1b0 --- /dev/null +++ b/memberportal/access/metrics.py @@ -0,0 +1,103 @@ +from prometheus_client import Counter, Histogram + +device_connections_total = Counter( + "device_connections_total", + "Number of device connections", + ["type", "id"], +) + +device_disconnections_total = Counter( + "device_disconnections_total", + "Number of device disconnections", + ["type", "id"], +) + +device_authentications_total = Counter( + "device_authentications_total", + "Number of device authentications", + ["type", "id"], +) + +device_checkins_total = Counter( + "device_checkins_total", + "Number of device checkins", + ["type", "id"], +) + +device_access_successes_total = Counter( + "device_access_successes_total", + "Number of successful access swipes logged", + ["type", "id"], +) + +device_access_failures_total = Counter( + "device_access_failures_total", + "Number of failed access swipes logged", + ["type", "id"], +) + +device_force_reboots_total = Counter( + "device_force_reboots_total", + "Number of force reboots", + ["type", "id"], +) + +device_syncs_total = Counter( + "device_syncs_total", + "Number of syncs", + ["type", "id"], +) + +device_force_bumps_total = Counter( + "device_force_bumps_total", + "Number of force bumps", + ["type", "id"], +) + +device_force_locks_total = Counter( + "device_force_locks_total", + "Number of force locks", + ["type", "id"], +) + +device_force_unlocks_total = Counter( + "device_force_unlocks_total", + "Number of force unlocks", + ["type", "id"], +) + +device_interlock_session_activations_total = Counter( + "device_interlock_session_activations_total", + "Number of interlock session activations", + ["type", "id"], +) + +device_interlock_sessions_left_on_total = Counter( + "device_interlock_sessions_left_on_total", + "Number of interlock sessions left on", + ["type", "id"], +) + +device_interlock_sessions_deactivated_total = Counter( + "device_interlock_sessions_deactivated_total", + "Number of interlock session deactivations", + ["type", "id"], +) + +device_interlock_sessions_rejected_total = Counter( + "device_interlock_sessions_rejected_total", + "Number of interlock session rejections", + ["type", "id"], +) + +device_interlock_sessions_cost_cents = Counter( + "device_interlock_sessions_cost_cents", + "Cost of interlock sessions", + ["type", "id"], +) + +device_interlock_session_duration_seconds = Histogram( + "device_interlock_session_duration_seconds", + "Duration of interlock sessions", + ["type", "id"], +) diff --git a/memberportal/access/models.py b/memberportal/access/models.py index 3a8b4f35..238837a2 100644 --- a/memberportal/access/models.py +++ b/memberportal/access/models.py @@ -17,6 +17,7 @@ import hashlib from django.core.validators import URLValidator from django_prometheus.models import ExportModelOperationsMixin +import metrics logger = logging.getLogger("access") User = auth.get_user_model() @@ -77,9 +78,18 @@ class AccessControlledDevice( "Hidden from members in their access permissions screen", default=False ) + type = "unknown" + + def get_metrics_labels(self): + return { + "type": self.type, + "id": self.id, + } + def checkin(self): self.last_seen = timezone.now() self.save(update_fields=["last_seen"]) + metrics.device_checkins_total.labels(**self.get_metrics_labels()).inc() def get_unavailable(self): if self.last_seen: @@ -92,7 +102,7 @@ def __str__(self): return self.name def log_access(self, member_id, success=True): - pass + metrics.device_access_successes_total.labels(**self.get_metrics_labels()).inc() def log_event(self, description=None, data=None): if self.type == "door": @@ -119,41 +129,49 @@ def log_connected(self): self.log_event( description=f"Device connected.", ) + metrics.device_connections_total.labels(**self.get_metrics_labels()).inc() def log_disconnected(self): self.log_event( description=f"Device disconnected.", ) + metrics.device_disconnections_total.labels(**self.get_metrics_labels()).inc() def log_authenticated(self): self.log_event( description=f"Device authenticated.", ) + metrics.device_authentications_total.labels(**self.get_metrics_labels()).inc() def log_force_rebooted(self): self.log_event( description=f"Device manually rebooted.", ) + metrics.device_force_reboots_total.labels(**self.get_metrics_labels()).inc() def log_force_sync(self): self.log_event( description=f"Device manually synced.", ) + metrics.device_syncs_total.labels(**self.get_metrics_labels()).inc() def log_force_bump(self): self.log_event( description=f"Device manually bumped.", ) + metrics.device_force_bumps_total.labels(**self.get_metrics_labels()).inc() def log_force_lock(self): self.log_event( description=f"Device manually locked.", ) + metrics.device_force_locks_total.labels(**self.get_metrics_labels()).inc() def log_force_unlock(self): self.log_event( description=f"Device manually unlocked.", ) + metrics.device_force_unlocks_total.labels(**self.get_metrics_labels()).inc() def sync(self, request=None): logger.info("Sending device sync to channels for {}".format(self.serial_number)) @@ -294,6 +312,7 @@ def bump(self, request=None): return True + metrics.device_force_bumps_total.labels(**self.get_metrics_labels()).inc() return False def log_access(self, member_id, success=True): @@ -306,10 +325,16 @@ def log_access(self, member_id, success=True): profile.save() if success == True: + metrics.device_access_successes_total.labels( + **self.get_metrics_labels() + ).inc() if self.post_to_discord: post_door_swipe_to_discord(profile.get_full_name(), self.name, success) - elif success == False: + elif not success: + metrics.device_access_failures_total.labels( + **self.get_metrics_labels() + ).inc() if self.post_to_discord: post_door_swipe_to_discord( profile.get_full_name(), self.name, "rejected" @@ -318,6 +343,9 @@ def log_access(self, member_id, success=True): sms_message.send_inactive_swipe_alert(profile.phone) elif success == "locked out": + metrics.device_access_failures_total.labels( + **self.get_metrics_labels() + ).inc() post_door_swipe_to_discord( profile.get_full_name(), self.name, "maintenance_lock_out" ) @@ -373,12 +401,21 @@ def log_access(self, user, type="activated"): ) if type == "activated": + metrics.device_interlock_session_activations_total.labels( + **self.get_metrics_labels() + ).inc() return True elif type == "left_on": + metrics.device_interlock_sessions_left_on_total.labels( + **self.get_metrics_labels() + ).inc() return True elif type == "deactivated": + metrics.device_interlock_sessions_deactivated_total.labels( + **self.get_metrics_labels() + ).inc() return True elif type == "rejected": @@ -392,6 +429,9 @@ def log_access(self, user, type="activated"): elif type == "not_signed_in": pass + metrics.device_interlock_sessions_rejected_total.labels( + **self.get_metrics_labels() + ).inc() self.session_rejected(user, reason=type) return True @@ -430,7 +470,7 @@ class InterlockLog(ExportModelOperationsMixin("interlock-log"), models.Model): total_cost = models.FloatField(default=None, blank=True, null=True) def __str__(self): - return f"{self.user_started.get_full_name()} ({self.user_started.profile.screen_name}) swiped at {self.interlock.name} {'successfully' if self.success else 'unsuccessfully'} for {round(self.total_time.total_seconds()/60)} mins at {self.date_started.date()}" + return f"{self.user_started.get_full_name()} ({self.user_started.profile.screen_name}) swiped at {self.interlock.name} {'successfully' if self.success else 'unsuccessfully'} for {round(self.total_time.total_seconds() / 60)} mins at {self.date_started.date()}" def calculate_cost(self): total_cost = self.interlock.cost_per_session @@ -463,6 +503,13 @@ def session_end(self, user=None, kwh=None, skip_cost=False): self.date_ended = timezone.now() self.save() + metrics.device_interlock_sessions_cost_cents.labels( + **self.interlock.get_metrics_labels() + ).inc(self.total_cost) + metrics.device_interlock_session_duration_seconds.labels( + **self.interlock.get_metrics_labels() + ).observe(self.total_time.total_seconds()) + if skip_cost or self.total_time.total_seconds() < 10: self.total_cost = 0 self.save() diff --git a/memberportal/api_access/metrics.py b/memberportal/api_access/metrics.py new file mode 100644 index 00000000..7c45d47e --- /dev/null +++ b/memberportal/api_access/metrics.py @@ -0,0 +1,25 @@ +from prometheus_client import Gauge + +devices_total = Gauge( + "devices_total", + "Number of devices", + ["type"], +) + +devices_online_total = Gauge( + "devices_online_total", + "Number of online devices", + ["type"], +) + +devices_offline_total = Gauge( + "devices_offline_total", + "Number of offline devices", + ["type"], +) + +devices_locked_out_total = Gauge( + "devices_locked_out_total", + "Number of locked out devices", + ["type"], +) diff --git a/memberportal/api_access/views.py b/memberportal/api_access/views.py index 11d8a816..988b5e58 100644 --- a/memberportal/api_access/views.py +++ b/memberportal/api_access/views.py @@ -1,10 +1,11 @@ -from django.utils import timezone from access.models import ( Doors, Interlock, + MemberbucksDevice, HasExternalAccessControlAPIKey, ) from profile.models import User +import metrics from rest_framework import status, permissions from rest_framework.response import Response @@ -27,9 +28,35 @@ def get(self, request): error_if_offline = request.GET.get("errorIfOffline", False) a_device_is_offline = False + total_count, offline_count, online_count, locked_out_count = 0, 0, 0, 0 + + def reset_count(): + nonlocal total_count, offline_count, online_count, locked_out_count + total_count, offline_count, online_count, locked_out_count = 0, 0, 0, 0 + + def update_count(device_offline=False, device_locked_out=False): + nonlocal total_count, offline_count, online_count, locked_out_count + total_count += 1 + if device_offline: + offline_count += 1 + else: + online_count += 1 + if device_locked_out: + locked_out_count += 1 + + def report_count(device_type: str): + nonlocal total_count, offline_count, online_count, locked_out_count + metrics.devices_total.labels(type=device_type).set(total_count) + metrics.devices_online_total.labels(type=device_type).set(online_count) + metrics.devices_offline_total.labels(type=device_type).set(offline_count) + metrics.devices_locked_out_total.labels(type=device_type).set( + locked_out_count + ) for door in Doors.objects.all(): offline = door.get_unavailable() + update_count(offline, door.locked_out) + statusObject["doors"].append( { "id": door.id, @@ -42,8 +69,14 @@ def get(self, request): if offline and door.report_online_status: a_device_is_offline = True + # report door metrics + report_count("door") + reset_count() + for interlock in Interlock.objects.all(): offline = interlock.get_unavailable() + update_count(offline, interlock.locked_out) + statusObject["interlocks"].append( { "id": interlock.id, @@ -56,6 +89,30 @@ def get(self, request): if offline and interlock.report_online_status: a_device_is_offline = True + # report interlock metrics + report_count("interlock") + reset_count() + + for memberbucksDevice in MemberbucksDevice.objects.all(): + offline = memberbucksDevice.get_unavailable() + update_count(offline, memberbucksDevice.locked_out) + + statusObject["memberbucksDevices"].append( + { + "id": memberbucksDevice.id, + "name": memberbucksDevice.name, + "lastSeen": memberbucksDevice.last_seen, + "lockedOut": memberbucksDevice.locked_out, + "offline": offline, + } + ) + if offline and memberbucksDevice.report_online_status: + a_device_is_offline = True + + # report spacebucksDevices metrics + report_count("spacebucksDevice") + reset_count() + if error_if_offline and a_device_is_offline: return Response(statusObject, status=status.HTTP_503_SERVICE_UNAVAILABLE) diff --git a/memberportal/requirements.txt b/memberportal/requirements.txt index f97c7c7d..498cae44 100644 --- a/memberportal/requirements.txt +++ b/memberportal/requirements.txt @@ -29,3 +29,4 @@ django-prometheus==2.3.1 psycopg2-binary~=2.9.6 django-oidc-provider~=0.8.0 djangorestframework-api-key==2.* +prometheus-client~=0.20.0 \ No newline at end of file