diff --git a/projects/common/src/telemetry/telemetry.ts b/projects/common/src/telemetry/telemetry.ts new file mode 100644 index 000000000..536055c85 --- /dev/null +++ b/projects/common/src/telemetry/telemetry.ts @@ -0,0 +1,30 @@ +import { ProviderToken } from '@angular/core'; +import { Dictionary } from './../utilities/types/types'; + +export interface UserTelemetryRegistrationConfig { + telemetryProvider: ProviderToken>; + initConfig: TInitConfig; + enablePageTracking: boolean; + enableEventTracking: boolean; + enableErrorTracking: boolean; +} + +export interface UserTelemetryProvider { + initialize(config: TInitConfig): void; + identify(userTraits: UserTraits): void; + trackEvent?(name: string, eventData: Dictionary): void; + trackPage?(url: string, eventData: Dictionary): void; + trackError?(error: string, eventData: Dictionary): void; + shutdown?(): void; +} + +export interface TelemetryProviderConfig { + orgId: string; +} + +export interface UserTraits extends Dictionary { + email?: string; + companyName?: string; + name?: string; + displayName?: string; +} diff --git a/projects/common/src/telemetry/user-telemetry-helper.service.test.ts b/projects/common/src/telemetry/user-telemetry-helper.service.test.ts new file mode 100644 index 000000000..6530430df --- /dev/null +++ b/projects/common/src/telemetry/user-telemetry-helper.service.test.ts @@ -0,0 +1,221 @@ +import { InjectionToken } from '@angular/core'; +import { Router } from '@angular/router'; +import { createServiceFactory, mockProvider } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; +import { TelemetryProviderConfig, UserTelemetryProvider, UserTelemetryRegistrationConfig } from './telemetry'; +import { UserTelemetryHelperService } from './user-telemetry-helper.service'; + +describe('User Telemetry helper service', () => { + const injectionToken = new InjectionToken('test-token'); + let telemetryProvider: UserTelemetryProvider; + let registrationConfig: UserTelemetryRegistrationConfig; + + const createService = createServiceFactory({ + service: UserTelemetryHelperService, + providers: [ + mockProvider(Router, { + events: of({}) + }) + ] + }); + + test('should delegate to telemetry provider after registration', () => { + registrationConfig = { + telemetryProvider: injectionToken, + initConfig: { orgId: 'test-id' }, + enablePageTracking: true, + enableEventTracking: true, + enableErrorTracking: true + }; + + telemetryProvider = { + initialize: jest.fn(), + identify: jest.fn(), + trackEvent: jest.fn(), + trackPage: jest.fn(), + trackError: jest.fn(), + shutdown: jest.fn() + }; + + const spectator = createService({ + providers: [ + { + provide: injectionToken, + useValue: telemetryProvider + } + ] + }); + + spectator.service.register(registrationConfig); + + // Initialize + spectator.service.initialize(); + expect(telemetryProvider.initialize).toHaveBeenCalledWith({ orgId: 'test-id' }); + + // Identify + spectator.service.identify({ email: 'test@email.com' }); + expect(telemetryProvider.identify).toHaveBeenCalledWith({ email: 'test@email.com' }); + + // TrackEvent + spectator.service.trackEvent('eventA', { target: 'unknown' }); + expect(telemetryProvider.trackEvent).toHaveBeenCalledWith('eventA', { target: 'unknown' }); + + // TrackPage + spectator.service.trackPageEvent('/abs', { target: 'unknown' }); + expect(telemetryProvider.trackPage).toHaveBeenCalledWith('/abs', { target: 'unknown' }); + + // TrackError + spectator.service.trackErrorEvent('console error', { target: 'unknown' }); + expect(telemetryProvider.trackError).toHaveBeenCalledWith('Error: console error', { target: 'unknown' }); + }); + + test('should not capture events if event tracking is disabled', () => { + registrationConfig = { + telemetryProvider: injectionToken, + initConfig: { orgId: 'test-id' }, + enablePageTracking: true, + enableEventTracking: false, + enableErrorTracking: true + }; + + telemetryProvider = { + initialize: jest.fn(), + identify: jest.fn(), + trackEvent: jest.fn(), + trackPage: jest.fn(), + trackError: jest.fn(), + shutdown: jest.fn() + }; + + const spectator = createService({ + providers: [ + { + provide: injectionToken, + useValue: telemetryProvider + } + ] + }); + + spectator.service.register(registrationConfig); + + // Initialize + spectator.service.initialize(); + expect(telemetryProvider.initialize).toHaveBeenCalledWith({ orgId: 'test-id' }); + + // Identify + spectator.service.identify({ email: 'test@email.com' }); + expect(telemetryProvider.identify).toHaveBeenCalledWith({ email: 'test@email.com' }); + + // TrackEvent + spectator.service.trackEvent('eventA', { target: 'unknown' }); + expect(telemetryProvider.trackEvent).not.toHaveBeenCalled(); + + // TrackPage + spectator.service.trackPageEvent('/abs', { target: 'unknown' }); + expect(telemetryProvider.trackPage).toHaveBeenCalledWith('/abs', { target: 'unknown' }); + + // TrackError + spectator.service.trackErrorEvent('console error', { target: 'unknown' }); + expect(telemetryProvider.trackError).toHaveBeenCalledWith('Error: console error', { target: 'unknown' }); + }); + + test('should not capture page events if page event tracking is disabled', () => { + registrationConfig = { + telemetryProvider: injectionToken, + initConfig: { orgId: 'test-id' }, + enablePageTracking: false, + enableEventTracking: true, + enableErrorTracking: true + }; + + telemetryProvider = { + initialize: jest.fn(), + identify: jest.fn(), + trackEvent: jest.fn(), + trackPage: jest.fn(), + trackError: jest.fn(), + shutdown: jest.fn() + }; + + const spectator = createService({ + providers: [ + { + provide: injectionToken, + useValue: telemetryProvider + } + ] + }); + + spectator.service.register(registrationConfig); + + // Initialize + spectator.service.initialize(); + expect(telemetryProvider.initialize).toHaveBeenCalledWith({ orgId: 'test-id' }); + + // Identify + spectator.service.identify({ email: 'test@email.com' }); + expect(telemetryProvider.identify).toHaveBeenCalledWith({ email: 'test@email.com' }); + + // TrackEvent + spectator.service.trackEvent('eventA', { target: 'unknown' }); + expect(telemetryProvider.trackEvent).toHaveBeenCalledWith('eventA', { target: 'unknown' }); + + // TrackPage + spectator.service.trackPageEvent('/abs', { target: 'unknown' }); + expect(telemetryProvider.trackPage).not.toHaveBeenCalled(); + + // TrackError + spectator.service.trackErrorEvent('console error', { target: 'unknown' }); + expect(telemetryProvider.trackError).toHaveBeenCalledWith('Error: console error', { target: 'unknown' }); + }); + + test('should not capture error events if eror event tracking is disabled', () => { + registrationConfig = { + telemetryProvider: injectionToken, + initConfig: { orgId: 'test-id' }, + enablePageTracking: true, + enableEventTracking: true, + enableErrorTracking: false + }; + + telemetryProvider = { + initialize: jest.fn(), + identify: jest.fn(), + trackEvent: jest.fn(), + trackPage: jest.fn(), + trackError: jest.fn(), + shutdown: jest.fn() + }; + + const spectator = createService({ + providers: [ + { + provide: injectionToken, + useValue: telemetryProvider + } + ] + }); + + spectator.service.register(registrationConfig); + + // Initialize + spectator.service.initialize(); + expect(telemetryProvider.initialize).toHaveBeenCalledWith({ orgId: 'test-id' }); + + // Identify + spectator.service.identify({ email: 'test@email.com' }); + expect(telemetryProvider.identify).toHaveBeenCalledWith({ email: 'test@email.com' }); + + // TrackEvent + spectator.service.trackEvent('eventA', { target: 'unknown' }); + expect(telemetryProvider.trackEvent).toHaveBeenCalledWith('eventA', { target: 'unknown' }); + + // TrackPage + spectator.service.trackPageEvent('/abs', { target: 'unknown' }); + expect(telemetryProvider.trackPage).toHaveBeenCalledWith('/abs', { target: 'unknown' }); + + // TrackError + spectator.service.trackPageEvent('console error', { target: 'unknown' }); + expect(telemetryProvider.trackError).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/common/src/telemetry/user-telemetry-helper.service.ts b/projects/common/src/telemetry/user-telemetry-helper.service.ts new file mode 100644 index 000000000..6c0d6fd12 --- /dev/null +++ b/projects/common/src/telemetry/user-telemetry-helper.service.ts @@ -0,0 +1,82 @@ +import { Injectable, Injector } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs/operators'; +import { Dictionary } from '../utilities/types/types'; +import { UserTelemetryProvider, UserTelemetryRegistrationConfig, UserTraits } from './telemetry'; + +@Injectable({ providedIn: 'root' }) +export class UserTelemetryHelperService { + private telemetryProviders: UserTelemetryInternalConfig[] = []; + + public constructor(private readonly injector: Injector, private readonly router: Router) { + this.setupAutomaticPageTracking(); + } + + public register(...configs: UserTelemetryRegistrationConfig[]): void { + try { + const providers = configs.map(config => this.buildTelemetryProvider(config)); + this.telemetryProviders = [...this.telemetryProviders, ...providers]; + } catch (error) { + /** + * Fail silently + */ + + // tslint:disable-next-line: no-console + console.error(error); + } + } + + public initialize(): void { + this.telemetryProviders.forEach(provider => provider.telemetryProvider.initialize(provider.initConfig)); + } + + public identify(userTraits: UserTraits): void { + this.telemetryProviders.forEach(provider => provider.telemetryProvider.identify(userTraits)); + } + + public shutdown(): void { + this.telemetryProviders.forEach(provider => provider.telemetryProvider.shutdown?.()); + } + + public trackEvent(name: string, data: Dictionary): void { + this.telemetryProviders + .filter(provider => provider.enableEventTracking) + .forEach(provider => provider.telemetryProvider.trackEvent?.(name, data)); + } + + public trackPageEvent(url: string, data: Dictionary): void { + this.telemetryProviders + .filter(provider => provider.enablePageTracking) + .forEach(provider => provider.telemetryProvider.trackPage?.(url, data)); + } + + public trackErrorEvent(error: string, data: Dictionary): void { + this.telemetryProviders + .filter(provider => provider.enableErrorTracking) + .forEach(provider => provider.telemetryProvider.trackError?.(`Error: ${error}`, data)); + } + + private buildTelemetryProvider(config: UserTelemetryRegistrationConfig): UserTelemetryInternalConfig { + const providerInstance = this.injector.get(config.telemetryProvider); + providerInstance.initialize(config.initConfig); + + return { + ...config, + telemetryProvider: providerInstance + }; + } + + private setupAutomaticPageTracking(): void { + this.router.events + .pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd)) + .subscribe(route => this.trackPageEvent(`Visited: ${route.url}`, { url: route.url })); + } +} + +interface UserTelemetryInternalConfig { + telemetryProvider: UserTelemetryProvider; + initConfig: InitConfig; + enablePageTracking: boolean; + enableEventTracking: boolean; + enableErrorTracking: boolean; +} diff --git a/projects/common/src/telemetry/user-telemetry.module.ts b/projects/common/src/telemetry/user-telemetry.module.ts new file mode 100644 index 000000000..f35d2094a --- /dev/null +++ b/projects/common/src/telemetry/user-telemetry.module.ts @@ -0,0 +1,31 @@ +import { Inject, ModuleWithProviders, NgModule, InjectionToken } from '@angular/core'; +import { UserTelemetryRegistrationConfig } from './telemetry'; +import { UserTelemetryHelperService } from './user-telemetry-helper.service'; + +@NgModule() +export class UserTelemetryModule { + public constructor( + @Inject(USER_TELEMETRY_PROVIDER_TOKENS) providerConfigs: UserTelemetryRegistrationConfig[][], + userTelemetryInternalService: UserTelemetryHelperService + ) { + userTelemetryInternalService.register(...providerConfigs.flat()); + } + + public static forRoot( + providerConfigs: UserTelemetryRegistrationConfig[] + ): ModuleWithProviders { + return { + ngModule: UserTelemetryModule, + providers: [ + { + provide: USER_TELEMETRY_PROVIDER_TOKENS, + useValue: providerConfigs + } + ] + }; + } +} + +const USER_TELEMETRY_PROVIDER_TOKENS = new InjectionToken[][]>( + 'USER_TELEMETRY_PROVIDER_TOKENS' +); diff --git a/projects/common/src/telemetry/user-telemetry.service.test.ts b/projects/common/src/telemetry/user-telemetry.service.test.ts new file mode 100644 index 000000000..b6d891e2d --- /dev/null +++ b/projects/common/src/telemetry/user-telemetry.service.test.ts @@ -0,0 +1,28 @@ +import { createServiceFactory, mockProvider } from '@ngneat/spectator/jest'; +import { UserTelemetryHelperService } from './user-telemetry-helper.service'; +import { UserTelemetryService } from './user-telemetry.service'; + +describe('User Telemetry service', () => { + const createService = createServiceFactory({ + service: UserTelemetryService, + providers: [ + mockProvider(UserTelemetryHelperService, { + initialize: jest.fn(), + identify: jest.fn(), + shutdown: jest.fn() + }) + ] + }); + + test('should delegate to helper service', () => { + const spectator = createService(); + const helperService = spectator.inject(UserTelemetryHelperService); + + spectator.service.initialize({ email: 'test@email.com' }); + expect(helperService.initialize).toHaveBeenCalledWith(); + expect(helperService.identify).toHaveBeenCalledWith({ email: 'test@email.com' }); + + spectator.service.shutdown(); + expect(helperService.shutdown).toHaveBeenCalledWith(); + }); +}); diff --git a/projects/common/src/telemetry/user-telemetry.service.ts b/projects/common/src/telemetry/user-telemetry.service.ts new file mode 100644 index 000000000..d5adf7aac --- /dev/null +++ b/projects/common/src/telemetry/user-telemetry.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { UserTraits } from './telemetry'; +import { UserTelemetryHelperService } from './user-telemetry-helper.service'; + +@Injectable({ providedIn: 'root' }) +export class UserTelemetryService { + public constructor(private readonly userTelemetryHelperService: UserTelemetryHelperService) {} + + public initialize(userTraits: UserTraits): void { + this.userTelemetryHelperService.initialize(); + this.userTelemetryHelperService.identify(userTraits); + } + + public shutdown(): void { + this.userTelemetryHelperService.shutdown(); + } +}