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

  • Tutorial


Привет, меня зовут Дмитрий Чернышов, я ведущий разработчик компании «БАРС Груп». Последние несколько лет среди прочих задач нам в команде приходится сталкиваться с необходимостью анализа производительности приложений на 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.
БАРС Груп
Создаем технологии. Меняем жизнь.

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

    +1
    Логи к базе можно включить куда проще — 'django.db.backends': {'level': 'DEBUG', 'handlers': ['console'], }, в настройках логирования. А для задачи сбора метрик есть django-prometheus.
      0
      Плюс к этому django-prometheus вполне умеет собирать данные по запросам (есть две MW) и вполне подключается к моделям и начинает считать время запросов и их кол-во. Ну про логирование через print() я вообще промолчу =)

      Страсть к велосипедостроению наблюдаю я в этом посте.

      Ну и дальше IMHO — не думаю, что лог SQL-запросов это хорошая идея в production, а время запроса итак есть в логах nginx и uwsgi.
        +1
        Подключение к моделям в django-prometheus действительно есть, вот только нам не очень подходит из-за необходимости подключать доп.класс к моделям. Кроме того, насколько я помню, он собирает метрики в разрезе именно моделей, а нам необходимо подсчет относительно запросов пользователей.

        По поводу велосипедостроения — возможно, но задачи продуктов пакет решает. Но никогда не лишним будет узнать иное мнение и сделать лучше.

        Про лог SQL-запросов в production — пакет, указанный в конце статьи, этого тоже не делает, для этого мы используем сторонние инструменты, в том числе упомянутый в статье pgBadger.
        0
        Логи к базе действительно можно включить через настройку backend'а. В статье этот блок дан не для того, чтобы показать, что их нужно включать именно так, а скорее для того, чтобы поэтапно перейти к сбору статистики по запросам.

        Да, django-prometheus действительно хороший инструмент. К сожалению, на момент, когда нам понадобился сбор метрик — он не подошел нам по ряду причин:
        — доп.пакеты, которые нежелательно было тянуть в окружение
        — нужны были связи с дополнительными метриками и логами
        — необходимость раскладывать логи в разные форматы для последующей обработки

        Возможно, стоит на него еще раз взглянуть, поскольку сейчас часть требований стала неактуальна. Спасибо!
        +1

        Мне почему-то кажется, что автор пытается переизобрести opentracing, sentry (beta), или opencensus (beta)…


        Теперь, о elasticsearch — если есть необходимость, можно реализовать, или использовать готовый обработчик логов, который сразу будет слать логи в elk.
        Например, django-structlog. В нем реализовано почти все, кроме генерации traceid — нужно будет добавлять менеджеры контекста и/или middleware, там где необходимо.


        Список того, что делать не следует:


        • Не логгируйте с помощью print — логи должны приходить туда, где их ожидают. При print, нужно вручную отслеживать контекст приложения, добавлять форматирование, а также передавать io.Stream, если у вас более одного обработчика логов.
        • Не сериализуйте в prod среде с помощью встроенного json. Он достаточно медленный, если вводные данные не являются объектом io.Stream. Есть достаточное количество оберток над native-библиотеками (orjson, ujson, rapidjson).
          +1
          Нет, мы не пытаемся изобрести sentry или opentracing, хотя подход в middleware очень напоминает последний. По поводу opencensus — интересно, посмотрим, спасибо!

          Про elasticsearch и способы логирования — да, действительно, можно сделать все совсем иначе, не как в статье, но здесь блок про elasticsearch дан как пример.

          Про логирование через print: в production-коде — да, в небольшом примере — допустимо, как мне кажется, суть от его использования не меняется. Опять, в итоговом пакете print не используется.

          Про сериализацию с помощью json — соглашусь, но в нашем случае сериализация словаря из десятка параметров не несет критичной просадки.
          0
          Очень интересный материал, спасибо!

          Но есть новичковый вопрос: в первом же фрагменте кода никак не могу понять, мы же не наследуем RequestTimeMiddleware ни от какого базового класса, откуда тогда берется self.get_response(request)?
            0
            get_response передается в __init__ как параметр и дальше используется как атрибут объекта. Если вопрос в том, как он попадает в __init__ — то это базовое поведение middleware в django.
              0
              Спасибо, я просто тупой: вместо
              def __init__(self, get_response)
              на рефлексах прочитал
              def __init__(self, response)
              со всеми вытекающими.
            0
            APM из ELK стека не подошел?
              0
              Нет, поскольку ELK не является первичной целью, только одним из способов отображения. Но да, в целом штука удобная даже в free-поставке.

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

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