feat(aio): add google analytics (#15081)

This commit is contained in:
Ward Bell 2017-03-13 18:08:23 -07:00 committed by Chuck Jazdzewski
parent 914797a8ff
commit 1c1085b140
12 changed files with 283 additions and 13 deletions

View File

@ -1,3 +1,4 @@
import { browser, element, by, promise } from 'protractor';
import { SitePage } from './app.po'; import { SitePage } from './app.po';
describe('site App', function() { describe('site App', function() {
@ -15,4 +16,31 @@ describe('site App', function() {
}); });
it('should convert a doc with a code-example'); it('should convert a doc with a code-example');
describe('google analytics', () => {
beforeEach(done => page.gaReady.then(done));
it('should call ga', done => {
page.ga()
.then(calls => {
expect(calls.length).toBeGreaterThan(2, 'ga calls');
done();
});
});
it('should call ga with initial URL', done => {
let path: string;
page.locationPath()
.then(p => path = p)
.then(() => page.ga().then(calls => {
expect(calls.length).toBeGreaterThan(2, 'ga calls');
expect(calls[1]).toEqual(['set', 'page', path]);
done();
}));
});
// Todo: add test to confirm tracking URL when navigate.
});
}); });

View File

@ -1,18 +1,46 @@
import { browser, element, by } from 'protractor'; import { browser, element, by, promise } from 'protractor';
export class SitePage { export class SitePage {
links = element.all(by.css('md-toolbar a')); links = element.all(by.css('md-toolbar a'));
docViewer = element(by.css('aio-doc-viewer')); docViewer = element(by.css('aio-doc-viewer'));
codeExample = element.all(by.css('aio-doc-viewer pre > code')); codeExample = element.all(by.css('aio-doc-viewer pre > code'));
featureLink = element(by.css('md-toolbar a[href="features"]')); featureLink = element(by.css('md-toolbar a[href="features"]'));
gaReady: promise.Promise<any>;
ga = () => browser.executeScript('return window["gaCalls"]') as promise.Promise<any[][]>;
locationPath = () => browser.executeScript('return document.location.pathname') as promise.Promise<string>;
navigateTo() { navigateTo() {
return browser.get('/'); return browser.get('/').then(_ => this.replaceGa(_));
} }
getDocViewerText() { getDocViewerText() {
return this.docViewer.getText(); return this.docViewer.getText();
} }
/**
* Replace the ambient Google Analytics tracker with homebrew spy
* don't send commands to GA during e2e testing!
* @param _ - forward's anything passed in
*/
private replaceGa(_: any) {
this.gaReady = browser.driver.executeScript(() => {
// Give ga() a "ready" callback:
// https://developers.google.com/analytics/devguides/collection/analyticsjs/command-queue-reference
window['ga'](() => {
window['gaCalls'] = [];
window['ga'] = function() { window['gaCalls'].push(arguments); };
});
})
.then(() => {
// wait for GaService to start using window.ga after analytics lib loads.
const d = promise.defer();
setTimeout(() => d.fulfill(), 1000); // GaService.initializeDelay
return d.promise;
});
return _;
}
} }

View File

@ -2,6 +2,7 @@ import { async, inject, ComponentFixture, TestBed } from '@angular/core/testing'
import { APP_BASE_HREF } from '@angular/common'; import { APP_BASE_HREF } from '@angular/common';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { GaService } from 'app/shared/ga.service';
import { SearchService } from 'app/search/search.service'; import { SearchService } from 'app/search/search.service';
import { MockSearchService } from 'testing/search.service'; import { MockSearchService } from 'testing/search.service';
@ -14,7 +15,8 @@ describe('AppComponent', () => {
imports: [ AppModule ], imports: [ AppModule ],
providers: [ providers: [
{ provide: APP_BASE_HREF, useValue: '/' }, { provide: APP_BASE_HREF, useValue: '/' },
{ provide: SearchService, useClass: MockSearchService } { provide: SearchService, useClass: MockSearchService },
{ provide: GaService, useClass: TestGaService }
] ]
}); });
TestBed.compileComponents(); TestBed.compileComponents();
@ -29,6 +31,18 @@ describe('AppComponent', () => {
expect(component).toBeDefined(); expect(component).toBeDefined();
}); });
describe('google analytics', () => {
it('should call gaService.locationChanged with initial URL', () => {
const url = window.location.pathname.substr(1); // strip leading '/'
const { locationChanged } = TestBed.get(GaService) as TestGaService;
expect(locationChanged.calls.count()).toBe(1, 'gaService.locationChanged');
const args = locationChanged.calls.first().args;
expect(args[0]).toBe(url);
});
// Todo: add test to confirm tracking URL when navigate.
});
describe('isHamburgerVisible', () => { describe('isHamburgerVisible', () => {
}); });
@ -57,3 +71,7 @@ describe('AppComponent', () => {
})); }));
}); });
}); });
class TestGaService {
locationChanged = jasmine.createSpy('locationChanged');
}

View File

@ -1,5 +1,8 @@
import { Component, ViewChild, OnInit } from '@angular/core'; import { Component, ViewChild, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import { GaService } from 'app/shared/ga.service';
import { LocationService } from 'app/shared/location.service';
import { DocumentService, DocumentContents } from 'app/documents/document.service'; import { DocumentService, DocumentContents } from 'app/documents/document.service';
import { NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.service'; import { NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.service';
import { SearchService } from 'app/search/search.service'; import { SearchService } from 'app/search/search.service';
@ -20,8 +23,15 @@ export class AppComponent implements OnInit {
navigationViews: Observable<NavigationViews>; navigationViews: Observable<NavigationViews>;
selectedNodes: Observable<NavigationNode[]>; selectedNodes: Observable<NavigationNode[]>;
constructor(documentService: DocumentService, navigationService: NavigationService, private searchService: SearchService) { constructor(
documentService: DocumentService,
gaService: GaService,
locationService: LocationService,
navigationService: NavigationService,
private searchService: SearchService) {
this.currentDocument = documentService.currentDocument; this.currentDocument = documentService.currentDocument;
locationService.currentUrl.subscribe(url => gaService.locationChanged(url));
this.navigationViews = navigationService.navigationViews; this.navigationViews = navigationService.navigationViews;
this.selectedNodes = navigationService.selectedNodes; this.selectedNodes = navigationService.selectedNodes;
} }

View File

@ -19,6 +19,7 @@ import { AppComponent } from 'app/app.component';
import { ApiService } from 'app/embedded/api/api.service'; import { ApiService } from 'app/embedded/api/api.service';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { embeddedComponents, EmbeddedComponents } from 'app/embedded'; import { embeddedComponents, EmbeddedComponents } from 'app/embedded';
import { GaService } from 'app/shared/ga.service';
import { Logger } from 'app/shared/logger.service'; import { Logger } from 'app/shared/logger.service';
import { LocationService } from 'app/shared/location.service'; import { LocationService } from 'app/shared/location.service';
import { NavigationService } from 'app/navigation/navigation.service'; import { NavigationService } from 'app/navigation/navigation.service';
@ -55,6 +56,7 @@ import { SearchBoxComponent } from './search/search-box/search-box.component';
providers: [ providers: [
ApiService, ApiService,
EmbeddedComponents, EmbeddedComponents,
GaService,
Logger, Logger,
Location, Location,
{ provide: LocationStrategy, useClass: PathLocationStrategy }, { provide: LocationStrategy, useClass: PathLocationStrategy },

View File

@ -5,11 +5,12 @@ import { Observable } from 'rxjs/Observable';
import { AsyncSubject } from 'rxjs/AsyncSubject'; import { AsyncSubject } from 'rxjs/AsyncSubject';
import 'rxjs/add/operator/switchMap'; import 'rxjs/add/operator/switchMap';
import { LocationService } from 'app/shared/location.service';
import { Logger } from 'app/shared/logger.service';
import { DocumentContents } from './document-contents'; import { DocumentContents } from './document-contents';
export { DocumentContents } from './document-contents'; export { DocumentContents } from './document-contents';
import { LocationService } from 'app/shared/location.service';
import { Logger } from 'app/shared/logger.service';
const FILE_NOT_FOUND_URL = 'file-not-found'; const FILE_NOT_FOUND_URL = 'file-not-found';
@Injectable() @Injectable()
@ -20,7 +21,10 @@ export class DocumentService {
currentDocument: Observable<DocumentContents>; currentDocument: Observable<DocumentContents>;
constructor(private logger: Logger, private http: Http, location: LocationService) { constructor(
private logger: Logger,
private http: Http,
location: LocationService) {
// Whenever the URL changes we try to get the appropriate doc // Whenever the URL changes we try to get the appropriate doc
this.currentDocument = location.currentUrl.switchMap(url => this.getDocument(url)); this.currentDocument = location.currentUrl.switchMap(url => this.getDocument(url));
} }

View File

@ -0,0 +1,107 @@
import { ReflectiveInjector } from '@angular/core';
import { fakeAsync, tick } from '@angular/core/testing';
import { GaService } from 'app/shared/ga.service';
import { Logger } from 'app/shared/logger.service';
describe('GaService', () => {
let gaSpy: jasmine.Spy;
let injector: ReflectiveInjector;
beforeEach(() => {
injector = ReflectiveInjector.resolveAndCreate([
GaService,
{ provide: Logger, useClass: TestLogger }
]);
});
describe('with ambient GA', () => {
let gaService: GaService;
beforeEach(fakeAsync(() => {
this.winGa = window['ga']; // remember current GA tracker just in case
// Replace Google Analytics tracker with spy after calling "ga ready" callback
window['ga'] = (fn: Function) => {
window['ga'] = gaSpy = jasmine.createSpy('ga');
fn();
tick(GaService.initializeDelay); // see GaService#initializeGa
};
gaService = injector.get(GaService);
}));
afterEach(() => {
window['ga'] = this.winGa;
});
it('should initialize ga with "create" when constructed', () => {
const first = gaSpy.calls.first().args;
expect(first[0]).toBe('create');
});
describe('#sendPage(url)', () => {
it('should set page to url w/ leading slash', () => {
gaService.sendPage('testUrl');
const args = gaSpy.calls.all()[1].args;
expect(args).toEqual(['set', 'page', '/testUrl']);
});
it('should send "pageview" ', () => {
gaService.sendPage('testUrl');
const args = gaSpy.calls.all()[2].args;
expect(args).toEqual(['send', 'pageview']);
});
it('should not send twice with same URL, back-to-back', () => {
gaService.sendPage('testUrl');
const count1 = gaSpy.calls.count();
gaService.sendPage('testUrl');
const count2 = gaSpy.calls.count();
expect(count2).toEqual(count1);
});
it('should send same URL twice when other intervening URL', () => {
gaService.sendPage('testUrl');
const count1 = gaSpy.calls.count();
gaService.sendPage('testUrl2');
const count2 = gaSpy.calls.count();
expect(count2).toBeGreaterThan(count1, 'testUrl2 was sent');
gaService.sendPage('testUrl');
const count3 = gaSpy.calls.count();
expect(count3).toBeGreaterThan(count1, 'testUrl was sent 2nd time');
});
});
describe('#locationChanged(url)', () => {
it('should send page to url w/ leading slash', () => {
gaService.locationChanged('testUrl');
let args = gaSpy.calls.all()[1].args;
expect(args).toEqual(['set', 'page', '/testUrl']);
args = gaSpy.calls.all()[2].args;
expect(args).toEqual(['send', 'pageview']);
});
});
});
describe('when no ambient GA', () => {
let gaService: GaService;
let logger: TestLogger;
it('should log with "create" when constructed', () => {
gaService = injector.get(GaService);
logger = injector.get(Logger);
expect(logger.log.calls.count()).toBe(1, 'logger.log should be called');
const first = logger.log.calls.first().args;
expect(first[0]).toBe('ga:');
expect(first[1][0]).toBe('create'); // first[1] is the array of args to ga()
});
});
});
class TestLogger {
log = jasmine.createSpy('log');
}

View File

@ -0,0 +1,59 @@
import { Injectable } from '@angular/core';
import { environment } from '../../environments/environment';
import { Logger } from 'app/shared/logger.service';
@Injectable()
/**
* Google Analytics Service - captures app behaviours and sends them to Google Analytics (GA).
* Presupposes that GA script has been loaded from a script on the host web page.
* Associates data with a GA "property" from the environment (`gaId`).
*/
export class GaService {
// ms to wait before acquiring window.ga after analytics library loads
// empirically determined to allow time for e2e test setup
static initializeDelay = 1000;
private previousUrl: string;
private ga: (...rest: any[]) => void;
constructor(private logger: Logger) {
this.initializeGa();
this.ga('create', environment['gaId'] , 'auto');
}
locationChanged(url: string) {
this.sendPage(url);
}
sendPage(url: string) {
if (url === this.previousUrl) { return; }
this.previousUrl = url;
this.ga('set', 'page', '/' + url);
this.ga('send', 'pageview');
}
// These gyrations are necessary to make the service e2e testable
// and to disable ga tracking during e2e tests.
private initializeGa() {
const ga = window['ga'];
if (ga) {
// Queue commands until GA analytics script has loaded.
const gaQueue: any[][] = [];
this.ga = (...rest: any[]) => { gaQueue.push(rest); };
// Then send queued commands to either real or e2e test ga();
// after waiting to allow possible e2e test to replace global ga function
ga(() => setTimeout(() => {
// this.logger.log('GA fn:', window['ga'].toString());
this.ga = window['ga'];
gaQueue.forEach((command) => this.ga.apply(null, command));
}, GaService.initializeDelay));
} else {
// delegate `ga` calls to the logger if no ga installed
this.ga = (...rest: any[]) => { this.logger.log('ga:', rest); };
}
}
}

View File

@ -1,18 +1,20 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Location, PlatformLocation } from '@angular/common'; import { Location, PlatformLocation } from '@angular/common';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import { ReplaySubject } from 'rxjs/ReplaySubject';
@Injectable() @Injectable()
export class LocationService { export class LocationService {
private urlSubject: BehaviorSubject<string>; private urlSubject = new ReplaySubject<string>(1);
get currentUrl() { return this.urlSubject.asObservable(); } currentUrl = this.urlSubject.asObservable();
constructor(private location: Location, private platformLocation: PlatformLocation) { constructor(
private location: Location,
private platformLocation: PlatformLocation) {
const initialUrl = this.stripLeadingSlashes(location.path(true)); const initialUrl = this.stripLeadingSlashes(location.path(true));
this.urlSubject = new BehaviorSubject(initialUrl); this.urlSubject.next(initialUrl);
this.location.subscribe(state => { this.location.subscribe(state => {
const url = this.stripLeadingSlashes(state.url); const url = this.stripLeadingSlashes(state.url);

View File

@ -1,3 +1,4 @@
export const environment = { export const environment = {
gaId: 'UA-8594346-26', // TODO: this is for the staging site; reset to correct account
production: true production: true
}; };

View File

@ -4,5 +4,6 @@
// The list of which env maps to which file can be found in `angular-cli.json`. // The list of which env maps to which file can be found in `angular-cli.json`.
export const environment = { export const environment = {
gaId: 'UA-8594346-26', // Staging site
production: false production: false
}; };

View File

@ -22,6 +22,16 @@
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<!-- Google Analytics -->
<script>
window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)};ga.l=+new Date;
// let GaService set the following
// ga('create', 'UA-xxxxxxx-1', 'auto');
// ga('send', 'pageview');
</script>
<script async src='https://www.google-analytics.com/analytics.js'></script>
<!-- End Google Analytics -->
</head> </head>
<body> <body>