Banx. Создание трекера событий пользователя в 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"
/>
Проведем аналогичные манипуляции со страницей сброса пароля.
Тестирование
Запустим бекенд и фронтенд.
Зайдем на главную, а потом перейдем на страницу авторизации, потом на восстановления.
Заполняем формы и уходим на другие страницы. В результате имеем:
Смотрим, что появилось в трекере:
Или все целиком:
Как видно из таблицы, все события по вводу данных и переходу страниц, были сохранены, а также события фокуса, блюра и смены вкладок.
Резюме
В данной статье рассмотрели создание трекера событий пользователя. Создали сервис, который на наблюдаемое событие создает запись и сохраняет ее. Так же добавили реализацию 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