Launching a rocket is so easy, for those who've done it! Same goes with Google Tag Manager. It really is like building a rocket, but once you've done it the first time, you rarely have to deal with it again.
Basics
This article is not about the use of Google Tag Manager, nor how-to install tags. It is an attempt to create an Angular service that takes away the pain of maintaining it. The following things are basics to keep in mind, so that we remain sane, because the docs of GTM will make you insane.
- It's GTM, that's how we will refer to it
- GA4 is Google Analytics, version 4
- We will never use
gtag.js
library - We will install on Web only
- The docs are too overwhelming, most of my work is around these documents:
- I might have used
gr-
andgarage-
prefix interchangeably, forgive me
Setup Google Tag Manager
Starting with tag manager website, create an account and an initial container of type web
.
It is recommended to place the scripts as high in the head
tag as possible, so I am not going to attempt to insert the script
via Angular - though I saw some online libraries do that. We can also create our script on PLATFORM_INITIALIZER
token. Read about Angular initialization tokens. But I see no added value.
<!-- index.html -->
<head>
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXX');</script>
<!-- End Google Tag Manager -->
</head>
<body>
<!-- somewhere in body -->
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
</body>
All what this does is create a global dataLayer
array, push the first gtm.start
event to it, then inject the script.
Okay, now what?
The end in sight
GTM is a just a consolidation layer that gathers information from the website, and sends it forward to wherever we hook it up to. The most natural use of GTM is, of course, Google Analytics. Hooking up GTM to GA4 is straight forward, the real challenge though is understanding yet one more version of Analytics. (Universal has retired, GA4 is in the house.)
The GA4 tracking code is buried under Admin > Property > Data Streams > Web. Or do as I do when I lose it, type tracking id in the search box. The default Enhanced measurement is set with "page views" by default, remember this.
Starting from list of trigger types on GTM, the one we are looking for is Page view triggers > Initialization to configure GA4. In GTM, we'll create a tag for Analytics "configuration" which is triggered on initialization.
What we are looking for is on history change, send a page_view
event to GA4.
According to Automatically collected events, page_view
is collected on history change, automatically.
Event | Trigger | Parameters |
---|---|---|
page_view (web) | each time the page loads or the browser history state is changed by the active site. Collected by default via enhanced measurement. | page_location (page URL), page_referrer (previous page URL), engagement_time_msec. In addition to the default language, page_location, page_referrer, page_title, screen_resolution |
So we should be set. To test, in GTM, we use Preview feature, and in GA4 we use Realtime reports. Running my app, clicking around to different routes, I can see page_view
events piling up.
A side node to remember, allowing "Enhanced Measurements" in GA4, for example
scroll
events, or creating a tag in GTM with a trigger typescroll
have the same effect. GA4 will log scroll events both ways. The main advantage of doing it in GTM; fine tuning the trigger conditions is much easier.
If "Enhanced measurements" was not set, we would have had to create a separate tag in GTM, with trigger History change.
Navigation and history change
Three scenarios I want to test before I move on. Navigation with replaceUrl
, skipLocationChange
and Location.go
.
-
replaceUrl
logs a properpage_view
: No extra work here -
location.go
logs apage_view
event with wrong page title: This is expected, because this changes the URL without navigating away from component, thus page title sticks around. On the positive side, this trick is helpful only on same route, so no work needed here. -
skipLocationChange
does not log any events
To catch the undetected event, one way involves work on GTM, with no interference of the developer, and the other is custom events for manual logging
Page view manual logging
Eventually, I need to do the following in my code
locationChange() {
this.router.navigate(['.', { page: 2 }], {skipLocationChange: true});
// log a page_view, read path elsewhere
TellGTMToLogEvent('garage_page_view');
}
In GTM, will create a trigger, could be anything. And then a tag, for that trigger, that pushes page_view
into GA4. (It took me a while to learn that!)
Note, anything that can be built in GTM without the support of an Angular developer, is out of scope of this article. But there are many, many options available in GTM. Click events specifically are quite rich.
My personal advice when dealing with GTM: distinguish everything by a suffix or a prefix, just to get a sense of what's happening, if you do want to reach 50 without losing your mind. I will rely on the term garage
or gr
to distinguish my custom events.
- New Trigger: Page View Trigger (add suffix "Trigger")
- Type: Custom Event
- Event Name:
garage_trigger
(this is our data layer event)
- New Tag: Page View Tag (add suffix "Tag")
- Type: Google Analytics: GA4 Event
- Event name:
page_view
(this is the event going to GA4)
In our Angular App, let me create a static service. It is static until we need to change it.
// GTM service
// declare the dataLayer to use in typescript
declare let dataLayer: any[];
export class GtmTracking {
// first method, register a garage_trigger event
public static RegisterView(): void {
dataLayer.push({ event: 'garage_trigger' });
}
}
In my component that has next
link
nextPage() {
// increase page, and get all other params
const page = this.paramState.currentItem.page + 1;
const isPublic = this.paramState.currentItem.isPublic;
// navigate with skipLocationChange
this.router.navigate(['.', { page, public: isPublic }], {
skipLocationChange: true
});
// register view
GtmTracking.RegisterView();
}
In GTM, the garage_trigger
should register, and in GA4, I should see the page_view
. I am assuming all data will be sent with it.
Running locally, clicking next, and the page_view
registers. But it registers information from the current URL. I want it to register a view for a different URL.
/projects;page=2;ispublic=false
In order to pass the extra parameters, ";page=2;ispublic=false" we first create a GTM variable for that purpose.
- New Variable: Garage page_location Variable (add suffix "Variable")
- Type: Data Layer variable
- Variable Name:
garage_page_location
.
In Page View Tag
we will add the parameter to be sent to GA; page_location
, and set it to the following:
{{Page Path}}{{Garage page_location Variable}}
Now in our Angular app, we just need to add garage_page_location
variable to the dataLayer
// in component
nextPage(event: MouseEvent) {
// ...
// register view event pass the extra params
GtmTracking.RegisterView(`;page=${page};public=${isPublic}`);
}
In GTM service
public static RegisterView(page_location?: string): void {
// add garage_page_location
dataLayer.push({ event: 'garage_trigger', garage_page_location: page_location });
}
We're supposed to see a page_view
event, with /product;page=2;public=false
logged in GA4.
Here it is Realtime report.
That was just a quick run with GTM. To organize it better, let's look at the other recommended parameters.
The data model
Looking into recommended events list and reference of all parameters of recommended events, I can see a certain pattern, a data model that looks like this:
// most popular parameters of recommended events
interface IGTMEvent {
event: string;
item_list_name: string;
items: {
item_id?: string,
item_name?: string,
price?: number,
currency?: string,
index?: number}[];
method?: string;
content_type?: string;
item_id?: string; // occured once in Share event
value?: number;
currency?: string;
search_term?: string;
}
There are few others. What we want to accomplish is adhering to one rule: Angular code, should be agnostic to the tracking data model. Not only you have other interesting third party trackers, but Analytics itself changes. So the GTM service we hope to accomplish, has its own internal mapper, that maps our App models into GTM models. Which later translates them into GA4 models, or any other third party.
Here are some examples I want to keep in mind as I build my service:
- In a login script, I expect to be able to do this on login success:
GtmTracking.Log({event: 'garage_login', method: 'Google', source: 'Login page'});
- On search
GtmTracking.Log({event: 'garage_search', source: 'Products list', searchTerm: searchTerm});
- On search results:
GtmTracking.Log({event: 'garage_view_item_list', source: 'Product list', items: results});
- On clicking to view a search result:
GtmTracking.Log({event: 'garage_view_item', source: 'Product list', position: item.index, item: item});
And so on. The idea is to send everything to GTM data layer, and let the GTM expert jiggle with it, to create the tags of choice. From my experience, the source of the engagement: where on site it occurred, is very handy.
My data model is looking like this:
export interface IGtmTrack {
event: EnumGtmEvent; // to control events site-wise
source?: EnumGtmSource; // to control where the event is coming from
}
Every call to register an event, has to identify itself. Then we run a mapper to send the different parts to dataLayer
. The GTM service is now like this:
// GTM service
declare let dataLayer: any[]; // Declare google tag
export enum EnumGtmSource {
// any source in web is added here
// left side is internal, right side is GTM
ProductsList = 'products list',
ProductsRelatedList = 'products related',
ProjectsList = 'projects list',
// ...etc
}
export enum EnumGtmEvent {
// any event are added here, prefixed with garage to clear head
// left side is internal, right side is GTM
Login = 'garage_login',
PageView = 'garage_page_view',
// ...etc
}
export interface IGtmTrack {
event: EnumGtmEvent;
source?: EnumGtmSource;
}
export class GtmTracking {
public static RegisterEvent(track: IGtmTrack, extra?: any): void {
const data = { event: track.event };
// depending on event, map, something like this
data['of some attribute'] = GtmTracking.MapExtra(extra);
// push data
dataLayer.push(data);
}
// the mappers that take an existing model, and turn it into GTM model
// for example products:
private static MapProducts(products: IProduct[]) {
// map products to "items"
return { items: products.map(GtmTracking.MapProduct) };
}
private static MapProduct(product: IProduct, index: number) {
// limitation on GTM, the property names must be identified by GA4 for easiest operations
return {
item_name: product.name,
item_id: product.id,
price: product.price,
currency: 'AUD',
index
};
}
// then all other mappers for employee, and project, search, login... etc
private static MapSearch(keyword: string) {
return { search_term: keyword };
}
private static MapLogin(method: string) {
// this better turn into Enum to tame it
return { method };
}
}
The array of "items" cannot be broken down in GTM, we can only pass it as is. If your app depends on any of GA4 recommended parameters, you need to use the same parameter names inside items
array. That's a GTM limitation.
The extras passed could be of project type, an employee, or a string, or array of strings... etc. That makes RegisterEvent
loaded with if-else
conditions, the simpler way is to provide public mappers for all possible models, and map before we pass to one RegisterEvent
.
We can also place our parameters inside one prefixed property, this will free us from prefixing all properties, and worrying about clashing with automatic dataLayer
properties.
The GTM service now looks like this:
public static RegisterEvent(track: IGtmTrack, extra?: any): void {
// separate the event, then pass everything else inside gr_track
const data = {
event: track.event,
gr_track: { source: track.source, ...extra },
};
dataLayer.push(data);
}
// also turn mappers into public methods
In GTM, the gr_track
can be dissected, and multiple variables created, with value set to gr_track.something
. For examples:
Garage track items variable: gr_track.items
In Triggers, we shall create a trigger for every event. garage_click
or garage_login
... etc.
Finally, the tags. Tracking view_item_list
of a list of products, Garage track items variable is passed as GA4 items
, and the Garage track source variable can be passed as item_list_name
.
In our code, where the product list is viewed:
GtmTracking.RegisterEvent({
event: EnumGtmEvent.List, // new event garage_view_list
source: EnumGtmSource.ProductsList // 'product list'
}, GtmTracking.MapProducts(products.matches));
Page view
Now let's rewrite the RegisterView, mapping the page_location
, with a proper event name garage_page_view
. In the service, create a new mapper
public static MapPath(path: string): any {
return { page_location: path };
}
And in component, on next click:
nextPage() {
// ...
// register event
GtmTracking.RegisterEvent(
{ event: EnumGtmEvent.PageView },
GtmTracking.MapPath(`;page=${page};public=${isPublic}`)
);
}
View item in a list
Let's make another one for the recommended view_item
, with event source. We want to track a click from search results, to view a specific item. In the product list template, we add a click handler:
// product list template
<ul>
<li *ngFor="let item of products" (click)="trackThis(item)">
{{ item.name }} - {{item.price }}
</li>
</ul>
In component
trackThis(item: IProduct) {
GtmTracking.RegisterEvent(
{
event: EnumGtmEvent.Click, // general click
source: EnumGtmSource.ProductsList, // coming from product list
},
GtmTracking.MapProducts([item]) // send items array
);
}
Since GA4 parameters all suggest item to be in an array, even if it were one item, then we wrap it in an array. But the index can be the location in the list. So, let's adapt the mapper to accept a second argument for position of element:
public static MapProducts(products: IProduct[], position?: number) {
const items = products.map(GtmTracking.MapProduct);
// if position is passed, change the element index,
// this happens only when there is a single item
if (position) {
items[0].index = position;
}
return {items};
}
And in template, let's pass the index
<ul class="rowlist" >
<li *ngFor="let item of products; let i = index" (click)="trackThis(item, i)">
{{ item.name }} - {{item.price }}
</li>
</ul>
And in component:
trackThis(item: IProduct, position: number) {
GtmTracking.RegisterEvent(
{
event: EnumGtmEvent.Click,
source: EnumGtmSource.ProductsList,
},
GtmTracking.MapProducts([item], position) // pass position
);
}
When clicking, this is what is set in dataLayer
The GTM tag could be set like this:
In GA4 now we can fine tune our reports to know where the most clicks come from, the search results, the related products, or may be from a campaign on the homepage.
Have a look at the final service on StackBlitz
Putting it to the test
These are recommended events, but we can enrich our GA4 reports with extra custom dimensions, we just need to keep in mind that GA4 limits custom events to 500, undeletable. Here are some example reports a GA4 expert might build, and let's see if our data model holds up:
GA4 report of "reveal details" clicks in multiple locations
The GA4 report needs a custom event: gr_reveal
and a source parameter (already set up), to create a report like this:
source | product - search | product - details | homepage - campaign | Totals |
---|---|---|---|---|
Event name | Event count | Event count | Event count | Event Count |
Totals | xxxx | xxxx | xxxx | xxxx |
gr_reveal | xxxx | xxxx | xxxx | xxxx |
Source can be item_list_name
, or a new GA4 dimention. None of the business of the developer. Our date model then looks like this:
{
event: 'gr_reveal',
gr_track: {
source: 'homepage - campaign',
items: [
{
item_name: 'Optional send item name'
// ...
}
]
}
}
GA4 report of Upload events
The new event to introduce is gr_upload
. The source could be the location on site, in addition to action: click, or drag and drop.
source | product - details | homepage - navigation | Totals | |
---|---|---|---|---|
action | click | drag | click | |
Event name | Event count | Event count | Event count | Event Count |
Totals | xxxx | xxxx | xxxx | xxxx |
gr_upload | xxxx | xxxx | xxxx | xxxx |
Our data model then looks like this
{
event: 'gr_upload',
gr_track: {
source: 'product - details',
// we need a mapper for this
action: 'drag'
}
}
The data model holds, but we need an extra action mapper:
// control it
export enum EnumGtmAction {
Click = 'click',
Drag = 'drag'
}
export class GtmTracking {
// ...
// map it
public static MapAction(action: EnumGtmAction) {
return { action }
}
}
// ... in component, use it
GtmTracking.RegisterEvent({
event: EnumGtmEvent.Upload,
source: EnumGtmSource.ProductsDetail,
}, GtmTracking.MapAction(EnumGtmAction.Drag));
Adding value
One constant parameter your GA4 expert might insist on is value, especially in ecommerce websites. The value is not a ready property, but rather a calculation of items values. So every MapList
method, will have its own value calculation, and again, this is an extra
.
Adjust the GTM service mapper
public static MapProducts(products: IProduct[], position?: number) {
// ...
// calculate value
const value = items.reduce((acc, item) => acc + parseFloat(item.price), 0);
// return items and value
return { items, value, currency: 'AUD' }; // currency is required in GA4
}
So far, so good.
Next
What happens when dataLayer
bloats? Let's investigate it next week 😴. In addition to creating a directive for general clicks that need less details, and digging into third party trackers like sentry.io, to see what else we need for our service.
Thank you for reading this far of yet another long post, have you spotted any crawling bugs? let me know.
Top comments (0)