GTM Tracking Service in Angular
Tags: angular, gtm, analytics, tutorial
Published: 2022-04-17 20:03:51

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:
    - [Trigger Types](https://support.google.com/tagmanager/topic/7679108)
    - [Automatically collected events](https://support.google.com/analytics/answer/9234069)
    - [GA4 recommended events parameters](https://developers.google.com/analytics/devguides/collection/ga4/reference/events)
- I might have used `gr-` and `garage-` prefix interchangeably, forgive me

### Setup Google Tag Manager
Starting with [tag manager](https://tagmanager.google.com/) 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](https://garage.sekrab.com/posts/the-mystic-three-tokens-of-angular-app_bootstrap_listener-app_initializer-platform_initializer). But I see no added value.

```html
<!-- 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](https://cdn.hashnode.com/res/hashnode/image/upload/v1649662852981/UcaoiGyg3m.png)

Starting from [list of trigger types](https://support.google.com/tagmanager/topic/7679108) 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](https://cdn.hashnode.com/res/hashnode/image/upload/v1649667294659/9rQZWuLwI.png)

What we are looking for is on history change, send a `page_view` event to GA4.

According to [Automatically collected events](https://support.google.com/analytics/answer/9234069), `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 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](https://support.google.com/tagmanager/answer/7679322?hl=en&ref_topic=7679108).

### 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
```typescript
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1650195788428/cNm_yBl7R.png)

- 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](https://cdn.hashnode.com/res/hashnode/image/upload/v1650196125603/LDTk3V28a.png)

In our Angular App, let me create a static service. It is static until we need to change it.

```typescript
// 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
```typescript
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1650196180559/5hzD1XJSl.png)

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](https://cdn.hashnode.com/res/hashnode/image/upload/v1650196294683/ZFym5-hkL.png)

Now in our Angular app, we just need to add `garage_page_location` variable to the `dataLayer`
```typescript
// in component
nextPage(event: MouseEvent) {
    // ...

    // register view event pass the extra params
    GtmTracking.RegisterView(`;page=${page};public=${isPublic}`);
  }
```
In GTM service
```typescript
 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.

![GA4 realtime](https://cdn.hashnode.com/res/hashnode/image/upload/v1650196213820/y4a5xNOVx.png)

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](https://support.google.com/analytics/answer/9267735) list and [reference of all parameters of recommended events](https://developers.google.com/analytics/devguides/collection/ga4/reference/events), I can see a certain pattern, a data model that looks like this:
```typescript
// 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:
```typescript
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:

```typescript
// 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](https://developers.google.com/analytics/devguides/collection/ga4/reference/events), 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:
```typescript
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1650196390113/OExKL-wKG.png)

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](https://cdn.hashnode.com/res/hashnode/image/upload/v1650204191180/cdll6bGMI.png)

In our code, where the product list is viewed:
```typescript
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
```typescript
public static MapPath(path: string): any {
    return { page_location: path };
}
```
And in component, on **next** click:
```typescript
   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:
```html
// product list template
<ul>
     <li *ngFor="let item of products" (click)="trackThis(item)"> 
         {{ item.name }} - {{item.price }}
      </li>
</ul>
```
In component
```typesript
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:
```typescript
  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
```html
<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:
```typescript
  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`

![javascript datalayer](https://cdn.hashnode.com/res/hashnode/image/upload/v1650287003768/8uuowli9H.png)

The GTM tag could be set like this:

![GTM Tag](https://cdn.hashnode.com/res/hashnode/image/upload/v1650215630798/FeWpVhNOw.png)

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](https://stackblitz.com/edit/gtm-angular-state-local?file=src/app/core/gtm.ts)

### 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:
```js
{
    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
```js
{
    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:
```typescript
// 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
```typescript
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.

##### Resourcse

- [GTM Trigger types](https://support.google.com/tagmanager/topic/7679108)
- [GA4 Recommended events](https://support.google.com/analytics/answer/9267735)
- [GA4 Events parameters](https://developers.google.com/analytics/devguides/collection/ga4/reference/events)
- [StackBlitz](https://stackblitz.com/edit/gtm-angular-state-local?file=src/app/core/gtm.ts)