diff --git a/src/backend/core/analytics/__init__.py b/src/backend/core/analytics/__init__.py new file mode 100644 index 00000000..d5d4148c --- /dev/null +++ b/src/backend/core/analytics/__init__.py @@ -0,0 +1,59 @@ +""" +Pluggable analytics. + +Usage anywhere in the codebase: + + from core import analytics + + analytics.capture(request.user, "room_created", {"room_id": str(room.pk)}) + +The concrete backend is resolved lazily from Django settings, so swapping +PostHog for anything else is a configuration change, not a code change. +""" + +from functools import lru_cache +from typing import Any + +from django.conf import settings +from django.utils.module_loading import import_string + +from .base import AnalyticsBackend, NoOpAnalytics +from .events import AnalyticsEvent + +__all__ = [ + "get_analytics", + "identify", + "capture", + "AnalyticsBackend", + "AnalyticsEvent", +] + + +@lru_cache(maxsize=1) +def get_analytics() -> AnalyticsBackend: + """Instantiate the configured backend once per process.""" + dotted_path = getattr(settings, "ANALYTICS_BACKEND", None) + options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {} + + if not dotted_path: + return NoOpAnalytics() + + backend_class = import_string(dotted_path) + return backend_class(**options) + + +# Convenience module-level shortcuts + +analytics_instance = get_analytics() + + +def identify(user, properties: dict[str, Any] | None = None) -> None: + """Associate traits with an identified user.""" + analytics_instance.identify(user, properties) + + +def capture( + user, event: AnalyticsEvent, properties: dict[str, Any] | None = None +) -> None: + """Record an event performed by an identified user.""" + analytics_instance.capture(user, event, properties) diff --git a/src/backend/core/analytics/base.py b/src/backend/core/analytics/base.py new file mode 100644 index 00000000..3c2b6b69 --- /dev/null +++ b/src/backend/core/analytics/base.py @@ -0,0 +1,50 @@ +"""Analytics backend protocol and default no-op implementation.""" + +from typing import Any, Protocol + +from ..models import User +from .events import AnalyticsEvent + + +class AnalyticsBackend(Protocol): + """ + Interface every analytics backend must implement. + + Backends are instantiated once (singleton) with the kwargs declared in + settings.ANALYTICS_BACKEND_SETTINGS, e.g.: + + ANALYTICS_BACKEND = "core.analytics.posthog.PostHogAnalytics" + ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."} + """ + + def __init__(self, **kwargs: Any) -> None: ... + + def identify(self, user: User, properties: dict[str, Any] | None = None) -> None: + """Associate traits (email, name, ...) with an identified user.""" + + def capture( + self, + user: User, + event: AnalyticsEvent, + properties: dict[str, Any] | None = None, + ) -> None: + """Record an event performed by an identified user.""" + + def shutdown(self) -> None: + """Flush pending events. Called on process exit.""" + + +class NoOpAnalytics: + """Default backend: silently discards everything.""" + + def __init__(self, **kwargs: Any) -> None: + """No-op: accepts and ignores any backend settings kwargs.""" + + def identify(self, user: User, properties=None) -> None: + """No-op: discards identify calls.""" + + def capture(self, user, event, properties=None) -> None: + """No-op: discards captured events.""" + + def shutdown(self) -> None: + """No-op: nothing to flush.""" diff --git a/src/backend/core/analytics/events.py b/src/backend/core/analytics/events.py new file mode 100644 index 00000000..d8f3e7ec --- /dev/null +++ b/src/backend/core/analytics/events.py @@ -0,0 +1,10 @@ +"""Catalog of all analytics events emitted by the backend.""" + +from enum import StrEnum + + +class AnalyticsEvent(StrEnum): + """All trackable events. Values are the wire names sent to the provider.""" + + # Rooms + ROOM_CREATED = "room_created" diff --git a/src/backend/core/analytics/posthog.py b/src/backend/core/analytics/posthog.py new file mode 100644 index 00000000..1fef96b9 --- /dev/null +++ b/src/backend/core/analytics/posthog.py @@ -0,0 +1,74 @@ +"""PostHog implementation of the analytics backend protocol.""" + +import logging +from typing import Any + +from posthog import Posthog + +from ..models import User +from .events import AnalyticsEvent + +logger = logging.getLogger(__name__) + + +class PostHogAnalytics: + """Send events to PostHog, keyed on the user's primary key (UUID).""" + + def __init__( + self, + *, + api_key: str, + host: str = "https://eu.i.posthog.com", + **kwargs: Any, + ) -> None: + + # The SDK batches and sends in a background thread by default, + # so calls below never block the request/response cycle. + self._client = Posthog( + project_api_key=api_key, + host=host, + **kwargs, + ) + + @staticmethod + def _distinct_id(user: User) -> str | None: + """Return the PostHog distinct_id for a user, or None if anonymous.""" + if user is None or not getattr(user, "is_authenticated", False): + return None + return str(user.pk) + + def identify(self, user: User, properties: dict[str, Any] | None = None) -> None: + """Associate traits (email, name, ...) with an identified user.""" + distinct_id = self._distinct_id(user) + if distinct_id is None: + return + try: + self._client.set( + distinct_id=distinct_id, + properties=properties or {}, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("PostHog identify failed") + + def capture( + self, + user: User, + event: AnalyticsEvent, + properties: dict[str, Any] | None = None, + ) -> None: + """Record an event performed by an identified user.""" + distinct_id = self._distinct_id(user) + if distinct_id is None: + return + try: + self._client.capture( + distinct_id=distinct_id, + event=str(event), + properties=properties or {}, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("PostHog capture failed for event %s", event) + + def shutdown(self) -> None: + """Flush pending events. Called on process exit.""" + self._client.shutdown() diff --git a/src/backend/core/tests/analytics/__init__.py b/src/backend/core/tests/analytics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/tests/analytics/test_posthog.py b/src/backend/core/tests/analytics/test_posthog.py new file mode 100644 index 00000000..87bb19ef --- /dev/null +++ b/src/backend/core/tests/analytics/test_posthog.py @@ -0,0 +1,263 @@ +""" +Unit tests for PostHogAnalytics. +""" + +# pylint: disable=redefined-outer-name,unused-argument,protected-access + +from unittest.mock import patch + +from django.contrib.auth.models import AnonymousUser + +import pytest + +from core.analytics.events import AnalyticsEvent +from core.analytics.posthog import PostHogAnalytics +from core.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +# ============================== +# __init__ +# ============================== + + +@patch("core.analytics.posthog.Posthog") +def test_init_constructs_posthog_client_with_api_key_and_host(mock_posthog_cls): + """Should forward api_key and host to the Posthog SDK constructor.""" + PostHogAnalytics(api_key="my-key", host="https://custom.i.posthog.com") + + mock_posthog_cls.assert_called_once_with( + project_api_key="my-key", + host="https://custom.i.posthog.com", + ) + + +@patch("core.analytics.posthog.Posthog") +def test_init_defaults_to_eu_host(mock_posthog_cls): + """Should default host to the EU PostHog cloud when not specified.""" + PostHogAnalytics(api_key="my-key") + + _, kwargs = mock_posthog_cls.call_args + assert kwargs["host"] == "https://eu.i.posthog.com" + + +@patch("core.analytics.posthog.Posthog") +def test_init_forwards_extra_kwargs_to_client(mock_posthog_cls): + """Should pass through arbitrary extra kwargs (e.g. debug, disabled) to the SDK.""" + PostHogAnalytics(api_key="my-key", debug=True, disabled=False) + + _, kwargs = mock_posthog_cls.call_args + assert kwargs["debug"] is True + assert kwargs["disabled"] is False + + +# ============================== +# _distinct_id +# ============================== + + +@patch("core.analytics.posthog.Posthog") +def test_distinct_id_returns_none_for_none_user(mock_posthog_cls): + """Should return None when user is None.""" + backend = PostHogAnalytics(api_key="test-api-key") + + assert backend._distinct_id(None) is None + + +@patch("core.analytics.posthog.Posthog") +def test_distinct_id_returns_none_for_anonymous_user(mock_posthog_cls): + """Should return None when user.is_authenticated is falsy.""" + backend = PostHogAnalytics(api_key="test-api-key") + + assert backend._distinct_id(AnonymousUser()) is None + + +@patch("core.analytics.posthog.Posthog") +def test_distinct_id_returns_none_when_attribute_missing(mock_posthog_cls): + """Should return None when the user object has no is_authenticated attribute at all.""" + backend = PostHogAnalytics(api_key="test-api-key") + + assert backend._distinct_id(object()) is None + + +@patch("core.analytics.posthog.Posthog") +def test_distinct_id_returns_stringified_pk_for_authenticated_user(mock_posthog_cls): + """Should return str(user.pk) for an authenticated user.""" + backend = PostHogAnalytics(api_key="test-api-key") + user = UserFactory() + + assert backend._distinct_id(user) == str(user.pk) + + +# ============================== +# identify +# ============================== + + +@patch("core.analytics.posthog.Posthog") +def test_identify_noop_for_anonymous_user(mock_posthog_cls): + """Should not call the SDK when the user is anonymous.""" + backend = PostHogAnalytics(api_key="test-api-key") + + backend.identify(AnonymousUser(), {"email": "a@example.com"}) + + mock_posthog_cls.return_value.set.assert_not_called() + + +@patch("core.analytics.posthog.Posthog") +def test_identify_noop_for_none_user(mock_posthog_cls): + """Should not call the SDK when user is None.""" + backend = PostHogAnalytics(api_key="test-api-key") + + backend.identify(None, {"email": "a@example.com"}) + + mock_posthog_cls.return_value.set.assert_not_called() + + +@patch("core.analytics.posthog.Posthog") +def test_identify_sends_set_properties_for_authenticated_user(mock_posthog_cls): + """Should call capture with event=$identify and properties wrapped in $set.""" + backend = PostHogAnalytics(api_key="test-api-key") + user = UserFactory() + + backend.identify(user, {"email": "a@example.com", "name": "A"}) + + mock_posthog_cls.return_value.set.assert_called_once_with( + distinct_id=str(user.pk), + properties={"email": "a@example.com", "name": "A"}, + ) + + +@patch("core.analytics.posthog.Posthog") +def test_identify_defaults_properties_to_empty_dict(mock_posthog_cls): + """Should send an empty $set payload when properties is None.""" + backend = PostHogAnalytics(api_key="test-api-key") + user = UserFactory() + + backend.identify(user, None) + + mock_posthog_cls.return_value.set.assert_called_once_with( + distinct_id=str(user.pk), + properties={}, + ) + + +@patch("core.analytics.posthog.Posthog") +def test_identify_swallows_sdk_exceptions(mock_posthog_cls): + """Should log and not raise when the SDK call fails.""" + mock_posthog_cls.return_value.set.side_effect = RuntimeError("network down") + backend = PostHogAnalytics(api_key="test-api-key") + user = UserFactory() + + # Must not propagate. + backend.identify(user, {"email": "a@example.com"}) + + +# ============================== +# capture +# ============================== + + +@patch("core.analytics.posthog.Posthog") +def test_capture_noop_for_anonymous_user(mock_posthog_cls): + """Should not call the SDK when the user is anonymous.""" + backend = PostHogAnalytics(api_key="test-api-key") + + backend.capture(AnonymousUser(), AnalyticsEvent.ROOM_CREATED, {"room_id": "1"}) + + mock_posthog_cls.return_value.capture.assert_not_called() + + +@patch("core.analytics.posthog.Posthog") +def test_capture_noop_for_none_user(mock_posthog_cls): + """Should not call the SDK when user is None.""" + backend = PostHogAnalytics(api_key="test-api-key") + + backend.capture(None, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"}) + + mock_posthog_cls.return_value.capture.assert_not_called() + + +@patch("core.analytics.posthog.Posthog") +def test_capture_sends_event_and_properties_for_authenticated_user(mock_posthog_cls): + """Should call capture with the distinct_id, event name, and properties.""" + backend = PostHogAnalytics(api_key="test-api-key") + user = UserFactory() + + backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "room-1"}) + + mock_posthog_cls.return_value.capture.assert_called_once_with( + distinct_id=str(user.pk), + event="room_created", + properties={"room_id": "room-1"}, + ) + + +@patch("core.analytics.posthog.Posthog") +def test_capture_serializes_event_enum_to_plain_string(mock_posthog_cls): + """Should send the wire string, not the AnalyticsEvent enum member, to the SDK.""" + backend = PostHogAnalytics(api_key="test-api-key") + user = UserFactory() + + backend.capture(user, AnalyticsEvent.ROOM_CREATED) + + _, kwargs = mock_posthog_cls.return_value.capture.call_args + assert kwargs["event"] == "room_created" + assert isinstance( + kwargs["event"], str + ) # not AnalyticsEvent, not StrEnum subclass leaking through + + +@patch("core.analytics.posthog.Posthog") +def test_capture_defaults_properties_to_empty_dict(mock_posthog_cls): + """Should send an empty properties dict when properties is None.""" + backend = PostHogAnalytics(api_key="test-api-key") + user = UserFactory() + + backend.capture(user, AnalyticsEvent.ROOM_CREATED, None) + + mock_posthog_cls.return_value.capture.assert_called_once_with( + distinct_id=str(user.pk), + event="room_created", + properties={}, + ) + + +@patch("core.analytics.posthog.Posthog") +def test_capture_swallows_sdk_exceptions(mock_posthog_cls): + """Should log and not raise when the SDK call fails.""" + mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down") + backend = PostHogAnalytics(api_key="test-api-key") + user = UserFactory() + + # Must not propagate. + backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"}) + + +@patch("core.analytics.posthog.Posthog") +def test_capture_logs_the_failing_event_name_on_exception(mock_posthog_cls, caplog): + """Should log which event failed, to aid debugging without crashing the caller.""" + mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down") + backend = PostHogAnalytics(api_key="test-api-key") + user = UserFactory() + + with caplog.at_level("ERROR"): + backend.capture(user, AnalyticsEvent.ROOM_CREATED) + + assert any("PostHog capture failed" in record.message for record in caplog.records) + + +# ============================== +# shutdown +# ============================== + + +@patch("core.analytics.posthog.Posthog") +def test_shutdown_flushes_the_client(mock_posthog_cls): + """Should delegate to the SDK's shutdown to flush pending events.""" + backend = PostHogAnalytics(api_key="test-api-key") + + backend.shutdown() + + mock_posthog_cls.return_value.shutdown.assert_called_once() diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index f8988273..0de934da 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -761,6 +761,14 @@ class Base(Configuration): None, environ_name="RECORDING_DOWNLOAD_BASE_URL", environ_prefix=None ) + # Analytics + ANALYTICS_BACKEND = values.Value( + None, environ_name="ANALYTICS_BACKEND", environ_prefix=None + ) + ANALYTICS_BACKEND_SETTINGS = values.DictValue( + {}, environ_name="ANALYTICS_BACKEND_SETTINGS", environ_prefix=None + ) + # Marketing and communication settings SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue( False, # When enabled, new users are automatically added to mailing list.