Собираем пользовательскую активность в Js и ASP

После написания функционала авторекордера действий пользователя, названного нами breadcrumbs, в WinForms и Wpf, пришло время добраться и до клиент-серверных технологий.

image
Начнем с простого — 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 и отделять уникальные сеансы, даже когда пользователь ещё не авторизован или сессия вообще выключена.

kqmmen1hmasxyjjlwicqcx52d6k.png

Таким образом, такой подход работает как в старом добром 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.

© Habrahabr.ru