diff --git a/forum/backend.py b/forum/backend.py index 194f5113..0d9ead35 100644 --- a/forum/backend.py +++ b/forum/backend.py @@ -2,6 +2,8 @@ from typing import Callable, Optional +from edx_django_utils.monitoring import set_custom_attribute + from forum.backends.mongodb.api import MongoBackend from forum.backends.mysql.api import MySQLBackend @@ -37,7 +39,15 @@ def get_backend( """Return a factory function that lazily loads the backend API based on course_id.""" def _get_backend() -> MongoBackend | MySQLBackend: - if is_mysql_backend_enabled(course_id): + backend_enabled = is_mysql_backend_enabled(course_id) + + # Track which backend is being used + backend_type = "mysql" if backend_enabled else "mongodb" + set_custom_attribute('forum.backend', backend_type) + if course_id: + set_custom_attribute('forum.backend.course_id', str(course_id)) + + if backend_enabled: return MySQLBackend() return MongoBackend() diff --git a/forum/backends/mongodb/comments.py b/forum/backends/mongodb/comments.py index 527fc0b0..62eb3439 100644 --- a/forum/backends/mongodb/comments.py +++ b/forum/backends/mongodb/comments.py @@ -4,6 +4,7 @@ from typing import Any, Optional from bson import ObjectId +from edx_django_utils.monitoring import set_custom_attribute from forum.backends.mongodb.contents import BaseContents from forum.backends.mongodb.threads import CommentThread @@ -102,6 +103,18 @@ def insert( Returns: str: The ID of the inserted document. """ + # Track comment insertion + set_custom_attribute('forum.backend.operation', 'insert_comment') + set_custom_attribute('forum.thread_id', comment_thread_id) + set_custom_attribute('forum.course_id', course_id) + set_custom_attribute('forum.author_id', author_id) + set_custom_attribute('forum.comment_depth', str(depth)) + if parent_id: + set_custom_attribute('forum.parent_comment_id', parent_id) + set_custom_attribute('forum.is_child_comment', True) + else: + set_custom_attribute('forum.is_child_comment', False) + date = datetime.now() comment_data = { "votes": self.get_votes_dict(up=[], down=[]), @@ -275,6 +288,13 @@ def delete( # type: ignore[override] Returns: The number of comments deleted. """ + # Track comment deletion + set_custom_attribute('forum.backend.operation', 'delete_comment') + set_custom_attribute('forum.comment_id', _id) + set_custom_attribute('forum.delete_mode', mode) + if deleted_by: + set_custom_attribute('forum.deleted_by', deleted_by) + comment = self.get(_id) if not comment: return 0, 0 diff --git a/forum/backends/mongodb/threads.py b/forum/backends/mongodb/threads.py index 9b5da85f..9b50504d 100644 --- a/forum/backends/mongodb/threads.py +++ b/forum/backends/mongodb/threads.py @@ -4,6 +4,7 @@ from typing import Any, Optional from bson import ObjectId +from edx_django_utils.monitoring import set_custom_attribute from forum.backends.mongodb.contents import BaseContents from forum.backends.mongodb.users import Users @@ -20,6 +21,9 @@ class CommentThread(BaseContents): def delete(self, _id: str) -> int: """Delete CommentThread""" + set_custom_attribute('forum.backend.operation', 'delete_thread') + set_custom_attribute('forum.thread_id', _id) + result = super().delete(_id) get_handler_by_name("comment_thread_deleted").send( sender=self.__class__, comment_thread_id=_id @@ -142,6 +146,15 @@ def insert( if historical_abuse_flaggers is None: historical_abuse_flaggers = [] + # Track thread insertion + set_custom_attribute('forum.backend.operation', 'insert_thread') + set_custom_attribute('forum.thread_type', thread_type) + set_custom_attribute('forum.course_id', course_id) + set_custom_attribute('forum.commentable_id', commentable_id) + set_custom_attribute('forum.author_id', author_id) + if group_id: + set_custom_attribute('forum.group_id', str(group_id)) + date = datetime.now() thread_data = { "votes": self.get_votes_dict(up=[], down=[]), diff --git a/forum/backends/mysql/api.py b/forum/backends/mysql/api.py index 087df1a4..b0be6db3 100644 --- a/forum/backends/mysql/api.py +++ b/forum/backends/mysql/api.py @@ -24,6 +24,7 @@ When, ) from django.utils import timezone +from edx_django_utils.monitoring import set_custom_attribute from rest_framework import status from rest_framework.response import Response @@ -1624,6 +1625,10 @@ def create_comment(cls, data: dict[str, Any]) -> str: @classmethod def delete_comment(cls, comment_id: str) -> None: """Delete comment from comment_id.""" + set_custom_attribute('forum.backend.operation', 'delete_comment') + set_custom_attribute('forum.comment_id', comment_id) + set_custom_attribute('forum.delete_mode', 'hard') + comment = Comment.objects.get(pk=comment_id) if comment.parent: cls.update_child_count_in_parent_comment(str(comment.parent.pk), -1) @@ -1639,6 +1644,12 @@ def soft_delete_comment( Returns: tuple: (responses_deleted, replies_deleted) """ + set_custom_attribute('forum.backend.operation', 'delete_comment') + set_custom_attribute('forum.comment_id', comment_id) + set_custom_attribute('forum.delete_mode', 'soft') + if deleted_by: + set_custom_attribute('forum.deleted_by', deleted_by) + comment = Comment.objects.get(pk=comment_id) deleted_user: Optional[User] = None if deleted_by: @@ -1982,6 +1993,17 @@ def get_commentables_counts_based_on_type(course_id: str) -> dict[str, Any]: @staticmethod def update_comment(comment_id: str, **kwargs: Any) -> int: """Updates a comment in the database.""" + # Track comment update + set_custom_attribute('forum.backend.operation', 'update_comment') + set_custom_attribute('forum.comment_id', comment_id) + + # Track what's being updated + update_fields = [k for k in kwargs.keys() if kwargs.get(k) is not None] + if update_fields: + set_custom_attribute('forum.update_fields', ','.join(update_fields)) + if 'course_id' in kwargs: + set_custom_attribute('forum.course_id', kwargs['course_id']) + try: comment = Comment.objects.get(id=comment_id) except Comment.DoesNotExist: @@ -2182,6 +2204,9 @@ def get_subscriptions(cls, query: dict[str, Any]) -> list[dict[str, Any]]: @staticmethod def delete_thread(thread_id: str) -> int: """Delete thread from thread_id.""" + set_custom_attribute('forum.backend.operation', 'delete_thread') + set_custom_attribute('forum.thread_id', thread_id) + try: thread = CommentThread.objects.get(pk=thread_id) except ObjectDoesNotExist: @@ -2192,6 +2217,12 @@ def delete_thread(thread_id: str) -> int: @staticmethod def soft_delete_thread(thread_id: str, deleted_by: Optional[str] = None) -> int: """Soft delete thread by marking it as deleted.""" + set_custom_attribute('forum.backend.operation', 'delete_thread') + set_custom_attribute('forum.thread_id', thread_id) + set_custom_attribute('forum.delete_mode', 'soft') + if deleted_by: + set_custom_attribute('forum.deleted_by', deleted_by) + try: thread = CommentThread.objects.get(pk=thread_id) except ObjectDoesNotExist: @@ -2206,6 +2237,15 @@ def soft_delete_thread(thread_id: str, deleted_by: Optional[str] = None) -> int: @staticmethod def create_thread(data: dict[str, Any]) -> str: """Create thread.""" + # Track thread creation + set_custom_attribute('forum.backend.operation', 'create_thread') + set_custom_attribute('forum.course_id', data['course_id']) + set_custom_attribute('forum.thread_type', data.get('thread_type', 'discussion')) + set_custom_attribute('forum.commentable_id', data.get('commentable_id', 'course')) + set_custom_attribute('forum.author_id', data['author_id']) + if 'group_id' in data: + set_custom_attribute('forum.group_id', str(data['group_id'])) + optional_args = {} if group_id := data.get("group_id"): optional_args["group_id"] = group_id @@ -2230,6 +2270,17 @@ def update_thread( **kwargs: Any, ) -> int: """Updates a thread document in the database.""" + # Track thread update + set_custom_attribute('forum.backend.operation', 'update_thread') + set_custom_attribute('forum.thread_id', thread_id) + + # Track what's being updated + update_fields = [k for k in kwargs.keys() if kwargs.get(k) is not None] + if update_fields: + set_custom_attribute('forum.update_fields', ','.join(update_fields)) + if 'course_id' in kwargs: + set_custom_attribute('forum.course_id', kwargs['course_id']) + thread = CommentThread.objects.get(id=thread_id) if "thread_type" in kwargs: diff --git a/forum/settings/common.py b/forum/settings/common.py index dbecd9cd..e3c49262 100644 --- a/forum/settings/common.py +++ b/forum/settings/common.py @@ -9,6 +9,9 @@ def plugin_settings(settings: Any) -> None: """ Common settings for forum app """ + # Configure Datadog monitoring + settings.OPENEDX_TELEMETRY = ["edx_django_utils.monitoring.DatadogBackend"] + # Search backend if getattr(settings, "MEILISEARCH_ENABLED", False): settings.FORUM_SEARCH_BACKEND = getattr( diff --git a/forum/views/comments.py b/forum/views/comments.py index 2dd4bf2b..cc183fb7 100644 --- a/forum/views/comments.py +++ b/forum/views/comments.py @@ -1,5 +1,6 @@ """Forum Comments API Views.""" +from edx_django_utils.monitoring import set_custom_attribute from rest_framework import status from rest_framework.permissions import AllowAny from rest_framework.request import Request @@ -38,6 +39,9 @@ def get(self, request: Request, comment_id: str) -> Response: Response: The details of the comment for the given comment_id. """ + set_custom_attribute('forum.operation', 'get_comment') + set_custom_attribute('forum.comment_id', comment_id) + try: data = get_parent_comment(comment_id) except ForumV2RequestError: @@ -65,8 +69,16 @@ def post(self, request: Request, comment_id: str) -> Response: Response: The details of the comment that is created. """ + set_custom_attribute('forum.operation', 'create_child_comment') + set_custom_attribute('forum.parent_comment_id', comment_id) + + request_data = request.data + if 'course_id' in request_data: + set_custom_attribute('forum.course_id', request_data['course_id']) + if 'user_id' in request_data: + set_custom_attribute('forum.author_id', request_data['user_id']) + try: - request_data = request.data comment = create_child_comment( comment_id, request_data["body"], @@ -99,8 +111,18 @@ def put(self, request: Request, comment_id: str) -> Response: Response: The details of the comment that is updated. """ + set_custom_attribute('forum.operation', 'update_comment') + set_custom_attribute('forum.comment_id', comment_id) + + # Track what fields are being updated + request_data = request.data + if request_data: + update_fields = [k for k in request_data.keys() if request_data.get(k) is not None] + set_custom_attribute('forum.update_fields', ','.join(update_fields)) + if 'course_id' in request_data: + set_custom_attribute('forum.course_id', request_data['course_id']) + try: - request_data = request.data if anonymous := request_data.get("anonymous"): anonymous = str_to_bool(anonymous) if anonymous_to_peers := request_data.get("anonymous_to_peers"): @@ -146,6 +168,9 @@ def delete(self, request: Request, comment_id: str) -> Response: Response: The details of the comment that is deleted. """ + set_custom_attribute('forum.operation', 'delete_comment') + set_custom_attribute('forum.comment_id', comment_id) + try: deleted_comment = delete_comment(comment_id) except ForumV2RequestError: @@ -179,8 +204,16 @@ def post(self, request: Request, thread_id: str) -> Response: Response: The details of the comment that is created. """ + set_custom_attribute('forum.operation', 'create_parent_comment') + set_custom_attribute('forum.thread_id', thread_id) + + request_data = request.data + if 'course_id' in request_data: + set_custom_attribute('forum.course_id', request_data['course_id']) + if 'user_id' in request_data: + set_custom_attribute('forum.author_id', request_data['user_id']) + try: - request_data = request.data comment = create_parent_comment( thread_id, request_data["body"], diff --git a/forum/views/threads.py b/forum/views/threads.py index 893bd2a3..54eaee6f 100644 --- a/forum/views/threads.py +++ b/forum/views/threads.py @@ -3,6 +3,7 @@ import logging from typing import Any +from edx_django_utils.monitoring import set_custom_attribute from rest_framework import status from rest_framework.permissions import AllowAny from rest_framework.request import Request @@ -42,8 +43,22 @@ def get(self, request: Request, thread_id: str) -> Response: Returns: Response: A Response object containing the serialized thread data or an error message. """ + set_custom_attribute('forum.operation', 'get_thread') + set_custom_attribute('forum.thread_id', thread_id) + try: params = request.query_params.dict() + + # Track request parameters + if 'user_id' in params: + set_custom_attribute('forum.user_id', params['user_id']) + if 'with_responses' in params: + set_custom_attribute('forum.with_responses', params['with_responses']) + if 'resp_limit' in params: + set_custom_attribute('forum.resp_limit', params['resp_limit']) + if 'resp_skip' in params: + set_custom_attribute('forum.resp_skip', params['resp_skip']) + data = get_thread(thread_id, params) except ForumV2RequestError as error: return Response( @@ -64,6 +79,9 @@ def delete(self, request: Request, thread_id: str) -> Response: Response: The details of the thread that is deleted. """ + set_custom_attribute('forum.operation', 'delete_thread') + set_custom_attribute('forum.thread_id', thread_id) + try: serialized_data = delete_thread(thread_id) return Response(serialized_data, status=status.HTTP_200_OK) @@ -85,6 +103,15 @@ def put(self, request: Request, thread_id: str) -> Response: Response: The details of the thread that is updated. """ + set_custom_attribute('forum.operation', 'update_thread') + set_custom_attribute('forum.thread_id', thread_id) + + # Track what fields are being updated + if request.data: + update_fields = list(request.data.keys()) + set_custom_attribute('forum.update_fields', ','.join(update_fields)) + if 'course_id' in request.data: + set_custom_attribute('forum.course_id', request.data['course_id']) try: serialized_data = update_thread(thread_id, **request.data) @@ -117,6 +144,19 @@ def post(self, request: Request) -> Response: Response: The details of the thread that is created. """ + set_custom_attribute('forum.operation', 'create_thread') + + # Track thread creation context + if 'course_id' in request.data: + set_custom_attribute('forum.course_id', request.data['course_id']) + if 'thread_type' in request.data: + set_custom_attribute('forum.thread_type', request.data['thread_type']) + if 'commentable_id' in request.data: + set_custom_attribute('forum.commentable_id', request.data['commentable_id']) + if 'user_id' in request.data: + set_custom_attribute('forum.author_id', request.data['user_id']) + if 'group_id' in request.data: + set_custom_attribute('forum.group_id', str(request.data['group_id'])) try: params = request.data diff --git a/forum/views/votes.py b/forum/views/votes.py index 2153c5cd..9a65e5ff 100644 --- a/forum/views/votes.py +++ b/forum/views/votes.py @@ -2,6 +2,7 @@ Vote Views """ +from edx_django_utils.monitoring import set_custom_attribute from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -52,6 +53,13 @@ def put(self, request: Request, thread_id: str) -> Response: Returns: Response: The HTTP response with the result of the vote operation. """ + set_custom_attribute('forum.operation', 'vote_thread') + set_custom_attribute('forum.thread_id', thread_id) + if 'user_id' in request.data: + set_custom_attribute('forum.user_id', request.data['user_id']) + if 'value' in request.data: + set_custom_attribute('forum.vote_type', request.data['value']) + try: thread_response = update_thread_votes( thread_id, request.data["user_id"], request.data["value"] @@ -72,8 +80,13 @@ def delete(self, request: Request, thread_id: str) -> Response: Returns: Response: The HTTP response with the result of the remove vote operation. """ + set_custom_attribute('forum.operation', 'remove_vote_thread') + set_custom_attribute('forum.thread_id', thread_id) + user_id = request.query_params.get("user_id", "") + if user_id: + set_custom_attribute('forum.user_id', user_id) + try: - user_id = request.query_params.get("user_id", "") thread_response = delete_thread_vote(thread_id, user_id) except (ForumV2RequestError, KeyError) as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) @@ -117,6 +130,13 @@ def put(self, request: Request, comment_id: str) -> Response: Returns: Response: The HTTP response with the result of the vote operation. """ + set_custom_attribute('forum.operation', 'vote_comment') + set_custom_attribute('forum.comment_id', comment_id) + if 'user_id' in request.data: + set_custom_attribute('forum.user_id', request.data['user_id']) + if 'value' in request.data: + set_custom_attribute('forum.vote_type', request.data['value']) + try: comment_response = update_comment_votes( comment_id, request.data["user_id"], request.data["value"] @@ -137,8 +157,13 @@ def delete(self, request: Request, comment_id: str) -> Response: Returns: Response: The HTTP response with the result of the remove vote operation. """ + set_custom_attribute('forum.operation', 'remove_vote_comment') + set_custom_attribute('forum.comment_id', comment_id) + user_id = request.query_params.get("user_id", "") + if user_id: + set_custom_attribute('forum.user_id', user_id) + try: - user_id = request.query_params.get("user_id", "") comment_response = delete_comment_vote(comment_id, user_id) except (ForumV2RequestError, KeyError) as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) diff --git a/requirements/base.in b/requirements/base.in index ab0c2866..c128e7c8 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -13,3 +13,5 @@ pymongo elasticsearch edx-search # meilisearch backend mysqlclient +ddtrace +edx-django-utils>=8.0.0