diff --git a/projects/common/src/telemetry/error-handler/telemetry-global-error-handler.test.ts b/projects/common/src/telemetry/error-handler/telemetry-global-error-handler.test.ts new file mode 100644 index 000000000..f0d60790f --- /dev/null +++ b/projects/common/src/telemetry/error-handler/telemetry-global-error-handler.test.ts @@ -0,0 +1,32 @@ +import { createServiceFactory, mockProvider } from '@ngneat/spectator/jest'; +import { UserTelemetryImplService } from '../user-telemetry-impl.service'; +import { TelemetryGlobalErrorHandler } from './telemetry-global-error-handler'; + +describe('Telemetry Global Error Handler ', () => { + const createService = createServiceFactory({ + service: TelemetryGlobalErrorHandler, + providers: [ + mockProvider(UserTelemetryImplService, { + trackErrorEvent: jest.fn() + }) + ] + }); + + test('should delegate to telemetry provider after registration', () => { + const spectator = createService(); + try { + spectator.service.handleError(new Error('Test error')); + } catch (_) { + // NoOP + } + + expect(spectator.inject(UserTelemetryImplService).trackErrorEvent).toHaveBeenCalledWith( + 'Test error', + expect.objectContaining({ + message: 'Test error', + name: 'Error', + isError: true + }) + ); + }); +}); diff --git a/projects/common/src/telemetry/error-handler/telemetry-global-error-handler.ts b/projects/common/src/telemetry/error-handler/telemetry-global-error-handler.ts new file mode 100644 index 000000000..b618beca2 --- /dev/null +++ b/projects/common/src/telemetry/error-handler/telemetry-global-error-handler.ts @@ -0,0 +1,26 @@ +import { LocationStrategy, PathLocationStrategy } from '@angular/common'; +import { ErrorHandler, Injectable, Injector } from '@angular/core'; +import { UserTelemetryImplService } from '../user-telemetry-impl.service'; + +@Injectable() +export class TelemetryGlobalErrorHandler implements ErrorHandler { + public constructor(private readonly injector: Injector) {} + + public handleError(error: Error): Error { + const telemetryService = this.injector.get(UserTelemetryImplService); + + const location = this.injector.get(LocationStrategy); + const message = error.message ?? error.toString(); + const url = location instanceof PathLocationStrategy ? location.path() : ''; + + telemetryService.trackErrorEvent(message, { + message: message, + url: url, + stack: error.stack, + name: error.name, + isError: true + }); + + throw error; + } +} diff --git a/projects/common/src/telemetry/telemetry.ts b/projects/common/src/telemetry/telemetry.ts index 536055c85..f355bdc6c 100644 --- a/projects/common/src/telemetry/telemetry.ts +++ b/projects/common/src/telemetry/telemetry.ts @@ -28,3 +28,8 @@ export interface UserTraits extends Dictionary { name?: string; displayName?: string; } + +export const enum TrackUserEventsType { + Click = 'click', + ContextMenu = 'context-menu' +} diff --git a/projects/common/src/telemetry/track/track.directive.test.ts b/projects/common/src/telemetry/track/track.directive.test.ts new file mode 100644 index 000000000..f3580a0ac --- /dev/null +++ b/projects/common/src/telemetry/track/track.directive.test.ts @@ -0,0 +1,67 @@ +import { CommonModule } from '@angular/common'; +import { fakeAsync } from '@angular/core/testing'; +import { createDirectiveFactory, mockProvider, SpectatorDirective } from '@ngneat/spectator/jest'; +import { UserTelemetryImplService } from '../user-telemetry-impl.service'; +import { TrackDirective } from './track.directive'; + +describe('Track directive', () => { + let spectator: SpectatorDirective; + + const createDirective = createDirectiveFactory({ + directive: TrackDirective, + imports: [CommonModule], + providers: [ + mockProvider(UserTelemetryImplService, { + trackEvent: jest.fn() + }) + ] + }); + + test('propagates events with default config', fakeAsync(() => { + spectator = createDirective( + ` +
Test Content
+ `, + { + hostProps: { + events: ['click'], + label: 'Content' + } + } + ); + + const telemetryService = spectator.inject(UserTelemetryImplService); + + spectator.click(spectator.element); + spectator.tick(); + + expect(telemetryService.trackEvent).toHaveBeenCalledWith( + 'click: Content', + expect.objectContaining({ type: 'click' }) + ); + })); + + test('propagates events with custom config', fakeAsync(() => { + spectator = createDirective( + ` +
Test Content
+ `, + { + hostProps: { + events: ['mouseover'], + label: 'Content' + } + } + ); + + const telemetryService = spectator.inject(UserTelemetryImplService); + + spectator.dispatchMouseEvent(spectator.element, 'mouseover'); + spectator.tick(); + + expect(telemetryService.trackEvent).toHaveBeenCalledWith( + 'mouseover: Content', + expect.objectContaining({ type: 'mouseover' }) + ); + })); +}); diff --git a/projects/common/src/telemetry/track/track.directive.ts b/projects/common/src/telemetry/track/track.directive.ts new file mode 100644 index 000000000..561170699 --- /dev/null +++ b/projects/common/src/telemetry/track/track.directive.ts @@ -0,0 +1,68 @@ +import { Directive, ElementRef, Input, OnChanges, OnDestroy, OnInit } from '@angular/core'; +import { fromEvent, Subscription } from 'rxjs'; +import { TypedSimpleChanges } from '../../utilities/types/angular-change-object'; +import { TrackUserEventsType } from '../telemetry'; +import { UserTelemetryImplService } from '../user-telemetry-impl.service'; + +@Directive({ + selector: '[htTrack]' +}) +export class TrackDirective implements OnInit, OnChanges, OnDestroy { + @Input('htTrack') + public userEvents: string[] = [TrackUserEventsType.Click]; + + @Input('htTrackLabel') + public label?: string; + + private activeSubscriptions: Subscription = new Subscription(); + private trackedEventLabel: string = ''; + + public constructor( + private readonly host: ElementRef, + private readonly userTelemetryImplService: UserTelemetryImplService + ) {} + + public ngOnInit(): void { + this.setupListeners(); + } + + public ngOnChanges(changes: TypedSimpleChanges): void { + if (changes.userEvents) { + this.setupListeners(); + } + + if (changes.label) { + this.trackedEventLabel = this.label ?? (this.host.nativeElement as HTMLElement)?.tagName; + } + } + + public ngOnDestroy(): void { + this.clearListeners(); + } + + private setupListeners(): void { + this.clearListeners(); + this.activeSubscriptions = new Subscription(); + + this.activeSubscriptions.add( + ...this.userEvents?.map(userEvent => + fromEvent(this.host.nativeElement, userEvent).subscribe(eventObj => + this.trackUserEvent(userEvent, eventObj) + ) + ) + ); + } + + private clearListeners(): void { + this.activeSubscriptions.unsubscribe(); + } + + private trackUserEvent(userEvent: string, eventObj: MouseEvent): void { + const targetElement = eventObj.target as HTMLElement; + this.userTelemetryImplService.trackEvent(`${userEvent}: ${this.trackedEventLabel}`, { + tagName: targetElement.tagName, + className: targetElement.className, + type: userEvent + }); + } +} diff --git a/projects/common/src/telemetry/user-telemetry-helper.service.test.ts b/projects/common/src/telemetry/user-telemetry-impl.service.test.ts similarity index 98% rename from projects/common/src/telemetry/user-telemetry-helper.service.test.ts rename to projects/common/src/telemetry/user-telemetry-impl.service.test.ts index 6530430df..9f1583ef5 100644 --- a/projects/common/src/telemetry/user-telemetry-helper.service.test.ts +++ b/projects/common/src/telemetry/user-telemetry-impl.service.test.ts @@ -3,7 +3,7 @@ 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'; +import { UserTelemetryImplService } from './user-telemetry-impl.service'; describe('User Telemetry helper service', () => { const injectionToken = new InjectionToken('test-token'); @@ -11,7 +11,7 @@ describe('User Telemetry helper service', () => { let registrationConfig: UserTelemetryRegistrationConfig; const createService = createServiceFactory({ - service: UserTelemetryHelperService, + service: UserTelemetryImplService, providers: [ mockProvider(Router, { events: of({}) diff --git a/projects/common/src/telemetry/user-telemetry-helper.service.ts b/projects/common/src/telemetry/user-telemetry-impl.service.ts similarity index 95% rename from projects/common/src/telemetry/user-telemetry-helper.service.ts rename to projects/common/src/telemetry/user-telemetry-impl.service.ts index 6c0d6fd12..83eb269e4 100644 --- a/projects/common/src/telemetry/user-telemetry-helper.service.ts +++ b/projects/common/src/telemetry/user-telemetry-impl.service.ts @@ -3,12 +3,14 @@ import { NavigationEnd, Router } from '@angular/router'; import { filter } from 'rxjs/operators'; import { Dictionary } from '../utilities/types/types'; import { UserTelemetryProvider, UserTelemetryRegistrationConfig, UserTraits } from './telemetry'; +import { UserTelemetryService } from './user-telemetry.service'; @Injectable({ providedIn: 'root' }) -export class UserTelemetryHelperService { +export class UserTelemetryImplService extends UserTelemetryService { private telemetryProviders: UserTelemetryInternalConfig[] = []; public constructor(private readonly injector: Injector, private readonly router: Router) { + super(); this.setupAutomaticPageTracking(); } diff --git a/projects/common/src/telemetry/user-telemetry.module.ts b/projects/common/src/telemetry/user-telemetry.module.ts index f35d2094a..30aa00ab6 100644 --- a/projects/common/src/telemetry/user-telemetry.module.ts +++ b/projects/common/src/telemetry/user-telemetry.module.ts @@ -1,14 +1,16 @@ -import { Inject, ModuleWithProviders, NgModule, InjectionToken } from '@angular/core'; +import { ErrorHandler, Inject, InjectionToken, ModuleWithProviders, NgModule } from '@angular/core'; +import { TelemetryGlobalErrorHandler } from './error-handler/telemetry-global-error-handler'; import { UserTelemetryRegistrationConfig } from './telemetry'; -import { UserTelemetryHelperService } from './user-telemetry-helper.service'; +import { UserTelemetryImplService } from './user-telemetry-impl.service'; +import { UserTelemetryService } from './user-telemetry.service'; @NgModule() export class UserTelemetryModule { public constructor( @Inject(USER_TELEMETRY_PROVIDER_TOKENS) providerConfigs: UserTelemetryRegistrationConfig[][], - userTelemetryInternalService: UserTelemetryHelperService + userTelemetryImplService: UserTelemetryImplService ) { - userTelemetryInternalService.register(...providerConfigs.flat()); + userTelemetryImplService.register(...providerConfigs.flat()); } public static forRoot( @@ -20,6 +22,14 @@ export class UserTelemetryModule { { provide: USER_TELEMETRY_PROVIDER_TOKENS, useValue: providerConfigs + }, + { + provide: UserTelemetryService, + useExisting: UserTelemetryImplService + }, + { + provide: ErrorHandler, + useClass: TelemetryGlobalErrorHandler } ] }; diff --git a/projects/common/src/telemetry/user-telemetry.service.test.ts b/projects/common/src/telemetry/user-telemetry.service.test.ts deleted file mode 100644 index b6d891e2d..000000000 --- a/projects/common/src/telemetry/user-telemetry.service.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 index d5adf7aac..caec9ed2f 100644 --- a/projects/common/src/telemetry/user-telemetry.service.ts +++ b/projects/common/src/telemetry/user-telemetry.service.ts @@ -1,17 +1,7 @@ -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(); - } +export abstract class UserTelemetryService { + public abstract initialize(): void; + public abstract identify(userTraits: UserTraits): void; + public abstract shutdown(): void; }