Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

}
```

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
28 changes: 27 additions & 1 deletion src/py_mixpanel/django_middleware.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from __future__ import annotations

import typing as t

import re
import logging
from .tracking import Tracking, ANONYMOUS_USER_ID

if t.TYPE_CHECKING:
Expand All @@ -12,6 +13,8 @@
MIXPANEL_SESSION_KEY = "_py_mixpanel"
DEFAULT_PAGE_VIEW_EVENT_NAME = "Page Viewed"

logger = logging.getLogger(__name__)


class DjangoMixpanelMiddleware:
"""
Expand All @@ -26,6 +29,26 @@ 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

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

Expand All @@ -37,6 +60,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):
Expand All @@ -52,6 +76,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", "")
Expand Down
1 change: 1 addition & 0 deletions src/py_mixpanel/django_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions src/py_mixpanel/tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
64 changes: 64 additions & 0 deletions tests/py_mixpanel/django_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}


Expand All @@ -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",
}
Expand Down Expand Up @@ -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
Expand All @@ -97,6 +102,7 @@ def test_tracking(
{
"origin": "django-middleware",
"page": "/hello-world",
"host": "example.com",
},
)

Expand Down Expand Up @@ -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",
Expand All @@ -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",
}
Expand All @@ -230,8 +238,64 @@ 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),
# Invalid pattern - should log warning and still track
([r"[invalid"], "/api/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
with patch("py_mixpanel.django_middleware.logger") as logger_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
else:
assert tracker_mock.call_count == 0
get_response_mock.assert_called_once_with(request_mock)
15 changes: 13 additions & 2 deletions tests/py_mixpanel/django_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading