Skip to content

Commit

Permalink
✨(web_analytics) improve web analytics
Browse files Browse the repository at this point in the history
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
  • Loading branch information
igobranco committed Feb 18, 2022
1 parent 7bf7baf commit 26a46a6
Show file tree
Hide file tree
Showing 20 changed files with 387 additions and 15 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 17 additions & 4 deletions docs/web-analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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:" | " }}");
</script>
```

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.
4 changes: 4 additions & 0 deletions src/frontend/js/data/useCourseEnrollment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
},
});

Expand Down
17 changes: 17 additions & 0 deletions src/frontend/js/types/WebAnalytics.ts
Original file line number Diff line number Diff line change
@@ -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',
}
1 change: 1 addition & 0 deletions src/frontend/js/types/commonDataProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ export interface CommonDataProps {
joanie_backend?: JoanieBackend;
release: string;
sentry_dsn: Nullable<string>;
web_analytics_provider?: Nullable<string>;
};
}
17 changes: 17 additions & 0 deletions src/frontend/js/types/web-analytics/google_analytics.d.ts
Original file line number Diff line number Diff line change
@@ -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
>;
}
19 changes: 19 additions & 0 deletions src/frontend/js/types/web-analytics/google_tag_manager.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>[];
}
23 changes: 23 additions & 0 deletions src/frontend/js/utils/api/web-analytics/base.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
24 changes: 24 additions & 0 deletions src/frontend/js/utils/api/web-analytics/google_analytics.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
26 changes: 26 additions & 0 deletions src/frontend/js/utils/api/web-analytics/google_analytics.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ga, undefined>;

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);
}
}
23 changes: 23 additions & 0 deletions src/frontend/js/utils/api/web-analytics/google_tag_manager.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
33 changes: 33 additions & 0 deletions src/frontend/js/utils/api/web-analytics/google_tag_manager.ts
Original file line number Diff line number Diff line change
@@ -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<typeof window.dataLayer, undefined>;

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,
},
});
}
}
24 changes: 24 additions & 0 deletions src/frontend/js/utils/api/web-analytics/index.ts
Original file line number Diff line number Diff line change
@@ -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<WebAnalyticsAPI> => {
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;
13 changes: 13 additions & 0 deletions src/frontend/js/utils/api/web-analytics/no_provider.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
15 changes: 15 additions & 0 deletions src/frontend/js/utils/api/web-analytics/unknown_provider.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
1 change: 1 addition & 0 deletions src/frontend/js/utils/test/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const ContextFactory = (context: Partial<CommonDataProps['context']> = {}
],
release: faker.system.semver(),
sentry_dsn: null,
web_analytics_provider: null,
...context,
});

Expand Down
13 changes: 13 additions & 0 deletions src/richie/apps/core/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion src/richie/apps/core/templates/richie/web_analytics.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{% block web_analytics %}
{% if WEB_ANALYTICS_ID %}
{% if WEB_ANALYTICS_PROVIDER == "google_analytics" %}
{% if WEB_ANALYTICS_PROVIDER == "google_tag_manager" %}
<script async src="https://www.googletagmanager.com/gtag/js?id={{ WEB_ANALYTICS_ID | safe }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
Expand All @@ -12,5 +12,18 @@
gtag('config', '{{ WEB_ANALYTICS_ID | safe }}');
</script>
{% endif %}
{% if WEB_ANALYTICS_PROVIDER == "google_analytics" %}
<script>
(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');
ga('create', '{{ WEB_ANALYTICS_ID | safe }}', 'auto');
{% for dimension_key, dimension_value_list in WEB_ANALYTICS_DIMENSIONS.items %}
ga('set', {'dimension{{forloop.counter}}': '{{ dimension_value_list|join:" | " }}'});
{% endfor %}
ga('send', 'pageview');
</script>
{% endif %}
{% endif %}
{% endblock web_analytics %}
5 changes: 5 additions & 0 deletions tests/apps/core/test_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
)
Loading

0 comments on commit 26a46a6

Please sign in to comment.