Setting up Segment Analytics in Angular with environment files

Setting up Segment Analytics in Angular with environment files

The techniques described also work for other analytics services like Google Analytics

Introduction

I recently finished working on an Angular web app and it was time to set up analytics to better understand the user experience it delivers. My choice of analytics provider was Segment as it allows setting up a single analytics source within the app and feed the data to various other destinations like Google Analytics and Amplitude.

Having decided on the analytics service to use, I now wanted to integrate it within Angular and test it across different app environments. For the app, I had three environments - dev, staging and production. By specifying different analytics keys for each environment, it prevents skewing live analytics while allowing us to test our integration in a non-production environment.

Including the Segment analytics tracking snippet

Setting up Segment starts with including their tracking snippet in our index.html:

<script>
  !function(){var analytics=window.analytics=window.analytics||[];if(!analytics.initialize)if(analytics.invoked)window.console&&console.error&&console.error("Segment snippet included twice.");else{analytics.invoked=!0;analytics.methods=["trackSubmit","trackClick","trackLink","trackForm","pageview","identify","reset","group","track","ready","alias","debug","page","once","off","on","addSourceMiddleware","addIntegrationMiddleware","setAnonymousId","addDestinationMiddleware"];analytics.factory=function(e){return function(){var t=Array.prototype.slice.call(arguments);t.unshift(e);analytics.push(t);return analytics}};for(var e=0;e<analytics.methods.length;e++){var key=analytics.methods[e];analytics[key]=analytics.factory(key)}analytics.load=function(key,e){var t=document.createElement("script");t.type="text/javascript";t.async=!0;t.src="https://cdn.segment.com/analytics.js/v1/" + key + "/analytics.min.js";var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(t,n);analytics._loadOptions=e};analytics._writeKey="YOUR_WRITE_KEY";analytics.SNIPPET_VERSION="4.15.2";
  analytics.load("YOUR_WRITE_KEY");
  analytics.page();
  }}();
</script>

Note the following lines of code towards the end:

  analytics._writeKey="YOUR_WRITE_KEY"
  analytics.load("YOUR_WRITE_KEY");
  analytics.page();

We'll remove these lines from the snippet and handle them ourselves in the Angular app, so the new tracking code we include in our index.html becomes:

<script>
  !function(){var analytics=window.analytics=window.analytics||[];if(!analytics.initialize)if(analytics.invoked)window.console&&console.error&&console.error("Segment snippet included twice.");else{analytics.invoked=!0;analytics.methods=["trackSubmit","trackClick","trackLink","trackForm","pageview","identify","reset","group","track","ready","alias","debug","page","once","off","on","addSourceMiddleware","addIntegrationMiddleware","setAnonymousId","addDestinationMiddleware"];analytics.factory=function(e){return function(){var t=Array.prototype.slice.call(arguments);t.unshift(e);analytics.push(t);return analytics}};for(var e=0;e<analytics.methods.length;e++){var key=analytics.methods[e];analytics[key]=analytics.factory(key)}analytics.load=function(key,e){var t=document.createElement("script");t.type="text/javascript";t.async=!0;t.src="https://cdn.segment.com/analytics.js/v1/" + key + "/analytics.min.js";var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(t,n);analytics._loadOptions=e};analytics.SNIPPET_VERSION="4.15.2";}}();
</script>

This allows us to share the "base" Segment tracking snippet in all app environments while specifying a SEGMENT_WRITE_KEY specific to each.

Specifying a different key for each environment

Now, how do we go about specifying a key for each environment?

At the time of writing, when creating a new app with the Angular CLI using ng new, it creates two environment files in the environments folder by default:

  1. environment.ts: environment variables for local development
  2. environment.prod.ts: environment variables for the production build when running ng build --configuration production or the default ng build command

So, we can use these files to specify different analytics keys for Segment:

// file environment.ts:

export const environment = {
  production: false,
  analytics: {
    segmentWriteKey: 'DEV_SEGMENT_WRITE_KEY'
  }
};
// file environment.prod.ts:

export const environment = {
  production: true,
  analytics: {
    segmentWriteKey: 'PRODUCTION_SEGMENT_WRITE_KEY'
  }
};

Having specified the different keys, we need to initiate Segment analytics with the correct key. This can be done in our main.ts file which is responsible for bootstrapping our Angular app. Before the platformBrowserDynamic().bootstrapModule(AppModule) call that loads our app, we can initialise Segment by adding back analytics._writeKey="YOUR_WRITE_KEY" and analytics.load("YOUR_WRITE_KEY"); we removed from the tracking snippet earlier but replace "YOUR_WRITE_KEY" with our environment variable segmentWriteKey.

Trying out this line of code as is though, makes TypeScript unhappy with the following error:

Cannot find name 'analytics'. ts(2304)

Well, this means that analytics isn't defined anywhere, but with the way Segment works, it should be part of the window object. So, let's try changing it to window.analytics.load(environment.analytics.segmentWriteKey). This gives us a different error:

Property 'analytics' does not exist on type 'Window & typeof globalThis'. ts(2339)

A different error message, some progress!

This is because TypeScript still does not know if the analytics object exists or the interface it provides within the window object. Thankfully, we can strongly type the analytics object by installing its types as a dev dependency using:

npm install --save-dev @types/segment-analytics

With the types installed, there's one last thing we need to do, to make TypeScript happy with using the analytics object anywhere within our code. That bit is making sure we include the segment-analytics namespace within our tsconfig.app.json types property like so:

// file: tsconfig.app.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app",
    "types": ["segment-analytics"]
  },
  "files": [
    "src/main.ts",
    "src/polyfills.ts"
  ],
  "include": [
    "src/**/*.d.ts"
  ]
}

Remember to restart your dev server with ng serve after updating tsconfig.app.json!

With this in place, we can now call all functions exposed by the analytics object anywhere in our code and enjoy all of TypeScript's goodness when doing so. Our final main.ts file should now look like:

// file: main.ts

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

// typing this as any since the types we installed don't include _writeKey
(analytics as any)._writeKey = environment.analytics.segmentWriteKey
analytics.load(environment.analytics.segmentWriteKey)

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));

Tracking page changes

Our final task is to now bring back the analytics.page() call we removed from the snippet earlier. Segment analytics includes this call in the tracking snippet assuming its usage in a traditional Multi Page Application (MPA) where we load a new index.html file for each page and thus invoke a new analytics.page() call.

With Angular though, this is a different story as we're building a Single Page Application (SPA) that only loads a single index.html file and handles page changes via JavaScript. So, we need to take control of the analytics page calls.

This is a perfect use-case for Angular Router Events as it allows us to hook into the lifecycle of the Angular router and subscribe to page changes. With this, any time the Angular Router loads a new page, we can hook into that event, and make a call to analytics.page(). We'll make use of the NavigationStart event, so we trigger the call as soon as a page is changed.

To be able to listen to router events, we first need to inject the Angular Router in our app.component.ts:

import { Router } from '@angular/router';

constructor(private _router: Router) {}

Using the injected router we can subscribe to all its events:

this._router.events.subscribe((event) => {
  console.log(event)
})

Since we're interested in the NavigationStart event only, we can add a check with instanceof:

import { NavigationStart } from '@angular/router';

if (event instanceof NavigationStart) {
  console.log(event)
}

If we replace console.log(event) in the above snippet with analytics.page(event.url), we can trigger an analytics page call, each time Angular Router changes a page. Putting all of these pieces together and remembering to unsubscribe from our subscription when this component destroys to prevent memory leaks, we get:

// file: app.component.ts

import { Subscription } from 'rxjs';
import { NavigationStart, Router } from '@angular/router';
import { Component, OnDestroy, OnInit } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit, OnDestroy {
  routerEventSubscription: Subscription | undefined;

  constructor(private _router: Router) {}

  public ngOnInit(): void {
    this.routerEventSubscription = this._router.events.subscribe((event) => {
      if (event instanceof NavigationStart) {
        analytics.page(event.url)
      }
    });
  }

  public ngOnDestroy(): void {
    this.routerEventSubscription?.unsubscribe()
  }
}

That sets us up for using Segment analytics in our Angular app! 🎉

For tracking particular events, like clicks, make use of the analytics.track() call which would also now be strongly typed thanks to the @types/segment-analytics package we set up.

Bonus – setting up the staging environment

As an added bonus, let's look at setting up a staging (or QA) environment for the app. We'll make use of the configuration and fileReplacement properties in angular.json which allow us to create a new build target and specify specific file replacements at compile time.

To do this, we'll duplicate the production configuration, rename the key to staging and within fileReplacements, specify environment.ts to be replaced with environment.staging.ts when we build the app for staging:

"staging": {
  "budgets": [
    {
      "type": "initial",
      "maximumWarning": "500kb",
      "maximumError": "1mb"
    },
    {
      "type": "anyComponentStyle",
      "maximumWarning": "2kb",
      "maximumError": "4kb"
    }
  ],
  "fileReplacements": [
    {
      "replace": "src/environments/environment.ts",
      "with": "src/environments/environment.staging.ts"
    }
  ],
  "outputHashing": "all"
},

Remember to create the environment.staging.ts file! Our new configuration will now be used when building the app with ng build --configuration staging

View the code from this article on GitHub

Thanks for reading!