Commit 888e0f1f authored by Ryan Diehl's avatar Ryan Diehl

Merge branch 'feature/analytics' into 'develop'

feat(analytics): ported from @psu/analytics

See merge request !29
parents 24d7e8c2 66ac6738
Pipeline #101514 passed with stages
in 5 minutes and 27 seconds
...@@ -448,6 +448,34 @@ ...@@ -448,6 +448,34 @@
"styleext": "scss" "styleext": "scss"
} }
} }
},
"utils-analytics": {
"projectType": "library",
"root": "libs/utils/analytics",
"sourceRoot": "libs/utils/analytics/src",
"prefix": "ut",
"architect": {
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": ["libs/utils/analytics/tsconfig.lib.json", "libs/utils/analytics/tsconfig.spec.json"],
"exclude": ["**/node_modules/**", "!libs/utils/analytics/**"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "libs/utils/analytics/jest.config.js",
"tsConfig": "libs/utils/analytics/tsconfig.spec.json",
"setupFile": "libs/utils/analytics/src/test-setup.ts"
}
}
},
"schematics": {
"@nrwl/angular:component": {
"styleext": "scss"
}
}
} }
}, },
"cli": { "cli": {
......
# @psu/utils/analytics
Provides integration with Google Analytics
## Setup
1. Setup Google Tag Manager and generate a container ID for your app (usually non-prod + prod)
1. Provide `ANALYTICS_CONFIG` injection token and initialize it in your app initializer
1. Inject `GoogleTagManagerService` into your `AppComponent`
## ErrorInterceptor
There is an optional `ErrorInterceptor` HTTP interceptor that can log any type of network errors to Google Analytics.
There is a custom report/dashboard that you can then view to see a summary of errors.
This has been helpful to debug specific customer problems - CORS errors, anti-virus software
that disabled PUT requests, etc.
## Custom Events
`AnalyticsService` can record page views and custom analytics events.
module.exports = {
name: 'utils-analytics',
preset: '../../../jest.config.js',
coverageDirectory: '../../../coverage/libs/utils/analytics',
snapshotSerializers: [
'jest-preset-angular/AngularSnapshotSerializer.js',
'jest-preset-angular/HTMLCommentSerializer.js'
]
};
{
"lib": {
"entryFile": "src/index.ts"
}
}
export * from './lib/analytics.model';
export { AnalyticsService } from './lib/analytics.service';
export { GoogleAnalyticsService } from './lib/google-analytics.service';
export * from './lib/google-tag-manager/google-tag-manager.service';
export { ErrorStatusInterceptor } from './lib/interceptor/error.interceptor';
import { InjectionToken } from '@angular/core';
export interface AnalyticsSource {
pageView(url: string): void;
recordEvent(category: string, label: string, action: string, value: any): void;
}
export interface AnalyticsConfig {
trackingId?: string;
tagManagerContainerId?: string;
}
export const ANALYTICS_CONFIG = new InjectionToken<AnalyticsConfig>('psu.utils.analytics.config');
import { Injectable } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter, tap } from 'rxjs/operators';
import { GoogleAnalyticsService } from './google-analytics.service';
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
constructor(private router: Router, private google: GoogleAnalyticsService) {
this.router.events
.pipe(
filter(event => event instanceof NavigationEnd),
tap(event => this.google.pageView((event as NavigationEnd).urlAfterRedirects))
)
.subscribe(event => {});
}
public recordEvent(action: string, category?: string, label?: string): void {
this.google.recordEvent(action, category, label);
}
public recordError(exceptionMsg: string): void {
this.google.recordException(exceptionMsg);
}
}
import { isPlatformBrowser } from '@angular/common';
import { Inject, Injectable, PLATFORM_ID } from '@angular/core';
import { AnalyticsConfig, AnalyticsSource, ANALYTICS_CONFIG } from './analytics.model';
declare global {
interface Window {
dataLayer: unknown[];
gtag: Function;
}
}
@Injectable({ providedIn: 'root' })
export class GoogleAnalyticsService implements AnalyticsSource {
private gtag: Function;
private scriptLoaded = false;
constructor(
@Inject(PLATFORM_ID) private platform: Object,
@Inject(ANALYTICS_CONFIG) private config: AnalyticsConfig
) {
this.gtag = this.loadGoogleAnalytics();
this.gtag('config', this.config.trackingId, {
send_page_view: false
});
}
private getReady(): void {
if (!this.scriptLoaded) {
this.scriptLoaded = true;
if (typeof window !== 'undefined') {
window.dataLayer = window.dataLayer || [];
window.gtag = function(): void {
window.dataLayer.push(arguments);
};
window.gtag('js', new Date());
}
const doc = <HTMLDivElement>document.body;
const script = document.createElement('script');
script.innerHTML = '';
script.src = `https://www.googletagmanager.com/gtag/js?id=${this.config.trackingId}`;
script.async = true;
script.defer = true;
doc.appendChild(script);
}
}
public pageView(url: string): void {
this.gtag('config', this.config.trackingId, {
page_path: url
});
}
public recordEvent(action: string, category?: string, label?: string): void {
this.gtag('event', action, {
event_category: category,
event_label: label
});
}
public recordException(exceptionMsg: string): void {
this.gtag('event', 'exception', {
method: 'Angular',
event_category: 'exception',
event_label: 'Network Error in From User',
description: exceptionMsg
});
}
private loadGoogleAnalytics(): Function {
let ret: Function;
if (isPlatformBrowser(this.platform)) {
this.getReady();
ret = (<any>window).gtag;
if (!ret) {
console.warn('Unable to find Google Analytics function (window.gtag). Returning empty function.');
ret = () => {};
}
} else {
ret = () => {};
}
return ret;
}
}
import { isPlatformBrowser } from '@angular/common';
import { Inject, Injectable, PLATFORM_ID } from '@angular/core';
import { Logger } from '@psu/utils/logger';
import { AnalyticsConfig, ANALYTICS_CONFIG } from '../analytics.model';
@Injectable({ providedIn: 'root' })
export class GoogleTagManagerService {
private scriptLoaded = false;
constructor(
@Inject(PLATFORM_ID) private platform: Object,
@Inject(ANALYTICS_CONFIG) private config: AnalyticsConfig,
private logger: Logger
) {
this.loadGoogleTagManager();
}
private loadGoogleTagManager(): void {
if (isPlatformBrowser(this.platform)) {
if (!this.scriptLoaded) {
if (this.config.tagManagerContainerId) {
this.scriptLoaded = true;
try {
const head = document.head;
const script = document.createElement('script');
script.innerHTML = `(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','${this.config.tagManagerContainerId}');`;
head.appendChild(script);
const doc = document.body;
const noscript = document.createElement('noscript');
noscript.innerHTML = `<iframe src="https://www.googletagmanager.com/ns.html?id=${this.config.tagManagerContainerId}"height="0" width="0" style="display:none;visibility:hidden"></iframe>`;
doc.appendChild(script);
} catch (err) {
this.scriptLoaded = false;
this.logger.error(`Error loading GoogleTagManager scripts: ${err}`);
}
} else {
this.logger.error(
`You have injected the GoogleTagManagerService but it did not find tagManagerContainerId in
the ANALYTICS_CONFIG InjectionToken. Possible race condition?`
);
}
}
}
}
}
import { HttpClient, HttpHeaders, HTTP_INTERCEPTORS } from '@angular/common/http';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Mock } from 'ts-mocks';
import { AnalyticsConfig, ANALYTICS_CONFIG } from '../..';
import { AnalyticsService } from '../analytics.service';
import { ErrorStatusInterceptor } from './error.interceptor';
describe('ErrorStatusInterceptor', () => {
let gtag: Mock<AnalyticsService>;
let mockRouter: Mock<Router>;
const config: AnalyticsConfig = {};
const end: NavigationEnd = {
urlAfterRedirects: '',
id: 1,
url: ''
};
let backend: HttpTestingController;
let http: HttpClient;
beforeEach(() => {
gtag = new Mock<AnalyticsService>({
recordError: () => {}
});
mockRouter = new Mock<Router>({
events: of(end)
});
TestBed.configureTestingModule({
imports: [RouterTestingModule, HttpClientTestingModule],
providers: [
{ provide: Router, useValue: mockRouter.Object },
{ provide: AnalyticsService, useValue: gtag.Object },
{ provide: ANALYTICS_CONFIG, useFactory: () => config },
{
provide: HTTP_INTERCEPTORS,
useClass: ErrorStatusInterceptor,
multi: true
}
]
});
backend = TestBed.get(HttpTestingController);
http = TestBed.get(HttpClient);
});
afterEach(() => {
backend.verify();
});
it('should create', () => {
expect(TestBed.get(HTTP_INTERCEPTORS)[0]).toBeDefined();
});
describe('intercepting http calls', () => {
function makeRequest(method = 'GET', url = '/data', headers?: HttpHeaders) {
http
.request(method, url, { headers })
.pipe(catchError(err => of(null)))
.subscribe();
}
describe('when request has no headers', () => {
beforeEach(() => {
makeRequest();
});
describe('when http response is error', () => {
beforeEach(() => {
backend.expectOne('/data').flush({ errorMessage: 'Uh oh!' }, { status: 500, statusText: 'Server Error' });
});
it('should call recordError with no request ID', () => {
expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/^((?!xRequestId).)*$/));
expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/^HTTP 500: Server Error/));
expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/requestMethod: GET/));
expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/fullRequestUrl: \/data/));
expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/Uh oh!/));
});
});
describe('when http error response has request ID header', () => {
beforeEach(() => {
backend
.expectOne('/data')
.flush({}, { status: 500, statusText: 'No', headers: { 'X-Request-Id': 'server-request' } });
});
it('should record event with request id from server', () => {
expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/xRequestId: server-request/));
});
});
});
describe('when request has requestid header', () => {
beforeEach(() => {
makeRequest('PUT', '/other/data?id=1', new HttpHeaders().append('X-Request-Id', 'client-request'));
});
it('should record event with request id from client', () => {
backend
.expectOne('/other/data?id=1')
.flush({ errorMessage: 'Uh oh!' }, { status: 404, statusText: 'Not Found' });
expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/xRequestId: client-request/));
expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/requestMethod: PUT/));
expect(gtag.Object.recordError).toHaveBeenCalledWith(
jasmine.stringMatching(/fullRequestUrl: \/other\/data\?id=1/)
);
});
it('should log client errors differently', () => {
backend.expectOne('/other/data?id=1').error(new ErrorEvent('CORS error', { message: 'not allowed' }));
expect(gtag.Object.recordError).toHaveBeenCalledWith(jasmine.stringMatching(/xRequestId: client-request/));
expect(gtag.Object.recordError).toHaveBeenCalledWith(
jasmine.stringMatching(/Client\/Network Error: CORS error -> not allowed/)
);
});
});
});
});
import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AnalyticsService } from '../analytics.service';
const X_REQUEST_ID = 'x-request-id';
@Injectable({ providedIn: 'root' })
export class ErrorStatusInterceptor implements HttpInterceptor {
constructor(private aService: AnalyticsService) {}
public intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(request).pipe(
catchError((error: HttpErrorResponse) => {
let errorMsg = 'not provided';
// capture the request ID either from the error response or the original request
let xRequestId = '';
if (error.headers.has(X_REQUEST_ID)) {
xRequestId = error.headers.get(X_REQUEST_ID);
} else if (request.headers.has(X_REQUEST_ID)) {
xRequestId = request.headers.get(X_REQUEST_ID);
}
if (error.error && error.error.errorMessage) {
errorMsg = error.error.errorMessage;
}
// there are 2 possible types of errors: client side and server side HTTP errors
// CORS errors fall into client side errors, they will have ErrorCode 0 in chrome and you will not see the real response code.
if (error.error instanceof ErrorEvent) {
// client error
errorMsg = `Client/Network Error: ${error.error.type} -> ${error.error.message}`;
} else {
// server error, pull out HTTP response status and message
errorMsg = `HTTP ${error.status}: ${error.statusText}, message: [${errorMsg}]`;
}
this.aService.recordError(
`${errorMsg} time: [${new Date().toISOString()}] from [${error.url}] requestMethod: ${
request.method
} fullRequestUrl: ${request.urlWithParams} ${!!xRequestId ? `xRequestId: ${xRequestId}` : ''}`
);
return throwError(error);
})
);
}
}
import 'jest-preset-angular';
import '../../../../jestGlobalMocks';
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"types": ["node", "jest"]
},
"include": ["**/*.ts"]
}
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"target": "es2015",
"declaration": true,
"inlineSources": true,
"types": [],
"lib": ["dom", "es2018"]
},
"angularCompilerOptions": {
"annotateForClosureCompiler": true,
"skipTemplateCodegen": true,
"strictMetadataEmit": true,
"fullTemplateTypeCheck": true,
"strictInjectionParameters": true,
"enableResourceInlining": true
},
"exclude": ["src/test-setup.ts", "**/*.spec.ts"]
}
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": ["**/*.spec.ts", "**/*.d.ts"]
}
{
"extends": "../../../tslint.json",
"rules": {
"directive-selector": [true, "attribute", "ut", "camelCase"],
"component-selector": [true, "element", "ut", "kebab-case"]
}
}
...@@ -52,6 +52,9 @@ ...@@ -52,6 +52,9 @@
}, },
"utils-form": { "utils-form": {
"tags": [] "tags": []
},
"utils-analytics": {
"tags": []
} }
} }
} }
...@@ -28,7 +28,8 @@ ...@@ -28,7 +28,8 @@
"@psu/utils/loading-events": ["libs/utils/loading-events/src/index.ts"], "@psu/utils/loading-events": ["libs/utils/loading-events/src/index.ts"],
"@psu/utils/ngrx": ["libs/utils/ngrx/src/index.ts"], "@psu/utils/ngrx": ["libs/utils/ngrx/src/index.ts"],
"@psu/utils/cdn": ["libs/utils/cdn/src/index.ts"], "@psu/utils/cdn": ["libs/utils/cdn/src/index.ts"],
"@psu/utils/form": ["libs/utils/form/src/index.ts"] "@psu/utils/form": ["libs/utils/form/src/index.ts"],
"@psu/utils/analytics": ["libs/utils/analytics/src/index.ts"]
} }
}, },
"exclude": ["node_modules", "tmp"] "exclude": ["node_modules", "tmp"]
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment