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"]