Skip to content

Instantly share code, notes, and snippets.

@peplocanto
Last active June 19, 2024 18:16
Show Gist options
  • Save peplocanto/3ff74ecc5ac1fe313e97ce4762ea2426 to your computer and use it in GitHub Desktop.
Save peplocanto/3ff74ecc5ac1fe313e97ce4762ea2426 to your computer and use it in GitHub Desktop.
Full implementation of an Analytics System in Angular using Google Tag Manager with Partytown Workers

How to add Analytics in Angular w/ GTM & Partytown

In this article, we will explore how to integrate analytics into an Angular application using Google Tag Manager (GTM) and Partytown. We will cover the necessary typings, a service for handling analytics events, a directive for tracking button clicks, an error interceptor for logging HTTP errors, and form tracking. Let's dive into each component.

Diagram of the final Result

angular analytics diagram

Prerequisites

Run the command below to install the dependencies:

npm install @builder.io/partytown

...and add the path to the Partytown JS files into the assets array in your angular.json file

"projects": {
    ...
    "{project-name}": {
        ...
        "architect": {
            ...
            "build": {
                ...
                "options": {
                    "assets": [...,
                      {
                        "glob": "**/*",
                        "input": "node_modules/@builder.io/partytown/lib",
                        "output": "/~partytown"
                      }
                    ]
                }
            }
        }
    }
}

Typings

Lets start with the basics...typings!

// analytics.model.ts

export type GtmDataCategory = 'navigation' | 'button' | 'form' | 'error';

export type GtmData = { category: GtmDataCategory } & (
  | GtmNavigationData
  | GtmButtonData
  | GtmFormData
  | GtmHttpErrorData
);

export type GtmNavigationData = {
  category: 'navigation';
  section: string;
  subsection: string;
};

export type GtmButtonAction = 'click' | 'back' | 'close';

export type GtmButtonData = {
  category: 'button';
  action: GtmButtonAction;
  label: string;
};

export type GtmFormData = {
  category: 'form';
  label: string;
  value: Record<string, any>;
};

export type GtmHttpErrorData = {
  category: 'error';
  label: string;
  status: number;
};

export type AnalyticsConfig = {
  gtm: {
    script: string;
  };
  partyTown: {
    debug: boolean;
    basePath: string;
    config: PartytownConfig;
  };
};

export type ExtraWindowProps = {
  dataLayer: GtmData[];
  partyTown: PartytownConfig;
};

export type GlobalObject = Window & typeof globalThis & ExtraWindowProps;

These typings define the different types of analytics data and the configuration for GTM and Partytown.

AnalyticsService

Next, let's create the AnalyticsService that handles the initialization and logging of analytics events.

// analytics.service.ts

@Injectable({
  providedIn: 'root'
})
export class AnalyticsService {
  private analyticsConfig = inject(ANALYTICS_CONFIG);
  private document = inject(DOCUMENT);
  private globalObject = inject(GLOBAL_OBJECT);
  private route = inject(ActivatedRoute);
  private router = inject(Router);
  private globalStore = inject(GlobalStore);

  private get isDebug() {
    return this.analyticsConfig.partyTown.debug;
  }

  init() {
    this.injectScripts();
    this.initNavigationLog();
  }

  logEvent(data: GtmData): void {
    this.addToGtm(data);
  }

  private injectScripts() {
    const headElement: HTMLHeadElement = this.document.head;
    const partyTownLibraryScript: HTMLScriptElement | null = this.analyticsConfig.partytown
      ? this.getPartytownScript()
      : null;
    const gtmScript: HTMLScriptElement = this.getGTMScript();

    if (!this.isDebug && partyTownLibraryScript) {
      headElement.appendChild(partyTownLibraryScript);
    }

    headElement.appendChild(gtmScript);
  }

  private initNavigationLog() {
    this.router.events
      .pipe(
        filter((e): e is NavigationEnd => e instanceof NavigationEnd),
        tap(() => this.logNavigation(this.getNavigationData()))
      )
      .subscribe();
  }

  private addToGtm(data: GtmData): void {
    if (data) {
      this.globalObject.dataLayer.push(data);
    }
  }

  private logNavigation(navigation: string[]): void {
    const [section, ...subsection] = navigation;
    this.addToGtm({
      category: 'navigation',
      section,
      subsection: subsection.join(' - '),
      logged: this.globalStore.isLoggedIn
    });
  }

  private getNavigationData() {
    const snapshot = this.route.snapshot;
    const navigationArr = [] as string[];
    function getDataRecursively(s: ActivatedRouteSnapshot): string[] {
      if (s.data?.['gtm']) navigationArr.push(s.data['gtm']);
      if (s.firstChild) getDataRecursively(s.firstChild);
      return [...new Set(navigationArr)];
    }
    return getDataRecursively(snapshot);
  }

  private getPartytownScript() {
    const { basePath } = this.analyticsConfig.partyTown;
    this.globalObject.partyTown = this.analyticsConfig.partyTown.config ?? {};
    const partyTownLibraryScript = this.document.createElement('script');
    partyTownLibraryScript.type = 'text/javascript';
    partyTownLibraryScript.src = `${basePath}/${this.isDebug ? 'debug/' : ''}partytown.js`;
    return partyTownLibraryScript;
  }

  private getGTMScript() {
    const gtmScript = this.document.createElement('script');
    gtmScript.type = this.isDebug || !this.analyticsConfig.partyTown.config ? 'text/javascript' : 'text/partyTown';
    gtmScript.textContent = this.analyticsConfig.gtm.script;
    return gtmScript;
  }
}

The AnalyticsService is responsible for injecting the necessary scripts, initializing the navigation log, and logging analytics events. It utilizes the provided configuration to inject GTM and Partytown scripts into the HTML head and logs navigation events. It also provides a logEvent method to log custom analytics events.

Analytics Configuration

Now, let's define the injection token for the analytics configuration.

export const ANALYTICS_CONFIG = new InjectionToken<AnalyticsConfig>(
  'ANALYTICS_CONFIG_INJECTION_TOKEN',
  {
    factory: () => {
      const env = inject(ENV_OBJECT);
      return {
        gtm: {
          script: getGTMScript(env['analytics']['googleID']),
        },
        partyTown: {
          debug: !env['production'],
          basePath: '/~partytown',
          config: {
            forward: ['dataLayer.push'],
          },
        },
      };
    },
  }
);

The ANALYTICS_CONFIG injection token provides a factory function that generates the analytics configuration based on the environment settings. It includes the GTM script and Partytown configuration.

GTM Script Utility

To simplify the creation of the GTM script with the appropriate Google ID, we can define a utility function.

export const getGTMScript = (googleID: string): string => `
(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', '${googleID}');
`;

This utility function generates the GTM script dynamically by injecting the appropriate Google ID.

Analytics Service Initialization

In this way AnalyticsService is initialized when the app starts.

// main.ts

bootstrapApplication(AppRoot, {
  providers: [
    { provide: ENV_OBJECT, useValue: environment },
    {
      provide: APP_INITIALIZER,
      multi: true,
      useFactory: (s = inject(AnalyticsService)) => () => s.init()
    },
  ],
});

register();

Routes Configuration

To track navigation the logNavigation method of AnalyticsService relies on Route data attribute. An example of this implementation is:

// example.routes.ts

export const exampleRoutes: Routes = [
  {
    path: 'example-page',
    loadComponent: () => import('./example-page').then((c) => c.ExamplePage),
    data: { gtm: 'example-page' } // will push to dataLayer { category: 'navigation', section: 'example-page', subsection: '' }
  },
  {
    path: 'example-module',
    loadChildren: () => import('./example-module').then((m) => m.EXAMPLE_NESTED_ROUTES),
    data: { gtm: 'example-module' } // will push to dataLayer { category: 'navigation', section: 'example-module', subsection: <EXAMPLE_NESTED_ROUTES> }
  }
];

Analytics Directive

To track button clicks, we can create an AnalyticsDirective.

// analytics.directive.ts

@Directive({
  selector: '[analytics]',
  standalone: true,
})
export class AnalyticsDirective {
  @Input() analytics!: string;
  @Input() action: GtmButtonAction = 'click';

  constructor(private analyticsService: AnalyticsService) {}

  @HostListener('click')
  private onClick(): void {
    if (!this.analytics) {
      throw new Error("Analitycs tag can't be null");
    }

    const data: GtmButtonData = {
      category: 'button',
      action: this.action,
      label: this.analytics,
    };

    this.analyticsService.logEvent(data);
  }
}

The AnalyticsDirective is responsible for tracking button clicks. It listens for the click event on elements with the analytics attribute and logs the associated analytics event using the AnalyticsService.

To use the directive, you can add it to your buttons as shown below:

<button analytics="home.hello-button">Hello World</button>
<button analytics="home.goodbye-button" action="close">Goodbye World</button>

The analytics attribute specifies the analytics tag for the button, and the action attribute (optional) specifies the type of button action.

Error Interceptor

For logging HTTP errors, we can create an error interceptor.

@Injectable()
class ExceptionsInterceptor implements HttpInterceptor {
  constructor(private analyticsService: AnalyticsService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler) {
    return next
      .handle(req)
      .pipe(catchError((error) => this.handleException(error)));
  }

  private handleException(error: unknown) {
    if (error instanceof HttpErrorResponse) {
      const message = error.message || error.error?.message;
      console.log('Http exception catched in interceptor: ', message);
      this.analyticsService.logEvent({
        category: 'error',
        label: message,
        status: error.status,
      });
    }
    return throwError(error);
  }
}

export const exceptionsInterceptorProvider = {
  provide: HTTP_INTERCEPTORS,
  useClass: ExceptionsInterceptor,
  multi: true,
};

The ExceptionsInterceptor intercepts HTTP requests and handles any errors that occur. It logs the error as an analytics event using the AnalyticsService.

Form Tracking

To track form submissions, you can directly call the logEvent method of the AnalyticsService in the onSubmit method of your form.

this.analyticsService.logEvent({
  category: 'form',
  label: 'form description',
  value: {}, // form values useful for analytics
});

This code snippet demonstrates how to log a form submission event by providing the appropriate data to the logEvent method.

Conclusions

By following these steps and integrating the provided components, you can enhance your Angular application with analytics using GTM and Partytown.

Resources

@peplocanto
Copy link
Author

getGTMScript script should have type="text/partytown" then only partytown will be applicable to that script right ?

You are right! I edit the gist adding gtmScript.type = this.isDebug || !this.analyticsConfig.partyTown.config ? 'text/javascript' : 'text/partyTown'; in getGTMScript method.

config: {
  forward: ['dataLayer.push'],
   lib: '/assets/~partytown/'
}

should be appended in the script before partytown script right, but we are just adding in ANALYTICS_CONFIG but not using it anywhere this.globalObject.partyTown = this.analyticsConfig.partyTown.config ?? {};

We are adding the configuration to the window object as per Partytown Documentation.

Tnx a lot for your comment!

@venkatnarasimharao
Copy link

getGTMScript script should have type="text/partytown" then only partytown will be applicable to that script right ?

You are right! I edit the gist adding gtmScript.type = this.isDebug || !this.analyticsConfig.partyTown.config ? 'text/javascript' : 'text/partyTown'; in getGTMScript method.

config: {
  forward: ['dataLayer.push'],
   lib: '/assets/~partytown/'
}

should be appended in the script before partytown script right, but we are just adding in ANALYTICS_CONFIG but not using it anywhere this.globalObject.partyTown = this.analyticsConfig.partyTown.config ?? {};

We are adding the configuration to the window object as per Partytown Documentation.

Tnx a lot for your comment!

Thanks for the updates,
i was trying to add type party Town to the script and run but its not working as expected do you have any working example & BTW type="type="text/partytown"" instead of type="text/partyTown" as per partytown.builder.io
Thanks a lot once again

@peplocanto
Copy link
Author

BTW type="type="text/partytown"" instead of type="text/partyTown" as per partytown.builder.io

Fixd! Tnx dude!

I was trying to add type party Town to the script and run but its not working as expected

can u be a little more specific or create a stackblitz?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment