1. Introduction
When a program breaks, a beginner often really wants to do this: catch the exception, print e.message, and calm down. It is like a situation where your car’s “Check Engine” light comes on, and you stick tape over it with the words “everything’s fine”. Yes, the light no longer annoys you, but the car is still in a weird state—and most importantly, you lost the information that would let you fix the problem quickly.
With exceptions in logs, the task is exactly the same: preserve as much diagnostically useful information as possible, but do not turn the application into a “chatty parrot” that prints a 20-page novel for every little thing. In this lecture we will find a balance: add a stack trace, learn how to properly “wrap” exceptions while preserving the cause, and choose the right places for logging so we do not duplicate the same thing at every layer.
What exactly do we log: Throwable, Exception, Error
Before printing a stack trace, it is important to understand that in Kotlin (and on the JVM) exceptions are objects and they have a hierarchy. The root of this hierarchy is Throwable, which is extended by Exception and Error. Exception is what you can usually (and sometimes should) handle, while Error is “the universe broke” (roughly speaking), like OutOfMemoryError, where your application rarely can continue to operate adequately.
The practical consequence for our logging is simple: when we design a logger API like error(..., e: Throwable), we are not limiting ourselves to Exception. We are ready to accept anything that actually got thrown. But in a catch it is usually reasonable to catch Exception (or specific types), because catching Error and continuing “as if nothing happened” is sometimes worse than failing honestly.
A small illustration (not a “rule for all cases”, but a useful guideline):
fun main() {
try {
val x = "not a number".toInt()
println(x)
} catch (e: Exception) {
println("Could not read the number") // user-facing
}
}
Here we catch Exception because it is a typical conversion error. But if the JVM starts running out of memory, pretending everything is fine is usually pointless.
2. Stack trace: what it is and why it matters
A stack trace is the runtime’s report of which functions were called until the program reached the point where the exception occurred. In plain human terms: it is the “route” by which the error came to visit us. On the JVM, a stack trace is usually printed automatically if the exception is not handled.
The key point: a stack trace answers the question “WHERE exactly did it crash?”. The exception message (e.message) more often answers “WHY did it (roughly) crash?”, and even that is not always clear. So the rule for logs is: if we are logging an ERROR-level failure, then in most cases we want to see the stack trace; otherwise debugging turns into guessing.
A mini example of “what a crash with a stack trace looks like” (we will not copy the full output, but you have already seen the idea earlier in the exceptions topic):
fun boom() {
error("Boom!")
}
fun main() {
boom()
}
If you do not catch it, the JVM will print the exception type, the location, and the call chain.
4. stackTraceToString(): a convenient way to put a stack trace into a log
It is also possible to mess up printing a stack trace by doing it “however”. For example, trying to manually iterate over e.stackTrace and join the lines is a great exercise in patience, but it is usually unnecessary.
On the JVM, Kotlin has a convenient method Throwable.stackTraceToString(), which returns the stack trace as a single string. This is convenient for our console logger: we can print one log line with message and context, and then the next chunk can be the stack trace as a separate block. It stays readable and is easy to copy.
Let’s make a small helper example: catch an exception, convert the stack trace to a string, print it.
fun main() {
try {
"12x".toInt()
} catch (e: Exception) {
println(e.stackTraceToString()) // stack trace will be here
}
}
Yes, this is not yet a “logger”, but it already shows that you can obtain the stack trace in a controlled way, not only “automatically on crash”.
5. Extending Logger: message + context + exception
Now let’s assemble everything into the familiar structure: Logger is the single place where formatting and output are decided. We already know how to print timestamp, level, message, context. Now we add a Throwable? parameter and agree on a format: first the main log line, then (if an exception is present) print its stack trace as a separate block.
An important idea: the calling code should not have to think “how do I print the exception nicely”. It should simply pass a Throwable, and the logger will do the rest.
Example (a snippet; assume you already have formatCtx(...) from the previous lecture about context):
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())
}
}
Notice: we accept Throwable?, not only Exception. And we print the stack trace only when e != null. This matters because sometimes ERROR is a “scenario failure without an exception”, for example “could not save the file because there are no permissions” (you might have handled it and returned an error as a value). But if an exception is present—we do not lose it.
6. Cause (cause): how not to lose the root source of the problem
A very common mistake is to “wrap” an exception but lose the original cause. It looks like this: somewhere deep down it failed with a useful exception (for example, NumberFormatException), and at the top you throw a new IllegalStateException("Something went wrong") without linking it to the original. As a result, the stack trace shows the top layer, while the real cause disappears, and you then spend hours looking for who exactly “something went wrong”.
Correct wrapping on the JVM: create a new exception and pass the original one as the cause.
Mini example of “how it should be done”: catch, add context, rethrow further with the cause.
fun parsePort(raw: String): Int {
try {
return raw.toInt()
} catch (e: Exception) {
throw IllegalArgumentException("Port must be a number: raw='$raw'", e)
}
}
The point of this code is not to “catch everything”, but to add clear context at this layer. If someone higher up logs this exception, the stack trace will show both our layer (with the port message) and the original cause (for example, NumberFormatException), which actually explains what happened.
7. Responsibility boundaries: where to log an exception
The most unpleasant kind of logs is when the same exception is printed 5 times in a row because it is logged “just in case” at every layer: in the parsing function, in the command handler, in main, in the storage logger, and somewhere else in a utility that nobody remembers. Logs bloat, important messages get lost, and you end up reading it like “War and Peace”, except without Pierre Bezukhov or meaning.
So we need the notion of a responsibility boundary. A simple, practical model for a console application:
- Internal functions either handle the error and return control “in a normal form”, or they throw the exception upward (possibly wrapping with a cause).
- Exception logging is done at the point where we have operation context and where we make the decision: “the scenario ended with an error”.
If your application is command-based, then such a boundary is often the command handler: it knows the command, input parameters, possibly requestId, and it decides what to show the user.
Schematically it can be represented like this:
flowchart TD
A[main reads a line] --> B[dispatcher: determine the command]
B --> C[command handler]
C --> D[internal functions: parsing/validation/storage]
D -->|throw| C
C -->|log ERROR + ctx + stacktrace| E[Logger]
C -->|user-facing message| F[println for the user]
Point C is often the ideal place to log: there is already enough information, but we have not gone too high to lose the scenario details.
8. Practice: the command handler as the logging point
Let’s take a typical piece of a command app: the user enters a command, we try to parse an argument (for example, an amount), and then do something. Assume our project already has Logger and context.
Let’s make a handler that catches the exception exactly once, logs it, and shows the user a short message.
fun handleAdd(amountRaw: String, logger: Logger) {
val ctx = mapOf("command" to "add", "amountRaw" to amountRaw)
try {
val amount = amountRaw.toInt()
println("Added an expense: $amount") // example user output
} catch (e: Exception) {
logger.error("Failed to process command add", ctx = ctx, e = e)
println("Error: the amount must be an integer") // user-facing
}
}
There are several important “grown-up” decisions here, even though the code is short.
We log the technical error with context and stack trace so the developer (or you a week later) can understand where it failed. And separately we write a friendly message to the user without unnecessary details. The user typically does not need NumberFormatException; they need to understand “what to do differently”.
9. Useful nuances of exception logging
Do not log too deep: too little context, too much noise
Sometimes it seems logical: “I caught the exception where it happened, so I should log it there”. But deep inside an application there is often no operation context. For example, parseAmount(raw) only knows the string; it does not know the command, does not know requestId, does not know the scenario. As a result, logs become faceless: “Parse failed”, “Parse failed”, “Parse failed”—and that is it.
In such a situation it is better to do one of two things:
Option one: rethrow the exception upward without logging, but if needed, wrap it by adding meaning and preserving the cause. This gives us a useful message, but the log will still be a single one at the top.
Option two: return an error as a value (if your architecture already supports that), but it requires discipline. Today we stay closer to try/catch and exceptions, because the lecture’s goal is specifically exception logging.
Example: “do not log deep; instead add meaning and rethrow”:
fun parseAmount(raw: String): Int {
try {
return raw.toInt()
} catch (e: Exception) {
throw IllegalArgumentException("Invalid amount: raw='$raw'", e)
}
}
And we will log at the top, where there is command and other fields.
How to read a stack trace: what is useful first
A stack trace can look scary, especially early on: many lines, unfamiliar classes, sometimes some JVM internals. But the good news is: for 80% of cases you only need two things.
First, the top line with the exception type and message.
Second, the first few at ... lines until you see your file and your line. As a rule, the most valuable place is the first frame in your code where it actually failed (for example, Main.kt:42), not where the logger prints.
To help yourself, you can agree on a style: we print the main log line (timestamp/level/message/ctx), and the stack trace as a separate block immediately after. Then visually it is clear: “here is the event”, “here are the exception details”.
Do not “swallow” exceptions silently
In exception logging there is another typical problem: “caught it—did nothing—moved on”. On the surface the program keeps working, but in reality it may enter a weird state: data is partially updated, state is inconsistent, the user got “success” even though a failure actually occurred.
So the rule is simple: if you catch an exception, then you either handled the situation correctly (for example, asked the user to re-enter the data), or at least logged it and ended the scenario in an understandable way. “Eating an exception” without a log is almost always “borrowing trouble from your future self”.
Example of bad code:
fun risky() {
try {
"x".toInt()
} catch (e: Exception) {
// silence...
}
}
Even if you do not want to crash, at minimum log at the correct responsibility boundary.
10. Common mistakes
Mistake #1: log only e.message and think that is “diagnostics”.
An exception message is often too short and does not answer the main question: “where did it crash?”. A stack trace is designed to show the call chain and the crash point. If there is no stack trace in an ERROR log, then when you search for the real problem you will almost certainly spend more time than you could have.
Mistake #2: wrap an exception and lose the cause (cause).
When you throw a new exception “with clearer text” but do not pass the original one as the cause, you destroy the most valuable part of diagnostics: the root cause and the original stack trace. The correct technique is to pass the original exception as the second constructor parameter so the cause is preserved and shows up in the stack trace.
Mistake #3: log the same exception at every call level.
If the parser, the command handler, and main all print an ERROR with a stack trace for one error, the logs turn into duplicates. That is not “more information”, it is “more noise”. You need one agreed-upon logging point—usually the operation boundary where you have the most context.
Mistake #4: catch too broad a type and keep going as if nothing happened.
Catching Throwable in catch and pretending everything is OK is dangerous, because you can catch Error-level situations where continued execution is not guaranteed. For most scenarios it is enough to catch Exception or specific exceptions, while the logger should be able to accept any Throwable if you want to log what actually arrived.
Mistake #5: show the user a stack trace instead of a normal message.
A stack trace is useful to a developer, but almost useless to a user and often scary. Good style is: a short user message—“invalid input”, “could not open the file”, “try again”. And in the log—everything technical: ERROR level, context, stack trace. This way you care about both UX and diagnostics.
GO TO FULL VERSION