From 9ba97fd14f59d48989dde85bb85d8b3f11861c69 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Wed, 1 Jul 2026 18:33:12 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20introduce=20a=20configurab?= =?UTF-8?q?le=20analytics=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an analytics abstraction that allows configuring which analytics solution the app uses. PostHog is implemented as one backend, but by default no analytics backend is activated. The goal is to track events in a sufficiently organized way and to let any developer implement their own backend as long as it follows the same protocol. --- src/backend/core/analytics/__init__.py | 59 ++++ src/backend/core/analytics/base.py | 50 ++++ src/backend/core/analytics/events.py | 10 + src/backend/core/analytics/posthog.py | 74 +++++ src/backend/core/tests/analytics/__init__.py | 0 .../core/tests/analytics/test_posthog.py | 263 ++++++++++++++++++ src/backend/meet/settings.py | 8 + 7 files changed, 464 insertions(+) create mode 100644 src/backend/core/analytics/__init__.py create mode 100644 src/backend/core/analytics/base.py create mode 100644 src/backend/core/analytics/events.py create mode 100644 src/backend/core/analytics/posthog.py create mode 100644 src/backend/core/tests/analytics/__init__.py create mode 100644 src/backend/core/tests/analytics/test_posthog.py 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.