JavaRush /Курси /Kotlin SELF /Винятки в логах

Винятки в логах

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

1. Вступ

Коли програма ламається, новачкові дуже хочеться зробити так: зловили виняток, вивели e.message — і заспокоїлися. Це схоже на ситуацію, коли у вас у машині загорівся «Check Engine», а ви наклеїли зверху скотч із написом «усе гаразд». Так, лампочка більше не дратує, але машина все ще в незрозумілому стані. А головне — ви втратили інформацію, за якою можна швидко полагодити проблему.

Із винятками в логах завдання рівно таке саме: зберегти максимум корисного для діагностики, але не перетворити застосунок на «балакучого папугу», який на кожну дрібницю друкує роман на 20 сторінок. У цій лекції ми знайдемо баланс: додамо stack trace, навчимося правильно «обгортати» винятки зі збереженням причини, а також виберемо вдалі місця для логування, щоб не дублювати одне й те саме на кожному рівні.

Що саме ми логуємо: Throwable, Exception, Error

Перш ніж друкувати stack trace, важливо розуміти: у Kotlin (і на JVM) винятки — це об’єкти, і в них є ієрархія. Корінь цієї ієрархії — Throwable, від якого наслідуються Exception і Error. Exception — це те, що зазвичай можна (і іноді потрібно) обробляти, а Error — це умовне «всесвіт зламався», як-от OutOfMemoryError, коли ваш застосунок рідко може адекватно продовжити роботу.

Практичний наслідок для нашого логування простий: коли ми робимо API логера виду error(..., e: Throwable), ми не обмежуємося лише Exception. Ми готові прийняти все, що справді «прилетіло». А от у catch зазвичай розумно ловити саме Exception (або конкретні типи), тому що ловити Error і робити вигляд, що «нічого не сталося», іноді гірше, ніж чесно впасти.

Невеличка ілюстрація (не «правило на всі випадки», але корисний орієнтир):

fun main() {
    try {
        val x = "not a number".toInt()
        println(x)
    } catch (e: Exception) {
        println("Не вдалося прочитати число") // користувацьке
    }
}

Тут ми ловимо Exception, бо це типова помилка перетворення. А от якщо JVM почне сипатися через нестачу пам’яті — прикидатися, що все добре, зазвичай безглуздо.

2. Stack trace: що це і чому він важливий

Stack trace — це звіт середовища виконання про те, які функції викликалися, доки програма не дійшла до точки, де стався виняток. Простими словами, це «маршрут», яким помилка прийшла до нас у гості. На JVM stack trace зазвичай друкується автоматично, якщо виняток не оброблено.

Ключове: stack trace відповідає на запитання «ДЕ саме впало?». Повідомлення винятку (e.message) частіше відповідає на запитання «ЧОМУ приблизно впало?» — і то не завжди зрозуміло. Тому правило для логів таке: якщо ми логуємо помилку рівня ERROR, то в більшості випадків хочемо бачити stack trace. Інакше налагодження перетворюється на вгадування.

Мініприклад «як виглядає падіння зі stack trace» (ми не будемо копіювати вивід повністю, але ідею ви вже бачили раніше в темі винятків):

fun boom() {
    error("Бум!")
}

fun main() {
    boom()
}

Якщо це не зловити, JVM надрукує і тип винятку, і місце, і ланцюжок викликів.

3. stackTraceToString(): зручний спосіб покласти stack trace в лог

Навіть друк stack trace можна зіпсувати, якщо зробити його «аби як». Наприклад, намагатися вручну пробігти e.stackTrace і склеїти рядки — заняття корисне для розвитку терпіння, але зазвичай зайве.

На JVM у Kotlin є зручний метод Throwable.stackTraceToString(), який повертає stack trace одним рядком. Це зручно для нашого консольного логера: ми можемо вивести один рядок лога з повідомленням і контекстом, а наступним блоком — stack trace. Так і читається нормально, і копіювати зручно.

Зробімо маленький допоміжний приклад: зловили виняток, перетворили stack trace на рядок, вивели.

fun main() {
    try {
        "12x".toInt()
    } catch (e: Exception) {
        println(e.stackTraceToString()) // тут буде stack trace
    }
}

Так, це ще не «логер», але вже видно: stack trace можна отримувати керовано, а не лише «автоматично під час падіння».

4. Розширюємо Logger: повідомлення + контекст + виняток

Тепер зберемо все в звичну для нас конструкцію: Logger — єдина точка, де вирішується форматування й виведення. Ми вже вміємо друкувати timestamp, рівень, повідомлення, контекст. Тепер додамо параметр Throwable? і домовимося про формат: спочатку основний рядок лога, а потім (якщо виняток є) окремим блоком друкуємо його stack trace.

Важлива думка: код, який викликає логер, не повинен думати «як красиво надрукувати виняток». Він має просто передати Throwable, а логер зробить решту.

Приклад (шматочок; припускаємо, що formatCtx(...) у вас уже є з минулої лекції про контекст):

class Logger(private val minLevel: LogLevel) {

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

    fun error(message: String, ctx: Map<String, String> = emptyMap(), e: Throwable? = null) {
        if (!enabled(LogLevel.ERROR)) return
        val ts = System.currentTimeMillis()
        println("$ts [ERROR] $message" + formatCtx(ctx))
        if (e != null) println(e.stackTraceToString())
    }
}

Зверніть увагу: ми приймаємо Throwable?, а не лише Exception. І друкуємо stack trace тільки тоді, коли e != null. Це важливо, тому що іноді ERROR — це «збій сценарію без винятку», наприклад «не вдалося зберегти файл, бо немає прав» (ви могли обробити це і повернути помилку як значення). Але якщо виняток усе-таки є — ми його не втрачаємо.

5. Причина (cause): як не втратити першоджерело проблеми

Дуже поширена помилка — «обгортати» виняток, але втрачати початкову причину. Виглядає це так: десь глибоко впало з корисним винятком (наприклад, NumberFormatException), а нагорі ви кинули новий IllegalStateException("Щось пішло не так") без прив’язки до початкового. У результаті stack trace показує верхній шар, а справжня причина зникає — і ви потім годинами шукаєте, хто саме «щось пішло не так».

Правильне обгортання на JVM: створити новий виняток і передати початковий як cause.

Мініприклад «як треба»: ловимо, додаємо контекст, прокидаємо далі з причиною.

fun parsePort(raw: String): Int {
    try {
        return raw.toInt()
    } catch (e: Exception) {
        throw IllegalArgumentException("Порт має бути числом: raw='$raw'", e)
    }
}

Сенс цього коду не в тому, щоб «усе ловити», а в тому, щоб на цьому рівні додати зрозумілий контекст. Якщо вище хтось залогує цей виняток, у stack trace буде видно і наш шар (із повідомленням про порт), і початкову причину (наприклад, NumberFormatException), яка реально пояснює, що сталося.

6. Межі відповідальності: де логувати виняток

Найнеприємніший вид логів — коли один і той самий виняток друкується 5 разів поспіль, бо його «про всяк випадок» логує кожен рівень: у функції парсингу, у функції обробки команди, у main, у логері сховища і ще десь в утилітці, про яку ніхто не пам’ятає. Логи роздуваються, важливі повідомлення губляться, а ви читаєте це як «Війну і мир», тільки без П’єра Безухова й без сенсу.

Тому нам потрібне поняття межі відповідальності. Проста робоча модель для консольного застосунку:

  • Внутрішні функції або обробляють помилку й повертають керування «в нормальному вигляді», або прокидають виняток вище (можливо, обгорнувши з cause).
  • Логування винятку відбувається в точці, де в нас є контекст операції і де ми ухвалюємо рішення: «сценарій завершився помилкою».

Якщо у вас командний застосунок, то такою межею часто є обробник команди: він знає command, вхідні параметри, можливо requestId, і саме він вирішує, що показати користувачу.

Схематично це можна уявити так:

flowchart TD
    A[main читає рядок] --> B[dispatcher: визначаємо команду]
    B --> C[handler команди]
    C --> D[внутрішні функції: парсинг/валідація/зберігання]
    D -->|throw| C
    C -->|логуємо ERROR + ctx + stacktrace| E[Logger]
    C -->|користувацьке повідомлення| F[println для користувача]

Точка C — часто ідеальне місце для логування: там уже достатньо інформації, але ми ще не піднялися надто високо, щоб не втратити деталі сценарію.

7. Практика: обробник команди як місце логування

Візьмімо типовий фрагмент командного застосунку: користувач вводить команду, ми намагаємося розпарсити аргумент (наприклад, суму), а потім щось зробити. Нехай наш проєкт уже має Logger і контекст.

Зробімо обробник, який ловить виняток рівно один раз: логує його, а користувачу показує коротке повідомлення.

fun handleAdd(amountRaw: String, logger: Logger) {
    val ctx = mapOf("command" to "add", "amountRaw" to amountRaw)

    try {
        val amount = amountRaw.toInt()
        println("Додали витрату: $amount") // приклад користувацького виводу
    } catch (e: Exception) {
        logger.error("Не вдалося обробити команду add", ctx = ctx, e = e)
        println("Помилка: сума має бути цілим числом") // користувацьке
    }
}

Тут є кілька важливих «дорослих» рішень, хоча код короткий.

Ми логуємо технічну помилку з контекстом і stack trace, щоб розробник (або ви за тиждень) міг зрозуміти, де впало. А окремо пишемо користувачу дружнє повідомлення — без зайвих деталей. Користувачу зазвичай не потрібен NumberFormatException, йому потрібно зрозуміти, «що зробити інакше».

8. Корисні нюанси логування винятків

Не логуємо надто глибоко: контексту мало, шуму багато

Іноді здається логічним: «я зловив виняток там, де він стався, отже, там і залогую». Але в глибині застосунку часто немає контексту операції. Наприклад, функція parseAmount(raw) знає лише рядок, але не знає команду, не знає requestId, не знає сценарій. У результаті логи виходять безликими: «Parse failed», «Parse failed», «Parse failed» — і все.

У такій ситуації краще зробити одну з двох речей:

Перший варіант — прокинути виняток вище без логування, але, якщо потрібно, обгорнути його, додавши зміст і зберігши cause. Так у нас з’явиться корисне повідомлення, але лог усе одно буде один — нагорі.

Другий варіант — повернути помилку як значення (якщо у вас уже є така архітектура), але це потребує дисципліни. Сьогодні ми тримаємося ближче до try/catch і винятків, бо завдання лекції — саме логування винятків.

Приклад «глибоко не логуємо, а додаємо зміст і прокидаємо далі»:

fun parseAmount(raw: String): Int {
    try {
        return raw.toInt()
    } catch (e: Exception) {
        throw IllegalArgumentException("Некоректна сума: raw='$raw'", e)
    }
}

А логувати будемо нагорі — там, де є command та інші поля.

Як читати stack trace: що в ньому корисно насамперед

Stack trace може виглядати лячно, особливо на початку навчання: багато рядків, незрозумілі класи, іноді якісь внутрішні речі JVM. Але гарна новина: у 80 % випадків вам потрібні всього дві речі.

По-перше, верхній рядок із типом винятку та повідомленням.

По-друге, перші кілька рядків at ... — доти, доки ви не побачите свій файл і свій рядок. Як правило, найцінніше місце — це перший кадр у вашому коді, де справді впало (наприклад, Main.kt:42), а не там, де логер друкує.

Щоб допомогти собі, ви можете домовитися про стиль: ми друкуємо основний рядок лога (timestamp/level/message/ctx), а stack trace — окремим блоком одразу після. Тоді візуально зрозуміло: «ось подія», «ось деталі винятку».

Не «глушимо» винятки мовчки

У логуванні винятків є ще одна типова проблема: «зловив — нічого не зробив — пішов далі». Зовні програма продовжує працювати, але насправді вона може перейти в дивний стан: дані частково оновлено, стан не узгоджено, користувач отримав «успіх», хоча насправді стався збій.

Тому правило просте: якщо ви зловили виняток, то або коректно обробили ситуацію (наприклад, попросили користувача ввести дані заново), або принаймні залогували й завершили сценарій зрозумілим чином. «З’їсти виняток» без лога — це майже завжди «взяти в борг проблеми у майбутнього себе».

Приклад поганого коду:

fun risky() {
    try {
        "x".toInt()
    } catch (e: Exception) {
        // тиша...
    }
}

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

9. Типові помилки

Помилка № 1: логувати лише e.message і вважати, що це «діагностика».
Повідомлення винятку часто надто коротке й не відповідає на головне запитання «де впало». Stack trace якраз створено, щоб показувати ланцюжок викликів і точку падіння. Якщо в ERROR-лозі немає stack trace, то під час реального пошуку проблеми ви майже гарантовано витратите більше часу, ніж могли б.

Помилка № 2: обгортати виняток і втрачати причину (cause).
Коли ви кидаєте новий виняток «із більш зрозумілим текстом», але не передаєте початковий як cause, ви знищуєте найціннішу частину діагностики: першопричину та початковий stack trace. Правильна техніка — передавати початковий виняток другим параметром конструктора, щоб cause зберігся й потрапив у stack trace.

Помилка № 3: логувати один і той самий виняток на кожному рівні викликів.
Якщо і парсер, і обробник команди, і main друкують ERROR зі stack trace для однієї помилки, логи перетворюються на дублікати. Це не «більше інформації», це «більше шуму». Потрібна одна узгоджена точка логування — зазвичай межа операції, де у вас максимум контексту.

Помилка № 4: ловити надто широкий тип і продовжувати роботу, ніби нічого не сталося.
Ловити Throwable у catch і робити вигляд, що все ок, — небезпечно, тому що ви можете зловити ситуації рівня Error, де продовження роботи не гарантовано. Для більшості сценаріїв достатньо ловити Exception або конкретні винятки, а логер при цьому має вміти прийняти будь-який Throwable, якщо ви хочете залогувати те, що справді прилетіло.

Помилка № 5: показувати користувачу stack trace замість нормального повідомлення.
Stack trace корисний розробнику, але майже марний користувачу й часто лякає. Хороший стиль — користувачу коротко: «неправильне введення», «не вдалося відкрити файл», «спробуйте ще раз». А в лог — усе технічне: рівень ERROR, контекст, stack trace. Так ви одночасно дбаєте і про UX, і про діагностику.

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