Sekrab Garage

Google Tag Manager and Analytics 4

GTM Tracking Service in Angular, Part II

AngularDesign April 25, 22
Series:

GTM Tracking Service

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.
  1. 6 months ago
    GTM Tracking Service in Angular
  2. 6 months ago
    GTM Tracking Service in Angular, Part II
  3. 5 months ago
    GTM Tracking Service in Angular, Part III

Previously, I created a service with simple data model to adapt the data layer with different mappers. Today, we create a directive, and look into flushing the data layer.

Directive

Most often than not, the analytics report is to measure clicks around an application. Let's create a directive for general clicks, that submits the basic data model:

const dataLayerO = {
    event: eventname || 'gr_click', 
    gr_track: { 
        source: eventsource
    }
}

Those elements are good enough to create a report of event counts, the end result of how it should be used in HTML looks like this:

// pass an object
<a [grGtm]="{ event: 'gr_click', source: 'home' }">Login</a>

// or setup data attributes
<a class="gr_catch" data-event="gr_click" data-source="Home">Projects</a>

Either way can be handled in GTM. With the former, we can update dataLayer programatically, and in the latter the work load is on GTM, but there are more information available per click. Let's create a directive that does both.

First let's make the Enum values available in templates. But instead of repeating on every template, we can create a base component for GTM purposes, and extend our component classes from it (this is a bit subjective, you are allowed to dislike it).

import { EnumGtmEvent, EnumGtmSource } from './gtm';

export class GtmComponent {
  // these will be used throughout the templates
  enumGtmEvent = EnumGtmEvent;
  enumGtmSource = EnumGtmSource;
}

Then in the template and component.

 
// in components that use the directive, extend from GtmComponent 
@Component({
  template: `
  <a routerLink="/login" [grGtm]="{ source: enumGtmSource.Home }">Login</a>`
})
export class AppComponent extends GtmComponent {
  // if you have a constructor, you need to call super() inside of it
}

Now the directive:

@Directive({
  selector: '[grGtm]'
})
export class GtmDirective implements AfterViewInit  {
  // we will decide later
  @Input() grGtm: any;

  constructor(private el: ElementRef){
  }

  ngAfterViewInit(): void { 
    // these will help GTM experts in creating Click triggers
    this.el.nativeElement.setAttribute('data-source', this.grGtm.source || EnumGtmSource.Anywhere);
    this.el.nativeElement.setAttribute('data-event', this.grGtm.event || EnumGtmEvent.Click);
    // later we will decide what other attributes we can add
  }
  
  // also create a click handler
  @HostListener('click', ['$event.target'])
  onClick(target: HTMLElement): void {
    // on event click, gather information and register gtm event
    GtmTracking.RegisterEvent(
      {
        event: this.grGtm.event || EnumGtmEvent.Click,
        source: this.grGtm.source || EnumGtmSource.Anywhere,
      }
    );
  }
}

This is the richest way to create an event for GTM experts to create a Click trigger with data-event (for example), or a Custom Event trigger. I will not dig any deeper, but there are pros and cons for either way. Just a couple of enhancements to cover all scenarios, then you can choose one or both ways in your project.

Enhancement: group events

We can group all of these directive clicks under one event, and add a new property to distinguish them. This allows the experts to create one tag, for all directive clicks, without flooding GA4 with custom events. The new property is group. In GTM Service:

// few examples
export enum EnumGtmGroup {
  Login = 'login', // watch all login button clicks
  Upload = 'upload', // wach all upload button clicks
  Reveal = 'reveal' // watch all reveal button clicks
  Navigation = 'navigtion', // watch all navigation clicks
  General = 'general' // the default
}

export enum EnumGtmEvent {
  // ... add a general directive click event
  GroupClick = 'garage_group_click',
}
export class GtmTracking {
  // ...
  
  // add a mapper for group clicks
  public static MapGroup(group: EnumGtmGroup) {
    return {
      group
    }
  }
}

And in the directive:

ngAfterViewInit(): void {
  // the event is always garage_group_click
  this.el.nativeElement.setAttribute('data-event', EnumGtmEvent.GroupClick);
        
  this.el.nativeElement.setAttribute('data-source', this.grGtm.source || EnumGtmSource.Anywhere);
  this.el.nativeElement.setAttribute('data-group', this.grGtm.group || EnumGtmGroup.General);
}
 
@HostListener('click', ['$event.target'])
onClick(target: HTMLElement): void {
  GtmTracking.RegisterEvent(
    {
      // this is now always group click
      event: EnumGtmEvent.GroupClick,
      source: this.grGtm.source || EnumGtmSource.Anywhere,
    },
    // map group
    GtmTracking.MapGroup(
      this.grGtm.group || EnumGtmGroup.General
    )
  );
}

In GTM, now we can create a new variable for gr_track.group. Then a Custom Event trigger for all events of type garage_group_click, and a Group tag, that passes source and group values. But we have no access to the text that distinguishes the click events. (Click text is only available with Click triggers.)

Enhancement: add label

In the directive, we have access to the triggering element, so we can pass the label as well.

In GTM service

// update mapper to accept label
public static MapGroup(group: EnumGtmGroup, label?: string) {
  return {
    group, label
  }
}

In directive click handler, and input model:

// the model of the input now clearer:
@Input() grGtm: { source: EnumGtmSource; group: EnumGtmGroup };

@HostListener('click', ['$event.target'])
onClick(target: HTMLElement): void {
  GtmTracking.RegisterEvent(
    {
      event: EnumGtmEvent.GroupClick,
      source: this.grGtm.source || EnumGtmSource.Anywhere,
    },
    // pass group and label
    GtmTracking.MapGroup(
      this.grGtm.group || EnumGtmGroup.General,
      this.el.nativeElement.innerText
    )
  );
}

And the templates now look like this

<a [grGtm]="{source: enumGtmSource.Homepage, group: enumGtmGroup.Login}">Login</a>

<a [grGrm]="{source: enumGtmSource.NavigationDesktop, group: enumGtmGroup.Navigation}">Projects</a>

And here is how the GTM tag looks like:

GTM Tag

Add label as a custom dimention to GA4, and this, quite much starts to look like Universal Analytics.

PS: Localizing? The label will be different for every language, if you, in anyway depend on it to create reports, take notice, and adapt.

Data layer flushing

As more events are pushed to data layer, the variables do not automatically reset, they are available as long as nothing resets them. Consider this:

setOne() {
    // reigster event and push datalayer
    GtmTracking.RegisterEvent({
        event: EnumGtmEvent.Filter,
        source: EnumGtmSource.ProjectsList,
    }, {
        filter: 'one'
    });
}
setTwo() {
    GtmTracking.RegisterEvent({
        event: EnumGtmEvent.Filter,
        source: EnumGtmSource.EmployeesList,
    });
}

The first function sets the data layer with filter "one", and the second call has no filter set. Here is how the dataLayer available to GA4 looks like after the second call:

dataLayer

In most cases, when you build a report on GA4, you filter out for a specific event, which usually has its parameters set together - because we are using internal mappers, like MapProduct. In other words, when you create a report for view_item event, you will not bother about the group property, rather the value property, which is set on every view_item event occurence, even if set to null. Thus, this isn't a big issue.

Nevertheless, we need a way to flush down the remote data layer, and we need to know when. The reset functionality is provided by GTM:

// in GTM service
public static Reset() {
  dataLayer.push(function () {
    this.reset();
  });
}

The other side effect, is the dataLayer array is growing on the client side. In most cases that is not an issue. Reseting the dataLayer variable is not allowed in GTM, and it breaks the push behavior. (The GTM dataLayer.push is an overridden method.)

Except... well, don't try this at home, but you can splice out all elements except the first one, which contains the gtm.start event. Use this at your own risk:

public static Reset() {
  // not recommended but works
  // remove all elemnts except the first, mutating the original array
  dataLayer.splice(1);

  dataLayer.push(function () {
      this.reset();
  });
}

Flushing the data layer can be a manual process, when in doubt, flush. We also can auto flush on route changes. In the base AppModule, detect NavigationEnd events, and flush.

export class AppRouteModule {
  constructor(router: Router) {
    router.events
      .pipe(filter((event) => event instanceof NavigationEnd))
      .subscribe({
        next: (event) => {
          // flush dataLayer
          GtmTracking.Reset();
        },
      });
  }
}

Next

We have created a directive, and managed the resetting of our data layer. Next we will add a third party, and track errors.

Thanks for sticking around, did you smell anything bad? Let me know in the comments.

Find the directive on StackBlitz.

  1. 6 months ago
    GTM Tracking Service in Angular
  2. 6 months ago
    GTM Tracking Service in Angular, Part II
  3. 5 months ago
    GTM Tracking Service in Angular, Part III