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 напечатает и тип исключения, и место, и цепочку вызовов.

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, и о диагностике.

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