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';
|
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.
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -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 _;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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');
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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 },
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
|
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 { 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);
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user