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
anchorAdding 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.)
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>
anchorError 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>
anchorSequence 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:
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!
anchorResetting 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);
anchorSever 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();
});
// ...
}
}
anchorTakeaway
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.