From 5eb9c4edd6d2965df0a401ee9f8265534e32ae48 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Thu, 28 May 2026 11:46:43 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=88(backend)=20create=20a=20utils=20to?= =?UTF-8?q?=20capture=20event=20with=20posthog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to capture event with posthog. We created a utils for this reposible to send the event or not if posthog is configured. --- CHANGELOG.md | 1 + .../core/tests/test_utils_analytics.py | 137 ++++++++++++++++++ src/backend/core/utils/analytics.py | 47 ++++++ 3 files changed, 185 insertions(+) create mode 100644 src/backend/core/tests/test_utils_analytics.py create mode 100644 src/backend/core/utils/analytics.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ffe306e0..82b36b03b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to - 🔧(backend) allow configuring settings OIDC_OP_USER_ENDPOINT_FORMAT - ⚡️(helm) create a dedicated svc and deployment for yprovider converter #2368 - ✨(backend) allow to leave a document #2365 +- 📈(backend) create a utils to capture event with posthog ### Changed diff --git a/src/backend/core/tests/test_utils_analytics.py b/src/backend/core/tests/test_utils_analytics.py new file mode 100644 index 000000000..da9ef4ae3 --- /dev/null +++ b/src/backend/core/tests/test_utils_analytics.py @@ -0,0 +1,137 @@ +"""Test analytics utilities.""" + +import json +from unittest import mock + +from django.contrib.auth.models import AnonymousUser + +import pytest + +from core import factories +from core.utils.analytics import PosthogEventName, posthog_capture + +pytestmark = pytest.mark.django_db + + +def test_posthog_capture_no_posthog_key(): + """When POSTHOG_KEY is not set, posthog.capture should not be called.""" + with mock.patch("core.utils.analytics.posthog.capture") as mock_capture: + posthog_capture(PosthogEventName.DOC_CREATED, None) + mock_capture.assert_not_called() + + +def test_posthog_capture_with_user_and_document(settings): + """posthog.capture should be called with correct args when user and document are provided.""" + settings.POSTHOG_KEY = "test-key" + user = factories.UserFactory() + document = factories.DocumentFactory() + properties = {"custom": "value"} + + with mock.patch("core.utils.analytics.posthog.capture") as mock_capture: + posthog_capture( + PosthogEventName.DOC_CREATED, + user, + properties, + document=document, + ) + mock_capture.assert_called_once_with( + PosthogEventName.DOC_CREATED, + distinct_id=user.email, + properties={ + "custom": "value", + "document_id": str(document.id), + "document_title": document.title, + "document_depth": document.depth, + "document_path": document.path, + }, + ) + + +def test_posthog_capture_with_user_no_document(settings): + """posthog.capture should be called with correct args when no document is provided.""" + settings.POSTHOG_KEY = "test-key" + user = factories.UserFactory() + properties = {"foo": "bar"} + + with mock.patch("core.utils.analytics.posthog.capture") as mock_capture: + posthog_capture(PosthogEventName.DOC_DELETED, user, properties) + mock_capture.assert_called_once_with( + PosthogEventName.DOC_DELETED, + distinct_id=user.email, + properties={"foo": "bar"}, + ) + + +def test_posthog_capture_no_user(settings): + """When user is None, distinct_id should be None.""" + settings.POSTHOG_KEY = "test-key" + with mock.patch("core.utils.analytics.posthog.capture") as mock_capture: + posthog_capture(PosthogEventName.DOC_CREATED, None) + mock_capture.assert_called_once_with( + PosthogEventName.DOC_CREATED, + distinct_id=None, + properties={}, + ) + + +def test_posthog_capture_anonymous_user(settings): + """An anonymous user (no email attribute) should resolve to a None distinct_id.""" + settings.POSTHOG_KEY = "test-key" + + with mock.patch("core.utils.analytics.posthog.capture") as mock_capture: + posthog_capture(PosthogEventName.DOC_AI_ACTION, AnonymousUser()) + mock_capture.assert_called_once_with( + PosthogEventName.DOC_AI_ACTION, + distinct_id=None, + properties={}, + ) + + +def test_posthog_capture_properties_are_json_serializable(settings): + """The document properties must be JSON-serializable (document_id is a str, not a UUID).""" + settings.POSTHOG_KEY = "test-key" + user = factories.UserFactory() + document = factories.DocumentFactory() + + with mock.patch("core.utils.analytics.posthog.capture") as mock_capture: + posthog_capture(PosthogEventName.DOC_CREATED, user, document=document) + + called_properties = mock_capture.call_args[1]["properties"] + assert isinstance(called_properties["document_id"], str) + # Mirrors PostHog's flush: properties must serialize without raising. + json.dumps(called_properties) + + +def test_posthog_capture_no_properties(settings): + """When properties is None, it should default to an empty dict.""" + settings.POSTHOG_KEY = "test-key" + user = factories.UserFactory() + + with mock.patch("core.utils.analytics.posthog.capture") as mock_capture: + posthog_capture(PosthogEventName.DOC_DELETED, user, None) + mock_capture.assert_called_once_with( + PosthogEventName.DOC_DELETED, + distinct_id=user.email, + properties={}, + ) + + +def test_posthog_capture_properties_not_mutated(settings): + """The original properties dict should not be mutated.""" + settings.POSTHOG_KEY = "test-key" + user = factories.UserFactory() + document = factories.DocumentFactory() + original_properties = {"key": "value"} + + with mock.patch("core.utils.analytics.posthog.capture") as mock_capture: + posthog_capture( + PosthogEventName.DOC_CREATED, + user, + original_properties, + document=document, + ) + assert original_properties == {"key": "value"} + # Verify the merged properties were passed to posthog + called_properties = mock_capture.call_args[1]["properties"] + assert "document_id" in called_properties + assert "document_id" not in original_properties diff --git a/src/backend/core/utils/analytics.py b/src/backend/core/utils/analytics.py new file mode 100644 index 000000000..46bb675c6 --- /dev/null +++ b/src/backend/core/utils/analytics.py @@ -0,0 +1,47 @@ +""" +PostHog utilities +""" + +from enum import StrEnum + +from django.conf import settings + +import posthog + +from core.models import User + + +class PosthogEventName(StrEnum): + """Posthog event name enum""" + + DOC_CREATED = "doc_created" + DOC_DELETED = "doc_deleted" + + +def posthog_capture( + event_name: PosthogEventName, + user: User | None, + properties: dict | None = None, + **kwargs, +): + """Capture an event with PostHog.""" + if settings.POSTHOG_KEY: + if properties is None: + properties = {} + + properties = properties.copy() + document = kwargs.get("document") + if document: + properties.update( + { + "document_id": str(document.id), + "document_title": document.title, + "document_depth": document.depth, + "document_path": document.path, + } + ) + posthog.capture( + event_name, + distinct_id=getattr(user, "email", None), + properties=properties, + )