JavaRush /Курсы /Kotlin SELF /Контекст логов: поля ключ‑значение и форматирование

Контекст логов: поля ключ‑значение и форматирование

Kotlin SELF
60 уровень , 1 лекция
Открыта

1. Зачем логам контекст

Когда приложение маленькое, кажется, что контекст не нужен: мы и так помним, что «перед add было parse». Но как только появляется несколько команд, несколько шагов обработки, пара функций‑помощников и хотя бы одна ошибка, логи начинают выглядеть как переписка людей, которые отвечают не в тот чат. Вы видите строки, но не понимаете, какие из них относятся к одной и той же операции.

Представьте, что наш консольный трекер расходов (условно назовём его ExpenseTracker) обрабатывает команду add 120 food. В процессе он делает несколько действий: читает строку, парсит, валидирует, добавляет запись, печатает итог. Если у нас в логах есть только «Started», «Parsed», «Saved», то при двух быстрых командах подряд вы легко получите перемешивание и начнёте гадать: «А “Saved” — это про первую команду или про вторую?».

Вот типичный пример «логов без контекста» (не код, а то, что вы видите в консоли):

1700000000000 [INFO] Command received
1700000000100 [INFO] Parsed command
1700000000150 [INFO] Command received
1700000000200 [INFO] Saved expense
1700000000250 [INFO] Parsed command
1700000000300 [INFO] Saved expense

Глаза видят знакомые слова, мозг видит хаос. Нам нужен способ «прошить» связанные сообщения общим набором полей.

2. Контекст лога: что это и зачем

Контекст лога — это небольшой набор полей ключ‑значение, который сопровождает сообщение и отвечает на вопрос: «В рамках чего это произошло?». То есть само сообщение говорит «что случилось», а контекст уточняет «с чем это связано»: какая команда, какой requestId, какой пользовательский ввод, какой режим работы.

Важно: контекст — это не попытка засунуть всё в message. Наоборот: мы стараемся держать message коротким и человеческим (Expense saved), а детали выносим в структуру (amount=120 category=food requestId=req-42). Так логи проще искать глазами и проще фильтровать (даже если фильтрация у вас пока «глазами» и через Ctrl+F).

Можно представить это так:

Часть лога Что отвечает Пример
message
«что произошло?»
Expense saved
context
«к чему относится?»
requestId=req-42 command=add amount=120 category=food

И это уже похоже на «нормальные инженерные следы», а не на дневник эмоций программы.

3. Контекст как Map<String, String>

Чтобы контекст был простым, переносимым и привычным, мы будем хранить его как Map<String, String>. Это прямое попадание в нашу задачу: Map хранит пары ключ‑значение, причём ключи уникальны (для одного ключа — одно значение). Так устроены отображения в Kotlin: набор entries (пар ключ‑значение), где ключами обычно удобно делать строки.

Давайте начнём с самого простого: создадим контекст прямо через mapOf(...):

fun main() {
    val ctx = mapOf(
        "requestId" to "req-42",
        "command" to "add"
    )

    println(ctx["requestId"]) // req-42
}

Ключи здесь — договорённость. Kotlin не мешает вам написать "reqId" в одном месте и "request_id" в другом, но потом вы сами же будете страдать. Поэтому мы сразу относимся к ключам как к «мини‑API»: они должны быть стабильными.

Ещё один важный момент: мы выбрали String для значений не потому, что «так модно», а потому что лог в итоге печатается в текст, и нам всё равно придётся превращать значения в строку. Если мы сделаем Map<String, Any?>, будет больше свободы, но новичкам там проще случайно начать логировать «весь объект целиком» и получить километровые строки. Мы остаёмся в безопасной “песочнице”: только строки.

4. Форматируем контекст в строку

Итак, контекст у нас есть. Но println(ctx) выводит что-то вроде {requestId=req-42, command=add} — иногда нормально, но нам нужен единый формат, чтобы логи выглядели одинаково и были удобны для поиска. Самый распространённый “человеческий” формат — key=value, разделённый пробелами.

Для сборки строки отлично подходит joinToString(): он превращает набор элементов в одну строку по заданным правилам (разделитель, префикс и т.п.).

Напишем функцию formatCtx(...). Она должна возвращать пустую строку, если контекст пустой (чтобы не печатать лишний пробел), и добавлять ведущий пробел, если поля есть:

fun formatCtx(ctx: Map<String, String>): String {
    if (ctx.isEmpty()) return ""

    return ctx.entries.joinToString(
        prefix = " ",
        separator = " "
    ) { (k, v) -> "$k=$v" }
}

Тут есть маленькая магия, но приятная: ctx.entries — это набор Map.Entry, и мы используем деконструкцию (k, v), чтобы не писать it.key и it.value.

Проверим, что получается:

fun main() {
    val ctx = mapOf("requestId" to "req-42", "command" to "add")

    println("Hello" + formatCtx(ctx))
    // Hello requestId=req-42 command=add
}

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

Порядок полей

Когда контекст небольшой, порядок не так важен. Но если вы хотите, чтобы одинаковый контекст всегда печатался одинаково (это помогает глазами сравнивать строки), можно сортировать ключи.

Делаем версию «чуть аккуратнее», но всё ещё простую:

fun formatCtxSorted(ctx: Map<String, String>): String {
    if (ctx.isEmpty()) return ""

    return ctx.entries
        .sortedBy { it.key }
        .joinToString(prefix = " ", separator = " ") { (k, v) -> "$k=$v" }
}

Да, сортировка — это лишняя работа, но на уровне учебного консольного приложения это нормально, а читаемость выигрывает.

5. Обновляем Logger: контекст в API

Сейчас у нас логгер умеет печатать timestamp, уровень и сообщение. Наша цель — добавить третий кусок после сообщения: formatCtx(ctx). Важно сделать это так, чтобы вызывающий код не думал о форматировании, а только передавал данные.

Ниже — минимальная версия Logger, совместимая с тем, что мы строили раньше: minLevel, проверка по ordinal, единый формат:

class Logger(private val minLevel: LogLevel) {

    private fun enabled(level: LogLevel): Boolean =
        level.ordinal >= minLevel.ordinal

    fun log(level: LogLevel, message: String, ctx: Map<String, String> = emptyMap()) {
        if (!enabled(level)) return

        val ts = System.currentTimeMillis()
        println("$ts [$level] $message" + formatCtx(ctx))
    }

    fun info(message: String, ctx: Map<String, String> = emptyMap()) =
        log(LogLevel.INFO, message, ctx)
}

Обратите внимание на параметр по умолчанию: ctx: Map<String, String> = emptyMap(). Это значит, что старые вызовы вида logger.info("Started") не сломаются, а новые смогут добавлять контекст: logger.info("Started", ctx).

6. Один запрос — один контекст

Контекст становится по-настоящему полезным, когда мы перестаём «прикручивать его по настроению», а начинаем думать в стиле: у каждой операции есть базовый контекст, и он переиспользуется в нескольких логах подряд.

В консольном приложении операция — это, например, обработка одной команды. Значит, нам нужен requestId (корреляционный идентификатор). Он не обязан быть крипто‑стойким или глобально уникальным, нам нужно лишь «чтобы отличался в рамках запуска приложения».

Самый простой генератор:

fun newRequestId(): String {
    val ts = System.currentTimeMillis()
    return "req-$ts"
}

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

Теперь базовый контекст команды:

fun commandCtx(requestId: String, rawLine: String): Map<String, String> {
    return mapOf(
        "requestId" to requestId,
        "raw" to rawLine
    )
}

И самое приятное: мы можем «добавлять» поля к контексту через оператор +, потому что Map + Pair возвращает новую карту (старую не мутирует):

fun main() {
    val base = mapOf("requestId" to "req-42")
    val extended = base + ("command" to "add")

    println(extended)
    // {requestId=req-42, command=add}
}

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

7. Хелпер ctxOf(...) для контекста без null

Когда вы начинаете логировать больше деталей, вы замечаете неприятную вещь: некоторые значения “временно неизвестны”. Например, category может быть не распарсена, amount может быть ошибочным, и так далее. В контекст не хочется класть "category" to "null" — это шумно и не помогает.

Поэтому полезно сделать маленькую утилиту ctxOf(...), которая принимает пары key to value, превращает значения в строки, а null аккуратно выкидывает. Здесь хорошо подходит mapNotNull, потому что он как раз позволяет «преобразовать или выкинуть».

Вот простой вариант:

fun ctxOf(vararg pairs: Pair<String, Any?>): Map<String, String> {
    return pairs
        .mapNotNull { (k, v) -> v?.toString()?.let { k to it } }
        .toMap()
}

Использование:

fun main() {
    val amount: Int? = null

    val ctx = ctxOf(
        "requestId" to "req-42",
        "amount" to amount,          // null — пропадёт
        "command" to "add"
    )

    println(ctx) // {requestId=req-42, command=add}
}

Эта функция делает контекст компактным: вы не печатаете «пустую информацию». И да, это тот редкий случай, когда маленький “умный” хелпер реально повышает читаемость, а не усложняет жизнь.

8. Практический пример: обработка команды в ExpenseTracker

Сейчас соберём всё в одну историю. У нас есть консольное приложение, которое читает строку команды и обрабатывает её. Пусть мы пока поддерживаем команды add и list, а остальное считаем ошибкой. Пользовательский вывод (то, что видит человек) оставим обычными println("..."), а логи — через logger.

Начнём с простого парсинга команды в Pair: команда и остаток строки. (Да, можно красивее, но мы держим фокус на контексте логов.)

fun splitCommand(line: String): Pair<String, String> {
    val trimmed = line.trim()
    val firstSpace = trimmed.indexOf(' ')
    if (firstSpace == -1) return trimmed to ""
    return trimmed.substring(0, firstSpace) to trimmed.substring(firstSpace + 1)
}

Теперь обработчик одной строки. Обратите внимание: мы создаём requestId один раз и используем в нескольких логах.

fun handleLine(line: String, logger: Logger) {
    val requestId = newRequestId()
    val baseCtx = ctxOf("requestId" to requestId, "raw" to line)

    logger.info("Command received", baseCtx)

    val (cmd, rest) = splitCommand(line)
    val cmdCtx = baseCtx + ("command" to cmd)

    logger.info("Command parsed", cmdCtx)

    // Пользовательский вывод отдельно от логов
    println("OK, I got command: $cmd") // OK, I got command: add
}

Если запустить два раза подряд, вы увидите, что каждую «цепочку» логов можно склеить по requestId:

1700000000000 [INFO] Command received requestId=req-1700000000000 raw=add 120 food
1700000000001 [INFO] Command parsed requestId=req-1700000000000 raw=add 120 food command=add
OK, I got command: add

Даже если логи перемешаются по времени, requestId будет вашим “проводом”, который соединяет события.

Небольшая схема потока обработки

Чтобы закрепить мысль «контекст создаётся один раз и живёт всю операцию», полезно представить обработку команды как короткий конвейер:

flowchart TD
    A["readln()"] --> B[requestId + baseCtx]
    B --> C[log: Command received]
    C --> D[parse command]
    D --> E[log: Command parsed + command]
    E --> F[execute]
    F --> G[log: Command finished]

Контекст — это не отдельный этап, а “клей”, который сопровождает этапы.

Формат: короткое сообщение + детали в контексте

Заметьте, что сообщения у нас максимально простые: Command received, Command parsed. Мы не делаем “поэму” в тексте сообщения, потому что поэмы плохо искать глазами. А вот контекст даёт точность: raw=... command=... requestId=....

Что класть в контекст

Контекст легко превратить в помойку: «а давай положим туда всё». Тогда вы получите строки длиной в экран, а пользы — ноль. Поэтому полезно держать в голове три правила: контекст должен помогать связать события, помочь диагностировать проблему и быть безопасным по содержимому.

В нашем CLI‑приложении обычно хватает таких полей:

Ключ Смысл Пример
requestId
склейка сообщений одной операции
req-1700000000000
command
какая команда выполняется
add
raw
исходная строка команды
add 120 food
amount
сумма (если есть)
120
category
категория (если есть)
food

Обратите внимание: мы не пытаемся логировать «всё состояние приложения» или «все расходы». Если вам хочется логировать “всё” — это обычно знак, что вы пока не решили, какие данные реально важны.

А ещё договоритесь о стиле ключей. В Kotlin‑коде приятно выглядит camelCase (requestId, userId, tookMs). Если начнёте мешать request_id и requestId, то через неделю сами же будете писать «почему поиск не находит» и грустно смотреть в окно.

9. Типичные ошибки при работе с контекстом логов

Ошибка №1: контекст «вшивается» в текст сообщения вручную.
Когда вы пишете logger.info("Command received requestId=$id raw=$line"), вы вроде бы добавили детали, но потеряли главное: единый формат. В одном месте будет raw=..., в другом input=..., в третьем вы забудете пробел, а в четвёртом случайно добавите лишнюю запятую. Держите контекст отдельной структурой (Map) и форматируйте его строго внутри Logger.

Ошибка №2: для одного смысла используются разные ключи.
Сегодня вы пишете reqId, завтра requestId, послезавтра rid. Через месяц вы уже не можете ни глазами сравнить логи, ни нормально отфильтровать их даже примитивным поиском. Лучше один раз выбрать ключи (хоть в виде констант, хоть просто договорённостью) и придерживаться их как “API для самого себя из будущего”.

Ошибка №3: контекст собирается по кусочкам и «плавает» от лога к логу.
Если в одном сообщении есть requestId, а в следующем вы забыли его передать — цепочка рвётся, и вся идея корреляции пропадает. Хороший стиль — создать baseCtx в начале операции и дальше расширять его через +, чтобы базовые поля никогда не терялись.

Ошибка №4: в контекст кладут слишком много или слишком большие значения.
Технически вы можете положить туда огромный текст или сериализованный объект, но потом читать это невозможно. Контекст должен быть компактным: короткие числа, короткие идентификаторы, короткие статусы. Если хочется логировать “большое”, чаще всего это отдельное событие и отдельное решение, а не поле контекста.

Ошибка №5: контекст делают мутабельным и меняют «на лету» так, что непонятно, что было в момент лога.
Если вы используете MutableMap и добавляете/удаляете поля в процессе, легко случайно переиспользовать одну и ту же карту между разными операциями (особенно если где-то храните ссылку). Новичкам проще и безопаснее держаться за неизменяемые mapOf(...) и создавать новые карты через +. Да, это создаёт новые объекты, но в рамках учебного консольного приложения цена этого мала, а предсказуемость — огромная.

Ошибка №6: в контекст попадают данные, которые не стоит печатать.
Даже в учебных проектах полезно привыкать, что логи могут оказаться где угодно: в истории терминала, в CI, в файле, который вы кому-то отправили. Поэтому лучше с самого начала держать дисциплину: контекст — это техническая диагностика, а не «всё, что мы знаем о мире». Если вы сомневаетесь, печатать ли значение — скорее всего, не печатайте (или печатайте в урезанном виде).

1
Задача
Kotlin SELF, 60 уровень, 1 лекция
Недоступна
Контекст операции
Контекст операции
1
Задача
Kotlin SELF, 60 уровень, 1 лекция
Недоступна
Печать контекста
Печать контекста
1
Задача
Kotlin SELF, 60 уровень, 1 лекция
Недоступна
Стабильный порядок
Стабильный порядок
1
Задача
Kotlin SELF, 60 уровень, 1 лекция
Недоступна
Логи с полями
Логи с полями
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ