From 271b598cee1101bb2179b3621487de967cdd6cab Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Sat, 3 Aug 2024 23:26:56 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=88(backend)=20introduce=20analytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this commit, we'll integrate a third-party service to track user events. We start by using the `identify` method to track sign-ins and sign-ups. Additionally, we use the `track` method to monitor custom events such as room creation, access token generation, and logouts. This will provide us with valuable data on current usage patterns. The analytics library operates by opening a queue in a separate thread for posting events, ensuring it remains non-blocking for the API. Let's test this in a real-world scenario. --- src/backend/core/analytics.py | 58 +++++++++ src/backend/core/api/viewsets.py | 16 +++ src/backend/core/authentication/backends.py | 3 + src/backend/core/authentication/views.py | 6 + src/backend/core/tests/test_analytics.py | 132 ++++++++++++++++++++ src/backend/meet/settings.py | 5 + 6 files changed, 220 insertions(+) create mode 100644 src/backend/core/analytics.py create mode 100644 src/backend/core/tests/test_analytics.py diff --git a/src/backend/core/analytics.py b/src/backend/core/analytics.py new file mode 100644 index 00000000..24fb1c4c --- /dev/null +++ b/src/backend/core/analytics.py @@ -0,0 +1,58 @@ +""" +Meet analytics class. +""" + +import uuid + +from django.conf import settings + +from june import analytics as jAnalytics + + +class Analytics: + """Analytics integration + + This class wraps the June analytics code to avoid coupling our code directly + with this third-party library. By doing so, we create a generic interface + for analytics that can be easily modified or replaced in the future. + """ + + def __init__(self): + key = getattr(settings, "ANALYTICS_KEY", None) + + if key is not None: + jAnalytics.write_key = key + + self._enabled = key is not None + + def _is_anonymous_user(self, user): + """Check if the user is anonymous.""" + return user is None or user.is_anonymous + + def identify(self, user, **kwargs): + """Identify a user""" + + if self._is_anonymous_user(user) or not self._enabled: + return + + traits = kwargs.pop("traits", {}) + traits.update({"email": user.email_anonymized}) + + jAnalytics.identify(user_id=user.sub, traits=traits, **kwargs) + + def track(self, user, **kwargs): + """Track an event""" + + if not self._enabled: + return + + event_data = {} + if self._is_anonymous_user(user): + event_data["anonymous_id"] = str(uuid.uuid4()) + else: + event_data["user_id"] = user.sub + + jAnalytics.track(**event_data, **kwargs) + + +analytics = Analytics() diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index af1cd818..fa662946 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -20,6 +20,7 @@ from rest_framework import ( from core import models, utils +from ..analytics import analytics from . import permissions, serializers # pylint: disable=too-many-ancestors @@ -185,6 +186,13 @@ class RoomViewSet( """ try: instance = self.get_object() + + analytics.track( + user=self.request.user, + event="Get Room", + properties={"slug": instance.slug}, + ) + except Http404: if not settings.ALLOW_UNREGISTERED_ROOMS: raise @@ -233,6 +241,14 @@ class RoomViewSet( role=models.RoleChoices.OWNER, ) + analytics.track( + user=self.request.user, + event="Create Room", + properties={ + "slug": room.slug, + }, + ) + class ResourceAccessListModelMixin: """List mixin for resource access API.""" diff --git a/src/backend/core/authentication/backends.py b/src/backend/core/authentication/backends.py index a4576916..3bb6fb04 100644 --- a/src/backend/core/authentication/backends.py +++ b/src/backend/core/authentication/backends.py @@ -10,6 +10,8 @@ from mozilla_django_oidc.auth import ( from core.models import User +from ..analytics import analytics + class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend): """Custom OpenID Connect (OIDC) Authentication Backend. @@ -79,6 +81,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend): else: user = None + analytics.identify(user=user) return user def create_user(self, claims): diff --git a/src/backend/core/authentication/views.py b/src/backend/core/authentication/views.py index 709f2097..f602cfcb 100644 --- a/src/backend/core/authentication/views.py +++ b/src/backend/core/authentication/views.py @@ -22,6 +22,8 @@ from mozilla_django_oidc.views import ( OIDCLogoutView as MozillaOIDCOIDCLogoutView, ) +from ..analytics import analytics + class OIDCLogoutView(MozillaOIDCOIDCLogoutView): """Custom logout view for handling OpenID Connect (OIDC) logout flow. @@ -98,6 +100,10 @@ class OIDCLogoutView(MozillaOIDCOIDCLogoutView): logout_url = self.redirect_url + analytics.track( + user=request.user, + event="Signed Out", + ) if request.user.is_authenticated: logout_url = self.construct_oidc_logout_url(request) diff --git a/src/backend/core/tests/test_analytics.py b/src/backend/core/tests/test_analytics.py new file mode 100644 index 00000000..fd91fa33 --- /dev/null +++ b/src/backend/core/tests/test_analytics.py @@ -0,0 +1,132 @@ +""" +Test for the Analytics class. +""" + +# pylint: disable=W0212 + +from unittest.mock import patch + +from django.contrib.auth.models import AnonymousUser +from django.test.utils import override_settings + +import pytest + +from core.analytics import Analytics +from core.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(name="mock_june_analytics") +def _mock_june_analytics(): + with patch("core.analytics.jAnalytics") as mock: + yield mock + + +@override_settings(ANALYTICS_KEY="test_key") +def test_analytics_init_enabled(mock_june_analytics): + """Should enable analytics and set the write key correctly when ANALYTICS_KEY is set.""" + analytics = Analytics() + assert analytics._enabled is True + assert mock_june_analytics.write_key == "test_key" + + +@override_settings(ANALYTICS_KEY=None) +def test_analytics_init_disabled(): + """Should disable analytics when ANALYTICS_KEY is not set.""" + analytics = Analytics() + assert analytics._enabled is False + + +@override_settings(ANALYTICS_KEY="test_key") +def test_analytics_identify_user(mock_june_analytics): + """Should identify a user with the correct traits when analytics is enabled.""" + user = UserFactory(sub="12345", email="user@example.com") + analytics = Analytics() + analytics.identify(user) + mock_june_analytics.identify.assert_called_once_with( + user_id="12345", traits={"email": "***@example.com"} + ) + + +@override_settings(ANALYTICS_KEY="test_key") +def test_analytics_identify_user_with_traits(mock_june_analytics): + """Should identify a user with additional traits when analytics is enabled.""" + user = UserFactory(sub="12345", email="user@example.com") + analytics = Analytics() + analytics.identify(user, traits={"email": "user@example.com", "foo": "foo"}) + mock_june_analytics.identify.assert_called_once_with( + user_id="12345", traits={"email": "***@example.com", "foo": "foo"} + ) + + +@override_settings(ANALYTICS_KEY=None) +def test_analytics_identify_not_enabled(mock_june_analytics): + """Should not call identify when analytics is not enabled.""" + user = UserFactory(sub="12345", email="user@example.com") + analytics = Analytics() + analytics.identify(user) + mock_june_analytics.identify.assert_not_called() + + +@override_settings(ANALYTICS_KEY="test_key") +def test_analytics_identify_no_user(mock_june_analytics): + """Should not call identify when the user is None.""" + analytics = Analytics() + analytics.identify(None) + mock_june_analytics.identify.assert_not_called() + + +@override_settings(ANALYTICS_KEY="test_key") +def test_analytics_identify_anonymous_user(mock_june_analytics): + """Should not call identify when the user is anonymous.""" + user = AnonymousUser() + analytics = Analytics() + analytics.identify(user) + mock_june_analytics.identify.assert_not_called() + + +@override_settings(ANALYTICS_KEY="test_key") +def test_analytics_track_event(mock_june_analytics): + """Should track an event with the correct user and event details when analytics is enabled.""" + user = UserFactory(sub="12345") + analytics = Analytics() + analytics.track(user, event="test_event", foo="foo") + mock_june_analytics.track.assert_called_once_with( + user_id="12345", event="test_event", foo="foo" + ) + + +@override_settings(ANALYTICS_KEY=None) +def test_analytics_track_event_not_enabled(mock_june_analytics): + """Should not call track when analytics is not enabled.""" + user = UserFactory(sub="12345") + analytics = Analytics() + analytics.track(user, event="test_event", foo="foo") + + mock_june_analytics.track.assert_not_called() + + +@override_settings(ANALYTICS_KEY="test_key") +@patch("uuid.uuid4", return_value="test_uuid4") +def test_analytics_track_event_no_user(mock_uuid4, mock_june_analytics): + """Should track an event with a random anonymous user ID when the user is None.""" + analytics = Analytics() + analytics.track(None, event="test_event", foo="foo") + mock_june_analytics.track.assert_called_once_with( + anonymous_id="test_uuid4", event="test_event", foo="foo" + ) + mock_uuid4.assert_called_once() + + +@override_settings(ANALYTICS_KEY="test_key") +@patch("uuid.uuid4", return_value="test_uuid4") +def test_analytics_track_event_anonymous_user(mock_uuid4, mock_june_analytics): + """Should track an event with a random anonymous user ID when the user is anonymous.""" + user = AnonymousUser() + analytics = Analytics() + analytics.track(user, event="test_event", foo="foo") + mock_june_analytics.track.assert_called_once_with( + anonymous_id="test_uuid4", event="test_event", foo="foo" + ) + mock_uuid4.assert_called_once() diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 033af803..3192f6d5 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -365,6 +365,9 @@ class Base(Configuration): ALLOW_UNREGISTERED_ROOMS = values.BooleanValue( True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None ) + ANALYTICS_KEY = values.Value( + None, environ_name="ANALYTICS_KEY", environ_prefix=None + ) # pylint: disable=invalid-name @property @@ -485,6 +488,8 @@ class Test(Base): CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True) + ANALYTICS_KEY = None + def __init__(self): # pylint: disable=invalid-name self.INSTALLED_APPS += ["drf_spectacular_sidecar"]