Logger
This guide covers logging in Tinka. You will learn how to:
- Write logs at six levels, with structured attributes
- Tag every log written during a request with the request's ID
- Configure readable logs for development and JSON logs for production
- Write logs to files and to several outputs at once
- Create child loggers that carry context into every record
- Hide sensitive values from log output
Overview
Tinka logs with the standard library's log/slog. The log package (github.com/mucolabs/tinka/log) builds a configured *slog.Logger, so every Go library that accepts a logger works with yours, and every slog.Handler written for Go works with Tinka. Records are written as JSON lines in production, ready for log collectors, and as readable lines in your terminal during development.
The logger integrates with the App. Every request gets an ID, and every record you log with the request's context carries it as request_id, so you can follow one request through the logs.
Writing your first log
Create a logger with log.New and call one of its methods. During development, log.Pretty writes a readable line with the time, the level and the message:
package main
import (
"github.com/mucolabs/tinka/log"
)
func main() {
logger := log.New(log.Config{Format: log.Pretty})
logger.Info("Processing home page request")
}[10:24:36.842] INFO: Processing home page requestslog has a method for each of its four levels, and Log writes at any level, including the two Tinka adds, trace and fatal:
logger.Log(ctx, log.LevelTrace, "Using config") // the most verbose
logger.Debug("User details", "user_id", 1)
logger.Info("Creating new post")
logger.Warn("Rate limit approaching")
logger.Error("Failed to save post", "err", err)
logger.Log(ctx, log.LevelFatal, "Database connection lost") // the most severeAdding context to logs
Pass key and value pairs after the message to add attributes to the record. They become fields of the JSON output, so log collectors can filter on them:
logger.Info("User logged in", "user_id", user.ID, "email", user.Email)Log errors under the err key, which slog writes as the error's message:
if err := publish(post); err != nil {
logger.Error("Publishing failed", "err", err, "post_id", post.ID)
}Request-aware logging
Log with the request's context, and the record carries the ID the App gave the request. The ID is the client's X-Request-Id header when it sends a valid one, and the same ID is sent back in the response and written in the App's access log:
package main
import (
"log/slog"
"net/http"
"github.com/mucolabs/tinka"
)
type Posts struct {
Log *slog.Logger
}
func (h Posts) Show(w http.ResponseWriter, r *http.Request) error {
id, err := tinka.PathInt(r, "id")
if err != nil {
return err
}
h.Log.InfoContext(r.Context(), "Fetching post", "post_id", id)
return tinka.JSON(w, http.StatusOK, map[string]int64{"id": id})
}[10:24:36.842] INFO: Fetching post post_id=42 request_id=6ZQ7K2M4XW1NB8P3R5T9VY0DCF
[10:24:36.843] INFO: request method=GET path=/posts/42 status=200 took=1.2ms request_id=6ZQ7K2M4XW1NB8P3R5T9VY0DCFPass the same logger to the App, so its access log and error records use your configuration:
logger := log.New(log.Config{Format: log.Pretty})
app := tinka.New(tinka.HTTPConfig{Logger: logger})
app.Get("/posts/{id}", Posts{Log: logger}.Show)To log from code that has the context but not the logger, make yours the default with slog.SetDefault(logger) and call slog.InfoContext(ctx, …).
You can add attributes of your own to a context with log.With, and every record logged with that context carries them:
ctx := log.With(r.Context(), slog.String("order_id", order.ID))
logger.InfoContext(ctx, "Order paid") // order_id and request_idConfiguring the logger
Build the logger's configuration from the environment at boot, with the rest of your configuration. Here the level comes from LOG_LEVEL, and the format depends on the Mode: readable lines in development, JSON elsewhere.
package main
import (
"github.com/mucolabs/tinka/env"
"github.com/mucolabs/tinka/log"
)
func loggerConfig(e *env.Env) log.Config {
format := log.JSON
if e.Mode() == env.Development {
format = log.Pretty
}
return log.Config{
Name: e.String("APP_NAME", "blog"),
Level: log.Level(e.OneOf("LOG_LEVEL", log.Levels, "info")),
Format: format,
}
}In production the same calls write one JSON object per line to standard output, where containers and platforms collect logs:
{"time":"2026-10-20T10:24:36.842Z","level":"INFO","msg":"Fetching post","name":"blog","post_id":42,"request_id":"6ZQ7K2M4XW1NB8P3R5T9VY0DCF"}Configuration reference
Log levels
The logger has six levels, from the most to the least verbose. A logger writes records at its level and above.
| Level | Name in LOG_LEVEL | Use for |
|---|---|---|
| log.LevelTrace | trace | Extremely detailed tracing |
| log.LevelDebug | debug | Details useful during development |
| log.LevelInfo | info | General operational information |
| log.LevelWarn | warn | Conditions that should be reviewed |
| log.LevelError | error | Errors that need attention |
| log.LevelFatal | fatal | Critical failures that need action now |
log.Level turns a name into a level, and log.Levels lists the names for env.OneOf, which reports any other value at boot. To change the level while the App runs, pass a *slog.LevelVar and call its Set method.
Customizing timestamps
By default, each record's time is written in RFC 3339 with nanoseconds. Set Time to log.TimeUnixMilli to write milliseconds since the Unix epoch, or to log.TimeOff to leave the time out when your platform stamps each line itself:
log.Config{Time: log.TimeUnixMilli}Writing logs to a file
Pass any io.Writer as Output. To write to a file, open it for appending:
f, err := os.OpenFile("/var/log/blog/app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
return err
}
logger := log.New(log.Config{Output: f})Tinka doesn't rotate files. Let a system tool such as logrotate do it, with its copytruncate option so the App keeps writing to the same file.
Writing to several outputs
log.NewHandler returns the handler behind a logger, and slog.NewMultiHandler sends every record to several of them. Each output keeps its own level and format, so you can read readable lines in your terminal while a file keeps JSON:
logger := slog.New(slog.NewMultiHandler(
log.NewHandler(log.Config{Format: log.Pretty, Level: log.LevelDebug}),
log.NewHandler(log.Config{Output: f, Level: log.LevelWarn}),
))Using multiple loggers
When a part of your App needs its own logging, such as payments written to their own file, create a second logger with its own configuration and pass it where it's needed:
appLog := log.New(loggerConfig(e))
paymentsLog := log.New(log.Config{Name: "payments", Output: paymentsFile})Passing the logger to your code
Give the code that logs a *slog.Logger as a field or a parameter, as Posts does above. Tests then pass a logger that writes to a buffer, or a disabled one, and code that runs during a request gets request_id from the context it already receives.
Child loggers
With returns a child logger that adds attributes to every record it writes, and WithGroup nests the attributes that follow under a name. A child keeps its parent's level, format and redaction:
orderLog := logger.With("order_id", "order_123")
orderLog.Info("Processing order") // order_id=order_123
orderLog.Info("Order complete") // order_id=order_123Conditional logging
When building a record's data is expensive, check whether the level is enabled before doing the work:
if logger.Enabled(ctx, log.LevelDebug) {
logger.DebugContext(ctx, "Cart contents", "items", expensiveSummary(cart))
}Hiding sensitive values
Logs can expose sensitive data by accident. Redact replaces the values of the attributes you name with [redacted]. A path names an attribute inside groups with dots, and * matches any one group or key:
logger := log.New(log.Config{
Redact: log.Redaction{Paths: []string{"password", "*.password", "card.*"}},
})
logger.Info("User signup", "username", "virk", "password", "secret123"){"time":"2026-10-20T10:24:36.842Z","level":"INFO","msg":"User signup","username":"virk","password":"[redacted]"}Set Censor to use another placeholder, or Remove to drop the attributes entirely:
log.Redaction{Paths: []string{"password"}, Censor: "[PRIVATE]"}
log.Redaction{Paths: []string{"password"}, Remove: true}Using env.Secret
An alternative to redaction is keeping the value in an env.Secret, which every logger writes as [redacted] wherever it appears. Values read with e.Secret from the environment already are:
key := e.Secret("STRIPE_KEY")
logger.Info("Payments ready", "key", key) // key=[redacted]Using slog directly
A Tinka logger is a plain *slog.Logger, so you can wrap its handler, use handlers from other libraries, or call slog functions as you would without Tinka. For a logger that doesn't come from log.New, log.Contextual adds the context's attributes to its records, request_id included.
See also: Configuration & Environment, App, Exception handling