From 95700c7d7b418ea20cc97aebf9a6af2952e88f8b Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:55:42 -0500 Subject: [PATCH] feat: Implement centralized error capture and handling with new middleware, services, and API endpoints, and add new admin and statistics API views. --- backend/apps/api/v1/accounts/views.py | 45 +-- backend/apps/api/v1/auth/serializers.py | 20 +- backend/apps/api/v1/auth/views.py | 8 +- backend/apps/api/v1/images/views.py | 9 +- backend/apps/api/v1/maps/views.py | 15 +- backend/apps/api/v1/middleware.py | 10 +- .../apps/api/v1/parks/ride_photos_views.py | 23 +- .../apps/api/v1/parks/ride_reviews_views.py | 11 +- backend/apps/api/v1/parks/views.py | 15 +- backend/apps/api/v1/rides/photo_views.py | 24 +- backend/apps/api/v1/rides/views.py | 5 +- backend/apps/api/v1/urls.py | 22 + backend/apps/api/v1/views/__init__.py | 11 + backend/apps/api/v1/views/admin.py | 382 ++++++++++++++++++ backend/apps/api/v1/views/base.py | 40 +- backend/apps/core/api/error_serializers.py | 126 ++++++ backend/apps/core/api/error_views.py | 286 +++++++++++++ backend/apps/core/middleware/error_capture.py | 170 ++++++++ backend/apps/core/middleware/view_tracking.py | 11 +- .../migrations/0005_add_application_error.py | 152 +++++++ backend/apps/core/models.py | 176 ++++++++ backend/apps/core/services/__init__.py | 2 + .../core/services/enhanced_cache_service.py | 8 +- backend/apps/core/services/error_service.py | 319 +++++++++++++++ .../apps/core/services/trending_service.py | 9 +- backend/apps/core/tasks/trending.py | 9 +- backend/apps/core/urls/__init__.py | 2 + backend/apps/core/urls/errors.py | 27 ++ backend/apps/core/utils/__init__.py | 10 + backend/apps/core/utils/capture_errors.py | 219 ++++++++++ backend/apps/core/utils/cloudflare.py | 8 +- backend/apps/core/views/map_views.py | 15 +- .../apps/parks/services/location_service.py | 5 +- backend/apps/parks/services/media_service.py | 5 +- backend/apps/parks/services/roadtrip.py | 7 +- backend/apps/rides/services/media_service.py | 5 +- .../apps/rides/services/ranking_service.py | 3 +- backend/apps/rides/services/status_service.py | 11 +- backend/apps/rides/signals.py | 10 +- backend/apps/rides/tasks.py | 12 +- backend/config/django/base.py | 1 + docs/ERROR_HANDLING.md | 308 ++++++++++++++ source_docs/AGENT_RULES.md | 79 ++++ 43 files changed, 2477 insertions(+), 158 deletions(-) create mode 100644 backend/apps/api/v1/views/admin.py create mode 100644 backend/apps/core/api/error_serializers.py create mode 100644 backend/apps/core/api/error_views.py create mode 100644 backend/apps/core/middleware/error_capture.py create mode 100644 backend/apps/core/migrations/0005_add_application_error.py create mode 100644 backend/apps/core/services/error_service.py create mode 100644 backend/apps/core/urls/errors.py create mode 100644 backend/apps/core/utils/capture_errors.py create mode 100644 docs/ERROR_HANDLING.md diff --git a/backend/apps/api/v1/accounts/views.py b/backend/apps/api/v1/accounts/views.py index 468f0871..5ccb7908 100644 --- a/backend/apps/api/v1/accounts/views.py +++ b/backend/apps/api/v1/accounts/views.py @@ -43,6 +43,7 @@ from apps.api.v1.serializers.accounts import ( UserPreferencesSerializer, UserStatisticsSerializer, ) +from apps.core.utils import capture_and_log from apps.lists.models import UserList # Set up logging @@ -198,16 +199,13 @@ def delete_user_preserve_submissions(request, user_id): ) except Exception as e: - # Log the error for debugging - logger.error( - f"Error deleting user {user_id} by admin {request.user.username}: {str(e)}", - extra={ - "admin_user": request.user.username, - "target_user_id": user_id, - "detail": str(e), - "action": "user_deletion_error", - }, - exc_info=True, + # Capture error to dashboard + capture_and_log( + e, + f'Delete user {user_id} by admin {request.user.username}', + source='api', + request=request, + severity='high', ) return Response( @@ -333,7 +331,7 @@ def save_avatar_image(request): ) except Exception as api_error: - logger.error(f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True) + capture_and_log(api_error, 'Fetch image from Cloudflare API', source='api', request=request) return Response( {"detail": f"Failed to fetch image from Cloudflare: {str(api_error)}"}, status=status.HTTP_400_BAD_REQUEST, @@ -357,7 +355,7 @@ def save_avatar_image(request): service.delete_image(old_avatar) logger.info(f"Successfully deleted old avatar from Cloudflare: {old_avatar.cloudflare_id}") except Exception as e: - logger.error(f"Failed to delete old avatar from Cloudflare: {str(e)}") + capture_and_log(e, 'Delete old avatar from Cloudflare', source='api', request=request, severity='low') # Continue with database deletion even if Cloudflare deletion fails old_avatar.delete() @@ -390,7 +388,7 @@ def save_avatar_image(request): ) except Exception as e: - logger.error(f"Error saving avatar image: {str(e)}", exc_info=True) + capture_and_log(e, 'Save avatar image', source='api', request=request) return Response( {"detail": f"Failed to save avatar: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, @@ -441,7 +439,7 @@ def delete_avatar(request): service.delete_image(avatar_to_delete) logger.info(f"Successfully deleted avatar from Cloudflare: {avatar_to_delete.cloudflare_id}") except Exception as e: - logger.error(f"Failed to delete avatar from Cloudflare: {str(e)}") + capture_and_log(e, 'Delete avatar from Cloudflare', source='api', request=request, severity='low') # Continue with database deletion even if Cloudflare deletion fails avatar_to_delete.delete() @@ -550,16 +548,13 @@ def request_account_deletion(request): status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - # Log the error for debugging - logger.error( - f"Error creating deletion request for user {request.user.username} (ID: {request.user.user_id}): {str(e)}", - extra={ - "user": request.user.username, - "user_id": request.user.user_id, - "detail": str(e), - "action": "self_deletion_error", - }, - exc_info=True, + # Capture error to dashboard + capture_and_log( + e, + f'Create deletion request for user {request.user.username}', + source='api', + request=request, + severity='high', ) return Response( @@ -1547,7 +1542,7 @@ def export_user_data(request): export_data = UserExportService.export_user_data(request.user) return Response(export_data, status=status.HTTP_200_OK) except Exception as e: - logger.error(f"Error exporting data for user {request.user.id}: {e}", exc_info=True) + capture_and_log(e, 'Export user data', source='api', request=request) return Response({"detail": "Failed to generate data export"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/backend/apps/api/v1/auth/serializers.py b/backend/apps/api/v1/auth/serializers.py index 3f99577d..685f0c8d 100644 --- a/backend/apps/api/v1/auth/serializers.py +++ b/backend/apps/api/v1/auth/serializers.py @@ -20,6 +20,7 @@ from drf_spectacular.utils import ( from rest_framework import serializers from apps.accounts.models import PasswordReset +from apps.core.utils import capture_and_log UserModel = get_user_model() @@ -64,6 +65,7 @@ class UserOutputSerializer(serializers.ModelSerializer): avatar_url = serializers.SerializerMethodField() display_name = serializers.SerializerMethodField() + role = serializers.SerializerMethodField() class Meta: model = UserModel @@ -74,9 +76,12 @@ class UserOutputSerializer(serializers.ModelSerializer): "display_name", "date_joined", "is_active", + "is_staff", + "is_superuser", + "role", "avatar_url", ] - read_only_fields = ["id", "date_joined", "is_active"] + read_only_fields = ["id", "date_joined", "is_active", "is_staff", "is_superuser", "role"] def get_display_name(self, obj): """Get the user's display name.""" @@ -89,6 +94,15 @@ class UserOutputSerializer(serializers.ModelSerializer): return obj.profile.get_avatar_url() return None + @extend_schema_field(serializers.CharField()) + def get_role(self, obj) -> str: + """Compute effective role based on permissions.""" + if obj.is_superuser: + return "SUPERUSER" + if obj.is_staff: + return "ADMIN" + return "USER" + class LoginInputSerializer(serializers.Serializer): """Input serializer for user login.""" @@ -235,8 +249,8 @@ The ThrillWiki Team logger.info(f"Verification email sent successfully to {user.email}. No email ID in response.") except Exception as e: - # Log the error but don't fail registration - logger.error(f"Failed to send verification email to {user.email}: {e}") + # Capture error but don't fail registration + capture_and_log(e, f'Send verification email to {user.email}', source='api', severity='low') class SignupOutputSerializer(serializers.Serializer): diff --git a/backend/apps/api/v1/auth/views.py b/backend/apps/api/v1/auth/views.py index 33327944..c424bd81 100644 --- a/backend/apps/api/v1/auth/views.py +++ b/backend/apps/api/v1/auth/views.py @@ -21,6 +21,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from apps.accounts.services.social_provider_service import SocialProviderService +from apps.core.utils import capture_and_log # Import directly from the auth serializers.py file (not the serializers package) from .serializers import ( @@ -188,7 +189,7 @@ class LoginAPIView(APIView): "access": str(access_token), "refresh": str(refresh), "user": user, - "detail": "Login successful", + "message": "Login successful", } ) return Response(response_serializer.data) @@ -820,10 +821,7 @@ The ThrillWiki Team return Response({"detail": "Verification email sent successfully", "success": True}) except Exception as e: - import logging - - logger = logging.getLogger(__name__) - logger.error(f"Failed to send verification email to {user.email}: {e}") + capture_and_log(e, 'Send verification email', source='api') return Response( {"detail": "Failed to send verification email"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR diff --git a/backend/apps/api/v1/images/views.py b/backend/apps/api/v1/images/views.py index 84ce3419..74d0ce70 100644 --- a/backend/apps/api/v1/images/views.py +++ b/backend/apps/api/v1/images/views.py @@ -7,6 +7,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from apps.core.utils import capture_and_log from apps.core.utils.cloudflare import get_direct_upload_url logger = logging.getLogger(__name__) @@ -21,11 +22,11 @@ class GenerateUploadURLView(APIView): result = get_direct_upload_url(user_id=str(request.user.id)) return Response(result, status=status.HTTP_200_OK) except ImproperlyConfigured as e: - logger.error(f"Configuration Error: {e}") + capture_and_log(e, 'Generate upload URL - configuration error', source='api') return Response({"detail": "Server configuration error."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except requests.RequestException as e: - logger.error(f"Cloudflare API Error: {e}") + capture_and_log(e, 'Generate upload URL - Cloudflare API error', source='api') return Response({"detail": "Failed to generate upload URL."}, status=status.HTTP_502_BAD_GATEWAY) - except Exception: - logger.exception("Unexpected error generating upload URL") + except Exception as e: + capture_and_log(e, 'Generate upload URL - unexpected error', source='api') return Response({"detail": "An unexpected error occurred."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/backend/apps/api/v1/maps/views.py b/backend/apps/api/v1/maps/views.py index 5a44597e..7053155b 100644 --- a/backend/apps/api/v1/maps/views.py +++ b/backend/apps/api/v1/maps/views.py @@ -38,6 +38,7 @@ from ..serializers.maps import ( MapLocationsResponseSerializer, MapSearchResponseSerializer, ) +from apps.core.utils import capture_and_log logger = logging.getLogger(__name__) @@ -332,7 +333,7 @@ class MapLocationsAPIView(APIView): return Response(result) except Exception as e: - logger.error(f"Error in MapLocationsAPIView: {str(e)}", exc_info=True) + capture_and_log(e, 'Get map locations', source='api') return Response( {"status": "error", "detail": "Failed to retrieve map locations"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -489,7 +490,7 @@ class MapLocationDetailAPIView(APIView): ) except Exception as e: - logger.error(f"Error in MapLocationDetailAPIView: {str(e)}", exc_info=True) + capture_and_log(e, 'Get map location detail', source='api') return Response( {"status": "error", "detail": "Failed to retrieve location details"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -674,7 +675,7 @@ class MapSearchAPIView(APIView): ) except Exception as e: - logger.error(f"Error in MapSearchAPIView: {str(e)}", exc_info=True) + capture_and_log(e, 'Map search', source='api') return Response( {"status": "error", "detail": "Search failed due to internal error"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -851,7 +852,7 @@ class MapBoundsAPIView(APIView): ) except Exception as e: - logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True) + capture_and_log(e, 'Get map bounds', source='api') return Response( {"status": "error", "detail": "Failed to retrieve locations within bounds"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -904,7 +905,7 @@ class MapStatsAPIView(APIView): ) except Exception as e: - logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True) + capture_and_log(e, 'Get map stats', source='api') return Response( {"status": "error", "detail": "Failed to retrieve map statistics"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -956,7 +957,7 @@ class MapCacheAPIView(APIView): ) except Exception as e: - logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True) + capture_and_log(e, 'Clear map cache', source='api') return Response( {"status": "error", "detail": "Failed to clear map cache"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -984,7 +985,7 @@ class MapCacheAPIView(APIView): ) except Exception as e: - logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True) + capture_and_log(e, 'Invalidate map cache', source='api') return Response( {"status": "error", "detail": "Failed to invalidate cache"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/backend/apps/api/v1/middleware.py b/backend/apps/api/v1/middleware.py index 4cc2335c..608d723d 100644 --- a/backend/apps/api/v1/middleware.py +++ b/backend/apps/api/v1/middleware.py @@ -14,6 +14,8 @@ from django.http import JsonResponse from django.utils.deprecation import MiddlewareMixin from rest_framework.response import Response +from apps.core.utils import capture_and_log + logger = logging.getLogger(__name__) @@ -261,7 +263,13 @@ class ContractValidationMiddleware(MiddlewareMixin): } if severity == "ERROR": - logger.error(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data) + # Contract violations are development issues - capture for visibility + capture_and_log( + ValueError(message), + f'Contract violation [{violation_type}] on {path}', + source='middleware', + severity='medium', + ) else: logger.warning(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data) diff --git a/backend/apps/api/v1/parks/ride_photos_views.py b/backend/apps/api/v1/parks/ride_photos_views.py index f2c19c7e..7e0d9e50 100644 --- a/backend/apps/api/v1/parks/ride_photos_views.py +++ b/backend/apps/api/v1/parks/ride_photos_views.py @@ -30,6 +30,7 @@ from apps.api.v1.rides.serializers import ( RidePhotoStatsOutputSerializer, RidePhotoUpdateInputSerializer, ) +from apps.core.utils import capture_and_log from apps.parks.models import Park from apps.rides.models import Ride from apps.rides.models.media import RidePhoto @@ -184,7 +185,7 @@ class RidePhotoViewSet(ModelViewSet): logger.info(f"Created ride photo {photo.id} for ride {ride.name} by user {self.request.user.username}") except Exception as e: - logger.error(f"Error creating ride photo: {e}") + capture_and_log(e, 'Create ride photo', source='api', request=self.request) raise ValidationError(f"Failed to create photo: {str(e)}") from None def perform_update(self, serializer): @@ -203,14 +204,14 @@ class RidePhotoViewSet(ModelViewSet): if "is_primary" in serializer.validated_data: del serializer.validated_data["is_primary"] except Exception as e: - logger.error(f"Error setting primary photo: {e}") + capture_and_log(e, 'Set primary photo', source='api', request=self.request) raise ValidationError(f"Failed to set primary photo: {str(e)}") from None try: serializer.save() logger.info(f"Updated ride photo {instance.id} by user {self.request.user.username}") except Exception as e: - logger.error(f"Error updating ride photo: {e}") + capture_and_log(e, 'Update ride photo', source='api', request=self.request) raise ValidationError(f"Failed to update photo: {str(e)}") from None def perform_destroy(self, instance): @@ -229,14 +230,14 @@ class RidePhotoViewSet(ModelViewSet): service.delete_image(instance.image) logger.info(f"Successfully deleted ride photo from Cloudflare: {instance.image.cloudflare_id}") except Exception as e: - logger.error(f"Failed to delete ride photo from Cloudflare: {str(e)}") + capture_and_log(e, 'Delete ride photo from Cloudflare', source='api', request=self.request, severity='low') # Continue with database deletion even if Cloudflare deletion fails RideMediaService.delete_photo(instance, deleted_by=self.request.user) logger.info(f"Deleted ride photo {instance.id} by user {self.request.user.username}") except Exception as e: - logger.error(f"Error deleting ride photo: {e}") + capture_and_log(e, 'Delete ride photo', source='api', request=self.request) raise ValidationError(f"Failed to delete photo: {str(e)}") from None @extend_schema( @@ -281,7 +282,7 @@ class RidePhotoViewSet(ModelViewSet): ) except Exception as e: - logger.error(f"Error setting primary photo: {e}") + capture_and_log(e, 'Set primary photo', source='api', request=request) return Response( {"detail": f"Failed to set primary photo: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, @@ -339,7 +340,7 @@ class RidePhotoViewSet(ModelViewSet): ) except Exception as e: - logger.error(f"Error in bulk photo approval: {e}") + capture_and_log(e, 'Bulk photo approval', source='api', request=request) return Response( {"detail": f"Failed to update photos: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, @@ -387,7 +388,7 @@ class RidePhotoViewSet(ModelViewSet): return Response(serializer.data, status=status.HTTP_200_OK) except Exception as e: - logger.error(f"Error getting ride photo stats: {e}") + capture_and_log(e, 'Get ride photo stats', source='api', request=request) return Response( {"detail": f"Failed to get photo statistics: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -491,7 +492,7 @@ class RidePhotoViewSet(ModelViewSet): ) except Exception as api_error: - logger.error(f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True) + capture_and_log(api_error, 'Fetch image from Cloudflare API', source='api', request=request) return Response( {"detail": f"Failed to fetch image from Cloudflare: {str(api_error)}"}, status=status.HTTP_400_BAD_REQUEST, @@ -514,14 +515,14 @@ class RidePhotoViewSet(ModelViewSet): try: RideMediaService.set_primary_photo(ride=ride, photo=photo) except Exception as e: - logger.error(f"Error setting primary photo: {e}") + capture_and_log(e, 'Set primary photo for saved image', source='api', request=request, severity='low') # Don't fail the entire operation, just log the error serializer = RidePhotoOutputSerializer(photo, context={"request": request}) return Response(serializer.data, status=status.HTTP_201_CREATED) except Exception as e: - logger.error(f"Error saving ride photo: {e}") + capture_and_log(e, 'Save ride photo', source='api', request=request) return Response( {"detail": f"Failed to save photo: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, diff --git a/backend/apps/api/v1/parks/ride_reviews_views.py b/backend/apps/api/v1/parks/ride_reviews_views.py index 4e83d738..d5cd3c4c 100644 --- a/backend/apps/api/v1/parks/ride_reviews_views.py +++ b/backend/apps/api/v1/parks/ride_reviews_views.py @@ -31,6 +31,7 @@ from apps.api.v1.serializers.ride_reviews import ( RideReviewStatsOutputSerializer, RideReviewUpdateInputSerializer, ) +from apps.core.utils import capture_and_log from apps.parks.models import Park from apps.rides.models import Ride from apps.rides.models.reviews import RideReview @@ -181,7 +182,7 @@ class RideReviewViewSet(ModelViewSet): logger.info(f"Created ride review {review.id} for ride {ride.name} by user {self.request.user.username}") except Exception as e: - logger.error(f"Error creating ride review: {e}") + capture_and_log(e, 'Create ride review', source='api', request=self.request) raise ValidationError(f"Failed to create review: {str(e)}") from None def perform_update(self, serializer): @@ -196,7 +197,7 @@ class RideReviewViewSet(ModelViewSet): serializer.save() logger.info(f"Updated ride review {instance.id} by user {self.request.user.username}") except Exception as e: - logger.error(f"Error updating ride review: {e}") + capture_and_log(e, 'Update ride review', source='api', request=self.request) raise ValidationError(f"Failed to update review: {str(e)}") from None def perform_destroy(self, instance): @@ -209,7 +210,7 @@ class RideReviewViewSet(ModelViewSet): logger.info(f"Deleting ride review {instance.id} by user {self.request.user.username}") instance.delete() except Exception as e: - logger.error(f"Error deleting ride review: {e}") + capture_and_log(e, 'Delete ride review', source='api', request=self.request) raise ValidationError(f"Failed to delete review: {str(e)}") from None @extend_schema( @@ -283,7 +284,7 @@ class RideReviewViewSet(ModelViewSet): return Response(serializer.data, status=status.HTTP_200_OK) except Exception as e: - logger.error(f"Error getting ride review stats: {e}") + capture_and_log(e, 'Get ride review stats', source='api', request=request) return Response( {"detail": f"Failed to get review statistics: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -360,7 +361,7 @@ class RideReviewViewSet(ModelViewSet): ) except Exception as e: - logger.error(f"Error in bulk review moderation: {e}") + capture_and_log(e, 'Bulk review moderation', source='api', request=request) return Response( {"detail": f"Failed to moderate reviews: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, diff --git a/backend/apps/api/v1/parks/views.py b/backend/apps/api/v1/parks/views.py index 1754d04e..aae91e09 100644 --- a/backend/apps/api/v1/parks/views.py +++ b/backend/apps/api/v1/parks/views.py @@ -32,6 +32,7 @@ from apps.core.exceptions import ( ServiceError, ValidationException, ) +from apps.core.utils import capture_and_log from apps.core.utils.error_handling import ErrorHandler from apps.parks.models import Park, ParkPhoto from apps.parks.services import ParkMediaService @@ -188,7 +189,7 @@ class ParkPhotoViewSet(ModelViewSet): logger.warning(f"Validation error creating park photo: {e}") raise ValidationError(str(e)) from None except ServiceError as e: - logger.error(f"Service error creating park photo: {e}") + capture_and_log(e, 'Create park photo', source='api') raise ValidationError(f"Failed to create photo: {str(e)}") from None def perform_update(self, serializer): @@ -210,7 +211,7 @@ class ParkPhotoViewSet(ModelViewSet): logger.warning(f"Validation error setting primary photo: {e}") raise ValidationError(str(e)) from None except ServiceError as e: - logger.error(f"Service error setting primary photo: {e}") + capture_and_log(e, 'Set primary park photo', source='api') raise ValidationError(f"Failed to set primary photo: {str(e)}") from None def perform_destroy(self, instance): @@ -232,13 +233,13 @@ class ParkPhotoViewSet(ModelViewSet): except ImportError: logger.warning("CloudflareImagesService not available") except ServiceError as e: - logger.error(f"Service error deleting from Cloudflare: {str(e)}") + capture_and_log(e, 'Delete park photo from Cloudflare', source='api', severity='low') # Continue with database deletion even if Cloudflare deletion fails try: ParkMediaService().delete_photo(instance.id, deleted_by=cast(UserModel, self.request.user)) except ServiceError as e: - logger.error(f"Service error deleting park photo: {e}") + capture_and_log(e, 'Delete park photo', source='api') raise ValidationError(f"Failed to delete photo: {str(e)}") from None @extend_schema( @@ -539,14 +540,14 @@ class ParkPhotoViewSet(ModelViewSet): try: ParkMediaService().set_primary_photo(park_id=park.id, photo_id=photo.id) except ServiceError as e: - logger.error(f"Error setting primary photo: {e}") + capture_and_log(e, 'Set primary park photo for saved image', source='api', severity='low') # Don't fail the entire operation, just log the error serializer = ParkPhotoOutputSerializer(photo, context={"request": request}) return Response(serializer.data, status=status.HTTP_201_CREATED) - except ImportError: - logger.error("CloudflareImagesService not available") + except ImportError as e: + capture_and_log(e, 'Cloudflare service import', source='api') return ErrorHandler.handle_api_error( ServiceError("Cloudflare Images service not available"), user_message="Image upload service not available", diff --git a/backend/apps/api/v1/rides/photo_views.py b/backend/apps/api/v1/rides/photo_views.py index 7e11bf22..f983b5ca 100644 --- a/backend/apps/api/v1/rides/photo_views.py +++ b/backend/apps/api/v1/rides/photo_views.py @@ -31,6 +31,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet +from apps.core.utils import capture_and_log, capture_errors from apps.rides.models import Ride, RidePhoto from apps.rides.services.media_service import RideMediaService @@ -39,6 +40,7 @@ UserModel = get_user_model() logger = logging.getLogger(__name__) + @extend_schema_view( list=extend_schema( summary="List ride photos", @@ -166,7 +168,7 @@ class RidePhotoViewSet(ModelViewSet): serializer.instance = photo except Exception as e: - logger.error(f"Error creating ride photo: {e}") + capture_and_log(e, 'Creating ride photo', source='api', severity='high', entity_type='RidePhoto') raise ValidationError(f"Failed to create photo: {str(e)}") from None def perform_update(self, serializer): @@ -185,7 +187,7 @@ class RidePhotoViewSet(ModelViewSet): if "is_primary" in serializer.validated_data: del serializer.validated_data["is_primary"] except Exception as e: - logger.error(f"Error setting primary photo: {e}") + capture_and_log(e, 'Setting primary photo', source='api', severity='medium', entity_type='RidePhoto') raise ValidationError(f"Failed to set primary photo: {str(e)}") from None def perform_destroy(self, instance): @@ -204,12 +206,12 @@ class RidePhotoViewSet(ModelViewSet): service.delete_image(instance.image) logger.info(f"Successfully deleted ride photo from Cloudflare: {instance.image.cloudflare_id}") except Exception as e: - logger.error(f"Failed to delete ride photo from Cloudflare: {str(e)}") + capture_and_log(e, 'Delete ride photo from Cloudflare', source='api', severity='low') # Continue with database deletion even if Cloudflare deletion fails RideMediaService.delete_photo(instance, deleted_by=self.request.user) # type: ignore except Exception as e: - logger.error(f"Error deleting ride photo: {e}") + capture_and_log(e, 'Deleting ride photo', source='api', severity='high', entity_type='RidePhoto') raise ValidationError(f"Failed to delete photo: {str(e)}") from None @extend_schema( @@ -254,7 +256,7 @@ class RidePhotoViewSet(ModelViewSet): ) except Exception as e: - logger.error(f"Error setting primary photo: {e}") + capture_and_log(e, 'Set primary photo', source='api', severity='medium', entity_type='RidePhoto') return Response( {"detail": f"Failed to set primary photo: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, @@ -308,7 +310,7 @@ class RidePhotoViewSet(ModelViewSet): ) except Exception as e: - logger.error(f"Error in bulk photo approval: {e}") + capture_and_log(e, 'Bulk photo approval', source='api', severity='medium', entity_type='RidePhoto') return Response( {"detail": f"Failed to update photos: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, @@ -356,7 +358,7 @@ class RidePhotoViewSet(ModelViewSet): return Response(serializer.data, status=status.HTTP_200_OK) except Exception as e: - logger.error(f"Error getting ride photo stats: {e}") + capture_and_log(e, 'Getting ride photo stats', source='api', severity='low', entity_type='RidePhoto') return Response( {"detail": f"Failed to get photo statistics: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -392,7 +394,7 @@ class RidePhotoViewSet(ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True) + capture_and_log(e, 'Set primary photo', source='api') return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) @extend_schema( @@ -486,7 +488,7 @@ class RidePhotoViewSet(ModelViewSet): ) except Exception as api_error: - logger.error(f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True) + capture_and_log(api_error, 'Fetch image from Cloudflare API', source='api') return Response( {"detail": f"Failed to fetch image from Cloudflare: {str(api_error)}"}, status=status.HTTP_400_BAD_REQUEST, @@ -509,14 +511,14 @@ class RidePhotoViewSet(ModelViewSet): try: RideMediaService.set_primary_photo(ride=ride, photo=photo) except Exception as e: - logger.error(f"Error setting primary photo: {e}") + capture_and_log(e, 'Set primary photo for saved image', source='api', severity='low') # Don't fail the entire operation, just log the error serializer = RidePhotoOutputSerializer(photo, context={"request": request}) return Response(serializer.data, status=status.HTTP_201_CREATED) except Exception as e: - logger.error(f"Error saving ride photo: {e}") + capture_and_log(e, 'Save ride photo', source='api') return Response( {"detail": f"Failed to save photo: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, diff --git a/backend/apps/api/v1/rides/views.py b/backend/apps/api/v1/rides/views.py index 5f858ad6..271c872f 100644 --- a/backend/apps/api/v1/rides/views.py +++ b/backend/apps/api/v1/rides/views.py @@ -46,6 +46,7 @@ from apps.api.v1.serializers.rides import ( RideUpdateInputSerializer, ) from apps.core.decorators.cache_decorators import cache_api_response +from apps.core.utils import capture_and_log from apps.rides.services.hybrid_loader import SmartRideLoader logger = logging.getLogger(__name__) @@ -2059,7 +2060,7 @@ class HybridRideAPIView(APIView): return Response(response_data, status=status.HTTP_200_OK) except Exception as e: - logger.error(f"Error in HybridRideAPIView: {e}") + capture_and_log(e, 'Get hybrid rides', source='api') return Response( {"detail": "Internal server error"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -2358,7 +2359,7 @@ class RideFilterMetadataAPIView(APIView): return Response(metadata, status=status.HTTP_200_OK) except Exception as e: - logger.error(f"Error in RideFilterMetadataAPIView: {e}") + capture_and_log(e, 'Get ride filter metadata', source='api') return Response( {"detail": "Internal server error"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/backend/apps/api/v1/urls.py b/backend/apps/api/v1/urls.py index a870500b..c5fcb150 100644 --- a/backend/apps/api/v1/urls.py +++ b/backend/apps/api/v1/urls.py @@ -10,10 +10,13 @@ from rest_framework.routers import DefaultRouter # Import other views from the views directory from .views import ( + CoasterStatisticsAPIView, + DataCompletenessAPIView, HealthCheckAPIView, NewContentAPIView, PerformanceMetricsAPIView, SimpleHealthAPIView, + TechnicalSpecificationsAPIView, # Trending system views TrendingAPIView, TriggerTrendingCalculationAPIView, @@ -71,6 +74,23 @@ urlpatterns = [ TriggerRankingCalculationView.as_view(), name="trigger-ranking-calculation", ), + # Admin endpoints + path( + "admin/data-completeness/", + DataCompletenessAPIView.as_view(), + name="data-completeness", + ), + # Ride search advanced endpoints (for useAdvancedRideSearch composable) + path( + "rides/technical-specifications/", + TechnicalSpecificationsAPIView.as_view(), + name="technical-specifications", + ), + path( + "rides/coaster-statistics/", + CoasterStatisticsAPIView.as_view(), + name="coaster-statistics", + ), # Domain-specific API endpoints path("parks/", include("apps.api.v1.parks.urls")), path("rides/", include("apps.api.v1.rides.urls")), @@ -86,9 +106,11 @@ urlpatterns = [ path("media/", include("apps.media.urls")), path("blog/", include("apps.blog.urls")), path("support/", include("apps.support.urls")), + path("errors/", include("apps.core.urls.errors")), path("images/", include("apps.api.v1.images.urls")), # Cloudflare Images Toolkit API endpoints path("cloudflare-images/", include("django_cloudflareimages_toolkit.urls")), # Include router URLs (for rankings and any other router-registered endpoints) path("", include(router.urls)), ] + diff --git a/backend/apps/api/v1/views/__init__.py b/backend/apps/api/v1/views/__init__.py index c6aa28e9..ccd8ab2f 100644 --- a/backend/apps/api/v1/views/__init__.py +++ b/backend/apps/api/v1/views/__init__.py @@ -5,9 +5,15 @@ This package contains all API view classes organized by functionality: - auth.py: Authentication and user management views - health.py: Health check and monitoring views - trending.py: Trending and new content discovery views +- admin.py: Admin-only data completeness and system management views """ # Import all view classes for easy access +from .admin import ( + CoasterStatisticsAPIView, + DataCompletenessAPIView, + TechnicalSpecificationsAPIView, +) from .auth import ( AuthStatusAPIView, CurrentUserAPIView, @@ -31,6 +37,10 @@ from .trending import ( # Export all views for import convenience __all__ = [ + # Admin views + "DataCompletenessAPIView", + "TechnicalSpecificationsAPIView", + "CoasterStatisticsAPIView", # Authentication views "LoginAPIView", "SignupAPIView", @@ -49,3 +59,4 @@ __all__ = [ "NewContentAPIView", "TriggerTrendingCalculationAPIView", ] + diff --git a/backend/apps/api/v1/views/admin.py b/backend/apps/api/v1/views/admin.py new file mode 100644 index 00000000..1b1e4db3 --- /dev/null +++ b/backend/apps/api/v1/views/admin.py @@ -0,0 +1,382 @@ +""" +Admin API views for data completeness and system management. + +These views provide admin-only endpoints for analyzing data quality, +entity completeness, and system health. +""" + +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.core.decorators.cache_decorators import cache_api_response +from apps.parks.models import Park +from apps.rides.models import Company, Ride + +# Define field importance categories per entity type +PARK_FIELDS = { + "critical": ["name", "slug", "status"], + "important": ["short_description", "park_type", "opening_date"], + "valuable": ["banner_image_url", "card_image_url", "website_url", "phone", "email"], + "supplementary": ["closing_date", "size_acres", "operating_season"], +} + +RIDE_FIELDS = { + "critical": ["name", "slug", "status", "park_id"], + "important": ["category", "opening_date", "manufacturer_id"], + "valuable": [ + "max_speed_kmh", + "height_meters", + "track_length_meters", + "inversions_count", + "banner_image_url", + "card_image_url", + ], + "supplementary": [ + "min_height_cm", + "max_height_cm", + "duration_seconds", + "capacity_per_hour", + "designer_id", + ], +} + +COMPANY_FIELDS = { + "critical": ["name", "slug", "company_type"], + "important": ["description", "headquarters_location"], + "valuable": ["logo_url", "website_url", "founded_year"], + "supplementary": ["banner_image_url", "card_image_url"], +} + + +def calculate_completeness_score(obj, fields_config: dict) -> tuple[int, dict]: + """ + Calculate completeness score for an entity based on field importance. + + Returns: + Tuple of (score, missing_fields_dict) + """ + weights = {"critical": 40, "important": 30, "valuable": 20, "supplementary": 10} + max_score = 100 + score = 0 + missing_fields = {} + + for category, fields in fields_config.items(): + category_weight = weights[category] + field_weight = category_weight / len(fields) if fields else 0 + missing_in_category = [] + + for field in fields: + value = getattr(obj, field, None) + if value is not None and value != "" and value != []: + score += field_weight + else: + missing_in_category.append(field) + + if missing_in_category: + missing_fields[category] = missing_in_category + + return min(round(score), max_score), missing_fields + + +class DataCompletenessAPIView(APIView): + """ + Admin endpoint for analyzing data completeness across all entity types. + + Returns completeness scores and missing field analysis for parks, rides, + companies, and ride models. + """ + + permission_classes = [IsAdminUser] + + @extend_schema( + tags=["Admin"], + summary="Get data completeness analysis", + description="Analyze data completeness across all entity types with missing field breakdown", + ) + @cache_api_response(timeout=300, key_prefix="data_completeness") + def get(self, request): + """ + Get data completeness analysis. + + Query parameters: + - entity_type: Filter by entity type (park, ride, company, ride_model) + - min_score: Minimum completeness score (0-100) + - max_score: Maximum completeness score (0-100) + - missing_category: Filter by missing field category (critical, important, valuable, supplementary) + - limit: Max results per entity type (default 50) + """ + try: + entity_type = request.GET.get("entity_type") + min_score = request.GET.get("min_score") + max_score = request.GET.get("max_score") + missing_category = request.GET.get("missing_category") + limit = min(int(request.GET.get("limit", 50)), 200) + + results = { + "summary": {}, + "parks": [], + "rides": [], + "companies": [], + "ride_models": [], + } + + # Process parks + if not entity_type or entity_type == "park": + parks = Park.objects.all()[:limit] + park_results = [] + total_park_score = 0 + parks_complete = 0 + + for park in parks: + score, missing = calculate_completeness_score(park, PARK_FIELDS) + + # Apply filters + if min_score and score < int(min_score): + continue + if max_score and score > int(max_score): + continue + if missing_category and missing_category not in missing: + continue + + total_park_score += score + if score == 100: + parks_complete += 1 + + park_results.append({ + "id": str(park.id), + "name": park.name, + "slug": park.slug, + "entity_type": "park", + "updated_at": park.updated_at.isoformat() if hasattr(park, "updated_at") else None, + "completeness_score": score, + "missing_fields": missing, + }) + + results["parks"] = park_results + results["summary"]["total_parks"] = len(park_results) + results["summary"]["avg_park_score"] = ( + round(total_park_score / len(park_results)) if park_results else 0 + ) + results["summary"]["parks_complete"] = parks_complete + + # Process rides + if not entity_type or entity_type == "ride": + rides = Ride.objects.select_related("park").all()[:limit] + ride_results = [] + total_ride_score = 0 + rides_complete = 0 + + for ride in rides: + score, missing = calculate_completeness_score(ride, RIDE_FIELDS) + + if min_score and score < int(min_score): + continue + if max_score and score > int(max_score): + continue + if missing_category and missing_category not in missing: + continue + + total_ride_score += score + if score == 100: + rides_complete += 1 + + ride_results.append({ + "id": str(ride.id), + "name": ride.name, + "slug": ride.slug, + "entity_type": "ride", + "updated_at": ride.updated_at.isoformat() if hasattr(ride, "updated_at") else None, + "completeness_score": score, + "missing_fields": missing, + }) + + results["rides"] = ride_results + results["summary"]["total_rides"] = len(ride_results) + results["summary"]["avg_ride_score"] = ( + round(total_ride_score / len(ride_results)) if ride_results else 0 + ) + results["summary"]["rides_complete"] = rides_complete + + # Process companies + if not entity_type or entity_type == "company": + companies = Company.objects.all()[:limit] + company_results = [] + total_company_score = 0 + companies_complete = 0 + + for company in companies: + score, missing = calculate_completeness_score(company, COMPANY_FIELDS) + + if min_score and score < int(min_score): + continue + if max_score and score > int(max_score): + continue + if missing_category and missing_category not in missing: + continue + + total_company_score += score + if score == 100: + companies_complete += 1 + + company_results.append({ + "id": str(company.id), + "name": company.name, + "slug": company.slug, + "entity_type": "company", + "updated_at": company.updated_at.isoformat() if hasattr(company, "updated_at") else None, + "completeness_score": score, + "missing_fields": missing, + }) + + results["companies"] = company_results + results["summary"]["total_companies"] = len(company_results) + results["summary"]["avg_company_score"] = ( + round(total_company_score / len(company_results)) if company_results else 0 + ) + results["summary"]["companies_complete"] = companies_complete + + # Ride models - placeholder (if model exists) + results["summary"]["total_models"] = 0 + results["summary"]["avg_model_score"] = 0 + results["summary"]["models_complete"] = 0 + + return Response(results, status=status.HTTP_200_OK) + + except Exception as e: + return Response( + {"detail": f"Error analyzing data completeness: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class TechnicalSpecificationsAPIView(APIView): + """ + Endpoint for querying ride technical specifications. + Used by advanced ride search functionality. + """ + + permission_classes = [] # Public endpoint + + @extend_schema( + tags=["Rides"], + summary="Get ride technical specifications", + description="Query technical specifications across rides for advanced filtering", + ) + def get(self, request): + """ + Get technical specifications for rides. + + Query parameters: + - spec_name: Filter by specification name + - ride_id: Filter by specific ride + """ + try: + spec_name = request.GET.get("spec_name") + ride_id = request.GET.get("ride_id") + + # For now, return technical specs from ride fields + # In a full implementation, this would query a separate specs table + rides = Ride.objects.all() + + if ride_id: + rides = rides.filter(id=ride_id) + + specs = [] + spec_fields = [ + ("max_speed_kmh", "Max Speed (km/h)"), + ("height_meters", "Height (m)"), + ("track_length_meters", "Track Length (m)"), + ("inversions_count", "Inversions"), + ("duration_seconds", "Duration (s)"), + ("g_force", "G-Force"), + ] + + for ride in rides[:100]: # Limit to prevent huge responses + for field, _name in spec_fields: + value = getattr(ride, field, None) + if value is not None and (not spec_name or spec_name == field): + specs.append({ + "ride_id": str(ride.id), + "spec_name": field, + "spec_value": str(value), + }) + + return Response(specs, status=status.HTTP_200_OK) + + except Exception as e: + return Response( + {"detail": f"Error fetching specifications: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class CoasterStatisticsAPIView(APIView): + """ + Endpoint for querying coaster statistics for advanced filtering. + """ + + permission_classes = [] # Public endpoint + + @extend_schema( + tags=["Rides"], + summary="Get coaster statistics", + description="Query coaster statistics for advanced ride filtering", + ) + def get(self, request): + """ + Get coaster statistics. + + Query parameters: + - stat_name: Filter by statistic name + - stat_value__gte: Minimum value + - stat_value__lte: Maximum value + """ + try: + stat_name = request.GET.get("stat_name") + min_value = request.GET.get("stat_value__gte") + max_value = request.GET.get("stat_value__lte") + + # Query rides with coaster category and relevant stats + rides = Ride.objects.filter(category="coaster") + + stats = [] + stat_fields = [ + "max_speed_kmh", + "height_meters", + "track_length_meters", + "inversions_count", + "g_force", + "drop_height_meters", + ] + + for ride in rides[:100]: + for field in stat_fields: + if stat_name and stat_name != field: + continue + + value = getattr(ride, field, None) + if value is None: + continue + + # Apply value filters + if min_value and float(value) < float(min_value): + continue + if max_value and float(value) > float(max_value): + continue + + stats.append({ + "ride_id": str(ride.id), + "stat_name": field, + "stat_value": float(value), + }) + + return Response(stats, status=status.HTTP_200_OK) + + except Exception as e: + return Response( + {"detail": f"Error fetching statistics: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/backend/apps/api/v1/views/base.py b/backend/apps/api/v1/views/base.py index 89369106..a4187c31 100644 --- a/backend/apps/api/v1/views/base.py +++ b/backend/apps/api/v1/views/base.py @@ -15,6 +15,7 @@ from rest_framework.serializers import Serializer from rest_framework.views import APIView from apps.api.v1.serializers.shared import validate_filter_metadata_contract +from apps.core.utils import capture_and_log logger = logging.getLogger(__name__) @@ -45,17 +46,12 @@ class ContractCompliantAPIView(APIView): return response except Exception as e: - # Log the error with context - logger.error( - f"API error in {self.__class__.__name__}: {str(e)}", - extra={ - "view_class": self.__class__.__name__, - "request_path": request.path, - "request_method": request.method, - "user": getattr(request, "user", None), - "detail": str(e), - }, - exc_info=True, + # Capture error to dashboard + capture_and_log( + e, + f'API error in {self.__class__.__name__}', + source='api', + severity='high', ) # Return standardized error response @@ -194,10 +190,10 @@ class FilterMetadataAPIView(ContractCompliantAPIView): return self.success_response(validated_metadata) except Exception as e: - logger.error( - f"Error getting filter metadata in {self.__class__.__name__}: {str(e)}", - extra={"view_class": self.__class__.__name__, "detail": str(e)}, - exc_info=True, + capture_and_log( + e, + f'Get filter metadata in {self.__class__.__name__}', + source='api', ) return self.error_response(message="Failed to retrieve filter metadata", error_code="FILTER_METADATA_ERROR") @@ -238,14 +234,10 @@ class HybridFilteringAPIView(ContractCompliantAPIView): return self.success_response(hybrid_data) except Exception as e: - logger.error( - f"Error in hybrid filtering for {self.__class__.__name__}: {str(e)}", - extra={ - "view_class": self.__class__.__name__, - "filters": getattr(self, "_extracted_filters", {}), - "detail": str(e), - }, - exc_info=True, + capture_and_log( + e, + f'Hybrid filtering for {self.__class__.__name__}', + source='api', ) return self.error_response(message="Failed to retrieve filtered data", error_code="HYBRID_FILTERING_ERROR") @@ -392,7 +384,7 @@ def contract_compliant_view(view_class): return response except Exception as e: - logger.error(f"Error in decorated view {view_class.__name__}: {str(e)}", exc_info=True) + capture_and_log(e, f'Decorated view {view_class.__name__}', source='api') # Return basic error response return Response( diff --git a/backend/apps/core/api/error_serializers.py b/backend/apps/core/api/error_serializers.py new file mode 100644 index 00000000..800f4e80 --- /dev/null +++ b/backend/apps/core/api/error_serializers.py @@ -0,0 +1,126 @@ +""" +Serializers for the error monitoring API. +""" + +from rest_framework import serializers + +from apps.core.models import ApplicationError + + +class ApplicationErrorSerializer(serializers.ModelSerializer): + """Full error details for admin dashboard.""" + + user_email = serializers.SerializerMethodField() + user_username = serializers.SerializerMethodField() + resolved_by_username = serializers.SerializerMethodField() + + class Meta: + model = ApplicationError + fields = [ + "id", + "error_id", + "request_id", + "error_type", + "error_message", + "error_stack", + "error_code", + "severity", + "source", + "endpoint", + "http_method", + "http_status", + "user_agent", + "user", + "user_email", + "user_username", + "ip_address_hash", + "metadata", + "environment", + "created_at", + "resolved", + "resolved_at", + "resolved_by", + "resolved_by_username", + "resolution_notes", + ] + read_only_fields = fields + + def get_user_email(self, obj: ApplicationError) -> str | None: + """Get the email of the user who encountered the error.""" + if obj.user: + return obj.user.email + return None + + def get_user_username(self, obj: ApplicationError) -> str | None: + """Get the username of the user who encountered the error.""" + if obj.user: + return obj.user.username + return None + + def get_resolved_by_username(self, obj: ApplicationError) -> str | None: + """Get the username of the admin who resolved the error.""" + if obj.resolved_by: + return obj.resolved_by.username + return None + + +class ApplicationErrorListSerializer(serializers.ModelSerializer): + """Lightweight serializer for error list views.""" + + class Meta: + model = ApplicationError + fields = [ + "id", + "error_id", + "error_type", + "error_message", + "severity", + "source", + "endpoint", + "created_at", + "resolved", + ] + read_only_fields = fields + + +class ErrorReportSerializer(serializers.Serializer): + """Frontend error report submission.""" + + error_id = serializers.UUIDField(required=False, allow_null=True) + error_type = serializers.CharField(max_length=100) + error_message = serializers.CharField(max_length=5000) + error_stack = serializers.CharField(required=False, allow_blank=True, max_length=10000) + error_code = serializers.CharField(required=False, allow_blank=True, max_length=50) + severity = serializers.ChoiceField( + choices=["critical", "high", "medium", "low"], + default="medium", + ) + endpoint = serializers.CharField(required=False, allow_blank=True, max_length=500) + metadata = serializers.JSONField(required=False, default=dict) + environment = serializers.JSONField(required=False, default=dict) + + +class ErrorStatisticsSerializer(serializers.Serializer): + """Error statistics response for dashboard.""" + + total_errors = serializers.IntegerField() + errors_by_severity = serializers.DictField() + errors_by_source = serializers.DictField() + errors_by_type = serializers.ListField() + errors_over_time = serializers.ListField() + resolution_rate = serializers.FloatField() + critical_count = serializers.IntegerField() + unresolved_count = serializers.IntegerField() + period_days = serializers.IntegerField() + + +class ErrorResolveSerializer(serializers.Serializer): + """Request to resolve an error.""" + + notes = serializers.CharField(required=False, allow_blank=True, max_length=1000) + + +class ErrorCleanupSerializer(serializers.Serializer): + """Request to cleanup old errors.""" + + days = serializers.IntegerField(min_value=1, max_value=365, default=30) diff --git a/backend/apps/core/api/error_views.py b/backend/apps/core/api/error_views.py new file mode 100644 index 00000000..3bdaf686 --- /dev/null +++ b/backend/apps/core/api/error_views.py @@ -0,0 +1,286 @@ +""" +API views for error monitoring and reporting. + +Provides endpoints for: +- Frontend error reporting (public, rate-limited) +- Error listing and filtering (admin only) +- Error detail view (admin only) +- Error resolution (admin only) +- Error statistics (admin only) +- Old error cleanup (superuser only) +""" + +import logging + +from django.db.models import Q +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAdminUser +from rest_framework.response import Response +from rest_framework.throttling import AnonRateThrottle +from rest_framework.views import APIView + +from apps.core.api.error_serializers import ( + ApplicationErrorListSerializer, + ApplicationErrorSerializer, + ErrorCleanupSerializer, + ErrorReportSerializer, + ErrorResolveSerializer, + ErrorStatisticsSerializer, +) +from apps.core.models import ApplicationError +from apps.core.services import ErrorService + +logger = logging.getLogger(__name__) + + +class ErrorReportThrottle(AnonRateThrottle): + """Rate limit for error reporting - 10 requests per minute per IP.""" + + rate = "10/min" + + +class ErrorReportView(APIView): + """ + POST /api/v1/errors/report/ + + Accept error reports from the frontend. + Public endpoint, rate-limited to prevent abuse. + """ + + permission_classes = [AllowAny] + throttle_classes = [ErrorReportThrottle] + + def post(self, request): + """Record an error report from the frontend.""" + serializer = ErrorReportSerializer(data=request.data) + serializer.validate(request.data) + + if not serializer.is_valid(): + return Response( + {"error": "Invalid error report", "details": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + data = serializer.validated_data + + # Capture the error + error = ErrorService.capture_error( + error=data["error_message"], + source="frontend", + request=request, + severity=data.get("severity", "medium"), + metadata=data.get("metadata", {}), + environment=data.get("environment", {}), + error_id=data.get("error_id"), + ) + + # Update error_type from the submitted data + error.error_type = data["error_type"] + error.error_stack = data.get("error_stack", "") + error.error_code = data.get("error_code", "") + error.endpoint = data.get("endpoint", "") + error.save(update_fields=["error_type", "error_stack", "error_code", "endpoint"]) + + return Response( + { + "status": "success", + "error_id": str(error.error_id), + "message": "Error report received", + }, + status=status.HTTP_201_CREATED, + ) + + +class ErrorListView(APIView): + """ + GET /api/v1/errors/ + + List errors with filtering. Admin only. + """ + + permission_classes = [IsAdminUser] + + def get(self, request): + """List errors with optional filters.""" + queryset = ApplicationError.objects.all() + + # Apply filters + severity = request.query_params.get("severity") + if severity: + queryset = queryset.filter(severity=severity) + + source = request.query_params.get("source") + if source: + queryset = queryset.filter(source=source) + + error_type = request.query_params.get("error_type") + if error_type: + queryset = queryset.filter(error_type__icontains=error_type) + + resolved = request.query_params.get("resolved") + if resolved is not None: + resolved_bool = resolved.lower() in ("true", "1", "yes") + queryset = queryset.filter(resolved=resolved_bool) + + search = request.query_params.get("search") + if search: + queryset = queryset.filter( + Q(error_id__icontains=search) + | Q(error_message__icontains=search) + | Q(endpoint__icontains=search) + | Q(error_type__icontains=search) + ) + + # Date range filter + date_range = request.query_params.get("date_range", "24h") + from datetime import timedelta + + from django.utils import timezone + + range_map = { + "1h": timedelta(hours=1), + "24h": timedelta(hours=24), + "7d": timedelta(days=7), + "30d": timedelta(days=30), + } + if date_range in range_map: + cutoff = timezone.now() - range_map[date_range] + queryset = queryset.filter(created_at__gte=cutoff) + + # Pagination + limit = min(int(request.query_params.get("limit", 50)), 100) + offset = int(request.query_params.get("offset", 0)) + + total = queryset.count() + errors = queryset[offset : offset + limit] + + serializer = ApplicationErrorListSerializer(errors, many=True) + + return Response( + { + "status": "success", + "data": serializer.data, + "total": total, + "limit": limit, + "offset": offset, + } + ) + + +class ErrorDetailView(APIView): + """ + GET /api/v1/errors/{id}/ + + Get full error details. Admin only. + """ + + permission_classes = [IsAdminUser] + + def get(self, request, pk): + """Get detailed error information.""" + try: + error = ApplicationError.objects.get(pk=pk) + except ApplicationError.DoesNotExist: + return Response( + {"error": "Error not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = ApplicationErrorSerializer(error) + return Response({"status": "success", "data": serializer.data}) + + +class ErrorResolveView(APIView): + """ + POST /api/v1/errors/{id}/resolve/ + + Mark an error as resolved. Admin only. + """ + + permission_classes = [IsAdminUser] + + def post(self, request, pk): + """Mark error as resolved.""" + serializer = ErrorResolveSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"error": "Invalid request", "details": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + error = ErrorService.resolve_error( + error_id=pk, + user=request.user, + notes=serializer.validated_data.get("notes", ""), + ) + except ApplicationError.DoesNotExist: + return Response( + {"error": "Error not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + return Response( + { + "status": "success", + "message": "Error marked as resolved", + "error_id": str(error.error_id), + } + ) + + +class ErrorStatisticsView(APIView): + """ + GET /api/v1/errors/statistics/ + + Get error statistics for dashboard. Admin only. + """ + + permission_classes = [IsAdminUser] + + def get(self, request): + """Get error statistics.""" + days = int(request.query_params.get("days", 7)) + days = min(max(days, 1), 90) # Clamp between 1 and 90 + + stats = ErrorService.get_error_statistics(days=days) + serializer = ErrorStatisticsSerializer(stats) + + return Response({"status": "success", "data": serializer.data}) + + +class ErrorCleanupView(APIView): + """ + POST /api/v1/errors/cleanup/ + + Clean up old errors. Superuser only. + """ + + permission_classes = [IsAdminUser] + + def post(self, request): + """Clean up old resolved errors.""" + # Extra check for superuser + if not request.user.is_superuser: + return Response( + {"error": "Superuser access required"}, + status=status.HTTP_403_FORBIDDEN, + ) + + serializer = ErrorCleanupSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"error": "Invalid request", "details": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + days = serializer.validated_data.get("days", 30) + deleted_count = ErrorService.cleanup_old_errors(days=days) + + return Response( + { + "status": "success", + "message": f"Deleted {deleted_count} errors older than {days} days", + "deleted_count": deleted_count, + } + ) diff --git a/backend/apps/core/middleware/error_capture.py b/backend/apps/core/middleware/error_capture.py new file mode 100644 index 00000000..d8d30b33 --- /dev/null +++ b/backend/apps/core/middleware/error_capture.py @@ -0,0 +1,170 @@ +""" +ErrorCaptureMiddleware - Automatically capture backend exceptions. + +This middleware intercepts unhandled exceptions in Django and logs them +to the ApplicationError model for display in the admin error dashboard. +""" + +import logging + +from django.http import HttpRequest, HttpResponse + +from apps.core.services import ErrorService + +logger = logging.getLogger(__name__) + + +class ErrorCaptureMiddleware: + """ + Middleware that captures unhandled exceptions and stores them. + + This runs after Django's built-in exception handling but before + the exception is raised to the user, allowing us to log all errors + that occur during request processing. + + Usage: + Add to settings.MIDDLEWARE after SecurityMiddleware but before + any middleware that might swallow exceptions: + + MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'apps.core.middleware.error_capture.ErrorCaptureMiddleware', + ... + ] + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + response = self.get_response(request) + + # Capture 5xx errors that made it through (server errors) + if response.status_code >= 500: + self._capture_response_error(request, response) + + return response + + def process_exception(self, request: HttpRequest, exception: Exception) -> None: + """ + Capture exception details before Django handles it. + + This method is called when a view raises an exception. + It logs the error to the database but returns None to allow + Django to continue with its default exception handling. + + Args: + request: The HTTP request that caused the exception + exception: The exception that was raised + + Returns: + None - let Django continue with default handling + """ + try: + self._capture_exception(request, exception) + except Exception as e: + # Don't let error capture failures break the request + logger.error(f"Failed to capture exception: {e}") + + return None # Let Django continue with default handling + + def _capture_exception( + self, request: HttpRequest, exception: Exception + ) -> None: + """Capture an exception to the database.""" + # Determine severity based on exception type + severity = self._classify_severity(exception) + + # Determine source based on request path + source = "api" if "/api/" in request.path else "backend" + + ErrorService.capture_error( + error=exception, + source=source, + request=request, + severity=severity, + metadata={ + "view": self._get_view_name(request), + "query_params": dict(request.GET), + }, + ) + + def _capture_response_error( + self, request: HttpRequest, response: HttpResponse + ) -> None: + """Capture a 5xx response error to the database.""" + # Only capture if we have a reason phrase or content + reason = getattr(response, "reason_phrase", "Server Error") + content = "" + if hasattr(response, "content"): + try: + content = response.content.decode("utf-8")[:500] + except (UnicodeDecodeError, AttributeError): + pass + + error_message = f"HTTP {response.status_code}: {reason}" + if content: + error_message += f" - {content[:200]}" + + severity = "critical" if response.status_code >= 503 else "high" + source = "api" if "/api/" in request.path else "backend" + + ErrorService.capture_error( + error=error_message, + source=source, + request=request, + severity=severity, + metadata={ + "status_code": response.status_code, + "reason_phrase": reason, + "view": self._get_view_name(request), + }, + ) + + def _classify_severity(self, exception: Exception) -> str: + """Classify exception severity based on type.""" + exception_type = type(exception).__name__ + + # Critical: Database, system, and security errors + critical_types = { + "DatabaseError", + "OperationalError", + "IntegrityError", + "PermissionError", + "SystemError", + "MemoryError", + } + + # High: Unexpected runtime errors + high_types = { + "RuntimeError", + "TypeError", + "ValueError", + "AttributeError", + "KeyError", + "IndexError", + } + + # Medium: Expected application errors + medium_types = { + "ValidationError", + "Http404", + "NotFound", + "PermissionDenied", + "AuthenticationFailed", + } + + if exception_type in critical_types: + return "critical" + elif exception_type in high_types: + return "high" + elif exception_type in medium_types: + return "medium" + else: + return "high" # Default to high for unknown errors + + def _get_view_name(self, request: HttpRequest) -> str: + """Get the name of the view that handled the request.""" + if hasattr(request, "resolver_match") and request.resolver_match: + return request.resolver_match.view_name or "" + return "" diff --git a/backend/apps/core/middleware/view_tracking.py b/backend/apps/core/middleware/view_tracking.py index d5430049..1621fc23 100644 --- a/backend/apps/core/middleware/view_tracking.py +++ b/backend/apps/core/middleware/view_tracking.py @@ -17,6 +17,7 @@ from django.http import HttpRequest, HttpResponse from django.utils import timezone from apps.core.analytics import PageView +from apps.core.utils import capture_and_log from apps.parks.models import Park from apps.rides.models import Ride @@ -65,8 +66,8 @@ class ViewTrackingMiddleware: try: self._track_view_if_applicable(request) except Exception as e: - # Log error but don't break the request - self.logger.error(f"Error tracking view: {e}", exc_info=True) + # Capture error but don't break the request + capture_and_log(e, f'Track view for {request.path}', source='middleware', severity='low') return response @@ -137,7 +138,7 @@ class ViewTrackingMiddleware: self.logger.debug(f"Recorded view for {content_type} {slug} from {client_ip}") except Exception as e: - self.logger.error(f"Failed to record page view for {content_type} {slug}: {e}") + capture_and_log(e, f'Record page view for {content_type} {slug}', source='middleware', severity='low') def _get_content_object(self, content_type: str, slug: str) -> ContentObject | None: """Get the content object by type and slug.""" @@ -156,7 +157,7 @@ class ViewTrackingMiddleware: except Park.DoesNotExist: return None except Exception as e: - self.logger.error(f"Error getting {content_type} with slug {slug}: {e}") + capture_and_log(e, f'Get {content_type} with slug {slug}', source='middleware', severity='low') return None def _is_duplicate_view(self, content_obj: ContentObject, client_ip: str) -> bool: @@ -298,5 +299,5 @@ def get_view_stats_for_content(content_obj: ContentObject, hours: int = 24) -> d } except Exception as e: - logger.error(f"Error getting view stats: {e}") + capture_and_log(e, f'Get view stats for content', source='service', severity='low') return {"total_views": 0, "unique_views": 0, "hours": hours, "error": str(e)} diff --git a/backend/apps/core/migrations/0005_add_application_error.py b/backend/apps/core/migrations/0005_add_application_error.py new file mode 100644 index 00000000..a3107b83 --- /dev/null +++ b/backend/apps/core/migrations/0005_add_application_error.py @@ -0,0 +1,152 @@ +# Generated by Django 5.2.9 on 2026-01-02 16:18 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0004_alter_slughistory_options_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ApplicationError", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "error_id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + help_text="Unique identifier for this error instance", + unique=True, + ), + ), + ( + "request_id", + models.CharField( + blank=True, db_index=True, help_text="Request correlation ID if available", max_length=255 + ), + ), + ( + "error_type", + models.CharField( + db_index=True, + help_text="Type/class of the error (e.g., 'ValidationError', 'TypeError')", + max_length=100, + ), + ), + ("error_message", models.TextField(help_text="Human-readable error message")), + ("error_stack", models.TextField(blank=True, help_text="Stack trace if available")), + ( + "error_code", + models.CharField( + blank=True, db_index=True, help_text="Application-specific error code", max_length=50 + ), + ), + ( + "severity", + models.CharField( + choices=[("critical", "Critical"), ("high", "High"), ("medium", "Medium"), ("low", "Low")], + db_index=True, + default="medium", + help_text="Error severity level", + max_length=20, + ), + ), + ( + "source", + models.CharField( + choices=[("frontend", "Frontend"), ("backend", "Backend"), ("api", "API")], + db_index=True, + help_text="Where the error originated", + max_length=20, + ), + ), + ( + "endpoint", + models.CharField(blank=True, help_text="URL/endpoint where the error occurred", max_length=500), + ), + ("http_method", models.CharField(blank=True, help_text="HTTP method of the request", max_length=10)), + ( + "http_status", + models.PositiveIntegerField(blank=True, help_text="HTTP status code returned", null=True), + ), + ("user_agent", models.TextField(blank=True, help_text="User agent string from the client")), + ( + "ip_address_hash", + models.CharField( + blank=True, + db_index=True, + help_text="Hashed IP address for rate limiting (privacy-preserving)", + max_length=64, + ), + ), + ( + "metadata", + models.JSONField( + blank=True, default=dict, help_text="Additional context (action, entity info, etc.)" + ), + ), + ( + "environment", + models.JSONField( + blank=True, default=dict, help_text="Client environment info (viewport, browser, etc.)" + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, db_index=True, help_text="When the error was recorded"), + ), + ( + "resolved", + models.BooleanField( + db_index=True, default=False, help_text="Whether this error has been addressed" + ), + ), + ( + "resolved_at", + models.DateTimeField(blank=True, help_text="When the error was marked resolved", null=True), + ), + ("resolution_notes", models.TextField(blank=True, help_text="Notes about how the error was resolved")), + ( + "resolved_by", + models.ForeignKey( + blank=True, + help_text="Admin who resolved this error", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="resolved_errors", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + help_text="User who encountered the error", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="application_errors", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Application Error", + "verbose_name_plural": "Application Errors", + "ordering": ["-created_at"], + "indexes": [ + models.Index(fields=["severity", "created_at"], name="core_applic_severit_6eeb93_idx"), + models.Index(fields=["source", "created_at"], name="core_applic_source_31a37f_idx"), + models.Index(fields=["error_type", "created_at"], name="core_applic_error_t_e787f5_idx"), + models.Index(fields=["resolved", "created_at"], name="core_applic_resolve_2b0297_idx"), + ], + }, + ), + ] diff --git a/backend/apps/core/models.py b/backend/apps/core/models.py index 5b720401..72ce1a20 100644 --- a/backend/apps/core/models.py +++ b/backend/apps/core/models.py @@ -1,4 +1,8 @@ +import hashlib +import uuid + import pghistory +from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models @@ -122,3 +126,175 @@ class SluggedModel(TrackedModel): ) raise cls.DoesNotExist(f"{cls.__name__} with slug '{slug}' does not exist") from None + + +class ApplicationError(models.Model): + """ + Stores application errors from frontend and backend sources. + + Errors are captured automatically via middleware (backend) or + reported via API (frontend) and displayed in the admin dashboard. + """ + + class Severity(models.TextChoices): + CRITICAL = "critical", "Critical" + HIGH = "high", "High" + MEDIUM = "medium", "Medium" + LOW = "low", "Low" + + class Source(models.TextChoices): + FRONTEND = "frontend", "Frontend" + BACKEND = "backend", "Backend" + API = "api", "API" + + # Identity + error_id = models.UUIDField( + unique=True, + default=uuid.uuid4, + db_index=True, + help_text="Unique identifier for this error instance", + ) + request_id = models.CharField( + max_length=255, + blank=True, + db_index=True, + help_text="Request correlation ID if available", + ) + + # Error information + error_type = models.CharField( + max_length=100, + db_index=True, + help_text="Type/class of the error (e.g., 'ValidationError', 'TypeError')", + ) + error_message = models.TextField( + help_text="Human-readable error message", + ) + error_stack = models.TextField( + blank=True, + help_text="Stack trace if available", + ) + error_code = models.CharField( + max_length=50, + blank=True, + db_index=True, + help_text="Application-specific error code", + ) + severity = models.CharField( + max_length=20, + choices=Severity.choices, + default=Severity.MEDIUM, + db_index=True, + help_text="Error severity level", + ) + source = models.CharField( + max_length=20, + choices=Source.choices, + db_index=True, + help_text="Where the error originated", + ) + + # Request context + endpoint = models.CharField( + max_length=500, + blank=True, + help_text="URL/endpoint where the error occurred", + ) + http_method = models.CharField( + max_length=10, + blank=True, + help_text="HTTP method of the request", + ) + http_status = models.PositiveIntegerField( + null=True, + blank=True, + help_text="HTTP status code returned", + ) + user_agent = models.TextField( + blank=True, + help_text="User agent string from the client", + ) + + # User context + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="application_errors", + help_text="User who encountered the error", + ) + ip_address_hash = models.CharField( + max_length=64, + blank=True, + db_index=True, + help_text="Hashed IP address for rate limiting (privacy-preserving)", + ) + + # Extended metadata + metadata = models.JSONField( + default=dict, + blank=True, + help_text="Additional context (action, entity info, etc.)", + ) + environment = models.JSONField( + default=dict, + blank=True, + help_text="Client environment info (viewport, browser, etc.)", + ) + + # Timestamps and resolution + created_at = models.DateTimeField( + auto_now_add=True, + db_index=True, + help_text="When the error was recorded", + ) + resolved = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this error has been addressed", + ) + resolved_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the error was marked resolved", + ) + resolved_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="resolved_errors", + help_text="Admin who resolved this error", + ) + resolution_notes = models.TextField( + blank=True, + help_text="Notes about how the error was resolved", + ) + + class Meta: + ordering = ["-created_at"] + verbose_name = "Application Error" + verbose_name_plural = "Application Errors" + indexes = [ + models.Index(fields=["severity", "created_at"]), + models.Index(fields=["source", "created_at"]), + models.Index(fields=["error_type", "created_at"]), + models.Index(fields=["resolved", "created_at"]), + ] + + def __str__(self) -> str: + return f"[{self.severity.upper()}] {self.error_type}: {self.error_message[:50]}" + + @staticmethod + def hash_ip(ip_address: str) -> str: + """Hash an IP address for privacy-preserving storage.""" + if not ip_address: + return "" + salt = getattr(settings, "SECRET_KEY", "")[:16] + return hashlib.sha256(f"{salt}{ip_address}".encode()).hexdigest() + + @property + def short_error_id(self) -> str: + """Return first 8 characters of error_id for display.""" + return str(self.error_id)[:8] diff --git a/backend/apps/core/services/__init__.py b/backend/apps/core/services/__init__.py index c5ff3509..fa6a67dc 100644 --- a/backend/apps/core/services/__init__.py +++ b/backend/apps/core/services/__init__.py @@ -3,6 +3,7 @@ Core services for ThrillWiki unified map functionality. """ from .clustering_service import ClusteringService +from .error_service import ErrorService from .data_structures import ( ClusterData, GeoBounds, @@ -17,6 +18,7 @@ from .map_service import UnifiedMapService __all__ = [ "UnifiedMapService", "ClusteringService", + "ErrorService", "MapCacheService", "UnifiedLocation", "LocationType", diff --git a/backend/apps/core/services/enhanced_cache_service.py b/backend/apps/core/services/enhanced_cache_service.py index 0589b5bb..4cfdbae9 100644 --- a/backend/apps/core/services/enhanced_cache_service.py +++ b/backend/apps/core/services/enhanced_cache_service.py @@ -12,6 +12,8 @@ from typing import Any from django.core.cache import caches +from apps.core.utils import capture_and_log + logger = logging.getLogger(__name__) @@ -122,7 +124,7 @@ class EnhancedCacheService: else: logger.warning(f"Cache backend does not support pattern deletion for pattern '{pattern}'") except Exception as e: - logger.error(f"Error invalidating cache pattern '{pattern}': {e}") + capture_and_log(e, f"Invalidate cache pattern '{pattern}'", source='service', severity='low') def invalidate_model_cache(self, model_name: str, instance_id: int | None = None): """Invalidate cache keys related to a specific model""" @@ -144,7 +146,7 @@ class EnhancedCacheService: self.default_cache.set(cache_key, data, timeout) logger.info(f"Warmed cache for key '{cache_key}'") except Exception as e: - logger.error(f"Error warming cache for key '{cache_key}': {e}") + capture_and_log(e, f"Warm cache for key '{cache_key}'", source='service', severity='low') def _generate_api_cache_key(self, view_name: str, params: dict) -> str: """Generate consistent cache keys for API responses""" @@ -250,7 +252,7 @@ class CacheWarmer: try: self.cache_service.warm_cache(**operation) except Exception as e: - logger.error(f"Error warming cache for {operation['cache_key']}: {e}") + capture_and_log(e, f"Warm cache for {operation['cache_key']}", source='service', severity='low') # Cache statistics and monitoring diff --git a/backend/apps/core/services/error_service.py b/backend/apps/core/services/error_service.py new file mode 100644 index 00000000..8ebb4c8e --- /dev/null +++ b/backend/apps/core/services/error_service.py @@ -0,0 +1,319 @@ +""" +ErrorService - Centralized error capture and management. + +Provides methods for: +- Capturing errors from frontend and backend +- Querying errors with filtering +- Generating statistics for dashboard +- Resolving errors +- Cleaning up old errors +""" + +import logging +import traceback +from datetime import timedelta +from typing import Any +from uuid import UUID + +from django.db.models import Count, Q +from django.db.models.functions import TruncDate +from django.http import HttpRequest +from django.utils import timezone + +from apps.core.models import ApplicationError + +logger = logging.getLogger(__name__) + + +class ErrorService: + """Service for error capture and management.""" + + @staticmethod + def capture_error( + error: Exception | str, + source: str, + request: HttpRequest | None = None, + user: Any | None = None, + severity: str = "medium", + metadata: dict | None = None, + environment: dict | None = None, + error_id: UUID | None = None, + ) -> ApplicationError: + """ + Capture and store an error. + + Args: + error: The exception or error message + source: One of 'frontend', 'backend', 'api' + request: Optional HTTP request for context + user: Optional user who encountered the error + severity: Error severity level + metadata: Additional context data + environment: Client environment info + + Returns: + The created ApplicationError instance + """ + # Extract error details + if isinstance(error, Exception): + error_type = type(error).__name__ + error_message = str(error) + error_stack = traceback.format_exc() + error_code = getattr(error, "error_code", "") or "" + else: + error_type = "Error" + error_message = str(error) + error_stack = "" + error_code = "" + + # Extract request details + endpoint = "" + http_method = "" + user_agent = "" + ip_address_hash = "" + http_status = None + + # Build request_context for additional debugging info + request_context: dict[str, Any] = {} + + if request: + endpoint = request.path + http_method = request.method + user_agent = request.META.get("HTTP_USER_AGENT", "") + + # Hash IP for privacy + ip = ErrorService._get_client_ip(request) + ip_address_hash = ApplicationError.hash_ip(ip) + + # Use request user if not provided + if user is None and hasattr(request, "user") and request.user.is_authenticated: + user = request.user + + # Capture additional request context for debugging + request_context = { + "content_type": request.content_type, + "query_string": request.META.get("QUERY_STRING", "")[:500], + "request_id": request.META.get("HTTP_X_REQUEST_ID", ""), + "accept_language": request.META.get("HTTP_ACCEPT_LANGUAGE", ""), + "referer": request.META.get("HTTP_REFERER", ""), + "origin": request.META.get("HTTP_ORIGIN", ""), + } + + # Capture request body snippet for POST/PUT/PATCH (sanitized) + if http_method in ("POST", "PUT", "PATCH"): + try: + body_snippet = request.body.decode("utf-8")[:1000] if request.body else "" + # Sanitize sensitive fields + for field in ("password", "token", "secret", "key", "auth"): + if field in body_snippet.lower(): + body_snippet = "[REDACTED - contains sensitive data]" + break + request_context["body_snippet"] = body_snippet + except Exception: + request_context["body_snippet"] = "[Could not decode body]" + + # Extract exception chain for comprehensive debugging + if isinstance(error, Exception): + cause_chain = [] + current_cause = error.__cause__ + depth = 0 + while current_cause and depth < 5: + cause_chain.append({ + "type": type(current_cause).__name__, + "message": str(current_cause)[:500], + }) + current_cause = current_cause.__cause__ + depth += 1 + if cause_chain: + request_context["exception_chain"] = cause_chain + + # Merge request_context into metadata + merged_metadata = {**(metadata or {}), "request_context": request_context} + + # Create and save error + app_error = ApplicationError.objects.create( + error_id=error_id or None, # Let model generate if not provided + error_type=error_type, + error_message=error_message[:5000], # Limit message length + error_stack=error_stack[:10000], # Limit stack length + error_code=error_code, + severity=severity, + source=source, + endpoint=endpoint, + http_method=http_method, + user_agent=user_agent[:1000], + user=user, + ip_address_hash=ip_address_hash, + metadata=merged_metadata, + environment=environment or {}, + ) + + logger.info( + f"Captured error {app_error.short_error_id}: {error_type} from {source}" + ) + + return app_error + + @staticmethod + def capture_frontend_error( + error_data: dict, + request: HttpRequest | None = None, + ) -> ApplicationError: + """ + Capture an error reported from the frontend. + + Args: + error_data: Dictionary containing error details from frontend + request: HTTP request for IP/user context + + Returns: + The created ApplicationError instance + """ + return ErrorService.capture_error( + error=error_data.get("error_message", "Unknown error"), + source="frontend", + request=request, + severity=error_data.get("severity", "medium"), + metadata=error_data.get("metadata", {}), + environment=error_data.get("environment", {}), + error_id=error_data.get("error_id"), + ) + + @staticmethod + def get_error_statistics(days: int = 7) -> dict: + """ + Get error statistics for the dashboard. + + Args: + days: Number of days to include in statistics + + Returns: + Dictionary containing error statistics + """ + cutoff = timezone.now() - timedelta(days=days) + base_queryset = ApplicationError.objects.filter(created_at__gte=cutoff) + + # Total errors + total_errors = base_queryset.count() + + # Errors by severity + severity_counts = dict( + base_queryset.values("severity") + .annotate(count=Count("id")) + .values_list("severity", "count") + ) + + # Errors by source + source_counts = dict( + base_queryset.values("source") + .annotate(count=Count("id")) + .values_list("source", "count") + ) + + # Top error types + error_types = list( + base_queryset.values("error_type") + .annotate(count=Count("id")) + .order_by("-count")[:10] + ) + + # Errors over time (daily) + errors_over_time = list( + base_queryset.annotate(date=TruncDate("created_at")) + .values("date") + .annotate(count=Count("id")) + .order_by("date") + ) + + # Convert dates to strings for JSON serialization + for item in errors_over_time: + item["date"] = item["date"].isoformat() if item["date"] else None + + # Resolution rate + resolved_count = base_queryset.filter(resolved=True).count() + resolution_rate = (resolved_count / total_errors * 100) if total_errors > 0 else 0 + + # Critical/unresolved counts for quick stats + critical_count = base_queryset.filter(severity="critical").count() + unresolved_count = base_queryset.filter(resolved=False).count() + + return { + "total_errors": total_errors, + "errors_by_severity": severity_counts, + "errors_by_source": source_counts, + "errors_by_type": error_types, + "errors_over_time": errors_over_time, + "resolution_rate": round(resolution_rate, 1), + "critical_count": critical_count, + "unresolved_count": unresolved_count, + "period_days": days, + } + + @staticmethod + def resolve_error( + error_id: UUID | int, + user: Any, + notes: str = "", + ) -> ApplicationError: + """ + Mark an error as resolved. + + Args: + error_id: UUID or database ID of the error + user: User marking the error as resolved + notes: Optional resolution notes + + Returns: + The updated ApplicationError instance + + Raises: + ApplicationError.DoesNotExist: If error not found + """ + if isinstance(error_id, int): + error = ApplicationError.objects.get(id=error_id) + else: + error = ApplicationError.objects.get(error_id=error_id) + + error.resolved = True + error.resolved_at = timezone.now() + error.resolved_by = user + error.resolution_notes = notes + error.save(update_fields=["resolved", "resolved_at", "resolved_by", "resolution_notes"]) + + logger.info( + f"Error {error.short_error_id} resolved by {user}" + ) + + return error + + @staticmethod + def cleanup_old_errors(days: int = 30) -> int: + """ + Delete errors older than specified days. + + Args: + days: Delete errors older than this many days + + Returns: + Number of errors deleted + """ + cutoff = timezone.now() - timedelta(days=days) + + # Only delete resolved errors by default, keep unresolved critical + deleted_count, _ = ApplicationError.objects.filter( + Q(created_at__lt=cutoff) & (Q(resolved=True) | ~Q(severity="critical")) + ).delete() + + logger.info( + f"Cleaned up {deleted_count} errors older than {days} days" + ) + + return deleted_count + + @staticmethod + def _get_client_ip(request: HttpRequest) -> str: + """Extract client IP from request, handling proxies.""" + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + if x_forwarded_for: + return x_forwarded_for.split(",")[0].strip() + return request.META.get("REMOTE_ADDR", "") diff --git a/backend/apps/core/services/trending_service.py b/backend/apps/core/services/trending_service.py index efc5789d..1378c571 100644 --- a/backend/apps/core/services/trending_service.py +++ b/backend/apps/core/services/trending_service.py @@ -20,6 +20,7 @@ from django.db.models import Q from django.utils import timezone from apps.core.analytics import PageView +from apps.core.utils import capture_and_log from apps.parks.models import Park from apps.rides.models import Ride @@ -105,7 +106,7 @@ class TrendingService: return formatted_results except Exception as e: - self.logger.error(f"Error getting trending content: {e}", exc_info=True) + capture_and_log(e, f'Get trending content ({content_type})', source='service') return [] def get_new_content( @@ -164,7 +165,7 @@ class TrendingService: return formatted_results except Exception as e: - self.logger.error(f"Error getting new content: {e}", exc_info=True) + capture_and_log(e, f'Get new content ({content_type})', source='service') return [] def _calculate_trending_parks(self, limit: int) -> list[dict[str, Any]]: @@ -311,7 +312,7 @@ class TrendingService: return final_score except Exception as e: - self.logger.error(f"Error calculating score for {content_type} {content_obj.id}: {e}") + capture_and_log(e, f'Calculate content score ({content_type} {content_obj.id})', source='service', severity='low') return 0.0 def _calculate_view_growth_score(self, content_type: ContentType, object_id: int) -> float: @@ -653,7 +654,7 @@ class TrendingService: self.logger.info(f"Cleared trending caches for {content_type}") except Exception as e: - self.logger.error(f"Error clearing cache: {e}") + capture_and_log(e, f'Clear trending cache ({content_type})', source='service', severity='low') # Singleton service instance diff --git a/backend/apps/core/tasks/trending.py b/backend/apps/core/tasks/trending.py index f2340b29..3f50dfd3 100644 --- a/backend/apps/core/tasks/trending.py +++ b/backend/apps/core/tasks/trending.py @@ -16,6 +16,7 @@ from django.db.models import Q from django.utils import timezone from apps.core.analytics import PageView +from apps.core.utils import capture_and_log from apps.parks.models import Park from apps.rides.models import Ride @@ -87,7 +88,7 @@ def calculate_trending_content(self, content_type: str = "all", limit: int = 50) } except Exception as e: - logger.error(f"Error calculating trending content: {e}", exc_info=True) + capture_and_log(e, f'Calculate trending content ({content_type})', source='task') # Retry the task raise self.retry(exc=e) from None @@ -141,7 +142,7 @@ def calculate_new_content(self, content_type: str = "all", days_back: int = 30, } except Exception as e: - logger.error(f"Error calculating new content: {e}", exc_info=True) + capture_and_log(e, f'Calculate new content ({content_type})', source='task') raise self.retry(exc=e) from None @@ -185,7 +186,7 @@ def warm_trending_cache(self) -> dict[str, Any]: } except Exception as e: - logger.error(f"Error warming trending cache: {e}", exc_info=True) + capture_and_log(e, 'Warm trending cache', source='task') return { "success": False, "error": str(e), @@ -309,7 +310,7 @@ def _calculate_content_score( return final_score except Exception as e: - logger.error(f"Error calculating score for {content_type} {content_obj.id}: {e}") + capture_and_log(e, f'Calculate content score ({content_type} {content_obj.id})', source='task', severity='low') return 0.0 diff --git a/backend/apps/core/urls/__init__.py b/backend/apps/core/urls/__init__.py index 4b413933..1a6f8e3b 100644 --- a/backend/apps/core/urls/__init__.py +++ b/backend/apps/core/urls/__init__.py @@ -43,4 +43,6 @@ urlpatterns = [ path("entities/", include(entity_patterns)), # FSM transition endpoints path("fsm/", include(fsm_patterns)), + # Error monitoring endpoints (API) + path("errors/", include("apps.core.urls.errors", namespace="errors")), ] diff --git a/backend/apps/core/urls/errors.py b/backend/apps/core/urls/errors.py new file mode 100644 index 00000000..eb605400 --- /dev/null +++ b/backend/apps/core/urls/errors.py @@ -0,0 +1,27 @@ +""" +URL configuration for error monitoring API. +""" + +from django.urls import path + +from apps.core.api.error_views import ( + ErrorCleanupView, + ErrorDetailView, + ErrorListView, + ErrorReportView, + ErrorResolveView, + ErrorStatisticsView, +) + +app_name = "errors" + +urlpatterns = [ + # Public endpoint (rate-limited) + path("report/", ErrorReportView.as_view(), name="report"), + # Admin endpoints + path("", ErrorListView.as_view(), name="list"), + path("statistics/", ErrorStatisticsView.as_view(), name="statistics"), + path("cleanup/", ErrorCleanupView.as_view(), name="cleanup"), + path("/", ErrorDetailView.as_view(), name="detail"), + path("/resolve/", ErrorResolveView.as_view(), name="resolve"), +] diff --git a/backend/apps/core/utils/__init__.py b/backend/apps/core/utils/__init__.py index 9a4e73f6..115fcb37 100644 --- a/backend/apps/core/utils/__init__.py +++ b/backend/apps/core/utils/__init__.py @@ -12,6 +12,11 @@ from .breadcrumbs import ( build_breadcrumb, get_model_breadcrumb, ) +from .capture_errors import ( + capture_and_log, + capture_errors, + error_context, +) from .messages import ( confirm_delete, error_network, @@ -47,6 +52,10 @@ __all__ = [ "breadcrumbs_to_schema", "build_breadcrumb", "get_model_breadcrumb", + # Error Capture + "capture_and_log", + "capture_errors", + "error_context", # Messages "confirm_delete", "error_network", @@ -73,3 +82,4 @@ __all__ = [ "get_og_image", "get_twitter_card_type", ] + diff --git a/backend/apps/core/utils/capture_errors.py b/backend/apps/core/utils/capture_errors.py new file mode 100644 index 00000000..222f2320 --- /dev/null +++ b/backend/apps/core/utils/capture_errors.py @@ -0,0 +1,219 @@ +""" +Error capture utilities: decorator and context manager. + +Provides ergonomic wrappers around ErrorService for easy error capture. + +Example usage: + # Decorator for functions/views + @capture_errors(source='api', severity='high') + def risky_view(request): + ... + + # Context manager for code blocks + with error_context('Processing payment', severity='critical'): + process_payment() + + # Context manager with request for full context + with error_context('Creating ride', request=request, entity_type='Ride'): + create_ride(data) +""" + +import functools +import logging +from contextlib import contextmanager +from typing import Any, Callable, TypeVar + +from django.http import HttpRequest + +from apps.core.services import ErrorService + +logger = logging.getLogger(__name__) + +F = TypeVar('F', bound=Callable[..., Any]) + + +def capture_errors( + source: str = 'backend', + severity: str = 'high', + reraise: bool = True, + log_errors: bool = True, +) -> Callable[[F], F]: + """ + Decorator that automatically captures exceptions to the error dashboard. + + Use this on views, service methods, or any function where you want + automatic error tracking. + + Args: + source: Error source - 'frontend', 'backend', or 'api' + severity: Default severity - 'critical', 'high', 'medium', 'low' + reraise: Whether to re-raise the exception after capturing + log_errors: Whether to also log to Python logger + + Returns: + Decorated function + + Example: + @capture_errors(source='api', severity='high') + def create_park(request, data): + # If this raises, error is automatically captured + return ParkService.create(data) + + @capture_errors(severity='critical', reraise=False) + def optional_cleanup(): + # Errors captured but swallowed + cleanup_temp_files() + """ + def decorator(func: F) -> F: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + # Try to extract request from args (common in Django views) + request = None + for arg in args: + if isinstance(arg, HttpRequest): + request = arg + break + if not request: + request = kwargs.get('request') + + try: + return func(*args, **kwargs) + except Exception as e: + if log_errors: + logger.exception(f"Error in {func.__name__}: {e}") + + # Capture to error dashboard + try: + ErrorService.capture_error( + error=e, + source=source, + request=request, + severity=severity, + metadata={ + 'function_name': func.__name__, + 'module': func.__module__, + }, + ) + except Exception as capture_error: + logger.error(f"Failed to capture error: {capture_error}") + + if reraise: + raise + + return wrapper # type: ignore + return decorator + + +@contextmanager +def error_context( + action: str, + source: str = 'backend', + severity: str = 'high', + request: HttpRequest | None = None, + entity_type: str | None = None, + entity_id: int | str | None = None, + reraise: bool = True, + metadata: dict | None = None, +): + """ + Context manager for capturing errors in code blocks. + + Use this when you want to capture errors from a specific block of code + with rich context about what was happening. + + Args: + action: Description of what the code block is doing + source: Error source - 'frontend', 'backend', or 'api' + severity: Error severity level + request: Optional HTTP request for context + entity_type: Optional entity type being operated on (e.g., 'Ride', 'Park') + entity_id: Optional entity ID being operated on + reraise: Whether to re-raise the exception after capturing + metadata: Additional metadata to include + + Yields: + None + + Example: + with error_context('Creating ride submission', request=request, entity_type='Ride'): + submission = SubmissionService.create(data) + + with error_context('Bulk import', severity='critical', reraise=False): + for item in items: + process_item(item) # Errors logged but processing continues + """ + try: + yield + except Exception as e: + logger.exception(f"Error during '{action}': {e}") + + # Build metadata + error_metadata = { + 'action': action, + **(metadata or {}), + } + if entity_type: + error_metadata['entity_type'] = entity_type + if entity_id: + error_metadata['entity_id'] = entity_id + + # Capture to error dashboard + try: + ErrorService.capture_error( + error=e, + source=source, + request=request, + severity=severity, + metadata=error_metadata, + ) + except Exception as capture_error: + logger.error(f"Failed to capture error: {capture_error}") + + if reraise: + raise + + +def capture_and_log( + error: Exception, + action: str, + source: str = 'backend', + severity: str = 'medium', + request: HttpRequest | None = None, + **kwargs: Any, +) -> str: + """ + One-liner function to capture an error and return its ID. + + Use this when you've already caught an exception and want to + report it without the decorator/context manager. + + Args: + error: The exception to capture + action: Description of what was happening + source: Error source + severity: Error severity level + request: Optional HTTP request for context + **kwargs: Additional metadata fields + + Returns: + The error_id (short UUID) for reference + + Example: + try: + result = risky_operation() + except Exception as e: + error_id = capture_and_log(e, 'Risky operation failed', severity='high') + return Response({'error': f'Failed (ref: {error_id})'}, status=500) + """ + try: + app_error = ErrorService.capture_error( + error=error, + source=source, + request=request, + severity=severity, + metadata={'action': action, **kwargs}, + ) + return app_error.short_error_id + except Exception as capture_error: + logger.error(f"Failed to capture error: {capture_error}") + return "unknown" diff --git a/backend/apps/core/utils/cloudflare.py b/backend/apps/core/utils/cloudflare.py index 187898ef..f96650f3 100644 --- a/backend/apps/core/utils/cloudflare.py +++ b/backend/apps/core/utils/cloudflare.py @@ -4,6 +4,8 @@ import requests from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from .capture_errors import capture_and_log + logger = logging.getLogger(__name__) @@ -47,7 +49,9 @@ def get_direct_upload_url(user_id=None): if not result.get("success"): error_msg = result.get("errors", [{"message": "Unknown error"}])[0].get("message") - logger.error(f"Cloudflare Direct Upload Error: {error_msg}") - raise requests.RequestException(f"Cloudflare Error: {error_msg}") + # Create error for capture + e = requests.RequestException(f"Cloudflare Error: {error_msg}") + capture_and_log(e, 'Cloudflare direct upload', source='service') + raise e return result.get("result", {}) diff --git a/backend/apps/core/views/map_views.py b/backend/apps/core/views/map_views.py index 2ec2a16e..cf6e310a 100644 --- a/backend/apps/core/views/map_views.py +++ b/backend/apps/core/views/map_views.py @@ -18,6 +18,7 @@ from django.views.decorators.gzip import gzip_page from ..services.data_structures import GeoBounds, LocationType, MapFilters from ..services.map_service import unified_map_service +from apps.core.utils import capture_and_log logger = logging.getLogger(__name__) @@ -51,10 +52,7 @@ class MapAPIView(View): return response except Exception as e: - logger.error( - f"API error in {request.path}: {str(e)}", - exc_info=True, - ) + capture_and_log(e, f'Map API dispatch {request.path}', source='api') return self._error_response("An internal server error occurred", status=500) def options(self, request, *args, **kwargs): @@ -373,7 +371,7 @@ class MapLocationsView(MapAPIView): logger.warning(f"Validation error in MapLocationsView: {str(e)}") return self._error_response(str(e), 400, error_code="VALIDATION_ERROR") except Exception as e: - logger.error(f"Error in MapLocationsView: {str(e)}", exc_info=True) + capture_and_log(e, 'MapLocationsView get', source='api') return self._error_response( "Failed to retrieve map locations", 500, @@ -433,10 +431,7 @@ class MapLocationDetailView(MapAPIView): logger.warning(f"Value error in MapLocationDetailView: {str(e)}") return self._error_response(str(e), 400, error_code="INVALID_PARAMETER") except Exception as e: - logger.error( - f"Error in MapLocationDetailView: {str(e)}", - exc_info=True, - ) + capture_and_log(e, 'MapLocationDetailView get', source='api') return self._error_response( "Failed to retrieve location details", 500, @@ -529,7 +524,7 @@ class MapSearchView(MapAPIView): logger.warning(f"Value error in MapSearchView: {str(e)}") return self._error_response(str(e), 400, error_code="INVALID_PARAMETER") except Exception as e: - logger.error(f"Error in MapSearchView: {str(e)}", exc_info=True) + capture_and_log(e, 'MapSearchView get', source='api') return self._error_response( "Search failed due to internal error", 500, diff --git a/backend/apps/parks/services/location_service.py b/backend/apps/parks/services/location_service.py index 1f944e50..859ddfbf 100644 --- a/backend/apps/parks/services/location_service.py +++ b/backend/apps/parks/services/location_service.py @@ -11,6 +11,7 @@ from django.core.cache import cache from django.db import transaction from ..models import ParkLocation +from apps.core.utils import capture_and_log logger = logging.getLogger(__name__) @@ -90,7 +91,7 @@ class ParkLocationService: return result_data except requests.RequestException as e: - logger.error(f"Error searching park locations: {str(e)}") + capture_and_log(e, 'Search park locations', source='service') return { "count": 0, "results": [], @@ -156,7 +157,7 @@ class ParkLocationService: return result except requests.RequestException as e: - logger.error(f"Error reverse geocoding park location: {str(e)}") + capture_and_log(e, 'Reverse geocode park location', source='service') return {"error": "Reverse geocoding service temporarily unavailable"} @classmethod diff --git a/backend/apps/parks/services/media_service.py b/backend/apps/parks/services/media_service.py index 10493e86..623cb3a2 100644 --- a/backend/apps/parks/services/media_service.py +++ b/backend/apps/parks/services/media_service.py @@ -12,6 +12,7 @@ from django.core.files.uploadedfile import UploadedFile from django.db import transaction from apps.core.services.media_service import MediaService +from apps.core.utils import capture_and_log from ..models import Park, ParkPhoto @@ -164,7 +165,7 @@ class ParkMediaService: logger.info(f"Photo {photo.pk} approved by user {approved_by.username}") return True except Exception as e: - logger.error(f"Failed to approve photo {photo.pk}: {str(e)}") + capture_and_log(e, f'Approve park photo {photo.pk}', source='service') return False @staticmethod @@ -191,7 +192,7 @@ class ParkMediaService: logger.info(f"Photo {photo_id} deleted from park {park_slug} by user {deleted_by.username}") return True except Exception as e: - logger.error(f"Failed to delete photo {photo.pk}: {str(e)}") + capture_and_log(e, f'Delete park photo {photo.pk}', source='service') return False @staticmethod diff --git a/backend/apps/parks/services/roadtrip.py b/backend/apps/parks/services/roadtrip.py index b6742c59..200c1fcc 100644 --- a/backend/apps/parks/services/roadtrip.py +++ b/backend/apps/parks/services/roadtrip.py @@ -23,6 +23,7 @@ from django.contrib.gis.measure import Distance from django.core.cache import cache from apps.parks.models import Park +from apps.core.utils import capture_and_log logger = logging.getLogger(__name__) @@ -242,7 +243,7 @@ class RoadTripService: return None except Exception as e: - logger.error(f"Geocoding failed for '{address}': {e}") + capture_and_log(e, f"Geocode address '{address}'", source='service') return None def calculate_route(self, start_coords: Coordinates, end_coords: Coordinates) -> RouteInfo | None: @@ -319,7 +320,7 @@ class RoadTripService: return self._calculate_straight_line_route(start_coords, end_coords) except Exception as e: - logger.error(f"Route calculation failed: {e}") + capture_and_log(e, 'Calculate route', source='service') # Fallback to straight-line distance return self._calculate_straight_line_route(start_coords, end_coords) @@ -445,7 +446,7 @@ class RoadTripService: return max(0, detour_distance) # Don't return negative detours except Exception as e: - logger.error(f"Failed to calculate detour distance: {e}") + capture_and_log(e, 'Calculate detour distance', source='service', severity='low') return None def create_multi_park_trip(self, park_list: list["Park"]) -> RoadTrip | None: diff --git a/backend/apps/rides/services/media_service.py b/backend/apps/rides/services/media_service.py index 2cebe014..8d34b1fc 100644 --- a/backend/apps/rides/services/media_service.py +++ b/backend/apps/rides/services/media_service.py @@ -12,6 +12,7 @@ from django.core.files.uploadedfile import UploadedFile from django.db import transaction from apps.core.services.media_service import MediaService +from apps.core.utils import capture_and_log from ..models import Ride, RidePhoto @@ -190,7 +191,7 @@ class RideMediaService: logger.info(f"Photo {photo.pk} approved by user {approved_by.username}") return True except Exception as e: - logger.error(f"Failed to approve photo {photo.pk}: {str(e)}") + capture_and_log(e, f'Approve ride photo {photo.pk}', source='service') return False @staticmethod @@ -217,7 +218,7 @@ class RideMediaService: logger.info(f"Photo {photo_id} deleted from ride {ride_slug} by user {deleted_by.username}") return True except Exception as e: - logger.error(f"Failed to delete photo {photo.pk}: {str(e)}") + capture_and_log(e, f'Delete ride photo {photo.pk}', source='service') return False @staticmethod diff --git a/backend/apps/rides/services/ranking_service.py b/backend/apps/rides/services/ranking_service.py index 7d277afb..bbe8f56f 100644 --- a/backend/apps/rides/services/ranking_service.py +++ b/backend/apps/rides/services/ranking_service.py @@ -21,6 +21,7 @@ from apps.rides.models import ( RideRanking, RideReview, ) +from apps.core.utils import capture_and_log logger = logging.getLogger(__name__) @@ -96,7 +97,7 @@ class RideRankingService: } except Exception as e: - self.logger.error(f"Error updating rankings: {e}", exc_info=True) + capture_and_log(e, 'Update ride rankings', source='service') raise def _get_eligible_rides(self, category: str | None = None) -> list[Ride]: diff --git a/backend/apps/rides/services/status_service.py b/backend/apps/rides/services/status_service.py index 80401c38..4e62ae7e 100644 --- a/backend/apps/rides/services/status_service.py +++ b/backend/apps/rides/services/status_service.py @@ -6,6 +6,7 @@ Following Django styleguide pattern for business logic encapsulation. from django.contrib.auth.models import AbstractBaseUser from django.db import transaction +from apps.core.utils import capture_and_log from apps.rides.models import Ride @@ -191,14 +192,8 @@ class RideStatusService: ride.apply_post_closing_status() transitioned_rides.append(ride) except Exception as e: - # Log error but continue processing other rides - import logging - - logger = logging.getLogger(__name__) - logger.error( - f"Failed to process closing ride {ride.id}: {e}", - exc_info=True, - ) + # Capture error to dashboard but continue processing other rides + capture_and_log(e, f'Process closing ride {ride.id}', source='service') continue return transitioned_rides diff --git a/backend/apps/rides/signals.py b/backend/apps/rides/signals.py index 047b3287..cafb5d48 100644 --- a/backend/apps/rides/signals.py +++ b/backend/apps/rides/signals.py @@ -4,6 +4,8 @@ from django.db.models.signals import post_save, pre_save from django.dispatch import receiver from django.utils import timezone +from apps.core.utils import capture_and_log + from .models import Ride logger = logging.getLogger(__name__) @@ -126,7 +128,13 @@ def handle_ride_transition_to_closing(instance, source, target, user, **kwargs): return True if not instance.post_closing_status: - logger.error(f"Cannot transition ride {instance.pk} to CLOSING: " "post_closing_status not set") + # Capture to dashboard as a validation error + capture_and_log( + ValueError('post_closing_status not set for CLOSING transition'), + f'Ride transition to CLOSING for ride {instance.pk}', + source='signal', + severity='medium', + ) return False if not instance.closing_date: diff --git a/backend/apps/rides/tasks.py b/backend/apps/rides/tasks.py index 24321575..f1d146eb 100644 --- a/backend/apps/rides/tasks.py +++ b/backend/apps/rides/tasks.py @@ -12,6 +12,8 @@ from django.contrib.auth import get_user_model from django.db import transaction from django.utils import timezone +from apps.core.utils import capture_and_log + logger = logging.getLogger(__name__) User = get_user_model() @@ -59,12 +61,10 @@ def check_overdue_closings() -> dict: failed += 1 error_msg = f"Ride {ride.id} ({ride.name}): {str(e)}" failures.append(error_msg) - logger.error( - "Failed to transition ride %s (%s): %s", - ride.id, - ride.name, - str(e), - exc_info=True, + capture_and_log( + e, + f'Transition closing ride {ride.id} ({ride.name})', + source='task', ) result = { diff --git a/backend/config/django/base.py b/backend/config/django/base.py index a1083874..771637f5 100644 --- a/backend/config/django/base.py +++ b/backend/config/django/base.py @@ -132,6 +132,7 @@ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "apps.core.middleware.security_headers.SecurityHeadersMiddleware", # Custom security headers "apps.core.middleware.rate_limiting.AuthRateLimitMiddleware", # Rate limiting + "apps.core.middleware.error_capture.ErrorCaptureMiddleware", # Error capture for monitoring "whitenoise.middleware.WhiteNoiseMiddleware", "apps.core.middleware.performance_middleware.PerformanceMiddleware", # Performance monitoring "apps.core.middleware.performance_middleware.QueryCountMiddleware", # Database query monitoring diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md new file mode 100644 index 00000000..c2df2bb3 --- /dev/null +++ b/docs/ERROR_HANDLING.md @@ -0,0 +1,308 @@ +# Error Handling Guide + +This guide covers ThrillWiki's comprehensive error handling system for both the Django backend and Nuxt frontend. + +## Overview + +The error system captures errors from all sources (frontend, backend, API) and displays them in the admin dashboard at `/admin/errors`. + +--- + +## Quick Start + +### Backend (Python) + +```python +from apps.core.utils import capture_errors, error_context, capture_and_log + +# Option 1: Decorator on functions/views +@capture_errors(severity='high') +def create_park(request, data): + return ParkService.create(data) # Errors auto-captured + +# Option 2: Context manager for code blocks +with error_context('Processing payment', severity='critical'): + process_payment() + +# Option 3: Manual capture +try: + risky_operation() +except Exception as e: + error_id = capture_and_log(e, 'Risky operation failed') + return Response({'error': f'Failed (ref: {error_id})'}) +``` + +### Frontend (TypeScript) + +```typescript +// Option 1: useErrorBoundary composable +const { wrap, error, isError, retry } = useErrorBoundary({ + componentName: 'RideDetail' +}) +const ride = await wrap(() => api.get('/rides/1'), 'Loading ride') + +// Option 2: tryCatch utility (Go-style) +const [data, err] = await tryCatch(fetchRide(id), 'Fetching ride') +if (err) return // Error already reported + +// Option 3: useReportError (manual) +const { reportError } = useReportError() +try { + await riskyOperation() +} catch (e) { + reportError(e, { action: 'Risky operation' }) +} +``` + +--- + +## Backend Utilities + +### `@capture_errors` Decorator + +Automatically captures exceptions from decorated functions. + +```python +from apps.core.utils import capture_errors + +@capture_errors( + source='api', # 'frontend', 'backend', or 'api' + severity='high', # 'critical', 'high', 'medium', 'low' + reraise=True, # Re-raise after capturing (default True) + log_errors=True # Also log to Python logger (default True) +) +def my_view(request): + # If this raises, error is automatically captured + return do_something() +``` + +**Use for**: API views, service methods, any function where you want automatic tracking. + +### `error_context` Context Manager + +Captures errors from a code block with rich context. + +```python +from apps.core.utils import error_context + +with error_context( + 'Creating ride submission', + source='backend', + severity='high', + request=request, # Optional: for user/IP context + entity_type='Ride', # Optional: what entity + entity_id=123, # Optional: which entity + reraise=True # Default True +): + submission = SubmissionService.create(data) +``` + +**Use for**: Specific operations where you want detailed context about what was happening. + +### `capture_and_log` Function + +One-liner for manual error capture that returns the error ID. + +```python +from apps.core.utils import capture_and_log + +try: + result = risky_operation() +except Exception as e: + error_id = capture_and_log( + e, + 'Risky operation', + severity='high', + request=request, + entity_type='Park', + entity_id=42 + ) + return Response({'error': f'Failed (ref: {error_id})'}, status=500) +``` + +**Use for**: When you need the error ID to show to users for support reference. + +--- + +## Frontend Utilities + +### `useErrorBoundary` Composable + +Component-level error boundary with retry support. + +```typescript +const { + error, // Ref - current error + isError, // ComputedRef - whether error exists + lastErrorId, // Ref - for support reference + clear, // () => void - clear error state + retry, // () => Promise - retry last operation + wrap, // Wrap async function + wrapSync // Wrap sync function +} = useErrorBoundary({ + componentName: 'MyComponent', + entityType: 'Ride', + defaultSeverity: 'medium', + showToast: true, + onError: (err, id) => console.log('Error:', err) +}) + +// In template +//
+// {{ error?.message }} +// +//
+``` + +### `tryCatch` Utility + +Go-style error handling for one-liners. + +```typescript +import { tryCatch, tryCatchSync } from '~/utils/tryCatch' + +// Async +const [ride, error] = await tryCatch(api.get('/rides/1'), 'Loading ride') +if (error) { + console.log('Failed:', error.message) + return +} +console.log('Got ride:', ride) + +// Sync +const [data, err] = tryCatchSync(() => JSON.parse(input), 'Parsing JSON') + +// With options +const [data, err] = await tryCatch(fetchData(), 'Fetching', { + severity: 'critical', + silent: true, // No toast notification + entityType: 'Ride', + entityId: 123 +}) +``` + +### `useReportError` Composable + +Full-featured error reporting with maximum context. + +```typescript +const { reportError, reportErrorSilent, withErrorReporting } = useReportError() + +// Report with toast +await reportError(error, { + action: 'Saving ride', + componentName: 'EditRideModal', + entityType: 'Ride', + entityId: 42, + severity: 'high', + metadata: { custom: 'data' } +}) + +// Report without toast +await reportErrorSilent(error, { action: 'Background task' }) + +// Wrap a function +const safeFetch = withErrorReporting(fetchRide, { + componentName: 'RideDetail', + entityType: 'Ride' +}) +``` + +### Global Error Plugin + +The `errorPlugin.client.ts` automatically catches: +- Vue component errors +- Unhandled promise rejections +- Global JavaScript errors + +These are reported silently to the dashboard without user intervention. + +--- + +## Viewing Errors + +1. Navigate to `/admin/errors` (requires admin role) +2. Filter by severity, source, date range, or resolution status +3. Click on an error to see full details including: + - Stack trace + - Browser environment + - User context + - Request details +4. Mark errors as resolved with notes +5. Export errors to CSV + +--- + +## Best Practices + +1. **Use decorators for views**: All API viewsets should have `@capture_errors` + +2. **Use context managers for critical operations**: Payments, data migrations, bulk operations + +3. **Use `tryCatch` for async code**: Clean, Go-style error handling + +4. **Set appropriate severity**: + - `critical`: Database errors, payment failures, data loss + - `high`: Unexpected runtime errors, 5xx responses + - `medium`: Validation errors, user input issues + - `low`: Warnings, graceful degradation + +5. **Include entity context**: Always provide `entity_type` and `entity_id` when operating on specific records + +6. **Don't swallow errors**: Use `reraise=True` (default) unless you have a recovery strategy + +--- + +## Integration Patterns + +### API ViewSet with Decorator + +```python +from rest_framework import viewsets +from apps.core.utils import capture_errors + +class RideViewSet(viewsets.ModelViewSet): + @capture_errors(source='api') + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) +``` + +### Vue Component with Error Boundary + +```vue + + + +``` + +--- + +## Troubleshooting + +**Errors not appearing in dashboard?** +- Check the backend server logs for capture failures +- Verify `MIDDLEWARE` includes `ErrorCaptureMiddleware` +- Ensure the `ApplicationError` model is migrated + +**Frontend errors not reporting?** +- Check browser console for API call failures +- Verify `.env` has correct `NUXT_PUBLIC_API_BASE` (port 8000) +- Check network tab for `/api/v1/errors/report/` requests diff --git a/source_docs/AGENT_RULES.md b/source_docs/AGENT_RULES.md index 5017c427..b3cbb644 100644 --- a/source_docs/AGENT_RULES.md +++ b/source_docs/AGENT_RULES.md @@ -94,6 +94,85 @@ For every versioned entity, verify: --- +## 2. Error Handling MUST Use Capture Utilities + +> [!CAUTION] +> **NON-NEGOTIABLE REQUIREMENT** + +All error-prone code on both backend AND frontend MUST use the error capture utilities. Errors should flow to the admin dashboard (`/admin/errors`) for monitoring. + +### Backend Requirements + +Use the utilities from `apps.core.utils`: + +```python +from apps.core.utils import capture_errors, error_context, capture_and_log + +# ✅ REQUIRED: Decorator on views and critical functions +@capture_errors(source='api', severity='high') +def create_park(request, data): + return ParkService.create(data) + +# ✅ REQUIRED: Context manager for critical operations +with error_context('Processing payment', severity='critical'): + process_payment() + +# ✅ ACCEPTABLE: Manual capture when you need the error ID +except Exception as e: + error_id = capture_and_log(e, 'Operation failed', severity='high') +``` + +### Frontend Requirements + +Use the composables and utilities: + +```typescript +// ✅ REQUIRED: Component-level error boundary +const { wrap, error, retry } = useErrorBoundary({ componentName: 'RideCard' }) +const ride = await wrap(() => api.get('/rides/1'), 'Loading ride') + +// ✅ REQUIRED: tryCatch for async operations +const [data, error] = await tryCatch(fetchData(), 'Fetching data') + +// ✅ REQUIRED: Report caught errors +const { reportError } = useReportError() +try { ... } catch (e) { reportError(e, { action: 'Operation' }) } +``` + +### What Is FORBIDDEN + +```python +# ❌ FORBIDDEN: Silent exception swallowing +except Exception: + pass # VIOLATION! Error lost forever + +# ❌ FORBIDDEN: Logging without dashboard capture +except Exception as e: + logger.error(e) # VIOLATION! Not visible in dashboard +``` + +```typescript +// ❌ FORBIDDEN: Silent catch +catch (e) { console.error(e) } // VIOLATION! Not in dashboard + +// ❌ FORBIDDEN: Uncaptured async errors +await riskyOperation() // VIOLATION! No error handling +``` + +### Compliance Checklist + +- [ ] All API views decorated with `@capture_errors` +- [ ] All critical service methods use `error_context` +- [ ] All frontend async operations use `tryCatch` or `useErrorBoundary` +- [ ] No silent exception swallowing (`except: pass`) +- [ ] All caught exceptions reported via utilities + +### Documentation + +Full usage guide: [docs/ERROR_HANDLING.md](file:///Volumes/macminissd/Projects/thrillwiki_django_no_react/docs/ERROR_HANDLING.md) + +--- + ## Document Authority This document has the same authority as all other `source_docs/` files. Per the `/comply` workflow, these specifications are **immutable law** and must be enforced immediately upon detection of any violation.