feat(aio): add google analytics (#15081)
This commit is contained in:
parent
914797a8ff
commit
1c1085b140
@ -1,3 +1,4 @@
|
||||
import { browser, element, by, promise } from 'protractor';
|
||||
import { SitePage } from './app.po';
|
||||
|
||||
describe('site App', function() {
|
||||
@ -15,4 +16,31 @@ describe('site App', function() {
|
||||
});
|
||||
|
||||
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.
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -1,18 +1,46 @@
|
||||
import { browser, element, by } from 'protractor';
|
||||
import { browser, element, by, promise } from 'protractor';
|
||||
|
||||
export class SitePage {
|
||||
|
||||
links = element.all(by.css('md-toolbar a'));
|
||||
docViewer = element(by.css('aio-doc-viewer'));
|
||||
codeExample = element.all(by.css('aio-doc-viewer pre > code'));
|
||||
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() {
|
||||
return browser.get('/');
|
||||
return browser.get('/').then(_ => this.replaceGa(_));
|
||||
}
|
||||
|
||||
getDocViewerText() {
|
||||
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 _;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,7 @@ import { async, inject, ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { APP_BASE_HREF } from '@angular/common';
|
||||
import { AppComponent } from './app.component';
|
||||
import { AppModule } from './app.module';
|
||||
import { GaService } from 'app/shared/ga.service';
|
||||
import { SearchService } from 'app/search/search.service';
|
||||
import { MockSearchService } from 'testing/search.service';
|
||||
|
||||
@ -14,7 +15,8 @@ describe('AppComponent', () => {
|
||||
imports: [ AppModule ],
|
||||
providers: [
|
||||
{ provide: APP_BASE_HREF, useValue: '/' },
|
||||
{ provide: SearchService, useClass: MockSearchService }
|
||||
{ provide: SearchService, useClass: MockSearchService },
|
||||
{ provide: GaService, useClass: TestGaService }
|
||||
]
|
||||
});
|
||||
TestBed.compileComponents();
|
||||
@ -29,6 +31,18 @@ describe('AppComponent', () => {
|
||||
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', () => {
|
||||
});
|
||||
|
||||
@ -57,3 +71,7 @@ describe('AppComponent', () => {
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
class TestGaService {
|
||||
locationChanged = jasmine.createSpy('locationChanged');
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { Component, ViewChild, OnInit } from '@angular/core';
|
||||
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 { NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.service';
|
||||
import { SearchService } from 'app/search/search.service';
|
||||
@ -20,8 +23,15 @@ export class AppComponent implements OnInit {
|
||||
navigationViews: Observable<NavigationViews>;
|
||||
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;
|
||||
locationService.currentUrl.subscribe(url => gaService.locationChanged(url));
|
||||
this.navigationViews = navigationService.navigationViews;
|
||||
this.selectedNodes = navigationService.selectedNodes;
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ import { AppComponent } from 'app/app.component';
|
||||
import { ApiService } from 'app/embedded/api/api.service';
|
||||
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
|
||||
import { embeddedComponents, EmbeddedComponents } from 'app/embedded';
|
||||
import { GaService } from 'app/shared/ga.service';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { LocationService } from 'app/shared/location.service';
|
||||
import { NavigationService } from 'app/navigation/navigation.service';
|
||||
@ -55,6 +56,7 @@ import { SearchBoxComponent } from './search/search-box/search-box.component';
|
||||
providers: [
|
||||
ApiService,
|
||||
EmbeddedComponents,
|
||||
GaService,
|
||||
Logger,
|
||||
Location,
|
||||
{ provide: LocationStrategy, useClass: PathLocationStrategy },
|
||||
|
@ -5,11 +5,12 @@ import { Observable } from 'rxjs/Observable';
|
||||
import { AsyncSubject } from 'rxjs/AsyncSubject';
|
||||
import 'rxjs/add/operator/switchMap';
|
||||
|
||||
import { LocationService } from 'app/shared/location.service';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { 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';
|
||||
|
||||
@Injectable()
|
||||
@ -20,7 +21,10 @@ export class DocumentService {
|
||||
|
||||
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
|
||||
this.currentDocument = location.currentUrl.switchMap(url => this.getDocument(url));
|
||||
}
|
||||
|
107
aio/src/app/shared/ga.service.spec.ts
Normal file
107
aio/src/app/shared/ga.service.spec.ts
Normal 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');
|
||||
}
|
59
aio/src/app/shared/ga.service.ts
Normal file
59
aio/src/app/shared/ga.service.ts
Normal 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); };
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,18 +1,20 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Location, PlatformLocation } from '@angular/common';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import { ReplaySubject } from 'rxjs/ReplaySubject';
|
||||
|
||||
@Injectable()
|
||||
export class LocationService {
|
||||
|
||||
private urlSubject: BehaviorSubject<string>;
|
||||
get currentUrl() { return this.urlSubject.asObservable(); }
|
||||
private urlSubject = new ReplaySubject<string>(1);
|
||||
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));
|
||||
this.urlSubject = new BehaviorSubject(initialUrl);
|
||||
this.urlSubject.next(initialUrl);
|
||||
|
||||
this.location.subscribe(state => {
|
||||
const url = this.stripLeadingSlashes(state.url);
|
||||
|
@ -1,3 +1,4 @@
|
||||
export const environment = {
|
||||
gaId: 'UA-8594346-26', // TODO: this is for the staging site; reset to correct account
|
||||
production: true
|
||||
};
|
||||
|
@ -4,5 +4,6 @@
|
||||
// The list of which env maps to which file can be found in `angular-cli.json`.
|
||||
|
||||
export const environment = {
|
||||
gaId: 'UA-8594346-26', // Staging site
|
||||
production: false
|
||||
};
|
||||
|
@ -22,6 +22,16 @@
|
||||
|
||||
<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>
|
||||
|
||||
<body>
|
||||
|
Loading…
x
Reference in New Issue
Block a user