Как подготовить логи logme для ELK, Loki, OpenSearch и Splunk
Подготовка логов для ELK, Loki, OpenSearch или Splunk начинается не с выбора агента и не с написания parser-а. Сначала нужно решить, каким является один логический event, какие поля должны быть стабильными, где находится время события и как файл будет жить после rotation.
Самая частая ошибка — считать, что достаточно включить JSON. JSON сам по себе не делает лог удобным для поиска. Если timestamp неоднозначный, уровень лежит внутри текстового сообщения, поля меняют имена от версии к версии, а после rotation collector теряет часть файла, то даже идеально валидный JSON не решает задачу.
Библиотека логирования logme позволяет сформировать нужный контракт прямо в приложении. FileBackend может писать один JSON object на каждую запись. При включенном Eol это JSONL: одна независимая запись на строку. Такой формат удобен для file collectors, stream processing и большинства систем анализа логов.
Главная идея проста: приложение должно писать стабильные структурированные события, а конкретный collector или ingest pipeline должен адаптировать их к схеме ELK, Loki, OpenSearch или Splunk.
JSONL, а не один большой JSON document
Для живого файла не нужен JSON array:
[
{"message":"server started"},
{"message":"request accepted"}
]
Такой файл неудобен для append-only logging. В процессе работы пришлось бы поддерживать запятые, закрывающую скобку и целостность всего документа. При crash или forced shutdown файл легко остался бы в полузаконченном состоянии.
Runtime JSON output logme устроен иначе. Каждая запись независима:
{"timestamp":"2026-06-20T12:00:00.000+00:00","level":"info","message":"server started"}
{"timestamp":"2026-06-20T12:00:01.000+00:00","level":"error","message":"request failed"}
Это JSONL. Каждая строка уже является завершенным JSON object. Collector может прочитать файл последовательно, передать event дальше и не ждать закрытия файла.
Для ELK, OpenSearch и Splunk это естественный формат для ingestion. Для Loki JSON остается log line, из которой агент или query pipeline может извлечь нужные поля.
logmefmt --finalize для постоянного ingestion обычно не нужен. Finalized JSON array или XML document полезны для экспорта, архива или передачи готового документа. Но live collector должен получать поток независимых записей.
Сначала задайте нейтральную схему
Не стоит сразу писать приложение под конкретный vendor schema. Например, не нужно превращать каждое внутреннее поле в ECS-only name только потому, что сейчас используется Elasticsearch. Через год тот же сервис может отправлять часть логов в Loki или OpenSearch.
В logme разумно оставить нейтральные поля:
{
"timestamp": "2026-06-20T12:00:01.000+00:00",
"level": "warn",
"process_id": 4120,
"thread_id": 8836,
"channel": "requests",
"subsystem": "http",
"file": "RequestHandler.cpp",
"line": 184,
"method": "HandleRequest",
"message": "slow request",
"duration": "1250ms"
}
Не каждая запись обязана содержать все поля. Например, subsystem появляется только там, где он задан, а source location зависит от output flags. Но имена и смысл полей должны оставаться стабильными.
Для большинства систем достаточно четырех базовых полей: timestamp, level, message и некоторого service context. Остальные поля полезны для диагностики, но не должны превращать каждое событие в огромный документ без необходимости.
channel хорошо отражает routing policy logme: например, requests, security, audit или performance. subsystem помогает понять функциональное происхождение сообщения: http, tls, cache, database. Это разные вещи, и полезно не смешивать их.
Timestamp должен быть однозначным
Время события — одно из важнейших полей. Если collector не может надежно распознать timestamp, он подставит время ingestion. После этого расследование инцидента начинает показывать не момент, когда событие произошло, а момент, когда агент успел его прочитать.
Для распределенных систем лучше использовать UTC или timestamp с явным timezone offset. В logme это можно задать через OutputFlags:
{
"flags": {
"json-ingest": {
"timestamp": "tz",
"signature": true,
"format": "json",
"eol": true
}
}
}
tz полезен, когда log должен нести timezone offset. utc подходит, когда в инфраструктуре принято хранить все события в UTC. Главное — выбрать один вариант и не смешивать локальное время разных машин без timezone information.
В ELK и OpenSearch ingest pipeline обычно преобразует application field timestamp в canonical @timestamp. В Splunk нужно настроить извлечение времени из event, а не полагаться на время чтения файла. Для Loki timestamp задается агентом или ingestion pipeline, поэтому важно, чтобы он использовал event time из JSON, а не собственное время отправки.
Level должен быть отдельным полем
Уровень не должен оставаться только частью текста:
[ERROR] database connection failed
Для человека это читаемо. Для поиска и aggregation хуже: системе приходится разбирать строку и надеяться, что format никогда не поменяется.
В JSON output logme уровень записывается отдельным полем:
{"level":"error","message":"database connection failed"}
Это позволяет строить запросы вроде “все error за последние десять минут”, группировать события по level и создавать alert rules без regex parsing.
logme использует значения debug, info, warn, error и critical. Если downstream system требует другой словарь, лучше нормализовать его в collector или ingest pipeline. Например, critical можно отобразить в fatal, но приложение не должно менять собственную семантику только ради одного receiver-а.
Сообщение остается сообщением
Поле message должно содержать текст, который инженер действительно хочет прочитать:
{
"level": "error",
"message": "upstream connection failed: timeout"
}
Не нужно превращать каждую деталь в отдельное поле. Если событие сообщает о сбое подключения, message остается полезным кратким описанием. А структурированные свойства, по которым реально будут искать или строить aggregation, можно вывести отдельно.
Например, запрос к upstream service может иметь такие данные:
{
"timestamp": "2026-06-20T12:00:01.000+00:00",
"level": "warn",
"channel": "requests",
"subsystem": "http",
"message": "upstream request timed out",
"upstream": "payments",
"status_code": 504,
"duration_ms": 5000
}
Встроенные поля logme описывают сам logging context. Предметные поля приложения обычно добавляются отдельным structured logging mechanism или формируются в downstream pipeline. Не стоит пытаться записывать всю бизнес-модель внутрь source location, channel name или message text.
Базовая JSON-конфигурация logme
Ниже пример канала, который пишет JSONL в файл. В нем включены timestamp, level, process/thread identifiers, channel, subsystem, source location и method. Это хороший диагностический baseline, но его можно упростить для очень высоконагруженных сервисов.
{
"flags": {
"json-ingest": {
"timestamp": "tz",
"signature": true,
"processid": true,
"threadid": true,
"channel": true,
"subsystem": true,
"location": "short",
"method": true,
"format": "json",
"eol": true
}
},
"channels": [
{
"name": "",
"flags": "json-ingest",
"level": "info",
"backends": [
{
"type": "FileBackend",
"file": "logs/app.jsonl",
"append": true,
"rotation": "daily",
"max-size": "128Mb",
"on-size-limit": "rotate",
"archive": "logs/archive/app.{date}.{index}.jsonl",
"retention": {
"max-files": 14,
"max-age": "30d",
"max-total-size": "5Gb",
"clean-on-start": true
}
}
]
}
]
}
Здесь eol: true принципиален: он делает каждый JSON object отдельной строкой. Rotation выполняется по времени и размеру, а completed files уходят в archive path.
При необходимости имена structured fields можно переопределить через structured_fields. Но для внешних систем чаще лучше сохранить нейтральные имена в файле и делать vendor-specific mapping уже после чтения лога.
ELK: сохраняйте JSONL и маппируйте в ECS на ingest
Для ELK удобнее всего писать JSONL и читать его через Elastic Agent или Filebeat. На ingest стороне application timestamp обычно превращается в @timestamp, а level — в log.level. message можно оставить message.
Например, схема может выглядеть так:
logme timestamp -> @timestamp
logme level -> log.level
logme message -> message
logme process_id -> process.pid
logme thread_id -> process.thread.id
logme channel -> logme.channel
logme subsystem -> logme.subsystem
Не все поля logme обязаны быть частью ECS. Например, channel и subsystem — это полезные признаки именно logme. Их нормально хранить в собственном namespace, а не искусственно подгонять под ближайшее похожее поле чужой схемы.
Особенно осторожно стоит обращаться с duration. Если logme выводит duration как читаемый текст, не нужно автоматически маппировать его в event.duration, где обычно ожидается числовое значение в определенной единице. Либо нормализуйте duration в pipeline, либо оставьте его в отдельном поле.
Loki: JSON полезен, но labels должны быть скучными
Loki хорошо работает с JSON log lines, но его индексная модель отличается от ELK. Главная ошибка при подготовке логов для Loki — сделать labels из всего, что есть в JSON.
Labels должны иметь маленькое и ограниченное число значений. Обычно это service, environment, region, cluster, host role. Иногда туда можно добавить channel, если набор channels фиксирован и мал.
Но request id, trace id, user id, session id, thread id, полный URL, source file и line number не должны становиться labels. Такие значения быстро создают высокую cardinality, ухудшают работу Loki и увеличивают стоимость хранения.
Для Loki хороший подход выглядит так: agent добавляет стабильные infrastructure labels, а JSON сохраняется в log body. Нужные поля извлекаются для конкретного query или передаются как structured metadata.
Например, channel: "requests" можно использовать как label, если channels действительно ограничены несколькими постоянными значениями. А thread_id: 8836 и file: "RequestHandler.cpp" должны оставаться частью JSON event, а не индексным label.
OpenSearch: заранее определите типы полей
OpenSearch хорошо принимает JSON documents, но важно не позволять структуре логов бесконтрольно создавать новые поля и mappings.
Timestamp должен быть преобразован в date field, обычно @timestamp. Level, channel, subsystem и service name обычно полезны как keyword-like поля. message остается текстом для поиска. Process и thread identifiers могут быть числами или keywords в зависимости от того, как ими будут пользоваться.
Если приложение пишет одинаковые поля стабильно, Data Prepper или ingest pipeline может нормализовать их один раз и дальше индексировать предсказуемо. Это намного лучше, чем потом разбирать text logs через grok pattern и надеяться, что префикс никогда не изменится.
OpenSearch особенно хорошо показывает пользу нейтральной схемы logme: collector может принять JSON event, распарсить timestamp и затем преобразовать только те поля, которые нужны конкретному index mapping.
Splunk: event boundary и timestamp важнее красивого текста
Для Splunk JSONL удобен и при чтении файлов, и при отправке через HTTP Event Collector. Главное — чтобы один event был отделен от другого без двусмысленности.
Одна строка JSON на одно событие решает эту задачу. Splunk может извлекать поля из structured event и использовать timestamp из самой записи. При работе через HEC время можно передать и в envelope, но источник времени должен быть один: либо application timestamp, либо явное преобразование в collector.
Не стоит строить лог, который состоит из многострочного human-readable text с JSON fragment внутри. Такой формат заставляет receiver угадывать границы событий. Для Splunk, как и для остальных систем, лучше сразу писать независимые JSON records.
Rotation: для collector-а важнее rename, чем truncate
File rotation часто воспринимают как локальную housekeeping функцию. Но для log ingestion это часть надежности доставки.
Если active file просто truncates при достижении размера, collector может потерять еще не прочитанные строки. Для файлов, которые читает агент, лучше использовать archive rotation:
{
"rotation": "daily",
"max-size": "128Mb",
"on-size-limit": "rotate",
"archive": "logs/archive/app.{date}.{index}.jsonl"
}
В этом случае active file завершает жизненный цикл, переименовывается в archive и открывается заново. Это понятнее для file collector-а, чем внезапное обнуление того же файла.
Но даже правильная rotation policy не отменяет тестирования. Каждый collector по-своему отслеживает file identity, rename и недочитанные bytes. Перед production rollout нужно проверить именно свою комбинацию: logme rotation, glob path агента, restart агента, pressure на disk и поведение при одновременной rotation по времени и размеру.
Compression completed archives тоже требует осознанного решения. Если .gz файлы нужны только для долгого хранения, compression полезна. Если collector должен дочитывать archive прямо после rotation, нужно убедиться, что он поддерживает выбранный workflow и не пропустит либо не продублирует записи.
Старые text logs: здесь полезен logmefmt
Для нового production output лучше включить JSON в FileBackend сразу. Но старые логи не нужно выбрасывать только потому, что они были текстовыми.
logmefmt может разобрать standard text prefix logme и преобразовать записи в JSONL:
logmefmt --input text --output json \
--in app.log \
--out app.jsonl
Это удобно для старых архивов, support bundles и постепенной миграции. Но не стоит строить live pipeline так, чтобы приложение всегда писало text, а отдельный process постоянно конвертировал его в JSON. Для постоянного ingestion проще, быстрее и надежнее писать JSONL непосредственно из logme.
Итог
Логи для ELK, Loki, OpenSearch и Splunk нужно готовить как поток стабильных событий, а не как красивый текст, который receiver потом пытается угадать.
В logme для этого достаточно включить JSON format, Eol, однозначный timestamp и нужные context fields. FileBackend будет писать JSONL сразу, а rotation и retention останутся частью того же logging lifecycle.
Дальше различается только модель ingestion. ELK и OpenSearch обычно нормализуют timestamp и schema в pipeline. Loki требует особенно аккуратно выбирать low-cardinality labels. Splunk требует четких boundaries между событиями и корректного event time.
Общий принцип остается одинаковым: приложение пишет нейтральный, стабильный JSON event; инфраструктура адаптирует его к конкретному receiver-у; rotation не должна уничтожать недочитанные данные.