diff --git a/apps/api/plane/app/serializers/user.py b/apps/api/plane/app/serializers/user.py index 670667a853f..59101785734 100644 --- a/apps/api/plane/app/serializers/user.py +++ b/apps/api/plane/app/serializers/user.py @@ -78,6 +78,7 @@ class Meta: "is_password_autoset", "is_email_verified", "last_login_medium", + "last_login_time", ] read_only_fields = fields diff --git a/apps/api/plane/app/views/workspace/base.py b/apps/api/plane/app/views/workspace/base.py index c27b7adbb26..f96b002fd20 100644 --- a/apps/api/plane/app/views/workspace/base.py +++ b/apps/api/plane/app/views/workspace/base.py @@ -42,7 +42,9 @@ from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS from plane.license.utils.instance_value import get_configuration_value from plane.bgtasks.workspace_seed_task import workspace_seed +from plane.bgtasks.event_tracking_task import track_event from plane.utils.url import contains_url +from plane.utils.analytics_events import WORKSPACE_CREATED, WORKSPACE_DELETED class WorkSpaceViewSet(BaseViewSet): @@ -131,6 +133,20 @@ def create(self, request): workspace_seed.delay(serializer.data["id"]) + track_event.delay( + user_id=request.user.id, + event_name=WORKSPACE_CREATED, + slug=data["slug"], + event_properties={ + "user_id": request.user.id, + "workspace_id": data["id"], + "workspace_slug": data["slug"], + "role": "owner", + "workspace_name": data["name"], + "created_at": data["created_at"], + }, + ) + return Response(data, status=status.HTTP_201_CREATED) return Response( [serializer.errors[error][0] for error in serializer.errors], @@ -164,6 +180,19 @@ def destroy(self, request, *args, **kwargs): # Get the workspace workspace = self.get_object() self.remove_last_workspace_ids_from_user_settings(workspace.id) + track_event.delay( + user_id=request.user.id, + event_name=WORKSPACE_DELETED, + slug=workspace.slug, + event_properties={ + "user_id": request.user.id, + "workspace_id": workspace.id, + "workspace_slug": workspace.slug, + "role": "owner", + "workspace_name": workspace.name, + "deleted_at": str(timezone.now().isoformat()), + }, + ) return super().destroy(request, *args, **kwargs) diff --git a/apps/api/plane/app/views/workspace/invite.py b/apps/api/plane/app/views/workspace/invite.py index 48bcf7eba30..c237e0860f8 100644 --- a/apps/api/plane/app/views/workspace/invite.py +++ b/apps/api/plane/app/views/workspace/invite.py @@ -21,12 +21,13 @@ WorkSpaceMemberSerializer, ) from plane.app.views.base import BaseAPIView -from plane.bgtasks.event_tracking_task import workspace_invite_event +from plane.bgtasks.event_tracking_task import track_event from plane.bgtasks.workspace_invitation_task import workspace_invitation from plane.db.models import User, Workspace, WorkspaceMember, WorkspaceMemberInvite from plane.utils.cache import invalidate_cache, invalidate_cache_directly from plane.utils.host import base_host from plane.utils.ip_address import get_client_ip +from plane.utils.analytics_events import USER_JOINED_WORKSPACE, USER_INVITED_TO_WORKSPACE from .. import BaseViewSet @@ -121,6 +122,19 @@ def create(self, request, slug): current_site, request.user.email, ) + track_event.delay( + user_id=request.user.id, + event_name=USER_INVITED_TO_WORKSPACE, + slug=slug, + event_properties={ + "user_id": request.user.id, + "workspace_id": workspace.id, + "workspace_slug": workspace.slug, + "invitee_role": invitation.role, + "invited_at": str(timezone.now()), + "invitee_email": invitation.email, + }, + ) return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK) @@ -186,20 +200,22 @@ def post(self, request, slug, pk): # Set the user last_workspace_id to the accepted workspace user.last_workspace_id = workspace_invite.workspace.id user.save() + track_event.delay( + user_id=user.id, + event_name=USER_JOINED_WORKSPACE, + slug=slug, + event_properties={ + "user_id": user.id, + "workspace_id": workspace_invite.workspace.id, + "workspace_slug": workspace_invite.workspace.slug, + "role": workspace_invite.role, + "joined_at": str(timezone.now()), + }, + ) # Delete the invitation workspace_invite.delete() - # Send event - workspace_invite_event.delay( - user=user.id if user is not None else None, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=get_client_ip(request=request), - event_name="MEMBER_ACCEPTED", - accepted_from="EMAIL", - ) - return Response( {"message": "Workspace Invitation Accepted"}, status=status.HTTP_200_OK, @@ -252,6 +268,20 @@ def create(self, request): is_active=True, role=invitation.role ) + # Track event + track_event.delay( + user_id=request.user.id, + event_name=USER_JOINED_WORKSPACE, + slug=invitation.workspace.slug, + event_properties={ + "user_id": request.user.id, + "workspace_id": invitation.workspace.id, + "workspace_slug": invitation.workspace.slug, + "role": invitation.role, + "joined_at": str(timezone.now()), + }, + ) + # Bulk create the user for all the workspaces WorkspaceMember.objects.bulk_create( [ diff --git a/apps/api/plane/authentication/utils/workspace_project_join.py b/apps/api/plane/authentication/utils/workspace_project_join.py index bd5ad8501b2..31dae55eb70 100644 --- a/apps/api/plane/authentication/utils/workspace_project_join.py +++ b/apps/api/plane/authentication/utils/workspace_project_join.py @@ -1,3 +1,7 @@ +# Django imports +from django.utils import timezone + +# Module imports from plane.db.models import ( ProjectMember, ProjectMemberInvite, @@ -5,6 +9,8 @@ WorkspaceMemberInvite, ) from plane.utils.cache import invalidate_cache_directly +from plane.bgtasks.event_tracking_task import track_event +from plane.utils.analytics_events import USER_JOINED_WORKSPACE def process_workspace_project_invitations(user): @@ -25,15 +31,25 @@ def process_workspace_project_invitations(user): ignore_conflicts=True, ) - [ + for workspace_member_invite in workspace_member_invites: invalidate_cache_directly( path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/", url_params=False, user=False, multiple=True, ) - for workspace_member_invite in workspace_member_invites - ] + track_event.delay( + user_id=user.id, + event_name=USER_JOINED_WORKSPACE, + slug=workspace_member_invite.workspace.slug, + event_properties={ + "user_id": user.id, + "workspace_id": workspace_member_invite.workspace.id, + "workspace_slug": workspace_member_invite.workspace.slug, + "role": workspace_member_invite.role, + "joined_at": str(timezone.now().isoformat()), + }, + ) # Check if user has any project invites project_member_invites = ProjectMemberInvite.objects.filter(email=user.email, accepted=True) diff --git a/apps/api/plane/bgtasks/event_tracking_task.py b/apps/api/plane/bgtasks/event_tracking_task.py index 0629db93af2..82857fdfe19 100644 --- a/apps/api/plane/bgtasks/event_tracking_task.py +++ b/apps/api/plane/bgtasks/event_tracking_task.py @@ -1,5 +1,7 @@ +import logging import os import uuid +from typing import Dict, Any # third party imports from celery import shared_task @@ -8,6 +10,11 @@ # module imports from plane.license.utils.instance_value import get_configuration_value from plane.utils.exception_logger import log_exception +from plane.db.models import Workspace +from plane.utils.analytics_events import USER_INVITED_TO_WORKSPACE, WORKSPACE_DELETED + + +logger = logging.getLogger("plane.worker") def posthogConfiguration(): @@ -17,7 +24,10 @@ def posthogConfiguration(): "key": "POSTHOG_API_KEY", "default": os.environ.get("POSTHOG_API_KEY", None), }, - {"key": "POSTHOG_HOST", "default": os.environ.get("POSTHOG_HOST", None)}, + { + "key": "POSTHOG_HOST", + "default": os.environ.get("POSTHOG_HOST", None), + }, ] ) if POSTHOG_API_KEY and POSTHOG_HOST: @@ -26,46 +36,42 @@ def posthogConfiguration(): return None, None -@shared_task -def auth_events(user, email, user_agent, ip, event_name, medium, first_time): - try: - POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() +def preprocess_data_properties( + user_id: uuid.UUID, event_name: str, slug: str, data_properties: Dict[str, Any] +) -> Dict[str, Any]: + if event_name == USER_INVITED_TO_WORKSPACE or event_name == WORKSPACE_DELETED: + try: + # Check if the current user is the workspace owner + workspace = Workspace.objects.get(slug=slug) + if str(workspace.owner_id) == str(user_id): + data_properties["role"] = "owner" + else: + data_properties["role"] = "admin" + except Workspace.DoesNotExist: + logger.warning(f"Workspace {slug} does not exist while sending event {event_name} for user {user_id}") + data_properties["role"] = "unknown" - if POSTHOG_API_KEY and POSTHOG_HOST: - posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) - posthog.capture( - email, - event=event_name, - properties={ - "event_id": uuid.uuid4().hex, - "user": {"email": email, "id": str(user)}, - "device_ctx": {"ip": ip, "user_agent": user_agent}, - "medium": medium, - "first_time": first_time, - }, - ) - except Exception as e: - log_exception(e) - return + return data_properties @shared_task -def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from): - try: - POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() +def track_event(user_id: uuid.UUID, event_name: str, slug: str, event_properties: Dict[str, Any]): + POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() - if POSTHOG_API_KEY and POSTHOG_HOST: - posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) - posthog.capture( - email, - event=event_name, - properties={ - "event_id": uuid.uuid4().hex, - "user": {"email": email, "id": str(user)}, - "device_ctx": {"ip": ip, "user_agent": user_agent}, - "accepted_from": accepted_from, - }, - ) + if not (POSTHOG_API_KEY and POSTHOG_HOST): + logger.warning("Event tracking is not configured") + return + + try: + # preprocess the data properties for massaging the payload + # in the correct format for posthog + data_properties = preprocess_data_properties(user_id, event_name, slug, event_properties) + groups = { + "workspace": slug, + } + # track the event using posthog + posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) + posthog.capture(distinct_id=str(user_id), event=event_name, properties=data_properties, groups=groups) except Exception as e: log_exception(e) - return + return False diff --git a/apps/api/plane/utils/analytics_events.py b/apps/api/plane/utils/analytics_events.py new file mode 100644 index 00000000000..7fa8af94931 --- /dev/null +++ b/apps/api/plane/utils/analytics_events.py @@ -0,0 +1,4 @@ +USER_JOINED_WORKSPACE = "user_joined_workspace" +USER_INVITED_TO_WORKSPACE = "user_invited_to_workspace" +WORKSPACE_CREATED = "workspace_created" +WORKSPACE_DELETED = "workspace_deleted"