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 напечатает и тип исключения, и место, и цепочку вызовов.
4. 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 можно получать управляемо, а не только «автоматически при падении».
5. Расширяем 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 — это «сбой сценария без исключения», например «не удалось сохранить файл, потому что нет прав» (вы могли обработать это и вернуть ошибку как значение). Но если исключение всё-таки есть — мы его не теряем.
6. Причина (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), которая реально объясняет, что произошло.
7. Границы ответственности: где логировать исключение
Самый неприятный вид логов — когда одно и то же исключение печатается 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 — часто идеальное место для логирования: там уже достаточно информации, но мы ещё не поднялись слишком высоко, чтобы потерять детали сценария.
8. Практика: обработчик команды как место логирования
Возьмём типичный кусочек командного приложения: пользователь вводит команду, мы пытаемся распарсить аргумент (например, сумму), а потом что-то сделать. Пусть наш проект уже имеет 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, ему нужно понять «что сделать иначе».
9. Полезные нюансы логирования исключений
Не логируем слишком глубоко: контекста мало, шума много
Иногда кажется логичным: «я поймал исключение там, где оно случилось, значит, там и логирую». Но в глубине приложения часто нет контекста операции. Например, функция 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) {
// тишина...
}
}
Даже если вы не хотите падать, минимум — залогировать на правильной границе ответственности.
10. Типичные ошибки
Ошибка №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, и о диагностике.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ