1. Why log hygiene matters
When you are just learning to program, logs feel like a “smart fmt.Println”: quickly peek at where you ended up and what is stored in a variable. But in real programs, logs live for a long time, get copied far away, and are read not only by you. That is why “hygiene” is about being careful: what you write, where you write it, how much, and in what style—so you do not end up embarrassed later, fixing an incident and explaining why a password ended up in the logs.
Imagine logs are not a private diary but a bulletin board in an apartment building lobby: you can post useful things, but it is better not to stick a photocopy of your passport and a PIN code for your bank card on it.
A small diagram to reinforce “where logs fly away to” (and why they rarely stay “only on your laptop”):
flowchart LR
A[Application] -->|stderr: logs| B[Console/service]
B --> C[File/rotation]
B --> D[Log collector]
D --> E[Storage/search]
E --> F[Dev team]
E --> G[Support/on-call]
2. Secrets and personal data in logs
What counts as a secret
When people say “do not log secrets,” a beginner usually nods and thinks: “OK, I won’t log the password.” That is a good start, but in practice there are more secrets, and they are trickier. In logs, you especially often see tokens, API keys, authorization headers, cookies, environment variable values, “helpful” dumps of structs, and generally anything that is convenient to print “for a minute,” and then you forget to remove.
A separate category is personal data. Formally, it might not be a “secret” in the cryptographic sense, but the consequences of a leak are no less serious: email, phone number, address, full name, and sometimes even the contents of a user’s tasks/notes. In our training TODO app, for example, the task text can be anything: from “buy milk” to “doctor appointment at 14:30, diagnosis such-and-such.” And if you log the task’s title “for debugging,” you suddenly do a very bad thing.
It is useful to keep a simple table of “what we do with data”:
| Category | Examples | What we do in logs |
|---|---|---|
| Access secrets | password, API key, Authorization: Bearer ..., session id | Do not log. If you really must—mask (redact) it. |
| Personal data | email, phone number, address, full name, note contents | Usually do not log. If needed—log the minimum (for example, user_id, but not email). |
| Internal system details | absolute paths, “raw” OS errors | You can log these, but carefully: do not turn it into a leak, do not show it to the user. |
| Technical metrics | duration, number of records | Log freely: it is safe and very useful. |
By the way, the general idea “show the user a simple message, keep details in the log” is considered a normal best practice: show a friendly text to the person, and keep the real cause in diagnostics so a developer can investigate. That is exactly what is illustrated in the example with an HTTP error handler, where the user gets a clear Message, and the detailed error is logged for the developer.
Masking secrets
A blanket ban like “do not log anything at all” sounds safe, but it leads to the other extreme: you stop understanding what is happening in the program. So we often use a compromise: redaction/masking. The idea is simple: we do not write the full secret, but we keep a small part so we can tell one token from another and correlate events.
The simplest technique is to show only the last 4 characters (or the first 4), replacing the middle with ***. This is not cryptography, but as a basic hygiene measure it works.
Example of a small masking function:
package main
import "strings"
func maskSecret(s string) string {
if len(s) <= 4 {
return "****"
}
return strings.Repeat("*", len(s)-4) + s[len(s)-4:]
}
Now let’s use it in a log. Suppose our CLI someday reads a token (for example, via env config, which you have already seen earlier). We will not log it “as is”:
package main
import (
"log/slog"
"os"
)
func main() {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
token := os.Getenv("TODO_TOKEN")
log.Info("config loaded", slog.String("token", maskSecret(token)))
}
It is important to understand the psychological trap: “I will log the token only in Debug.” And then someone enables Debug in prod “for five minutes,” and the tokens go into a centralized collector. So the rule is simple: a secret must not get into the log at any level. If you really need to correlate something, log not the secret but a safe identifier (for example, token_id, if you have it), or a mask.
3. Do not make noise: log levels by meaning
Noisy logs are when there is a lot of text but little meaning. The worst part about noise is that it does not look like an error: the program works, logs are written… it is just that nobody reads them. And when a real problem happens, you open the logs and see 50,000 lines of “starting,” “processing,” “still processing,” “processing again,” and somewhere among them one real warning got lost.
So logs do not turn into a wall of sound, slog has levels, and we must use them by meaning, not by mood. Let’s recall the level model as the “loudness of an event”:
| Level | When it is appropriate | Typical meaning |
|---|---|---|
|
debugging, details, algorithm steps | “what exactly happened inside” |
|
normal important events | “what useful thing we did” |
|
odd, but not a failure | “something suspicious” |
|
operation failed | “we did not do what we promised” |
Now the key point: the level is a noise filter. If you write everything as Info, there is no filter. If you write everything as Error, you are setting yourself on fire with anxiety. So the discipline is: detailed chatter goes to Debug, working facts go to Info, suspicious things go to Warn, failures go to Error.
One more important nuance: if you return an error upward and log it there, do not log every step along the way. This is related to the idea “log at the boundary” to avoid duplicates, and it pairs well with the recommendation to separate “user message” from “detail for the developer.”
4. Structured logs: stability, keys, and volume
The message should be stable
It is very tempting to write logs like a person: “Uh-oh, something went wrong, we’ll try again, hang tight!” But logs are not read only with human eyes—they are also searched, grouped, compared, and sometimes used to build statistics. So good logs have a property: stability.
A stable message is when the text is short, repeatable, and classifies the event, while the details live in fields. Then searching and analysis become easier: you search for "task created" and see all such events, rather than 20 variants like “created a task,” “task was created,” “task creation succeeded,” “yay, we created a task.”
Compare two approaches. Bad (because the text keeps changing):
log.Info("created task #42 with title 'Buy milk' in 12ms")
Good (because the text is stable and details are in fields):
log.Info(
"task created",
slog.Int("id", 42),
slog.Int("title_len", 8),
slog.Int("duration_ms", 12),
)
Notice the trick: instead of title we write title_len. Yes, it is less “convenient right now,” but you avoid leaking user data contents. And this is exactly an example of hygiene: logs are useful, but not toxic.
One meaning—one key
When you start adding fields, a new danger appears: not leaks and not noise, but key chaos. In one place you write op, in another operation, in a third action. In one place taskId, in another id, in a third task_id. The result is predictable: filtering and searching becomes impossible; it is “kind of structured logs,” but it feels like mush.
That is why we fix a small “dictionary” and stick to it:
| Key | What it means | Example |
|---|---|---|
|
subsystem/layer | |
|
action | |
|
entity identifier | |
|
duration | |
|
error | |
Also, keep the type stable. If id is a number, let it be a number everywhere. Otherwise you end up in a situation where in half the logs id=42, and in the other half id="42", and the filter suddenly stops working.
Do not turn Any into a dump
log/slog gives you a tempting button: slog.Any("something", x). It feels like you can just throw the whole object in there and be happy. But this is a common trap.
First, a large object can contain secrets and personal data “inside.” You might think you are logging a “task,” but inside the struct there might be, for example, the author’s email or a comment. Second, large objects make logs heavy: they are slower to write, harder to read, and more expensive to store. Third, they often break stability: the structure changes—and the log format changes too.
So the practice is: instead of “logging the whole world,” we log a minimal set of signals that helps diagnostics. Often this is id, count, duration, and a careful err.
For example, when listing tasks we usually do not need a full dump of the task array in the log. It is enough to know how many there are:
package main
import (
"log/slog"
"os"
)
func main() {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
tasks := []string{"buy milk", "write code", "sleep"}
log.Info("tasks loaded", slog.Int("count", len(tasks)))
}
If you really need to understand “which ones exactly,” it is better to do it temporarily and locally in development, rather than turning production logs into a file containing the entire user database.
5. Apply hygiene in the TODO CLI and do not break stdout
Now let’s ground all of this in our training project: a CLI for tasks. We already agreed that the command result goes to stdout, and diagnostics go to stderr. That means we configure our slog logger to write to os.Stderr, while printing tables/JSON/lists stays in fmt/tabwriter to stdout (you have already seen this separation earlier).
Let’s make a small command-run “skeleton” where we measure duration and log it as a field. The important thing here: the message is stable, details are in fields.
package main
import (
"log/slog"
"os"
"time"
)
func main() {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
start := time.Now()
exitCode := 0 // assume the command succeeded
log.Info("command finished",
slog.String("component", "cli"),
slog.String("operation", "todo"),
slog.Int("exit_code", exitCode),
slog.Duration("duration", time.Since(start)),
)
}
Now an “add task” example: we log the fact of creation by id, but we do not write the full title (remember personal data).
package main
import (
"log/slog"
)
func addTask(log *slog.Logger, id int, title string) {
log.Info("task created",
slog.String("operation", "task_add"),
slog.Int("id", id),
slog.Int("title_len", len(title)),
)
}
And here is an error handling example: we show the user a short message, and write err plus operation context into the log.
package main
import (
"fmt"
"log/slog"
)
func run(log *slog.Logger) int {
err := fmt.Errorf("open storage: permission denied")
if err != nil {
log.Error("command failed",
slog.String("operation", "task_list"),
slog.Any("err", err),
)
fmt.Println("ERROR: cannot list tasks") // stdout: short for the user
return 1
}
return 0
}
There is a subtle point here: sometimes a developer out of habit wants to show the user err.Error()—like, “let them know the truth.” But often this reveals internal details (file paths, system messages, internal error types). For a public interface this is a bad idea: internals should stay internal, and the contract with the user should be stable and safe.
6. Common mistakes in log hygiene
This topic seems simple right up until the first real incident, when you suddenly realize logs are read not only by you, and the data lives there longer than your memory of why you wrote it that way. Below are the rakes even very energetic beginners step on (and, to be honest, not only beginners), and almost always “with the best intentions.”
Mistake #1: log a secret “only during debugging,” then forget.
A classic: you add log.Debug("token", ...), verify everything works, then forget to remove it. A week later someone enables Debug in prod, and tokens end up in log storage. The cure is boring: do not log secrets at all; at most—a mask or a safe identifier.
Mistake #2: confuse useful diagnostics with a user message.
When your CLI sends “open /var/lib/todo/data.json: permission denied” to stdout, you both reveal internals and break the output format. It is better to keep the user message short and stable, and send details (including the original err) to the log on stderr.
Mistake #3: turn Info into “we’re alive, we’re alive, we’re alive” on every line.
If you write Info on every step of a loop, the log becomes noise. As a result, nobody sees real warnings. A simple rule usually helps: Info is for meaningful events (command start/finish, an important result), “step-by-step chatter” goes to Debug and only when you really need it.
Mistake #4: change keys and field types from place to place.
Today you write taskId=42, tomorrow id="42", the day after tomorrow task_id=42. On the surface it looks like “structured logs,” but filtering becomes impossible. Pick a key dictionary (operation, component, id, duration, err) and stick to it, and keep id as one type.
Mistake #5: log entire structures via Any without thinking about the data inside.
A secret or personal data may be inside the structure. Also, logs bloat and become unreadable. It is better to log minimal signals: id, count, duration, plus err on error. If you need to diagnose contents—do it selectively and consciously.
GO TO FULL VERSION