Sekrab Garage

Google Tag Manager and Analytics 4

GTM Tracking Service in Angular

AngularDesign April 18, 22

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.

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.

GA4 tracking

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.

GTM Tag

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.

EventTriggerParameters
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 type scroll 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 proper page_view No extra work here
  • location.go logs a page_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)
GTM Trigger
  • New Tag: Page View Tag (add suffix "Tag")
  • Type: Google Analytics: GA4 Event
  • Event name: page_view (this is the event going to GA4)
GTM Tag

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.
GTM variable

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}}

GTM Tag

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.

GA realtime

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

GTM variable

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.

GTM Tag

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

dataLayer

The GTM tag could be set like this:

GTM Tag

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:

sourceproduct - searchproduct - detailshomepage - campaignTotals
Event nameEvent countEvent countEvent countEvent count
Totalsxxxxxxxxxxxxxxxx
gr_revealxxxxxxxxxxxxxxxx

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.

sourceproduct - detailshomepage - navigationTotals
actionclickdragclick
Event nameEvent countEvent countEvent countEvent count
Totalsxxxxxxxxxxxx
gr_uploadxxxxxxxxxxxx

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.