- PVSM.RU - https://www.pvsm.ru -

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

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

image
Начнем с простого — JavaScript. В отличии от десктопных приложений тут все довольно просто — подписываемся на события, записываем необходимые данные и, в общем-то, всё.

Используем стандартный addEventListener [3] для подписки на события в 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 [4]. Ох, как же их много… Чтобы не захламлять лог кучей лишней информации, придется выбрать самые главные события:

  • DOMContentLoaded [5] — для падения обязательно знать была ли уже загружена страница
  • Мышиные события (click​ [6], dblclick​ [7], auxclick​ [8]) — тут даже не возникает сомнений в важности. Знать когда и куда пользователь кликнул — ну просто «маст хэв». В js click срабатывает только на нажатия левой клавиши мыши. Для обработки средней и правой клавиш используем событие auxclick.
  • Клавиатурные события (keyDown​ [9], keyPress [10], keyUp​ [11]) — введенный текст, нажатие комбинации клавиш — все здесь и все важно.

    А как же быть с паролями, мы же их соберем? Нехорошо это, неприватно…
    Сделаем проверку на то, является ли event.target инпутом и получим тип этого инпута. Если получили password — запишем * вместо значения.

    isSecureElement(event) {
       return event.target && event.target.type && event.target.type.toLowerCase() === "password";
    }
    
  • События стандартных форм (submit​ [12] и reset​ [13]) — информация об отправке данных формы или их очистке.
  • Событие change [14] — куда же без событий изменения стандартных элементов формы.

Но есть события, на которые не подпишешься простым 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 [15]. Документация по клиенту здесь [16].

А теперь немного о том, что можно сделать для решения этой задачи в ASP.NET. На серверной стороне трекаем все входящие реквесты, предшествующие падению. Для ASP.NET (WebForms + MVC) реализуем на базе IHttpModule и эвента HttpApplication.BeginRequest [17]:

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

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

Таким образом, такой подход работает как в старом добром 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 [20] и ASP.NET Core [21].

Автор: r_kolesnikov

Источник [22]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/269931

Ссылки в тексте:

[1] WinForms: https://habrahabr.ru/company/devexpress/blog/342530/

[2] Wpf: https://habrahabr.ru/company/devexpress/blog/343358/

[3] addEventListener: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener

[4] Events: https://developer.mozilla.org/en-US/docs/Web/Events

[5] DOMContentLoaded: https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded

[6] click​: https://developer.mozilla.org/en-US/docs/Web/Events/click

[7] dblclick​: https://developer.mozilla.org/en-US/docs/Web/Events/dblclick

[8] auxclick​: https://developer.mozilla.org/en-US/docs/Web/Events/auxclick

[9] keyDown​: https://developer.mozilla.org/en-US/docs/Web/Events/keydown

[10] keyPress: https://developer.mozilla.org/en-US/docs/Web/Events/keypress

[11] keyUp​: https://developer.mozilla.org/en-US/docs/Web/Events/keyup

[12] submit​: https://developer.mozilla.org/en-US/docs/Web/Events/submit

[13] reset​: https://developer.mozilla.org/en-US/docs/Web/Events/reset

[14] change: https://developer.mozilla.org/en-US/docs/Web/Events/change

[15] GitHub: https://github.com/DevExpress/Logify.Alert.Clients/tree/develop/js/Logify.Alert.JS

[16] здесь: https://logify.devexpress.com/Alert/Documentation/Send/ApiReference/JS?utm_source=publication_breadcrumbs&utm_campaign=blog

[17] HttpApplication.BeginRequest: https://msdn.microsoft.com/en-us/library/system.web.httpapplication.beginrequest(v=vs.110).aspx

[18] авторизован: https://msdn.microsoft.com/en-us/library/system.web.sessionstate.httpsessionstate.sessionid.aspx#Remarks

[19] выключена: https://msdn.microsoft.com/en-us/library/system.web.sessionstate.sessionstatemode(v=vs.110).aspx

[20] ASP.NET: https://github.com/DevExpress/Logify.Alert.Clients/tree/develop/dotnet/Logify.Alert.Web

[21] ASP.NET Core: https://github.com/DevExpress/Logify.Alert.Clients/tree/develop/dotnet/Logify.Alert.NetCore.Web

[22] Источник: https://habrahabr.ru/post/343816/