diff --git a/angular.json b/angular.json index 0348f3534bccf87ab13ea65efaea6cde6b3cf21d..d86e40d3dd4757152f8f514e5e16820d815ef55a 100644 --- a/angular.json +++ b/angular.json @@ -448,6 +448,34 @@ "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": { diff --git a/libs/utils/analytics/README.md b/libs/utils/analytics/README.md new file mode 100644 index 0000000000000000000000000000000000000000..2193b54f65b471eb43c282d73ddbd783d48933ec --- /dev/null +++ b/libs/utils/analytics/README.md @@ -0,0 +1,20 @@ +# @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. diff --git a/libs/utils/analytics/jest.config.js b/libs/utils/analytics/jest.config.js new file mode 100644 index 0000000000000000000000000000000000000000..8db9150d4d9d62f3022325fe2ac5647a3bf07d63 --- /dev/null +++ b/libs/utils/analytics/jest.config.js @@ -0,0 +1,9 @@ +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' + ] +}; diff --git a/libs/utils/analytics/ng-package.json b/libs/utils/analytics/ng-package.json new file mode 100644 index 0000000000000000000000000000000000000000..c781f0df4675ef304c7d745c98df9f59daa25dee --- /dev/null +++ b/libs/utils/analytics/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/utils/analytics/src/index.ts b/libs/utils/analytics/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..18c1e7b02669581d9bc9c37a6c1b501cac11bfa6 --- /dev/null +++ b/libs/utils/analytics/src/index.ts @@ -0,0 +1,5 @@ +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'; diff --git a/libs/utils/analytics/src/lib/analytics.model.ts b/libs/utils/analytics/src/lib/analytics.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..48f48b4035dc3b65ff964a56d51cf84faf515acd --- /dev/null +++ b/libs/utils/analytics/src/lib/analytics.model.ts @@ -0,0 +1,13 @@ +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('psu.utils.analytics.config'); diff --git a/libs/utils/analytics/src/lib/analytics.service.ts b/libs/utils/analytics/src/lib/analytics.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..e188e7cc0966a898e952bf9e3e21e3222bd8c8ae --- /dev/null +++ b/libs/utils/analytics/src/lib/analytics.service.ts @@ -0,0 +1,24 @@ +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); + } +} diff --git a/libs/utils/analytics/src/lib/google-analytics.service.ts b/libs/utils/analytics/src/lib/google-analytics.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..946c1455060ab817057f2f865f80078b8d3155e7 --- /dev/null +++ b/libs/utils/analytics/src/lib/google-analytics.service.ts @@ -0,0 +1,85 @@ +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 = 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 = (window).gtag; + if (!ret) { + console.warn('Unable to find Google Analytics function (window.gtag). Returning empty function.'); + ret = () => {}; + } + } else { + ret = () => {}; + } + return ret; + } +} diff --git a/libs/utils/analytics/src/lib/google-tag-manager/google-tag-manager.service.ts b/libs/utils/analytics/src/lib/google-tag-manager/google-tag-manager.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5d873134342c95c29efdda8ee74f11702b3e256 --- /dev/null +++ b/libs/utils/analytics/src/lib/google-tag-manager/google-tag-manager.service.ts @@ -0,0 +1,51 @@ +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 = ``; + 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?` + ); + } + } + } + } +} diff --git a/libs/utils/analytics/src/lib/interceptor/error.interceptor.spec.ts b/libs/utils/analytics/src/lib/interceptor/error.interceptor.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a72350b4e0fc21464a7e96f67e7b9efc1801b8a7 --- /dev/null +++ b/libs/utils/analytics/src/lib/interceptor/error.interceptor.spec.ts @@ -0,0 +1,123 @@ +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; + let mockRouter: Mock; + const config: AnalyticsConfig = {}; + const end: NavigationEnd = { + urlAfterRedirects: '', + id: 1, + url: '' + }; + let backend: HttpTestingController; + let http: HttpClient; + + beforeEach(() => { + gtag = new Mock({ + recordError: () => {} + }); + mockRouter = new Mock({ + 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/) + ); + }); + }); + }); +}); diff --git a/libs/utils/analytics/src/lib/interceptor/error.interceptor.ts b/libs/utils/analytics/src/lib/interceptor/error.interceptor.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e4a97f59fb9c4d5d250f96515f24ffbc7e24730 --- /dev/null +++ b/libs/utils/analytics/src/lib/interceptor/error.interceptor.ts @@ -0,0 +1,49 @@ +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, next: HttpHandler): Observable> { + 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); + }) + ); + } +} diff --git a/libs/utils/analytics/src/test-setup.ts b/libs/utils/analytics/src/test-setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea6fbefa5107bf0742b20f740fe208d15657248b --- /dev/null +++ b/libs/utils/analytics/src/test-setup.ts @@ -0,0 +1,2 @@ +import 'jest-preset-angular'; +import '../../../../jestGlobalMocks'; diff --git a/libs/utils/analytics/tsconfig.json b/libs/utils/analytics/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..08c7db8c96729fc7e5d208344835a678c973f96c --- /dev/null +++ b/libs/utils/analytics/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": ["**/*.ts"] +} diff --git a/libs/utils/analytics/tsconfig.lib.json b/libs/utils/analytics/tsconfig.lib.json new file mode 100644 index 0000000000000000000000000000000000000000..1c600457d394acbbc693cc87f480a170a4b277cd --- /dev/null +++ b/libs/utils/analytics/tsconfig.lib.json @@ -0,0 +1,20 @@ +{ + "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"] +} diff --git a/libs/utils/analytics/tsconfig.spec.json b/libs/utils/analytics/tsconfig.spec.json new file mode 100644 index 0000000000000000000000000000000000000000..fd405a65ef42fc2a9dece7054ce3338c0195210b --- /dev/null +++ b/libs/utils/analytics/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/libs/utils/analytics/tslint.json b/libs/utils/analytics/tslint.json new file mode 100644 index 0000000000000000000000000000000000000000..5c1c5f0ecc3b54c086b81dbe1161948f212a4b49 --- /dev/null +++ b/libs/utils/analytics/tslint.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tslint.json", + "rules": { + "directive-selector": [true, "attribute", "ut", "camelCase"], + "component-selector": [true, "element", "ut", "kebab-case"] + } +} diff --git a/nx.json b/nx.json index 21c89845f907de45bddf5fcda9e3b845683d0b9c..49647bbf697df3c1e23b91ceae1d058655968c2f 100644 --- a/nx.json +++ b/nx.json @@ -52,6 +52,9 @@ }, "utils-form": { "tags": [] + }, + "utils-analytics": { + "tags": [] } } } diff --git a/tsconfig.json b/tsconfig.json index 6d485d6dec8c61fb81863590278b95e230b872bd..dcd31763e0313b3e072d9abb3c7dcf167e0e86e2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,7 +28,8 @@ "@psu/utils/loading-events": ["libs/utils/loading-events/src/index.ts"], "@psu/utils/ngrx": ["libs/utils/ngrx/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"]