docs: add human-friendly documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-11 13:02:40 +00:00
parent 700747aa0b
commit 032617e646
3 changed files with 469 additions and 0 deletions

237
docs/architecture.md Normal file
View file

@ -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:
"<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:
```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.

133
docs/development.md Normal file
View file

@ -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 <noreply@anthropic.com>
```
### 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

99
docs/index.md Normal file
View file

@ -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