Skip to content
30 changes: 30 additions & 0 deletions projects/common/src/telemetry/telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ProviderToken } from '@angular/core';
import { Dictionary } from './../utilities/types/types';

export interface UserTelemetryRegistrationConfig<TInitConfig> {
telemetryProvider: ProviderToken<UserTelemetryProvider<TInitConfig>>;
initConfig: TInitConfig;
enablePageTracking: boolean;
enableEventTracking: boolean;
enableErrorTracking: boolean;
}

export interface UserTelemetryProvider<TInitConfig = unknown> {
initialize(config: TInitConfig): void;
identify(userTraits: UserTraits): void;
trackEvent?(name: string, eventData: Dictionary<unknown>): void;
trackPage?(url: string, eventData: Dictionary<unknown>): void;
trackError?(error: string, eventData: Dictionary<unknown>): void;
shutdown?(): void;
}

export interface TelemetryProviderConfig {
orgId: string;
}

export interface UserTraits extends Dictionary<unknown> {
email?: string;
companyName?: string;
name?: string;
displayName?: string;
}
221 changes: 221 additions & 0 deletions projects/common/src/telemetry/user-telemetry-helper.service.test.ts
Original file line number Diff line number Diff line change
@@ -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<TelemetryProviderConfig>;

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: '[email protected]' });
expect(telemetryProvider.identify).toHaveBeenCalledWith({ email: '[email protected]' });

// 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: '[email protected]' });
expect(telemetryProvider.identify).toHaveBeenCalledWith({ email: '[email protected]' });

// 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: '[email protected]' });
expect(telemetryProvider.identify).toHaveBeenCalledWith({ email: '[email protected]' });

// 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: '[email protected]' });
expect(telemetryProvider.identify).toHaveBeenCalledWith({ email: '[email protected]' });

// 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();
});
});
82 changes: 82 additions & 0 deletions projects/common/src/telemetry/user-telemetry-helper.service.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>[]): 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<unknown>): void {
this.telemetryProviders
.filter(provider => provider.enableEventTracking)
.forEach(provider => provider.telemetryProvider.trackEvent?.(name, data));
}

public trackPageEvent(url: string, data: Dictionary<unknown>): void {
this.telemetryProviders
.filter(provider => provider.enablePageTracking)
.forEach(provider => provider.telemetryProvider.trackPage?.(url, data));
}

public trackErrorEvent(error: string, data: Dictionary<unknown>): void {
this.telemetryProviders
.filter(provider => provider.enableErrorTracking)
.forEach(provider => provider.telemetryProvider.trackError?.(`Error: ${error}`, data));
}

private buildTelemetryProvider(config: UserTelemetryRegistrationConfig<unknown>): 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<InitConfig = unknown> {
telemetryProvider: UserTelemetryProvider<InitConfig>;
initConfig: InitConfig;
enablePageTracking: boolean;
enableEventTracking: boolean;
enableErrorTracking: boolean;
}
31 changes: 31 additions & 0 deletions projects/common/src/telemetry/user-telemetry.module.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>[][],
userTelemetryInternalService: UserTelemetryHelperService
) {
userTelemetryInternalService.register(...providerConfigs.flat());
}

public static forRoot(
providerConfigs: UserTelemetryRegistrationConfig<unknown>[]
): ModuleWithProviders<UserTelemetryModule> {
return {
ngModule: UserTelemetryModule,
providers: [
{
provide: USER_TELEMETRY_PROVIDER_TOKENS,
useValue: providerConfigs
}
]
};
}
}

const USER_TELEMETRY_PROVIDER_TOKENS = new InjectionToken<UserTelemetryRegistrationConfig<unknown>[][]>(
'USER_TELEMETRY_PROVIDER_TOKENS'
);
28 changes: 28 additions & 0 deletions projects/common/src/telemetry/user-telemetry.service.test.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]' });
expect(helperService.initialize).toHaveBeenCalledWith();
expect(helperService.identify).toHaveBeenCalledWith({ email: '[email protected]' });

spectator.service.shutdown();
expect(helperService.shutdown).toHaveBeenCalledWith();
});
});
Loading