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

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

Kotlin SELF
Рівень 60 , Лекція 1
Відкрита

1. Навіщо логам контекст

Коли застосунок маленький, здається, що контекст не потрібен: ми й так памʼятаємо, що «перед add було parse». Але щойно зʼявляються кілька команд, кілька кроків обробки, кілька допоміжних функцій і хоча б одна помилка, логи починають скидатися на переписку людей, які відповідають не в той чат. Рядки ви бачите, а от зрозуміти, які з них належать до однієї й тієї самої операції, уже складно.

Уявіть, що наш консольний трекер витрат (умовно назвемо його ExpenseTracker) обробляє команду add 120 food. У процесі він виконує кілька дій: читає рядок, парсить, валідує, додає запис, друкує підсумок. Якщо в логах є лише «Started», «Parsed», «Saved», то після двох швидких команд підряд ви легко отримаєте перемішування й почнете гадати: «А “Saved” — це про першу команду чи про другу?».

Ось типовий приклад «логів без контексту» (не код, а те, що ви бачите в консолі):

1700000000000 [INFO] Команду отримано
1700000000100 [INFO] Команду розібрано
1700000000150 [INFO] Команду отримано
1700000000200 [INFO] Витрату збережено
1700000000250 [INFO] Команду розібрано
1700000000300 [INFO] Витрату збережено

Очі бачать знайомі слова, а мозок — хаос. Потрібен спосіб «прошити» повʼязані повідомлення спільним набором полів.

2. Контекст лога: що це і навіщо

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

Важливо: контекст — це не спроба запхати все в message. Навпаки: ми намагаємося тримати message коротким і «людяним» (Витрату збережено), а деталі виносимо в структуру (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

Зараз логер уміє друкувати часову мітку, рівень і повідомлення. Наша мета — додати третю частину після повідомлення: 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("Команду отримано", baseCtx)

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

    logger.info("Команду розібрано", cmdCtx)

    // Користувацький вивід окремо від логів
    println("OK, я отримав команду: $cmd") // OK, я отримав команду: add
}

Якщо запустити двічі підряд, ви побачите, що кожен «ланцюжок» логів можна склеїти за requestId:

1700000000000 [INFO] Команду отримано requestId=req-1700000000000 raw=add 120 food
1700000000001 [INFO] Команду розібрано requestId=req-1700000000000 raw=add 120 food command=add
OK, я отримав команду: add

Навіть якщо логи перемішаються за часом, requestId буде вашим «проводом», який поєднує події.

Невеличка схема потоку обробки

Щоб закріпити думку «контекст створюється один раз і живе всю операцію», корисно уявити обробку команди як короткий конвеєр:

flowchart TD
    A["readln()"] --> B[requestId + baseCtx]
    B --> C[лог: Команду отримано]
    C --> D[парсити команду]
    D --> E[лог: Команду розібрано + command]
    E --> F[виконати]
    F --> G[лог: Команду завершено]

Контекст — це не окремий етап, а «клей», який супроводжує етапи.

Формат: коротке повідомлення + деталі в контексті

Зверніть увагу: повідомлення в нас максимально прості — Команду отримано, Команду розібрано. Ми не пишемо «поему» в тексті повідомлення, бо такі тексти погано шукати очима. Натомість контекст дає точність: 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, у файлі, який ви комусь надіслали. Тому краще від самого початку тримати дисципліну: контекст — це технічна діагностика, а не «все, що ми знаємо про світ». Якщо ви сумніваєтеся, чи варто друкувати значення, найімовірніше, не друкуйте (або друкуйте в урізаному вигляді).

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ