Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
d05a768
create new errorreports app and database for writing reports to
thesujai Jun 5, 2024
f3e18ec
add ErrorReports model with and its class methods
thesujai Jun 7, 2024
429c42a
Add middleware for handling runtime errors
thesujai Jun 9, 2024
bbf15e9
Simplify calling insert_or_update_error and tests
thesujai Jun 10, 2024
06593ed
put all the constants together in errorreports
thesujai Jun 11, 2024
e8b9f9d
improve testcase for middleware
thesujai Jun 12, 2024
27e4d71
add serializer ErrorReprotsSerializers:frontend data validation
thesujai Jun 10, 2024
de95e45
make error_from default to 'frontend'
thesujai Jun 10, 2024
5f435e7
simplify API: remove conditioning before calling insert_or_update_err…
thesujai Jun 10, 2024
766c324
name changes
thesujai Jun 12, 2024
45cd9c0
expect (AttributeError, Exception) while calling insert_or_update
thesujai Jun 12, 2024
7b2bb54
test for anything other than AttributeError or Exception can be caught
thesujai Jun 12, 2024
8f47dfe
create task ping_error_report
thesujai Jun 25, 2024
dfd9d6e
improve code
thesujai Jun 27, 2024
677b985
improvise: remove mark_as_sent to use update on queryset, use DjangoJ…
thesujai Jul 1, 2024
85a15af
remove mark_errors_as_sent
thesujai Jul 1, 2024
e42837c
update ErrorReports for new fields
thesujai Jul 1, 2024
61ba3b5
add context field in errorreports/report/
thesujai Jul 1, 2024
0726d38
update middleware to capture more fields
thesujai Jul 1, 2024
52bd259
update erroreports task to report more fields
thesujai Jul 1, 2024
3076a92
add test for tasks
thesujai Jul 2, 2024
eeff206
update schema of frontend
thesujai Jul 3, 2024
d95a2b9
add installation_type in tasks
thesujai Jul 3, 2024
f363395
use ua-parser for the device and os
thesujai Jul 3, 2024
fa2221b
add os in context_frontend
thesujai Jul 3, 2024
745591e
revert
thesujai Jul 3, 2024
817435b
clarity
thesujai Jul 11, 2024
538f20e
format python version
thesujai Jul 11, 2024
9894ea3
use definations for schema
thesujai Jul 15, 2024
f6e93ce
seeprate device and isTouchDevice
thesujai Jul 15, 2024
8f75d5d
changes: single context instead of two, full version instead of parse…
thesujai Jul 18, 2024
5659d98
changes: importlib instead of pkg_resources and pass context to the s…
thesujai Jul 18, 2024
1862489
changes: modelserializer instead of regular, pass context to the save…
thesujai Jul 18, 2024
25027ee
use single context
thesujai Jul 18, 2024
7701fa1
add more screen info in schemas and remove default schemas
thesujai Jul 18, 2024
534ed36
add query_params and improve packages retreival
thesujai Jul 25, 2024
58084c0
Add pingback_id and request_time
thesujai Jul 31, 2024
b7aea46
raise 400 instead of 500
thesujai Aug 2, 2024
49bcd82
reruns migrations
akolson Aug 9, 2024
5ee68a7
Removes information exposure through exception
akolson Aug 9, 2024
cb6d7f0
refactor stuffs
thesujai Sep 14, 2024
31f1c86
use get_or_create() with defaults arg
thesujai Sep 23, 2024
1dfab60
Make error reporting pluggable.
rtibbles Sep 25, 2024
97a325d
Add error capturing for tasks.
rtibbles Sep 26, 2024
8b3b22a
Refactor to standardize naming of core app and model.
rtibbles Sep 26, 2024
cde94fc
vendor sentry sdk for scrubbing request object
thesujai May 29, 2025
292fbc6
use screen breakpoints instead of absolute screen size
thesujai May 29, 2025
600b1dd
minor code optimizations
thesujai May 29, 2025
f5a256f
use screen breakpoints from kds
thesujai Jul 8, 2025
783d5f7
merge PII to default denylist
thesujai Jul 14, 2025
ab06242
Merge pull request #13434 from thesujai/der-sentry-headers
rtibbles Jul 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions kolibri/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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",
Expand Down
3 changes: 3 additions & 0 deletions kolibri/core/analytics/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from kolibri.core.device.utils import allow_guest_access
from kolibri.core.device.utils import get_device_setting
from kolibri.core.discovery.utils.network.client import NetworkClient
from kolibri.core.error_reports.tasks import ping_error_reports
from kolibri.core.exams.models import Exam
from kolibri.core.lessons.models import Lesson
from kolibri.core.logger.models import AttemptLog
Expand Down Expand Up @@ -471,3 +472,5 @@ 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)
ping_error_reports.enqueue(args=(server, data["id"]))
return data["id"]
Empty file.
7 changes: 7 additions & 0 deletions kolibri/core/error_reports/apps.py
Original file line number Diff line number Diff line change
@@ -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"
9 changes: 9 additions & 0 deletions kolibri/core/error_reports/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FRONTEND = "frontend"
BACKEND = "backend"
TASK = "task"

POSSIBLE_ERRORS = [
(FRONTEND, "Frontend"),
(BACKEND, "Backend"),
(TASK, "Task"),
]
113 changes: 113 additions & 0 deletions kolibri/core/error_reports/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import json
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.core.error_reports.utils.scrubber import scrub_data
from kolibri.plugins.registry import registered_plugins


def get_request_info(request):
context = {
"url": request.build_absolute_uri(),
"method": request.method,
"headers": dict(request.headers),
"query_params": dict(request.GET),
"body": None,
}

if request.headers.get("Content-Type", "").lower() == "application/json":
try:
# a json req body can have sensitive data, other types can have
context["body"] = json.loads(request.body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
pass

scrub_data(context)
return context


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()
54 changes: 54 additions & 0 deletions kolibri/core/error_reports/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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)),
],
),
]
Empty file.
112 changes: 112 additions & 0 deletions kolibri/core/error_reports/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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) or getattr(
settings, "TESTING", 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.info("ErrorReport: Database updated.")
return error_report

@classmethod
def get_unreported_errors(cls):
return cls.objects.filter(reported=False)

@classmethod
def delete_error(cls):
pass
Loading
Loading