Собираем пользовательскую активность в Js и ASP
После написания функционала авторекордера действий пользователя, названного нами breadcrumbs, в WinForms и Wpf, пришло время добраться и до клиент-серверных технологий.
Начнем с простого — JavaScript. В отличии от десктопных приложений тут все довольно просто — подписываемся на события, записываем необходимые данные и, в общем-то, всё.
Используем стандартный addEventListener для подписки на события в js. Вешаем обработчики событий на объект window для того, чтобы получать уведомления о событиях со всех элементов страницы.
Напишем класс, который будет подписываться на все нужные нам события:
class eventRecorder {
constructor() {
this._events = [
"DOMContentLoaded",
"click",
...,
"submit"
];
}
startListening(eventCallback) {
this._mainCallback = function (event) {
this.collectBreadcrumb(event, eventCallback);
}.bind(this);
for (let i = 0; i < this._events.length; i++) {
window.addEventListener(
this._events[i],
this._mainCallback,
false
);
}
}
stopListening() {
if (this._mainCallback) {
for (let i = 0; i < this._events.length; i++) {
window.removeEventListener(
this._events[i],
this._mainCallback,
false
);
}
}
}
}
Теперь нас ждут увлекательные муки выбора тех самых ценных событий, на которые имеет смысл подписаться. Для начала найдем полный список событий: Events. Ох, как же их много… Чтобы не захламлять лог кучей лишней информации, придется выбрать самые главные события:
- DOMContentLoaded — для падения обязательно знать была ли уже загружена страница
- Мышиные события (click, dblclick, auxclick) — тут даже не возникает сомнений в важности. Знать когда и куда пользователь кликнул — ну просто «маст хэв». В js click срабатывает только на нажатия левой клавиши мыши. Для обработки средней и правой клавиш используем событие auxclick.
- Клавиатурные события (keyDown, keyPress, keyUp) — введенный текст, нажатие комбинации клавиш — все здесь и все важно.
А как же быть с паролями, мы же их соберем? Нехорошо это, неприватно…
Сделаем проверку на то, является ли event.target инпутом и получим тип этого инпута. Если получили password — запишем * вместо значения.isSecureElement(event) { return event.target && event.target.type && event.target.type.toLowerCase() === "password"; }
- События стандартных форм (submit и reset) — информация об отправке данных формы или их очистке.
- Событие change — куда же без событий изменения стандартных элементов формы.
Но есть события, на которые не подпишешься простым addEventListener, как мы делали это ранее. Это такие события, как ajax запросы и логирование в console. Ajax запросы важно логировать для того, чтобы получить полную картину действий пользователя, к тому же, падения часто происходят как раз на взаимодействиях с сервером. В console же может писаться важная отладочная информация (в виде предупреждений, ошибок, ну или просто логов) как самим разработчиком сайта, так и из сторонних библиотек.
Для таких видов событий придется писать обертки для стандартных js функций. В них подменяем стандартную функцию на свою собственную (createBreadcrumb), где параллельно с нашими действиями (в данном случае записью в breadcrumbs) вызываем предварительно сохраненную стандартную функцию. Вот как это выглядит для console:
export default class consoleEventRecorder {
constructor() {
this._events = [
"log",
"error",
"warn"
];
}
startListening(eventCallback) {
for (let i = 0; i < this._events.length; i++) {
this.wrapObject(console, this._events[i], eventCallback);
}
}
wrapObject(object, property, callback) {
this._defaultCallback[property] = object[property];
let wrapperClass = this;
object[property] = function () {
let args = Array.prototype.slice.call(arguments, 0);
wrapperClass.createBreadcrumb(args, property, callback);
if (typeof wrapperClass._defaultCallback[property] === "function") {
Function.prototype.apply.call(wrapperClass.
_defaultCallback[property], console, args);
}
};
}
}
Для ajax запросов все несколько сложнее — тут помимо того, что надо переопределить стандартную функцию open, надо еще и добавить callback на функцию onload, чтобы получать данные на изменении статуса requestа, иначе не получим код ответа сервера.
И вот что у нас получилось:
addXMLRequestListenerCallback(callback) {
if (XMLHttpRequest.callbacks) {
XMLHttpRequest.callbacks.push(callback);
} else {
XMLHttpRequest.callbacks = [callback];
this._defaultCallback = XMLHttpRequest.prototype.open;
const wrapper = this;
XMLHttpRequest.prototype.open = function () {
const xhr = this;
try {
if ('onload' in xhr) {
if (!xhr.onload) {
xhr.onload = callback;
} else {
const oldFunction = xhr.onload;
xhr.onload = function() {
callback(Array.prototype.slice.call(arguments));
oldFunction.apply(this, arguments);
}
}
}
} catch (e) {
this.onreadystatechange = callback;
}
wrapper._defaultCallback.apply(this, arguments);
}
}
}
Правда, стоит отметить, что у оберток есть один серьезный недостаток — вызываемая функция перемещается внутрь нашей функции, в связи с чем меняется имя файла, из которого она была вызвана.
Полный исходный код JavaScript клиента на ES6 можно посмотреть на GitHub. Документация по клиенту здесь.
А теперь немного о том, что можно сделать для решения этой задачи в ASP.NET. На серверной стороне трекаем все входящие реквесты, предшествующие падению. Для ASP.NET (WebForms + MVC) реализуем на базе IHttpModule и эвента HttpApplication.BeginRequest:
using System.Web;
public class AspExceptionHandler : IHttpModule {
public void OnInit(HttpApplication context) {
try {
if(LogifyAlert.Instance.CollectBreadcrumbs)
context.BeginRequest += this.OnBeginRequest;
}
catch { }
}
void OnBeginRequest(object sender, EventArgs e) {
AspBreadcrumbsRecorder
.Instance
.AddBreadcrumb(sender as HttpApplication);
}
}
Для разделения и фильтрации реквестов от разных пользователей используем куку-трекер. При сохранении информации о реквесте проверяем, есть ли в нём нужная нам кука. Если ещё нет, добавляем и сохраняем её значение, не забываем валидировать:
using System.Web;
public class AspBreadcrumbsRecorder : BreadcrumbsRecorderBase{
internal void AddBreadcrumb(HttpApplication httpApplication) {
...
HttpRequest request = httpApplication.Context.Request;
HttpResponse response = httpApplication.Context.Response;
Breadcrumb breadcrumb = new Breadcrumb();
breadcrumb.CustomData = new Dictionary() {
...
{ "session", TryGetSessionId(request, response) }
};
base.AddBreadcrumb(breadcrumb);
}
string CookieName = "BreadcrumbsCookie";
string TryGetSessionId(HttpRequest request, HttpResponse response) {
string cookieValue = null;
try {
HttpCookie cookie = request.Cookies[CookieName];
if(cookie != null) {
Guid validGuid = Guid.Empty;
if(Guid.TryParse(cookie.Value, out validGuid))
cookieValue = cookie.Value;
} else {
cookieValue = Guid.NewGuid().ToString();
cookie = new HttpCookie(CookieName, cookieValue);
cookie.HttpOnly = true;
response.Cookies.Add(cookie);
}
} catch { }
return cookieValue;
}
}
Это позволяет не закладываться, например, на SessionState и отделять уникальные сеансы, даже когда пользователь ещё не авторизован или сессия вообще выключена.
Таким образом, такой подход работает как в старом добром ASP.NET (WebForms + MVC),
так и в новом ASP.NET Core, где с привычной всем сессией дела несколько по-другому:
Middleware:
using Microsoft.AspNetCore.Http;
internal class LogifyAlertMiddleware {
RequestDelegate next;
public LogifyAlertMiddleware(RequestDelegate next) {
this.next = next;
...
}
public async Task Invoke(HttpContext context) {
try {
if(LogifyAlert.Instance.CollectBreadcrumbs)
NetCoreWebBreadcrumbsRecorder.Instance.AddBreadcrumb(context);
await next(context);
}
...
}
}
Сохранение реквеста:
using Microsoft.AspNetCore.Http;
public class NetCoreWebBreadcrumbsRecorder : BreadcrumbsRecorderBase {
internal void AddBreadcrumb(HttpContext context) {
if(context.Request != null && context.Request.Path != null &&
context.Response != null) {
Breadcrumb breadcrumb = new Breadcrumb();
breadcrumb.CustomData = new Dictionary() {
...
{ "session", TryGetSessionId(context) }
};
base.AddBreadcrumb(breadcrumb);
}
}
string CookieName = "BreadcrumbsCookie";
string TryGetSessionId(HttpContext context) {
string cookieValue = null;
try {
string cookie = context.Request.Cookies[CookieName];
if(!string.IsNullOrEmpty(cookie)) {
Guid validGuid = Guid.Empty;
if(Guid.TryParse(cookie, out validGuid))
cookieValue = cookie;
}
if(string.IsNullOrEmpty(cookieValue)) {
cookieValue = Guid.NewGuid().ToString();
context.Response.Cookies.Append(CookieName, cookieValue,
new CookieOptions() { HttpOnly = true });
}
} catch { }
return cookieValue;
}
}
Полный исходный код ASP.NET клиетов на GitHub: ASP.NET и ASP.NET Core.