diff --git a/analytical/templatetags/analytical.py b/analytical/templatetags/analytical.py index 8ab2cdb..6e74bd7 100644 --- a/analytical/templatetags/analytical.py +++ b/analytical/templatetags/analytical.py @@ -23,6 +23,7 @@ TAG_MODULES = [ 'analytical.facebook_pixel', 'analytical.gauges', 'analytical.google_analytics', + 'analytical.google_analytics_js', 'analytical.gosquared', 'analytical.hotjar', 'analytical.hubspot', diff --git a/analytical/templatetags/google_analytics.py b/analytical/templatetags/google_analytics.py index d7e7768..e876f17 100644 --- a/analytical/templatetags/google_analytics.py +++ b/analytical/templatetags/google_analytics.py @@ -1,5 +1,7 @@ """ Google Analytics template tags and filters. + +DEPRECATED """ from __future__ import absolute_import diff --git a/analytical/templatetags/google_analytics_js.py b/analytical/templatetags/google_analytics_js.py new file mode 100644 index 0000000..377d1f8 --- /dev/null +++ b/analytical/templatetags/google_analytics_js.py @@ -0,0 +1,155 @@ +""" +Google Analytics template tags and filters, using the new analytics.js library. +""" + +from __future__ import absolute_import + +import decimal +import re +from django.conf import settings +from django.template import Library, Node, TemplateSyntaxError + +from analytical.utils import ( + AnalyticalException, + disable_html, + get_domain, + get_required_setting, + is_internal_ip, +) + +TRACK_SINGLE_DOMAIN = 1 +TRACK_MULTIPLE_SUBDOMAINS = 2 +TRACK_MULTIPLE_DOMAINS = 3 + +PROPERTY_ID_RE = re.compile(r'^UA-\d+-\d+$') +SETUP_CODE = """ + +""" +REQUIRE_DISPLAY_FEATURES = "ga('require', 'displayfeatures');" +CUSTOM_VAR_CODE = "ga('set', '{name}', {value});" +ANONYMIZE_IP_CODE = "ga('set', 'anonymizeIp', true);" + +register = Library() + + +@register.tag +def google_analytics_js(parser, token): + """ + Google Analytics tracking template tag. + + Renders Javascript code to track page visits. You must supply + your website property ID (as a string) in the + ``GOOGLE_ANALYTICS_JS_PROPERTY_ID`` setting. + """ + bits = token.split_contents() + if len(bits) > 1: + raise TemplateSyntaxError("'%s' takes no arguments" % bits[0]) + return GoogleAnalyticsJsNode() + + +class GoogleAnalyticsJsNode(Node): + def __init__(self): + self.property_id = get_required_setting( + 'GOOGLE_ANALYTICS_JS_PROPERTY_ID', PROPERTY_ID_RE, + "must be a string looking like 'UA-XXXXXX-Y'") + + def render(self, context): + import json + create_fields = self._get_domain_fields(context) + create_fields.update(self._get_other_create_fields(context)) + commands = self._get_custom_var_commands(context) + commands.extend(self._get_other_commands(context)) + display_features = getattr(settings, 'GOOGLE_ANALYTICS_DISPLAY_ADVERTISING', False) + html = SETUP_CODE.format( + property_id=self.property_id, + create_fields=json.dumps(create_fields), + display_features=REQUIRE_DISPLAY_FEATURES if display_features else '', + commands=" ".join(commands), + ) + if is_internal_ip(context, 'GOOGLE_ANALYTICS'): + html = disable_html(html, 'Google Analytics') + return html + + def _get_domain_fields(self, context): + domain_fields = {} + tracking_type = getattr(settings, 'GOOGLE_ANALYTICS_TRACKING_STYLE', TRACK_SINGLE_DOMAIN) + if tracking_type == TRACK_SINGLE_DOMAIN: + pass + else: + domain = get_domain(context, 'google_analytics') + if domain is None: + raise AnalyticalException( + "tracking multiple domains with Google Analytics requires a domain name") + domain_fields['legacyCookieDomain'] = domain + if tracking_type == TRACK_MULTIPLE_DOMAINS: + domain_fields['allowLinker'] = True + return domain_fields + + def _get_other_create_fields(self, context): + other_fields = {} + + site_speed_sample_rate = getattr(settings, 'GOOGLE_ANALYTICS_SITE_SPEED_SAMPLE_RATE', False) + if site_speed_sample_rate is not False: + value = int(decimal.Decimal(site_speed_sample_rate)) + if not 0 <= value <= 100: + raise AnalyticalException( + "'GOOGLE_ANALYTICS_SITE_SPEED_SAMPLE_RATE' must be >= 0 and <= 100") + other_fields['siteSpeedSampleRate'] = value + + sample_rate = getattr(settings, 'GOOGLE_ANALYTICS_SAMPLE_RATE', False) + if sample_rate is not False: + value = int(decimal.Decimal(sample_rate)) + if not 0 <= value <= 100: + raise AnalyticalException("'GOOGLE_ANALYTICS_SAMPLE_RATE' must be >= 0 and <= 100") + other_fields['sampleRate'] = value + + cookie_expires = getattr(settings, 'GOOGLE_ANALYTICS_COOKIE_EXPIRATION', False) + if cookie_expires is not False: + value = int(decimal.Decimal(cookie_expires)) + if value < 0: + raise AnalyticalException("'GOOGLE_ANALYTICS_COOKIE_EXPIRATION' must be >= 0") + other_fields['cookieExpires'] = value + + return other_fields + + def _get_custom_var_commands(self, context): + values = ( + context.get('google_analytics_var%s' % i) for i in range(1, 6) + ) + params = [(i, v) for i, v in enumerate(values, 1) if v is not None] + commands = [] + for _, var in params: + name = var[0] + value = var[1] + try: + float(value) + except ValueError: + value = "'{}'".format(value) + commands.append(CUSTOM_VAR_CODE.format( + name=name, + value=value, + )) + return commands + + def _get_other_commands(self, context): + commands = [] + + if getattr(settings, 'GOOGLE_ANALYTICS_ANONYMIZE_IP', False): + commands.append(ANONYMIZE_IP_CODE) + + return commands + + +def contribute_to_analytical(add_node): + GoogleAnalyticsJsNode() # ensure properly configured + add_node('head_bottom', GoogleAnalyticsJsNode) diff --git a/analytical/tests/test_tag_google_analytics_js.py b/analytical/tests/test_tag_google_analytics_js.py new file mode 100644 index 0000000..517eb8b --- /dev/null +++ b/analytical/tests/test_tag_google_analytics_js.py @@ -0,0 +1,169 @@ +""" +Tests for the Google Analytics template tags and filters, using the new analytics.js library. +""" + +from django.http import HttpRequest +from django.template import Context +from django.test.utils import override_settings + +from analytical.templatetags.google_analytics_js import GoogleAnalyticsJsNode, \ + TRACK_SINGLE_DOMAIN, TRACK_MULTIPLE_DOMAINS, TRACK_MULTIPLE_SUBDOMAINS +from analytical.tests.utils import TestCase, TagTestCase +from analytical.utils import AnalyticalException + + +@override_settings(GOOGLE_ANALYTICS_JS_PROPERTY_ID='UA-123456-7', + GOOGLE_ANALYTICS_TRACKING_STYLE=TRACK_SINGLE_DOMAIN) +class GoogleAnalyticsTagTestCase(TagTestCase): + """ + Tests for the ``google_analytics_js`` template tag. + """ + + def test_tag(self): + r = self.render_tag('google_analytics_js', 'google_analytics_js') + self.assertTrue("""(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ +(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), +m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) +})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');""" in r, r) + self.assertTrue("ga('create', 'UA-123456-7', 'auto', {});" in r, r) + self.assertTrue("ga('send', 'pageview');" in r, r) + + def test_node(self): + r = GoogleAnalyticsJsNode().render(Context()) + self.assertTrue("""(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ +(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), +m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) +})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');""" in r, r) + self.assertTrue("ga('create', 'UA-123456-7', 'auto', {});" in r, r) + self.assertTrue("ga('send', 'pageview');" in r, r) + + @override_settings(GOOGLE_ANALYTICS_JS_PROPERTY_ID=None) + def test_no_property_id(self): + self.assertRaises(AnalyticalException, GoogleAnalyticsJsNode) + + @override_settings(GOOGLE_ANALYTICS_JS_PROPERTY_ID='wrong') + def test_wrong_property_id(self): + self.assertRaises(AnalyticalException, GoogleAnalyticsJsNode) + + @override_settings(GOOGLE_ANALYTICS_TRACKING_STYLE=TRACK_MULTIPLE_SUBDOMAINS, + GOOGLE_ANALYTICS_DOMAIN='example.com') + def test_track_multiple_subdomains(self): + r = GoogleAnalyticsJsNode().render(Context()) + self.assertTrue( + """ga('create', 'UA-123456-7', 'auto', {"legacyCookieDomain": "example.com"}""" in r, r) + + @override_settings(GOOGLE_ANALYTICS_TRACKING_STYLE=TRACK_MULTIPLE_DOMAINS, + GOOGLE_ANALYTICS_DOMAIN='example.com') + def test_track_multiple_domains(self): + r = GoogleAnalyticsJsNode().render(Context()) + self.assertTrue("ga('create', 'UA-123456-7', 'auto', {" in r, r) + self.assertTrue('"legacyCookieDomain": "example.com"' in r, r) + self.assertTrue('"allowLinker\": true' in r, r) + + def test_custom_vars(self): + context = Context({ + 'google_analytics_var1': ('test1', 'foo'), + 'google_analytics_var2': ('test2', 'bar'), + 'google_analytics_var4': ('test4', 1), + 'google_analytics_var5': ('test5', 2.2), + }) + r = GoogleAnalyticsJsNode().render(context) + self.assertTrue("ga('set', 'test1', 'foo');" in r, r) + self.assertTrue("ga('set', 'test2', 'bar');" in r, r) + self.assertTrue("ga('set', 'test4', 1);" in r, r) + self.assertTrue("ga('set', 'test5', 2.2);" in r, r) + + def test_display_advertising(self): + with override_settings(GOOGLE_ANALYTICS_DISPLAY_ADVERTISING=True): + r = GoogleAnalyticsJsNode().render(Context()) + self.assertTrue("""ga('create', 'UA-123456-7', 'auto', {}); +ga('require', 'displayfeatures'); +ga('send', 'pageview');""" in r, r) + + @override_settings(ANALYTICAL_INTERNAL_IPS=['1.1.1.1']) + def test_render_internal_ip(self): + req = HttpRequest() + req.META['REMOTE_ADDR'] = '1.1.1.1' + context = Context({'request': req}) + r = GoogleAnalyticsJsNode().render(context) + self.assertTrue(r.startswith( + ''), r) + + @override_settings(GOOGLE_ANALYTICS_ANONYMIZE_IP=True) + def test_anonymize_ip(self): + r = GoogleAnalyticsJsNode().render(Context()) + self.assertTrue("ga('set', 'anonymizeIp', true);" in r, r) + + @override_settings(GOOGLE_ANALYTICS_ANONYMIZE_IP=False) + def test_anonymize_ip_not_present(self): + r = GoogleAnalyticsJsNode().render(Context()) + self.assertFalse("ga('set', 'anonymizeIp', true);" in r, r) + + @override_settings(GOOGLE_ANALYTICS_SAMPLE_RATE=0.0) + def test_set_sample_rate_min(self): + r = GoogleAnalyticsJsNode().render(Context()) + self.assertTrue("""ga('create', 'UA-123456-7', 'auto', {"sampleRate": 0});""" in r, r) + + @override_settings(GOOGLE_ANALYTICS_SAMPLE_RATE='100.00') + def test_set_sample_rate_max(self): + r = GoogleAnalyticsJsNode().render(Context()) + self.assertTrue("""ga('create', 'UA-123456-7', 'auto', {"sampleRate": 100});""" in r, r) + + @override_settings(GOOGLE_ANALYTICS_SAMPLE_RATE=-1) + def test_exception_whenset_sample_rate_too_small(self): + context = Context() + self.assertRaises(AnalyticalException, GoogleAnalyticsJsNode().render, context) + + @override_settings(GOOGLE_ANALYTICS_SAMPLE_RATE=101) + def test_exception_when_set_sample_rate_too_large(self): + context = Context() + self.assertRaises(AnalyticalException, GoogleAnalyticsJsNode().render, context) + + @override_settings(GOOGLE_ANALYTICS_SITE_SPEED_SAMPLE_RATE=0.0) + def test_set_site_speed_sample_rate_min(self): + r = GoogleAnalyticsJsNode().render(Context()) + self.assertTrue( + """ga('create', 'UA-123456-7', 'auto', {"siteSpeedSampleRate": 0});""" in r, r) + + @override_settings(GOOGLE_ANALYTICS_SITE_SPEED_SAMPLE_RATE='100.00') + def test_set_site_speed_sample_rate_max(self): + r = GoogleAnalyticsJsNode().render(Context()) + self.assertTrue( + """ga('create', 'UA-123456-7', 'auto', {"siteSpeedSampleRate": 100});""" in r, r) + + @override_settings(GOOGLE_ANALYTICS_SITE_SPEED_SAMPLE_RATE=-1) + def test_exception_whenset_site_speed_sample_rate_too_small(self): + context = Context() + self.assertRaises(AnalyticalException, GoogleAnalyticsJsNode().render, context) + + @override_settings(GOOGLE_ANALYTICS_SITE_SPEED_SAMPLE_RATE=101) + def test_exception_when_set_site_speed_sample_rate_too_large(self): + context = Context() + self.assertRaises(AnalyticalException, GoogleAnalyticsJsNode().render, context) + + @override_settings(GOOGLE_ANALYTICS_COOKIE_EXPIRATION=0) + def test_set_cookie_expiration_min(self): + r = GoogleAnalyticsJsNode().render(Context()) + self.assertTrue("""ga('create', 'UA-123456-7', 'auto', {"cookieExpires": 0});""" in r, r) + + @override_settings(GOOGLE_ANALYTICS_COOKIE_EXPIRATION='10000') + def test_set_cookie_expiration_as_string(self): + r = GoogleAnalyticsJsNode().render(Context()) + self.assertTrue( + """ga('create', 'UA-123456-7', 'auto', {"cookieExpires": 10000});""" in r, r) + + @override_settings(GOOGLE_ANALYTICS_COOKIE_EXPIRATION=-1) + def test_exception_when_set_cookie_expiration_too_small(self): + context = Context() + self.assertRaises(AnalyticalException, GoogleAnalyticsJsNode().render, context) + + +@override_settings(GOOGLE_ANALYTICS_JS_PROPERTY_ID='UA-123456-7', + GOOGLE_ANALYTICS_TRACKING_STYLE=TRACK_MULTIPLE_DOMAINS, + GOOGLE_ANALYTICS_DOMAIN=None, + ANALYTICAL_DOMAIN=None) +class NoDomainTestCase(TestCase): + def test_exception_without_domain(self): + context = Context() + self.assertRaises(AnalyticalException, GoogleAnalyticsJsNode().render, context) diff --git a/docs/services/google_analytics.rst b/docs/services/google_analytics.rst index c34ba36..94587de 100644 --- a/docs/services/google_analytics.rst +++ b/docs/services/google_analytics.rst @@ -1,5 +1,5 @@ ====================================== - Google Analytics -- traffic analysis + Google Analytics (legacy) -- traffic analysis ====================================== `Google Analytics`_ is the well-known web analytics service from @@ -15,7 +15,7 @@ features. Installation ============ -To start using the Google Analytics integration, you must have installed +To start using the Google Analytics (legacy) integration, you must have installed the django-analytical package and have added the ``analytical`` application to :const:`INSTALLED_APPS` in your project :file:`settings.py` file. See :doc:`../install` for details. @@ -72,7 +72,7 @@ Tracking multiple domains The default code is suitable for tracking a single domain. If you track multiple domains, set the :const:`GOOGLE_ANALYTICS_TRACKING_STYLE` -setting to one of the :const:`analytical.templatetags.google_analytics.SCOPE_*` +setting to one of the :const:`analytical.templatetags.google_analytics.TRACK_*` constants: ============================= ===== ============================================= diff --git a/docs/services/google_analytics_js.rst b/docs/services/google_analytics_js.rst new file mode 100644 index 0000000..764e023 --- /dev/null +++ b/docs/services/google_analytics_js.rst @@ -0,0 +1,227 @@ +====================================== + Google Analytics -- traffic analysis +====================================== + +`Google Analytics`_ is the well-known web analytics service from +Google. The product is aimed more at marketers than webmasters or +technologists, supporting integration with AdWords and other e-commence +features. + +.. _`Google Analytics`: http://www.google.com/analytics/ + + +.. google-analytics-installation: + +Installation +============ + +To start using the Google Analytics integration, you must have installed +the django-analytical package and have added the ``analytical`` +application to :const:`INSTALLED_APPS` in your project +:file:`settings.py` file. See :doc:`../install` for details. + +Next you need to add the Google Analytics template tag to your +templates. This step is only needed if you are not using the generic +:ttag:`analytical.*` tags. If you are, skip to +:ref:`google-analytics-configuration`. + +The Google Analytics tracking code is inserted into templates using a +template tag. Load the :mod:`google_analytics_js` template tag library and +insert the :ttag:`google_analytics_js` tag. Because every page that you +want to track must have the tag, it is useful to add it to your base +template. Insert the tag at the bottom of the HTML head:: + + {% load google_analytics_js %} + + + ... + {% google_analytics_js %} + + ... + + +.. _google-analytics-configuration: + +Configuration +============= + +Before you can use the Google Analytics integration, you must first set +your website property ID. If you track multiple domains with the same +code, you also need to set-up the domain. Finally, you can add custom +segments for Google Analytics to track. + + +.. _google-analytics-property-id: + +Setting the property ID +----------------------- + +Every website you track with Google Analytics gets its own property ID, +and the :ttag:`google_analytics_js` tag will include it in the rendered +Javascript code. You can find the web property ID on the overview page +of your account. Set :const:`GOOGLE_ANALYTICS_JS_PROPERTY_ID` in the +project :file:`settings.py` file:: + + GOOGLE_ANALYTICS_JS_PROPERTY_ID = 'UA-XXXXXX-X' + +If you do not set a property ID, the tracking code will not be rendered. + + +Tracking multiple domains +------------------------- + +The default code is suitable for tracking a single domain. If you track +multiple domains, set the :const:`GOOGLE_ANALYTICS_TRACKING_STYLE` +setting to one of the :const:`analytical.templatetags.google_analytics_js.TRACK_*` +constants: + +============================= ===== ============================================= +Constant Value Description +============================= ===== ============================================= +``TRACK_SINGLE_DOMAIN`` 1 Track one domain. +``TRACK_MULTIPLE_SUBDOMAINS`` 2 Track multiple subdomains of the same top + domain (e.g. `fr.example.com` and + `nl.example.com`). +``TRACK_MULTIPLE_DOMAINS`` 3 Track multiple top domains (e.g. `example.fr` + and `example.nl`). +============================= ===== ============================================= + +As noted, the default tracking style is +:const:`~analytical.templatetags.google_analytics_js.TRACK_SINGLE_DOMAIN`. + +When you track multiple (sub)domains, django-analytical needs to know +what domain name to pass to Google Analytics. If you use the contrib +sites app, the domain is automatically picked up from the current +:const:`~django.contrib.sites.models.Site` instance. Otherwise, you may +either pass the domain to the template tag through the context variable +:const:`google_analytics_domain` (fallback: :const:`analytical_domain`) +or set it in the project :file:`settings.py` file using +:const:`GOOGLE_ANALYTICS_DOMAIN` (fallback: :const:`ANALYTICAL_DOMAIN`). + +Display Advertising +------------------- + +Display Advertising allows you to view Demographics and Interests reports, +add Remarketing Lists and support DoubleClick Campain Manager integration. + +You can enable `Display Advertising features`_ by setting the +:const:`GOOGLE_ANALYTICS_DISPLAY_ADVERTISING` configuration setting:: + + GOOGLE_ANALYTICS_DISPLAY_ADVERTISING = True + +By default, display advertising features are disabled. + +.. _`Display Advertising features`: https://support.google.com/analytics/answer/3450482 + + +.. _google-analytics-internal-ips: + +Internal IP addresses +--------------------- + +Usually you do not want to track clicks from your development or +internal IP addresses. By default, if the tags detect that the client +comes from any address in the :const:`GOOGLE_ANALYTICS_INTERNAL_IPS` +setting, the tracking code is commented out. It takes the value of +:const:`ANALYTICAL_INTERNAL_IPS` by default (which in turn is +:const:`INTERNAL_IPS` by default). See :ref:`identifying-visitors` for +important information about detecting the visitor IP address. + + +.. _google-analytics-custom-variables: + +Custom variables +---------------- + +As described in the Google Analytics `custom variables`_ documentation +page, you can define custom segments. Using template context variables +``google_analytics_var1`` through ``google_analytics_var5``, you can let +the :ttag:`google_analytics_js` tag pass custom variables to Google +Analytics automatically. You can set the context variables in your view +when your render a template containing the tracking code:: + + context = RequestContext({'google_analytics_var1': ('gender', 'female'), + 'google_analytics_var2': ('visit', 1)}) + return some_template.render(context) + +The value of the context variable is a tuple *(name, value)*. + +You may want to set custom variables in a context processor that you add +to the :data:`TEMPLATE_CONTEXT_PROCESSORS` list in :file:`settings.py`:: + + def google_analytics_segment_language(request): + try: + return {'google_analytics_var3': request.LANGUAGE_CODE} + except AttributeError: + return {} + +Just remember that if you set the same context variable in the +:class:`~django.template.context.RequestContext` constructor and in a +context processor, the latter clobbers the former. + +.. _`custom variables`: https://developers.google.com/analytics/devguides/collection/upgrade/reference/gajs-analyticsjs#custom-vars + + +.. _google-analytics-anonimyze-ips: + +Anonymize IPs +------------- + +You can enable the `IP anonymization`_ feature by setting the +:const:`GOOGLE_ANALYTICS_ANONYMIZE_IP` configuration setting:: + + GOOGLE_ANALYTICS_ANONYMIZE_IP = True + +This may be mandatory for deployments in countries that have a firm policies +concerning data privacy (e.g. Germany). + +By default, IPs are not anonymized. + +.. _`IP anonymization`: https://support.google.com/analytics/bin/answer.py?hl=en&answer=2763052 + + +.. _google-analytics-sample-rate: + +Sample Rate +----------- + +You can configure the `Sample Rate`_ feature by setting the +:const:`GOOGLE_ANALYTICS_SAMPLE_RATE` configuration setting:: + + GOOGLE_ANALYTICS_SAMPLE_RATE = 10 + +The value is a percentage and can be between 0 and 100 and can be a string or +integer value. + +.. _`Sample Rate`: https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#sampleRate + + +.. _google-analytics-site-speed-sample-rate: + +Site Speed Sample Rate +---------------------- + +You can configure the `Site Speed Sample Rate`_ feature by setting the +:const:`GOOGLE_ANALYTICS_SITE_SPEED_SAMPLE_RATE` configuration setting:: + + GOOGLE_ANALYTICS_SITE_SPEED_SAMPLE_RATE = 10 + +The value is a percentage and can be between 0 and 100 and can be a string or +integer value. + +.. _`Site Speed Sample Rate`: https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#siteSpeedSampleRate + + +.. _google-analytics-cookie-expiration: + +Cookie Expiration +---------------------- + +You can configure the `Cookie Expiration`_ feature by setting the +:const:`GOOGLE_ANALYTICS_COOKIE_EXPIRATION` configuration setting:: + + GOOGLE_ANALYTICS_COOKIE_EXPIRATION = 3600000 + +The value is the cookie expiration in seconds or 0 to delete the cookie when the browser is closed. + +.. _`Cookie Expiration`: https://developers.google.com/analytics/devguides/collection/gajs/methods/gaJSApiBasicConfiguration#_setsessioncookietimeout