Logging
Muzak logs through log/slog. The handler it installs picks its format by looking at
where its output is going: the aligned console format on a terminal, one JSON object per
record anywhere else.
14:32:07.482 INFO [Server] Starting Muzak application...
14:32:07.492 INFO [Server] Listening on :8080
14:32:10.114 INFO [Request] GET /items/ status=200 duration=412µs bytes=181 request_id=0611f4b2
14:32:10.115 INFO [UsersService] user created user_id=42 request_id=0611f4b2
Colour is used only when the output is a terminal and NO_COLOR is unset.
Reaching the logger
// The application's logger.
log := app.Logger()
// Inside a handler, the same logger annotated by the logging middleware with
// per-request attributes such as the method, path and request identifier.
func ListItems(ctx *muzak.Context, _ muzak.Empty) (schemas.ItemListOut, error) {
ctx.Logger().Info("listing items")
// ...
}
Scopes
Give each subsystem its own scope so its lines line up in the console and can be filtered in production.
log := muzak.Scoped(app.Logger(), "UsersService")
log.Info("user created", "user_id", 42)
14:32:10.115 INFO [UsersService] user created user_id=42
The scope is an ordinary attribute under muzak.ScopeKey, which the console handler
lifts out of the attribute list and renders as the bracketed column after the level.
Scoped returns the logger unchanged when it is nil, so it is safe to call on an
application that has not been built yet.
The framework uses four scopes of its own, so its lines can be told apart from yours at a glance:
| Constant | Value | Covers |
|---|---|---|
ScopeServer | Server | Start-up, listening, shutdown, panics |
ScopeRouter | Router | Route registration and the routing table |
ScopeRequest | Request | The per-request access log |
ScopeDocs | Docs | The OpenAPI document and the documentation UI |
Configuring it
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
LoggerOptions: muzak.LoggerOptions{
Level: slog.LevelDebug,
Format: muzak.LogFormatConsole,
},
})
| Field | Default | What it does |
|---|---|---|
Level | slog.LevelInfo | The minimum level emitted. Pass a *slog.LevelVar to change it while the process runs |
Format | LogFormatAuto | Auto, Console, JSON or None |
Output | os.Stderr | Where records are written, which keeps logs out of a program's data output |
Color | terminal and NO_COLOR | Forces ANSI colour on or off for the console format |
TimeFormat | 15:04:05.000 | The timestamp layout in the console format |
ScopeWidth | 16 | The column reserved for the bracketed scope. A longer scope pushes the message right rather than being truncated |
AddSource | off | Records the source file and line. It costs a stack walk per record |
RedactKeys | DefaultRedactedKeys | Replaces the redaction list |
ShortRequestID | on | Truncates request identifiers to eight characters in the console format only |
Formats
| Value | Behaviour |
|---|---|
LogFormatAuto | Console when the output is an interactive terminal, JSON otherwise |
LogFormatConsole | Always the aligned, optionally coloured console format |
LogFormatJSON | Always one JSON object per record |
LogFormatNone | Discards every record. The fastest option, and what tests use to keep output clean |
ShortRequestID never applies to JSON output, where the full identifier is always
written.
Changing the level at runtime
var level slog.LevelVar
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
LoggerOptions: muzak.LoggerOptions{Level: &level},
})
// Later, from a handler or a signal:
level.Set(slog.LevelDebug)
Bringing your own logger
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
Logger: slog.New(myHandler),
})
AppOptions.Logger takes precedence, and LoggerOptions is then unused. To build
Muzak's own handler outside an application, call muzak.NewLogger:
log := muzak.NewLogger(muzak.LoggerOptions{Format: muzak.LogFormatJSON})
The result is safe for concurrent use, redacts sensitive attributes, and performs no formatting work for records below its level.
Redaction
Credentials reach logs by accident far more often than by design, most often through an
attribute carrying a whole header map or request struct. Attribute values under these keys
are replaced with muzak.RedactedPlaceholder, which is [redacted]:
authorization proxy-authorization cookie set-cookie
password passwd secret token
access-token refresh-token api-key apikey
private-key client-secret session credentials
log.Info("calling upstream", "api_key", key)
14:32:10.115 INFO [Upstream] calling upstream api_key=[redacted]
Matching is case-insensitive and ignores - and _, so API-Key, api_key and apikey
are all caught.
Replace the list with one of your own:
muzak.LoggerOptions{
RedactKeys: append(muzak.DefaultRedactedKeys, "internal_user_id"),
}
Passing an empty, non-nil slice disables redaction entirely, which is only appropriate when nothing sensitive can reach the logger.
Redaction covers attribute values, not message text. log.Info("token is " + token) is
still a leak, and no key-based rule can catch it.
The access log
One line per request, written by the middleware Muzak installs by default.
14:32:10.114 INFO [Request] GET /items/ status=200 duration=412µs bytes=181 request_id=0611f4b2
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
AccessLogOptions: muzak.AccessLogOptions{
Level: slog.LevelInfo,
SkipPaths: []string{"/healthz", "/metrics"},
},
})
Successful responses use Level. Server errors are always logged at error level and
client errors at warn level, so a quiet production level still surfaces failures.
Only fixed, non-sensitive fields are recorded. Query strings, request bodies and headers are deliberately omitted, because each of them routinely carries credentials or personal data that should not be duplicated into a log store.
DisableAccessLog removes the middleware entirely.
Request identifiers
Every request is assigned one, echoed in the X-Request-Id response header, copied into
the request_id member of an error body, and recorded under muzak.RequestIDKey on
every log line the request produces.
// Inside a handler.
ctx.RequestID()
// In code that has only a context.Context, such as a repository or a client
// wrapper that wants to propagate the identifier downstream.
id, ok := muzak.RequestIDFromContext(ctx)
The console handler shortens values under that key to their first eight characters, which
keeps a development session narrow while JSON output keeps the identifier in full. Turn it
off with ShortRequestID.
Identifiers are UUID version 7, which sort chronologically, so a log store's index on the field is a time index for free.
Logging from a goroutine that outlives the request
A Context is pooled and reused, so it must not be captured. Pass ctx.Context() instead
and copy out whatever else you need first.
func Enqueue(ctx *muzak.Context, in JobIn) (JobOut, error) {
log := muzak.Scoped(ctx.Logger(), "Jobs")
requestCtx := ctx.Context()
id := in.ID
go func() {
if err := process(requestCtx, id); err != nil {
log.Error("job failed", "job_id", id, "error", err.Error())
}
}()
return JobOut{ID: id}, nil
}
Quiet logs in tests
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
LoggerOptions: muzak.LoggerOptions{Format: muzak.LogFormatNone},
})
Where to go next
Error Handling covers what reaches the log when a request fails, and Testing covers exercising an application in-process.