go-log/docs/architecture.md
Virgil f72c8daf3b
Some checks failed
CI / test (push) Failing after 2s
CI / auto-fix (push) Failing after 0s
CI / auto-merge (push) Failing after 0s
refactor(ax): align recovery helpers with AX docs
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-30 13:36:30 +00:00

7.1 KiB

title description
Architecture Internals of go-log -- types, data flow, and design decisions

Architecture

go-log is split into two complementary halves that share a single package: structured logging (log.go) and structured errors (errors.go). The two halves are wired together so that when an *Err value appears in a log line's key-value pairs the logger automatically extracts the operation name and stack trace.

Key Types

Level

type Level int

const (
    LevelQuiet Level = iota  // suppress all output
    LevelError               // errors only
    LevelWarn                // warnings + errors
    LevelInfo                // info + warnings + errors
    LevelDebug               // everything
)

Levels are ordered by increasing verbosity. A message is emitted only when its level is less than or equal to the logger's configured level. LevelQuiet suppresses all output, including errors.

Logger

type Logger struct {
    mu         sync.RWMutex
    level      Level
    output     io.Writer
    redactKeys []string

    // Overridable style functions
    StyleTimestamp func(string) string
    StyleDebug     func(string) string
    StyleInfo      func(string) string
    StyleWarn      func(string) string
    StyleError     func(string) string
    StyleSecurity  func(string) string
}

All fields are protected by sync.RWMutex, making the logger safe for concurrent use. The Style* function fields default to the identity function; consumers (such as a TUI layer) can replace them to add ANSI colour or other decoration without forking the logger.

Err

type Err struct {
    Op   string // e.g. "user.Save"
    Msg  string // human-readable description
    Err  error  // underlying cause (optional)
    Code string // machine-readable code (optional, e.g. "VALIDATION_FAILED")
    Retryable  bool          // whether the caller can retry the operation
    RetryAfter *time.Duration // optional retry delay hint
    NextAction string        // suggested next step when not retryable
}

Err implements both the error and Unwrap interfaces so it participates fully in the standard errors.Is / errors.As machinery.

Options and RotationOptions

type Options struct {
    Level      Level
    Output     io.Writer
    Rotation   *RotationOptions
    RedactKeys []string
}

type RotationOptions struct {
    Filename   string
    MaxSize    int  // megabytes, default 100
    MaxAge     int  // days, default 28
    MaxBackups int  // default 5
    Compress   bool // default true
}

When Rotation is provided and RotationWriterFactory is set, the logger writes to a rotating file instead of the supplied Output.

Data Flow

Logging a Message

caller
  |
  v
log.Info("msg", "k1", v1, "k2", v2)
  |
  v
defaultLogger.Info(...)          -- package-level proxy
  |
  v
shouldLog(LevelInfo)             -- RLock, compare level, RUnlock
  |  (if filtered out, return immediately)
  v
log(LevelInfo, "[INF]", ...)
  |
  +-- format timestamp with StyleTimestamp
  +-- scan keyvals for error values:
  |     if any value implements `error`:
  |       extract Op  -> append "op"    key if not already present
  |       extract FormatStackTrace -> append "stack" key if not already present
  |       extract recovery hints -> append "retryable",
  |                                 "retry_after_seconds",
  |                                 "next_action" if not already present
  +-- format key-value pairs:
  |     string values -> %q (quoted, injection-safe)
  |     other values  -> %v
  |     redacted keys -> "[REDACTED]"
  +-- write single line to output:
        "<timestamp> <prefix> <msg> <kvpairs>\n"

Building an Error Chain

root cause (any error)
  |
  v
log.E("db.Query", "query failed", rootErr)
  |   -> &Err{Op:"db.Query", Msg:"query failed", Err:rootErr}
  v
log.Wrap(err, "repo.FindUser", "user lookup failed")
  |   -> &Err{Op:"repo.FindUser", Msg:"user lookup failed", Err:prev}
  v
log.Wrap(err, "handler.Get", "request failed")
  |   -> &Err{Op:"handler.Get", Msg:"request failed", Err:prev}
  v
log.StackTrace(err)
  -> ["handler.Get", "repo.FindUser", "db.Query"]

log.FormatStackTrace(err)
  -> "handler.Get -> repo.FindUser -> db.Query"

log.Root(err)
  -> rootErr  (the original cause)

Wrap preserves any Code from a wrapped *Err, so error codes propagate upward automatically.

Combined Log-and-Return

LogError and LogWarn combine two operations into one call:

func LogError(err error, op, msg string) error {
    wrapped := Wrap(err, op, msg)       // 1. wrap with context
    defaultLogger.Error(msg, ...)       // 2. log at Error level
    return wrapped                       // 3. return wrapped error
}

Both return nil when given a nil error, making them safe to use unconditionally.

Must follows the same pattern but panics instead of returning, intended for startup-time invariants that must hold.

Security Features

Log Injection Prevention

String values in key-value pairs are formatted with %q, which escapes newlines, quotes, and other control characters. This prevents an attacker from injecting fake log lines via user-controlled input:

l.Info("msg", "key", "value\n[SEC] injected message")
// Output: ... key="value\n[SEC] injected message"   (single line, escaped)

Key Redaction

Keys listed in RedactKeys have their values replaced with [REDACTED]:

l := log.New(log.Options{
    Level:      log.LevelInfo,
    RedactKeys: []string{"password", "token"},
})
l.Info("login", "user", "admin", "password", "secret123")
// Output: ... user="admin" password="[REDACTED]"

Security Log Level

The Security method uses a dedicated [SEC] prefix and logs at LevelError so that security events remain visible even in restrictive configurations:

l.Security("unauthorised access", "user", "admin", "ip", "10.0.0.1")
// Output: 14:32:01 [SEC] unauthorised access user="admin" ip="10.0.0.1"

Log Rotation

go-log defines the RotationOptions struct and an optional RotationWriterFactory variable:

var RotationWriterFactory func(RotationOptions) io.WriteCloser

This is a seam for dependency injection. The core/go-io package (or any other provider) can set this factory at init time. When Options.Rotation is provided and the factory is non-nil, the logger creates a rotating file writer instead of using Options.Output.

This design keeps go-log free of file-system and compression dependencies.

Concurrency Model

  • All Logger fields are guarded by sync.RWMutex.
  • shouldLog and log acquire a read lock to snapshot the level, output, and redact keys.
  • SetLevel, SetOutput, and SetRedactKeys acquire a write lock.
  • The default logger is a package-level variable set at init time. SetDefault replaces it (not goroutine-safe itself, but intended for use during startup).

Default Logger

A package-level defaultLogger is created at import time with LevelInfo and os.Stderr output. All top-level functions (log.Info, log.Error, etc.) delegate to it. Use log.SetDefault to replace it with a custom instance.