docs: add human-friendly documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
700747aa0b
commit
032617e646
3 changed files with 469 additions and 0 deletions
237
docs/architecture.md
Normal file
237
docs/architecture.md
Normal 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
133
docs/development.md
Normal 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
99
docs/index.md
Normal 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
|
||||
Loading…
Add table
Reference in a new issue