∿Метрики и
наблюдаемость
Главы темы 1
1.5

Логи: запись о событии и её контекст

В главе разобрано, из каких полей состоит полезная запись о событии, как работает модуль logging в Python, зачем писать логи в JSON и как читать traceback настоящей ошибки сервиса products-api.

Главное в главе

Запись в логе полезна, только если по ней можно найти запрос и понять, чем он закончился.

Раздел
Основы наблюдаемости
Уровень
Начальный · Python знаком
Связанные главы
1.3 — телеметрия, 1.4 — метрики
Зачем это знать
Писать записи, по которым можно найти конкретный запрос и понять, что с ним случилось

§1Лог: запись о событии

Глава 1.4 показала, что метрика сообщает масштаб: за пять минут 5% запросов вернули ошибку. Какая это ошибка, у каких запросов и в какой строке кода она возникла, метрика не хранит. Для этого нужна запись о каждом событии.

Сравнение из жизни
Журнал вахтёра

Вахтёр записывает в журнал каждое происшествие: время, место, что случилось. Через месяц по журналу находят, кто и когда приходил в здание ночью.

Лог сервиса

Сервис записывает каждое событие: время, операцию, результат. Через неделю по записям находят, что случилось с конкретным запросом.

Лог (журнал событий) — последовательность записей о событиях, произошедших в программе. Каждая запись фиксирует, когда что-то произошло, где и с каким результатом.

Событие — это завершение HTTP-запроса, вход пользователя, неудачная попытка подключения к базе, запуск фоновой задачи. Какие события записывать, решает разработчик, и от этого решения зависит, что удастся выяснить при расследовании.

Ниже две записи об одном и том же запросе.

Без контекста

Непонятно, о каком запросе речь

Лог
Request processed

Видно только, что выполнение дошло до вызова логирования. Когда это было, какой запрос обработан и успешно ли — неизвестно. Если за минуту таких строк тысяча, связать хоть одну с жалобой пользователя невозможно.

С контекстом

Запись можно найти и понять

Лог
2026-09-22T13:23:26.548Z ERROR products-api
request_completed route=/products
status=500 duration_ms=76.503
request_id=1cf09e6f…

Время, уровень, сервис, тип события, маршрут, результат, длительность и идентификатор запроса. Получив идентификатор от пользователя, запись находят поиском.

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

§2Какие поля нужны записи

Состав полей определяют вопросы, на которые должна отвечать запись. Для веб-сервиса набор почти всегда один и тот же. Ниже запись сервиса о запросе, завершившемся ошибкой. Выберите поле, чтобы прочитать, что оно даёт.

Запись о событии по полям
{"timestamp": "2026-09-22T13:23:26.548004+00:00", "level": "ERROR",
 "service": "products-api", "event": "request_completed", "status": 500,
 "request_id": "1cf09e6f86f04fa7b6b4941d75d388e9", "duration_ms": 76.503}

Выберите поле, чтобы увидеть, на какой вопрос оно отвечает и чего не гарантирует.

Рис. 1.5.1 запись о запросе, завершившемся ошибкой

Кнопки над записью выбирают поле. Под записью появляется пояснение: на какой вопрос поле отвечает и чего не гарантирует. Запись взята со снимка сценария error ниже.

ПолеНа какой вопрос отвечаетПример
timestampКогда произошло событие2026-09-22T13:23:26.548004+00:00
levelНасколько событие важноERROR
serviceКакой компонент создал записьproducts-api
eventЧто произошлоrequest_completed
route, statusКакая операция и с каким результатом/products, 500
duration_msСколько длилась операция и в каких единицах76.503
request_idК какому запросу относится запись1cf09e6f86f04fa7b6b4941d75d388e9

Про единицы. Поле duration со значением 72 можно понять как секунды, миллисекунды или микросекунды. Если единица записана в имени поля, как в duration_ms, ошибиться нельзя. Время события записывается в UTC с явным смещением +00:00, чтобы записи с разных серверов сравнивались без пересчёта.

В рабочих сервисах к этому набору добавляют версию приложения и имя экземпляра. Без версии не понять, появилась ли ошибка после выпуска. Без экземпляра не заметить, что сбоит только один сервер из трёх.

Одно из полей, level, определяет, попадёт ли запись в журнал вообще.

§3Уровни важности

У каждой записи есть уровень важности. По нему поток фильтруют: в обычной работе смотрят только предупреждения и ошибки, а при отладке включают подробные сообщения. В модуле logging пять стандартных уровней, у каждого есть числовое значение.

УровеньЧислоКогда использоватьПример
DEBUG10Подробности для отладки; в рабочей среде обычно выключеныЗначения параметров запроса к базе
INFO20Обычные события, подтверждающие работуЗапрос обработан, задача запущена
WARNING30Что-то пошло не так, но операция выполненаЗапрос длился дольше порога, выполнен повтор
ERROR40Операция не выполненаИсключение при обращении к зависимости
CRITICAL50Приложение не может продолжать работуНе удалось прочитать обязательную настройку при запуске

Логгер пропускает записи, уровень которых не ниже заданного. При уровне INFO записи DEBUG отбрасываются, а INFO, WARNING, ERROR и CRITICAL проходят. Сравнение идёт по числу: порог INFO равен 20, уровень DEBUG равен 10 и порог не проходит.

Поля и уровни разобраны. Следующий раздел показывает, как записи создаются в коде.

§4Модуль logging в Python

Для записи логов в Python есть стандартный модуль logging. Им пользуются и сервис стенда, и Uvicorn. Минимальная настройка показана на отдельном примере.

Python · log_basic.py
import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("telemetry_lab")

logger.debug("подробности для отладки")
logger.info("request_completed status=%s duration_ms=%s", 200, 82.627)
logger.warning("slow_request duration_ms=%s", 3022.741)
Вывод в терминале
2026-09-15 16:17:58,865 INFO telemetry_lab request_completed status=200 duration_ms=82.627
2026-09-15 16:17:58,865 WARNING telemetry_lab slow_request duration_ms=3022.741
СтрокаЧто делает
logging.basicConfigНастраивает корневой логгер: куда писать и в каком виде. Без аргументов записи идут в стандартный поток ошибок
level=logging.INFOЗадаёт порог. Поэтому строка debug в вывод не попала: её уровень 10 ниже порога 20
formatШаблон строки: %(asctime)s — время, %(levelname)s — уровень, %(name)s — имя логгера, %(message)s — текст
logging.getLogger("telemetry_lab")Возвращает именованный логгер. Имя попадает в запись и показывает, какая часть программы её создала
logger.info("…%s", 200, …)Значения переданы отдельными аргументами, без f-строки. Модуль подставит их в %s, только если запись пройдёт порог
Так строка строится зря

f-строка

Python
logger.debug(f"данные: {big_object}")

Python сначала соберёт строку, а логгер её отбросит, если DEBUG выключен.

Так строка не строится

аргументы логгера

Python
logger.debug("данные: %s", big_object)

Если запись не проходит порог, подстановка не выполняется.

Время в выводе записано по местному времени машины и без часового пояса: 2026-09-15 16:17:58,865. Для одного компьютера этого достаточно, для нескольких серверов нет. Поэтому сервис стенда формирует время события через datetime.now(timezone.utc).

Первоисточник: Python · logging ↗

Пример выводит записи строкой текста. Чтобы по записям можно было искать, их формат делают строже.

§5Структурированные записи в JSON

Строку status=200 duration_ms=82.627 человек прочитает, а программе её придётся разбирать. Если в тексте сообщения встретится пробел или знак равенства, разбор сломается. Поэтому в рабочих сервисах записи часто пишут в JSON: каждое поле становится ключом объекта.

Python · log_json.py
import json
import logging

logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("telemetry_lab")

event = {
    "event": "request_completed",
    "route": "/products",
    "status": 200,
    "duration_ms": 82.627,
    "request_id": "e14dff1e8db440709a2640f599322917",
}
logger.info(json.dumps(event, ensure_ascii=False))
Вывод в терминале
{"event": "request_completed", "route": "/products", "status": 200, "duration_ms": 82.627, "request_id": "e14dff1e8db440709a2640f599322917"}

Запись собрана в словарь, а json.dumps превращает его в строку JSON. Параметр ensure_ascii=False оставляет русские буквы как есть, без замены на коды вида \u0442. В формате логгера оставлено только %(message)s, чтобы каждая строка вывода была корректным JSON.

Польза видна при поиске. Системы хранения логов разбирают JSON на поля, и запрос «все записи, где status равен 500 и route равен /products» выполняется точно. С текстовыми строками пришлось бы писать регулярное выражение и надеяться, что формат сообщения нигде не отличается.

В контейнерах логи пишут в стандартный вывод процесса. Docker и Kubernetes собирают вывод каждого контейнера, а отдельный агент пересылает записи в хранилище. Приложению не нужно самому управлять файлами логов и их ротацией.

Формат записи готов. Осталось разобрать записи, которые при расследовании читают чаще других: записи об ошибках.

§6Исключение и traceback

При расследовании чаще других нужны записи об ошибках. Для них у логгера есть метод logger.exception: он добавляет к сообщению полный traceback текущего исключения.

Traceback — отчёт Python о цепочке вызовов, которая привела к исключению. Он показывает, в каком файле, строке и функции возникла ошибка и через какие вызовы выполнение туда пришло.

Python · log_exception.py
import logging

logging.basicConfig(format="%(levelname)s %(message)s")
logger = logging.getLogger("telemetry_lab")


def load_products():
    raise TimeoutError("dependency timeout")


try:
    load_products()
except TimeoutError:
    logger.exception("dependency_timeout request_id=%s", "1cf09e6f86f04fa7b6b4941d75d388e9")
Вывод в терминале · путь к файлу сокращён
ERROR dependency_timeout request_id=1cf09e6f86f04fa7b6b4941d75d388e9
Traceback (most recent call last):
  File ".../log_exception.py", line 12, in <module>
    load_products()
  File ".../log_exception.py", line 8, in load_products
    raise TimeoutError("dependency timeout")
TimeoutError: dependency timeout

Traceback читают снизу вверх.

  1. Последняя строка — что случилось

    Тип исключения и сообщение: TimeoutError: dependency timeout.

  2. Над ней — где случилось

    Файл, строка 8, функция load_products и сам код строки.

  3. Ещё выше — откуда пришли

    Строка 12, код верхнего уровня <module>. Так восстанавливается путь выполнения до ошибки.

Первая строка вывода — сообщение, переданное в logger.exception. Метод всегда пишет запись уровня ERROR. Вызывают его только внутри блока except: вне обработки исключения traceback взять неоткуда.

В Python 3.12 traceback содержит абсолютный путь к файлу, например /home/deploy/app/log_exception.py, и в журнал попадает устройство файловой системы сервера. Сервис стенда перед сохранением убирает путь к папке проекта и оставляет только имя файла.

Traceback разобран на отдельном примере. Следующий раздел находит его в записи сервиса стенда.

§7Ошибка 500 на стенде

Теперь ошибка на стенде: на странице стенда запущен сценарий error. Обработчик ждёт зависимость, поднимает TimeoutError, перехватывает его, записывает событие и возвращает клиенту ответ 500.

Рис. 1.5.2 сценарий error на странице стенда

Тот же обработчик, что в сценариях fast и slow. Отличаются код ответа, тело ответа, уровень записи и поле exception.

  1. Строка итога. HTTP 500, клиент ждал 78,4 мс.
  2. Ответ клиенту. только краткое описание и request_id, без traceback.
  3. level. уровень записи ERROR.
  4. exception. полный traceback в одну строку JSON: переносы заменены на \n.
  5. TRACE. два этапа вместо трёх: исключение прервало выполнение до serialize.
Открыть в полном размере ↗

Строка итога: HTTP 500 · клиент: 78.4 ms · обработчик: 76.5 ms. Ошибка вернулась быстрее успешного быстрого ответа, потому что этап serialize не выполнялся.

Блок HTTP — то, что получил клиент: объект detail с кратким описанием simulated dependency timeout и request_id. Traceback клиенту не отправляется: пользователю внутреннее устройство сервиса не нужно, а злоумышленнику сведения о коде могут пригодиться.

Блок LOG содержит полную запись: уровень ERROR, код 500 и сценарий error. В поле exception — traceback, записанный в одну строку JSON. Снизу вверх: TimeoutError: Simulated dependency timeout; no database is connected возникла в файле app.py, строка 55, функция products.

Блок TRACE показывает только два этапа: validate — 11,1 мс и dependency.wait (simulated) — 61,1 мс. Третьего этапа нет: исключение прервало выполнение до него.

Ответ пользователю и запись в логе связывает request_id. Клиент получает идентификатор 1cf09e6f… в теле ответа и в заголовке X-Request-ID. Пользователь пересылает его в поддержку. Разработчик ищет по нему запись в хранилище логов и находит полный traceback, которого у пользователя нет.

Python · обработка ошибки в app.pyФайл целиком ↓
try:
    await stage("dependency.wait (simulated)", 3.0 if scenario == "slow" else 0.06)
    if scenario == "error":
        raise TimeoutError("Simulated dependency timeout; no database is connected")
    await stage("serialize", 0.01)
except TimeoutError:
    status = 500
    exception_text = traceback.format_exc().replace(str(Path(__file__).parent) + os.sep, "")
    logger.exception("dependency_timeout request_id=%s", request_id)

traceback.format_exc() возвращает текст traceback текущего исключения строкой. Метод replace убирает из него путь к папке проекта. Строка попадает в поле exception записи, а logger.exception дополнительно выводит сообщение с traceback в терминал Uvicorn.

Первоисточник: Python · traceback ↗

В логах есть запись о каждом запросе. Следующий раздел проверяет, можно ли по ним считать то же, что считают метрики.

§8Можно ли считать метрики по логам

Раз в логах есть запись о каждом запросе, из них можно получить и количества. В файле events.json записанного прогона есть записи обо всех двадцати запросах, и расчёт сделан по нему.

Python · доля ошибок по записям
import json

with open("results/events.json") as file:
    events = json.load(file)

errors = sum(1 for event in events if event["status"] >= 500)
print("событий:", len(events))
print("ошибок:", errors)
print(f"доля: {errors / len(events):.1%}")
Вывод в терминале
событий: 20
ошибок: 1
доля: 5.0%

Результат совпал с расчётом по CSV из главы 1.4, потому что выполнены два условия: каждый запрос записан ровно один раз, и в записи есть нужное поле. На стенде событие request_completed создаётся один раз в конце обработчика.

Пример

Почему подсчёт строк с ERROR даёт другое число

Первый запрос трижды обращался к зависимости: две попытки завершились ошибкой, третья успешно. Каждая неудачная попытка записала строку ERROR. Второй запрос упал сразу, без повторов, и тоже записал ERROR.

В логе три строки ERROR. Неудачных попыток обращения к зависимости тоже три, а неуспешный запрос один. Три числа отвечают на три разных вопроса. Чтобы считать по логам неуспешные запросы, нужно событие, которое пишется один раз на запрос и содержит итоговый статус.

Даже с правильными событиями расчёт метрик по логам требует много места. Сервис с 1000 запросов в секунду и записью по 500 байт создаёт около 43 ГБ логов в сутки, а метрика с тем же смыслом занимает несколько рядов. Поэтому доли и длительности обычно считают метриками, а логи держат для подробностей.

Логи хранят подробности, и из-за этого у них есть ограничение, которого нет у метрик.

§9Чего не должно быть в логах

Логи читают многие: разработчики, дежурные инженеры, иногда подрядчики. Их пересылают в отдельные системы и хранят месяцами. Поэтому данные, доступ к которым ограничен, в записи попадать не должны.

Нельзя записыватьПочемуЧто записать вместо
Пароли, токены, заголовок AuthorizationЛюбой читатель лога получит доступ к чужой учётной записиФакт успешной или неуспешной проверки
Полные номера банковских картТребования платёжных систем запрещают хранить их в открытом видеПоследние четыре цифры
Персональные данные: паспорт, адрес, телефонИх обработку регулирует закон, в России — 152-ФЗВнутренний идентификатор пользователя
Тело запроса целикомВ нём могут оказаться любые из перечисленных данныхРазмер тела и нужные для разбора поля

Это решают при проектировании записи, до первой утечки. Надёжнее перечислить поля, которые писать можно, чем запрещать отдельные: тогда новое поле запроса не попадёт в лог случайно.

§10Частые ошибки

Что делаютЧто получаетсяКак правильно
Пишут сообщения без контекста: «Request processed»Запись нельзя связать ни с запросом, ни с результатомДобавлять время, маршрут, статус, длительность и идентификатор
Не указывают единицы измеренияЧисло 72 читают как секунды вместо миллисекундПисать единицу в имени поля: duration_ms
Используют f-строки в logger.debugСтрока строится, даже если уровень DEBUG выключенПередавать значения аргументами: logger.debug("x=%s", x)
Вызывают logger.error вместо logger.exception в exceptВ запись не попадает tracebackВ блоке except использовать logger.exception
Отправляют traceback клиенту в ответеПользователь видит устройство сервисаВозвращать краткое описание и request_id, traceback — только в лог
Считают ошибочные запросы по числу строк ERRORПовторы и промежуточные ошибки завышают результатСчитать по событию, которое пишется один раз на запрос
Записывают токены и персональные данныеУтечка доступа и нарушение законаПеречислять разрешённые поля записи

§11Проверьте себя

Уровень логгера — WARNING. Какие из вызовов debug, info, warning, error попадут в вывод?

warning и error. Логгер пропускает записи с уровнем не ниже заданного: WARNING — 30, ERROR — 40, а DEBUG — 10 и INFO — 20 ниже порога.

В каком порядке читают traceback и что находится в последней строке?

Снизу вверх. В последней строке тип исключения и сообщение, над ней — место, где оно возникло, выше — цепочка вызовов, которая туда привела.

Пользователь прислал снимок экрана с ошибкой 500 и значением request_id. Как найти причину?

Найти в хранилище логов запись с этим request_id. В ней уровень ERROR, маршрут и поле exception с traceback, которого у пользователя нет.

Почему на снимке сценария error в блоке TRACE только два этапа?

Исключение TimeoutError возникло после этапа dependency.wait и прервало выполнение блока try, поэтому этап serialize не выполнялся.

Запрос выполнил два неудачных повтора и в итоге завершился успешно. Сколько неуспешных запросов он добавляет?

Ноль. Неуспешных попыток две, но итоговый результат запроса успешный.

§12Шпаргалка

  • Лог — последовательность записей о событиях: когда, где, что и с каким результатом.
  • Обязательные поля: время в UTC, уровень, сервис, событие, результат, длительность, идентификатор.
  • Уровни logging: DEBUG 10, INFO 20, WARNING 30, ERROR 40, CRITICAL 50.
  • Значения передают аргументами логгера, без f-строки.
  • JSON делает поля доступными для точного поиска; имена полей одинаковы во всём сервисе.
  • logger.exception внутри except добавляет traceback.
  • Traceback читают снизу вверх: исключение → место → цепочка вызовов.
  • Клиенту — краткое описание и request_id, в лог — полный traceback.
  • Число строк ERROR не равно числу неуспешных запросов.
  • Пароли, токены и персональные данные в лог не пишут.

Первоисточники