Logging: structured slog messages throughout, and WithLogger options so callers choose the logger

Log messages are lower-case event names with their details as key/value
attributes ("error" for errors), in place of formatted strings.

Loggable package take a WithLogger option as part of their "New*" call,
defaulting to slog.Default(). Each tags its lines with its `component`.

Some debug loggers print directly rather than using slog when in
terminal logging mode.
This commit is contained in:
bwilliams committed 2026-10-15 16:10:52 +01:00
1 parent 0b5fde9563
commit 043e307663
49 files changed
+598 -248

No files matched your search

+9 -6
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"hash/fnv"
"log"
"math"
"math/rand"
"time"
@@ -21,15 +20,18 @@ type Generator struct {
cat *Catalog
store *Store
rnd *rand.Rand
opts options
}
// NewGenerator returns a generator drawing from cat and storing releases in
// store. The same seed and store contents give the same releases.
func NewGenerator(cat *Catalog, store *Store, seed int64) *Generator {
// store. The same seed and store contents give the same releases. It logs to
// slog.Default() unless given WithLogger.
func NewGenerator(cat *Catalog, store *Store, seed int64, opts ...Option) *Generator {
return &Generator{
cat: cat,
store: store,
rnd: rand.New(rand.NewSource(seed)), //nolint:gosec // seeded for repeatable mock data
opts: applyOptions(opts),
}
}
@@ -235,9 +237,10 @@ func (g *Generator) CatchUp(
// Run publishes a new release after a random gap between min and max until ctx ends.
func (g *Generator) Run(ctx context.Context, min, max time.Duration) {
logger := g.opts.logger("generator")
for {
d := g.gap(min, max)
log.Printf("generator: next release in %s", d.Round(time.Second))
logger.Info("next release", "in", d.Round(time.Second))
timer := time.NewTimer(d)
select {
@@ -249,11 +252,11 @@ func (g *Generator) Run(ctx context.Context, min, max time.Duration) {
r := g.Make(time.Now())
if err := g.store.Append(r); err != nil {
log.Printf("generator: storing release: %v", err)
logger.Error("storing release failed", "error", err)
continue
}
log.Printf("generator: published %s (%s) %s", r.ID, r.Tag[0], r.OCID)
logger.Info("published release", "release_id", r.ID, "tag", r.Tag[0], "ocid", r.OCID)
}
}
+7 -5
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
@@ -27,17 +26,20 @@ type server struct {
store *Store
baseURL string
loc *time.Location // zone for updatedFrom/updatedTo without an offset; nil means UTC
opts options
}
// NewHandler serves release packages from store. baseURL is the public base
// URL used in each package's uri and links.next. loc is the time zone that
// updatedFrom and updatedTo are read in when they carry no offset, as the
// documented yyyy-MM-ddTHH:mm:ss format doesn't (Europe/London for FTS).
// Stored release dates are UTC; comparisons are on absolute instants.
// Stored release dates are UTC; comparisons are on absolute instants. It
// logs to slog.Default() unless given WithLogger.
func NewHandler(
store *Store, baseURL string, loc *time.Location,
store *Store, baseURL string, loc *time.Location, opts ...Option,
) http.HandlerFunc {
return (&server{store: store, baseURL: baseURL, loc: loc}).handle
s := &server{store: store, baseURL: baseURL, loc: loc, opts: applyOptions(opts)}
return s.handle
}
// encodeCursor makes the opaque cursor for a next link that resumes at store
@@ -134,7 +136,7 @@ func (s *server) handle(w http.ResponseWriter, req *http.Request) {
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
if err := enc.Encode(pkg); err != nil {
log.Printf("handler: writing response: %v", err)
s.opts.logger("handler").Warn("writing response failed", "error", err)
}
}
+40
View File
@@ -0,0 +1,40 @@
package mock
import (
"log/slog"
)
// Option configures a Store, Generator or handler.
type Option func(*options)
// options holds the settings Options change.
type options struct {
log *slog.Logger
}
// WithLogger makes a Store, Generator or handler log to logger instead of
// slog.Default().
func WithLogger(logger *slog.Logger) Option {
return func(opts *options) {
opts.log = logger
}
}
// applyOptions returns the settings opts make.
func applyOptions(opts []Option) options {
var settings options
for _, opt := range opts {
opt(&settings)
}
return settings
}
// logger returns the logger to use, tagged with component: the given one, or
// slog.Default(), looked up now.
func (opts options) logger(component string) *slog.Logger {
log := opts.log
if log == nil {
log = slog.Default()
}
return log.With("component", component)
}
+17 -8
View File
@@ -6,7 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"math/rand"
"os"
"path/filepath"
@@ -33,19 +33,27 @@ type Store struct {
// removing and picking one at random are all constant time.
open []string
openPos map[string]int
opts options
}
// OpenStore loads the releases stored at path, creating the file and its
// directory if needed, and opens it for appending. Lines that can't be read
// are skipped, releases are put back in date order if needed, and a record
// left incomplete by a crash is ended so new ones start on a fresh line.
func OpenStore(path string) (*Store, error) {
s := &Store{latest: map[string]int{}, lastNum: map[int]int{}, openPos: map[string]int{}}
// It logs to slog.Default() unless given WithLogger.
func OpenStore(path string, opts ...Option) (*Store, error) {
s := &Store{
latest: map[string]int{},
lastNum: map[int]int{},
openPos: map[string]int{},
opts: applyOptions(opts),
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, err
}
releases, err := readReleases(path)
releases, err := readReleases(s.opts.logger("store"), path)
if err != nil {
return nil, err
}
@@ -67,9 +75,10 @@ func OpenStore(path string) (*Store, error) {
return s, nil
}
// readReleases reads the releases stored at path, skipping lines that are
// blank or can't be decoded. A missing file holds no releases.
func readReleases(path string) ([]ocds.Release, error) {
// readReleases reads the releases stored at path, skipping blank lines and
// lines that can't be decoded (which are logged to logger). A missing file
// holds no releases.
func readReleases(logger *slog.Logger, path string) ([]ocds.Release, error) {
in, err := os.Open(filepath.Clean(path))
if os.IsNotExist(err) {
return nil, nil
@@ -89,7 +98,7 @@ func readReleases(path string) ([]ocds.Release, error) {
}
var r ocds.Release
if err := json.Unmarshal(b, &r); err != nil {
log.Printf("store: skipping unreadable line %d: %v", line, err)
logger.Warn("skipping unreadable line", "line", line, "error", err)
continue
}
releases = append(releases, r)