From 032617e64665c5cfeab10df0aef943579fec19f0 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 11 Mar 2026 13:02:40 +0000 Subject: [PATCH] docs: add human-friendly documentation Co-Authored-By: Claude Opus 4.6 --- docs/architecture.md | 237 +++++++++++++++++++++++++++++++++++++++++++ docs/development.md | 133 ++++++++++++++++++++++++ docs/index.md | 99 ++++++++++++++++++ 3 files changed, 469 insertions(+) create mode 100644 docs/architecture.md create mode 100644 docs/development.md create mode 100644 docs/index.md diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..08c87ad --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,237 @@ +--- +title: Architecture +description: 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 + +```go +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 + +```go +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 + +```go +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") +} +``` + +`Err` implements both the `error` and `Unwrap` interfaces so it participates +fully in the standard `errors.Is` / `errors.As` machinery. + +### Options and RotationOptions + +```go +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 + +-- format key-value pairs: + | string values -> %q (quoted, injection-safe) + | other values -> %v + | redacted keys -> "[REDACTED]" + +-- write single line to output: + " \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: + +```go +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: + +```go +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]`: + +```go +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: + +```go +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: + +```go +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. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..4cb7642 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,133 @@ +--- +title: Development +description: How to build, test, and contribute to go-log +--- + +# Development + +## Prerequisites + +- **Go 1.26+** -- go-log uses `iter.Seq` from the standard library. +- **Core CLI** (`core` binary) -- for running tests and quality checks. + Build it from `~/Code/host-uk/core` with `task cli:build`. + +If you do not have the Core CLI, plain `go test` works fine. + +## Running Tests + +```bash +# All tests via Core CLI +core go test + +# All tests via plain Go +go test ./... + +# Single test +core go test --run TestLogger_Levels +# or +go test -run TestLogger_Levels ./... +``` + +### Test Coverage + +```bash +core go cov # generate coverage report +core go cov --open # generate and open in browser +``` + +## Code Quality + +```bash +core go fmt # format with gofmt +core go lint # run linters +core go vet # run go vet + +core go qa # all of the above + tests +core go qa full # + race detector, vulnerability scan, security audit +``` + +## Test Naming Convention + +Tests follow the `_Good` / `_Bad` / `_Ugly` suffix pattern: + +| Suffix | Meaning | +|--------|---------| +| `_Good` | Happy path -- the function behaves correctly with valid input | +| `_Bad` | Expected error conditions -- the function returns an error or handles invalid input gracefully | +| `_Ugly` | Edge cases, panics, or truly degenerate input | + +Examples from the codebase: + +```go +func TestErr_Error_Good(t *testing.T) { /* valid Err produces correct string */ } +func TestMust_Good_NoError(t *testing.T) { /* nil error does not panic */ } +func TestMust_Ugly_Panics(t *testing.T) { /* non-nil error triggers panic */ } +``` + +## Project Structure + +``` +go-log/ + log.go # Logger, levels, formatting, default logger + log_test.go # Logger tests + errors.go # Err type, creation, introspection, log-and-return helpers + errors_test.go # Error tests + go.mod # Module definition + go.sum # Dependency checksums + .core/ + build.yaml # Build configuration (targets, flags) + release.yaml # Release configuration (changelog rules) + docs/ + index.md # This documentation + architecture.md # Internal design + development.md # Build and contribution guide +``` + +## Contributing + +### Coding Standards + +- **UK English** in comments and documentation (colour, organisation, centre). +- `declare(strict_types=1)` does not apply (this is Go), but do use strong + typing: all exported functions should have explicit parameter and return types. +- Tests use the **Pest-style naming** adapted for Go: descriptive names with + `_Good` / `_Bad` / `_Ugly` suffixes. +- Format with `gofmt` (or `core go fmt`). The CI pipeline will reject + unformatted code. + +### Commit Messages + +Use conventional commits: + +``` +type(scope): description +``` + +Common types: `feat`, `fix`, `perf`, `refactor`, `test`, `docs`, `chore`. + +Include the co-author trailer: + +``` +Co-Authored-By: Claude Opus 4.5 +``` + +### Adding a New Log Level or Feature + +1. Add the level constant to the `Level` iota block in `log.go`. +2. Add its `String()` case. +3. Add a method on `*Logger` and a package-level proxy function. +4. If the level needs a distinct prefix (like `[SEC]` for Security), add a + `Style*` field to the Logger struct and initialise it to `identity` in `New`. +5. Write tests covering `_Good` and at least one `_Bad` or `_Ugly` case. + +### Dependencies Policy + +go-log has **zero runtime dependencies**. `testify` is permitted for tests +only. Any new dependency must be justified -- prefer the standard library. + +Log rotation is handled via the `RotationWriterFactory` injection point, not +by importing a rotation library directly. + +## Licence + +EUPL-1.2 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..945ed86 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,99 @@ +--- +title: go-log +description: Structured logging and error handling for Core applications +--- + +# go-log + +`forge.lthn.ai/core/go-log` provides structured logging and contextual error +handling for Go applications built on the Core framework. It is a small, +zero-dependency library (only `testify` at test time) that replaces ad-hoc +`fmt.Println` / `log.Printf` calls with level-filtered, key-value structured +output and a rich error type that carries operation context through the call +stack. + +## Quick Start + +```go +import "forge.lthn.ai/core/go-log" + +// Use the package-level default logger straight away +log.SetLevel(log.LevelDebug) +log.Info("server started", "port", 8080) +log.Warn("high latency", "ms", 320) +log.Error("request failed", "err", err) + +// Security events are always visible at Error level +log.Security("brute force detected", "ip", "10.0.0.1", "attempts", 47) +``` + +### Creating a Custom Logger + +```go +logger := log.New(log.Options{ + Level: log.LevelInfo, + Output: os.Stdout, + RedactKeys: []string{"password", "token", "secret"}, +}) + +logger.Info("login", "user", "admin", "password", "hunter2") +// Output: 14:32:01 [INF] login user="admin" password="[REDACTED]" +``` + +### Structured Errors + +```go +// Create an error with operational context +err := log.E("db.Connect", "connection refused", underlyingErr) + +// Wrap errors as they bubble up through layers +err = log.Wrap(err, "user.Save", "failed to persist user") + +// Inspect the chain +log.Op(err) // "user.Save" +log.Root(err) // the original underlyingErr +log.StackTrace(err) // ["user.Save", "db.Connect"] +log.FormatStackTrace(err) // "user.Save -> db.Connect" +``` + +### Combined Log-and-Return + +```go +if err != nil { + return log.LogError(err, "handler.Process", "request failed") + // Logs at Error level AND returns a wrapped error -- one line instead of three +} +``` + +## Package Layout + +| File | Purpose | +|------|---------| +| `log.go` | Logger type, log levels, key-value formatting, redaction, default logger, `Username()` helper | +| `errors.go` | `Err` structured error type, creation helpers (`E`, `Wrap`, `WrapCode`, `NewCode`), introspection (`Op`, `ErrCode`, `Root`, `StackTrace`), combined log-and-return helpers (`LogError`, `LogWarn`, `Must`) | +| `log_test.go` | Tests for the Logger: level filtering, key-value output, redaction, injection prevention, security logging | +| `errors_test.go` | Tests for structured errors: creation, wrapping, code propagation, introspection, stack traces, log-and-return helpers | + +## Dependencies + +| Module | Purpose | +|--------|---------| +| Go standard library only | Runtime -- no external dependencies | +| `github.com/stretchr/testify` | Test assertions (test-only) | + +The package deliberately avoids external runtime dependencies. Log rotation is +supported through an optional `RotationWriterFactory` hook that can be wired up +by `core/go-io` or any other provider -- go-log itself carries no file-rotation +code. + +## Module Path + +``` +forge.lthn.ai/core/go-log +``` + +Requires **Go 1.26+** (uses `iter.Seq` from the standard library). + +## Licence + +EUPL-1.2