Собираем пользовательскую активность в 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<string, string>() {
                ...
                { "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<string, string>() {
                        ...
                        { "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.
    Developer Soft
    73,00
    Компания
    Поделиться публикацией

    Комментарии 0

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

    Самое читаемое