pkg/logger and pkg/testutil
This commit is contained in:
parent
81f5a49cea
commit
5055d69685
20
CLAUDE.md
20
CLAUDE.md
@ -19,6 +19,23 @@ Module: `gitea.djmil.dev/go/template` — update this when you fork.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Design philosophy
|
||||||
|
|
||||||
|
**[The Twelve-Factor App](https://12factor.net) is the primary reference for default implementation choices.**
|
||||||
|
Follow its recommendations unless circumstances force a deviation — and document the reason when you do.
|
||||||
|
|
||||||
|
Key factors that shape this codebase most directly:
|
||||||
|
|
||||||
|
- **[Logs (factor XI)](https://12factor.net/logs)** — the app writes log events to `stderr` as an unbuffered stream.
|
||||||
|
It never opens log files or manages rotation. The execution environment (shell, systemd, Docker, k8s)
|
||||||
|
routes and stores the stream. Human-readable program output goes to `stdout` (`fmt.Print*`).
|
||||||
|
- **[Config (factor III)](https://12factor.net/config)** — all configuration comes from the environment
|
||||||
|
(flags in this template; env vars are the 12-factor ideal). No hard-coded values in logic packages.
|
||||||
|
- **[Dependencies (factor II)](https://12factor.net/dependencies)** — explicit declaration; dev tools are
|
||||||
|
pinned in `tools.versions` and kept out of `go.mod` so published packages have a clean module graph.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -47,7 +64,7 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
|
|||||||
- bridge existing `(T, error)` stdlib/third-party calls with `result.Of(...)`: `result.Of(os.ReadFile("cfg.json")).Expect("read config")`
|
- bridge existing `(T, error)` stdlib/third-party calls with `result.Of(...)`: `result.Of(os.ReadFile("cfg.json")).Expect("read config")`
|
||||||
- use `result.StackTrace(err)` to retrieve the capture-site stack from a caught error
|
- use `result.StackTrace(err)` to retrieve the capture-site stack from a caught error
|
||||||
- still use `fmt.Errorf("context: %w", err)` when wrapping errors *before* constructing a `result.Fail`
|
- still use `fmt.Errorf("context: %w", err)` when wrapping errors *before* constructing a `result.Fail`
|
||||||
- **Logging** — use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages; `log/slog` is the backend
|
- **Logging** — logs go to `stderr` (structured, machine-readable, per 12-factor XI); human output goes to `stdout` via `fmt.Print*`. Use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages; `log/slog` is the backend
|
||||||
- **Config** — all configuration through `internal/config` (flag-parsed); no hard-coded values in logic packages
|
- **Config** — all configuration through `internal/config` (flag-parsed); no hard-coded values in logic packages
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -70,6 +87,7 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
|
|||||||
- Test files: `package foo_test` (black-box) unless white-box access is needed
|
- Test files: `package foo_test` (black-box) unless white-box access is needed
|
||||||
- Fake dependencies with **manual fakes** (implement the interface inline in `_test.go`)
|
- Fake dependencies with **manual fakes** (implement the interface inline in `_test.go`)
|
||||||
- Use `logger.NewNop()` when the test doesn't care about log output
|
- Use `logger.NewNop()` when the test doesn't care about log output
|
||||||
|
- Use `logger.NewWriter(&buf, "debug")` in acceptance / integration tests that need to assert on log content
|
||||||
- Table-driven tests with `t.Run("description", ...)` for multiple cases
|
- Table-driven tests with `t.Run("description", ...)` for multiple cases
|
||||||
- The race detector is enabled in CI (`make test-race`); don't introduce data races
|
- The race detector is enabled in CI (`make test-race`); don't introduce data races
|
||||||
- Never use `time.Sleep` in tests; use channels or `t.Cleanup`
|
- Never use `time.Sleep` in tests; use channels or `t.Cleanup`
|
||||||
|
|||||||
@ -1,13 +1,4 @@
|
|||||||
// Package config parses application configuration from command-line flags.
|
package main
|
||||||
// Defaults are defined here; override at runtime with flags:
|
|
||||||
//
|
|
||||||
// ./app -port 9090 -env prod -log-level warn
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// cfg := config.Load()
|
|
||||||
// fmt.Println(cfg.App.Port)
|
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
@ -36,9 +27,16 @@ type GreeterConfig struct {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load parses command-line flags and returns a Config.
|
// parseArgs parses application configuration from command-line flags.
|
||||||
// Call this once at startup before any other flag parsing.
|
// Defaults are defined here; override at runtime with flags:
|
||||||
func Load() *Config {
|
//
|
||||||
|
// ./app -port 9090 -env prod -log-level warn 2 > log.log
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// cfg := config.parseArgs()
|
||||||
|
// fmt.Println(cfg.App.Port)
|
||||||
|
func parseArgs() *Config {
|
||||||
name := flag.String("name", "Gopher", "application name")
|
name := flag.String("name", "Gopher", "application name")
|
||||||
port := flag.Int("port", 8080, "listen port")
|
port := flag.Int("port", 8080, "listen port")
|
||||||
env := flag.String("env", "dev", "environment: dev | staging | prod")
|
env := flag.String("env", "dev", "environment: dev | staging | prod")
|
||||||
@ -1,6 +1,7 @@
|
|||||||
// main is the composition root for the application.
|
// main is the composition root for the application.
|
||||||
// It wires together config, logger, and domain services — nothing more.
|
// It parses config, wires dependencies into an app struct, and delegates.
|
||||||
// Business logic lives in internal/; cmd/ is deliberately thin.
|
// Implementation details lives in internal/; cmd/ should be kept thin by
|
||||||
|
// deliberately operating only with high level concepts.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -8,14 +9,42 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.djmil.dev/go/template/internal/config"
|
|
||||||
"gitea.djmil.dev/go/template/internal/greeter"
|
"gitea.djmil.dev/go/template/internal/greeter"
|
||||||
"gitea.djmil.dev/go/template/internal/logger"
|
"gitea.djmil.dev/go/template/pkg/logger"
|
||||||
"gitea.djmil.dev/go/template/pkg/result"
|
"gitea.djmil.dev/go/template/pkg/result"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// app holds all wired dependencies for the lifetime of the process.
|
||||||
|
type app struct {
|
||||||
|
cfg *Config
|
||||||
|
log *logger.Logger
|
||||||
|
greeter *greeter.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func newApp(cfg *Config) *app {
|
||||||
|
var log *logger.Logger
|
||||||
|
if cfg.App.Env == "dev" {
|
||||||
|
log = logger.NewDevelopment()
|
||||||
|
} else {
|
||||||
|
log = logger.New(cfg.Logger.Level).Expect("create logger") // might fail dramatically
|
||||||
|
}
|
||||||
|
return &app{
|
||||||
|
cfg: cfg,
|
||||||
|
log: log,
|
||||||
|
greeter: greeter.New(log),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := result.Run(showGreeting); err != nil {
|
conf := parseArgs()
|
||||||
|
|
||||||
|
app := newApp(conf)
|
||||||
|
app.log.WithFields(map[string]any{
|
||||||
|
"app": filepath.Base(os.Args[0]),
|
||||||
|
"env": conf.App.Env,
|
||||||
|
}).Info("starting up")
|
||||||
|
|
||||||
|
if err := result.Run(app.run); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "[failed] %v\n", err)
|
fmt.Fprintf(os.Stderr, "[failed] %v\n", err)
|
||||||
if stack := result.StackTrace(err); stack != "" {
|
if stack := result.StackTrace(err); stack != "" {
|
||||||
fmt.Fprintf(os.Stderr, "%s\n", stack)
|
fmt.Fprintf(os.Stderr, "%s\n", stack)
|
||||||
@ -24,30 +53,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func showGreeting() {
|
func (a *app) run() {
|
||||||
// ── Config ────────────────────────────────────────────────────────────────
|
// High level business logic goes here
|
||||||
cfg := config.Load()
|
a.showGreeting(a.cfg.Greeter.Name)
|
||||||
|
|
||||||
// ── Logger ────────────────────────────────────────────────────────────────
|
// Human readable messages.
|
||||||
var log *logger.Logger
|
// Use logs for presenting technical data in machine friendly format.
|
||||||
if cfg.App.Env == "dev" {
|
fmt.Printf("TODO: implement listening on port %d\n", a.cfg.App.Port)
|
||||||
log = logger.NewDevelopment()
|
}
|
||||||
} else {
|
|
||||||
log = logger.New(cfg.Logger.Level).Expect("create logger")
|
func (a *app) showGreeting(name string) {
|
||||||
}
|
msg := a.greeter.Greet(name).Expect("greeting")
|
||||||
|
a.log.WithField("message", msg).Info("greeting complete")
|
||||||
log.WithFields(map[string]any{
|
fmt.Println(msg)
|
||||||
"app": filepath.Base(os.Args[0]),
|
|
||||||
"env": cfg.App.Env,
|
|
||||||
}).Info("starting up")
|
|
||||||
|
|
||||||
// ── Services ──────────────────────────────────────────────────────────────
|
|
||||||
greetSvc := greeter.New(log)
|
|
||||||
|
|
||||||
// ── Example usage ─────────────────────────────────────────────────────────
|
|
||||||
msg := greetSvc.Greet(cfg.Greeter.Name).Expect("greeting")
|
|
||||||
|
|
||||||
log.WithField("message", msg).Info("greeting complete")
|
|
||||||
|
|
||||||
fmt.Printf("%s (listening on :%d)\n", msg, cfg.App.Port)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
// Package greeter is a minimal example domain package.
|
// Package greeter is a minimal example domain package.
|
||||||
// It demonstrates how to:
|
// It demonstrates how to:
|
||||||
// - define an interface (satisfied by manual fakes in tests)
|
// - define an interface (satisfied by manual fakes in tests)
|
||||||
// - inject dependencies (logger) through a constructor
|
// - inject a component-scoped logger through the constructor
|
||||||
// - use the logger.WithField pattern
|
|
||||||
//
|
//
|
||||||
// Replace this package with your own domain logic.
|
// Replace this package with your own domain logic.
|
||||||
package greeter
|
package greeter
|
||||||
@ -10,7 +9,7 @@ package greeter
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.djmil.dev/go/template/internal/logger"
|
"gitea.djmil.dev/go/template/pkg/logger"
|
||||||
"gitea.djmil.dev/go/template/pkg/result"
|
"gitea.djmil.dev/go/template/pkg/result"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -21,7 +20,7 @@ type Service struct {
|
|||||||
|
|
||||||
// New creates a Greeter service with the provided logger.
|
// New creates a Greeter service with the provided logger.
|
||||||
func New(log *logger.Logger) *Service {
|
func New(log *logger.Logger) *Service {
|
||||||
return &Service{log: log}
|
return &Service{log: log.WithField("component", "greeter")}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Greet returns a personalized greeting and logs the interaction.
|
// Greet returns a personalized greeting and logs the interaction.
|
||||||
@ -32,10 +31,7 @@ func (s *Service) Greet(name string) result.Expect[string] {
|
|||||||
|
|
||||||
msg := fmt.Sprintf("Hello, %s!", name)
|
msg := fmt.Sprintf("Hello, %s!", name)
|
||||||
|
|
||||||
s.log.
|
s.log.WithField("name", name).Debug("greeting generated")
|
||||||
WithField("component", "greeter").
|
|
||||||
WithField("name", name).
|
|
||||||
Info("greeting generated")
|
|
||||||
|
|
||||||
return result.Ok(msg)
|
return result.Ok(msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.djmil.dev/go/template/internal/greeter"
|
"gitea.djmil.dev/go/template/internal/greeter"
|
||||||
"gitea.djmil.dev/go/template/internal/logger"
|
"gitea.djmil.dev/go/template/pkg/logger"
|
||||||
"gitea.djmil.dev/go/template/internal/testutil"
|
|
||||||
"gitea.djmil.dev/go/template/pkg/result"
|
"gitea.djmil.dev/go/template/pkg/result"
|
||||||
|
"gitea.djmil.dev/go/template/pkg/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Service (unit tests) ──────────────────────────────────────────────────────
|
// ── Service (unit tests) ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -1,16 +1,26 @@
|
|||||||
// Package logger wraps log/slog with a thin, ergonomic API.
|
// Package logger wraps log/slog with a thin, ergonomic API.
|
||||||
//
|
//
|
||||||
// The key addition over raw slog is the WithField / WithFields helpers that
|
// Per the Twelve-Factor App (factor XI), the application writes structured log
|
||||||
// return a *Logger (not a *slog.Logger), so callers stay in the typed world
|
// events to stderr and never manages log files or routing itself. The execution
|
||||||
// and can chain field attachments without importing slog directly.
|
// environment (shell, systemd, Docker) is responsible for capturing and storing
|
||||||
|
// the stream. Human-readable output belongs on stdout via fmt.Print*.
|
||||||
//
|
//
|
||||||
// Usage:
|
// Typical use:
|
||||||
//
|
//
|
||||||
// log := logger.New("info").Expect("create logger")
|
// log := logger.New("info").Expect("create logger")
|
||||||
// log.Info("server started")
|
// log.Info("server started", "port", 8080)
|
||||||
//
|
//
|
||||||
// req := log.WithField("request_id", rid).WithField("user_id", uid)
|
// // child logger for request-scoped fields that repeat across many lines:
|
||||||
// req.Info("handling request")
|
// req := log.WithField("request_id", rid)
|
||||||
|
// req.Info("start")
|
||||||
|
// req.Info("end")
|
||||||
|
//
|
||||||
|
// In tests that need to assert on log content, use NewWriter with a buffer:
|
||||||
|
//
|
||||||
|
// var buf bytes.Buffer
|
||||||
|
// log := logger.NewWriter(&buf, "debug").Expect("create logger")
|
||||||
|
// // ... exercise code ...
|
||||||
|
// // assert on buf.String()
|
||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -41,7 +51,7 @@ func New(level string) result.Expect[*Logger] {
|
|||||||
return result.Ok(logger)
|
return result.Ok(logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDevelopment creates a human-friendly text logger writing to stderr.
|
// NewDevelopment creates a human-friendly text logger writing to stderr at debug level.
|
||||||
// Use this in local dev; prefer New() in any deployed environment.
|
// Use this in local dev; prefer New() in any deployed environment.
|
||||||
func NewDevelopment() *Logger {
|
func NewDevelopment() *Logger {
|
||||||
h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
|
h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||||
@ -49,6 +59,19 @@ func NewDevelopment() *Logger {
|
|||||||
return &Logger{slog.New(h)}
|
return &Logger{slog.New(h)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewWriter creates a JSON logger writing to w. Intended for tests that need to
|
||||||
|
// assert on log content — pass a *bytes.Buffer and inspect it after the fact.
|
||||||
|
func NewWriter(w io.Writer, level string) result.Expect[*Logger] {
|
||||||
|
lvl := parseLevel(level)
|
||||||
|
if lvl.Err() != nil {
|
||||||
|
return result.Errw[*Logger](lvl.Err(), "parse log level")
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: lvl.Value()})
|
||||||
|
|
||||||
|
return result.Ok(&Logger{slog.New(handler)})
|
||||||
|
}
|
||||||
|
|
||||||
// NewNop returns a no-op logger. Useful in tests that don't care about logs.
|
// NewNop returns a no-op logger. Useful in tests that don't care about logs.
|
||||||
func NewNop() *Logger {
|
func NewNop() *Logger {
|
||||||
return &Logger{slog.New(slog.NewTextHandler(io.Discard, nil))}
|
return &Logger{slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||||
@ -3,8 +3,8 @@ package logger_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.djmil.dev/go/template/internal/logger"
|
"gitea.djmil.dev/go/template/pkg/logger"
|
||||||
"gitea.djmil.dev/go/template/internal/testutil"
|
"gitea.djmil.dev/go/template/pkg/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
func TestNew(t *testing.T) {
|
||||||
@ -1,5 +1,5 @@
|
|||||||
// Package testutil provides lightweight test helpers to reduce boilerplate in
|
// Package testutil provides lightweight test helpers to reduce boilerplate in
|
||||||
// table-driven tests. Import it from any _test.go file in this module.
|
// table-driven tests.
|
||||||
//
|
//
|
||||||
// Every helper calls t.Helper() so failures are reported at the call site, not
|
// Every helper calls t.Helper() so failures are reported at the call site, not
|
||||||
// inside this package.
|
// inside this package.
|
||||||
Loading…
Reference in New Issue
Block a user