From 5e17160e9655cd784e38d4480cbb5f71f6580bbc Mon Sep 17 00:00:00 2001 From: Gautam Aggrawal Date: Wed, 21 Jan 2026 16:52:30 +0530 Subject: [PATCH 1/2] PP-605: Add extra Mixpanel settings and context --- README.md | 4 ++ pyproject.toml | 2 +- src/py_mixpanel/django_middleware.py | 14 ++++ src/py_mixpanel/django_views.py | 1 + src/py_mixpanel/tracking.py | 67 ++++++++++++++++++ tests/py_mixpanel/django_middleware.py | 59 ++++++++++++++++ tests/py_mixpanel/django_views.py | 15 +++- tests/py_mixpanel/tracking.py | 96 ++++++++++++++++++++++++++ 8 files changed, 255 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 599b453..651560d 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,10 @@ MIXPANEL_OPTIONS = { # Provide a static default payload for all events PAGE_VIEW_EVENT_PAYLOAD: dict, # default: {} + # Paths matching any pattern will not trigger automatic page view events + EXCLUDE_PATHS: list[str], # default: [] + # Example: [r"^/api/", r"^/health"] to exclude API and health check endpoints + } ``` diff --git a/pyproject.toml b/pyproject.toml index e0623fa..b2aa082 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "py-mixpanel" -version = "0.3.0" +version = "0.4.0" description = "Python wrapper for Mixpanel analytics" authors = [ {name = "Mathieu Lamiot", email = "mathieu@wp-media.me"}, diff --git a/src/py_mixpanel/django_middleware.py b/src/py_mixpanel/django_middleware.py index 87cb13e..1ab3565 100644 --- a/src/py_mixpanel/django_middleware.py +++ b/src/py_mixpanel/django_middleware.py @@ -1,6 +1,7 @@ from __future__ import annotations import typing as t +import re from .tracking import Tracking, ANONYMOUS_USER_ID @@ -26,6 +27,16 @@ def __init__(self, get_response) -> None: self._get_django_settings(), "MIXPANEL_OPTIONS", {} ) + def _should_exclude_path(self, path: str) -> bool: + """ + Check if the path should be excluded from tracking. + """ + exclusion_patterns = self.settings.get("EXCLUDE_PATHS", []) + if not exclusion_patterns: + return False + + return any(re.match(pattern, path) for pattern in exclusion_patterns) + def _get_django_settings(self) -> LazySettings: from django.conf import settings @@ -37,6 +48,7 @@ def get_payload(self, request: HttpRequest) -> dict[str, t.Any]: payload = { "origin": "django-middleware", "page": str(request.path), + "host": request.get_host(), } if self.settings.get("TRACK_HTMX", True): @@ -52,6 +64,8 @@ def get_payload(self, request: HttpRequest) -> dict[str, t.Any]: return payload def __call__(self, request: HttpRequest): + if self._should_exclude_path(str(request.path)): + return self.get_response(request) if ( bool(self.settings.get("ENABLE_TRACKING", True)) and self.settings.get("TOKEN", "") diff --git a/src/py_mixpanel/django_views.py b/src/py_mixpanel/django_views.py index a96f6aa..1ae0307 100644 --- a/src/py_mixpanel/django_views.py +++ b/src/py_mixpanel/django_views.py @@ -26,6 +26,7 @@ def mixpanel_get_payload( base = { "origin": "django-view-mixin", "page": request.path, + "host": request.get_host(), } base.update(payload) return base diff --git a/src/py_mixpanel/tracking.py b/src/py_mixpanel/tracking.py index 7b95871..c375fb0 100644 --- a/src/py_mixpanel/tracking.py +++ b/src/py_mixpanel/tracking.py @@ -74,3 +74,70 @@ def hash(self, user_id: str) -> str: return user_id return hashlib.sha224(user_id.encode("utf-8")).hexdigest() + + def track_order_placed( + self, + user_id: str, + order_id: str, + product_name: str | None = None, + additional_context: dict[str, t.Any] | None = None, + ) -> None: + """ + Track an Order Placed event. + """ + properties: dict[str, t.Any] = { + "order_id": order_id, + } + + if product_name: + properties["product_name"] = product_name + + if additional_context: + properties.update(additional_context) + + self.track(user_id, "Order Placed", properties) + + def track_sso_link_clicked( + self, + user_id: str, + sso_provider: str | None = None, + redirect_url: str | None = None, + additional_context: dict[str, t.Any] | None = None, + ) -> None: + """ + Track when a user clicks an SSO link. + """ + properties: dict[str, t.Any] = {} + + if sso_provider: + properties["sso_provider"] = sso_provider + + if redirect_url: + properties["redirect_url"] = redirect_url + + if additional_context: + properties.update(additional_context) + + self.track(user_id, "SSO Link Clicked", properties) + + def track_actionable_resolved( + self, + user_id: str, + actionable_id: str, + actionable_type: str | None = None, + additional_context: dict[str, t.Any] | None = None, + ) -> None: + """ + Track when an actionable is resolved. + """ + properties: dict[str, t.Any] = { + "actionable_id": actionable_id, + } + + if actionable_type: + properties["actionable_type"] = actionable_type + + if additional_context: + properties.update(additional_context) + + self.track(user_id, "Actionable Resolved", properties) diff --git a/tests/py_mixpanel/django_middleware.py b/tests/py_mixpanel/django_middleware.py index 61a855e..238a3b6 100644 --- a/tests/py_mixpanel/django_middleware.py +++ b/tests/py_mixpanel/django_middleware.py @@ -21,10 +21,12 @@ def test_initialization(django_settings_mock: Mock) -> None: def test_get_payload(request_mock: Mock) -> None: middleware = DjangoMixpanelMiddleware(Mock()) request_mock.path = "/hello-world" + request_mock.get_host = lambda: "example.com" payload = middleware.get_payload(request_mock) assert payload == { "origin": "django-middleware", "page": "/hello-world", + "host": "example.com", } @@ -34,10 +36,12 @@ def test_get_payload_with_setting_dict( django_settings_mock["PAGE_VIEW_EVENT_PAYLOAD"] = {"a": "1", "b": "2"} middleware = DjangoMixpanelMiddleware(Mock()) request_mock.path = "/hello-world" + request_mock.get_host = lambda: "example.com" payload = middleware.get_payload(request_mock) assert payload == { "origin": "django-middleware", "page": "/hello-world", + "host": "example.com", "a": "1", "b": "2", } @@ -80,6 +84,7 @@ def test_tracking( request_mock.user.is_authenticated = True request_mock.user.email = "mark@mark.com" + request_mock.get_host = lambda: "example.com" tracking_instance = Mock() tracker_mock.return_value = tracking_instance @@ -97,6 +102,7 @@ def test_tracking( { "origin": "django-middleware", "page": "/hello-world", + "host": "example.com", }, ) @@ -207,6 +213,7 @@ def test_htmx_tracking( middleware = DjangoMixpanelMiddleware(Mock()) request_mock.user.is_authenticated = True + request_mock.get_host = lambda: "example.com" request_mock.headers.update( { "HX-ABC": "abc", @@ -218,6 +225,7 @@ def test_htmx_tracking( assert middleware.get_payload(request_mock) == { "origin": "django-middleware", "page": "/hello-world", + "host": "example.com", "hx-abc": "abc", "hx-xyz": "xyz", } @@ -230,8 +238,59 @@ def test_middleware_renders_response() -> None: middleware = DjangoMixpanelMiddleware(get_response_mock) request_mock = Mock() request_mock.user.is_authenticated = False + request_mock.get_host = lambda: "example.com" response = middleware(request_mock) get_response_mock.assert_called_once_with(request_mock) assert response == SENTINEL + + +@pytest.mark.parametrize( + "exclude_paths,request_path,should_track", + [ + # Matches exclusion pattern - excluded + ([r"^/api/", r"^/health"], "/api/v1/users", False), + ([r"^/api/", r"^/health"], "/health", False), + ([r"^/api/", r"^/health"], "/api/users", False), + # Doesn't match exclusion pattern - tracked + ([r"^/api/", r"^/health"], "/dashboard", True), + ([r"^/api/", r"^/health"], "/users", True), + # Empty or missing exclusion list - all tracked + ([], "/api/v1/users", True), + ([], "/health", True), + (None, "/api/v1/users", True), + ], +) +def test_path_exclusion( + django_settings_mock: dict, + tracker_mock: Mock, + request_mock: Mock, + exclude_paths: list[str] | None, + request_path: str, + should_track: bool, +) -> None: + """Test path exclusion functionality with various patterns and paths.""" + if exclude_paths is not None: + django_settings_mock["EXCLUDE_PATHS"] = exclude_paths + + middleware = DjangoMixpanelMiddleware(Mock()) + + request_mock.user.is_authenticated = True + request_mock.user.email = "mark@domain.com" + request_mock.path = request_path + request_mock.get_host.return_value = "example.com" + + tracking_instance = Mock() + tracker_mock.return_value = tracking_instance + + get_response_mock = Mock() + middleware.get_response = get_response_mock + + middleware(request_mock) + + if should_track: + assert tracker_mock.call_count == 1 + else: + assert tracker_mock.call_count == 0 + get_response_mock.assert_called_once_with(request_mock) diff --git a/tests/py_mixpanel/django_views.py b/tests/py_mixpanel/django_views.py index 64ab427..81f6899 100644 --- a/tests/py_mixpanel/django_views.py +++ b/tests/py_mixpanel/django_views.py @@ -13,14 +13,25 @@ def tracker_mock() -> t.Generator[MagicMock, None, None]: def test_get_payload(request_mock: Mock) -> None: instance = DjangoMixpanelMixin() + request_mock.get_host = lambda: "example.com" result = instance.mixpanel_get_payload(request_mock, {"a": 1}) - assert result == {"a": 1, "origin": "django-view-mixin", "page": "/hello-world"} + assert result == { + "a": 1, + "origin": "django-view-mixin", + "page": "/hello-world", + "host": "example.com", + } def test_get_payload_empty(request_mock: Mock) -> None: instance = DjangoMixpanelMixin() + request_mock.get_host = lambda: "example.com" result = instance.mixpanel_get_payload(request_mock, {}) - assert result == {"origin": "django-view-mixin", "page": "/hello-world"} + assert result == { + "origin": "django-view-mixin", + "page": "/hello-world", + "host": "example.com", + } @pytest.mark.parametrize( diff --git a/tests/py_mixpanel/tracking.py b/tests/py_mixpanel/tracking.py index 868aee6..013d84a 100644 --- a/tests/py_mixpanel/tracking.py +++ b/tests/py_mixpanel/tracking.py @@ -154,3 +154,99 @@ def test_hashing_anonymous_user_id(mixpanel_mock: Mock) -> None: tracker = Tracking(ANONYMOUS_USER_ID, enable_tracking=True) assert tracker.hash(ANONYMOUS_USER_ID) == "anonymous" + + +@patch("mixpanel.Mixpanel") +def test_track_order_placed(mixpanel_mock: Mock) -> None: + mixpanel_instance = Mock() + mixpanel_mock.return_value = mixpanel_instance + + tracker = Tracking("abc", enable_tracking=True) + user_id = "user@example.com" + tracker.track_order_placed( + user_id, + "order-123", + product_name="Product A", + additional_context={"product_id": "123"}, + ) + + mixpanel_instance.track.assert_called_once_with( + tracker.hash(user_id), + "Order Placed", + {"order_id": "order-123", "product_name": "Product A", "product_id": "123"}, + ) + + +@patch("mixpanel.Mixpanel") +def test_track_sso_link_clicked(mixpanel_mock: Mock) -> None: + mixpanel_instance = Mock() + mixpanel_mock.return_value = mixpanel_instance + + tracker = Tracking("abc", enable_tracking=True) + user_id = "user@example.com" + tracker.track_sso_link_clicked( + user_id, + sso_provider="Google", + redirect_url="https://example.com", + additional_context={"product_id": "123"}, + ) + + mixpanel_instance.track.assert_called_once_with( + tracker.hash(user_id), + "SSO Link Clicked", + { + "sso_provider": "Google", + "redirect_url": "https://example.com", + "product_id": "123", + }, + ) + + +@patch("mixpanel.Mixpanel") +def test_track_actionable_resolved(mixpanel_mock: Mock) -> None: + mixpanel_instance = Mock() + mixpanel_mock.return_value = mixpanel_instance + + tracker = Tracking("abc", enable_tracking=True) + user_id = "user@example.com" + tracker.track_actionable_resolved( + user_id, + actionable_id="123", + actionable_type="Product", + additional_context={"product_id": "123"}, + ) + + mixpanel_instance.track.assert_called_once_with( + tracker.hash(user_id), + "Actionable Resolved", + {"actionable_id": "123", "actionable_type": "Product", "product_id": "123"}, + ) + + +@patch("mixpanel.Mixpanel") +def test_track_sso_link_clicked_empty_strings(mixpanel_mock: Mock) -> None: + """Test that empty string optional parameters are treated as falsy.""" + mixpanel_instance = Mock() + mixpanel_mock.return_value = mixpanel_instance + + tracker = Tracking("abc", enable_tracking=True) + user_id = "user@example.com" + tracker.track_sso_link_clicked(user_id, sso_provider="", redirect_url="") + + call_args = mixpanel_instance.track.call_args + assert "sso_provider" not in call_args[0][2] + assert "redirect_url" not in call_args[0][2] + + +@patch("mixpanel.Mixpanel") +def test_track_order_placed_empty_string_product_name(mixpanel_mock: Mock) -> None: + """Test empty string product_name is treated as falsy and not included.""" + mixpanel_instance = Mock() + mixpanel_mock.return_value = mixpanel_instance + + tracker = Tracking("abc", enable_tracking=True) + user_id = "user@example.com" + tracker.track_order_placed(user_id, "order-123", product_name="") + + call_args = mixpanel_instance.track.call_args + assert "product_name" not in call_args[0][2] From 8678fa45573209e7dad85784be705e31db5c3189 Mon Sep 17 00:00:00 2001 From: Gautam Aggrawal Date: Wed, 21 Jan 2026 22:29:13 +0530 Subject: [PATCH 2/2] PP-605: Added error handling for regex and logging --- src/py_mixpanel/django_middleware.py | 16 ++++++++++++++-- tests/py_mixpanel/django_middleware.py | 7 ++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/py_mixpanel/django_middleware.py b/src/py_mixpanel/django_middleware.py index 1ab3565..7644d73 100644 --- a/src/py_mixpanel/django_middleware.py +++ b/src/py_mixpanel/django_middleware.py @@ -2,7 +2,7 @@ import typing as t import re - +import logging from .tracking import Tracking, ANONYMOUS_USER_ID if t.TYPE_CHECKING: @@ -13,6 +13,8 @@ MIXPANEL_SESSION_KEY = "_py_mixpanel" DEFAULT_PAGE_VIEW_EVENT_NAME = "Page Viewed" +logger = logging.getLogger(__name__) + class DjangoMixpanelMiddleware: """ @@ -35,7 +37,17 @@ def _should_exclude_path(self, path: str) -> bool: if not exclusion_patterns: return False - return any(re.match(pattern, path) for pattern in exclusion_patterns) + for pattern in exclusion_patterns: + try: + if re.match(pattern, path): + return True + except re.error as e: + logger.warning( + f"Invalid regex pattern in EXCLUDE_PATHS: {pattern!r}. " + f"Error: {e}. Skipping." + ) + continue + return False def _get_django_settings(self) -> LazySettings: from django.conf import settings diff --git a/tests/py_mixpanel/django_middleware.py b/tests/py_mixpanel/django_middleware.py index 238a3b6..d1ef653 100644 --- a/tests/py_mixpanel/django_middleware.py +++ b/tests/py_mixpanel/django_middleware.py @@ -260,6 +260,8 @@ def test_middleware_renders_response() -> None: ([], "/api/v1/users", True), ([], "/health", True), (None, "/api/v1/users", True), + # Invalid pattern - should log warning and still track + ([r"[invalid"], "/api/users", True), ], ) def test_path_exclusion( @@ -286,8 +288,11 @@ def test_path_exclusion( get_response_mock = Mock() middleware.get_response = get_response_mock + with patch("py_mixpanel.django_middleware.logger") as logger_mock: + middleware(request_mock) - middleware(request_mock) + if exclude_paths and r"[invalid" in exclude_paths: + logger_mock.warning.assert_called_once() if should_track: assert tracker_mock.call_count == 1