JavaRush/Courses/Kotlin SELF/Log context: key-value fields and formatting

Log context: key-value fields and formatting

Open

1. Why logs need context

When an application is small, it feels like context is unnecessary: we already remember that “before add there was parse”. But as soon as you have multiple commands, multiple processing steps, a couple of helper functions, and at least one error, the logs start to look like a chat where people keep replying in the wrong thread. You see lines, but you don’t understand which ones belong to the same operation.

Imagine our console expense tracker (let’s call it ExpenseTracker) processes the command add 120 food. Along the way it does several things: reads the line, parses it, validates it, adds a record, prints the result. If the logs contain only “Started”, “Parsed”, “Saved”, then with two quick commands back-to-back you’ll easily get interleaving and start guessing: “Is ‘Saved’ for the first command or the second?”.

Here is a typical example of “logs without context” (not code, but what you see in the console):

1700000000000 [INFO] Command received
1700000000100 [INFO] Parsed command
1700000000150 [INFO] Command received
1700000000200 [INFO] Saved expense
1700000000250 [INFO] Parsed command
1700000000300 [INFO] Saved expense

Your eyes see familiar words; your brain sees chaos. We need a way to “stitch” related messages together with a shared set of fields.

2. Log context: what it is and why it matters

Log context is a small set of key-value fields that accompanies a message and answers: “Within what did this happen?”. The message itself says “what happened”, and the context clarifies “what it’s related to”: which command, which requestId, which user input, which run mode.

Important: context is not an attempt to cram everything into message. Quite the opposite: we try to keep message short and human (Expense saved), and move details into a structure (amount=120 category=food requestId=req-42). This makes logs easier to scan and easier to filter (even if your filtering is still “with your eyes” and Ctrl+F).

You can think of it like this:

Log part Answers Example
message
“what happened?”
Expense saved
context
“what does it relate to?”
requestId=req-42 command=add amount=120 category=food

And that already looks like “normal engineering traces”, not a diary of the program’s feelings.

3. Context as Map<String, String>

To keep context simple, portable, and familiar, we’ll store it as Map<String, String>. This is a perfect match for our task: Map stores key-value pairs, and keys are unique (one value per key). That’s how maps work in Kotlin: a set of entries (key-value pairs), where it’s usually convenient to use strings as keys.

Let’s start with the simplest thing: create context directly via mapOf(...):

fun main() {
    val ctx = mapOf(
        "requestId" to "req-42",
        "command" to "add"
    )

    println(ctx["requestId"]) // req-42
}

Keys here are a convention. Kotlin won’t stop you from writing "reqId" in one place and "request_id" in another, but later you’ll be the one suffering. So from the start we treat keys like a “mini-API”: they must be stable.

One more important point: we chose String for values not because “it’s trendy”, but because logs are eventually printed as text anyway, and we’ll have to convert values to strings regardless. If we make Map<String, Any?>, we gain flexibility, but for beginners it becomes easier to accidentally log “the whole object” and get kilometer-long lines. We stay in a safe sandbox: strings only.

4. Formatting context into a string

So we have context. But println(ctx) prints something like {requestId=req-42, command=add}—sometimes fine, but we want a single unified format so logs look consistent and are easy to search. The most common “human-friendly” format is key=value separated by spaces.

To build that string, joinToString() is a great fit: it turns a collection of elements into one string according to given rules (separator, prefix, etc.).

Let’s write formatCtx(...). It should return an empty string when context is empty (so we don’t print an extra space), and add a leading space when there are fields:

fun formatCtx(ctx: Map<String, String>): String {
    if (ctx.isEmpty()) return ""

    return ctx.entries.joinToString(
        prefix = " ",
        separator = " "
    ) { (k, v) -> "$k=$v" }
}

There’s a bit of nice magic here: ctx.entries is a set of Map.Entry, and we use destructuring (k, v) so we don’t have to write it.key and it.value.

Let’s verify the result:

fun main() {
    val ctx = mapOf("requestId" to "req-42", "command" to "add")

    println("Hello" + formatCtx(ctx))
    // Hello requestId=req-42 command=add
}

Now your logs will look like you’re already a grown-up company, not just someone who really loves println.

Field order

When context is small, order doesn’t matter much. But if you want the same context to always print the same way (which helps you compare lines visually), you can sort keys.

Here’s a “slightly cleaner” version that’s still simple:

fun formatCtxSorted(ctx: Map<String, String>): String {
    if (ctx.isEmpty()) return ""

    return ctx.entries
        .sortedBy { it.key }
        .joinToString(prefix = " ", separator = " ") { (k, v) -> "$k=$v" }
}

Yes, sorting is extra work, but at the level of a learning console app it’s fine, and readability benefits.

5. Updating Logger: context in the API

Right now our logger can print a timestamp, a level, and a message. Our goal is to add a third part after the message: formatCtx(ctx). It’s important to do this so the calling code doesn’t think about formatting, and only passes data.

Below is a minimal version of Logger, compatible with what we built earlier: minLevel, comparison via ordinal, unified format:

class Logger(private val minLevel: LogLevel) {

    private fun enabled(level: LogLevel): Boolean =
        level.ordinal >= minLevel.ordinal

    fun log(level: LogLevel, message: String, ctx: Map<String, String> = emptyMap()) {
        if (!enabled(level)) return

        val ts = System.currentTimeMillis()
        println("$ts [$level] $message" + formatCtx(ctx))
    }

    fun info(message: String, ctx: Map<String, String> = emptyMap()) =
        log(LogLevel.INFO, message, ctx)
}

Note the default parameter: ctx: Map<String, String> = emptyMap(). This means old calls like logger.info("Started") won’t break, while new ones can add context: logger.info("Started", ctx).

6. One request, one context

Context becomes truly useful when we stop “bolting it on when we feel like it” and start thinking like this: every operation has a base context, and it gets reused across several logs in a row.

In a console app, an operation is, for example, processing one command. So we need a requestId (a correlation identifier). It doesn’t have to be crypto-strong or globally unique; we only need it to be “distinct within a single app run”.

The simplest generator:

fun newRequestId(): String {
    val ts = System.currentTimeMillis()
    return "req-$ts"
}

Yes, if you’re an incredibly fast robot and send two commands within the same millisecond, you’ll get a collision. For a learning project, that’s acceptable.

Now the base context for a command:

fun commandCtx(requestId: String, rawLine: String): Map<String, String> {
    return mapOf(
        "requestId" to requestId,
        "raw" to rawLine
    )
}

And the best part: we can “add” fields to context with the + operator, because Map + Pair returns a new map (it does not mutate the old one):

fun main() {
    val base = mapOf("requestId" to "req-42")
    val extended = base + ("command" to "add")

    println(extended)
    // {requestId=req-42, command=add}
}

This gives us a clean style: base context lives alongside the operation, and details are added where needed.

7. A helper ctxOf(...) for context without null

When you start logging more details, you’ll notice an annoying thing: some values are “temporarily unknown”. For example, category might not be parsed yet, amount might be invalid, and so on. You don’t want to put "category" to "null" into context—it's noisy and unhelpful.

So it’s useful to create a small utility ctxOf(...) that accepts key to value pairs, converts values to strings, and neatly drops null. mapNotNull works well here because it lets you “transform or drop”.

Here’s a simple version:

fun ctxOf(vararg pairs: Pair<String, Any?>): Map<String, String> {
    return pairs
        .mapNotNull { (k, v) -> v?.toString()?.let { k to it } }
        .toMap()
}

Usage:

fun main() {
    val amount: Int? = null

    val ctx = ctxOf(
        "requestId" to "req-42",
        "amount" to amount,          // null — will be dropped
        "command" to "add"
    )

    println(ctx) // {requestId=req-42, command=add}
}

This function keeps context compact: you don’t print “empty information”. And yes, this is that rare case where a small “smart” helper genuinely improves readability instead of making life harder.

8. Practical example: handling a command in ExpenseTracker

Now let’s assemble everything into one story. We have a console app that reads a command line and processes it. Suppose for now we support add and list, and treat everything else as an error. We’ll keep user output (what a person sees) as plain println("..."), and logs will go through logger.

We’ll start with simple parsing into a Pair: the command and the rest of the line. (Yes, it can be done more nicely, but we keep focus on log context.)

fun splitCommand(line: String): Pair<String, String> {
    val trimmed = line.trim()
    val firstSpace = trimmed.indexOf(' ')
    if (firstSpace == -1) return trimmed to ""
    return trimmed.substring(0, firstSpace) to trimmed.substring(firstSpace + 1)
}

Now a handler for a single line. Notice: we create requestId once and reuse it across multiple logs.

fun handleLine(line: String, logger: Logger) {
    val requestId = newRequestId()
    val baseCtx = ctxOf("requestId" to requestId, "raw" to line)

    logger.info("Command received", baseCtx)

    val (cmd, rest) = splitCommand(line)
    val cmdCtx = baseCtx + ("command" to cmd)

    logger.info("Command parsed", cmdCtx)

    // User output separate from logs
    println("OK, I got command: $cmd") // OK, I got command: add
}

If you run it twice in a row, you’ll see that each log “chain” can be stitched together by requestId:

1700000000000 [INFO] Command received requestId=req-1700000000000 raw=add 120 food
1700000000001 [INFO] Command parsed requestId=req-1700000000000 raw=add 120 food command=add
OK, I got command: add

Even if logs get interleaved in time, requestId will be your “wire” connecting events.

A small processing flow diagram

To reinforce the idea “context is created once and lives for the whole operation”, it helps to picture command processing as a short pipeline:

flowchart TD
    A["readln()"] --> B[requestId + baseCtx]
    B --> C[log: Command received]
    C --> D[parse command]
    D --> E[log: Command parsed + command]
    E --> F[execute]
    F --> G[log: Command finished]

Context is not a separate stage; it’s the “glue” that follows the stages.

Format: short message + details in context

Notice that our messages are as simple as possible: Command received, Command parsed. We don’t write a “poem” in the message text because poems are hard to scan. Context provides precision: raw=... command=... requestId=....

What to put into context

Context can easily turn into a junk drawer: “let’s put everything in there”. Then you get screen-long lines and zero value. So it helps to keep three rules in mind: context should help link events, help diagnose a problem, and be safe in terms of content.

In our CLI app, these fields are usually enough:

Key Meaning Example
requestId
stitching messages of a single operation
req-1700000000000
command
which command is being executed
add
raw
the original command line
add 120 food
amount
amount (if present)
120
category
category (if present)
food

Note: we are not trying to log “the entire application state” or “all expenses”. If you feel like logging “everything”, that’s usually a sign you haven’t decided which data is actually important yet.

Also agree on a key naming style. In Kotlin code, camelCase looks nice (requestId, userId, tookMs). If you mix request_id and requestId, then a week later you’ll be writing “why doesn’t search find it” and sadly staring out the window.

9. Typical mistakes when working with log context

Mistake #1: context is “embedded” into the message text manually.
When you write logger.info("Command received requestId=$id raw=$line"), you’ve kind of added details, but you’ve lost the main benefit: a unified format. In one place it will be raw=..., in another input=..., in a third you’ll forget a space, and in a fourth you’ll accidentally add an extra comma. Keep context as a separate structure (Map) and format it strictly inside Logger.

Mistake #2: different keys are used for the same meaning.
Today you write reqId, tomorrow requestId, the day after tomorrow rid. A month later you can’t compare logs visually, and you can’t filter them properly even with a simple text search. Choose keys once (as constants or just as a convention) and stick to them like an “API for your future self”.

Mistake #3: context is assembled in pieces and “drifts” from log to log.
If one message has requestId and in the next you forget to pass it, the chain breaks and the whole idea of correlation is gone. A good style is to create baseCtx at the beginning of the operation and then extend it via + so base fields never get lost.

Mistake #4: context includes too many or too large values.
Technically you can put a huge text or a serialized object in there, but then it becomes impossible to read. Context should be compact: short numbers, short identifiers, short statuses. If you want to log something “big”, that’s usually a separate event and a separate decision, not a context field.

Mistake #5: context is made mutable and changed “on the fly” so it’s unclear what it was at the moment of logging.
If you use MutableMap and add/remove fields while processing, it’s easy to accidentally reuse the same map across different operations (especially if you store a reference somewhere). For beginners it’s simpler and safer to stick to immutable mapOf(...) and create new maps via +. Yes, that creates new objects, but in a learning console app the cost is small and predictability is huge.

Mistake #6: context contains data that shouldn’t be printed.
Even in learning projects, it’s useful to get used to the idea that logs can end up anywhere: terminal history, CI, a file you send to someone. So from the start, keep discipline: context is for technical diagnostics, not “everything we know about the world”. If you’re unsure whether you should print a value, you probably shouldn’t (or print a redacted version).

1
Task
Kotlin SELF,  60 level,  1 lecture
Locked
Operation context
Operation context
1
Task
Kotlin SELF,  60 level,  1 lecture
Locked
Printing the context
Printing the context
1
Task
Kotlin SELF,  60 level,  1 lecture
Locked
Stable order
Stable order
1
Task
Kotlin SELF,  60 level,  1 lecture
Locked
Logs with fields
Logs with fields
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet