Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
})
);
});
});
Original file line number Diff line number Diff line change
@@ -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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be registered as part of the telemetry module, right? or intentionally keeping it as a separate registration?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I need to provide it in the module. I missed it in this PR

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;
}
}
5 changes: 5 additions & 0 deletions projects/common/src/telemetry/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,8 @@ export interface UserTraits extends Dictionary<unknown> {
name?: string;
displayName?: string;
}

export const enum TrackUserEventsType {
Click = 'click',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason to limit to these two events?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes sense to keep it flexible (string), but I want to expand this list after I test their behavior, how many events they generate and any perf impact. From Product/Marketing standpoint, the 4 event types we discussed previously are important. Events like MouseOver, MouseOut are not useful.

ContextMenu = 'context-menu'
}
67 changes: 67 additions & 0 deletions projects/common/src/telemetry/track/track.directive.test.ts
Original file line number Diff line number Diff line change
@@ -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<TrackDirective>;

const createDirective = createDirectiveFactory<TrackDirective>({
directive: TrackDirective,
imports: [CommonModule],
providers: [
mockProvider(UserTelemetryImplService, {
trackEvent: jest.fn()
})
]
});

test('propagates events with default config', fakeAsync(() => {
spectator = createDirective(
`
<div class="content" [htTrack] [htTrackLabel]="label">Test Content</div>
`,
{
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(
`
<div class="content" [htTrack]="events" [htTrackLabel]="label">Test Content</div>
`,
{
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' })
);
}));
});
68 changes: 68 additions & 0 deletions projects/common/src/telemetry/track/track.directive.ts
Original file line number Diff line number Diff line change
@@ -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<this>): void {
if (changes.userEvents) {
this.setupListeners();
}

if (changes.label) {
this.trackedEventLabel = this.label ?? (this.host.nativeElement as HTMLElement)?.tagName;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tag name doesnt seem very useful, maybe we should require label? Could make that the main arg and the event name the addtl arg since that's easier to default.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, the autotrack based telemetry tools extract certain fields from the element. I am yet to figure out the relevant ones. After I do that, I will add those fields here.

Imo htTrack= "events" is more readable. You can always miss providing a directive input and angular won't complain. So let's keep the default logic. I will use a better field than just with a later PR.

}
}

public ngOnDestroy(): void {
this.clearListeners();
}

private setupListeners(): void {
this.clearListeners();
this.activeSubscriptions = new Subscription();

this.activeSubscriptions.add(
...this.userEvents?.map(userEvent =>
fromEvent<MouseEvent>(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
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ 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');
let telemetryProvider: UserTelemetryProvider;
let registrationConfig: UserTelemetryRegistrationConfig<TelemetryProviderConfig>;

const createService = createServiceFactory({
service: UserTelemetryHelperService,
service: UserTelemetryImplService,
providers: [
mockProvider(Router, {
events: of({})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
18 changes: 14 additions & 4 deletions projects/common/src/telemetry/user-telemetry.module.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>[][],
userTelemetryInternalService: UserTelemetryHelperService
userTelemetryImplService: UserTelemetryImplService
) {
userTelemetryInternalService.register(...providerConfigs.flat());
userTelemetryImplService.register(...providerConfigs.flat());
}

public static forRoot(
Expand All @@ -20,6 +22,14 @@ export class UserTelemetryModule {
{
provide: USER_TELEMETRY_PROVIDER_TOKENS,
useValue: providerConfigs
},
{
provide: UserTelemetryService,
useExisting: UserTelemetryImplService
},
{
provide: ErrorHandler,
useClass: TelemetryGlobalErrorHandler
}
]
};
Expand Down
28 changes: 0 additions & 28 deletions projects/common/src/telemetry/user-telemetry.service.test.ts

This file was deleted.

18 changes: 4 additions & 14 deletions projects/common/src/telemetry/user-telemetry.service.ts
Original file line number Diff line number Diff line change
@@ -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;
}