diff --git a/README.rst b/README.rst index 282546b1..c66174e6 100644 --- a/README.rst +++ b/README.rst @@ -54,6 +54,7 @@ Current features * `Admin Theme utilities <#admin-theme-utilities>`_ * `REST API utilities <#rest-api-utilities>`_ * `Test utilities <#test-utilities>`_ +* `Collection of Usage Metrics <#collection-of-usage-metrics>`_ * `Quality assurance checks <#quality-assurance-checks>`_ ------------ @@ -1347,6 +1348,60 @@ Usage: but not for complex background tasks which can take a long time to execute (eg: firmware upgrades, network operations with retry mechanisms). +``openwisp_utils.tasks.retryable_requests`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +A utility function for making HTTP requests with built-in retry logic. +This function is useful for handling transient errors encountered during HTTP +requests by automatically retrying failed requests with exponential backoff. +It provides flexibility in configuring various retry parameters to suit +different use cases. + +Usage: + +.. code-block:: python + + from your_module import retryable_request + + response = retryable_request( + method='GET', + url='https://openwisp.org', + timeout=(4, 8), + max_retries=3, + backoff_factor=1, + backoff_jitter=0.0, + status_forcelist=(429, 500, 502, 503, 504), + allowed_methods=('HEAD', 'GET', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'), + retry_kwargs=None, + headers={'Authorization': 'Bearer token'} + ) + +**Paramters:** + +- ``method`` (str): The HTTP method to be used for the request in lower + case (e.g., 'get', 'post', etc.). +- ``timeout`` (tuple): A tuple containing two elements: connection timeout + and read timeout in seconds (default: (4, 8)). +- ``max_retries`` (int): The maximum number of retry attempts in case of + request failure (default: 3). +- ``backoff_factor`` (float): A factor by which the retry delay increases + after each retry (default: 1). +- ``backoff_jitter`` (float): A jitter to apply to the backoff factor to prevent + retry storms (default: 0.0). +- ``status_forcelist`` (tuple): A tuple of HTTP status codes for which retries + should be attempted (default: (429, 500, 502, 503, 504)). +- ``allowed_methods`` (tuple): A tuple of HTTP methods that are allowed for + the request (default: ('HEAD', 'GET', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST')). +- ``retry_kwargs`` (dict): Additional keyword arguments to be passed to the + retry mechanism (default: None). +- ``**kwargs``: Additional keyword arguments to be passed to the underlying request + method (e.g. 'headers', etc.). + +Note: This method will raise a requests.exceptions.RetryError if the request +remains unsuccessful even after all retry attempts have been exhausted. +This exception indicates that the operation could not be completed successfully +despite the retry mechanism. + Storage utilities ----------------- @@ -1615,6 +1670,29 @@ This backend extends ``django.contrib.gis.db.backends.spatialite`` database backend to implement a workaround for handling `issue with sqlite 3.36 and spatialite 5 `_. +Collection of Usage Metrics +--------------------------- + +The openwisp-utils module includes an optional sub-app ``openwisp_utils.measurements``. +This sub-app enables collection of following measurements: + +- Installed OpenWISP Version +- Enabled OpenWISP modules: A list of the enabled OpenWISP modules + along with their respective versions +- OS details: Information on the operating system, including its + version, kernel version, and platform +- Whether the event is related to a new installation or an upgrade + +We collect data on OpenWISP usage to gauge user engagement, satisfaction, +and upgrade patterns. This informs our development decisions, ensuring +continuous improvement aligned with user needs. + +To enhance our understanding and management of this data, we have +integrated `Clean Insights `_, a privacy-preserving +analytics tool. Clean Insights allows us to responsibly gather and analyze +usage metrics without compromising user privacy. It provides us with the +means to make data-driven decisions while respecting our users' rights and trust. + Quality Assurance Checks ------------------------ diff --git a/openwisp_utils/measurements/__init__.py b/openwisp_utils/measurements/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openwisp_utils/measurements/apps.py b/openwisp_utils/measurements/apps.py new file mode 100644 index 00000000..222bd59f --- /dev/null +++ b/openwisp_utils/measurements/apps.py @@ -0,0 +1,42 @@ +from django.apps import AppConfig +from django.conf import settings +from django.db.models.signals import post_migrate + + +class MeasurementsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'openwisp_utils.measurements' + app_label = 'openwisp_measurements' + + def ready(self): + super().ready() + self.connect_post_migrate_signal() + + def connect_post_migrate_signal(self): + post_migrate.connect(self.post_migrate_receiver, sender=self) + + @classmethod + def post_migrate_receiver(cls, **kwargs): + if getattr(settings, 'DEBUG', False): + # Do not send usage metrics in debug mode + # i.e. when running tests. + return + + from .tasks import send_usage_metrics + + is_new_install = False + if kwargs.get('plan'): + migration, migration_rolled_back = kwargs['plan'][0] + is_new_install = ( + migration_rolled_back is False + and str(migration) == 'contenttypes.0001_initial' + ) + + # If the migration plan includes creating table + # for the ContentType model, then the installation is + # treated as a new installation. + if is_new_install: + # This is a new installation + send_usage_metrics.delay() + else: + send_usage_metrics.delay(upgrade_only=True) diff --git a/openwisp_utils/measurements/migrations/0001_initial.py b/openwisp_utils/measurements/migrations/0001_initial.py new file mode 100644 index 00000000..b3eff0a9 --- /dev/null +++ b/openwisp_utils/measurements/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.7 on 2023-12-06 15:30 + +from django.db import migrations, models +import django.utils.timezone +import model_utils.fields +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="OpenwispVersion", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ("module_version", models.JSONField(blank=True, default=dict)), + ], + options={ + "ordering": ("-created",), + }, + ), + ] diff --git a/openwisp_utils/measurements/migrations/__init__.py b/openwisp_utils/measurements/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openwisp_utils/measurements/models.py b/openwisp_utils/measurements/models.py new file mode 100644 index 00000000..563d7e93 --- /dev/null +++ b/openwisp_utils/measurements/models.py @@ -0,0 +1,46 @@ +from django.db import models +from openwisp_utils.base import TimeStampedEditableModel +from packaging.version import parse as parse_version + + +class OpenwispVersion(TimeStampedEditableModel): + modified = None + module_version = models.JSONField(default=dict, blank=True) + + class Meta: + ordering = ('-created',) + + @classmethod + def is_new_installation(cls): + return not cls.objects.exists() + + @classmethod + def get_upgraded_modules(cls, current_versions): + """ + Retrieves a dictionary of upgraded modules based on current versions. + Also updates the OpenwispVersion object with the new versions. + + Args: + current_versions (dict): A dictionary containing the current versions of modules. + + Returns: + dict: A dictionary containing the upgraded modules and their versions. + """ + openwisp_version = cls.objects.first() + if not openwisp_version: + cls.objects.create(module_version=current_versions) + return {} + old_versions = openwisp_version.module_version + upgraded_modules = {} + for module, version in current_versions.items(): + if module in old_versions and parse_version( + old_versions[module] + ) < parse_version(version): + upgraded_modules[module] = version + openwisp_version.module_version[module] = version + if upgraded_modules: + # Save the new versions in a new object + OpenwispVersion.objects.create( + module_version=openwisp_version.module_version + ) + return upgraded_modules diff --git a/openwisp_utils/measurements/tasks.py b/openwisp_utils/measurements/tasks.py new file mode 100644 index 00000000..7eabbf09 --- /dev/null +++ b/openwisp_utils/measurements/tasks.py @@ -0,0 +1,56 @@ +import logging + +from celery import shared_task +from openwisp_utils.admin_theme.system_info import ( + get_enabled_openwisp_modules, + get_openwisp_version, + get_os_details, +) + +from ..tasks import OpenwispCeleryTask +from ..utils import retryable_request +from .models import OpenwispVersion +from .utils import _get_events, get_openwisp_module_metrics, get_os_detail_metrics + +USER_METRIC_COLLECTION_URL = 'https://analytics.openwisp.io/cleaninsights.php' + +logger = logging.getLogger(__name__) + + +def post_usage_metrics(events): + try: + response = retryable_request( + 'post', + url=USER_METRIC_COLLECTION_URL, + json={ + 'idsite': 5, + 'events': events, + }, + max_retries=10, + ) + assert response.status_code == 204 + except Exception as error: + if isinstance(error, AssertionError): + message = f'HTTP {response.status_code} Response' + else: + message = str(error) + logger.error( + f'Collection of usage metrics failed, max retries exceeded. Error: {message}' + ) + + +@shared_task(base=OpenwispCeleryTask) +def send_usage_metrics(upgrade_only=False): + current_versions = get_enabled_openwisp_modules() + current_versions.update({'OpenWISP Version': get_openwisp_version()}) + metrics = [] + metrics.extend(get_os_detail_metrics(get_os_details())) + if OpenwispVersion.is_new_installation(): + metrics.extend(_get_events('Install', current_versions)) + OpenwispVersion.objects.create(module_version=current_versions) + else: + upgraded_modules = OpenwispVersion.get_upgraded_modules(current_versions) + metrics.extend(_get_events('Upgrade', upgraded_modules)) + if not upgrade_only: + metrics.extend(get_openwisp_module_metrics(current_versions)) + post_usage_metrics(metrics) diff --git a/openwisp_utils/measurements/tests/__init__.py b/openwisp_utils/measurements/tests/__init__.py new file mode 100644 index 00000000..5a964e0f --- /dev/null +++ b/openwisp_utils/measurements/tests/__init__.py @@ -0,0 +1,206 @@ +_ENABLED_OPENWISP_MODULES_RETURN_VALUE = { + 'openwisp-utils': '1.1.0a', + 'openwisp-users': '1.1.0a', +} +_OS_DETAILS_RETURN_VALUE = { + 'kernel_version': '5.13.0-52-generic', + 'os_version': '#59~20.04.1-Ubuntu SMP Thu Jun 16 21:21:28 UTC 2022', + 'hardware_platform': 'x86_64', +} + +_MODULES_UPGRADE_EXPECTED_METRICS = [ + { + 'category': 'OS Detail', + 'action': 'kernel_version', + 'name': '5.13.0-52-generic', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'OS Detail', + 'action': 'os_version', + 'name': '#59~20.04.1-Ubuntu SMP Thu Jun 16 21:21:28 UTC 2022', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'OS Detail', + 'action': 'hardware_platform', + 'name': 'x86_64', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'Upgrade', + 'action': 'openwisp-utils', + 'name': '1.1.0a', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'Upgrade', + 'action': 'openwisp-users', + 'name': '1.1.0a', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'Upgrade', + 'action': 'OpenWISP Version', + 'name': '23.0.0a', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'Heartbeat', + 'action': 'openwisp-utils', + 'name': '1.1.0a', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'Heartbeat', + 'action': 'openwisp-users', + 'name': '1.1.0a', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'Heartbeat', + 'action': 'OpenWISP Version', + 'name': '23.0.0a', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, +] + +_HEARTBEAT_METRICS = [ + { + 'category': 'OS Detail', + 'action': 'kernel_version', + 'name': '5.13.0-52-generic', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'OS Detail', + 'action': 'os_version', + 'name': '#59~20.04.1-Ubuntu SMP Thu Jun 16 21:21:28 UTC 2022', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'OS Detail', + 'action': 'hardware_platform', + 'name': 'x86_64', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'Heartbeat', + 'action': 'openwisp-utils', + 'name': '1.1.0a', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'Heartbeat', + 'action': 'openwisp-users', + 'name': '1.1.0a', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'Heartbeat', + 'action': 'OpenWISP Version', + 'name': '23.0.0a', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, +] +_NEW_INSTALLATION_METRICS = [ + { + 'category': 'OS Detail', + 'action': 'kernel_version', + 'name': '5.13.0-52-generic', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'OS Detail', + 'action': 'os_version', + 'name': '#59~20.04.1-Ubuntu SMP Thu Jun 16 21:21:28 UTC 2022', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'OS Detail', + 'action': 'hardware_platform', + 'name': 'x86_64', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'Install', + 'action': 'openwisp-utils', + 'name': '1.1.0a', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'Install', + 'action': 'openwisp-users', + 'name': '1.1.0a', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, + { + 'category': 'Install', + 'action': 'OpenWISP Version', + 'name': '23.0.0a', + 'value': 1, + 'times': 1, + 'period_start': 1701388800, + 'period_end': 1701388800, + }, +] diff --git a/openwisp_utils/measurements/tests/runner.py b/openwisp_utils/measurements/tests/runner.py new file mode 100644 index 00000000..ce7378e7 --- /dev/null +++ b/openwisp_utils/measurements/tests/runner.py @@ -0,0 +1,19 @@ +from unittest.mock import MagicMock + +import requests +from django.test.runner import DiscoverRunner +from openwisp_utils import utils + +success_response = requests.Response() +success_response.status_code = 204 + + +class MockRequestPostRunner(DiscoverRunner): + """ + This runner ensures that usage metrics are + not sent in development when running tests. + """ + + def setup_databases(self, **kwargs): + utils.requests.post = MagicMock(return_value=success_response) + return super().setup_databases(**kwargs) diff --git a/openwisp_utils/measurements/tests/test_models.py b/openwisp_utils/measurements/tests/test_models.py new file mode 100644 index 00000000..1a5b1c3a --- /dev/null +++ b/openwisp_utils/measurements/tests/test_models.py @@ -0,0 +1,281 @@ +from datetime import datetime, timezone +from unittest.mock import patch + +import requests +from django.apps import apps +from django.db import migrations +from django.test import TestCase, override_settings +from freezegun import freeze_time +from urllib3.response import HTTPResponse + +from .. import tasks +from ..models import OpenwispVersion +from . import ( + _ENABLED_OPENWISP_MODULES_RETURN_VALUE, + _HEARTBEAT_METRICS, + _MODULES_UPGRADE_EXPECTED_METRICS, + _NEW_INSTALLATION_METRICS, + _OS_DETAILS_RETURN_VALUE, +) + + +class TestOpenwispVersion(TestCase): + def setUp(self): + # The post_migrate signal creates the first OpenwispVersion object + # and uses the actual modules installed in the Python environment. + # This would cause tests to fail when other modules are also installed. + # import ipdb; ipdb.set_trace() + OpenwispVersion.objects.update( + module_version={ + 'OpenWISP Version': '23.0.0a', + **_ENABLED_OPENWISP_MODULES_RETURN_VALUE, + }, + created=datetime.strptime( + '2023-11-01 00:00:00', '%Y-%m-%d %H:%M:%S' + ).replace(tzinfo=timezone.utc), + ) + + def test_get_upgraded_modules_when_openwispversion_object_does_not_exist(self): + OpenwispVersion.objects.all().delete() + self.assertEqual( + OpenwispVersion.get_upgraded_modules(tasks.get_enabled_openwisp_modules()), + {}, + ) + + def test_get_upgraded_modules_on_new_installation(self): + self.assertEqual( + OpenwispVersion.get_upgraded_modules(tasks.get_enabled_openwisp_modules()), + {}, + ) + + @patch.object(tasks, 'get_openwisp_version', return_value='23.0.0a') + @patch.object( + tasks, + 'get_enabled_openwisp_modules', + return_value=_ENABLED_OPENWISP_MODULES_RETURN_VALUE, + ) + @patch.object( + tasks, + 'get_os_details', + return_value=_OS_DETAILS_RETURN_VALUE, + ) + @patch.object(tasks, 'post_usage_metrics') + @freeze_time('2023-12-01 00:00:00') + def test_new_installation(self, mocked_post, *args): + OpenwispVersion.objects.all().delete() + tasks.send_usage_metrics.delay() + mocked_post.assert_called_with(_NEW_INSTALLATION_METRICS) + self.assertEqual(OpenwispVersion.objects.count(), 1) + version = OpenwispVersion.objects.first() + expected_module_version = { + 'OpenWISP Version': '23.0.0a', + **_ENABLED_OPENWISP_MODULES_RETURN_VALUE, + } + self.assertEqual(version.module_version, expected_module_version) + + @patch.object(tasks, 'get_openwisp_version', return_value='23.0.0a') + @patch.object( + tasks, + 'get_enabled_openwisp_modules', + return_value=_ENABLED_OPENWISP_MODULES_RETURN_VALUE, + ) + @patch.object( + tasks, + 'get_os_details', + return_value=_OS_DETAILS_RETURN_VALUE, + ) + @patch.object(tasks, 'post_usage_metrics') + @freeze_time('2023-12-01 00:00:00') + def test_heartbeat(self, mocked_post, *args): + expected_module_version = { + 'OpenWISP Version': '23.0.0a', + **_ENABLED_OPENWISP_MODULES_RETURN_VALUE, + } + self.assertEqual(OpenwispVersion.objects.count(), 1) + tasks.send_usage_metrics.delay() + mocked_post.assert_called_with(_HEARTBEAT_METRICS) + self.assertEqual(OpenwispVersion.objects.count(), 1) + version = OpenwispVersion.objects.first() + self.assertEqual(version.module_version, expected_module_version) + + @patch.object(tasks, 'get_openwisp_version', return_value='23.0.0a') + @patch.object( + tasks, + 'get_enabled_openwisp_modules', + return_value=_ENABLED_OPENWISP_MODULES_RETURN_VALUE, + ) + @patch.object( + tasks, + 'get_os_details', + return_value=_OS_DETAILS_RETURN_VALUE, + ) + @patch.object(tasks, 'post_usage_metrics') + @freeze_time('2023-12-01 00:00:00') + def test_modules_upgraded(self, mocked_post, *args): + self.assertEqual(OpenwispVersion.objects.count(), 1) + OpenwispVersion.objects.update( + module_version={ + 'OpenWISP Version': '22.10.0', + 'openwisp-utils': '1.0.5', + 'openwisp-users': '1.0.2', + } + ) + tasks.send_usage_metrics.delay() + mocked_post.assert_called_with(_MODULES_UPGRADE_EXPECTED_METRICS) + + self.assertEqual(OpenwispVersion.objects.count(), 2) + version = OpenwispVersion.objects.first() + expected_module_version = { + 'OpenWISP Version': '23.0.0a', + **_ENABLED_OPENWISP_MODULES_RETURN_VALUE, + } + self.assertEqual(version.module_version, expected_module_version) + + @freeze_time('2023-12-01 00:00:00') + @patch.object(tasks, 'get_openwisp_version', return_value='23.0.0a') + @patch.object( + tasks, + 'get_enabled_openwisp_modules', + return_value=_ENABLED_OPENWISP_MODULES_RETURN_VALUE, + ) + @patch.object( + tasks, + 'get_os_details', + return_value=_OS_DETAILS_RETURN_VALUE, + ) + @patch.object(tasks, 'post_usage_metrics') + @patch.object(tasks, 'get_openwisp_module_metrics') + def test_send_usage_metrics_upgrade_only_flag( + self, mocked_get_openwisp_module_metrics, *args + ): + self.assertEqual(OpenwispVersion.objects.count(), 1) + # Store old versions of OpenWISP modules in OpenwispVersion object + OpenwispVersion.objects.update( + module_version={ + 'OpenWISP Version': '22.10.0', + 'openwisp-utils': '1.0.5', + 'openwisp-users': '1.0.2', + } + ) + tasks.send_usage_metrics.delay(upgrade_only=True) + mocked_get_openwisp_module_metrics.assert_not_called() + self.assertEqual(OpenwispVersion.objects.count(), 2) + version = OpenwispVersion.objects.first() + expected_module_version = { + 'OpenWISP Version': '23.0.0a', + **_ENABLED_OPENWISP_MODULES_RETURN_VALUE, + } + self.assertEqual(version.module_version, expected_module_version) + + @patch('time.sleep') + @patch('logging.Logger.warning') + @patch('logging.Logger.error') + def test_post_usage_metrics_400_response(self, mocked_error, mocked_warning, *args): + bad_response = requests.Response() + bad_response.status_code = 400 + with patch.object( + requests.Session, 'post', return_value=bad_response + ) as mocked_post: + tasks.send_usage_metrics.delay() + mocked_post.assert_called_once() + mocked_warning.assert_not_called() + mocked_error.assert_called_with( + 'Collection of usage metrics failed, max retries exceeded.' + ' Error: HTTP 400 Response' + ) + + @patch('urllib3.util.retry.Retry.sleep') + @patch( + 'urllib3.connectionpool.HTTPConnection.getresponse', + return_value=HTTPResponse(status=500, version='1.1'), + ) + @patch('logging.Logger.error') + def test_post_usage_metrics_500_response( + self, mocked_error, mocked_getResponse, *args + ): + tasks.send_usage_metrics.delay() + self.assertEqual(len(mocked_getResponse.mock_calls), 11) + mocked_error.assert_called_with( + 'Collection of usage metrics failed, max retries exceeded.' + ' Error: HTTPSConnectionPool(host=\'analytics.openwisp.io\', port=443):' + ' Max retries exceeded with url: /cleaninsights.php (Caused by ResponseError' + '(\'too many 500 error responses\'))' + ) + + @patch('time.sleep') + @patch('logging.Logger.warning') + @patch('logging.Logger.error') + def test_post_usage_metrics_204_response(self, mocked_error, mocked_warning, *args): + bad_response = requests.Response() + bad_response.status_code = 204 + with patch.object( + requests.Session, 'post', return_value=bad_response + ) as mocked_post: + tasks.send_usage_metrics.delay() + self.assertEqual(len(mocked_post.mock_calls), 1) + mocked_warning.assert_not_called() + mocked_error.assert_not_called() + + @patch('urllib3.util.retry.Retry.sleep') + @patch( + 'urllib3.connectionpool.HTTPConnectionPool._get_conn', + side_effect=OSError, + ) + @patch('logging.Logger.error') + def test_post_usage_metrics_connection_error( + self, mocked_error, mocked_get_conn, *args + ): + tasks.send_usage_metrics.delay() + mocked_error.assert_called_with( + 'Collection of usage metrics failed, max retries exceeded.' + ' Error: HTTPSConnectionPool(host=\'analytics.openwisp.io\', port=443):' + ' Max retries exceeded with url: /cleaninsights.php' + ' (Caused by ProtocolError(\'Connection aborted.\', OSError()))' + ) + self.assertEqual(mocked_get_conn.call_count, 11) + + @patch.object(tasks.send_usage_metrics, 'delay') + def test_post_migrate_receiver(self, mocked_task, *args): + app = apps.get_app_config('measurements') + + with self.subTest( + 'Test task is called for checking upgrades when plan is empty' + ): + app.post_migrate_receiver(plan=[]) + mocked_task.assert_called_with(upgrade_only=True) + mocked_task.reset_mock() + + with self.subTest( + 'Test task is called for checking upgrades ' + 'when first migration in plan is not for ContentTypes' + ): + app.post_migrate_receiver( + plan=[ + ( + migrations.Migration( + name='0001_initial', app_label='openwisp_users' + ), + False, + ) + ] + ) + mocked_task.assert_called_with(upgrade_only=True) + mocked_task.reset_mock() + plan = [ + ( + migrations.Migration(name='0001_initial', app_label='contenttypes'), + False, + ) + ] + + with self.subTest( + 'Test task called when first migration in plan is for ContentTypes' + ): + app.post_migrate_receiver(plan=plan) + mocked_task.assert_called_with() + mocked_task.reset_mock() + + with self.subTest('Test task not called in development'): + with override_settings(DEBUG=True): + app.post_migrate_receiver(plan=plan) + mocked_task.assert_not_called() diff --git a/openwisp_utils/measurements/utils.py b/openwisp_utils/measurements/utils.py new file mode 100644 index 00000000..c816c3a0 --- /dev/null +++ b/openwisp_utils/measurements/utils.py @@ -0,0 +1,36 @@ +from django.utils.html import escape +from django.utils.timezone import now + + +def _get_events(category, data): + """ + This function takes a category and data representing usage metrics, + and returns a list of events in a format accepted by the + Clean Insights Matomo Proxy (CIMP) API. + + Read the "Event Measurement Schema" in the CIMP documentation: + https://cutt.ly/SwBkC40A + """ + events = [] + unix_time = int(now().timestamp()) + for key, value in data.items(): + events.append( + { + 'category': category, + 'action': escape(key), + 'name': escape(value), + 'value': 1, + 'times': 1, + 'period_start': unix_time, + 'period_end': unix_time, + } + ) + return events + + +def get_openwisp_module_metrics(module_versions): + return _get_events('Heartbeat', module_versions) + + +def get_os_detail_metrics(os_detail): + return _get_events('OS Detail', os_detail) diff --git a/openwisp_utils/utils.py b/openwisp_utils/utils.py index d4190249..1193cddb 100644 --- a/openwisp_utils/utils.py +++ b/openwisp_utils/utils.py @@ -1,8 +1,11 @@ from collections import OrderedDict from copy import deepcopy +import requests from django.conf import settings from django.utils.crypto import get_random_string +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry class SortedOrderedDict(OrderedDict): @@ -62,3 +65,42 @@ def print_color(string, color_name, end='\n'): } color = color_dict.get(color_name, '0') print(f'\033[{color}m{string}\033[0m', end=end) + + +def retryable_request( + method, + timeout=(4, 8), + max_retries=3, + backoff_factor=1, + backoff_jitter=0.0, + status_forcelist=(429, 500, 502, 503, 504), + allowed_methods=('HEAD', 'GET', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST'), + retry_kwargs=None, + **kwargs, +): + retry_kwargs = retry_kwargs or {} + retry_kwargs.update( + dict( + total=max_retries, + backoff_factor=backoff_factor, + status_forcelist=status_forcelist, + allowed_methods=allowed_methods, + backoff_jitter=backoff_jitter, + ) + ) + retry_kwargs = retry_kwargs or {} + retry_kwargs.update( + dict( + total=max_retries, + backoff_factor=backoff_factor, + backoff_jitter=backoff_jitter, + status_forcelist=status_forcelist, + allowed_methods=allowed_methods, + ) + ) + request_session = requests.Session() + retries = Retry(**retry_kwargs) + request_session.mount('https://', HTTPAdapter(max_retries=retries)) + request_session.mount('http://', HTTPAdapter(max_retries=retries)) + request_method = getattr(request_session, method) + return request_method(timeout=timeout, **kwargs) diff --git a/requirements-test.txt b/requirements-test.txt index a4fd17a5..35090b90 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,3 +1,3 @@ # For testing Dependency loaders openwisp_controller @ https://github.com/openwisp/openwisp-controller/tarball/master - +freezegun diff --git a/runtests.py b/runtests.py index 5427a446..4dbbe8a1 100755 --- a/runtests.py +++ b/runtests.py @@ -13,4 +13,5 @@ args = sys.argv args.insert(1, "test") args.insert(2, "test_project") + args.insert(3, "openwisp_utils.measurements") execute_from_command_line(args) diff --git a/tests/openwisp2/__init__.py b/tests/openwisp2/__init__.py index 62bcec8d..e61ef422 100644 --- a/tests/openwisp2/__init__.py +++ b/tests/openwisp2/__init__.py @@ -1 +1,5 @@ +from .celery import app as celery_app + +__all__ = ['celery_app'] + __openwisp_version__ = '23.0.0a' diff --git a/tests/openwisp2/celery.py b/tests/openwisp2/celery.py new file mode 100644 index 00000000..e3c1425d --- /dev/null +++ b/tests/openwisp2/celery.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'openwisp2.settings') + +app = Celery('openwisp2') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py index 7e3493d6..737f4327 100644 --- a/tests/openwisp2/settings.py +++ b/tests/openwisp2/settings.py @@ -19,6 +19,7 @@ # test project 'test_project', 'openwisp_utils.admin_theme', + 'openwisp_utils.measurements', 'django.contrib.sites', # admin 'django.contrib.admin', @@ -81,7 +82,7 @@ DATABASES = { 'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': 'openwisp_utils.db'} } - +TEST_RUNNER = 'openwisp_utils.measurements.tests.runner.MockRequestPostRunner' OPENWISP_ADMIN_SITE_CLASS = 'test_project.site.CustomAdminSite' SITE_ID = 1 @@ -124,6 +125,10 @@ ] OPENWISP_ADMIN_THEME_JS = ['dummy.js'] +CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_EAGER_PROPAGATES = True +CELERY_BROKER_URL = 'memory://' + # local settings must be imported before test runner otherwise they'll be ignored try: from local_settings import * diff --git a/tests/test_project/tests/test_test_utils.py b/tests/test_project/tests/test_test_utils.py index f228eb71..417d286a 100644 --- a/tests/test_project/tests/test_test_utils.py +++ b/tests/test_project/tests/test_test_utils.py @@ -1,5 +1,5 @@ import sys -import unittest +from unittest.mock import patch from django.dispatch import Signal from django.test import TestCase, override_settings @@ -11,7 +11,9 @@ capture_stdout, catch_signal, ) -from openwisp_utils.utils import deep_merge_dicts, print_color +from openwisp_utils.utils import deep_merge_dicts, print_color, retryable_request +from requests.exceptions import ConnectionError, RetryError +from urllib3.response import HTTPResponse from ..models import Shelf @@ -87,12 +89,66 @@ def test_capture_stderr(self, captured_error): print('Testing capture_stderr', file=sys.stderr, end='') self.assertEqual(captured_error.getvalue(), 'Testing capture_stderr') + @patch('urllib3.util.retry.Retry.sleep') + def test_retryable_request(self, *args): + with self.subTest('Test failure to connect to server'): + with patch( + 'urllib3.connectionpool.HTTPConnectionPool._get_conn', + side_effect=OSError, + ) as mocked__get_conn: + with self.assertRaises(ConnectionError) as error: + retryable_request('get', url='https://openwisp.org') + self.assertEqual(len(mocked__get_conn.mock_calls), 4) + self.assertIn( + 'OSError', + str(error.exception), + ) + + with self.subTest('Test retry on server error'): + # Simulates the test never recovered + with patch( + 'urllib3.connectionpool.HTTPConnection.getresponse', + return_value=HTTPResponse(status=500, version='1.1'), + ) as mocked_getResponse: + with self.assertRaises(RetryError) as error: + retryable_request('get', url='https://openwisp.org') + self.assertEqual(len(mocked_getResponse.mock_calls), 4) + self.assertIn( + 'too many 500 error responses', + str(error.exception), + ) + + with self.subTest('Test customization with retry_kwargs'): + with patch( + 'openwisp_utils.utils.Retry', + ) as mocked_retry, patch('openwisp_utils.utils.requests.Session'): + retryable_request( + method='get', + url='https://openwisp.org', + max_retries=10, + backoff_factor=2, + backoff_jitter=0.2, + status_forcelist=(429, 500), + allowed_methods=('HEAD',), + retry_kwargs={'raise_on_redirect': False}, + ) + self.assertDictEqual( + mocked_retry.call_args.kwargs, + { + 'total': 10, + 'backoff_factor': 2, + 'status_forcelist': (429, 500), + 'allowed_methods': ('HEAD',), + 'backoff_jitter': 0.2, + 'raise_on_redirect': False, + }, + ) + self.assertEqual(mocked_retry.call_args[1]['total'], 10) + class TestAssertNumQueriesSubTest(AssertNumQueriesSubTestMixin, TestCase): def test_assert_num_queries(self): - with unittest.mock.patch.object( - self, 'subTest', wraps=self.subTest - ) as patched_subtest: + with patch.object(self, 'subTest', wraps=self.subTest) as patched_subtest: with self.assertNumQueries(1): Shelf.objects.count() patched_subtest.assert_called_once()