From 26a46a6aa7b38b774c6e458e75a06ea36ee89345 Mon Sep 17 00:00:00 2001 From: Ivo Branco Date: Thu, 3 Feb 2022 11:46:14 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(web=5Fanalytics)=20improve=20web=20an?= =?UTF-8?q?alytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Differentiate between Google Analytics and Google Tag Manager js code as different Web Analytics providers. Send web analytics event when an user enrolls to a course using the LMS enrollment api. Closes #1565 --- CHANGELOG.md | 4 + docs/web-analytics.md | 21 +++- .../js/data/useCourseEnrollment/index.ts | 4 + src/frontend/js/types/WebAnalytics.ts | 17 +++ src/frontend/js/types/commonDataProps.ts | 1 + .../types/web-analytics/google_analytics.d.ts | 17 +++ .../web-analytics/google_tag_manager.d.ts | 19 ++++ .../js/utils/api/web-analytics/base.ts | 23 ++++ .../web-analytics/google_analytics.spec.ts | 24 ++++ .../api/web-analytics/google_analytics.ts | 26 +++++ .../web-analytics/google_tag_manager.spec.ts | 23 ++++ .../api/web-analytics/google_tag_manager.ts | 33 ++++++ .../js/utils/api/web-analytics/index.ts | 24 ++++ .../api/web-analytics/no_provider.spec.ts | 13 +++ .../web-analytics/unknown_provider.spec.ts | 15 +++ src/frontend/js/utils/test/factories.ts | 1 + src/richie/apps/core/context_processors.py | 13 +++ .../core/templates/richie/web_analytics.html | 15 ++- tests/apps/core/test_pages.py | 5 + tests/apps/core/test_web_analytics.py | 104 ++++++++++++++++-- 20 files changed, 387 insertions(+), 15 deletions(-) create mode 100644 src/frontend/js/types/WebAnalytics.ts create mode 100644 src/frontend/js/types/web-analytics/google_analytics.d.ts create mode 100644 src/frontend/js/types/web-analytics/google_tag_manager.d.ts create mode 100644 src/frontend/js/utils/api/web-analytics/base.ts create mode 100644 src/frontend/js/utils/api/web-analytics/google_analytics.spec.ts create mode 100644 src/frontend/js/utils/api/web-analytics/google_analytics.ts create mode 100644 src/frontend/js/utils/api/web-analytics/google_tag_manager.spec.ts create mode 100644 src/frontend/js/utils/api/web-analytics/google_tag_manager.ts create mode 100644 src/frontend/js/utils/api/web-analytics/index.ts create mode 100644 src/frontend/js/utils/api/web-analytics/no_provider.spec.ts create mode 100644 src/frontend/js/utils/api/web-analytics/unknown_provider.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d54ea7b7aa..8d807a3edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Versioning](https://semver.org/spec/v2.0.0.html). ## [Unrealeased] +- Differentiate between Google Analytics and Google Tag Manager js code as + different Web Analytics providers. +- Send web analytics event when an user enrolls to a course using the + LMS enrollment api. - Show organization acronym by using the `menu_title` field (if set) on the organization page. - Improve pagination blocks labels for screen reader users when there are diff --git a/docs/web-analytics.md b/docs/web-analytics.md index 57e6e52e0e..b34bd78516 100644 --- a/docs/web-analytics.md +++ b/docs/web-analytics.md @@ -4,12 +4,12 @@ title: Add web analytics to your site sidebar_label: Web Analytics --- -The purpose of this file is to document how you can enable a Web Analytics solution. -Currently Richie only supports by default the Google Analytics using the Google Tag Manager Javascript. -But it is possible with little cost to add support for other web analytics solutions. +Richie has native support to [Google Analytics](#google-analytics) and [Google Tag Manager](#google-tag-manager) Web Analytics solutions. +The purpose of this file is to explain how you can enable one of the supported Web Analytics providers +and how you can extend Richie with an alternative solution. ## Google Analytics -Next, it is decribed how you can configure the **Google Analytics** on your Richie site. +Next, it is described how you can configure the **Google Analytics** on your Richie site. - Add the `WEB_ANALYTICS_ID` setting, with your Google Analytics tracking id code. @@ -21,6 +21,14 @@ Custom dimensions with a value as example: * Course runs resource links - `http://example.edx:8073/courses/course-v1:edX+DemoX+Demo_Course/info` * Page title - `Introduction to Programming` +## Google Tag Manager +Next, it is described how you can configure the **Google Tag Manager** on your Richie site. + +- Add the `WEB_ANALYTICS_ID` setting, with your Google Tag Manager tracking id code. +- Add the `WEB_ANALYTICS_PROVIDER` setting with the `google_tag_manager` value. + +The current Google Tag Manager implementation also defines a custom dimensions like the [Google Analytics](#google-analytics). + ## Location of the web analytics javascript Use the `WEB_ANALYTICS_LOCATION` settings to decide where do you want to put the Javascript code. Use `head` (**default** value), to put the Javascript on HTML header, or `footer`, to put the Javascript code to the bottom of the body. @@ -80,3 +88,8 @@ Example, if you only need the organization codes on your custom `richie/web_anal console.log("organization codes: '{{ WEB_ANALYTICS.DIMENSIONS.organizations_codes |join:" | " }}"); ``` + +The frontend code also sends **events** to the web analytics provider. +Richie sends events when the user is enrolled on a course run. +To support different providers, you need to create a similar file +of `src/frontend/js/utils/api/web-analytics/google_analytics.ts` and change the `src/frontend/js/utils/api/web-analytics/index.ts` file to include that newer provider. diff --git a/src/frontend/js/data/useCourseEnrollment/index.ts b/src/frontend/js/data/useCourseEnrollment/index.ts index 9e90009939..1e37fb8d18 100644 --- a/src/frontend/js/data/useCourseEnrollment/index.ts +++ b/src/frontend/js/data/useCourseEnrollment/index.ts @@ -3,6 +3,7 @@ import EnrollmentApiInterface from 'utils/api/enrollment'; import { useSession } from 'data/SessionProvider'; import { useSessionQuery } from 'utils/react-query/useSessionQuery'; import { useSessionMutation } from 'utils/react-query/useSessionMutation'; +import WebAnalyticsAPIHandler from 'utils/api/web-analytics'; /** * Hook to manage an enrollment related to a `resource_link`. It provides interface to @@ -37,6 +38,9 @@ const useCourseEnrollment = (resourceLink: string) => { mutationKey: queryKey, onSuccess: () => { queryClient.invalidateQueries(queryKey); + + // After enrolls the user, then send enrolled event to the web analytics handler. + WebAnalyticsAPIHandler()?.sendEnrolledEvent(resourceLink); }, }); diff --git a/src/frontend/js/types/WebAnalytics.ts b/src/frontend/js/types/WebAnalytics.ts new file mode 100644 index 0000000000..e00b98bc1b --- /dev/null +++ b/src/frontend/js/types/WebAnalytics.ts @@ -0,0 +1,17 @@ +/** + * Interface for every the Web Analytics API. + * Each web analytics provider should implement this interface + * to adapt the calls from React to the external JS. + */ +export interface WebAnalyticsAPI { + /** + * Sends the enrolled user to a course event. + * @param resourceLink the course link that the user have been enrolled + */ + sendEnrolledEvent(resourceLink: string): void; +} + +export enum WebAnalyticsAPIBackend { + GOOGLE_ANALYTICS = 'google_analytics', + GOOGLE_TAG_MANAGER = 'google_tag_manager', +} diff --git a/src/frontend/js/types/commonDataProps.ts b/src/frontend/js/types/commonDataProps.ts index 6bd1ee4e7f..8f33f675ce 100644 --- a/src/frontend/js/types/commonDataProps.ts +++ b/src/frontend/js/types/commonDataProps.ts @@ -25,5 +25,6 @@ export interface CommonDataProps { joanie_backend?: JoanieBackend; release: string; sentry_dsn: Nullable; + web_analytics_provider?: Nullable; }; } diff --git a/src/frontend/js/types/web-analytics/google_analytics.d.ts b/src/frontend/js/types/web-analytics/google_analytics.d.ts new file mode 100644 index 0000000000..a3e0da78df --- /dev/null +++ b/src/frontend/js/types/web-analytics/google_analytics.d.ts @@ -0,0 +1,17 @@ +/** + * Declare the Google Analytics ga function. + */ +import type { Maybe } from 'types/utils'; + +declare global { + const ga: Maybe< + ( + tracker: string, + hitType: string, + eventCategory: string, + eventAction: string, + eventLabel: string, + eventValue?: any, + ) => void + >; +} diff --git a/src/frontend/js/types/web-analytics/google_tag_manager.d.ts b/src/frontend/js/types/web-analytics/google_tag_manager.d.ts new file mode 100644 index 0000000000..f66491f4e0 --- /dev/null +++ b/src/frontend/js/types/web-analytics/google_tag_manager.d.ts @@ -0,0 +1,19 @@ +/** + * Declare the Google Tag Manager `window.dataLayer` array. + * Used when the web analytics is configured with `google_tag_manager`. + * + * So we can use `window.dataLayer` has normal JS code. + * + * window.dataLayer.push({ + * event: 'event', + * eventProps: { + * category: category, + * action: action, + * label: label, + * value: value + * } + * }); + */ +interface Window { + dataLayer?: Record[]; +} diff --git a/src/frontend/js/utils/api/web-analytics/base.ts b/src/frontend/js/utils/api/web-analytics/base.ts new file mode 100644 index 0000000000..8032eaf363 --- /dev/null +++ b/src/frontend/js/utils/api/web-analytics/base.ts @@ -0,0 +1,23 @@ +import { WebAnalyticsAPI } from 'types/WebAnalytics'; + +/** + * Base implementation for each web analytics provider. + */ +export abstract class BaseWebAnalyticsApi implements WebAnalyticsAPI { + /** + * Abstract method to send events by the overridden web analytics API providers. + * @param category the category of the event + * @param action the action that the user has performed or the action of the event. + * @param label additional info about specific elements to identify a product like the course run. + * @param value + */ + abstract sendEvent(category: string, action: string, label: string, value?: number): void; + + /** + * Sends the enrolled user to a course event. + * @param resourceLink the course link that the user have been enrolled + */ + sendEnrolledEvent(resourceLink: string): void { + return this.sendEvent('courseEnroll', 'courseEnrollApi', resourceLink); + } +} diff --git a/src/frontend/js/utils/api/web-analytics/google_analytics.spec.ts b/src/frontend/js/utils/api/web-analytics/google_analytics.spec.ts new file mode 100644 index 0000000000..1677a34540 --- /dev/null +++ b/src/frontend/js/utils/api/web-analytics/google_analytics.spec.ts @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { ContextFactory as mockContextFactory } from 'utils/test/factories'; +import WebAnalyticsAPIHandler from '.'; +import GoogleAnalyticsApi from './google_analytics'; + +jest.mock('utils/context', () => ({ + __esModule: true, + default: mockContextFactory({ + web_analytics_provider: 'google_analytics', + }).generate(), +})); + +describe('Web Analytics', () => { + beforeAll(() => { + // Mock the `ga` function so the verification of the presence of the Google Analytics passes. + (global as any).ga = jest.fn(); + }); + + it('returns the Google Analytics API if provider is `google_analytics`', () => { + const api = WebAnalyticsAPIHandler(); + expect(api).toBeDefined(); + expect(api).toBeInstanceOf(GoogleAnalyticsApi); + }); +}); diff --git a/src/frontend/js/utils/api/web-analytics/google_analytics.ts b/src/frontend/js/utils/api/web-analytics/google_analytics.ts new file mode 100644 index 0000000000..013ed537c2 --- /dev/null +++ b/src/frontend/js/utils/api/web-analytics/google_analytics.ts @@ -0,0 +1,26 @@ +/* eslint-disable class-methods-use-this */ +import { BaseWebAnalyticsApi } from './base'; + +/** + * + * Google Analytics Richie Web Analytics API Implementation + * + * This implementation is used when web analytics is configured has `google_analytics`. + * It will send events to the google analytics. + * + */ +export default class GoogleAnalyticsApi extends BaseWebAnalyticsApi { + ga: Exclude; + + constructor() { + super(); + if (ga === undefined) { + throw new Error('Incorrect configuration on Google Analytics on Web Analytics.'); + } + this.ga = ga; + } + + sendEvent(category: string, action: string, label: string, value?: number): void { + this.ga('send', 'event', category, action, label, value); + } +} diff --git a/src/frontend/js/utils/api/web-analytics/google_tag_manager.spec.ts b/src/frontend/js/utils/api/web-analytics/google_tag_manager.spec.ts new file mode 100644 index 0000000000..ec1f97c307 --- /dev/null +++ b/src/frontend/js/utils/api/web-analytics/google_tag_manager.spec.ts @@ -0,0 +1,23 @@ +import { ContextFactory as mockContextFactory } from 'utils/test/factories'; +import WebAnalyticsAPIHandler from '.'; +import GoogleTagManagerApi from './google_tag_manager'; + +jest.mock('utils/context', () => ({ + __esModule: true, + default: mockContextFactory({ + web_analytics_provider: 'google_tag_manager', + }).generate(), +})); + +describe('Web Analytics', () => { + beforeAll(() => { + // Mock the `windows.dataLayer` so the verification of the presence of the Google Tag Manager passes. + window.dataLayer = []; + }); + + it('returns the Google Tag Manager API if provider is `google_tag_manager`', () => { + const api = WebAnalyticsAPIHandler(); + expect(api).toBeDefined(); + expect(api).toBeInstanceOf(GoogleTagManagerApi); + }); +}); diff --git a/src/frontend/js/utils/api/web-analytics/google_tag_manager.ts b/src/frontend/js/utils/api/web-analytics/google_tag_manager.ts new file mode 100644 index 0000000000..a6364e85d5 --- /dev/null +++ b/src/frontend/js/utils/api/web-analytics/google_tag_manager.ts @@ -0,0 +1,33 @@ +/* eslint-disable class-methods-use-this */ +import { BaseWebAnalyticsApi } from './base'; + +/** + * + * Google Tag Manager Richie Web Analytics API Implementation + * + * This implementation is used when web analytics is configured has `google_tag_manager`. + * It will send events to the google tag manager. + * + */ +export default class GoogleTagManagerApi extends BaseWebAnalyticsApi { + dataLayer: Exclude; + + constructor() { + super(); + if (window.dataLayer === undefined) { + throw new Error('Incorrect configuration on Google Tag Manager on Web Analytics.'); + } + this.dataLayer = window.dataLayer; + } + sendEvent(category: string, action: string, label: string, value?: number): void { + this.dataLayer.push({ + event: 'courseEnroll', + eventProps: { + category, + action, + label, + value, + }, + }); + } +} diff --git a/src/frontend/js/utils/api/web-analytics/index.ts b/src/frontend/js/utils/api/web-analytics/index.ts new file mode 100644 index 0000000000..da0472d7d5 --- /dev/null +++ b/src/frontend/js/utils/api/web-analytics/index.ts @@ -0,0 +1,24 @@ +import { Maybe } from 'types/utils'; +import { WebAnalyticsAPI, WebAnalyticsAPIBackend } from 'types/WebAnalytics'; +import context from 'utils/context'; +import { handle } from 'utils/errors/handle'; +import GoogleAnalyticsApi from './google_analytics'; +import GoogleTagManagerApi from './google_tag_manager'; + +const WEB_ANALYTICS_PROVIDER = context?.web_analytics_provider; + +const WebAnalyticsAPIHandler = (): Maybe => { + try { + switch (WEB_ANALYTICS_PROVIDER) { + case WebAnalyticsAPIBackend.GOOGLE_ANALYTICS: + return new GoogleAnalyticsApi(); + case WebAnalyticsAPIBackend.GOOGLE_TAG_MANAGER: + return new GoogleTagManagerApi(); + } + } catch (error) { + handle(error); + } + return undefined; +}; + +export default WebAnalyticsAPIHandler; diff --git a/src/frontend/js/utils/api/web-analytics/no_provider.spec.ts b/src/frontend/js/utils/api/web-analytics/no_provider.spec.ts new file mode 100644 index 0000000000..b1ce3f27ac --- /dev/null +++ b/src/frontend/js/utils/api/web-analytics/no_provider.spec.ts @@ -0,0 +1,13 @@ +import { ContextFactory as mockContextFactory } from 'utils/test/factories'; +import WebAnalyticsAPIHandler from '.'; + +jest.mock('utils/context', () => ({ + __esModule: true, + default: mockContextFactory().generate(), +})); +describe('Web Analytics', () => { + it('returns a concrete implementation when the web analytics module is activated', () => { + const api = WebAnalyticsAPIHandler(); + expect(api).toBeUndefined(); + }); +}); diff --git a/src/frontend/js/utils/api/web-analytics/unknown_provider.spec.ts b/src/frontend/js/utils/api/web-analytics/unknown_provider.spec.ts new file mode 100644 index 0000000000..c0a8f27815 --- /dev/null +++ b/src/frontend/js/utils/api/web-analytics/unknown_provider.spec.ts @@ -0,0 +1,15 @@ +import { ContextFactory as mockContextFactory } from 'utils/test/factories'; +import WebAnalyticsAPIHandler from '.'; + +jest.mock('utils/context', () => ({ + __esModule: true, + default: mockContextFactory({ + web_analytics_provider: 'unknown_provider', + }).generate(), +})); +describe('Web Analytics', () => { + it('returns undefined when an unknown provider for the frontend code is activated', () => { + const api = WebAnalyticsAPIHandler(); + expect(api).toBeUndefined(); + }); +}); diff --git a/src/frontend/js/utils/test/factories.ts b/src/frontend/js/utils/test/factories.ts index ef42a0b981..32726b1a9e 100644 --- a/src/frontend/js/utils/test/factories.ts +++ b/src/frontend/js/utils/test/factories.ts @@ -65,6 +65,7 @@ export const ContextFactory = (context: Partial = {} ], release: faker.system.semver(), sentry_dsn: null, + web_analytics_provider: null, ...context, }); diff --git a/src/richie/apps/core/context_processors.py b/src/richie/apps/core/context_processors.py index 652004d9ff..ad0b77b0df 100644 --- a/src/richie/apps/core/context_processors.py +++ b/src/richie/apps/core/context_processors.py @@ -47,6 +47,7 @@ def site_metas(request: HttpRequest): "environment": getattr(settings, "ENVIRONMENT", ""), "release": getattr(settings, "RELEASE", ""), "sentry_dsn": getattr(settings, "SENTRY_DSN", ""), + **WebAnalyticsContextProcessor().frontend_context_processor(request), } }, **WebAnalyticsContextProcessor().context_processor(request), @@ -130,6 +131,18 @@ class WebAnalyticsContextProcessor: frontend. """ + # pylint: disable=no-self-use + def frontend_context_processor(self, request: HttpRequest) -> dict: + """ + Additional web analytics information for the frontend react + """ + context = {} + if getattr(settings, "WEB_ANALYTICS_ID"): + context["web_analytics_provider"] = getattr( + settings, "WEB_ANALYTICS_PROVIDER", "google_analytics" + ) + return context + def context_processor(self, request: HttpRequest) -> dict: """ Real implementation of the context processor for the Web Analytics core app sub-module diff --git a/src/richie/apps/core/templates/richie/web_analytics.html b/src/richie/apps/core/templates/richie/web_analytics.html index e63c4fb771..5a646e3548 100644 --- a/src/richie/apps/core/templates/richie/web_analytics.html +++ b/src/richie/apps/core/templates/richie/web_analytics.html @@ -1,6 +1,6 @@ {% block web_analytics %} {% if WEB_ANALYTICS_ID %} - {% if WEB_ANALYTICS_PROVIDER == "google_analytics" %} + {% if WEB_ANALYTICS_PROVIDER == "google_tag_manager" %} {% endif %} + {% if WEB_ANALYTICS_PROVIDER == "google_analytics" %} + + {% endif %} {% endif %} {% endblock web_analytics %} diff --git a/tests/apps/core/test_pages.py b/tests/apps/core/test_pages.py index 864515359c..9bac57cdb1 100644 --- a/tests/apps/core/test_pages.py +++ b/tests/apps/core/test_pages.py @@ -84,6 +84,7 @@ def test_pages_i18n_hreflang(self): } ] ) + @override_settings(WEB_ANALYTICS_ID="TRACKING_ID") def test_page_includes_frontend_context(self): """ Create a page and make sure it includes the frontend context as included @@ -116,3 +117,7 @@ def test_page_includes_frontend_context(self): r"\u0022^https://lms\u005C\u005C.example\u005C\u005C.com/courses/(.*)/course/?$\u0022" # noqa pylint: disable=line-too-long ), ) + self.assertContains( + response, + r"\u0022web_analytics_provider\u0022: \u0022google_analytics\u0022", + ) diff --git a/tests/apps/core/test_web_analytics.py b/tests/apps/core/test_web_analytics.py index eb3d9442b5..06f501b75f 100644 --- a/tests/apps/core/test_web_analytics.py +++ b/tests/apps/core/test_web_analytics.py @@ -43,13 +43,13 @@ def test_web_analytics_organization_page(self): self.assertContains( response, - "googletagmanager", - msg_prefix="Page should include the Google Analytics snippet code", + "google-analytics.com", + msg_prefix="Page should include the Google Analytics js code", ) self.assertContains( response, "UA-XXXXXXXXX-X", - msg_prefix="Page should include the Google Analytics tracking code", + msg_prefix="Page should include the Google Analytics tracking id code", ) self.assertRegex( response.content.decode("UTF-8"), @@ -64,7 +64,7 @@ def test_web_analytics_organization_page(self): response_content = response.content.decode("UTF-8") self.assertGreater( response_content.index("