Sekrab Garage

Google Tag Manager and Analytics 4

GTM Tracking Service in Angular, Part III

AngularDesign April 27, 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

After creating a basic service to capture all GTM required logging, let's add another tracker, sentry.io. The how-to use sentry is beyond the scope of this post, rather, what other methods we need to make available, to allow third party configurations.

The final project is on StackBlitz

Adding a third party

Heading to Sentry.io CDN installation, to see what setup is needed. The code snippet is this:

<script src="https://browser.sentry-cdn.com/6.19.7/bundle.min.js" crossorigin="anonymous"></script>
<script>
  Sentry.init({
    dsn: "https://productKey@o1104258.ingest.sentry.io/projectID",
    // ect
  });
</script>

In GTM, that's a Custom HTML tag that fires on DOM Ready trigger. The second part of the snippet is a call to Sentry.init with optional configurations.

One configuration option is initialScope. Which allows us to pass extra information about the visit. In the documentation, the example given is

user: { id: 42, email: "john.doe@example.com" }

To capture the error, the line of code is:

Sentry.captureException(err);

We need a way to pass those values to GTM, without an event. As for the unhandled error, we need to catch it before we send it to GTM. Working backwards:

// call this as soon as information is available
GtmTracking.SetValues(GtmTracking.MapUser({name: 'John Doe', id: '123', email: 'email@address.com'}));

// call this when language is available
GtmTracking.SetValues(GtmTracking.MapProfile({language: 'en'}));

// in error, simply register an event with error as a sub property
GtmTracking.RegisterEvent({event: EnumGtmEvent.Error}, {error: error});

That is a continuation of the line of thought we already setup. Remember, the idea of creating mappers is to isolate our internal models, from what gets reported to GTM. The GTM Service:

export enum EnumGtmEvent {
  // ... 
  // ADD new event
  Error = 'garage_error'
}

export class GtmTracking {
  // ...
 
  public static SetValues(values: any): void {
    // pass values into gr_values for GTM better handling
    dataLayer.push({
      gr_values: {...values}
    });
  }
  
  // create individual mappers when needed
  public static MapUser(user: IUser) {
    return {
      user: user.name,
      email: user.email
    }
  }
  
  // or mappers with no model
  public static MapProfile(profile: any) {
    return {
      language: profile.language,
      country: profile.country
    }
  }
  
  // or if you are quite confident about your props, pass without mapping
  // like {error}
}

In GTM, we create a variable to extract the information from gr_values. And from there, the Sentry Error tag can make use of some of them. Unlike event parameters set in GA4 Event tags, we do not have to create a variable for every property if we are using Custom HTML tag. (Note, a bit of more work is needed to make sure the variables are not null.)

GTM Tag

As for exceptions, we need to also create a trigger for the garage_error custom event, and a tag that uses the error property. The Sentry Error tag of type Custom HTML has this:

<script>
if (window.Sentry && window.Sentry.captureException) {
  // pass error variable 
  window.Sentry.captureException({{Garage Track error Variable}}))
}
</script>

Error tracking

Generic JavaScript errors are not thrown by default in Angular, thus JavaScript Error built-in Trigger in GTM won't work. Instead, manually report unhandled errors to GTM using a custom ErrorHandler. In App.module

@NgModule({
  //... create our own Error Hander to overwrite default ErrorHandler
  providers: [{ provide: ErrorHandler, useClass: OurErrorHandler }]
})
export class AppModule {}

The ErrorHandler service:

import { ErrorHandler, Injectable } from '@angular/core';
import { EnumGtmEvent, GtmTracking } from './gtm';

@Injectable()
export class OurErrorHandler implements ErrorHandler {
  handleError(error: any) {
    console.error(error);

    // track in GTM
    GtmTracking.RegisterEvent({ event: EnumGtmEvent.Error }, { error: error });

    // don't rethrow, it will call the hanlder again
    // throw(error);
  }
}

Out of zone errors, and errors which occur in the GTM container itself (like in other Custom HTML tags), are caught with trigger of type JavaScript Error. You can access the built-in variable: Error Message to report to Sentry. We rewrite the Sentry Error Tag to handle both triggers.

<script>
if (window.Sentry && window.Sentry.captureException) {
  // construct error, from custom event trigger
  var _error = {{Garage Track error Variable}};
  if (!_error){
    // from JavaScript error
    _error = new Error({{Error Message}} || 'Unknown Error');
  }
  window.Sentry.captureException(_error);
}
</script>

Sequence of events, again

Cute asynchronism, never quits popping its head when least expected. In most cases, we do not need to initialize with specific data layer variables. Thus using Page Initialization built-in event, is good enough. If we need to access data layer variables, Dom Ready event is better.

Consider the case of an error occurring on the first page load. Take a look at the sequence of events occurring in GTM:

GTM sequence

The message events, are where the data layer variables are set in Angular. The ideal place for Sentry Init Tag to fire is after data layer variables are sent, but before any garage_error is triggered. That is a bit tricky to accomplish, we have two options:

  • Initialize Sentry Tag on a custom event trigger (garage_sentry_init), that you call directly after setting values.
  • Initialize Sentry Tag on DOM ready, but check if it is initialized before you fire an Error tag, and wait.

The latter method, we replace the Sentry Error Tag with the following:

<script>
  var _capture = function(){
     window.Sentry.captureException({{Garage Track error Variable}});
  }
  if (!window.Sentry){
    // 2000 is long enough
     setTimeout(_capture, 2000); 
  } else {
    _capture(); 
  }
</script>

Note: You might think Sentry Lazy Loaded sdk does it, but it does not! The sdk does not load on captureException call.

Lesson learned: thou shall know thy sequence of events!

Resetting with defaults

Last time we added a method to reset data layer. We called it on NavigationEnd of route events. This makes sense, because every page has its own properties. If however we need to access global data layer values, with a tag that notifies a third party just in time, we want to keep track of those default values, and set them after data layer reset. Let's adjust GTM service with a new property:

export class GtmTracking {
  private static _values = {};
  public static get Values(): any {
    return this._values;
  }
  public static set Values(value: any) {
    // append to values
    this._values = {...this._values,...value};
  }
  
  // ...
  // update Reset
  public static Reset() {
    dataLayer.push(function () {
      this.reset();
    });
    // set values of defaults, again
    GtmTracking.SetValues(GtmTracking.Values);
  }
  
  // ...
}

First time setting values now becomes like this:

// first keep track of values
GtmTracking.Values = GtmTracking.MapUser({name: 'John Doe', id: '123', email: 'email@address.com'});
GtmTracking.Values = GtmTracking.MapProfile({language: 'en', country: 'jo'});
// then set data layer values
GtmTracking.SetValues(GtmTracking.Values);

Sever platform

UPDATE: The last bit to add is to condition out dataLayer push events that run on the server, this is a strictly browser platform activity. We create a private method to do the pushing, and delegate all dataLayer.push events:

// GTM service updated
export class GtmTracking {
  // ...
  
  private static Push(data: any) {
    // check if window exists
    if (window && window['dataLayer']) {
        dataLayer.push(data);
    }
  }
  
  // update all push events
  public static RegisterEvent(track: IGtmTrack, extra?: any): void {
    // ...
    this.Push(data)
  }

  public static SetValues(values: any): void {
    // ...
    this.Push(data);
  }
  public static Reset() {
    this.Push(function () {
      this.reset();
    });
    // ...
    }
}

Takeaway

We managed to map our internal models to data models that can be translated and used by GTM. I remember only last year we had to do much more for GA, and third party. After this exercise, my faith in GTM has been restored. Though bets are, next year this needs an update!

Thank you for staying alert, God knows it was hard for me as well.

  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