Структурированные логи: как перейти от plain text к JSON и что это даёт на практике | AdminWiki

Структурированные логи: как перейти от plain text к JSON и что это даёт на практике

30 сентября 2026 13 мин. чтения

Структурированный лог - это JSON-объект с полями, которые система хранения индексирует сразу после приёма. Из записи с полями request_id, level, service и status можно за секунды достать все ошибки конкретного заказа. Те же данные в plain text приходится вытаскивать grep-ом по гигабайтам и проверять глазами.

Переход на JSON не требует менять стек сбора. Агент продолжает читать тот же файл, journald или stdout контейнера: JSON-строка остаётся обычной строкой в потоке. Меняется один этап: вместо регулярного выражения включается JSON-декодер.

Дальше разбираем обязательные поля (timestamp, level, service, request_id, trace_id), готовые конфиги Filebeat, Fluent Bit, Vector и Promtail, сравнение одного инцидента в текстовом и структурированном логе, план перехода через feature flag, риски и чек-лист перед продом.

Почему plain text ломает поиск, фильтрацию и дашборды

У неструктурированного лога нет полей, есть только строка. Любая аналитика над ней требует предварительного разбора, и этот разбор живёт либо в конфиге агента, либо в тексте запроса.

Поиск по идентификатору. Команда grep -r «req-abc123» /var/log/app/ читает файлы целиком и находит совпадение везде: в записи приложения, в access-логе nginx, в теле запроса, которое клиент прислал как данные. Ложные срабатывания отсеиваются вручную, а на объёме в десятки гигабайт в сутки один такой поиск занимает минуты и греет диск.

Фильтрация по уровню. Выражение ERROR|WARN ловит слово ERROR в подстроке NOERROR, в тексте сообщения и в имени класса внутри стектрейса. Каждое уточнение порождает новый regex: сначала граница слова, потом исключение кавычек, потом исключение тела сообщения. Через полгода в пайплайне десятки выражений, и никто не помнит, какое за что отвечает.

Дашборды. В Grafana по Loki график «доля ошибок по сервисам» не построить, пока service и status не извлечены на этапе приёма. До этого каждый запрос перечитывает сырой текст: это дороже по CPU и медленнее по выдаче.

Типовая деградация выглядит так. Разработчик поменял сообщение с «user login failed» на «login failed for user», regex перестал совпадать, поле level исчезло, панель ошибок показывает ноль. Инцидент расследуют вслепую, потому что метрика врёт.

Хрупкость регулярных выражений на реальных примерах

Три случая, которые ломают пайплайны чаще всего.

  1. Шаблон level=(\w+) рассчитан на пару key=value. Приложение пишет level: INFO или [INFO], выражение не совпадает, поле level пустое, алерт по ошибкам молчит.
  2. Разбор времени по маске 2026-01-15 10:23:45 ломается при переходе на ISO 8601 с разделителем T и суффиксом Z. Сортировка событий между сервисами рассыпается.
  3. Многострочный стектрейс разрывает одну запись на 10-30 строк. Агент отправляет их отдельными событиями: в хранилище 30 записей с одним request_id, без уровня и без имени сервиса.

Каждый новый формат сообщения означает новый regex в пайплайне и новый технический долг. Правки копятся, покрытие тестами у парсеров обычно нулевое.

Что именно теряется: группировка, агрегация, корреляция

Без извлечённых полей недоступны:

  • группировка по service: нельзя посмотреть ошибки только сервиса checkout;
  • агрегация длительности: нельзя посчитать p95 по полю duration_ms;
  • корреляция запросов: нельзя связать логи двух сервисов по trace_id;
  • алерты по условию: нельзя сформулировать «рост 5xx по сервису X за 5 минут», пока status не стало полем;
  • точные срезы в Kibana и Loki: фильтр по полю дешевле полнотекстового поиска по сырой строке.

В Elasticsearch структурированные поля извлекаются на этапе ingest: ingest pipelines позволяют выполнять типовые преобразования данных до индексирования, например удалять поля, извлекать значения из текста и обогащать данные. Пайплайн состоит из последовательно выполняемых процессоров, после чего Elasticsearch добавляет преобразованные документы в data stream или индекс (Elastic Docs, ingest pipelines). Извлечение структурированных полей вроде @timestamp, log.level и host.ip из неструктурированных сообщений выполняется с помощью dissect-процессоров, и это упрощает поиск, анализ и фильтрацию логов (Elastic Docs, parse and route logs). Для Grafana Loki в переданных материалах подтверждающих фрагментов документации не нашлось, поэтому утверждение про перечитывание сырого текста именно движком Loki здесь не приводим. Как устроен конвейер доставки и хранения, разбираем в материале про централизованный сбор и анализ логов в 2026 году.

Обязательный набор полей JSON-лога: timestamp, level, service, request_id

Минимальный набор, который закрывает расследования и дашборды, выглядит так:

{"timestamp":"2026-01-15T10:23:45.123Z","level":"ERROR","service":"checkout","request_id":"req-abc123","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","message":"payment failed","method":"POST","path":"/api/v1/orders","status":500,"duration_ms":1240}
ПолеТипЗачем нужно
timestampстрока, ISO 8601 UTCсортировка и корреляция между сервисами и регионами
levelстрока, INFO/WARN/ERRORфильтрация и условия алертов
serviceстрока, стабильное имягруппировка и разбивка панелей дашборда
request_idстрокапоиск всех записей одного HTTP-запроса
trace_idстрока, W3C Trace Contextцепочка вызовов между сервисами
messageстрокачеловекочитаемое описание события
duration_msчислопоиск медленных запросов и перцентили
statusчислодоля ошибок в разрезе сервиса

Формат JSON описан в RFC 8259 «The JavaScript Object Notation (JSON) Data Interchange Format»: это Internet Standards Track document, продукт IETF, опубликованный в декабре 2017 года, он заменяет RFC 7159 (RFC 8259). RFC описывает JSON как легковесный, текстовый, независимый от языка формат обмена данными; медиатип JSON-текста — application/json. Придерживайтесь его буквально: двойные кавычки для ключей и строк, экранирование перевода строки как \n, никаких комментариев и висячих запятых. Сериализатор логгера делает это сам, а ручная сборка строки через конкатенацию почти всегда даёт невалидный JSON. Как это устроено в конкретных библиотеках, показываем в гайде про python-json-logger и structlog для ELK Stack и Grafana Loki.

Формат timestamp: почему ISO 8601 и UTC

Локальное время без таймзоны ломает корреляцию. Два сервиса в регионах с разницей в 3 часа пишут 10:23:45 и 07:23:45. По логам инцидент выглядит как рассинхрон на три часа, хотя события одновременные. Формат 2026-01-15T10:23:45.123Z однозначен: суффикс Z означает UTC.

Миллисекунды нужны для быстрых инцидентов: запрос с таймаутом 200 мс и двумя ретраями укладывается в одну секунду, и без миллисекунд порядок событий не восстановить. Держите одинаковую точность во всех сервисах: смесь секунд и миллисекунд ломает сортировку в Loki и Kibana.

request_id и trace_id: как связать логи одного запроса

request_id уникален для одного HTTP-запроса. Генерируйте его на входе (nginx, API gateway, балансировщик) и прокидывайте во все сервисы заголовком X-Request-ID: тогда идентификатор появится и в access-логе, и в логах приложений.

trace_id приходит из распределённой трассировки по спецификации W3C Trace Context. Спецификация определяет стандартные HTTP-заголовки и формат значений для распространения контекстной информации, обеспечивающей сценарии распределённой трассировки, и стандартизирует способ передачи и изменения контекстной информации между сервисами (W3C Trace Context). Она описывает два поля, которые должны передаваться в заголовке HTTP-запроса по всей цепочке трассировки: traceparent и tracestate. traceparent описывает позицию входящего запроса в графе трассировки и состоит из четырёх подполей: версия (текущая — 00), trace-id (16-байтовый идентификатор всей трассировки), parent-id (8-байтовый идентификатор родительского или текущего span) и flags. tracestate расширяет traceparent вендор-специфичными данными и опционален (Using W3C Trace Context standard in distributed tracing). Разница в масштабе: request_id отвечает на вопрос, что происходило внутри одного запроса, trace_id показывает, через какие сервисы он прошёл.

Пример: пользователь жалуется на медленное оформление заказа. По request_id находим все записи этого запроса, по trace_id видим, что 900 из 1240 мс съел downstream-сервис платежей. Без обоих полей записи пришлось бы сопоставлять по времени вручную.

Не пишите в лог пароли, токены доступа, номера карт и полные адреса почты. Маскирование делайте на уровне приложения, до записи в поток вывода.

Совместимость с существующими агентами сбора: Filebeat, Fluent Bit, Vector, Promtail

JSON-строка в stdout или journald остаётся обычной строкой, поэтому агенты продолжают её собирать без изменений. Меняется один этап: вместо регулярного выражения включается JSON-декодер, и поля поднимаются на верхний уровень события. Обратная совместимость сохраняется, потому что поток символов для агента не меняется.

Важная деталь: если приложение пишет JSON, а агент читает файл как plain text, поля не появятся. Хранилище получит одну строку с полем message, внутри которой лежит весь JSON. Парсер нужно включать явно. Логику маршрутизации таких событий по тегам и приоритетам разбираем в статье про конвейер от Fluentd и Prometheus до Grafana.

Пример конфига Filebeat для JSON-логов

Фрагмент filebeat.yml с input filestream и декодером:

filebeat.inputs:
- type: filestream
  id: app-json
  paths:
    - /var/log/app/*.log
  processors:
    - decode_json_fields:
        fields: ["message"]
        target: ""
        overwrite_keys: true
        add_error_key: true

Процессор decode_json_fields декодирует поля, содержащие JSON-строки, и заменяет строки валидными JSON-объектами (Elastic Docs, decode_json_fields). Параметр fields задаёт список полей для декодирования, target — поле, куда записывается результат (пустая строка означает слияние с корнем события), overwrite_keys управляет перезаписью существующих ключей (по умолчанию false), а add_error_key при ошибке декодирования добавляет ключи error.message и error.type: json. Есть также ignore_decoding_error. Учтите: Filebeat обрабатывает логи построчно, поэтому декодирование работает, только если в одном сообщении ровно один JSON-объект, и происходит оно до фильтрации строк. В input filestream значения из декодированного JSON перезаписывают служебные поля, которые Filebeat добавляет обычно (type, source, offset и т.д.), при конфликтах (Elastic Docs, filestream input). Служебные поля вроде agent.* убирайте через drop_fields, чтобы не раздувать индекс.

Пример конфига Fluent Bit, Vector и Promtail

Fluent Bit (облегчённая версия Fluentd) разбирает JSON через парсер и фильтр:

[PARSER]
    Name        json_app
    Format      json
    Time_Key    timestamp
    Time_Format %Y-%m-%dT%H:%M:%S.%LZ

[FILTER]
    Name        parser
    Match       app.*
    Key_Name    log
    Parser      json_app

Vector в конфиге использует transform типа remap:

[transforms.parse_json]
  type = "remap"
  inputs = ["app_logs"]
  source = '''
    . = parse_json!(.message)
  '''

Promtail описывает разбор через pipeline_stages:

- json:
    expressions:
      level: level
      service: service
      request_id: request_id

Во всех трёх случаях строка остаётся строкой в потоке, а агент лишь извлекает поля. Time_Key и Time_Format задают, что метка времени берётся из самого сообщения, а не из момента чтения файла: без этого записи из буфера получат неверное время. Оговорка по источникам: параметры Filebeat сверены с официальной документацией Elastic, а фрагменты официальной документации Fluent Bit (Time_Key, Time_Format), Vector (parse_json remap) и Promtail (pipeline_stages json) в переданных материалах отсутствуют, поэтому приведённые конфиги для этих трёх агентов стоит сверить с их актуальной документацией перед выкаткой.

Один инцидент - два расследования: plain text против JSON

Сценарий одинаковый: пользователь получает 500 на оформлении заказа, известен request_id req-abc123.

В текстовых логах расследование идёт через поиск по файлам: grep -r «req-abc123» /var/log/app/, затем просмотр контекста на 20 строк до и после найденной записи, склейка многострочного стектрейса, отдельное сопоставление с access-логом nginx по времени. Точность падает, если идентификатор встречается в теле запроса, а поиск по ротированным архивам добавляется ещё одним шагом.

В JSON-логах запрос идёт по полю. LogQL: {service="checkout"} | json | request_id="req-abc123". KQL в Kibana: service:"checkout" and request_id:"req-abc123". В выдаче сразу видны level, status, duration_ms и trace_id, а переход к следующему сервису выполняется по trace_id.

КритерийPlain textJSON
Поиск по request_idполнотекстовый grep, минутыфильтр по полю, секунды
Ложные срабатыванияесть, отсеиваются вручнуюнет, поле строгое
Стектрейссклейка десятков строкодно поле message
Переход к downstreamсопоставление по временипереход по trace_id

Команды проверки JSON-логов локально

Проверяйте формат до отправки в централизованную систему:

  • docker logs app | jq . : проверка валидности каждой строки;
  • jq -r '.level' app.log | sort | uniq -c : распределение событий по уровням;
  • jq 'select(.duration_ms > 1000)' app.log : поиск медленных запросов;
  • jq -c . app.log | wc -l : сравните с wc -l app.log, чтобы увидеть, не распадается ли запись на несколько строк.

Если jq падает с ошибкой разбора, агент тоже не сможет обработать такие строки. Причина обычно одна: сообщение собрано конкатенацией и содержит неэкранированную кавычку или перевод строки.

Постепенная миграция на JSON без остановки сервисов

План, который не требует простоя и даёт предсказуемый откат:

  1. Выберите один некритичный сервис-пилот, желательно с небольшим трафиком.
  2. Включите JSON-вывод через переменную окружения LOG_FORMAT=json, не меняя логику приложения.
  3. Настройте агент на двойной режим чтения: сначала JSON-парсер, при ошибке запись уходит дальше как plain text с тегом format=text.
  4. Проверьте результат в staging: поля появились, дашборд строится, алерты срабатывают.
  5. Раскатайте canary на один инстанс, затем расширяйте на остальные через rolling update. Для критичных сервисов подойдёт blue-green: новый вариант полный цикл проверок проходит на отдельной группе, трафик переключается после сверки счётчиков.
  6. Когда счётчик записей с тегом format=text упадёт до нуля, уберите fallback и удалите regex-парсеры из пайплайна.

Feature flag и переменные окружения для формата логов

Флаг формата читается один раз при старте приложения, чтобы поведение было предсказуемым, а откат сводился к перезапуску с другим значением.

  • В Kubernetes значение приходит из ConfigMap. Правка ConfigMap сама по себе pod не перезапускает: добавьте rollout restart или контрольную сумму конфига в аннотацию деплоймента.
  • В systemd используйте директиву Environment=LOG_FORMAT=json в unit-файле, после правки выполните systemctl daemon-reload и restart.
  • В Docker Compose хватит переменной в секции environment и команды docker compose up -d для пересоздания контейнера.

Стратегию уровней детализации для dev, test и prod, а также ротацию и сроки хранения разбираем в руководстве по эффективному логированию для DevOps.

Dual-write и fallback на стороне агента

Схема страховки проста: log shipper пытается разобрать строку как JSON, при неудаче отправляет её как plain text и проставляет тег format=text. Ни одна запись не теряется, а в Kibana или Loki можно фильтровать по тегу и видеть, какие сервисы ещё не перешли на новый формат. Снимайте fallback только тогда, когда счётчик таких записей равен нулю за полный цикл, включая ночные задачи и деплой.

Риски и подводные камни JSON-логов

  • Рост объёма. Ключи добавляют байты, но gzip на агенте и сжатие в хранилище сокращают разницу. Измерьте на своём профиле: сравните размер сжатого файла за смену до и после перехода.
  • Невалидный JSON. Ручная сборка строки через конкатенацию рано или поздно даёт кавычку внутри значения. Используйте сериализатор логгера, а не склейку.
  • Многострочные сообщения. Стектрейс пишите одним полем message с переводами строк как \n, а не набором отдельных JSON-объектов: иначе одна ошибка превращается в тридцать событий без уровня.
  • Высокая кардинальность. Mapping explosion в Elasticsearch возникает, когда в качестве JSON-ключа используются высококардинальные идентификаторы: каждый раз при появлении нового ключа обновляется cluster state, что может быстро привести к проблемам стабильности кластера (How to Debug an Unresponsive Elasticsearch Cluster). Fielddata не подходит для полей с очень высокой кардинальностью (например, UUID или trace id), так как создаёт массивные структуры fielddata; запросы или маппинг стоит скорректировать так, чтобы не агрегировать по высококардинальным ключам. Длинные уникальные значения (полный URL, идентификатор сессии, user_id) логируйте, но не делайте keyword-полем по умолчанию. Оговорка: эти данные взяты из разбора на DEV Community, а не из официальной документации Elastic, поэтому перед изменением маппинга сверьтесь с актуальной документацией Elasticsearch.
  • Смешанные форматы в одном потоке. Пока часть сервисов пишет текст, агент должен уметь оба варианта, иначе поля появляются только у половины записей.

Как не утечь секреты в логи

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

Добавьте в чек-лист код-ревью правило: любое новое поле в логе проходит проверку на персональные данные. Структурированные логи помогают аудиту: запросом ищутся все записи с полем password или authorization, и отсутствие результата подтверждает, что утечки нет. Критерии, что писать в лог стоит, а что нет, собраны в статье что логировать, а что нет.

Чек-лист перед выкаткой JSON-логов в прод

  • обязательные поля на месте: timestamp, level, service, request_id, trace_id;
  • timestamp в ISO 8601 с суффиксом Z и одинаковой точностью во всех сервисах;
  • level в верхнем регистре, значения ограничены списком DEBUG, INFO, WARN, ERROR;
  • секреты и персональные данные маскируются до записи, проверено на код-ревью;
  • каждая строка валидна: jq -c . пропускает файл без ошибок;
  • агент переключён на JSON-парсер, поля service и level видны в Kibana или Loki отдельными колонками;
  • fallback на plain text работает и помечает записи тегом format=text;
  • дашборды переведены с regex на поля, панели не опустели;
  • алерты переписаны с шаблонов сообщений на условия по полям;
  • пилотный сервис отработал сутки без потери строк, счётчики до и после совпадают;
  • план отката через переключение LOG_FORMAT проверен на стенде;
  • ротация файлов и срок хранения пересмотрены под новый объём.

Переход сводится к двум правкам: включить JSON-формат в приложении и JSON-парсер в агенте. Начните с одного некритичного сервиса, дождитесь суток чистых данных в дашбордах, затем расширяйте на остальные через rolling update.

Поделиться: