1. Lots of println is still not logging
When an app is small, it feels like you can just write println("I am here"), println("and now here"), println("and now not here anymore"). It works! But at some point you hit the classic situation: you catch a weird bug, run the program, see ten lines saying “I am here”, and… you don’t understand which “here” mattered and in what order everything happened.
It helps to separate two kinds of console output. The first is user-facing output: prompts, command results, messages like “Enter a number”, “Added”, “Deleted”. The second is the program’s technical trace: what it decided to do, which logic branches it took, what data it processed, what warnings it noticed. That second kind is called logging.
And an important idea: logs don’t have to be “pretty” or “friendly”. Their job is to be stable, consistent, and easy to scan with your eyes.
To make logs manageable, we need two things: severity levels and a single format.
2. Log levels and the minimum level
If you imagine that a program is a person, then logs are its diary. Only the diary is not about feelings, but about “what I did and why”. And, like in any diary, not all entries are equally important.
In this lecture we will introduce four classic levels:
| Level | Meaning in plain English | Typical situations |
|---|---|---|
|
“Details for the developer” | intermediate values, branching, processing details |
|
“Normal program life” | start/stop, command processed, action completed successfully |
|
“Something suspicious, but we can live with it” | odd input, skipping data, non-ideal situation without crashing |
|
“The scenario failed” | operation error, exception, cannot continue the scenario |
The main practical point of levels is the ability to say: “Show me only warnings and errors, and hide the details.” This is called the minimum logging level (minLevel): a log is printed only if its level is not below the configured one.
3. Minimal Logger: levels, format, and filtering
LogLevel as an enum class
When we set levels as strings ("WARN", "ERROR"), we create a trap for ourselves: "WARM", "ERORR", "warning"—the compiler will accept it, and later you’ll be hunting for why the filtering “sometimes works”.
That’s why log levels are an ideal candidate for an enum class: it’s a fixed set of options that the compiler can control.
enum class LogLevel {
DEBUG, INFO, WARN, ERROR
}
What’s convenient here is that enum stores elements in declaration order, and each element has an ordinal (0, 1, 2, 3...). You can use this order to compare levels: the “more to the right” a level is, the more serious it is.
A small warning for the future (no panic): if you later change the order of levels in the enum class, the meaning of filtering by ordinal will change too. For a learning mini-logger this is fine, but it’s useful to remember.
A single log format
When logs are printed in a “whatever goes” style, they’re hard to read. One person writes "[INFO] started", another writes "INFO: start", a third writes "start ok". In the end, even visually it’s hard to understand what’s where.
A minimally useful format (already enough to live with) is:
timestamp [LEVEL] message
Why is timestamp useful? Because it gives an ordering of events and lets you at least roughly estimate pauses. Why do we use plain System.currentTimeMillis()? Because it’s available immediately, without pretty date formatting, and right now discipline matters more than beauty. Beauty is easy to add later, but the habit of writing logs in a single consistent way is a bit harder.
Let’s make a small function to format a log line. It doesn’t have to be a separate function, but it’s easier to read that way.
fun formatLogLine(ts: Long, level: LogLevel, message: String): String {
return "$ts [$level] $message"
}
If you call formatLogLine(123, LogLevel.WARN, "Suspicious input"), you get a predictable line:
123 [WARN] Suspicious input
And “predictable” is the key word here.
The Logger class: filtering and printing in one place
Now let’s assemble everything into a Logger class. The idea is very simple: decide three things in one place.
The first task: is it allowed to print a message of this level with the current minLevel?
class Logger(private val minLevel: LogLevel) {
private fun enabled(level: LogLevel): Boolean {
return level.ordinal >= minLevel.ordinal
}
}
The second task: if it is allowed—build a line using the single format and print it.
class Logger(private val minLevel: LogLevel) {
private fun enabled(level: LogLevel): Boolean =
level.ordinal >= minLevel.ordinal
fun log(level: LogLevel, message: String) {
if (!enabled(level)) return
val ts = System.currentTimeMillis()
println("$ts [$level] $message")
}
}
Note the style: we use a guard clause—“if we can’t, exit immediately”. This makes the code straightforward: you don’t have to nest everything inside an if.
Now let’s add a small test to feel how minLevel works:
fun main() {
val logger = Logger(minLevel = LogLevel.WARN)
logger.log(LogLevel.DEBUG, "Details") // will not be printed
logger.log(LogLevel.INFO, "Started") // will not be printed
logger.log(LogLevel.WARN, "Odd input") // will be printed
logger.log(LogLevel.ERROR, "Failed") // will be printed
}
The output will be approximately like this (your timestamp will of course be different):
1700000000000 [WARN] Odd input
1700000000001 [ERROR] Failed
And that’s already a huge win over “a hundred println calls all over the code”.
Methods debug/info/warn/error
If you have to write logger.log(LogLevel.INFO, "...") every time, it gets old quickly. Also, the code reads worse: your eye keeps tripping over the repeated LogLevel.
That’s why loggers almost always provide wrapper methods.
class Logger(private val minLevel: LogLevel) {
private fun enabled(level: LogLevel): Boolean =
level.ordinal >= minLevel.ordinal
fun log(level: LogLevel, message: String) {
if (!enabled(level)) return
val ts = System.currentTimeMillis()
println("$ts [$level] $message")
}
fun debug(message: String) = log(LogLevel.DEBUG, message)
fun info(message: String) = log(LogLevel.INFO, message)
fun warn(message: String) = log(LogLevel.WARN, message)
fun error(message: String) = log(LogLevel.ERROR, message)
}
Now the code starts to look like a story of what’s happening:
fun main() {
val logger = Logger(LogLevel.INFO)
logger.info("App started") // will be printed
logger.debug("x=10, y=20") // will not be printed
}
This filter is exactly why we started all of this: debug is for “digging”, info is for normal life.
User-facing output vs logs
It’s very easy to accidentally mix “messages to the user” and “logs” into one mess. In console apps this is especially tricky: both are just println.
Let’s agree on a simple rule: user-facing output stays as plain println, and technical events go through Logger.
Imagine we have a learning CLI app with scenarios like “add a record” and “show the list” (you’ve done something similar in the course). We won’t complicate the domain model—we care about the message flow itself.
fun handleAdd(rawAmount: String, logger: Logger) {
logger.info("Handle add started")
val amount = rawAmount.toIntOrNull()
if (amount == null) {
println("Error: amount must be a number") // user-facing message
logger.warn("Add failed: amount is not a number: '$rawAmount'")
return
}
println("Added: $amount") // user-facing message
logger.info("Add finished: amount=$amount")
}
Notice the difference in tone. The user-facing message doesn’t need to know that “handle started”. It needs it short: what’s wrong or what succeeded. And it’s fine for logs to be “dry” and technical: they’re not for the user, they’re for you (and for your future self who will try to remember what you meant).
Let’s test a mini-scenario:
fun main() {
val logger = Logger(LogLevel.INFO)
println("Enter amount:")
val raw = readln()
handleAdd(raw, logger)
}
If the user enters abc, the output will look roughly like this:
Enter amount:
abc
1700000000000 [INFO] Handle add started
Error: amount must be a number
1700000000001 [WARN] Add failed: amount is not a number: 'abc'
Yes, the user will see both the log and the message. In real applications, logs are often sent to a separate stream (for example, stderr) or to a file—but that’s another story. For now, what matters is learning to separate the intent.
Priority instead of ordinal
We’re already using ordinal, and it honestly works. But there’s a nuance: ordinal is tied to the declaration order inside the enum. If someone on the team decides “let’s put ERROR first because it’s the most important”—filtering will break.
To make the behavior more stable, you can set an explicit numeric priority. Still simple, but a bit more “grown-up”.
enum class LogLevel(val priority: Int) {
DEBUG(10),
INFO(20),
WARN(30),
ERROR(40)
}
Then the check becomes independent of the order:
class Logger(private val minLevel: LogLevel) {
private fun enabled(level: LogLevel): Boolean =
level.priority >= minLevel.priority
fun log(level: LogLevel, message: String) {
if (!enabled(level)) return
val ts = System.currentTimeMillis()
println("$ts [$level] $message")
}
fun debug(message: String) = log(LogLevel.DEBUG, message)
fun info(message: String) = log(LogLevel.INFO, message)
fun warn(message: String) = log(LogLevel.WARN, message)
fun error(message: String) = log(LogLevel.ERROR, message)
}
You can keep ordinal (for a learning project it’s fine), but it’s useful to understand what’s happening.
How it works
Sometimes it’s easier to see it once than read the text ten times. In logging, what matters is that the entire decision about printing is concentrated in one place.
flowchart TD
A["Application code
logger.info(...)"] --> B["Logger.enabled(level)"]
B -->|false| C["Print nothing"]
B -->|true| D["Format the line
ts + [LEVEL] + message"]
D --> E["println(...) in one place"]
If you get used to this discipline, you’ll gain a very pleasant superpower: when you want to change the log format or add new details, you’ll do it in one place instead of running around the project searching for println("DEBUG").
4. Common mistakes
Mistake #1: turning every message into INFO because “it’s easier this way”.
It really is easier... for one evening. Then you’ll have a million INFO lines, with an important warning or error hiding among them. Levels were invented not for beauty, but so you can control the noise: DEBUG is details, INFO is normal events, WARN is oddities, ERROR is an operation failure.
Mistake #2: formatting log messages differently in different places.
When one part of the code writes "ts=$ts level=$level msg=$message" and another writes "$level: $message", logs are impossible to “scan” with your eyes. The correct approach is: the line format should live inside Logger, and external code should pass only the meaningful message.
Mistake #3: mixing user-facing output and logging so the user sees the “internals”.
A user-facing message should answer “what should I do/what happened”, while a log should answer “what did the program do”. If your human-facing UI prints HandleAdd started; parsing raw=..., the user will feel like an unwilling tester. It’s better to keep println for UX and logger.* for diagnostics.
Mistake #4: making the filtering rule non-obvious.
If the logger sometimes prints and sometimes doesn’t, and the rule looks like “if today is Tuesday and the string is not empty, then print”, you’ll stop trusting the logs. At the basic level, the rule should be simple and single: print if level >= minLevel.
Mistake #5: using strings instead of an enum class for levels.
Strings are convenient right up until the first typo. An enum class gives you type safety: the compiler won’t let you pass an “invalid” level. This is exactly the case where strict types make life easier, not harder.
Mistake #6: forgetting that ordinal depends on the order of values in an enum.
As long as you have four levels and they are declared in the right order, everything is fine. But if you change the order, the filtering changes with it. If you want a bit more stability, define an explicit priority and compare by it.
Mistake #7: trying to solve all future logging tasks at once.
It’s very tempting at this point to start doing “pretty dates”, writing to a file, adding context, stack traces, metrics, and ten more things. But then instead of a simple working logger, you get a pain constructor. A minimal logger is good because you can introduce it quickly and immediately get value: levels, filtering, a single format.
GO TO FULL VERSION