Banx. Создание трекера событий пользователя в Angular.

Aleksandr Serenko
F.A.F.N.U.R
Published in
8 min readOct 5, 2021
Создание трекера событий пользователя в Angular

В данной статье поговорим о создании трекера событий пользователя, который будет собирать информацию о действиях пользователя с приложением.

Custom tracker on Angular

Зачем это делать, если есть множество метрик, таких как гугл, яндекс и прочее? В большинстве случаев данные из гугл аналитики или яндекса не персонализированные, то есть невозможно использовать эти данные для анализа.

Реализация собственного трекера позволяет собирать информацию о конкретном пользователе, что позволяет:

  • Использовать эти данные в анализе пользователей. Подобного рода данные позволяют проверить множество гипотез, для определенных групп пользователей и в результате, предоставить пользователю те или иные услуги.
  • Использовать эти данные для создания определенных метрик, которые будут реагировать на изменение поведения пользователей. Например, все клиенты проводят на странице “X” в среднем 1 минуту, но после релиза, время увеличивается на 200%. Соответственно необходимо разобраться, что произошло и понять, сломалось ли приложение или это новое поведение клиента.
  • Использовать эти данные для борьбы с мошенниками. Полная история действий пользователя с приложением является хорошей поддержкой для анализа логов и истории действий конкретного пользователя.

Принцип работы

Реализуем простой трекер, который будут собирать действия пользователя. В данном случае, будем собирать информацию о взаимодействии с HTML формами (focus, change, blur), глобальные собития window (focus, blur, visabilitychange) и события навигации в Angular (navigationEnd) для фиксирования смены страницы.

Для трекера понадобится:

  • Tracker Service — сервис, который будет создавать и сохранять произошедшие события;
  • Tracker State — state, который будет с некоторым интервалом брать накопившиеся события и отправлять их на сервер;
  • Tracker Directives — ряд директив, которые можно будет добавить к html элементам формы, и записывать происходящие события.

На бекенде добавим таблицу, в которую будем записывать данные из endpoint’а.

Создание TrackerService

Определим интерфейс для отслеживаемого события:

  • type — тип произошедшего события. Как видно из примера, в enum есть и стандартные DOM события, а также ряд кастомных событий (open, close, custom, press);
  • element — название отслеживаемого элемента;
  • value — значение элемента. Обычно используется для input’ов;
  • time — время, когда произошло событие;
  • url — страница, на которой произошло событие;
  • user — id пользователя, если пользователь авторизован;
  • keys — сочетания клавиш, которые были нажаты.
  • data — дополнительные параметры, которые могут содержать любую информацию. Например, версию релиза, номер ноды и прочее.

TrackerService на основании TrackerEvent будет создавать TrackerRecord:

В TracekRecord добавился uid и часть полей стали обязательными.

Создадим TrackerService:

В начале объявляются свойства:

private records: TrackerRecord[] = [];
private repeats = 0;
private lastRecord!: TrackerRecord;
private sending: string[] = [];
private readonly added$ = new Subject<TrackerRecord>();
  • records — массив, в котором хранятся произошедшие события;
  • repeats — свойсто, которое хранит количетсво “залипаний” (идентичных событий);
  • sending — массив, который хранит uid’ы записей, которые в данный момент пытаются сохраниться на сервере;
  • added$ — subject, который будет оповещать подписчиков, что новая запись создана.

Когда происходит отслеживаемое событие, то вызывается метод add():

private get isStickyKeys(): boolean {
return this.repeats > 100;
}

add(payload: TrackerEvent): void {
const record = this.createRecord(payload);

let stickyKeysFinish = null;
if (
this.lastRecord &&
this.lastRecord.type === payload.type &&
this.lastRecord.element === payload.element &&
this.lastRecord.value === payload.value
) {
this.repeats++;
} else {
if (this.isStickyKeys) {
stickyKeysFinish = this.createRecord({
element: payload.element,
type: TrackerEventType.StickyKeyEnd,
value: this.repeats.toString(),
time: payload.time ?? new Date().toISOString(),
data: {
repeats: this.repeats,
},
});
}
this.repeats = 0;
}

if (!this.isStickyKeys) {
if (stickyKeysFinish) {
this.records.push(stickyKeysFinish);
}
this.records.push(record);
this.added$.next(record);

// Save snapshot on storage
this.localAsyncStorage.setItem(TrackerKeys.Records, this.records);
}
this.lastRecord = record;
}

Сначала идет проверка на то, что не является ли событие дублем. Если дубль, то тогда увеличиваем счетчик дублей и обновляем последнее событие. Если событие не дубль, то тогда смотрим были ли раньше дубли. Если были, то тогда сохраняем последние событие, где указываем количество дублей. Иначе просто добавляем новую запись. В конце, сохраняем события в localStorage, для того, чтобы если события не были сохранены, то при последующей отправки событий, предыдущие события тоже были отправлены.

Сам метод по созданию записи по событию выглядит следующим образом:

private createRecord(payload: TrackerEvent): TrackerRecord {
const uid = uuidv4();
const time = payload.time ?? new Date().toISOString();
let value = payload.value;
if (value == null || value === 'null') {
value = '';
}
const keys = payload.keys ?? '';
const url = this.router.url;
const user = this.localAsyncStorage.state[UserStorageKeys.Id] ?? null;
const data = { ...payload.data, version: this.configService.config.version };

return { uid, type: payload.type, element: payload.element, url, time, value, keys, user, data };
}

В методе создаются все недостающие поля:

  • создается uid;
  • создается временная метка;
  • добавляется значение наблюдаемого элемента, где если прилетает Null, то тогда значение заменяется на пустую строку;
  • добавляется путь страницы, пользователь.

Остальные методы тривиальны:

clear(): void {
this.records = [];
this.localAsyncStorage.setItem(TrackerKeys.Records, []);
}

getRecords(): TrackerRecord[] {
return this.sending.length ? this.records.filter((record) => !this.sending.includes(record.uid)) : this.records;
}

removeRecords(records: TrackerRecord[]): void {
this.unmarkRecords(records);

const ids = records.map((record) => record.uid);
this.records = this.records.filter((record) => !ids.includes(record.uid));

this.localAsyncStorage.setItem(TrackerKeys.Records, this.records);
}

markRecords(records: TrackerRecord[]): void {
records.forEach((record) => this.sending.push(record.uid));
}

unmarkRecords(records: TrackerRecord[]): void {
const ids = records.map((record) => record.uid);

this.sending = this.sending.filter((recordId) => !ids.includes(recordId));
}
  • clear — очищает события;
  • getRecords — возвращает список не отправленных событий
  • removeRecords — удаляет сохраненные записи
  • markRecords — помечает записи, которые сохраняются в данный момент
  • unmarkRecords — удаляет записи из списка сохраняемых

Создание TrackerState

Создадим state, который будет управлять сохранением событий, а также будет наблюдать за глобальными событиями в приложении.

TrackerState содержит в себе лишь состояние активного сохранения записей.

export interface TrackerState {
saving: boolean;
}

В экшенах есть события инициализации стейта, добавления записи и цепочка сохранения.

export const init = createAction('[Tracker] Init');

export const addRecord = createAction('[Tracker] Add Record', payload<TrackerEvent>());

export const saveRecords = createAction('[Tracker] Save Records');

export const saveRecordsSuccess = createAction('[Tracker] Save Records Success');

export const saveRecordsFailure = createAction('[Tracker] Save Records Failure', payload<Record<string, any>>());

В эффектах три важных действия:

saveRecords$ = createEffect(() =>
this.actions$.pipe(
ofType(TrackerActions.saveRecords),
fetch({
id: () => 'tracker-save',
run: () => {
// TODO: Add logic for fake login
const records = this.trackerService.getRecords();
this.trackerService.markRecords(records);

return records && records.length > 0 && this.platformService.isBrowser
? this.trackerApiService
.save({
records,
visitor: this.visitorService.getUuid(),
})
.pipe(
map<void, Action>(() => {
this.trackerService.removeRecords(records);

return TrackerActions.saveRecordsSuccess();
}),
catchError((error) => {
this.trackerService.unmarkRecords(records);

return throwError(error);
})
)
: TrackerActions.saveRecordsSuccess();
},
onError: (action, error) =>
this.loggerService.logEffect({ context: { action, error } }, TrackerActions.saveRecordsFailure({ payload: error })),
})
)
);

Эффект сохранения записей берет текущие записи из TrackerService, помечает записи как отправляемые и в случае успешной отправки, события удаляются из сервиса, иначе просто снимаются с отрпавки.

Стоит отметить, что данные на сервер отправляются в следующем виде:

export interface TrackerRecordsDto {
visitor: string;
records: TrackerRecord[];
}
  • visitor — это уникальный идентификатор устройства. Он генерируется один раз для клиента и сохраняется в localStorage. Если только в localStorage нету visitor’а, то генерируется новый.
  • records — массив записей, которые нужно сохранить.

Второе важное действие — это сохранение навигации пользователя:

routerNavigated$ = createEffect(() =>
this.actions$.pipe(
ofType(ROUTER_NAVIGATED),
fetch({
id: () => 'tracker-router-navigated',
run: (action: any) => {
const url = action.payload.event.url ?? 'unknown';
const payload = { element: 'router', type: TrackerEventType.Navigate, value: url };
this.trackerService.add(payload);
},
onError: (action, error) => this.loggerService.logEffect({ context: { action, error } }),
})
)
);

В данном случае, просто берется событие окончания навигации и создается новая запись в трекере.

Последней важной частью в эффектах является подписка на глобальные события:

if (this.windowService.window) {
this.trackerService.add({
element: 'window',
type: TrackerEventType.Focus,
});

this.windowService.window.addEventListener('focus', () =>
this.trackerService.add({
element: 'window',
type: TrackerEventType.Focus,
})
);
this.windowService.window.addEventListener('blur', () =>
this.trackerService.add({
element: 'window',
type: TrackerEventType.Blur,
})
);

this.windowService.document.addEventListener('visibilitychange', () =>
this.trackerService.add({
element: 'window',
type: TrackerEventType.VisibilityChange,
value: this.windowService.document.hidden ? 'invisible' : 'visible',
})
);
}

Из примера видно, что сделаны 3 подписки:

  • подписка на событие фокуса для window;
  • подписка на событие блюра для window;
  • подписка на событие смены активной вкладки в document.

Заметим, что WindowService это всего лишь обертка над window:

Это будет необходимо позднее, для SSR.

Для автосохранения записей используется таймер с интервалом в 5 секунд, после добавления последнего события:

this.subscription.add(
this.trackerService.recordAdded$
.pipe(switchMap(() => timer(5000).pipe(tap(() => this.store.dispatch(TrackerActions.saveRecords())))))
.subscribe()
);

Tracker Directives

После того как есть сервис и хранилище, добавим несколько директив для форм:

В данном случае имеются следующие директивы:

  • InputTrackDirective,
  • ButtonTrackDirective,
  • DatepickerTrackDirective,
  • AutocompleteTrackDirective,
  • CheckboxTrackDirective,
  • InputFileTrackDirective,
  • RadioTrackDirective,
  • SliderTrackDirective,
  • SelectTrackDirective.

Из названия директив понятно к какому типу HTML элементу формы она будет применяться.

Рассмотрим первую в списке директиву:

import { Directive, HostListener, Input, Optional, Self } from '@angular/core';
import { NgControl } from '@angular/forms';

import { TrackerEventType } from '@banx/trackers/common';
import { TrackerService } from '@banx/trackers/service';

@Directive({
selector:
// eslint-disable-next-line max-len
'[banxAutocompleteTrack][trackId][formControlName],[banxAutocompleteTrack][trackId][formControl],[banxAutocompleteTrack][trackId][trackValue]',
})
export class AutocompleteTrackDirective {
@Input() trackId!: string;
@Input() trackValue?: string;

constructor(@Optional() private readonly trackerService: TrackerService, @Optional() @Self() public ngControl: NgControl) {}

@HostListener('focusout')
onBlur(): void {
this.track(TrackerEventType.Blur);
}

@HostListener('focus')
onFocus(): void {
this.track(TrackerEventType.Focus);
}

@HostListener('optionSelected')
onInput(): void {
this.track(TrackerEventType.Change);
}

@HostListener('closed')
onClose(): void {
this.track(TrackerEventType.Close);
}

@HostListener('opened')
onOpen(): void {
this.track(TrackerEventType.Open);
}

private track(type: TrackerEventType): void {
this.trackerService.add({
type,
value: this.trackValue ?? this.ngControl?.value ?? '',
time: new Date().toISOString(),
element: this.trackId,
});
}
}

В примере видно, что для подписки на события используется HostListener.

Каждое отслеживаемое событие вызывает метод track, который добавляет необходимые данные и создает запись в трекере.

Все необходимые данные для метода track передаются с помощью input’ов.

Tracker API

Добавим сервис Api для сохранения записей:

Из примера видно, что сервис реализует всего-лишь один метод — save.

Реализуем сам backend на NestJS:

Если в кратце, то:

  • Создается сущность TrackerEntity для TypeORM.
  • Добавляем соответствующий TrackerService, который является реализацией EntityRepository.
  • Создается контроллер, в котором указывается путь endpoint’а.
  • Все подключается в соответствующем модуле TrackersModule, который в свою очередь подключается в AppModule.

Интеграция трекера

Так как в проекте есть только страницы авторизации и сброса пароля, то добавим в трекер события с данных страниц.

Для LoginFormComponent добавим пару директив в шаблоне для кнопки:

<button
automation-id="login"
class="login-submit"
type="button"
id="LoginButton"
i18n="Login form|Log in"
mat-raised-button
color="accent"
banxButtonTrack
trackId="LoginButton"
trackValue="login"
(click)="onLogin()"
>
Log in
</button>

Для LoginFormUsernameComponent и LoginFormPasswordComponent добавим отслеживание input’ов:

<input
automation-id="control"
matInput
type="tel"
i18n-placeholder="Login form|Mobile phone"
placeholder="Mobile phone"
id="AuthUsername"
name="username"
autocomplete="username"
banxInputTrack
trackId="AuthUsername"
[formControl]="control"
[imask]="{ mask: '+{7} 000 000-00-00' }"
[unmask]="true"
/>
<input
type="password"
matInput
i18n-placeholder="Login form|Password placeholder"
placeholder="Password"
id="AuthPassword"
name="password"
automation-id="control"
banxInputTrack
trackId="AuthPassword"
[password]="true"
[formControl]="control"
/>

Проведем аналогичные манипуляции со страницей сброса пароля.

Тестирование

Запустим бекенд и фронтенд.

Зайдем на главную, а потом перейдем на страницу авторизации, потом на восстановления.

Заполняем формы и уходим на другие страницы. В результате имеем:

Custom tracker on Angular

Смотрим, что появилось в трекере:

Или все целиком:

Как видно из таблицы, все события по вводу данных и переходу страниц, были сохранены, а также события фокуса, блюра и смены вкладок.

Резюме

В данной статье рассмотрели создание трекера событий пользователя. Создали сервис, который на наблюдаемое событие создает запись и сохраняет ее. Так же добавили реализацию state’а, которая наблюдает за событиями window, а также реализует автосохранение событий с установленным интервалом.

Ссылки

Вернуться к оглавлению — Введение.

Предыдущая статья — Banx. Создание страницы авторизации и сброса пароля.

Следующая статья — Banx. Реализация отпечатков пальцев браузера в Angular.

Все исходники находятся на github, в репозитории:

Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий тег — tracker.

git checkout tracker

Подписывайтесь на блог, чтобы не пропустить новые статьи про Angular, веб-разработку и новости из мира фронтенда.

Группа в Medium: https://medium.com/fafnur
Группа в Vkontakte: https://vk.com/fafnur
Группа в Facebook: https://www.facebook.com/groups/fafnur/
Telegram канал: https://t.me/f_a_f_n_u_r
Twitter: https://twitter.com/Fafnur1
Instagram: https://www.instagram.com/fafnur
LinkedIn: https://www.linkedin.com/in/fafnur

--

--

Aleksandr Serenko
F.A.F.N.U.R

Senior Front-end Developer, Angular evangelist, Nx apologist, NodeJS warlock