diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index 1163c0a25168..c3ae53366c2d 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -21,7 +21,7 @@ from django.http import Http404 from django.urls import reverse from django.utils.html import strip_tags -from edx_django_utils.monitoring import function_trace +from edx_django_utils.monitoring import function_trace, set_custom_attribute from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import CourseKey from pytz import UTC @@ -1905,6 +1905,7 @@ def create_thread(request, thread_data): ) serializer.save() cc_thread = serializer.instance + set_custom_attribute("forum.entity_id", str(cc_thread.id)) # Use send_signal_after_commit() to ensure the signal is sent only after the transaction commits. send_signal_after_commit( lambda: thread_created.send(sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners) diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index 0bb60eaee48b..3358ce64b25f 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -11,6 +11,7 @@ from django.core.exceptions import BadRequest, ValidationError from django.shortcuts import get_object_or_404 from drf_yasg import openapi +from edx_django_utils.monitoring import set_custom_attribute from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import ( SessionAuthenticationAllowInactiveUser, @@ -18,7 +19,11 @@ from opaque_keys.edx.keys import CourseKey from rest_framework import permissions, status from rest_framework.authentication import SessionAuthentication -from rest_framework.exceptions import ParseError, UnsupportedMediaType +from rest_framework.exceptions import ( + ParseError, + PermissionDenied, + UnsupportedMediaType, +) from rest_framework.parsers import JSONParser from rest_framework.response import Response from rest_framework.views import APIView @@ -120,6 +125,23 @@ User = get_user_model() +def _discussion_error_type(exc): + """Map common discussion exceptions to a stable Datadog error type.""" + if isinstance(exc, PermissionError): + return "permission_denied" + if isinstance(exc, PermissionDenied): + return "permission_denied" + if isinstance(exc, InvalidKeyError): + return "validation_error" + if isinstance(exc, ValidationError): + return "validation_error" + if isinstance(exc, ParseError): + return "validation_error" + if isinstance(exc, UnsupportedMediaType): + return "validation_error" + return "backend_error" + + @view_auth_classes() class CourseView(DeveloperErrorViewMixin, APIView): """ @@ -713,35 +735,71 @@ def create(self, request): Implements the POST method for the list endpoint as described in the class docstring. """ - if not request.data.get("course_id"): - raise ValidationError({"course_id": ["This field is required."]}) - course_key_str = request.data.get("course_id") - course_key = CourseKey.from_string(course_key_str) + set_custom_attribute("forum.operation", "thread.create") + set_custom_attribute("forum.course_id", request.data.get("course_id", "")) + set_custom_attribute("forum.entity_type", "thread") + set_custom_attribute("forum.actor_id", str(getattr(request.user, "id", ""))) - if is_content_creation_rate_limited(request, course_key=course_key): - return Response( - "Too many requests", status=status.HTTP_429_TOO_MANY_REQUESTS - ) + if request.data.get("type"): + set_custom_attribute("forum.thread_type", request.data.get("type")) + if request.data.get("topic_id"): + set_custom_attribute("forum.commentable_id", request.data.get("topic_id")) + if request.data.get("group_id") is not None: + set_custom_attribute("forum.group_id", str(request.data.get("group_id"))) - if is_captcha_enabled(course_key) and is_only_student(course_key, request.user): - captcha_token = request.data.get("captcha_token") - if not captcha_token: - raise ValidationError({"captcha_token": "This field is required."}) + try: + if not request.data.get("course_id"): + raise ValidationError({"course_id": ["This field is required."]}) + course_key_str = request.data.get("course_id") + course_key = CourseKey.from_string(course_key_str) + + if is_content_creation_rate_limited(request, course_key=course_key): + set_custom_attribute("forum.result", "error") + set_custom_attribute( + "forum.http_status", str(status.HTTP_429_TOO_MANY_REQUESTS) + ) + set_custom_attribute("forum.error_type", "rate_limited") + return Response( + "Too many requests", status=status.HTTP_429_TOO_MANY_REQUESTS + ) - if not verify_recaptcha_token(captcha_token): - return Response({"error": "CAPTCHA verification failed."}, status=400) + if is_captcha_enabled(course_key) and is_only_student( + course_key, request.user + ): + captcha_token = request.data.get("captcha_token") + if not captcha_token: + raise ValidationError({"captcha_token": "This field is required."}) + + if not verify_recaptcha_token(captcha_token): + set_custom_attribute("forum.result", "error") + set_custom_attribute( + "forum.http_status", str(status.HTTP_400_BAD_REQUEST) + ) + set_custom_attribute("forum.error_type", "validation_error") + return Response( + {"error": "CAPTCHA verification failed."}, + status=status.HTTP_400_BAD_REQUEST, + ) - if ( - ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key) - and not request.user.is_active - ): - raise ValidationError( - {"detail": "Only verified users can post in discussions."} - ) + if ( + ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key) + and not request.user.is_active + ): + raise ValidationError( + {"detail": "Only verified users can post in discussions."} + ) - data = request.data.copy() - data.pop("captcha_token", None) - return Response(create_thread(request, data)) + data = request.data.copy() + data.pop("captcha_token", None) + response = Response(create_thread(request, data)) + set_custom_attribute("forum.result", "success") + set_custom_attribute("forum.http_status", str(response.status_code)) + return response + except Exception as exc: + set_custom_attribute("forum.result", "error") + set_custom_attribute("forum.http_status", str(status.HTTP_400_BAD_REQUEST)) + set_custom_attribute("forum.error_type", _discussion_error_type(exc)) + raise def partial_update(self, request, thread_id): """