Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GSoC: Distributed error reporting #12489

Open
wants to merge 84 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 80 commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
d814aa1
add errors in additional_sqlite_databases
thesujai Jun 5, 2024
afe3ad9
add errorreports database in aditional sqlite db
thesujai Jun 5, 2024
5e10f53
create new errorreports app
thesujai Jun 5, 2024
abb63f3
add ErrorReports db router
thesujai Jun 5, 2024
0e01b17
change ellipsis to pass
thesujai Jun 11, 2024
20e540c
remove unused ready
thesujai Jun 11, 2024
e5f2b4c
Merge pull request #12250 from thesujai/distributed-error-reporting
akolson Jun 11, 2024
8b0d8cf
add ErrorReports model with and its class methods
thesujai Jun 7, 2024
1b50c66
add tests for model methods
thesujai Jun 7, 2024
f89c601
add DEVELOPER_MODE = False on settings
thesujai Jun 10, 2024
e49b82d
conditional check of dev mode during writing into database
thesujai Jun 10, 2024
2114c4d
pass>>>`...`
thesujai Jun 11, 2024
678d0cd
use getattr for accessing settings.DEVELOPER_MODE
thesujai Jun 11, 2024
5af22b6
Merge pull request #12255 from thesujai/distributed-error-reporting-t…
akolson Jun 11, 2024
2e1b852
Add middleware for handling runtime errors
thesujai Jun 9, 2024
722efe7
Add test for error-report middleware
thesujai Jun 9, 2024
4c62c27
Simplify calling insert_or_update_error and tests
thesujai Jun 10, 2024
a859358
put all the constants together in errorreports
thesujai Jun 11, 2024
fa40e38
move POSSIBLE_ERRORS to contants.py
thesujai Jun 11, 2024
b1c50bf
improve testcase for middleware
thesujai Jun 12, 2024
c2207f5
Merge pull request #12260 from thesujai/distributed-error-reporting-t…
akolson Jun 12, 2024
388dd17
add serializer ErrorReprotsSerializers:frontend data validation
thesujai Jun 10, 2024
26f0018
add API for frontend error report
thesujai Jun 10, 2024
b6de284
testcase for frontendreport view
thesujai Jun 10, 2024
c67851b
make error_from default to 'frontend'
thesujai Jun 10, 2024
cc968b6
simplify API: remove conditioning before calling insert_or_update_err…
thesujai Jun 10, 2024
09e2af3
name changes
thesujai Jun 12, 2024
d103ff8
expect (AttributeError, Exception) while calling insert_or_update
thesujai Jun 12, 2024
00b7be1
test for anything other than AttributeError or Exception can be caught
thesujai Jun 12, 2024
53ae2ad
Merge pull request #12261 from thesujai/distributed-error-reporting-t…
akolson Jun 12, 2024
77507a4
error-handling mechanism
thesujai Jun 17, 2024
30c13b4
optimized code for backend call
thesujai Jun 18, 2024
2e7539b
remove old reporting util
thesujai Jun 18, 2024
86b980e
pass entire error object to report()
thesujai Jun 20, 2024
a0ea7b4
useless comment
thesujai Jun 20, 2024
cb259fa
Merge pull request #12291 from thesujai/distributed-error-reporting-t…
akolson Jun 20, 2024
d389b27
create task ping_error_report
thesujai Jun 25, 2024
0dcbd4a
tests for error_report task
thesujai Jun 25, 2024
aa5960b
add error report task
thesujai Jun 25, 2024
4b86a2b
improve code
thesujai Jun 27, 2024
1768cd2
improvise: remove mark_as_sent to use update on queryset, use DjangoJ…
thesujai Jul 1, 2024
7f88d06
remove mark_errors_as_sent
thesujai Jul 1, 2024
2d89272
Merge pull request #12357 from thesujai/distributed-error-reporting-t…
akolson Jul 1, 2024
d02097f
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Jul 25, 2024
d9ef827
update ErrorReports for new fields
thesujai Jul 1, 2024
984261f
add context field in errorreports/report/
thesujai Jul 1, 2024
44ce4e1
update middleware to capture more fields
thesujai Jul 1, 2024
2798cdf
update frontend erroreports to capture more fields
thesujai Jul 1, 2024
bea2e64
update erroreports task to report more fields
thesujai Jul 1, 2024
afef5ad
add test for tasks
thesujai Jul 2, 2024
e3252ee
update schema of frontend
thesujai Jul 3, 2024
01474a2
add installation_type in tasks
thesujai Jul 3, 2024
d530b3a
use ua-parser for the device and os
thesujai Jul 3, 2024
7056944
add os in context_frontend
thesujai Jul 3, 2024
109acd2
revert
thesujai Jul 3, 2024
cd373c4
clarity
thesujai Jul 11, 2024
a558f37
format python version
thesujai Jul 11, 2024
6c4ce3a
use definations for schema
thesujai Jul 15, 2024
1cc20dc
seeprate device and isTouchDevice
thesujai Jul 15, 2024
a7414c8
change screen object and move getContext to parent method
thesujai Jul 18, 2024
dea1d32
changes: single context instead of two, full version instead of parse…
thesujai Jul 18, 2024
86159d0
changes: importlib instead of pkg_resources and pass context to the s…
thesujai Jul 18, 2024
22cfce2
changes: modelserializer instead of regular, pass context to the save…
thesujai Jul 18, 2024
15e34ba
use single context
thesujai Jul 18, 2024
85cf9c0
add more screen info in schemas and remove default schemas
thesujai Jul 18, 2024
c5eb2ae
add query_params and improve packages retreival
thesujai Jul 25, 2024
14ceceb
Add pingback_id and request_time
thesujai Jul 31, 2024
ee7eb3e
raise 400 instead of 500
thesujai Aug 2, 2024
d8a713e
Merge pull request #12382 from thesujai/distributed-error-reporting-t…
akolson Aug 7, 2024
780d544
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Aug 7, 2024
4f104ef
reruns migrations
akolson Aug 9, 2024
085cf88
reruns migrations
akolson Aug 9, 2024
5b909bc
reruns migrations
akolson Aug 9, 2024
fd9e925
reruns migrations
akolson Aug 9, 2024
ac07e7a
Removes information exposure through exception
akolson Aug 9, 2024
6939d0e
Removes information exposure through exception
akolson Aug 9, 2024
aacc517
Merge pull request #12551 from akolson/Fixes-migrations-and-tests
rtibbles Aug 22, 2024
b64c4c2
refactor stuffs
thesujai Sep 14, 2024
340f714
use get_or_create() with defaults arg
thesujai Sep 23, 2024
73aed79
Merge pull request #12660 from thesujai/distributed-error-reporting
rtibbles Sep 23, 2024
8ab29d4
Make error reporting pluggable.
rtibbles Sep 25, 2024
39d07ad
Add error capturing for tasks.
rtibbles Sep 26, 2024
f996888
Refactor to standardize naming of core app and model.
rtibbles Sep 26, 2024
0c82a59
Merge pull request #12681 from rtibbles/plugin_error_reports
rtibbles Oct 24, 2024
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
5 changes: 4 additions & 1 deletion kolibri/core/analytics/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from kolibri.core.analytics.utils import DEFAULT_SERVER_URL
from kolibri.core.analytics.utils import ping_once
from kolibri.core.errorreports.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
Expand All @@ -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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see this is creating two different pathways that hinges on the pingback_id. In utils.py, there already exists logic dependent on if "id" in data:, which is the same condition here. It seems like this fits alongside the existing logic there.

except ConnectionError:
logger.warning(
"Ping failed (could not connect). Trying again in {} minutes.".format(
Expand Down
1 change: 1 addition & 0 deletions kolibri/core/analytics/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
1 change: 1 addition & 0 deletions kolibri/core/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@
re_path(r"^discovery/", include("kolibri.core.discovery.api_urls")),
re_path(r"^notifications/", include("kolibri.core.analytics.api_urls")),
re_path(r"^public/", include("kolibri.core.public.api_urls")),
re_path(r"^errorreports/", include("kolibri.core.errorreports.api_urls")),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import urls from 'kolibri.urls';
import Resource from '../errorReport';
import {
VueErrorReport,
JavascriptErrorReport,
UnhandledRejectionErrorReport,
} from '../../utils/errorReportUtils';

/* eslint-env jest */
jest.mock('kolibri.urls', () => ({
'kolibri:core:report': jest.fn(),
}));

describe('Error Report', () => {
beforeEach(() => {
urls['kolibri:core:report'].mockReturnValue('/api/core/report');
});

afterEach(() => {
jest.clearAllMocks();
});

it('should call api/core/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',
},
};

Resource.client = jest.fn();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, whenever you mock something in Python or JS, you want to ensure that the original implementation can be restored after the test is completed. That approach can keep tests from interfering with other tests, because of their use of mocks.

Since this is a direct replace of Resource.client, there isn't a way for it to be restored. So it would be better to use mock.spyOn or mock.replaceProperty here, and do so in the beforeEach. Then instead of clearAllMocks in afterEach (which only clears the mock state), I would suggest using restoreAllMocks as that would ensure any mocks are restored to what they should be (assuming the appropriate approach was used to create the mock in the first place).

Resource.report(errorReport);

expect(Resource.client).toHaveBeenCalledWith({
url: '/api/core/report',
method: 'post',
data: expectedData,
});
});

it('should call api/core/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(),
};

Resource.client = jest.fn();
Resource.report(errorReport);

expect(Resource.client).toHaveBeenCalledWith({
url: '/api/core/report',
method: 'post',
data: expectedData,
});
});

it('should call api/core/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(),
};

Resource.client = jest.fn();
Resource.report(errorReport);

expect(Resource.client).toHaveBeenCalledWith({
url: '/api/core/report',
method: 'post',
data: expectedData,
});
});
});
15 changes: 15 additions & 0 deletions kolibri/core/assets/src/api-resources/errorReport.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Resource } from 'kolibri.lib.apiResource';
import urls from 'kolibri.urls';

export default new Resource({
name: 'errorreports',
report(error) {
const url = urls['kolibri:core:report']();
const data = error.getErrorReport();
return this.client({
url,
method: 'post',
data: data,
});
},
});
1 change: 1 addition & 0 deletions kolibri/core/assets/src/api-resources/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export { default as UserSyncStatusResource } from './userSyncStatus';
export { default as ContentRequestResource } from './contentRequest';
export { default as ContentNodeProgressResource } from './contentNodeProgress';
export { default as DevicePermissionsResource } from './devicePermissions';
export { default as ErrorReportResource } from './errorReport';
export { default as RemoteChannelResource } from './remoteChannel';
export { default as LessonResource } from './lesson';
export { default as AttemptLogResource } from './attemptLog';
Expand Down
28 changes: 27 additions & 1 deletion kolibri/core/assets/src/core-app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ import heartbeat from 'kolibri.heartbeat';
import ContentRenderer from '../views/ContentRenderer';
import initializeTheme from '../styles/initializeTheme';
import { i18nSetup } from '../utils/i18n';
import setupPluginMediator from './pluginMediator';
import { ErrorReportResource } from '../api-resources';
import {
VueErrorReport,
JavascriptErrorReport,
UnhandledRejectionErrorReport,
} from '../utils/errorReportUtils';
import apiSpec from './apiSpec';
import setupPluginMediator from './pluginMediator';

// Do this before any async imports to ensure that public paths
// are set correctly
Expand Down Expand Up @@ -71,6 +77,26 @@ heartbeat.startPolling();

i18nSetup().then(coreApp.ready);

// these shall be responsibe for catching runtime errors
Vue.config.errorHandler = function (err, vm) {
logging.error(`Unexpected Error: ${err}`);
const error = new VueErrorReport(err, vm);
ErrorReportResource.report(error);
};

window.addEventListener('error', e => {
logging.error(`Unexpected Error: ${e.error}`);
const error = new JavascriptErrorReport(e);
ErrorReportResource.report(error);
});

window.addEventListener('unhandledrejection', event => {
event.preventDefault();
logging.error(`Unhandled Rejection: ${event.reason}`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know the unhandledrejection listener will prevent default logging of the error, so in regards to that and the other logging statements, I'm concerned whether these are suppressing necessary log information, i.e. a stack trace, that developers would need? If logging.error outputs a stack trace, that may not be the same trace as the error itself.

const error = new UnhandledRejectionErrorReport(event);
ErrorReportResource.report(error);
});

// This is exported by webpack as the kolibriCoreAppGlobal object, due to the 'output.library' flag
// which exports the coreApp at the bottom of this file as a named global variable:
// https://webpack.github.io/docs/configuration.html#output-library
Expand Down
7 changes: 7 additions & 0 deletions kolibri/core/assets/src/utils/browserInfo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down
66 changes: 66 additions & 0 deletions kolibri/core/assets/src/utils/errorReportUtils.js
Original file line number Diff line number Diff line change
@@ -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,
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was discussion about using the screen size breakpoints instead of the actual width and height. Is that the case, because it doesn't look like it? The reason is that it protects privacy. Specific sizes can be used to identify users, which reduces the anonymity of the data

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we should definitely make this update. Although I am also noticing that this file shouldn't exist, because it has been moved into the plugin to make this behaviour pluggable.

},
};
}
}

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,
};
}
}
Empty file.
39 changes: 39 additions & 0 deletions kolibri/core/errorreports/api.py
Original file line number Diff line number Diff line change
@@ -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 .constants import FRONTEND
from .models import ErrorReports
from .serializers import ErrorReportSerializer


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 = ErrorReports.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)
5 changes: 5 additions & 0 deletions kolibri/core/errorreports/api_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.urls import re_path

from .api import report

urlpatterns = [re_path(r"^report", report, name="report")]
7 changes: 7 additions & 0 deletions kolibri/core/errorreports/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.errorreports"
label = "errorreports"
verbose_name = "Kolibri ErrorReports"
7 changes: 7 additions & 0 deletions kolibri/core/errorreports/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FRONTEND = "frontend"
BACKEND = "backend"

POSSIBLE_ERRORS = [
(FRONTEND, "Frontend"),
(BACKEND, "Backend"),
]
Loading