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, і про діагностику.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ