1. Noise in logs: why it hurts
When you start writing logs, a typical stage happens: “log everything, we’ll sort it out later.” It’s like putting ten alarm clocks in a room “just in case” — you don’t wake up earlier, you wake up in panic. Log noise is when important events (an error, a warning, a key step in a scenario) get lost among dozens of lines like “we entered the function,” “we exited the function,” “we read a space.”
The problem with noise is not only aesthetic. Overly detailed logs make diagnostics harder: you spend time searching, and your brain gets overloaded by repetitive messages. Plus, excessive logging can noticeably slow down a program (even a console one): string interpolation, building large messages, assembling context — all of that is work you do even if you later decide not to show those messages.
To make it easier to think about, it helps to treat logging levels not as “nice words,” but as an agreement:
| Level | Meaning | Should it be visible by default? |
|---|---|---|
|
details for the developer, the “internal kitchen” | usually no |
|
normal important milestones in the scenario | usually yes |
|
suspicious, but the program continues to run | yes |
|
an error; the scenario could not be completed | yes |
Our goal today is to learn how to enable/disable details without rewriting the program and without “commenting out println by hand,” and also to learn how not to write unnecessary things (especially unnecessary and dangerous things).
2. Verbose logging mode
The verbose flag and choosing minLevel
Verbose mode is a simple switch for “more details.” In console applications, it’s very convenient to tie it to launch arguments: run normally — see INFO/WARN/ERROR; run with "--verbose" — also see DEBUG. This is not a “feature for big companies”; it’s literally a way to keep output clean in the normal scenario and turn on details when something starts behaving oddly.
Let’s start with the simplest check: is "--verbose" among the arguments of main(args: Array<String>)? In Kotlin it’s done almost in plain English:
fun isVerbose(args: Array<String>): Boolean =
args.contains("--verbose")
Now we tie verbose to the minimum logging level. If verbose is off, we log starting from INFO. If it’s on, starting from DEBUG.
fun chooseMinLevel(args: Array<String>): LogLevel {
return if (isVerbose(args)) LogLevel.DEBUG else LogLevel.INFO
}
And in main we create the logger:
fun main(args: Array<String>) {
val logger = Logger(minLevel = chooseMinLevel(args))
logger.info("App started") // will print almost always
logger.debug { "Details..." } // will print only with --verbose
}
At this point, a common question is: “Why not make DEBUG the default and not worry?” Because then you’ll quickly stop reading the logs. And if a developer stops reading logs, logs turn into decoration. Nice-looking, but useless.
Lazy DEBUG: debug { ... } instead of debug("...$heavy")
Even if you filter by level, there is a sneaky trap: the message is often built before the logger decides whether to print it. For example, string interpolation in Kotlin does the work immediately:
logger.debug { "state=${heavyStateDump()}" } // correct: computed only in DEBUG
But if you write an “early” string build, computations will start regardless of whether DEBUG is enabled:
val message = "state=${heavyStateDump()}" // heavyStateDump() will be called always
logger.debug { message }
In small examples you won’t notice it, but in real programs (especially when you do joinToString, serialization, table formatting) it can become unpleasant: you “don’t print,” but you still spend CPU, memory, and time.
The solution is to make debug lazy: accept not a String, but a lambda () -> String, and call it only if the level is enabled.
Below is a compact version of a logger with lazy DEBUG and context support. The key point: we format context centrally so we don’t repeat this code everywhere.
enum class LogLevel { DEBUG, INFO, WARN, ERROR }
class Logger(private val minLevel: LogLevel) {
private fun isEnabled(level: LogLevel): Boolean =
level.ordinal >= minLevel.ordinal
fun debug(message: () -> String, ctx: Map<String, String> = emptyMap()) {
if (!isEnabled(LogLevel.DEBUG)) return
log(LogLevel.DEBUG, message(), ctx)
}
fun info(message: String, ctx: Map<String, String> = emptyMap()) =
log(LogLevel.INFO, message, ctx)
fun warn(message: String, ctx: Map<String, String> = emptyMap()) =
log(LogLevel.WARN, message, ctx)
fun error(message: String, ctx: Map<String, String> = emptyMap()) =
log(LogLevel.ERROR, message, ctx)
private fun log(level: LogLevel, message: String, ctx: Map<String, String>) {
if (!isEnabled(level)) return
val ts = System.currentTimeMillis()
println("$ts [$level] $message${formatCtx(ctx)}")
}
private fun formatCtx(ctx: Map<String, String>): String {
val safe = sanitizeCtx(ctx)
if (safe.isEmpty()) return ""
return safe.entries.joinToString(prefix = " ", separator = " ") { (k, v) -> "$k=$v" }
}
private fun sanitizeCtx(ctx: Map<String, String>): Map<String, String> {
return ctx.mapValues { (k, v) ->
if (looksSensitiveKey(k)) maskSecret(v) else v
}
}
private fun looksSensitiveKey(key: String): Boolean {
val k = key.lowercase()
return k.contains("token") || k.contains("password") || k.contains("secret") || k.contains("api_key")
}
}
By the way, if you format context with joinToString(), that may also not be “free.” joinToString() is a fine and readable tool for building text from a collection, but it’s better not to build the final string unless you need it — especially in DEBUG. The example above accounts for this: formatting happens only if the level is actually printed.
3. Where to place a log so it helps
It’s tempting to give the rule “log everything at every step,” but that’s like the advice “to avoid losing your keys, tape them to your hand.” Technically you won’t lose them, but life gets harder. In logging, it’s more useful to think not about the number of lines, but about meaning boundaries.
A good intuition is: INFO is “big milestones” of the scenario, DEBUG is “internal details,” WARN is “suspicious but tolerable,” ERROR is “we could not perform the action.” Then you start placing logs not “everywhere,” but at boundaries.
For example, handling a command in a console app is a great candidate for an “operation boundary.” There you have context (requestId, command, arguments), and it’s reasonable to:
- log the start of processing (INFO),
- in DEBUG log argument-parsing details,
- for weird input data emit WARN,
- on an exception or failure emit ERROR,
- log completion (INFO), possibly with timing.
Inner loops where you iterate over a list of 1000 elements generally should not be logged at INFO — otherwise you’ll get a log flood. If you really need it, put that log at DEBUG and make it lazy (via a lambda), otherwise you get noise plus the extra cost of building the message.
4. Security: what you must not write to logs
The most dangerous phrase in the history of software development is “I’ll add this temporarily to debug.” Temporary code lives longer than many relationships, and logs sometimes “travel” into bug reports, terminal history, CI, screenshots, and corporate chats. And that’s where it turns out that “temporarily” you logged a password.
It’s important to accept a rule: logs are more public than they look. Even if you have a local console program, you might send a log to a friend/teacher/in a ticket, and suddenly it’s a leak.
The most typical “don’ts” in logs:
- passwords, PIN codes, one-time codes (OTP),
- authorization tokens (API keys, bearer tokens),
- secret keys, private keys,
- full card numbers,
- sometimes email/phone (depends on the project policy), but in a learning project it’s better to practice “don’t log extra stuff.”
A particularly unpleasant category is log context. We love it for the key=value structure, but it’s also a trap: you can absentmindedly put token=... there, because “it’s context, not the message.” From a security standpoint, it doesn’t matter where you wrote it: in the text or in the context. If it reached the output, it reached the log.
5. Data masking: useful, but not dangerous
Masking is a compromise: we don’t want to store the secret in full, but we want to be able to understand “is it the same token or a different one.” So people often keep a small “tail” and replace the rest with ****. This lets you distinguish token=...1234 from token=...9876 without revealing the entire secret.
Let’s start with a basic function. It’s intentionally short so it’s easy to understand the logic and not drown in “magic.”
fun maskSecret(value: String): String {
if (value.length <= 4) return "****"
val tail = value.substring(value.length - 4)
return "****$tail"
}
Example usage:
fun main() {
val token = "abcd-efgh-ijkl-1234"
println(maskSecret(token)) // ****1234
}
Next comes the practical question: “What about context?”
The idea is simple: if the key in the context looks like a secret key (token, password, secret), then the value should be masked automatically. In this lecture we implement a simple rule based on the key name. In real projects, instead of “contains the word token” there may be stricter rules, but the principle is the same: security should be the default.
Pay attention: masking is not “once and forever.” It works if you agreed on context keys. If one developer writes authToken, another writes token, and a third writes t, then the automatic rule will start letting secrets through. That’s why real projects standardize key names. In a learning project, we do the same thing, just without bureaucracy.
6. Putting it together: "--verbose" and safe context
Now we’ll neatly stitch everything together using a small fragment of a console application. The point of the example is not to “build a whole system,” but to see that verbose, lazy debug, and masking really do work together and don’t require heroics.
Imagine we have a "login" command that takes a token. In real life, you must not log the token. But during debugging, we want to understand that the token arrived at all and what it is “approximately” (to distinguish one from another). So we log only the mask.
First, let’s assemble the operation context and requestId:
fun makeRequestId(): String = "req-${System.currentTimeMillis()}"
fun makeBaseCtx(requestId: String, command: String): Map<String, String> {
return mapOf("requestId" to requestId, "command" to command)
}
Next, the command handler:
fun handleLogin(rawToken: String, logger: Logger) {
val requestId = makeRequestId()
val ctx = makeBaseCtx(requestId, "login")
logger.info("Login started", ctx)
// Important: even in DEBUG we log only the mask, not the full secret
logger.debug({ "rawToken=${maskSecret(rawToken)}" }, ctx)
logger.info("Login finished", ctx)
}
Notice the “psychological” moment. We could write it safely without a lambda (for example, build the string in advance), but the habit “everything debug is lazy and careful” builds discipline: you make fewer mistakes because you more often ask yourself what exactly you are logging and when it gets computed.
Now let’s assemble main, which turns on verbose by the flag:
fun main(args: Array<String>) {
val logger = Logger(minLevel = chooseMinLevel(args))
val token = readln().trim()
handleLogin(rawToken = token, logger = logger)
}
In a normal run (INFO), the user won’t see diagnostic details. In verbose mode ("--verbose"), they will see a DEBUG line with the token mask. And that’s exactly the balance we need: quiet by default, detailed when there’s a problem — but safe.
7. Common mistakes
Mistake No. 1: verbose is always on because “it’s more convenient.”
When DEBUG is enabled all the time, habituation happens quickly: you stop distinguishing the important from the noise. As a result, when a real problem occurs, you go back to “adding more logs,” even though the problem wasn’t the amount, it was discipline. Treat DEBUG as a diagnostics mode, not a lifestyle.
Mistake No. 2: building a heavy message even when DEBUG is off.
In words, it seems like if DEBUG is off then “nothing happens.” In reality, computations may still happen — you just don’t see the result. In small programs it’s not noticeable, but in real logic (building large strings, joinToString, serialization, table formatting) you pay the debugging tax even in normal mode. Make DEBUG lazy: debug { ... }, and don’t build the string ahead of time.
Mistake No. 3: masking in only one place, and then “we’ll forget somewhere else.”
If you mask secrets manually in every logger call, sooner or later you will forget. The most reliable path is to sanitize context and sensitive values centrally (inside Logger or in a single utility) so security is the default.
Mistake No. 4: “I’ll print the token only in DEBUG; that’s safe.”
DEBUG is not a “secret level.” It’s just a detail level. Logs can end up anywhere regardless of level. If a value must not be shown, it must not be shown at any level. At most: a mask or a short identifier.
Mistake No. 5: logging errors at the wrong level “to avoid scaring people.”
Sometimes you want to write “well, it’s an expected error, let’s use INFO.” If an error is truly expected and the scenario continues correctly, maybe it’s WARN (and even that is not always). If the scenario failed, it’s ERROR. When levels are used honestly, it becomes much easier to filter events and understand what is happening.