Логирование запросов к приложению Django

от автора

Привет, меня зовут Дмитрий Чернышов, я ведущий разработчик компании «БАРС Груп». Последние несколько лет среди прочих задач нам в команде приходится сталкиваться с необходимостью анализа производительности приложений на Django с большим количеством пользователей и постоянно меняющимися профилями нагрузки. В данной статье я хочу поделиться наработками, которые появились у нас в процессе работы.

Предыстория/Описание проблемы

В Django достаточно средств для того, чтобы организовать логирование запросов к приложению с целью получения ответов на следующие вопросы:

  • Какие запросы в среднем работают дольше всего с точки зрения пользователей?
  • Каких запросов выполняется больше всего?
  • Какие запросы создают больше всего запросов к СУБД?
  • В каких запросах самые длинные транзакции?

Демо-приложение

Для начала установим Django 3 версии и создадим простое приложение (первых двух частей туториала будет достаточно). В качестве СУБД будем использовать PostgreSQL. Для тех, кто не хочет собирать приложение с нуля — исходный код, который используется в статье, находится в репозитории. Для каждого шага в статье будет проставлена соответствующая метка в репозитории, чтобы можно было перейти на соответствующее состояние и посмотреть diff с предыдущей/последующей меткой. Базовое приложение без лишних модификаций доступно по метке stage_00.

Логируем время на сервере

Рассмотрим простую задачу: мы хотим логировать продолжительность запросов к приложению. Что для этого мы можем использовать? В Django есть подходящий инструмент, который называется middleware. Как следует из описания — мы можем выполнить некие действия до того, как запрос попадет во view (и нижестоящие middleware), а также обработать результат выполнения запроса, когда он будет возвращаться клиенту. Каким образом нам это может помочь? Мы можем зафиксировать временную отметку, когда запрос придет к нам, а в момент возвращения ответа — вычислить разность между текущим временем и зафиксированной ранее временной отметкой. Давайте реализуем такой middleware:

polls/middleware.py

import time   class RequestTimeMiddleware:    def __init__(self, get_response):        self.get_response = get_response     def __call__(self, request):        timestamp = time.monotonic()         response = self.get_response(request)         print(            f'Продолжительность запроса {request.path} - '            f'{time.monotonic() - timestamp:.3f} сек.'        )         return response

После реализации необходимо подключить его к нашему приложению. Для этого необходимо указать реализованный middleware в settings.py:

mysite/settings.py

# ...  MIDDLEWARE = [     'polls.middleware.RequestTimeMiddleware',     'django.middleware.security.SecurityMiddleware',     'django.contrib.sessions.middleware.SessionMiddleware',     # ...    ]

Стоит обратить внимание, что наш middleware лучше поставить как можно выше в списке (в идеале, первым), чтобы в подсчет продолжительности запроса вошло не только время работы view, но и всех подключенных middleware.

После подключения необходимо запустить приложение и выполнить запрос к админ-панеле по адресу http://127.0.0.1:8000/admin/. После выполнения запроса в консоли отобразится вывод, подобный представленному ниже:

Performing system checks...  System check identified no issues (0 silenced). June 23, 2020 - 21:21:56 Django version 3.0.7, using settings 'mysite.settings' Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. [23/Jun/2020 21:22:01] "GET /admin/ HTTP/1.1" 200 3721 Продолжительность запроса /admin/ - 0.036 сек. [23/Jun/2020 21:22:01] "GET /static/admin/css/base.css HTTP/1.1" 304 0 [23/Jun/2020 21:22:01] "GET /static/admin/css/dashboard.css HTTP/1.1" 304 0 [23/Jun/2020 21:22:01] "GET /static/admin/css/responsive.css HTTP/1.1" 304 0 [23/Jun/2020 21:22:03] "GET /admin/ HTTP/1.1" 200 3721 Продолжительность запроса /admin/ - 0.021 сек. 

В репозитории результат выполнения этой части можно посмотреть по метке stage_01.

Отделяем приложение от СУБД

Кроме выполнения python-кода, приложение выполняет запросы к СУБД. Было бы удобно отделить время выполнения python-кода от времени выполнения запросов к СУБД. Что нам предлагает Django? Для работы с СУБД Django использует механизм engine. В нашем случае он должен уметь логировать время выполнения запросов. Воспользуемся наследованием базового engine. Для реализации логирования используем класс CursorWrapper и разместим наши наработки в файле base.py:

polls/base.py

import time from contextlib import contextmanager  from django.db.backends.postgresql.base import DatabaseWrapper as DjangoDatabaseWrapper from django.db.backends.utils import CursorWrapper as DjangoCursorWrapper   @contextmanager def calc_sql_time(sql):    timestamp = time.monotonic()     yield   print(        f'Продолжительность SQL-запроса {sql} - '        f'{time.monotonic() - timestamp:.3f} сек.'    )   class CursorWrapper(DjangoCursorWrapper):    def execute(self, sql, params=None):        with calc_sql_time(sql):            return super().execute(sql, params)   class DatabaseWrapper(DjangoDatabaseWrapper):    def create_cursor(self, name=None):        cursor = super().create_cursor(name)        return CursorWrapper(cursor, self) 

После того, как мы создали наш engine, необходимо указать его в settings.py:

mysite/settings.py

# ...  DATABASES = {    'default': {        'ENGINE': 'polls',        'NAME': 'mysite',        'USER': 'postgres',        'PASSWORD': 'postgres',    } } 

Теперь запустим приложение и посмотрим, что у нас получилось:

[29/Jun/2020 11:41:05] "GET /admin/ HTTP/1.1" 200 3721 Продолжительность SQL-запроса SELECT "django_session"."session_key", "django_session"."session_data", "django_session"."expire_date" FROM "django_session" WHERE ("django_session"."expire_date" > %s AND "django_session"."session_key" = %s) LIMIT 21 - 0.001 сек. Продолжительность SQL-запроса SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = %s LIMIT 21 - 0.001 сек. Продолжительность SQL-запроса SELECT COUNT(*) AS "__count" FROM "polls_question" - 0.001 сек. Продолжительность SQL-запроса SELECT COUNT(*) AS "__count" FROM "polls_question" - 0.000 сек. Продолжительность SQL-запроса SELECT "polls_question"."id", "polls_question"."question_text", "polls_question"."pub_date" FROM "polls_question" ORDER BY "polls_question"."id" DESC - 0.000 сек. [29/Jun/2020 11:41:09] "GET /admin/polls/question/ HTTP/1.1" 200 3179 Продолжительность запроса /admin/polls/question/ - 0.034 сек. Продолжительность SQL-запроса SELECT "django_session"."session_key", "django_session"."session_data", "django_session"."expire_date" FROM "django_session" WHERE ("django_session"."expire_date" > %s AND "django_session"."session_key" = %s) LIMIT 21 - 0.002 сек. Продолжительность SQL-запроса SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = %s LIMIT 21 - 0.001 сек. Продолжительность запроса /admin/jsi18n/ - 0.012 сек. [29/Jun/2020 11:41:10] "GET /admin/jsi18n/ HTTP/1.1" 200 3223

В репозитории результат выполнения этой части можно посмотреть по метке stage_02.

Связываем запросы в БД и web-запросы

В некоторых случаях при анализе запросов к БД бывает полезно знать, какой запрос к приложению сгенерировал конкретный SQL-запрос. Это особенно полезно, когда какой-либо базовый механизм используется во многих компонентах системы. Для решения этой задачи можно воспользоваться реализованными ранее middleware и курсором.

Для начала, модифицируем middleware, чтобы зафиксировать текущий path запроса:

polls/middleware.py

import time from threading import local   thread_locals = local()   class RequestTimeMiddleware:    def __init__(self, get_response):        self.get_response = get_response   def __call__(self, request):        thread_locals.path = request.path        timestamp = time.monotonic()         response = self.get_response(request)         print(            f'Продолжительность запроса {request.path} - '            f'{time.monotonic() - timestamp:.3f} сек.'        )        thread_locals.path = ''         return response 

Теперь сохраненный путь можно добавить как комментарий к запросу, который выполняется в курсоре:

polls/base.py

from django.utils.encoding import force_text  from polls.middleware import thread_locals  # ...   def make_safe(s):    return s.replace('*', '').replace('\\', '').replace('%', '')   class CursorWrapper(DjangoCursorWrapper):    def execute(self, sql, params=None):        path = getattr(thread_locals, 'path', '')        if path:            path = make_safe(path)            sql = f'/* {path} */\n{force_text(sql)}\n/* {path} */'         with calc_sql_time(sql):            return super().execute(sql, params)

В результате лог выполнения приложения будет выглядеть следующим образом:

Продолжительность SQL-запроса /* /admin/auth/user/ */ SELECT "django_session"."session_key", "django_session"."session_data", "django_session"."expire_date" FROM "django_session" WHERE ("django_session"."expire_date" > %s AND "django_session"."session_key" = %s) LIMIT 21 /* /admin/auth/user/ */ - 0.001 сек. Продолжительность SQL-запроса /* /admin/auth/user/ */ SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = %s LIMIT 21 /* /admin/auth/user/ */ - 0.001 сек. Продолжительность SQL-запроса /* /admin/auth/user/ */ SELECT "auth_group"."id", "auth_group"."name" FROM "auth_group" ORDER BY "auth_group"."name" ASC /* /admin/auth/user/ */ - 0.001 сек. Продолжительность SQL-запроса /* /admin/auth/user/ */ SELECT COUNT(*) AS "__count" FROM "auth_user" /* /admin/auth/user/ */ - 0.000 сек. Продолжительность SQL-запроса /* /admin/auth/user/ */ SELECT COUNT(*) AS "__count" FROM "auth_user" /* /admin/auth/user/ */ - 0.000 сек. Продолжительность SQL-запроса /* /admin/auth/user/ */ SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" ORDER BY "auth_user"."username" ASC /* /admin/auth/user/ */ - 0.000 сек. Продолжительность запроса /admin/auth/user/ - 0.071 сек. [29/Jun/2020 12:19:15] "GET /admin/auth/user/ HTTP/1.1" 200 6608 Продолжительность SQL-запроса /* /admin/jsi18n/ */ SELECT "django_session"."session_key", "django_session"."session_data", "django_session"."expire_date" FROM "django_session" WHERE ("django_session"."expire_date" > %s AND "django_session"."session_key" = %s) LIMIT 21 /* /admin/jsi18n/ */ - 0.001 сек. Продолжительность SQL-запроса /* /admin/jsi18n/ */ SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = %s LIMIT 21 /* /admin/jsi18n/ */ - 0.001 сек. Продолжительность запроса /admin/jsi18n/ - 0.009 сек. [29/Jun/2020 12:19:15] "GET /admin/jsi18n/ HTTP/1.1" 200 3223 Продолжительность SQL-запроса /* /admin/auth/user/ */ SELECT "django_session"."session_key", "django_session"."session_data", "django_session"."expire_date" FROM "django_session" WHERE ("django_session"."expire_date" > %s AND "django_session"."session_key" = %s) LIMIT 21 /* /admin/auth/user/ */ - 0.001 сек. Продолжительность SQL-запроса /* /admin/auth/user/ */ SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = %s LIMIT 21 /* /admin/auth/user/ */ - 0.001 сек. Продолжительность SQL-запроса /* /admin/auth/user/ */ SELECT "auth_group"."id", "auth_group"."name" FROM "auth_group" ORDER BY "auth_group"."name" ASC /* /admin/auth/user/ */ - 0.000 сек. Продолжительность SQL-запроса /* /admin/auth/user/ */ SELECT COUNT(*) AS "__count" FROM "auth_user" /* /admin/auth/user/ */ - 0.000 сек. Продолжительность SQL-запроса /* /admin/auth/user/ */ SELECT COUNT(*) AS "__count" FROM "auth_user" /* /admin/auth/user/ */ - 0.000 сек. Продолжительность SQL-запроса /* /admin/auth/user/ */ SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" ORDER BY "auth_user"."username" ASC /* /admin/auth/user/ */ - 0.000 сек. [29/Jun/2020 12:19:18] "GET /admin/auth/user/ HTTP/1.1" 200 6608 Продолжительность запроса /admin/auth/user/ - 0.045 сек. Продолжительность SQL-запроса /* /admin/jsi18n/ */ SELECT "django_session"."session_key", "django_session"."session_data", "django_session"."expire_date" FROM "django_session" WHERE ("django_session"."expire_date" > %s AND "django_session"."session_key" = %s) LIMIT 21 /* /admin/jsi18n/ */ - 0.002 сек. Продолжительность SQL-запроса /* /admin/jsi18n/ */ SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."id" = %s LIMIT 21 /* /admin/jsi18n/ */ - 0.001 сек. Продолжительность запроса /admin/jsi18n/ - 0.013 сек. [29/Jun/2020 12:19:19] "GET /admin/jsi18n/ HTTP/1.1" 200 3223 

Где можно посмотреть добавленный контекст далее? Например, при просмотреть отчета pgBadger. Следует отметить, что таким образом можно пробросить не только url, но и другие атрибуты, например пользователя, который выполняется запрос.

В репозитории результат выполнения этой части можно посмотреть по метке stage_03.

Логируем дополнительные параметры

Если мы говорим об анализе приложения в production, то отдельно взятый SQL-запрос нам не всегда интересен. Зачастую важнее увидеть статистику того, сколько SQL-запросов выполнено в рамках того или иного запроса, а также их суммарное время выполнения.

Чтобы решить данную задачу — модифицируем созданные ранее компоненты. Для начала, добавим новые параметры в middleware:

polls/middleware.py

# ...  class RequestTimeMiddleware:    # ...     def __call__(self, request):        thread_locals.path = request.path        thread_locals.sql_count = 0        thread_locals.sql_total = 0        timestamp = time.monotonic()         response = self.get_response(request)         print(            f'Продолжительность запроса {request.path} - '            f'{time.monotonic() - timestamp:.3f} сек. '            f'Количество SQL-запросов - {thread_locals.sql_count}. '            f'Продолжительность SQL-запросов - {thread_locals.sql_total:.3f}.'        )         thread_locals.sql_total = 0        thread_locals.sql_count = 0        thread_locals.path = ''         return response

После этого реализуем заполнение данных атрибутов на уровне курсора:

polls/base.py

# ...  @contextmanager def calc_sql_time(sql):    timestamp = time.monotonic()     yield     if hasattr(thread_locals, 'sql_count'):        thread_locals.sql_count += 1        thread_locals.sql_total += time.monotonic() - timestamp

В результате выполненных изменений лог запросов к приложению будет выглядеть следующим образом:

Продолжительность запроса /admin/ - 0.041 сек. Количество SQL-запросов - 3. Продолжительность SQL-запросов - 0.004. [29/Jun/2020 13:44:49] "GET /admin/ HTTP/1.1" 200 3721 Продолжительность запроса /admin/polls/question/ - 0.031 сек. Количество SQL-запросов - 5. Продолжительность SQL-запросов - 0.004. [29/Jun/2020 13:44:56] "GET /admin/polls/question/ HTTP/1.1" 200 3179 [29/Jun/2020 13:44:56] "GET /static/admin/js/vendor/jquery/jquery.js HTTP/1.1" 200 287630 [29/Jun/2020 13:44:56] "GET /admin/jsi18n/ HTTP/1.1" 200 3223 Продолжительность запроса /admin/jsi18n/ - 0.011 сек. Количество SQL-запросов - 2. Продолжительность SQL-запросов - 0.002.

В реальных кейсах отслеживаемых атрибутов может быть гораздо больше. Например:

  • количество SQL-запросов в разрезе типов (SELECT, INSERT, UPDATE, DELETE)
  • время выполнения SQL-запросов в разрезе типов
  • количество повторяющихся SQL-запросов
  • подсчет количества и времени транзакций
  • алиасы базы, в которую выполняются SQL-запросы (для случая использования нескольких БД)

В репозитории результат выполнения этой части можно посмотреть по метке stage_04.

Сохраняем данные для последующего анализа

Статистика нужна, чтобы потом ее проанализировать. Соответственно, текущий подход с выводом данных в консоль не подходит. Реализуем подход с сохранением в файл. Для этого модифицируем middleware:

polls/base.py

import json  # ...  class RequestTimeMiddleware:    # ...     def __call__(self, request):        # ...         response = self.get_response(request)         data = {            'path': request.path,            'request_total': round(time.monotonic() - timestamp, 3),            'sql_count': round(thread_locals.sql_count, 3),            'sql_total': round(thread_locals.sql_total, 3),        }         with open('request.log', 'a') as f:            f.write(json.dumps(data) + '\n')         thread_locals.sql_total = 0        thread_locals.sql_count = 0        thread_locals.path = ''         return response

В результате в файле request.log будут строки следующего вида:

{"path": "/admin/", "request_total": 0.025, "sql_count": 3, "sql_total": 0.004} {"path": "/admin/polls/question/", "request_total": 0.029, "sql_count": 5, "sql_total": 0.002} {"path": "/admin/jsi18n/", "request_total": 0.01, "sql_count": 2, "sql_total": 0.003} {"path": "/admin/", "request_total": 0.027, "sql_count": 3, "sql_total": 0.003} {"path": "/admin/auth/user/", "request_total": 0.047, "sql_count": 6, "sql_total": 0.004} {"path": "/admin/jsi18n/", "request_total": 0.011, "sql_count": 2, "sql_total": 0.003}

В репозитории результат выполнения этой части можно посмотреть по метке stage_05.

Логируем время на клиенте (js)

В некоторых случаях полезно будет знать еще и время выполнения с точки зрения клиента. Для этого нам необходимо зафиксировать момент отправки запроса, а также вычислить продолжительность при поступлении ответа. Рассмотрим вариант, когда приложение представляет из себя SPA. Для начала, реализуем обработчик XHR-запросов:

polls/static/xhr_interceptor.js

'use strict';  /** * Позволяет выполнить обработку XHR-запросов и ответов на стороне клиента */ var XhrInterceptor = {   /**    * Публичные атрибуты и методы    */    addRequestCallback: function (callback) {       // Добавляет обработчик к запросу       this.requestCallbacks.push(callback);   },    removeRequestCallback: function (callback) {       // Удаляет обработчик запроса       this.arrayRemove(this.requestCallbacks, callback);   },    addResponseCallback: function (callback) {       // Добавляет обработчик к ответу       this.responseCallbacks.push(callback);   },    removeResponseCallback: function (callback) {       // Удаляет обработчик ответа       this.arrayRemove(this.responseCallbacks, callback);   },    /**    * Подключает XhrInterceptor к обработке xhr    */   wire: function () {       if (this.wired) throw new Error("Ajax interceptor already wired");        // Перегрузка метода отправки всех xhr       XMLHttpRequest.prototype.send = function () {            // Запустим обработчики запроса до его отправки           XhrInterceptor.fireCallbacks(XhrInterceptor.requestCallbacks, this);            // Подключаем обработчики ответов           if (this.addEventListener) {               var xhr = this;               this.addEventListener("readystatechange", function () {                   XhrInterceptor.fireResponseCallbacksIfCompleted(xhr);               }, false);           }           else {               XhrInterceptor.proxifyOnReadyStateChange(this);           }            XhrInterceptor.RealXHRSend.apply(this, arguments);       };       this.wired = true;   },    /**    * Отключает XhrInterceptor от обработки xhr    */   unwire: function () {       if (!this.wired) throw new Error("Ajax interceptor not currently wired");       XMLHttpRequest.prototype.send = this.RealXHRSend;       this.wired = false;   },    isWired: function () {       // Возвращает признак того, подключен ли XhrInterceptor       return this.wired;   },    /**    * Внутренние атрибуты и методы    */   // Неизмененный метод отправки xhr-запроса   RealXHRSend: XMLHttpRequest.prototype.send,   // Обработчики запроса   requestCallbacks: [],   // Обработчики ответа   responseCallbacks: [],   // Признак подключения к обработке   wired: false,    /**    * Удаляет элемент из массива    */   arrayRemove: function (array, item) {       var index = array.indexOf(item);       if (index > -1) {           array.splice(index, 1);       } else {           throw new Error("Could not remove " + item + " from array");       }   },   /**    * Вызывает обработчики    */   fireCallbacks: function (callbacks, xhr) {       for (var i = 0; i < callbacks.length; i++) {           callbacks[i](xhr);       }   },   /**    * Вызывает обработчики ответа, если запрос завершен    */   fireResponseCallbacksIfCompleted: function (xhr) {       if (xhr.readyState === XMLHttpRequest.DONE) {           this.fireCallbacks(this.responseCallbacks, xhr);       }   },   /**    * Дополнительное проксирование успешного завершения запроса для случая,    * когда добавить listener нельзя    */   proxifyOnReadyStateChange: function (xhr) {       var realOnReadyStateChange = xhr.onreadystatechange;       if (realOnReadyStateChange) {           xhr.onreadystatechange = function () {               this.fireResponseCallbacksIfCompleted(xhr);               realOnReadyStateChange();           };       }   } }; 

Далее воспользуемся реализованным обработчиком, чтобы добавить необходимые данные к запросу:

polls/static/request_client.js

function generate_request_guid() {   function s4() {       return Math.floor((1 + Math.random()) * 0x10000)           .toString(16)           .substring(1);   }    return s4() + s4() + s4(); }  var clientUUID = generate_request_guid();  XhrInterceptor.addRequestCallback(function(xhr) {   try {       xhr.setRequestHeader('UUID', clientUUID + '#' + generate_request_guid());       xhr.setRequestHeader('C-STARTED', Date.now());   } catch (e) {       console.log("Can't set request headers. Details:", e.error.toString());   } });

После этого обработаем ответ, когда он вернется:

polls/static/request_client.js

var logStore = []; var urlForExclude = '';  XhrInterceptor.addResponseCallback(function(xhr) {   try {       var req_uuid = xhr.getResponseHeader('UUID');       var req_started = xhr.getResponseHeader('C-STARTED');        if (req_uuid && req_started && !xhr.responseURL.includes(urlForExclude)) {           req_started = parseInt(req_started, 10);            var s_total = parseFloat(xhr.getResponseHeader('REQUEST-TOTAL') || 0);            var record = {               'uuid': req_uuid,               'c_total': Math.max(Date.now() - req_started, s_total, 0),               'started': xhr.getResponseHeader('STARTED') || '',               'path': xhr.getResponseHeader('PATH') || '',               'total': s_total,               'sql_count': parseInt(xhr.getResponseHeader('SQL-COUNT') || 0),               'sql_total': parseFloat(xhr.getResponseHeader('SQL-TOTAL') || 0),           };            record['tr_total'] = (record['c_total'] - record['total']).toFixed(4);           logStore.push(record);       }    } catch (e) {       console.log("Can't process response. Details:", e.error.toString());   } }); 

Затем подключим обработчики к запросам и настроим отправку собранных данных на клиент:

polls/static/request_client.js

// Подключаемся к запросам XhrInterceptor.wire();  function startLogging(clientLogUrl, timeout = 10000) {   function sendRequestStats() {       if (logStore.length) {           var log_part = JSON.stringify(logStore.slice());           logStore = [];            var xhttp = new XMLHttpRequest();           xhttp.open("POST", clientLogUrl, true);           xhttp.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');           xhttp.send("logs=" + log_part);       }   }    urlForExclude = clientLogUrl;   setInterval(sendRequestStats, timeout); } 

Поскольку мы добавили новые атрибуты — необходимо отразить их в middleware:

polls/middleware.py

....  def __call__(self, request):    thread_locals.path = request.path    thread_locals.sql_count = 0    thread_locals.sql_total = 0    timestamp = time.monotonic()     response = self.get_response(request)     data = {        'uuid': request.META.get('HTTP_UUID'),        'c_started': request.META.get('HTTP_C_STARTED'),        'path': request.path,        'request_total': round(time.monotonic() - timestamp, 3),        'sql_count': thread_locals.sql_count,        'sql_total': round(thread_locals.sql_total, 3),    }     for key, value in data.items():        response[key.capitalize().replace("_", "-")] = value     thread_locals.sql_total = 0    thread_locals.sql_count = 0    thread_locals.path = ''     return response

Для сохранения данных на стороне сервера нам понадобится view:

polls/views.py

import json  from django.http import HttpResponse from django.template import loader from django.views.decorators.csrf import csrf_exempt   @csrf_exempt def save_client_log(request):   """   Сохраняет собранные данные с клиента в лог   """   logs = request.POST.get('logs', '[]')   with open('request.log', 'a') as f:      for log_str in json.loads(logs):          f.write(json.dumps(log_str) + '\n')    return HttpResponse()

Для проверки решения создадим две view: одна будет возвращать страницу с кнопкой для асинхронного запроса, вторая — обрабатывать асинхронный запрос и возвращать время:

polls/views.py

import datetime  ....  def get_page_with_button(request):    """    Возвращает страницу с кнопкой    """    template = loader.get_template('polls/index.html')     return HttpResponse(template.render({}, request))   def get_current_datetime(request):    """    Возвращает текущую дату и время    """    return HttpResponse(datetime.datetime.now())

Шаблон страницы будет выглядеть следующим образом:

polls/templates/polls/index.html

<!DOCTYPE html> <html lang="en"> <head>     <meta charset="UTF-8">     {% load static %}     <script src="{% static 'xhr_interceptor.js' %}"></script>     <script src="{% static 'request_client.js' %}"></script>     <script>         function getDateTime() {             var xhttp = new XMLHttpRequest();             xhttp.onreadystatechange = function() {                 if (this.readyState == 4 && this.status == 200) {                     document.getElementById("datetime").innerHTML = this.responseText;                 }             };             xhttp.open("GET", "{% url 'current_datetime' %}", true);             xhttp.send();         }     </script>     <script>         startLogging("{% url 'save_client_log' %}");     </script>      <title>Title</title> </head> <body>  <button type="button" onclick="getDateTime()">Запросить дату и время</button>  <div id="datetime">  </div>  </body> </html> 

Созданные view нужно зарегистрировать в urls.py:

polls/urls.py

from django.urls import path  from . import views   urlpatterns = [    path('', views.get_page_with_button, name='page_with_button'),    path('current-datetime', views.get_current_datetime, name='current_datetime'),    path('save-client-log', views.save_client_log, name='save_client_log'), ] 

После запуска приложения перейдем на http://127.0.0.1:8000/polls/, сделаем несколько запросов и убедимся, что в файле request.log появились необходимые строки. В репозитории результат выполнения этой части можно посмотреть по метке stage_06.

Делаем представление данных (визуализация)

Собранные данные в сыром виде разбирать и анализировать не очень удобно. Тем более, что, зачастую, отдельно взятый запрос не интересен. Гораздо важнее увидеть агрегированную статистику для понимания текущего состояния приложения или его состояния в определенный момент времени. Для того, чтобы получить подобное представление данных — воспользуемся стеком ELK. Для установки можно обратиться к данному документу или выбрать одну из множества инструкции в сети.

После установки используем logstash для загрузки данных в elasticsearch, настроив конфиг по следующему шаблону:

input {   file {     path => "/путь/до/логов/**/*.log"     start_position => "beginning"   } } filter {   json {       source => "message"   }   date {       match => [ "[started]", "yyyy-MM-dd HH:mm:ss'.'SSSSSS"]   } } output {   elasticsearch {     hosts => "http://адрес-elasticsearch"     manage_template => false     index => "requests-%{+YYYY.MM.dd}"     document_type => "%{[@metadata][type]}"   } } 

Если для сбора используется filebeat, то input следует заменить на следующий:

input {   beats {     port => "5044"     ssl  => false   } }

После загрузки данных вариантов работы с ними — великое множество. Можно настроить сборку визуализаций, собрать их в dashboard’ы; можно сделать выборки по конкретным атрибутам и их значениям. Выбранные подходы зависит от конкретных кейсов, которые предстоит решить. Вот несколько примеров того, что можно собрать на основе собранных данных:


Заключение

В рамках статьи мы рассмотрели варианты логирования параметров запросов. За кадром остались различные варианты параметров и аналитик по ним. Их можно будет найти в пакете production-request, который используется на нескольких production-приложениях. По мере разбора различных кейсов пакет дорабатывается улучшается. В планах — перенос пакета на github.

ссылка на оригинал статьи https://habr.com/ru/company/barsgroup/blog/523068/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *