Skip to content

Commit 6ed8f86

Browse files
authored
feat: adding error handler and track directive (#1127)
* feat: adding user telemetry config and service * feat: adding error handler and a track directive * refactor: using abstract service approach * refactor: updating api for htTrack directive * revert: package-lock.json * revert: revert package.json * revert: revert package.json peer deps * refactor: adding tests and addressing commments * refactor: fix lint errors * refactor: adding only few properties * refactor: addressing review comments
1 parent be9edf4 commit 6ed8f86

10 files changed

+221
-49
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { createServiceFactory, mockProvider } from '@ngneat/spectator/jest';
2+
import { UserTelemetryImplService } from '../user-telemetry-impl.service';
3+
import { TelemetryGlobalErrorHandler } from './telemetry-global-error-handler';
4+
5+
describe('Telemetry Global Error Handler ', () => {
6+
const createService = createServiceFactory({
7+
service: TelemetryGlobalErrorHandler,
8+
providers: [
9+
mockProvider(UserTelemetryImplService, {
10+
trackErrorEvent: jest.fn()
11+
})
12+
]
13+
});
14+
15+
test('should delegate to telemetry provider after registration', () => {
16+
const spectator = createService();
17+
try {
18+
spectator.service.handleError(new Error('Test error'));
19+
} catch (_) {
20+
// NoOP
21+
}
22+
23+
expect(spectator.inject(UserTelemetryImplService).trackErrorEvent).toHaveBeenCalledWith(
24+
'Test error',
25+
expect.objectContaining({
26+
message: 'Test error',
27+
name: 'Error',
28+
isError: true
29+
})
30+
);
31+
});
32+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { LocationStrategy, PathLocationStrategy } from '@angular/common';
2+
import { ErrorHandler, Injectable, Injector } from '@angular/core';
3+
import { UserTelemetryImplService } from '../user-telemetry-impl.service';
4+
5+
@Injectable()
6+
export class TelemetryGlobalErrorHandler implements ErrorHandler {
7+
public constructor(private readonly injector: Injector) {}
8+
9+
public handleError(error: Error): Error {
10+
const telemetryService = this.injector.get(UserTelemetryImplService);
11+
12+
const location = this.injector.get(LocationStrategy);
13+
const message = error.message ?? error.toString();
14+
const url = location instanceof PathLocationStrategy ? location.path() : '';
15+
16+
telemetryService.trackErrorEvent(message, {
17+
message: message,
18+
url: url,
19+
stack: error.stack,
20+
name: error.name,
21+
isError: true
22+
});
23+
24+
throw error;
25+
}
26+
}

projects/common/src/telemetry/telemetry.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,8 @@ export interface UserTraits extends Dictionary<unknown> {
2828
name?: string;
2929
displayName?: string;
3030
}
31+
32+
export const enum TrackUserEventsType {
33+
Click = 'click',
34+
ContextMenu = 'context-menu'
35+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { CommonModule } from '@angular/common';
2+
import { fakeAsync } from '@angular/core/testing';
3+
import { createDirectiveFactory, mockProvider, SpectatorDirective } from '@ngneat/spectator/jest';
4+
import { UserTelemetryImplService } from '../user-telemetry-impl.service';
5+
import { TrackDirective } from './track.directive';
6+
7+
describe('Track directive', () => {
8+
let spectator: SpectatorDirective<TrackDirective>;
9+
10+
const createDirective = createDirectiveFactory<TrackDirective>({
11+
directive: TrackDirective,
12+
imports: [CommonModule],
13+
providers: [
14+
mockProvider(UserTelemetryImplService, {
15+
trackEvent: jest.fn()
16+
})
17+
]
18+
});
19+
20+
test('propagates events with default config', fakeAsync(() => {
21+
spectator = createDirective(
22+
`
23+
<div class="content" [htTrack] [htTrackLabel]="label">Test Content</div>
24+
`,
25+
{
26+
hostProps: {
27+
events: ['click'],
28+
label: 'Content'
29+
}
30+
}
31+
);
32+
33+
const telemetryService = spectator.inject(UserTelemetryImplService);
34+
35+
spectator.click(spectator.element);
36+
spectator.tick();
37+
38+
expect(telemetryService.trackEvent).toHaveBeenCalledWith(
39+
'click: Content',
40+
expect.objectContaining({ type: 'click' })
41+
);
42+
}));
43+
44+
test('propagates events with custom config', fakeAsync(() => {
45+
spectator = createDirective(
46+
`
47+
<div class="content" [htTrack]="events" [htTrackLabel]="label">Test Content</div>
48+
`,
49+
{
50+
hostProps: {
51+
events: ['mouseover'],
52+
label: 'Content'
53+
}
54+
}
55+
);
56+
57+
const telemetryService = spectator.inject(UserTelemetryImplService);
58+
59+
spectator.dispatchMouseEvent(spectator.element, 'mouseover');
60+
spectator.tick();
61+
62+
expect(telemetryService.trackEvent).toHaveBeenCalledWith(
63+
'mouseover: Content',
64+
expect.objectContaining({ type: 'mouseover' })
65+
);
66+
}));
67+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { Directive, ElementRef, Input, OnChanges, OnDestroy, OnInit } from '@angular/core';
2+
import { fromEvent, Subscription } from 'rxjs';
3+
import { TypedSimpleChanges } from '../../utilities/types/angular-change-object';
4+
import { TrackUserEventsType } from '../telemetry';
5+
import { UserTelemetryImplService } from '../user-telemetry-impl.service';
6+
7+
@Directive({
8+
selector: '[htTrack]'
9+
})
10+
export class TrackDirective implements OnInit, OnChanges, OnDestroy {
11+
@Input('htTrack')
12+
public userEvents: string[] = [TrackUserEventsType.Click];
13+
14+
@Input('htTrackLabel')
15+
public label?: string;
16+
17+
private activeSubscriptions: Subscription = new Subscription();
18+
private trackedEventLabel: string = '';
19+
20+
public constructor(
21+
private readonly host: ElementRef,
22+
private readonly userTelemetryImplService: UserTelemetryImplService
23+
) {}
24+
25+
public ngOnInit(): void {
26+
this.setupListeners();
27+
}
28+
29+
public ngOnChanges(changes: TypedSimpleChanges<this>): void {
30+
if (changes.userEvents) {
31+
this.setupListeners();
32+
}
33+
34+
if (changes.label) {
35+
this.trackedEventLabel = this.label ?? (this.host.nativeElement as HTMLElement)?.tagName;
36+
}
37+
}
38+
39+
public ngOnDestroy(): void {
40+
this.clearListeners();
41+
}
42+
43+
private setupListeners(): void {
44+
this.clearListeners();
45+
this.activeSubscriptions = new Subscription();
46+
47+
this.activeSubscriptions.add(
48+
...this.userEvents?.map(userEvent =>
49+
fromEvent<MouseEvent>(this.host.nativeElement, userEvent).subscribe(eventObj =>
50+
this.trackUserEvent(userEvent, eventObj)
51+
)
52+
)
53+
);
54+
}
55+
56+
private clearListeners(): void {
57+
this.activeSubscriptions.unsubscribe();
58+
}
59+
60+
private trackUserEvent(userEvent: string, eventObj: MouseEvent): void {
61+
const targetElement = eventObj.target as HTMLElement;
62+
this.userTelemetryImplService.trackEvent(`${userEvent}: ${this.trackedEventLabel}`, {
63+
tagName: targetElement.tagName,
64+
className: targetElement.className,
65+
type: userEvent
66+
});
67+
}
68+
}

projects/common/src/telemetry/user-telemetry-helper.service.test.ts renamed to projects/common/src/telemetry/user-telemetry-impl.service.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ import { Router } from '@angular/router';
33
import { createServiceFactory, mockProvider } from '@ngneat/spectator/jest';
44
import { of } from 'rxjs';
55
import { TelemetryProviderConfig, UserTelemetryProvider, UserTelemetryRegistrationConfig } from './telemetry';
6-
import { UserTelemetryHelperService } from './user-telemetry-helper.service';
6+
import { UserTelemetryImplService } from './user-telemetry-impl.service';
77

88
describe('User Telemetry helper service', () => {
99
const injectionToken = new InjectionToken('test-token');
1010
let telemetryProvider: UserTelemetryProvider;
1111
let registrationConfig: UserTelemetryRegistrationConfig<TelemetryProviderConfig>;
1212

1313
const createService = createServiceFactory({
14-
service: UserTelemetryHelperService,
14+
service: UserTelemetryImplService,
1515
providers: [
1616
mockProvider(Router, {
1717
events: of({})

projects/common/src/telemetry/user-telemetry-helper.service.ts renamed to projects/common/src/telemetry/user-telemetry-impl.service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { NavigationEnd, Router } from '@angular/router';
33
import { filter } from 'rxjs/operators';
44
import { Dictionary } from '../utilities/types/types';
55
import { UserTelemetryProvider, UserTelemetryRegistrationConfig, UserTraits } from './telemetry';
6+
import { UserTelemetryService } from './user-telemetry.service';
67

78
@Injectable({ providedIn: 'root' })
8-
export class UserTelemetryHelperService {
9+
export class UserTelemetryImplService extends UserTelemetryService {
910
private telemetryProviders: UserTelemetryInternalConfig[] = [];
1011

1112
public constructor(private readonly injector: Injector, private readonly router: Router) {
13+
super();
1214
this.setupAutomaticPageTracking();
1315
}
1416

projects/common/src/telemetry/user-telemetry.module.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
import { Inject, ModuleWithProviders, NgModule, InjectionToken } from '@angular/core';
1+
import { ErrorHandler, Inject, InjectionToken, ModuleWithProviders, NgModule } from '@angular/core';
2+
import { TelemetryGlobalErrorHandler } from './error-handler/telemetry-global-error-handler';
23
import { UserTelemetryRegistrationConfig } from './telemetry';
3-
import { UserTelemetryHelperService } from './user-telemetry-helper.service';
4+
import { UserTelemetryImplService } from './user-telemetry-impl.service';
5+
import { UserTelemetryService } from './user-telemetry.service';
46

57
@NgModule()
68
export class UserTelemetryModule {
79
public constructor(
810
@Inject(USER_TELEMETRY_PROVIDER_TOKENS) providerConfigs: UserTelemetryRegistrationConfig<unknown>[][],
9-
userTelemetryInternalService: UserTelemetryHelperService
11+
userTelemetryImplService: UserTelemetryImplService
1012
) {
11-
userTelemetryInternalService.register(...providerConfigs.flat());
13+
userTelemetryImplService.register(...providerConfigs.flat());
1214
}
1315

1416
public static forRoot(
@@ -20,6 +22,14 @@ export class UserTelemetryModule {
2022
{
2123
provide: USER_TELEMETRY_PROVIDER_TOKENS,
2224
useValue: providerConfigs
25+
},
26+
{
27+
provide: UserTelemetryService,
28+
useExisting: UserTelemetryImplService
29+
},
30+
{
31+
provide: ErrorHandler,
32+
useClass: TelemetryGlobalErrorHandler
2333
}
2434
]
2535
};

projects/common/src/telemetry/user-telemetry.service.test.ts

Lines changed: 0 additions & 28 deletions
This file was deleted.
Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,7 @@
1-
import { Injectable } from '@angular/core';
21
import { UserTraits } from './telemetry';
3-
import { UserTelemetryHelperService } from './user-telemetry-helper.service';
42

5-
@Injectable({ providedIn: 'root' })
6-
export class UserTelemetryService {
7-
public constructor(private readonly userTelemetryHelperService: UserTelemetryHelperService) {}
8-
9-
public initialize(userTraits: UserTraits): void {
10-
this.userTelemetryHelperService.initialize();
11-
this.userTelemetryHelperService.identify(userTraits);
12-
}
13-
14-
public shutdown(): void {
15-
this.userTelemetryHelperService.shutdown();
16-
}
3+
export abstract class UserTelemetryService {
4+
public abstract initialize(): void;
5+
public abstract identify(userTraits: UserTraits): void;
6+
public abstract shutdown(): void;
177
}

0 commit comments

Comments
 (0)