From 5af33facb6cebd1956bc9d5b0a64130c7748a13f Mon Sep 17 00:00:00 2001 From: Ariel Rin Date: Sat, 3 Jul 2021 04:43:16 +0000 Subject: [PATCH] Analytics Module --- allianceauth/analytics/__init__.py | 1 + allianceauth/analytics/admin.py | 21 ++ allianceauth/analytics/apps.py | 9 + .../analytics/fixtures/disable_analytics.json | 21 ++ allianceauth/analytics/middleware.py | 41 ++++ .../analytics/migrations/0001_initial.py | 42 ++++ .../migrations/0002_add_AA_Team_Token.py | 34 +++ .../migrations/0003_Generate_Identifier.py | 30 +++ allianceauth/analytics/migrations/__init__.py | 0 allianceauth/analytics/models.py | 38 ++++ allianceauth/analytics/signals.py | 50 +++++ allianceauth/analytics/tasks.py | 207 ++++++++++++++++++ allianceauth/analytics/tests/__init__.py | 0 .../analytics/tests/test_middleware.py | 23 ++ allianceauth/analytics/tests/test_models.py | 26 +++ allianceauth/analytics/tests/test_tasks.py | 119 ++++++++++ allianceauth/analytics/tests/test_utils.py | 55 +++++ allianceauth/analytics/utils.py | 36 +++ .../project_name/settings/base.py | 6 + .../modules/discord/tests/test_integration.py | 3 + .../images/features/core/analytics/tokens.png | Bin 0 -> 29346 bytes docs/features/core/analytics.md | 63 ++++++ docs/features/core/index.md | 1 + setup.py | 1 + 24 files changed, 827 insertions(+) create mode 100644 allianceauth/analytics/__init__.py create mode 100644 allianceauth/analytics/admin.py create mode 100644 allianceauth/analytics/apps.py create mode 100644 allianceauth/analytics/fixtures/disable_analytics.json create mode 100644 allianceauth/analytics/middleware.py create mode 100644 allianceauth/analytics/migrations/0001_initial.py create mode 100644 allianceauth/analytics/migrations/0002_add_AA_Team_Token.py create mode 100644 allianceauth/analytics/migrations/0003_Generate_Identifier.py create mode 100644 allianceauth/analytics/migrations/__init__.py create mode 100644 allianceauth/analytics/models.py create mode 100644 allianceauth/analytics/signals.py create mode 100644 allianceauth/analytics/tasks.py create mode 100644 allianceauth/analytics/tests/__init__.py create mode 100644 allianceauth/analytics/tests/test_middleware.py create mode 100644 allianceauth/analytics/tests/test_models.py create mode 100644 allianceauth/analytics/tests/test_tasks.py create mode 100644 allianceauth/analytics/tests/test_utils.py create mode 100644 allianceauth/analytics/utils.py create mode 100644 docs/_static/images/features/core/analytics/tokens.png create mode 100644 docs/features/core/analytics.md diff --git a/allianceauth/analytics/__init__.py b/allianceauth/analytics/__init__.py new file mode 100644 index 00000000..5e4f9545 --- /dev/null +++ b/allianceauth/analytics/__init__.py @@ -0,0 +1 @@ +default_app_config = 'allianceauth.analytics.apps.AnalyticsConfig' diff --git a/allianceauth/analytics/admin.py b/allianceauth/analytics/admin.py new file mode 100644 index 00000000..28a38b48 --- /dev/null +++ b/allianceauth/analytics/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin + +from .models import AnalyticsIdentifier, AnalyticsPath, AnalyticsTokens + + +@admin.register(AnalyticsIdentifier) +class AnalyticsIdentifierAdmin(admin.ModelAdmin): + search_fields = ['identifier', ] + list_display = ('identifier',) + + +@admin.register(AnalyticsTokens) +class AnalyticsTokensAdmin(admin.ModelAdmin): + search_fields = ['name', ] + list_display = ('name', 'type',) + + +@admin.register(AnalyticsPath) +class AnalyticsPathAdmin(admin.ModelAdmin): + search_fields = ['ignore_path', ] + list_display = ('ignore_path',) diff --git a/allianceauth/analytics/apps.py b/allianceauth/analytics/apps.py new file mode 100644 index 00000000..85050412 --- /dev/null +++ b/allianceauth/analytics/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class AnalyticsConfig(AppConfig): + name = 'allianceauth.analytics' + label = 'analytics' + + def ready(self): + import allianceauth.analytics.signals diff --git a/allianceauth/analytics/fixtures/disable_analytics.json b/allianceauth/analytics/fixtures/disable_analytics.json new file mode 100644 index 00000000..d97ff6c1 --- /dev/null +++ b/allianceauth/analytics/fixtures/disable_analytics.json @@ -0,0 +1,21 @@ +[ + { + "model": "analytics.AnalyticsTokens", + "pk": 1, + "fields": { + "name": "AA Team Public Google Analytics (Universal)", + "type": "GA-V4", + "token": "UA-186249766-2", + "send_page_views": "False", + "send_celery_tasks": "False", + "send_stats": "False" + } + }, + { + "model": "analytics.AnalyticsIdentifier", + "pk": 1, + "fields": { + "identifier": "ab33e241fbf042b6aa77c7655a768af7" + } + } +] diff --git a/allianceauth/analytics/middleware.py b/allianceauth/analytics/middleware.py new file mode 100644 index 00000000..d3876dee --- /dev/null +++ b/allianceauth/analytics/middleware.py @@ -0,0 +1,41 @@ +from bs4 import BeautifulSoup + +from django.utils.deprecation import MiddlewareMixin +from .models import AnalyticsTokens, AnalyticsIdentifier +from .tasks import send_ga_tracking_web_view + + +class AnalyticsMiddleware(MiddlewareMixin): + def process_response(self, request, response): + """Django Middleware: Process Page Views and creates Analytics Celery Tasks""" + analyticstokens = AnalyticsTokens.objects.all() + client_id = AnalyticsIdentifier.objects.get(id=1).identifier.hex + try: + title = BeautifulSoup( + response.content, "html.parser").html.head.title.text + except AttributeError: + title = '' + for token in analyticstokens: + # Check if Page View Sending is Disabled + if token.send_page_views is False: + continue + # Check Exclusions + if request.path in token.ignore_paths.all(): + continue + + tracking_id = token.token + locale = request.LANGUAGE_CODE + path = request.path + try: + useragent = request.headers["User-Agent"] + except KeyError: + useragent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36" + + send_ga_tracking_web_view.s(tracking_id=tracking_id, + client_id=client_id, + page=path, + title=title, + locale=locale, + useragent=useragent).\ + apply_async(priority=9) + return response diff --git a/allianceauth/analytics/migrations/0001_initial.py b/allianceauth/analytics/migrations/0001_initial.py new file mode 100644 index 00000000..c5a1f33a --- /dev/null +++ b/allianceauth/analytics/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 3.1.4 on 2020-12-30 13:11 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AnalyticsIdentifier', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('identifier', models.UUIDField(default=uuid.uuid4, editable=False)), + ], + ), + migrations.CreateModel( + name='AnalyticsPath', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ignore_path', models.CharField(default='/example/', max_length=254)), + ], + ), + migrations.CreateModel( + name='AnalyticsTokens', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=254)), + ('type', models.CharField(choices=[('GA-U', 'Google Analytics Universal'), ('GA-V4', 'Google Analytics V4')], max_length=254)), + ('token', models.CharField(max_length=254)), + ('send_page_views', models.BooleanField(default=False)), + ('send_celery_tasks', models.BooleanField(default=False)), + ('send_stats', models.BooleanField(default=False)), + ('ignore_paths', models.ManyToManyField(blank=True, to='analytics.AnalyticsPath')), + ], + ), + ] diff --git a/allianceauth/analytics/migrations/0002_add_AA_Team_Token.py b/allianceauth/analytics/migrations/0002_add_AA_Team_Token.py new file mode 100644 index 00000000..b64a7f5d --- /dev/null +++ b/allianceauth/analytics/migrations/0002_add_AA_Team_Token.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1.4 on 2020-12-30 08:53 + +from django.db import migrations + + +def add_aa_team_token(apps, schema_editor): + # We can't import the Person model directly as it may be a newer + # version than this migration expects. We use the historical version. + Tokens = apps.get_model('analytics', 'AnalyticsTokens') + token = Tokens() + token.type = 'GA-U' + token.token = 'UA-186249766-2' + token.send_page_views = True + token.send_celery_views = True + token.send_stats = True + token.name = 'AA Team Public Google Analytics (Universal)' + token.save() + + +def remove_aa_team_token(apps, schema_editor): + # Have to define some code to remove this identifier + # In case of migration rollback? + Tokens = apps.get_model('analytics', 'AnalyticsTokens') + token = Tokens.objects.filter(token="UA-186249766-2").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('analytics', '0001_initial'), + ] + + operations = [migrations.RunPython(add_aa_team_token, remove_aa_team_token) + ] diff --git a/allianceauth/analytics/migrations/0003_Generate_Identifier.py b/allianceauth/analytics/migrations/0003_Generate_Identifier.py new file mode 100644 index 00000000..5c4daba8 --- /dev/null +++ b/allianceauth/analytics/migrations/0003_Generate_Identifier.py @@ -0,0 +1,30 @@ +# Generated by Django 3.1.4 on 2020-12-30 08:53 + +from uuid import uuid4 +from django.db import migrations + + +def generate_identifier(apps, schema_editor): + # We can't import the Person model directly as it may be a newer + # version than this migration expects. We use the historical version. + Identifier = apps.get_model('analytics', 'AnalyticsIdentifier') + identifier = Identifier() + identifier.id = 1 + identifier.save() + + +def zero_identifier(apps, schema_editor): + # Have to define some code to remove this identifier + # In case of migration rollback? + Identifier = apps.get_model('analytics', 'AnalyticsIdentifier') + Identifier.objects.filter(id=1).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('analytics', '0002_add_AA_Team_Token'), + ] + + operations = [migrations.RunPython(generate_identifier, zero_identifier) + ] diff --git a/allianceauth/analytics/migrations/__init__.py b/allianceauth/analytics/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/allianceauth/analytics/models.py b/allianceauth/analytics/models.py new file mode 100644 index 00000000..508435af --- /dev/null +++ b/allianceauth/analytics/models.py @@ -0,0 +1,38 @@ +from django.db import models +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from uuid import uuid4 + + +class AnalyticsIdentifier(models.Model): + + identifier = models.UUIDField(default=uuid4, + editable=False) + + def save(self, *args, **kwargs): + if not self.pk and AnalyticsIdentifier.objects.exists(): + # Force a single object + raise ValidationError('There is can be only one \ + AnalyticsIdentifier instance') + self.pk = self.id = 1 # If this happens to be deleted and recreated, force it to be 1 + return super(AnalyticsIdentifier, self).save(*args, **kwargs) + + +class AnalyticsPath(models.Model): + ignore_path = models.CharField(max_length=254, default="/example/") + + +class AnalyticsTokens(models.Model): + + class Analytics_Type(models.TextChoices): + GA_U = 'GA-U', _('Google Analytics Universal') + GA_V4 = 'GA-V4', _('Google Analytics V4') + + name = models.CharField(max_length=254) + type = models.CharField(max_length=254, choices=Analytics_Type.choices) + token = models.CharField(max_length=254, blank=False) + send_page_views = models.BooleanField(default=False) + send_celery_tasks = models.BooleanField(default=False) + send_stats = models.BooleanField(default=False) + ignore_paths = models.ManyToManyField(AnalyticsPath, blank=True) diff --git a/allianceauth/analytics/signals.py b/allianceauth/analytics/signals.py new file mode 100644 index 00000000..39ff87af --- /dev/null +++ b/allianceauth/analytics/signals.py @@ -0,0 +1,50 @@ +from allianceauth.analytics.tasks import analytics_event +from celery.signals import task_failure, task_success + +import logging +logger = logging.getLogger(__name__) + + +@task_failure.connect +def process_failure_signal( + exception, traceback, + sender, task_id, signal, + args, kwargs, einfo, **kw): + logger.debug("Celery task_failure signal %s" % sender.__class__.__name__) + + category = sender.__module__ + + if 'allianceauth.analytics' not in category: + if category.endswith(".tasks"): + category = category[:-6] + + action = sender.__name__ + + label = f"{exception.__class__.__name__}: {str(exception)}" + + analytics_event(category=category, + action=action, + label=label) + + +@task_success.connect +def celery_success_signal(sender, result=None, **kw): + logger.debug("Celery task_success signal %s" % sender.__class__.__name__) + + category = sender.__module__ + + if 'allianceauth.analytics' not in category: + if category.endswith(".tasks"): + category = category[:-6] + + action = sender.__name__ + label = "Success" + + value = 0 + if isinstance(result, int): + value = result + + analytics_event(category=category, + action=action, + label=label, + value=value) diff --git a/allianceauth/analytics/tasks.py b/allianceauth/analytics/tasks.py new file mode 100644 index 00000000..7fe22477 --- /dev/null +++ b/allianceauth/analytics/tasks.py @@ -0,0 +1,207 @@ +import requests +import logging +from django.conf import settings +from django.apps import apps +from celery import shared_task +from allianceauth import __version__ +from .models import AnalyticsTokens, AnalyticsIdentifier +from .utils import ( + install_stat_addons, + install_stat_tokens, + install_stat_users) + +logger = logging.getLogger(__name__) + +BASE_URL = "https://www.google-analytics.com/" + +DEBUG_URL = f"{BASE_URL}debug/collect" +COLLECTION_URL = f"{BASE_URL}collect" + +if getattr(settings, "ANALYTICS_ENABLE_DEBUG", False) and settings.DEBUG: + # Force sending of analytics data during in a debug/test environemt + # Usefull for developers working on this feature. + logger.warning( + "You have 'ANALYTICS_ENABLE_DEBUG' Enabled! " + "This debug instance will send analytics data!") + DEBUG_URL = COLLECTION_URL + +ANALYTICS_URL = COLLECTION_URL + +if settings.DEBUG is True: + ANALYTICS_URL = DEBUG_URL + + +def analytics_event(category: str, + action: str, + label: str, + value: int = 0, + event_type: str = 'Celery'): + """ + Send a Google Analytics Event for each token stored + Includes check for if its enabled/disabled + + Parameters + ------- + `category` (str): Celery Namespace + `action` (str): Task Name + `label` (str): Optional, Task Success/Exception + `value` (int): Optional, If bulk, Query size, can be a binary True/False + `event_type` (str): Optional, Celery or Stats only, Default to Celery + """ + analyticstokens = AnalyticsTokens.objects.all() + client_id = AnalyticsIdentifier.objects.get(id=1).identifier.hex + for token in analyticstokens: + if event_type == 'Celery': + allowed = token.send_celery_tasks + elif event_type == 'Stats': + allowed = token.send_stats + else: + allowed = False + + if allowed is True: + tracking_id = token.token + send_ga_tracking_celery_event.s(tracking_id=tracking_id, + client_id=client_id, + category=category, + action=action, + label=label, + value=value).\ + apply_async(priority=9) + + +@shared_task() +def analytics_daily_stats(): + """Celery Task: Do not call directly + + Gathers a series of daily statistics and sends analytics events containing them""" + users = install_stat_users() + tokens = install_stat_tokens() + addons = install_stat_addons() + logger.debug("Running Daily Analytics Upload") + + analytics_event(category='allianceauth.analytics', + action='send_install_stats', + label='existence', + value=1, + event_type='Stats') + analytics_event(category='allianceauth.analytics', + action='send_install_stats', + label='users', + value=users, + event_type='Stats') + analytics_event(category='allianceauth.analytics', + action='send_install_stats', + label='tokens', + value=tokens, + event_type='Stats') + analytics_event(category='allianceauth.analytics', + action='send_install_stats', + label='addons', + value=addons, + event_type='Stats') + + for appconfig in apps.get_app_configs(): + analytics_event(category='allianceauth.analytics', + action='send_extension_stats', + label=appconfig.label, + value=1, + event_type='Stats') + + +@shared_task() +def send_ga_tracking_web_view( + tracking_id: str, + client_id: str, + page: str, + title: str, + locale: str, + useragent: str) -> requests.Response: + + """Celery Task: Do not call directly + + Sends Page View events to GA, Called only via analytics.middleware + + Parameters + ---------- + `tracking_id` (str): Unique Server Identifier + `client_id` (str): GA Token + `page` (str): Page Path + `title` (str): Page Title + `locale` (str): Browser Language + `useragent` (str): Browser UserAgent + + Returns + ------- + requests.Reponse Object + """ + headers = {"User-Agent": useragent} + + payload = { + 'v': '1', + 'tid': tracking_id, + 'cid': client_id, + 't': 'pageview', + 'dp': page, + 'dt': title, + 'ul': locale, + 'ua': useragent, + 'aip': 1, + 'an': "allianceauth", + 'av': __version__ + } + + response = requests.post( + ANALYTICS_URL, data=payload, + timeout=5, headers=headers) + logger.debug(f"Analytics Page View HTTP{response.status_code}") + return response + + +@shared_task() +def send_ga_tracking_celery_event( + tracking_id: str, + client_id: str, + category: str, + action: str, + label: str, + value: int) -> requests.Response: + """Celery Task: Do not call directly + + Sends Page View events to GA, Called only via analytics.middleware + + Parameters + ---------- + `tracking_id` (str): Unique Server Identifier + `client_id` (str): GA Token + `category` (str): Celery Namespace + `action` (str): Task Name + `label` (str): Optional, Task Success/Exception + `value` (int): Optional, If bulk, Query size, can be a binary True/False + + Returns + ------- + requests.Reponse Object + """ + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"} + + payload = { + 'v': '1', + 'tid': tracking_id, + 'cid': client_id, + 't': 'event', + 'ec': category, + 'ea': action, + 'el': label, + 'ev': value, + 'aip': 1, + 'an': "allianceauth", + 'av': __version__ + } + + response = requests.post( + ANALYTICS_URL, data=payload, + timeout=5, headers=headers) + logger.debug(f"Analytics Celery/Stats Event HTTP{response.status_code}") + return response diff --git a/allianceauth/analytics/tests/__init__.py b/allianceauth/analytics/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/allianceauth/analytics/tests/test_middleware.py b/allianceauth/analytics/tests/test_middleware.py new file mode 100644 index 00000000..eecfbbc7 --- /dev/null +++ b/allianceauth/analytics/tests/test_middleware.py @@ -0,0 +1,23 @@ +from allianceauth.analytics.middleware import AnalyticsMiddleware +from unittest.mock import Mock + +from django.test.testcases import TestCase + + +class TestAnalyticsMiddleware(TestCase): + + def setUp(self): + self.middleware = AnalyticsMiddleware() + self.request = Mock() + self.request.headers = { + "User-Agent": "AUTOMATED TEST" + } + self.request.path = '/testURL/' + self.request.session = {} + self.request.LANGUAGE_CODE = 'en' + self.response = Mock() + self.response.content = 'hello world' + + def test_middleware(self): + response = self.middleware.process_response(self.request, self.response) + self.assertEqual(self.response, response) diff --git a/allianceauth/analytics/tests/test_models.py b/allianceauth/analytics/tests/test_models.py new file mode 100644 index 00000000..fd424f11 --- /dev/null +++ b/allianceauth/analytics/tests/test_models.py @@ -0,0 +1,26 @@ +from allianceauth.analytics.models import AnalyticsIdentifier +from django.core.exceptions import ValidationError + +from django.test.testcases import TestCase + +from uuid import UUID, uuid4 + + +# Identifiers +uuid_1 = "ab33e241fbf042b6aa77c7655a768af7" +uuid_2 = "7aa6bd70701f44729af5e3095ff4b55c" + + +class TestAnalyticsIdentifier(TestCase): + + def test_identifier_random(self): + self.assertNotEqual(AnalyticsIdentifier.objects.get(), uuid4) + + def test_identifier_singular(self): + AnalyticsIdentifier.objects.all().delete() + AnalyticsIdentifier.objects.create(identifier=uuid_1) + # Yeah i have multiple asserts here, they all do the same thing + with self.assertRaises(ValidationError): + AnalyticsIdentifier.objects.create(identifier=uuid_2) + self.assertEqual(AnalyticsIdentifier.objects.count(), 1) + self.assertEqual(AnalyticsIdentifier.objects.get(pk=1).identifier, UUID(uuid_1)) diff --git a/allianceauth/analytics/tests/test_tasks.py b/allianceauth/analytics/tests/test_tasks.py new file mode 100644 index 00000000..badc59f5 --- /dev/null +++ b/allianceauth/analytics/tests/test_tasks.py @@ -0,0 +1,119 @@ +from allianceauth.analytics.tasks import ( + analytics_event, + send_ga_tracking_celery_event, + send_ga_tracking_web_view) +from django.test.testcases import TestCase + + +class TestAnalyticsTasks(TestCase): + def test_analytics_event(self): + analytics_event( + category='allianceauth.analytics', + action='send_tests', + label='test', + value=1, + event_type='Stats') + + def test_send_ga_tracking_web_view_sent(self): + # This test sends if the event SENDS to google + # Not if it was successful + tracking_id = 'UA-186249766-2' + client_id = 'ab33e241fbf042b6aa77c7655a768af7' + page = '/index/' + title = 'Hello World' + locale = 'en' + useragent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36" + response = send_ga_tracking_web_view( + tracking_id, + client_id, + page, + title, + locale, + useragent) + self.assertEqual(response.status_code, 200) + + def test_send_ga_tracking_web_view_success(self): + tracking_id = 'UA-186249766-2' + client_id = 'ab33e241fbf042b6aa77c7655a768af7' + page = '/index/' + title = 'Hello World' + locale = 'en' + useragent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36" + json_response = send_ga_tracking_web_view( + tracking_id, + client_id, + page, + title, + locale, + useragent).json() + self.assertTrue(json_response["hitParsingResult"][0]["valid"]) + + def test_send_ga_tracking_web_view_invalid_token(self): + tracking_id = 'UA-IntentionallyBadTrackingID-2' + client_id = 'ab33e241fbf042b6aa77c7655a768af7' + page = '/index/' + title = 'Hello World' + locale = 'en' + useragent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36" + json_response = send_ga_tracking_web_view( + tracking_id, + client_id, + page, + title, + locale, + useragent).json() + self.assertFalse(json_response["hitParsingResult"][0]["valid"]) + self.assertEqual(json_response["hitParsingResult"][0]["parserMessage"][1]["description"], "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.") + + # [{'valid': False, 'parserMessage': [{'messageType': 'INFO', 'description': 'IP Address from this hit was anonymized to 1.132.110.0.', 'messageCode': 'VALUE_MODIFIED'}, {'messageType': 'ERROR', 'description': "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.", 'messageCode': 'VALUE_INVALID', 'parameter': 'tid'}], 'hit': '/debug/collect?v=1&tid=UA-IntentionallyBadTrackingID-2&cid=ab33e241fbf042b6aa77c7655a768af7&t=pageview&dp=/index/&dt=Hello World&ul=en&ua=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36&aip=1&an=allianceauth&av=2.9.0a2'}] + + def test_send_ga_tracking_celery_event_sent(self): + tracking_id = 'UA-186249766-2' + client_id = 'ab33e241fbf042b6aa77c7655a768af7' + category = 'test' + action = 'test' + label = 'test' + value = '1' + response = send_ga_tracking_celery_event( + tracking_id, + client_id, + category, + action, + label, + value) + self.assertEqual(response.status_code, 200) + + def test_send_ga_tracking_celery_event_success(self): + tracking_id = 'UA-186249766-2' + client_id = 'ab33e241fbf042b6aa77c7655a768af7' + category = 'test' + action = 'test' + label = 'test' + value = '1' + json_response = send_ga_tracking_celery_event( + tracking_id, + client_id, + category, + action, + label, + value).json() + self.assertTrue(json_response["hitParsingResult"][0]["valid"]) + + def test_send_ga_tracking_celery_event_invalid_token(self): + tracking_id = 'UA-IntentionallyBadTrackingID-2' + client_id = 'ab33e241fbf042b6aa77c7655a768af7' + category = 'test' + action = 'test' + label = 'test' + value = '1' + json_response = send_ga_tracking_celery_event( + tracking_id, + client_id, + category, + action, + label, + value).json() + self.assertFalse(json_response["hitParsingResult"][0]["valid"]) + self.assertEqual(json_response["hitParsingResult"][0]["parserMessage"][1]["description"], "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.") + + # [{'valid': False, 'parserMessage': [{'messageType': 'INFO', 'description': 'IP Address from this hit was anonymized to 1.132.110.0.', 'messageCode': 'VALUE_MODIFIED'}, {'messageType': 'ERROR', 'description': "The value provided for parameter 'tid' is invalid. Please see http://goo.gl/a8d4RP#tid for details.", 'messageCode': 'VALUE_INVALID', 'parameter': 'tid'}], 'hit': '/debug/collect?v=1&tid=UA-IntentionallyBadTrackingID-2&cid=ab33e241fbf042b6aa77c7655a768af7&t=pageview&dp=/index/&dt=Hello World&ul=en&ua=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36&aip=1&an=allianceauth&av=2.9.0a2'}] diff --git a/allianceauth/analytics/tests/test_utils.py b/allianceauth/analytics/tests/test_utils.py new file mode 100644 index 00000000..0da45c23 --- /dev/null +++ b/allianceauth/analytics/tests/test_utils.py @@ -0,0 +1,55 @@ +from django.apps import apps +from allianceauth.authentication.models import User +from esi.models import Token +from allianceauth.analytics.utils import install_stat_users, install_stat_tokens, install_stat_addons + +from django.test.testcases import TestCase + + +def create_testdata(): + User.objects.all().delete() + User.objects.create_user( + 'user_1' + 'abc@example.com', + 'password' + ) + User.objects.create_user( + 'user_2' + 'abc@example.com', + 'password' + ) + #Token.objects.all().delete() + #Token.objects.create( + # character_id=101, + # character_name='character1', + # access_token='my_access_token' + #) + #Token.objects.create( + # character_id=102, + # character_name='character2', + # access_token='my_access_token' + #) + + +class TestAnalyticsUtils(TestCase): + + def test_install_stat_users(self): + create_testdata() + expected = 2 + + users = install_stat_users() + self.assertEqual(users, expected) + + #def test_install_stat_tokens(self): + # create_testdata() + # expected = 2 + # + # tokens = install_stat_tokens() + # self.assertEqual(tokens, expected) + + def test_install_stat_addons(self): + # this test does what its testing... + # but helpful for existing as a sanity check + expected = len(list(apps.get_app_configs())) + addons = install_stat_addons() + self.assertEqual(addons, expected) diff --git a/allianceauth/analytics/utils.py b/allianceauth/analytics/utils.py new file mode 100644 index 00000000..e8c57927 --- /dev/null +++ b/allianceauth/analytics/utils.py @@ -0,0 +1,36 @@ +from django.apps import apps +from allianceauth.authentication.models import User +from esi.models import Token + + +def install_stat_users() -> int: + """Count and Return the number of User accounts + + Returns + ------- + int + The Number of User objects""" + users = User.objects.count() + return users + + +def install_stat_tokens() -> int: + """Count and Return the number of ESI Tokens Stored + + Returns + ------- + int + The Number of Token Objects""" + tokens = Token.objects.count() + return tokens + + +def install_stat_addons() -> int: + """Count and Return the number of Django Applications Installed + + Returns + ------- + int + The Number of Installed Apps""" + addons = len(list(apps.get_app_configs())) + return addons diff --git a/allianceauth/project_template/project_name/settings/base.py b/allianceauth/project_template/project_name/settings/base.py index 96c331ff..7fdc29b3 100644 --- a/allianceauth/project_template/project_name/settings/base.py +++ b/allianceauth/project_template/project_name/settings/base.py @@ -30,6 +30,7 @@ INSTALLED_APPS = [ 'allianceauth.groupmanagement', 'allianceauth.notifications', 'allianceauth.thirdparty.navhelper', + 'allianceauth.analytics', ] SECRET_KEY = "wow I'm a really bad default secret key" @@ -53,6 +54,10 @@ CELERYBEAT_SCHEDULE = { 'check_all_character_ownership': { 'task': 'allianceauth.authentication.tasks.check_all_character_ownership', 'schedule': crontab(minute=0, hour='*/4'), + }, + 'analytics_daily_stats': { + 'task': 'allianceauth.analytics.tasks.analytics_daily_stats', + 'schedule': crontab(minute=0, hour=2), } } @@ -69,6 +74,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'allianceauth.analytics.middleware.AnalyticsMiddleware', ] ROOT_URLCONF = 'allianceauth.urls' diff --git a/allianceauth/services/modules/discord/tests/test_integration.py b/allianceauth/services/modules/discord/tests/test_integration.py index 2fc4d408..4a944693 100644 --- a/allianceauth/services/modules/discord/tests/test_integration.py +++ b/allianceauth/services/modules/discord/tests/test_integration.py @@ -107,6 +107,8 @@ def reset_testdata(): @override_settings(CELERY_ALWAYS_EAGER=True) @requests_mock.Mocker() class TestServiceFeatures(TransactionTestCase): + fixtures = ['disable_analytics.json'] + @classmethod def setUpClass(cls): @@ -434,6 +436,7 @@ class StateTestCase(TestCase): @patch(MODULE_PATH + '.models.DISCORD_GUILD_ID', TEST_GUILD_ID) @requests_mock.Mocker() class TestUserFeatures(WebTest): + fixtures = ['disable_analytics.json'] def setUp(self): clear_cache() diff --git a/docs/_static/images/features/core/analytics/tokens.png b/docs/_static/images/features/core/analytics/tokens.png new file mode 100644 index 0000000000000000000000000000000000000000..7c0c7176d511b677efb00474b73f0269e4062ee1 GIT binary patch literal 29346 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 ZqAQd=?z@k-gV&qB|IX%I#hL53{tFrTgp&XO literal 0 HcmV?d00001 diff --git a/docs/features/core/analytics.md b/docs/features/core/analytics.md new file mode 100644 index 00000000..7b750ada --- /dev/null +++ b/docs/features/core/analytics.md @@ -0,0 +1,63 @@ +# Analytics FAQ + +**Alliance Auth** has an opt-out analytics module using [Google Analytics Measurement Protocol](https://developers.google.com/analytics/devguides/collection/protocol/v1/). + +## How to Opt-Out + +Before you proceed, please read through this page and/or raise any concerns on the Alliance Auth discord. This data helps us make AA better. + +To Opt-Out, modify our pre-loaded token using the Admin dashboard */admin/analytics/analyticstokens/1/change/ + +Each of the three features Daily Stats, Celery Events and Page Views can be enabled/Disabled independently. + +![Analytics Tokens](/_static/images/features/core/analytics/tokens.png) + +## What + +Alliance Auth has taken great care to anonymize the data sent. In order to identify _unique_ installs we generate a UUIDv4, a random mathematical construct which does not contain any identifying information [UUID - UUID Objects](https://docs.python.org/3/library/uuid.html#uuid.uuid4) + +Analytics comes pre-loaded with our Google Analytics Token, and the Three Types of task can be opted out independently. Analytics can also be loaded with your _own_ GA token and the analytics module will act any/all tokens loaded. + +Our Daily Stats contain the following: + +- A phone-in task to identify a servers existence +- A task to send the Number of User models +- A task to send the Number of Token Models +- A task to send the Number of Installed Apps +- A task to send a List of Installed Apps +- Each Task contains the UUID and Alliance Auth Version + +Our Celery Events contain the following: + +- Unique Identifier (The UUID) +- Celery Namespace of the task eg allianceauth.eveonline +- Celery Task +- Task Success or Exception +- A context number for bulk tasks or sometimes a binary True/False + +Our Page Views contain the following: + +- Unique Identifier (The UUID) +- Page Path +- Page Title +- The locale of the users browser +- The User-Agent of the users browser +- The Alliance Auth Version + +## Why + +This data allows Alliance Auth development to gather accurate statistics on our install base, as well as how those installs are used. + +This allows us to better target our development time to commonly used modules and features and test them at the scales in use. + +## Where + +This data is stored in a Team Google Analytics Dashboard. The Maintainers all have Management permissions here, and if you have contributed to the Alliance Auth project or third party applications feel free to ask in the Alliance Auth discord for access. + +## Using Analytics in my App + +### Analytics Event + +.. automodule:: allianceauth.analytics.tasks + :members: analytics_event + :undoc-members: diff --git a/docs/features/core/index.md b/docs/features/core/index.md index 4fa74139..ec2003c7 100644 --- a/docs/features/core/index.md +++ b/docs/features/core/index.md @@ -10,5 +10,6 @@ Managing access to applications and services is one of the core functions of **A states groups groupmanagement + analytics notifications ``` diff --git a/setup.py b/setup.py index 5c9b37eb..52841339 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,7 @@ install_requires = [ 'requests-oauthlib', 'semantic_version', 'packaging>=20.1,<21', + 'beautifulsoup4', 'redis>=3.3.1,<4.0.0', 'celery>=4.3.0,<5.0.0,!=4.4.4', # 4.4.4 is missing a dependency