Skip to content

Commit

Permalink
feat(Analytics): add an analytics module (#773)
Browse files Browse the repository at this point in the history
* feat(Analytics): add an analytics module

* chore(analytics): Use a middleware instead of signals when setting user properties, better error handling, removing useless condition

* fix tests

---------

Co-authored-by: Quentin Gérôme <[email protected]>
  • Loading branch information
cheikhgwane and qgerome authored Jul 24, 2024
1 parent 8298410 commit 7d9e098
Show file tree
Hide file tree
Showing 20 changed files with 140 additions and 34 deletions.
4 changes: 3 additions & 1 deletion config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"ariadne_django",
"dpq",
"hexa.user_management",
"hexa.analytics",
"hexa.core",
"hexa.catalog",
"hexa.countries",
Expand Down Expand Up @@ -92,6 +93,7 @@
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"hexa.user_management.middlewares.login_required_middleware",
"hexa.analytics.middlewares.set_analytics_middleware",
]

ROOT_URLCONF = "config.urls"
Expand Down Expand Up @@ -196,7 +198,7 @@
RAW_CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS")
if RAW_CORS_ALLOWED_ORIGINS is not None:
CORS_ALLOWED_ORIGINS = RAW_CORS_ALLOWED_ORIGINS.split(",")
CORS_URLS_REGEX = r"^/graphql/(\w+\/)?$"
CORS_URLS_REGEX = r"^[/graphql/(\w+\/)?|/analytics/track]$"
CORS_ALLOW_CREDENTIALS = True


Expand Down
1 change: 1 addition & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
path("notebooks/", include("hexa.notebooks.urls", namespace="notebooks")),
path("pipelines/", include("hexa.pipelines.urls", namespace="pipelines")),
path("workspaces/", include("hexa.workspaces.urls", namespace="workspaces")),
path("analytics/", include("hexa.analytics.urls", namespace="analytics")),
# Order matters, we override the default logout view defined later
# We do this to logout the user from jupyterhub at the end of the openhexa
# session. the jupyterhub will redirect to the openhexa login after it
Expand Down
Empty file added hexa/analytics/__init__.py
Empty file.
10 changes: 5 additions & 5 deletions hexa/core/analytics.py → hexa/analytics/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@


def track(
request: HttpRequest,
request: HttpRequest | None,
event: str,
properties: dict = {},
user: User = None,
Expand All @@ -27,20 +27,20 @@ def track(
An identifier for the event to track.
properties : dict
A dictionary holding the event properties
user: User |
user: User
User entity to track
"""
if mixpanel is None:
return

people = user if user else getattr(request, "user", None)
can_track = (
people is None or isinstance(user, AnonymousUser) or people.analytics_enabled
people is None or isinstance(people, AnonymousUser) or people.analytics_enabled
)
if can_track is False:
return

if request:
if request and "User-Agent" in request.headers:
# Add request related properties
parsed = user_agent_parser.Parse(request.headers["User-Agent"])
properties.update(
Expand All @@ -66,7 +66,7 @@ def set_user_properties(user: User):
return

try:
mixpanel.people_set_once(
mixpanel.people_set(
distinct_id=str(user.id),
properties={
"$email": user.email,
Expand Down
8 changes: 8 additions & 0 deletions hexa/analytics/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from hexa.app import CoreAppConfig


class AnalyticsConfig(CoreAppConfig):
name = "hexa.analytics"
label = "analytics"

ANONYMOUS_URLS = ["analytics:track"]
19 changes: 19 additions & 0 deletions hexa/analytics/middlewares.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import Callable

from django.http import HttpRequest, HttpResponse

from hexa.analytics.api import set_user_properties


def set_analytics_middleware(
get_response: Callable[[HttpRequest], HttpResponse],
) -> Callable[[HttpRequest], HttpResponse]:
"""Send the user properties to the analytics service."""

def middleware(request: HttpRequest) -> HttpResponse:
response = get_response(request)
if getattr(request, "user") and request.user.is_authenticated:
set_user_properties(request.user)
return response

return middleware
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from django.test import RequestFactory

from hexa.core.analytics import set_user_properties, track
from hexa.analytics.api import set_user_properties, track
from hexa.core.test import TestCase
from hexa.files.tests.mocks.mockgcp import mock_gcp_storage
from hexa.pipelines.models import Pipeline, PipelineRunTrigger
Expand Down Expand Up @@ -36,7 +36,7 @@ def setUpTestData(cls):
)
cls.factory = RequestFactory()

@mock.patch("hexa.core.analytics.Mixpanel")
@mock.patch("hexa.analytics.api.Mixpanel")
def test_track_event_user_no_token(self, mock_mixpanel):
with self.settings(
MIXPANEL_TOKEN=None,
Expand All @@ -54,7 +54,7 @@ def test_track_event_user_no_token(self, mock_mixpanel):
track(request, event, properties, user=self.USER)
mock_mixpanel.assert_not_called()

@mock.patch("hexa.core.analytics.Mixpanel")
@mock.patch("hexa.analytics.api.Mixpanel")
def test_track_user_analytics_not_enabled(
self,
mock_mixpanel,
Expand Down Expand Up @@ -85,7 +85,7 @@ def test_track_user_analytics_not_enabled(
track(request, event, properties, user=self.USER)
mock_mixpanel_instance.assert_not_called()

@mock.patch("hexa.core.analytics.mixpanel")
@mock.patch("hexa.analytics.api.mixpanel")
def test_track_user_analytics_enabled(
self,
mock_mixpanel,
Expand Down Expand Up @@ -113,7 +113,7 @@ def test_track_user_analytics_enabled(
},
)

@mock.patch("hexa.core.analytics.mixpanel")
@mock.patch("hexa.analytics.api.mixpanel")
def test_track_pipelinerun_no_user(
self,
mock_mixpanel,
Expand Down Expand Up @@ -141,7 +141,7 @@ def test_track_pipelinerun_no_user(
distinct_id=None, event_name=event, properties=properties
)

@mock.patch("hexa.core.analytics.mixpanel")
@mock.patch("hexa.analytics.api.mixpanel")
def test_create_user_profile(
self,
mock_mixpanel,
Expand All @@ -151,7 +151,7 @@ def test_create_user_profile(
with self.settings(MIXPANEL_TOKEN=mixpanel_token):
set_user_properties(self.USER)

mock_mixpanel.people_set_once.assert_called_once_with(
mock_mixpanel.people_set.assert_called_once_with(
distinct_id=str(self.USER.id),
properties={
"$email": self.USER.email,
Expand Down
54 changes: 54 additions & 0 deletions hexa/analytics/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from unittest import mock

from django.urls import reverse

from hexa.core.test import TestCase
from hexa.user_management.models import User


class AnalyticsTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.USER = User.objects.create_user(
"[email protected]",
"user_password",
)

def test_track_event_bad_request(self):
self.client.force_login(self.USER)
r = self.client.post(
reverse(
"analytics:track",
),
{"properties": {}},
content_type="application/json",
)
self.assertEqual(r.status_code, 400)
self.assertEqual(r.json(), {"Bad request": "event name is required."})

def test_track_event(self):
self.client.force_login(self.USER)
r = self.client.post(
reverse(
"analytics:track",
),
{"event": "page_viewed", "properties": {"page": "database"}},
content_type="application/json",
)
self.assertEqual(r.status_code, 200)

@mock.patch("hexa.analytics.api.mixpanel")
def test_track_event_analytics_not_enabled(self, mixpanel_mock):
self.USER.analytics_enabled = False
self.USER.save()
self.client.force_login(self.USER)
with self.settings(MIXPANEL_TOKEN="123"):
r = self.client.post(
reverse(
"analytics:track",
),
{"event": "page_viewed", "properties": {"page": "database"}},
content_type="application/json",
)
self.assertEqual(r.status_code, 200)
mixpanel_mock.track.assert_not_called()
9 changes: 9 additions & 0 deletions hexa/analytics/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.urls import path

from . import views

app_name = "analytics"

urlpatterns = [
path("track", views.track_event, name="track"),
]
27 changes: 27 additions & 0 deletions hexa/analytics/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import json

from django.http import HttpRequest, HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from .api import track


@csrf_exempt
@require_POST
def track_event(request: HttpRequest) -> HttpResponse:
"""This API endpoint is called by the frontend to track events."""
try:
payload = json.loads(request.body.decode("utf-8"))
except json.JSONDecodeError:
return JsonResponse({"Bad request": "Invalid JSON payload."}, status=400)

if "event" not in payload:
return JsonResponse({"Bad request": "event name is required."}, status=400)

track(
request,
payload.get("event"),
payload.get("properties", {}),
)
return JsonResponse({}, status=200)
2 changes: 1 addition & 1 deletion hexa/datasets/schema/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import IntegrityError, transaction

from hexa.core.analytics import track
from hexa.analytics.api import track
from hexa.pipelines.authentication import PipelineRunUser
from hexa.workspaces.models import Workspace

Expand Down
2 changes: 1 addition & 1 deletion hexa/files/schema/mutations.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from ariadne import MutationType

from hexa.core.analytics import track
from hexa.analytics.api import track
from hexa.files.api import get_storage
from hexa.workspaces.models import Workspace

Expand Down
2 changes: 1 addition & 1 deletion hexa/notebooks/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.conf import settings
from django.http import HttpRequest

from hexa.core.analytics import track
from hexa.analytics.api import track
from hexa.workspaces.models import Workspace

from .api import create_server, create_user, get_user, server_ready
Expand Down
2 changes: 1 addition & 1 deletion hexa/pipelines/management/commands/pipelines_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from django.core.management.base import BaseCommand
from django.utils import timezone

from hexa.core.analytics import track
from hexa.analytics.api import track
from hexa.pipelines.models import Pipeline, PipelineRunTrigger

logger = getLogger(__name__)
Expand Down
2 changes: 1 addition & 1 deletion hexa/pipelines/schema/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from django.db import IntegrityError
from django.http import HttpRequest

from hexa.core.analytics import track
from hexa.analytics.api import track
from hexa.databases.utils import get_table_definition
from hexa.files.api import NotFound, get_storage
from hexa.pipelines.authentication import PipelineRunUser
Expand Down
2 changes: 1 addition & 1 deletion hexa/pipelines/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from hexa.analytics.api import track
from hexa.app import get_hexa_app_configs
from hexa.core.analytics import track
from hexa.pipelines.models import Environment

from .credentials import PipelinesCredentials
Expand Down
3 changes: 0 additions & 3 deletions hexa/user_management/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,3 @@ class UserManagementConfig(CoreAppConfig):
"password_reset_done",
"password_reset_complete",
]

def ready(self):
from . import signals # noqa
2 changes: 1 addition & 1 deletion hexa/user_management/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from django_otp.plugins.otp_email.models import EmailDevice
from graphql import default_field_resolver

from hexa.core.analytics import track
from hexa.analytics.api import track
from hexa.core.graphql import result_page
from hexa.core.templatetags.colors import hash_color
from hexa.user_management.models import (
Expand Down
11 changes: 0 additions & 11 deletions hexa/user_management/signals.py

This file was deleted.

0 comments on commit 7d9e098

Please sign in to comment.