- Overview
- Setting Up Google Tag Manager
- Javascript interface
- Data streams differentiation
- Firebase SDK
- PWA (Progressive Web App)
- YouTube video tracking
- Consent mode v2
- Enhancing Scroll Tracking in Single Page Applications
- Deep Linking
- Development server
- Build
- Documentation as a static site (Experimental)
- License
This project demonstrates a simple implementation of Google Tag Manager (GTM) with an Angular application. It showcases various GTM events like page_view
, view_promotion
, and more, to help you understand and test GTM integration in a real-world scenario.
For now, the app supports:
page_view
view_promotion
select_promotion
view_item_list
select_item
view_item
add_to_cart
remove_from_cart
view_cart
begin_checkout
add_shipping_info
add_payment_info
purchase
refund
To configure Google Tag Manager for this project, please locate the file titled GTM-NBMX2DWS_workspace<version>.json
in the project's root directory. This file contains the necessary settings for your GTM workspace. You can easily import this configuration into your GTM account. Once imported, select and follow the specific topics relevant to your needs. Also, please refer to the data layer checker extension to inject your own GTM on the GitHub page.
The Javascript interface is used to bridge the Angular application and the Android/iOS applications. Additionally, I configured the flutter_inappwebview plugin to send the data back to the Flutter application.
Sometimes we want to reuse the website and embed it in the Android/iOS application. The data in the app (Android/iOS) should be separated from the website. The project demonstrates how to differentiate the data streams from the website and the Android/iOS application.
The basic methodology in the project cached a query parameter, app_source
, and in GTM, we can use a custom Javascript variable app_source
variable to differentiate the data stream. For example, http://localhost:4200/?app_source=app
is the data stream for the Android/iOS application, and http://localhost:4200/
is by default the data stream for the web application.
Checking GTM tags via GTM preview mode is straightforward. There could be another way to differentiate data streams such as checking registered window objects from Flutter/Android/iOS, but not obvious.
The events data sent from the website to Flutter/Android/iOS are in the same format suggested by the GA4 recommended events and it's easy to integrate and map events with the Firebase SDK.
Be aware of the data types of the parameters. For instance, inconsistent value
parameter types such as double from the website and integer in Flutter will cause the purchase
event to fail to send.
A Progressive Web App (PWA) is designed to work offline, mimicking a native app experience on the user's device. To ensure important analytics data isn't lost when users are offline, Dexie.js is utilized to store data in IndexedDB. Once the user is back online, the stored data is sent to the GA4 property through window.dataLayer.push()
, adhering to Google Tag Manager (GTM) practices. For more details about PWAs and their capabilities, refer to the PWA documentation. Please also refer to the Angular service worker documentation for more implementation details.
Use the following steps to test the PWA functionality:
- Run
ng build
to build the project. - Use
http-server
and runnpx http-server -p 8080 -c-1 dist/ng-gtm-integration-sample
- Follow and click the port number link in the terminal to open the PWA.
- Turn off the network and trigger some events.
- Turn on the network and check the events in the data layer object.
Please follow the documentation in the YouTube Player API Reference for iframe Embeds to set up YouTube video tracking. The project utilizes Angular's youtube-player component to streamline the integration process.
Due to CORS policy restrictions, the YouTube iframe is unable to perform postMessage
actions and use enhanced measurement to transmit data to the data layer. To address this, it is necessary to modify the Content Security Policy (CSP) to permit these actions from the YouTube iframe. For detailed guidance on configuring CSP, please refer to the Content Security Policy (CSP) documentation.
The enhanced measurement is unable to differentiate data streams. To address this, the project implements events manually according to API. The details are in the services/youtube/youtube.service.ts
file.
Google is updating its offerings, including Consent Mode, to comply with regulations like GDPR and DMA. The Consent Mode V2 introduces additional settings to better control data usage and ensure lawful consent collection. This tool helps organizations adapt Google tags based on user consent, with new parameters like "Ad personalization" and "ad user data" for more refined control. Organizations in the European Economic Area using Google's advertising and measurement products must upgrade to Consent Mode V2 by March 6, 2024, to maintain features and comply with DMA requirements​. You may load the GTM script dynamically based on the consent status or related logic. The implementation below ensures privacy primarily by using GTM.
The implementation uses localStorage to store the consent status and uses the gtm-templates-simo-ahava
template in GTM implementation. Here are some setup steps in GTM:
- A
Default Consent
Tag. It is a tag that fires on the earliestConsent Initialization
stage with default values. - An
Update Consent
Tag. It is a tag that fires when the consent status is updated, with a trigger ofCustom Event
,update_consent
for example.
The codebase updates the consent status within the local storage and fires the update_consent
event with the consent status. Then, the tag uses data layer variables to update the consent status.
- A
Default Configuration tag
is necessary before data collection as it uses gtag.js to configure the analytics tracking:
// Default Configuration tag; Tag Type: 'Google Tag' in GTM
gtag("config", "G-XXXXXXXX", {
send_page_view: false,
allow_ad_personalization_signals: false, // consent mode v2 ad_personalization parameter
allow_google_signals: false, // consent mode v2 advertising features
debug_mode: "true", // to use it in the Google Analytics 4 debug mode
});
Please refer to the configuration settings in the documentation.
The trigger is usually All Pages
, with the condition {{CJS - analytics_consent}}
equals true.
// {{CJS - analytics_consent}} is a custom Javascript variable
function() {
var consent = JSON.parse(localStorage.getItem('consentPreferences'));
var analytics_storage = consent.analytics_storage;
var ad_storage = consent.ad_storage;
var ad_user_data = consent.ad_user_data;
var ad_personalization = consent.ad_personalization;
return ad_storage && analytics_storage && ad_user_data;
}
- An
Update Configuration tag
is necessary to update the configuration settings when the consent status is updated. The trigger is usuallyCustom Event
,update_consent
for example. To stop data collection when the consent status is false, the workaround, for now, is utilizing Tag ID. The variable{{CJS - Measurement ID}}
is used to control the data collection.
// {{CJS - Measurement ID}} is a custom Javascript variable
function() {
var analyticsConsent = {{CJS - analytics_consent}};
return analyticsConsent ? {{Measurement ID}} : 'G-0';
}
The {{Measurement ID}}
is the measurement ID of the GA4 property. The G-0
is a non-existent measurement ID.
All tags should configure the measurement ID with the {{CJS - Measurement ID}}
variable.
Single Page Applications (SPAs) present unique challenges for scroll tracking due to their dynamic nature. The default scroll tracking method with enhanced measurement often falls short for several reasons:
-
Limited Trigger Scope: In SPAs, the
{{ Scroll Depth Threshold }}
variable and associated triggers typically only activate on the initial page load (the landing page). As users navigate to other "pages" or routes within the SPA, these scroll events don't re-trigger as they would in a traditional multi-page website. -
Inaccuracy due to Lazy Loading: Many SPAs implement lazy loading to improve performance, loading components only as needed. This can interfere with scroll tracking accuracy. For example, if the landing page defers loading of a carousel until it's in or near the viewport, the scroll depth might be reported as 100% prematurely, because the full content length wasn't considered at the initial calculation.
In addressing the scroll tracking issue within our Angular SPA, the logic is divided into three critical parts to ensure accurate and meaningful event firing:
The Angular SPA is designed to initially display a loading Div while deferring the loading of components. To track the completion of this process, the ngAfterViewChecked lifecycle hook is employed. This hook is part of Angular's change detection mechanism, which runs after every cycle of view checks. By implementing a check within this hook, the app continuously monitors the presence of the loading Div. Once this Div is no longer found in the DOM, it's interpreted that all deferred components have finished loading. This transition signifies that the page is fully rendered and interactive, marking an ideal point to initiate scroll tracking.
In pages where content length doesn't necessitate scrolling, traditional scroll tracking might inaccurately report a 100% scroll event. To address this, a custom JavaScript variable, as suggested by Simo Ahava, is implemented. This variable introduces a refined logic that discerns between meaningful and unmeaningful scroll events. It accounts for various factors like the viewport size, content length, and user interaction to determine if a scroll event genuinely represents user engagement or is merely a default behavior in a non-scrollable context. By integrating this variable, the scroll tracking becomes more precise, only firing events that truly reflect user interaction and intent.
By combining these two strategies, the Angular SPA not only ensures that all components are fully loaded before initiating scroll tracking but also refines the scroll tracking mechanism to report only meaningful interactions. This dual approach significantly enhances the accuracy of engagement metrics, providing more reliable data for understanding user behavior and optimizing the website experience.
By implementing this custom method, the project can more accurately track user engagement and scroll behavior throughout the entire SPA, regardless of how content is loaded or how users navigate between sections. You may involve the logic in the sample app. The usual triggers would be window loaded
for the initial page loading and the history change
trigger when route changes.
Custom HTML
<script>
// IIFE to avoid global window pollution
var PageScrollTracker = (function () {
var dataLayer = window.dataLayer || [];
var pageScroll = {
min: 1.0,
sc25: false,
sc50: false,
sc75: false,
sc95: false,
sc2pg: true,
sclstop: 0,
};
function init() {
resetPageScroll();
calculateMetrics();
}
function resetPageScroll() {
pageScroll = {
min: 1.0,
sc25: false,
sc50: false,
sc75: false,
sc95: false,
sc2pg: true,
sclstop: 0,
};
}
function calculateMetrics() {
pageScroll.DocSize = getViewportHeight() / getDocumentHeight();
pageScroll.DocSizeName = getViewportHeight() / getDocumentHeight() < pageScroll.min ? "long-doc" : "test1-too-small";
pageScroll.DocPages = getDocumentHeight() / getViewportHeight();
pageScroll.DocCP = getCurrentPosition() / getDocumentHeight();
pageScroll.TooSmall = getViewportHeight() / getDocumentHeight() > pageScroll.min;
}
function getDocumentHeight() {
var selector = "div#__next > div";
var element = document.querySelector(selector);
if (element !== null) {
return element.offsetHeight;
}
return Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight);
}
function getCurrentPosition() {
return window.pageYOffset + getViewportHeight();
}
function getViewportHeight() {
if (typeof window.innerHeight === "number") {
return window.innerHeight;
}
if (document.documentElement && document.documentElement.clientHeight) {
return document.documentElement.clientHeight;
}
if (document.body && document.body.clientHeight) {
return document.body.clientHeight;
}
}
function trackScroll() {
calculateMetrics();
if (getViewportHeight() / getDocumentHeight() > pageScroll.min) {
pageScroll.TooSmall = true;
} else {
pageScroll.TooSmall = false;
var isScrollingDown = getCurrentPosition() > pageScroll.sclstop;
pageScroll.sclstop = getCurrentPosition();
if (isScrollingDown) {
checkScrollThresholdsAndPushEvents();
}
}
}
function checkScrollThresholdsAndPushEvents() {
var scrollThresholds = [
{ name: "sc25", value: 0.25, pushed: false },
{ name: "sc50", value: 0.5, pushed: false },
{ name: "sc75", value: 0.75, pushed: false },
{ name: "sc95", value: 1, pushed: false },
];
scrollThresholds.forEach(function (threshold) {
var hasScrolledPastThreshold = getCurrentPosition() >= threshold.value * getDocumentHeight();
if (hasScrolledPastThreshold && !pageScroll[threshold.name]) {
// can set the event name manually or pass it as function parameter
dataLayer.push({ event: "CustomScroll", customScrollPercent: threshold.value * 100 });
pageScroll[threshold.name] = true;
}
});
}
return {
init: init,
trackScroll: trackScroll,
};
})();
try {
PageScrollTracker.init();
window.onscroll = PageScrollTracker.trackScroll;
} catch (e) {
console.error("scroll plugin failed.", e);
}
</script>
The deep linking feature allows users to open the app from the website. The project demonstrates how to implement deep linking in the Android application. The implementation is based on the documentation.
The website implementation uses a
tag with the href
attribute. i.e., <a href="https://wodenwang820118.github.io/ng-gtm-integration-sample/?utm_source=ng-gtm-integration-sample&utm_medium=website&utm_campaign=app_download">App</a>
. It intends to have UTM parameters to test Firebase UTM tracking. After clicking the link on the mobile browser, the Android application will be opened.
Please note that in the Settings
-> Apps
-> All apps
-> App info
-> Open by default
settings (or any other way to access the app's setting), the Links to open in this app
should be enabled with wodenwang820118.github.io
as the domain. Otherwise, the app will not be opened.
We'll need to add the following code in the AndroidManifest.xml
file to support deep linking.
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Accepts URIs that begin with
"https://wodenwang820118.github.io/ng-gtm-integration-sample/" -->
<data android:scheme="https" />
<data android:host="wodenwang820118.github.io" />
<!-- note that the leading "/" is required for pathPrefix-->
</intent-filter>
Please refer to the Deep Linking documentation for more information about testing deep linking.
If you want to test the deep linking feature on an emulator, you could open the development server on the Android emulator. Please refer to the stackoverflow post. Start the server with the command:
ng serve --disable-host-check --host 0.0.0.0
The default port is 4200.
Then, on the emulator, open the browser and type: 10.0.2.2:4200
Run ng serve
for a dev server. Navigate to http://localhost:4200/
. The application will automatically reload if you change any of the source files.
- Run
npm run build-file
to generate the local file with http-server. - Run
npm run build-github
to generate the GitHub page. Remember to change the repo to your own. - By allowing Workflow permissions, the GitHub page will be automatically updated after pushing the code to the
main
branch.
Utilizing Docusaurus, this project's REAEMD.md is transformed into a static website, which is hosted via Netlify. It is designed specifically for non-technical users, providing an easy-to-understand overview of the project and topics related to GTM, without the need for familiarity with GitHub or GitLab.
MIT