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.
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"
}
]
}
}
}
}
}
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.
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.
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.
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.
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();
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> }
}
];
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.
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.
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.
By following these steps and integrating the provided components, you can enhance your Angular application with analytics using GTM and Partytown.