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:

ConstantValueCovers
ScopeServerServerStart-up, listening, shutdown, panics
ScopeRouterRouterRoute registration and the routing table
ScopeRequestRequestThe per-request access log
ScopeDocsDocsThe 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,
    },
})
FieldDefaultWhat it does
Levelslog.LevelInfoThe minimum level emitted. Pass a *slog.LevelVar to change it while the process runs
FormatLogFormatAutoAuto, Console, JSON or None
Outputos.StderrWhere records are written, which keeps logs out of a program's data output
Colorterminal and NO_COLORForces ANSI colour on or off for the console format
TimeFormat15:04:05.000The timestamp layout in the console format
ScopeWidth16The column reserved for the bracketed scope. A longer scope pushes the message right rather than being truncated
AddSourceoffRecords the source file and line. It costs a stack walk per record
RedactKeysDefaultRedactedKeysReplaces the redaction list
ShortRequestIDonTruncates request identifiers to eight characters in the console format only

Formats

ValueBehaviour
LogFormatAutoConsole when the output is an interactive terminal, JSON otherwise
LogFormatConsoleAlways the aligned, optionally coloured console format
LogFormatJSONAlways one JSON object per record
LogFormatNoneDiscards 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.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.