diff --git a/kolibri/__init__.py b/kolibri/__init__.py index 8aa9879b173..6c7b49bf316 100755 --- a/kolibri/__init__.py +++ b/kolibri/__init__.py @@ -27,6 +27,7 @@ "kolibri.plugins.demo_server", "kolibri.plugins.device", "kolibri.plugins.epub_viewer", + "kolibri.plugins.error_reports", "kolibri.plugins.html5_viewer", "kolibri.plugins.facility", "kolibri.plugins.learn", diff --git a/kolibri/core/analytics/tasks.py b/kolibri/core/analytics/tasks.py index afbcdb01877..4864e361592 100644 --- a/kolibri/core/analytics/tasks.py +++ b/kolibri/core/analytics/tasks.py @@ -7,6 +7,7 @@ from kolibri.core.analytics.utils import DEFAULT_SERVER_URL from kolibri.core.analytics.utils import ping_once +from kolibri.core.error_reports.tasks import ping_error_reports from kolibri.core.tasks.decorators import register_task from kolibri.core.tasks.exceptions import JobRunning from kolibri.core.tasks.main import job_storage @@ -24,7 +25,9 @@ @register_task(job_id=DEFAULT_PING_JOB_ID) def _ping(started, server, checkrate): try: - ping_once(started, server=server) + pingback_id = ping_once(started, server=server) + if pingback_id: + ping_error_reports.enqueue(args=(server, pingback_id)) except ConnectionError: logger.warning( "Ping failed (could not connect). Trying again in {} minutes.".format( diff --git a/kolibri/core/analytics/utils.py b/kolibri/core/analytics/utils.py index d08d5be7298..c5db074f617 100644 --- a/kolibri/core/analytics/utils.py +++ b/kolibri/core/analytics/utils.py @@ -471,3 +471,4 @@ def ping_once(started, server=DEFAULT_SERVER_URL): if "id" in data: stat_data = perform_statistics(server, data["id"]) create_and_update_notifications(stat_data, nutrition_endpoints.STATISTICS) + return data["id"] diff --git a/kolibri/core/assets/src/utils/browserInfo.js b/kolibri/core/assets/src/utils/browserInfo.js index 516024db0fe..52bc9d5bfd0 100644 --- a/kolibri/core/assets/src/utils/browserInfo.js +++ b/kolibri/core/assets/src/utils/browserInfo.js @@ -64,6 +64,13 @@ export const os = { patch: osVersion[2], }; +// Device info +export const device = { + type: info.device.type || 'desktop', + model: info.device.model, + vendor: info.device.vendor, +}; + // Check for presence of the touch event in DOM or multi-touch capabilities export const isTouchDevice = 'ontouchstart' in window || diff --git a/kolibri/core/assets/src/utils/errorReportUtils.js b/kolibri/core/assets/src/utils/errorReportUtils.js new file mode 100644 index 00000000000..5b47b817b67 --- /dev/null +++ b/kolibri/core/assets/src/utils/errorReportUtils.js @@ -0,0 +1,66 @@ +import { browser, os, device, isTouchDevice } from './browserInfo'; + +class ErrorReport { + constructor(e) { + this.e = e; + this.context = this.getContext(); + } + + getErrorReport() { + throw new Error('getErrorReport() method must be implemented.'); + } + + getContext() { + return { + browser: browser, + os: os, + device: { + ...device, + is_touch_device: isTouchDevice, + screen: { + width: window.screen.width, + height: window.screen.height, + available_width: window.screen.availWidth, + available_height: window.screen.availHeight, + }, + }, + }; + } +} + +export class VueErrorReport extends ErrorReport { + constructor(e, vm) { + super(e); + this.vm = vm; + } + getErrorReport() { + return { + error_message: this.e.message, + traceback: this.e.stack, + context: { + ...this.context, + component: this.vm.$options.name || this.vm.$options._componentTag || 'Unknown Component', + }, + }; + } +} + +export class JavascriptErrorReport extends ErrorReport { + getErrorReport() { + return { + error_message: this.e.error.message, + traceback: this.e.error.stack, + context: this.context, + }; + } +} + +export class UnhandledRejectionErrorReport extends ErrorReport { + getErrorReport() { + return { + error_message: this.e.reason.message, + traceback: this.e.reason.stack, + context: this.context, + }; + } +} diff --git a/kolibri/core/error_reports/__init__.py b/kolibri/core/error_reports/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/kolibri/core/error_reports/apps.py b/kolibri/core/error_reports/apps.py new file mode 100644 index 00000000000..64bd043a1ad --- /dev/null +++ b/kolibri/core/error_reports/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class KolibriErrorConfig(AppConfig): + name = "kolibri.core.error_reports" + label = "error_reports" + verbose_name = "Kolibri Error Reports" diff --git a/kolibri/core/error_reports/constants.py b/kolibri/core/error_reports/constants.py new file mode 100644 index 00000000000..1042ac7c300 --- /dev/null +++ b/kolibri/core/error_reports/constants.py @@ -0,0 +1,9 @@ +FRONTEND = "frontend" +BACKEND = "backend" +TASK = "task" + +POSSIBLE_ERRORS = [ + (FRONTEND, "Frontend"), + (BACKEND, "Backend"), + (TASK, "Task"), +] diff --git a/kolibri/core/error_reports/middleware.py b/kolibri/core/error_reports/middleware.py new file mode 100644 index 00000000000..ddafef4d397 --- /dev/null +++ b/kolibri/core/error_reports/middleware.py @@ -0,0 +1,108 @@ +import logging +import time +import traceback +from sys import version_info + +if version_info < (3, 10): + from importlib_metadata import distributions +else: + from importlib.metadata import distributions + +from django.core.exceptions import MiddlewareNotUsed +from django.core.exceptions import ValidationError +from django.db import IntegrityError + +from .constants import BACKEND +from .models import ErrorReport + +from kolibri.plugins.error_reports.kolibri_plugin import ErrorReportsPlugin +from kolibri.plugins.registry import registered_plugins + + +def get_request_info(request): + # checked the codebase and found these are the sensitive headers + request_headers = dict(request.headers) + request_headers.pop("X-Csrftoken", None) + request_headers.pop("Cookie", None) + + request_get = dict(request.GET) + request_get.pop("token", None) + + return { + "url": request.build_absolute_uri(), + "method": request.method, + "headers": request_headers, + "body": request.body.decode("utf-8"), + "query_params": request_get, + } + + +def get_server_info(request): + return {"host": request.get_host(), "port": request.get_port()} + + +def get_packages(): + packages = [f"{dist.metadata['Name']}=={dist.version}" for dist in distributions()] + return packages + + +def get_python_version(): + return ".".join(str(v) for v in version_info[:3]) + + +def get_request_time_to_error(request): + return time.time() - request.start_time + + +class ErrorReportingMiddleware: + """ + Middleware to log exceptions to the database. + """ + + def __init__(self, get_response): + if ErrorReportsPlugin not in registered_plugins: + raise MiddlewareNotUsed("ErrorReportsPlugin is not enabled.") + self.get_response = get_response + self.logger = logging.getLogger(__name__) + + def __call__(self, request): + response = self.get_response(request) + return response + + def process_exception(self, request, exception): + error_message = str(exception) + traceback_info = traceback.format_exc() + context = { + "request_info": get_request_info(request), + "server": get_server_info(request), + "packages": get_packages(), + "python_version": get_python_version(), + "avg_request_time_to_error": get_request_time_to_error(request), + } + self.logger.error("Unexpected Error: %s", error_message) + try: + self.logger.error("Saving error report to the database.") + ErrorReport.insert_or_update_error( + BACKEND, + error_message, + traceback_info, + context, + ) + except (IntegrityError, ValidationError) as e: + self.logger.error( + "Error occurred while saving error report to the database: %s", str(e) + ) + + +class PreRequestMiddleware: + def __init__(self, get_response): + if ErrorReportsPlugin not in registered_plugins: + raise MiddlewareNotUsed("ErrorReportsPlugin is not enabled.") + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + return response + + def process_view(self, request, view_func, view_args, view_kwargs): + request.start_time = time.time() diff --git a/kolibri/core/error_reports/migrations/0001_initial.py b/kolibri/core/error_reports/migrations/0001_initial.py new file mode 100644 index 00000000000..b1feb4e183a --- /dev/null +++ b/kolibri/core/error_reports/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 3.2.25 on 2024-09-26 01:14 +import django.utils.timezone +from django.db import migrations +from django.db import models + +import kolibri.core.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="ErrorReport", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "category", + models.CharField( + choices=[ + ("frontend", "Frontend"), + ("backend", "Backend"), + ("task", "Task"), + ], + max_length=10, + ), + ), + ("error_message", models.CharField(max_length=255)), + ("traceback", models.TextField()), + ( + "first_occurred", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "last_occurred", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("reported", models.BooleanField(default=False)), + ("events", models.IntegerField(default=1)), + ("context", kolibri.core.fields.JSONField(blank=True, null=True)), + ], + ), + ] diff --git a/kolibri/core/error_reports/migrations/__init__.py b/kolibri/core/error_reports/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/kolibri/core/error_reports/models.py b/kolibri/core/error_reports/models.py new file mode 100644 index 00000000000..190b017de38 --- /dev/null +++ b/kolibri/core/error_reports/models.py @@ -0,0 +1,110 @@ +import logging + +from django.conf import settings +from django.db import models +from django.utils import timezone + +from .constants import POSSIBLE_ERRORS +from .schemas import SCHEMA_MAP +from kolibri.core.fields import JSONField +from kolibri.core.utils.validators import JSON_Schema_Validator +from kolibri.deployment.default.sqlite_db_names import ERROR_REPORTS + + +logger = logging.getLogger(__name__) + + +class ErrorReportsRouter(object): + """ + Determine how to route database calls for the ErrorReports app. + """ + + def db_for_read(self, model, **hints): + if model._meta.app_label == "error_reports": + return ERROR_REPORTS + return None + + def db_for_write(self, model, **hints): + if model._meta.app_label == "error_reports": + return ERROR_REPORTS + return None + + def allow_relation(self, obj1, obj2, **hints): + if ( + obj1._meta.app_label == "error_reports" + and obj2._meta.app_label == "error_reports" + ): + return True + elif "error_reports" not in [obj1._meta.app_label, obj2._meta.app_label]: + return None + + return False + + def allow_migrate(self, db, app_label, model_name=None, **hints): + if app_label == "error_reports": + return db == ERROR_REPORTS + elif db == ERROR_REPORTS: + return False + + return None + + +class ErrorReport(models.Model): + category = models.CharField(max_length=10, choices=POSSIBLE_ERRORS) + error_message = models.CharField(max_length=255) + traceback = models.TextField() + first_occurred = models.DateTimeField(default=timezone.now) + last_occurred = models.DateTimeField(default=timezone.now) + reported = models.BooleanField(default=False) + events = models.IntegerField(default=1) + context = JSONField( + null=True, + blank=True, + ) + + def __str__(self): + return f"{self.error_message} ({self.category})" + + def clean(self): + schema = SCHEMA_MAP.get(self.category, None) + if schema is None: + raise ValueError("Category not found in SCHEMA_MAP") + JSON_Schema_Validator(schema)(self.context) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + @classmethod + def insert_or_update_error(cls, category, error_message, traceback, context): + if getattr(settings, "DEVELOPER_MODE", False): + logger.info("ErrorReport: Database not updated, as DEVELOPER_MODE is True.") + return + error_report, created = cls.objects.get_or_create( + category=category, + error_message=error_message, + traceback=traceback, + defaults={"context": context}, + ) + if not created: + error_report.events += 1 + error_report.last_occurred = timezone.now() + if error_report.context.get("avg_request_time_to_error", None): + context["avg_request_time_to_error"] = ( + error_report.context["avg_request_time_to_error"] + * (error_report.events - 1) + + context["avg_request_time_to_error"] + ) / error_report.events + error_report.context = context + + error_report.save() + logger.error("ErrorReport: Database updated.") + return error_report + + @classmethod + def get_unreported_errors(cls): + return cls.objects.filter(reported=False) + + @classmethod + def delete_error(cls): + pass diff --git a/kolibri/core/error_reports/schemas.py b/kolibri/core/error_reports/schemas.py new file mode 100644 index 00000000000..4210d40983a --- /dev/null +++ b/kolibri/core/error_reports/schemas.py @@ -0,0 +1,108 @@ +from .constants import BACKEND +from .constants import FRONTEND +from .constants import TASK + + +context_frontend_schema = { + "type": "object", + "definitions": { + "versionInfo": { + "type": "object", + "properties": { + "name": {"type": "string", "optional": True}, + "major": {"type": "string", "optional": True}, + "minor": {"type": "string", "optional": True}, + "patch": {"type": "string", "optional": True}, + }, + } + }, + "properties": { + "browser": { + "$ref": "#/definitions/versionInfo", + }, + "component": {"type": "string", "optional": True}, + "os": { + "$ref": "#/definitions/versionInfo", + }, + "device": { + "type": "object", + "properties": { + "model": {"type": "string", "optional": True}, + "type": {"type": "string", "optional": True}, + "vendor": {"type": "string", "optional": True}, + "is_touch_device": {"type": "boolean", "optional": True}, + "screen": { + "type": "object", + "properties": { + "width": {"type": "integer", "optional": True}, + "height": {"type": "integer", "optional": True}, + "available_width": {"type": "integer", "optional": True}, + "available_height": {"type": "integer", "optional": True}, + }, + }, + }, + }, + }, +} +context_backend_schema = { + "type": "object", + "properties": { + "request_info": { + "type": "object", + "properties": { + "url": {"type": "string", "optional": True}, + "method": {"type": "string", "optional": True}, + "headers": {"type": "object", "optional": True}, + "body": {"type": "string", "optional": True}, + "query_params": {"type": "object", "optional": True}, + }, + }, + "server": { + "type": "object", + "properties": { + "host": {"type": "string", "optional": True}, + "port": {"type": "string", "optional": True}, + }, + }, + "packages": {"type": "array", "optional": True}, + "python_version": {"type": "string", "optional": True}, + "avg_request_time_to_error": {"type": "number", "optional": True}, + }, +} + +context_task_schema = { + "type": "object", + "properties": { + "job_info": { + "type": "object", + "properties": { + "job_id": {"type": "string", "optional": True}, + "func": {"type": "string", "optional": True}, + "facility_id": {"type": ["string", "null"], "optional": True}, + "args": {"type": "array", "optional": True}, + "kwargs": {"type": "object", "optional": True}, + "progress": {"type": "integer", "optional": True}, + "total_progress": {"type": "integer", "optional": True}, + "extra_metadata": {"type": "object", "optional": True}, + }, + }, + "worker_info": { + "type": "object", + "properties": { + "worker_host": {"type": ["string", "null"], "optional": True}, + "worker_process": {"type": ["string", "null"], "optional": True}, + "worker_thread": {"type": ["string", "null"], "optional": True}, + "worker_extra": {"type": ["string", "null"], "optional": True}, + }, + }, + "packages": {"type": "array", "optional": True}, + "python_version": {"type": "string", "optional": True}, + }, +} + + +SCHEMA_MAP = { + FRONTEND: context_frontend_schema, + BACKEND: context_backend_schema, + TASK: context_task_schema, +} diff --git a/kolibri/core/error_reports/tasks.py b/kolibri/core/error_reports/tasks.py new file mode 100644 index 00000000000..c94d442791b --- /dev/null +++ b/kolibri/core/error_reports/tasks.py @@ -0,0 +1,61 @@ +import json +import logging + +import requests +from django.core.serializers.json import DjangoJSONEncoder +from django.db import connection +from requests.exceptions import ConnectionError +from requests.exceptions import RequestException +from requests.exceptions import Timeout + +from .models import ErrorReport +from kolibri.core.tasks.decorators import register_task +from kolibri.core.utils.urls import join_url + +logger = logging.getLogger(__name__) + + +def serialize_error_reports_to_json_response(errors, pingback_id): + errors_list = [] + for error in errors: + errors_list.append( + { + "category": error.category, + "error_message": error.error_message, + "traceback": error.traceback, + "first_occurred": error.first_occurred, + "last_occurred": error.last_occurred, + "events": error.events, + "context": error.context, + "pingback_id": pingback_id, + } + ) + return json.dumps(errors_list, cls=DjangoJSONEncoder) + + +@register_task +def ping_error_reports(server, pingback_id): + try: + errors = ErrorReport.get_unreported_errors() + + errors_json = serialize_error_reports_to_json_response(errors, pingback_id) + + requests.post( + join_url(server, "/api/v1/errors/report/"), + data=errors_json, + headers={"Content-Type": "application/json"}, + ) + + errors.update(reported=True) + + except ConnectionError: + logger.warning("Reporting Error failed (could not connect).") + raise + except Timeout: + logger.warning("Reporting Error failed (connection timed out).") + raise + except RequestException as e: + logger.warning("Reporting Error failed ({})!".format(e)) + raise + finally: + connection.close() diff --git a/kolibri/core/error_reports/test/__init__.py b/kolibri/core/error_reports/test/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/kolibri/core/error_reports/test/test_middleware.py b/kolibri/core/error_reports/test/test_middleware.py new file mode 100644 index 00000000000..bfa4a345ef0 --- /dev/null +++ b/kolibri/core/error_reports/test/test_middleware.py @@ -0,0 +1,87 @@ +import logging +import traceback +from unittest.mock import patch + +from django.db import IntegrityError +from django.test import RequestFactory +from django.test import TestCase + +from ..constants import BACKEND +from ..middleware import ErrorReportingMiddleware +from ..models import ErrorReport + + +class ErrorReportingMiddlewareTestCase(TestCase): + def setUp(self): + self.factory = RequestFactory() + + @patch( + "kolibri.core.error_reports.middleware.get_request_time_to_error", + return_value=0.0, + ) + @patch( + "kolibri.core.error_reports.middleware.get_python_version", return_value="3.9.9" + ) + @patch( + "kolibri.core.error_reports.middleware.get_packages", + return_value=["Django==3.2.25"], + ) + @patch.object(ErrorReport, "insert_or_update_error") + @patch.object(logging.Logger, "error") + def test_process_exception( + self, + mock_logger_error, + mock_insert_or_update_error, + mock_get_packages, + mock_get_python_version, + mock_get_request_time_to_error, + ): + middleware = ErrorReportingMiddleware(lambda r: None) + request = self.factory.get("/") + exception = Exception("Test Exception") + try: + raise exception + except Exception as e: + middleware.process_exception(request, exception=e) + # I am just coverting exception.__traceback__ to string + expected_traceback_info = "".join( + traceback.format_exception( + type(exception), exception, exception.__traceback__ + ) + ) + + mock_insert_or_update_error.assert_called_once_with( + BACKEND, + str(exception), + expected_traceback_info, + { + "request_info": { + "url": "http://testserver/", + "method": "GET", + "headers": {}, # checking whether cookies are removed + "body": "", + "query_params": {}, + }, + "server": {"host": "testserver", "port": "80"}, + "packages": ["Django==3.2.25"], + "python_version": "3.9.9", + "avg_request_time_to_error": 0.0, + }, + ) + + @patch.object(ErrorReport, "insert_or_update_error") + @patch.object(logging.Logger, "error") + def test_process_exception_integrity_error( + self, mock_logger_error, mock_insert_or_update_error + ): + middleware = ErrorReportingMiddleware(lambda r: None) + request = self.factory.get("/") + request.start_time = 0.0 + exception = Exception("Test Exception") + mock_insert_or_update_error.side_effect = IntegrityError("Some Integrity Error") + middleware.process_exception(request, exception) + + mock_logger_error.assert_any_call( + "Error occurred while saving error report to the database: %s", + str(mock_insert_or_update_error.side_effect), + ) diff --git a/kolibri/core/error_reports/test/test_models.py b/kolibri/core/error_reports/test/test_models.py new file mode 100644 index 00000000000..7bc0af229e0 --- /dev/null +++ b/kolibri/core/error_reports/test/test_models.py @@ -0,0 +1,240 @@ +from django.test import override_settings +from django.test import TestCase +from django.utils import timezone + +from ..constants import BACKEND +from ..constants import FRONTEND +from ..constants import TASK +from kolibri.core.error_reports.models import ErrorReport + + +class ErrorReportTestCase(TestCase): + databases = "__all__" + + def setUp(self): + self.category_frontend = FRONTEND + self.category_backend = BACKEND + self.error_message = "Test Error" + self.traceback = "Test Traceback" + self.context_frontend = { + "browser": {}, + "os": {}, + "component": "HeaderComponent", + "device": { + "is_touch_device": True, + "screen": { + "width": 1920, + "height": 1080, + "available_width": 1920, + "available_height": 1040, + }, + }, + } + self.context_backend = { + "request_info": { + "url": "/api/test", + "method": "GET", + "headers": {"User-Agent": "TestAgent"}, + "body": "", + "query_params": {"test": "true"}, + }, + "server": {"host": "localhost", "port": "8000"}, + "packages": ["django==3.2", "kolibri==0.15.8"], + "python_version": "3.9.1", + } + self.context_task = { + "job_info": { + "job_id": "1", + "func": "test_func", + "facility_id": None, + "args": ["test"], + "kwargs": {"test": "test"}, + "progress": 0, + "total_progress": 0, + "extra_metadata": {}, + }, + "worker_info": { + "worker_host": "localhost", + "worker_process": "1", + "worker_thread": "1", + "worker_extra": None, + }, + } + + def create_error( + self, + category, + error_message, + traceback, + context, + reported=False, + ): + return ErrorReport.objects.create( + category=category, + error_message=error_message, + traceback=traceback, + context=context, + reported=reported, + ) + + @override_settings(DEVELOPER_MODE=False) + def test_insert_or_update_frontend_error_prod_mode(self): + error = ErrorReport.insert_or_update_error( + self.category_frontend, + self.error_message, + self.traceback, + self.context_frontend, + ) + self.assertEqual(error.category, self.category_frontend) + self.assertEqual(error.error_message, self.error_message) + self.assertEqual(error.traceback, self.traceback) + self.assertEqual(error.context, self.context_frontend) + self.assertEqual(error.events, 1) + self.assertFalse(error.reported) + self.assertLess( + timezone.now() - error.first_occurred, timezone.timedelta(seconds=1) + ) + self.assertLess( + timezone.now() - error.last_occurred, timezone.timedelta(seconds=1) + ) + + # Creating the error again, so this time it should update the error + error = ErrorReport.insert_or_update_error( + self.category_frontend, + self.error_message, + self.traceback, + self.context_frontend, + ) + self.assertEqual(error.category, self.category_frontend) + self.assertEqual(error.error_message, self.error_message) + self.assertEqual(error.traceback, self.traceback) + self.assertEqual(error.context, self.context_frontend) + self.assertEqual(error.events, 2) + self.assertFalse(error.reported) + self.assertLess( + timezone.now() - error.first_occurred, timezone.timedelta(seconds=1) + ) + self.assertLess( + timezone.now() - error.last_occurred, timezone.timedelta(seconds=1) + ) + + @override_settings(DEVELOPER_MODE=False) + def test_insert_or_update_backend_error_prod_mode(self): + error = ErrorReport.insert_or_update_error( + self.category_backend, + self.error_message, + self.traceback, + self.context_backend, + ) + self.assertEqual(error.category, self.category_backend) + self.assertEqual(error.error_message, self.error_message) + self.assertEqual(error.traceback, self.traceback) + self.assertEqual(error.context, self.context_backend) + self.assertEqual(error.events, 1) + self.assertFalse(error.reported) + self.assertLess( + timezone.now() - error.first_occurred, timezone.timedelta(seconds=1) + ) + self.assertLess( + timezone.now() - error.last_occurred, timezone.timedelta(seconds=1) + ) + + # Creating the error again, so this time it should update the error + error = ErrorReport.insert_or_update_error( + self.category_backend, + self.error_message, + self.traceback, + self.context_backend, + ) + self.assertEqual(error.category, self.category_backend) + self.assertEqual(error.error_message, self.error_message) + self.assertEqual(error.traceback, self.traceback) + self.assertEqual(error.context, self.context_backend) + self.assertEqual(error.events, 2) + self.assertFalse(error.reported) + self.assertLess( + timezone.now() - error.first_occurred, timezone.timedelta(seconds=1) + ) + self.assertLess( + timezone.now() - error.last_occurred, timezone.timedelta(seconds=1) + ) + + @override_settings(DEVELOPER_MODE=False) + def test_insert_or_update_task_error_prod_mode(self): + error = ErrorReport.insert_or_update_error( + TASK, + self.error_message, + self.traceback, + self.context_task, + ) + self.assertEqual(error.category, TASK) + self.assertEqual(error.error_message, self.error_message) + self.assertEqual(error.traceback, self.traceback) + self.assertEqual(error.context, self.context_task) + self.assertEqual(error.events, 1) + self.assertFalse(error.reported) + self.assertLess( + timezone.now() - error.first_occurred, timezone.timedelta(seconds=1) + ) + self.assertLess( + timezone.now() - error.last_occurred, timezone.timedelta(seconds=1) + ) + + # Creating the error again, so this time it should update the error + error = ErrorReport.insert_or_update_error( + TASK, + self.error_message, + self.traceback, + self.context_task, + ) + self.assertEqual(error.category, TASK) + self.assertEqual(error.error_message, self.error_message) + self.assertEqual(error.traceback, self.traceback) + self.assertEqual(error.context, self.context_task) + self.assertEqual(error.events, 2) + self.assertFalse(error.reported) + self.assertLess( + timezone.now() - error.first_occurred, timezone.timedelta(seconds=1) + ) + self.assertLess( + timezone.now() - error.last_occurred, timezone.timedelta(seconds=1) + ) + + @override_settings(DEVELOPER_MODE=True) + def test_insert_or_update_error_dev_mode(self): + error = ErrorReport.insert_or_update_error( + self.category_backend, + self.error_message, + self.traceback, + self.context_backend, + ) + self.assertIsNone(error) + + def test_get_unreported_errors(self): + self.create_error( + self.category_frontend, + "Error 1", + "Traceback 1", + self.context_frontend, + reported=False, + ) + self.create_error( + self.category_backend, + "Error 2", + "Traceback 2", + self.context_backend, + reported=False, + ) + self.create_error( + self.category_backend, + "Error 3", + "Traceback 3", + self.context_backend, + reported=True, + ) + + # Get unreported errors, should be only 2 as out of 3, 1 is reported + unreported_errors = ErrorReport.get_unreported_errors() + self.assertEqual(unreported_errors.count(), 2) + self.assertFalse(unreported_errors[0].reported) + self.assertFalse(unreported_errors[1].reported) diff --git a/kolibri/core/error_reports/test/test_tasks.py b/kolibri/core/error_reports/test/test_tasks.py new file mode 100644 index 00000000000..b10477733e3 --- /dev/null +++ b/kolibri/core/error_reports/test/test_tasks.py @@ -0,0 +1,89 @@ +from unittest.mock import patch + +import pytest +from django.test import TestCase +from requests.exceptions import ConnectionError +from requests.exceptions import RequestException +from requests.exceptions import Timeout + +from kolibri.core.error_reports.models import ErrorReport +from kolibri.core.error_reports.tasks import ping_error_reports + + +class TestPingErrorReports(TestCase): + databases = "__all__" + + def setUp(self): + ErrorReport.objects.create( + category="frontend", + error_message="Test Error", + traceback="Test Traceback", + context={ + "browser": {}, + "os": {}, + "component": "HeaderComponent", + "device": { + "is_touch_device": True, + "screen": { + "width": 1920, + "height": 1080, + "available_width": 1920, + "available_height": 1040, + }, + }, + }, + ) + ErrorReport.objects.create( + category="backend", + error_message="Test Error", + traceback="Test Traceback", + context={ + "request_info": { + "url": "/api/test", + "method": "GET", + "headers": {"User-Agent": "TestAgent"}, + "body": "", + "query_params": {"test": "true"}, + }, + "server": {"host": "localhost", "port": "8000"}, + "packages": ["Django==3.2.25"], + "python_version": "3.9.1", + "request_time_to_error": 0.0, + }, + ) + + @patch("kolibri.core.error_reports.tasks.requests.post") + @patch( + "kolibri.core.error_reports.tasks.serialize_error_reports_to_json_response", + return_value="[]", + ) + def test_ping_error_reports(self, mock_serializer, mock_post): + ping_error_reports("http://testserver", "test-pingback-id") + mock_post.assert_called_with( + "http://testserver/api/v1/errors/report/", + data="[]", + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(ErrorReport.objects.filter(reported=True).count(), 2) + + @patch( + "kolibri.core.error_reports.tasks.requests.post", side_effect=ConnectionError + ) + def test_ping_error_reports_connection_error(self, mock_post): + with pytest.raises(ConnectionError): + ping_error_reports("http://testserver", "test-pingback-id") + self.assertEqual(ErrorReport.objects.filter(reported=True).count(), 0) + + @patch("kolibri.core.error_reports.tasks.requests.post", side_effect=Timeout) + def test_ping_error_reports_timeout(self, mock_post): + with pytest.raises(Timeout): + ping_error_reports("http://testserver", "test-pingback-id") + self.assertEqual(ErrorReport.objects.filter(reported=True).count(), 0) + + @patch( + "kolibri.core.error_reports.tasks.requests.post", side_effect=RequestException + ) + def test_ping_error_reports_request_exception(self, mock_post): + with pytest.raises(RequestException): + ping_error_reports("http://testserver", "test-pingback-id") + self.assertEqual(ErrorReport.objects.filter(reported=True).count(), 0) diff --git a/kolibri/deployment/default/settings/base.py b/kolibri/deployment/default/settings/base.py index 8206a1b8a88..06cb70dbe95 100644 --- a/kolibri/deployment/default/settings/base.py +++ b/kolibri/deployment/default/settings/base.py @@ -58,6 +58,9 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = conf.OPTIONS["Server"]["DEBUG"] +# Developer mode should be False instead of None in production mode +DEVELOPER_MODE = False + ALLOWED_HOSTS = ["*"] # Application definition @@ -73,6 +76,7 @@ "kolibri.core.auth.apps.KolibriAuthConfig", "kolibri.core.bookmarks", "kolibri.core.content", + "kolibri.core.error_reports", "kolibri.core.logger", "kolibri.core.notifications.apps.KolibriNotificationsConfig", "kolibri.core.tasks.apps.KolibriTasksConfig", @@ -100,6 +104,8 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "kolibri.core.auth.middleware.CustomAuthenticationMiddleware", + "kolibri.core.error_reports.middleware.ErrorReportingMiddleware", + "kolibri.core.error_reports.middleware.PreRequestMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.security.SecurityMiddleware", @@ -161,6 +167,7 @@ "kolibri.core.notifications.models.NotificationsRouter", "kolibri.core.device.models.SyncQueueRouter", "kolibri.core.discovery.models.NetworkLocationRouter", + "kolibri.core.error_reports.models.ErrorReportsRouter", ) elif conf.OPTIONS["Database"]["DATABASE_ENGINE"] == "postgres": diff --git a/kolibri/deployment/default/sqlite_db_names.py b/kolibri/deployment/default/sqlite_db_names.py index 3118a4049ff..d8cae6a8989 100644 --- a/kolibri/deployment/default/sqlite_db_names.py +++ b/kolibri/deployment/default/sqlite_db_names.py @@ -10,5 +10,12 @@ NOTIFICATIONS = "notifications" +ERROR_REPORTS = "error_reports" -ADDITIONAL_SQLITE_DATABASES = (SYNC_QUEUE, NETWORK_LOCATION, NOTIFICATIONS) + +ADDITIONAL_SQLITE_DATABASES = ( + SYNC_QUEUE, + NETWORK_LOCATION, + NOTIFICATIONS, + ERROR_REPORTS, +) diff --git a/kolibri/plugins/error_reports/__init__.py b/kolibri/plugins/error_reports/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/kolibri/plugins/error_reports/api.py b/kolibri/plugins/error_reports/api.py new file mode 100644 index 00000000000..e8ac8def089 --- /dev/null +++ b/kolibri/plugins/error_reports/api.py @@ -0,0 +1,39 @@ +import logging + +from django.core.exceptions import ValidationError +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from .serializers import ErrorReportSerializer +from kolibri.core.error_reports.constants import FRONTEND +from kolibri.core.error_reports.models import ErrorReport + + +logger = logging.getLogger(__name__) + + +@api_view(["POST"]) +def report(request): + serializer = ErrorReportSerializer(data=request.data) + if serializer.is_valid(): + data = serializer.validated_data + try: + error = ErrorReport.insert_or_update_error( + FRONTEND, + data["error_message"], + data["traceback"], + context=data["context"], + ) + return Response( + {"error_id": error.id if error else None}, status=status.HTTP_200_OK + ) + + except (AttributeError, ValidationError) as e: + logger.error("Error while saving error report: {}".format(e)) + return Response( + {"error": "An error occurred while saving errors."}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/kolibri/plugins/error_reports/api_urls.py b/kolibri/plugins/error_reports/api_urls.py new file mode 100644 index 00000000000..02be1b333d9 --- /dev/null +++ b/kolibri/plugins/error_reports/api_urls.py @@ -0,0 +1,5 @@ +from django.urls import re_path + +from .api import report + +urlpatterns = [re_path(r"^report", report, name="report")] diff --git a/kolibri/plugins/error_reports/assets/src/__tests__/errorReport.test.js b/kolibri/plugins/error_reports/assets/src/__tests__/errorReport.test.js new file mode 100644 index 00000000000..1a863a63c0d --- /dev/null +++ b/kolibri/plugins/error_reports/assets/src/__tests__/errorReport.test.js @@ -0,0 +1,96 @@ +import client from 'kolibri.client'; +import urls from 'kolibri.urls'; +import { + report, + VueErrorReport, + JavascriptErrorReport, + UnhandledRejectionErrorReport, +} from '../utils'; + +/* eslint-env jest */ +jest.mock('kolibri.urls', () => ({ + 'kolibri:kolibri.plugins.error_reports:report': jest.fn(), +})); +jest.mock('kolibri.client', () => jest.fn()); + +describe('Error Report', () => { + beforeEach(() => { + urls['kolibri:kolibri.plugins.error_reports:report'].mockReturnValue( + '/error_reports/api/report' + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call /error_reports/api/report with VueErrorReport data', () => { + const vueError = new Error('Vue error'); + vueError.stack = 'My stack trace'; + const vm = { $options: { name: 'TestComponent' } }; + const errorReport = new VueErrorReport(vueError, vm); + + const expectedData = { + error_message: 'Vue error', + traceback: 'My stack trace', + context: { + ...errorReport.getContext(), + component: 'TestComponent', + }, + }; + + report(errorReport); + + expect(client).toHaveBeenCalledWith({ + url: '/error_reports/api/report', + method: 'post', + data: expectedData, + }); + }); + + it('should call /error_reports/api/report with JavascriptErrorReport data', () => { + const jsErrorEvent = { + error: new Error('Javascript error'), + }; + jsErrorEvent.error.stack = 'My stack trace'; + + const errorReport = new JavascriptErrorReport(jsErrorEvent); + + const expectedData = { + error_message: 'Javascript error', + traceback: 'My stack trace', + context: errorReport.getContext(), + }; + + report(errorReport); + + expect(client).toHaveBeenCalledWith({ + url: '/error_reports/api/report', + method: 'post', + data: expectedData, + }); + }); + + it('should call /error_reports/api/report with UnhandledRejectionErrorReport data', () => { + const rejectionEvent = { + reason: new Error('Unhandled rejection'), + }; + rejectionEvent.reason.stack = 'My stack trace'; + + const errorReport = new UnhandledRejectionErrorReport(rejectionEvent); + + const expectedData = { + error_message: 'Unhandled rejection', + traceback: 'My stack trace', + context: errorReport.getContext(), + }; + + report(errorReport); + + expect(client).toHaveBeenCalledWith({ + url: '/error_reports/api/report', + method: 'post', + data: expectedData, + }); + }); +}); diff --git a/kolibri/plugins/error_reports/assets/src/index.js b/kolibri/plugins/error_reports/assets/src/index.js new file mode 100644 index 00000000000..d6408297759 --- /dev/null +++ b/kolibri/plugins/error_reports/assets/src/index.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import logging from 'kolibri.lib.logging'; +import { + report, + VueErrorReport, + JavascriptErrorReport, + UnhandledRejectionErrorReport, +} from './utils'; + +const logger = logging.getLogger(__filename); + +// these shall be responsibe for catching runtime errors +Vue.config.errorHandler = function(err, vm) { + logger.debug(`Unexpected Error: ${err}`); + const error = new VueErrorReport(err, vm); + report(error); +}; + +window.addEventListener('error', e => { + logger.debug(`Unexpected Error: ${e.error}`); + const error = new JavascriptErrorReport(e); + report(error); +}); + +window.addEventListener('unhandledrejection', event => { + if (process.env.NODE_ENV === 'production') { + event.preventDefault(); + } + logger.debug(`Unhandled Rejection: ${event.reason}`); + const error = new UnhandledRejectionErrorReport(event); + report(error); +}); diff --git a/kolibri/plugins/error_reports/assets/src/utils.js b/kolibri/plugins/error_reports/assets/src/utils.js new file mode 100644 index 00000000000..e242a44a8b5 --- /dev/null +++ b/kolibri/plugins/error_reports/assets/src/utils.js @@ -0,0 +1,74 @@ +import client from 'kolibri.client'; +import urls from 'kolibri.urls'; +import { browser, os, device, isTouchDevice } from 'kolibri.utils.browserInfo'; + +export function report(error) { + const url = urls['kolibri:kolibri.plugins.error_reports:report'](); + const data = error.getErrorReport(); + return client({ + url, + method: 'post', + data: data, + }); +} + +class ErrorReport { + constructor(e) { + this.message = e?.message || 'Unknown Error'; + this.stack = e?.stack || 'No stack trace available'; + } + + getErrorReport() { + return { + error_message: this.message, + traceback: this.stack, + context: this.getContext(), + }; + } + + getContext() { + return { + browser: browser, + os: os, + device: { + ...device, + is_touch_device: isTouchDevice, + screen: { + width: window.screen.width, + height: window.screen.height, + available_width: window.screen.availWidth, + available_height: window.screen.availHeight, + }, + }, + ...this.getExtraContext(), + }; + } + + getExtraContext() { + return {}; + } +} + +export class VueErrorReport extends ErrorReport { + constructor(e, vm) { + super(e); + this.vm = vm; + } + getExtraContext() { + return { + component: this.vm.$options.name || this.vm.$options._componentTag || 'Unknown Component', + }; + } +} + +export class JavascriptErrorReport extends ErrorReport { + constructor(e) { + super(e.error || { message: e.message }); + } +} + +export class UnhandledRejectionErrorReport extends ErrorReport { + constructor(e) { + super(e.reason); + } +} diff --git a/kolibri/plugins/error_reports/buildConfig.js b/kolibri/plugins/error_reports/buildConfig.js new file mode 100644 index 00000000000..502e9e18dd0 --- /dev/null +++ b/kolibri/plugins/error_reports/buildConfig.js @@ -0,0 +1,8 @@ +module.exports = [ + { + bundle_id: 'main', + webpack_config: { + entry: './assets/src/index.js', + }, + }, +]; diff --git a/kolibri/plugins/error_reports/kolibri_plugin.py b/kolibri/plugins/error_reports/kolibri_plugin.py new file mode 100644 index 00000000000..6ac41135543 --- /dev/null +++ b/kolibri/plugins/error_reports/kolibri_plugin.py @@ -0,0 +1,71 @@ +import logging + +from kolibri.core.error_reports.constants import TASK +from kolibri.core.hooks import FrontEndBaseSyncHook +from kolibri.core.tasks.hooks import StorageHook +from kolibri.core.tasks.job import State +from kolibri.core.webpack.hooks import WebpackBundleHook +from kolibri.plugins import KolibriPluginBase +from kolibri.plugins.hooks import register_hook + +logger = logging.getLogger(__name__) + + +class ErrorReportsPlugin(KolibriPluginBase): + """ + A plugin to capture and report errors in Kolibri. + """ + + untranslated_view_urls = "api_urls" + + +@register_hook +class ErrorReportsPluginAsset(WebpackBundleHook): + bundle_id = "main" + + +@register_hook +class ErrorReportsPluginInclusionHook(FrontEndBaseSyncHook): + bundle_class = ErrorReportsPluginAsset + + +@register_hook +class ErrorReportsPluginStorageHook(StorageHook): + def schedule(self, job, orm_job): + pass + + def update(self, job, orm_job, state=None, **kwargs): + if state == State.FAILED: + # Importing here to avoid importing models at the top level + from kolibri.core.error_reports.middleware import get_packages + from kolibri.core.error_reports.middleware import get_python_version + from kolibri.core.error_reports.models import ErrorReport + + ErrorReport.insert_or_update_error( + TASK, + job.exception, + job.traceback, + context={ + "job_info": { + "job_id": job.job_id, + "func": job.func, + "facility_id": job.facility_id, + "args": job.args, + "kwargs": job.kwargs, + "progress": job.progress, + "total_progress": job.total_progress, + "extra_metadata": job.extra_metadata, + }, + "worker_info": { + "worker_host": orm_job.worker_host, + "worker_process": orm_job.worker_process, + "worker_thread": orm_job.worker_thread, + "worker_extra": orm_job.worker_extra, + }, + "packages": get_packages(), + "python_version": get_python_version(), + }, + ) + + def clear(self, job, orm_job): + pass diff --git a/kolibri/plugins/error_reports/serializers.py b/kolibri/plugins/error_reports/serializers.py new file mode 100644 index 00000000000..80a8e78ed3c --- /dev/null +++ b/kolibri/plugins/error_reports/serializers.py @@ -0,0 +1,11 @@ +from rest_framework import serializers + +from kolibri.core.error_reports.models import ErrorReport + + +class ErrorReportSerializer(serializers.ModelSerializer): + context = serializers.JSONField() + + class Meta: + model = ErrorReport + fields = ["error_message", "traceback", "context"] diff --git a/kolibri/plugins/error_reports/test/test_api.py b/kolibri/plugins/error_reports/test/test_api.py new file mode 100644 index 00000000000..519444297e9 --- /dev/null +++ b/kolibri/plugins/error_reports/test/test_api.py @@ -0,0 +1,82 @@ +from unittest.mock import patch + +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.urls import reverse +from rest_framework.status import HTTP_200_OK +from rest_framework.status import HTTP_400_BAD_REQUEST +from rest_framework.test import APIClient + + +class FrontendReportTestCase(TestCase): + databases = "__all__" + data = { + "error_message": "Something went wrong", + "traceback": "Traceback information", + "context": { + "browser": { + "name": "Chrome", + "major": "1", + "minor": "2", + "patch": "3", + }, + "os": { + "name": "OS", + "major": "1", + "minor": "2", + "patch": "3", + }, + "component": "component", + "device": { + "model": "", + "type": "type", + "vendor": "vendor", + "is_touch_device": True, + "screen": { + "width": 100, + "height": 200, + "available_width": 100, + "available_height": 200, + }, + }, + }, + } + + def setUp(self): + self.client = APIClient() + + def test_frontend_report(self): + url = reverse("kolibri:kolibri.plugins.error_reports:report") + response = self.client.post(url, self.data, format="json") + self.assertEqual(response.status_code, HTTP_200_OK) + + def test_frontend_report_invalid_data(self): + url = reverse("kolibri:kolibri.plugins.error_reports:report") + data = self.data.copy() + invalid_data = data.pop("context") + response = self.client.post(url, invalid_data, format="json") + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + @patch( + "kolibri.core.error_reports.models.ErrorReport.insert_or_update_error", + side_effect=AttributeError("Mocked AttributeError"), + ) + def test_frontend_report_server_error_attribute_error( + self, mock_insert_or_update_error + ): + url = reverse("kolibri:kolibri.plugins.error_reports:report") + response = self.client.post(url, self.data, format="json") + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + self.assertIn("error", response.data) + + @patch( + "kolibri.core.error_reports.models.ErrorReport.insert_or_update_error", + side_effect=ValidationError("Mocked exception"), + ) + def test_frontend_report_server_error_validation_error( + self, mock_insert_or_update_error + ): + url = reverse("kolibri:kolibri.plugins.error_reports:report") + response = self.client.post(url, self.data, format="json") + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + self.assertIn("error", response.data) diff --git a/kolibri/utils/build_config/default_plugins.py b/kolibri/utils/build_config/default_plugins.py index c2892a53e50..54d7bec0b8d 100644 --- a/kolibri/utils/build_config/default_plugins.py +++ b/kolibri/utils/build_config/default_plugins.py @@ -3,6 +3,7 @@ "kolibri.plugins.default_theme", "kolibri.plugins.device", "kolibri.plugins.epub_viewer", + "kolibri.plugins.error_reports", "kolibri.plugins.html5_viewer", "kolibri.plugins.facility", "kolibri.plugins.learn", diff --git a/yarn.lock b/yarn.lock index 21d577c750b..273163724f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2438,15 +2438,6 @@ aphrodite@1.1.0: asap "^2.0.3" inline-style-prefixer "^2.0.0" -"aphrodite@git+https://github.com/learningequality/aphrodite.git": - version "2.2.3" - uid fdc8d7be8912a5cf17f74ff10f124013c52c3e32 - resolved "git+https://github.com/learningequality/aphrodite.git#fdc8d7be8912a5cf17f74ff10f124013c52c3e32" - dependencies: - asap "^2.0.3" - inline-style-prefixer "^4.0.2" - string-hash "^1.1.3" - "aphrodite@https://github.com/learningequality/aphrodite/": version "2.2.3" resolved "https://github.com/learningequality/aphrodite/#fdc8d7be8912a5cf17f74ff10f124013c52c3e32"