about summary refs log tree commit diff stats
path: root/internal
diff options
context:
space:
mode:
authorAlan Pearce2024-07-01 22:15:06 +0200
committerAlan Pearce2024-07-01 22:15:06 +0200
commit94b21b286edff37496a2fe481963625ac01c30a1 (patch)
treebdfd7803dd4eb22da785331b8547ff9858e2b0e6 /internal
parentf076b5bd6cb82edd99be50f3dbdd39bb9be2c44e (diff)
downloadsearchix-94b21b286edff37496a2fe481963625ac01c30a1.tar.lz
searchix-94b21b286edff37496a2fe481963625ac01c30a1.tar.zst
searchix-94b21b286edff37496a2fe481963625ac01c30a1.zip
feat: more structured logging
Diffstat (limited to 'internal')
-rw-r--r--internal/components/results.templ7
-rw-r--r--internal/config/config.go6
-rw-r--r--internal/config/structs.go11
-rw-r--r--internal/fetcher/channel.go6
-rw-r--r--internal/fetcher/download.go8
-rw-r--r--internal/fetcher/http.go7
-rw-r--r--internal/fetcher/main.go4
-rw-r--r--internal/fetcher/nixpkgs-channel.go8
-rw-r--r--internal/importer/importer.go4
-rw-r--r--internal/importer/main.go26
-rw-r--r--internal/importer/main_test.go7
-rw-r--r--internal/importer/options.go20
-rw-r--r--internal/importer/package.go9
-rw-r--r--internal/index/index_meta.go15
-rw-r--r--internal/index/indexer.go28
-rw-r--r--internal/index/search.go2
-rw-r--r--internal/server/dev.go34
-rw-r--r--internal/server/error.go5
-rw-r--r--internal/server/logging.go15
-rw-r--r--internal/server/mux.go28
-rw-r--r--internal/server/server.go23
21 files changed, 155 insertions, 118 deletions
diff --git a/internal/components/results.templ b/internal/components/results.templ
index 4897638..b051219 100644
--- a/internal/components/results.templ
+++ b/internal/components/results.templ
@@ -2,16 +2,11 @@ package components
 
 import (
 	"strconv"
-	"log/slog"
 	"go.alanpearce.eu/searchix/internal/nix"
 )
 
 func convertMatch[I nix.Importable](m nix.Importable) *I {
-	i, ok := m.(I)
-	if !ok {
-		slog.Warn("Converting match failed", "match", m)
-		return nil
-	}
+	i := m.(I)
 	return &i
 }
 
diff --git a/internal/config/config.go b/internal/config/config.go
index 14375d6..f3c0b57 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -1,7 +1,6 @@
 package config
 
 import (
-	"log/slog"
 	"maps"
 	"net/url"
 	"os"
@@ -9,6 +8,7 @@ import (
 
 	"github.com/pelletier/go-toml/v2"
 	"github.com/pkg/errors"
+	"go.alanpearce.eu/x/log"
 )
 
 var Version string
@@ -102,10 +102,10 @@ func mustLocalTime(in string) (time LocalTime) {
 	return
 }
 
-func GetConfig(filename string) (*Config, error) {
+func GetConfig(filename string, log *log.Logger) (*Config, error) {
 	config := DefaultConfig
 	if filename != "" {
-		slog.Debug("reading config", "filename", filename)
+		log.Debug("reading config", "filename", filename)
 		f, err := os.Open(filename)
 		if err != nil {
 			return nil, errors.Wrap(err, "reading config failed")
diff --git a/internal/config/structs.go b/internal/config/structs.go
index e6cf20b..dd79303 100644
--- a/internal/config/structs.go
+++ b/internal/config/structs.go
@@ -5,14 +5,15 @@ package config
 
 import (
 	"fmt"
-	"log/slog"
+
+	"go.uber.org/zap/zapcore"
 )
 
 type Config struct {
-	DataPath string     `comment:"Path to store index data."`
-	LogLevel slog.Level `comment:"How much information to log, one of 'debug', 'info', 'warn', 'error'."`
-	Web      *Web       `comment:"Settings for the web server"`
-	Importer *Importer  `comment:"Settings for the import job"`
+	DataPath string        `comment:"Path to store index data."`
+	LogLevel zapcore.Level `comment:"How much information to log, one of 'debug', 'info', 'warn', 'error', 'panic', 'fatal'."`
+	Web      *Web          `comment:"Settings for the web server"`
+	Importer *Importer     `comment:"Settings for the import job"`
 }
 
 type Web struct {
diff --git a/internal/fetcher/channel.go b/internal/fetcher/channel.go
index 2bde631..8f0aa03 100644
--- a/internal/fetcher/channel.go
+++ b/internal/fetcher/channel.go
@@ -3,7 +3,6 @@ package fetcher
 import (
 	"context"
 	"fmt"
-	"log/slog"
 	"os"
 	"os/exec"
 	"path"
@@ -13,6 +12,7 @@ import (
 
 	"go.alanpearce.eu/searchix/internal/config"
 	"go.alanpearce.eu/searchix/internal/index"
+	"go.alanpearce.eu/x/log"
 
 	"github.com/pkg/errors"
 )
@@ -20,12 +20,12 @@ import (
 type ChannelFetcher struct {
 	Source     *config.Source
 	SourceFile string
-	Logger     *slog.Logger
+	Logger     *log.Logger
 }
 
 func NewChannelFetcher(
 	source *config.Source,
-	logger *slog.Logger,
+	logger *log.Logger,
 ) (*ChannelFetcher, error) {
 	switch source.Importer {
 	case config.Options:
diff --git a/internal/fetcher/download.go b/internal/fetcher/download.go
index 941a683..a34c838 100644
--- a/internal/fetcher/download.go
+++ b/internal/fetcher/download.go
@@ -3,24 +3,24 @@ package fetcher
 import (
 	"context"
 	"fmt"
-	"log/slog"
 	"net/url"
 
 	"go.alanpearce.eu/searchix/internal/config"
 	"go.alanpearce.eu/searchix/internal/index"
 
 	"github.com/pkg/errors"
+	"go.alanpearce.eu/x/log"
 )
 
 type DownloadFetcher struct {
 	Source     *config.Source
 	SourceFile string
-	Logger     *slog.Logger
+	Logger     *log.Logger
 }
 
 func NewDownloadFetcher(
 	source *config.Source,
-	logger *slog.Logger,
+	logger *log.Logger,
 ) (*DownloadFetcher, error) {
 	switch source.Importer {
 	case config.Options:
@@ -59,7 +59,7 @@ func (i *DownloadFetcher) FetchIfNeeded(
 
 		i.Logger.Debug("preparing to fetch URL", "url", fetchURL)
 
-		body, mtime, err := fetchFileIfNeeded(ctx, sourceUpdated, fetchURL)
+		body, mtime, err := fetchFileIfNeeded(ctx, i.Logger, sourceUpdated, fetchURL)
 		if err != nil {
 			i.Logger.Warn("failed to fetch file", "url", fetchURL, "error", err)
 
diff --git a/internal/fetcher/http.go b/internal/fetcher/http.go
index c848dc9..c5ec8fc 100644
--- a/internal/fetcher/http.go
+++ b/internal/fetcher/http.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 	"io"
-	"log/slog"
 	"net/http"
 	"strings"
 	"time"
@@ -13,6 +12,7 @@ import (
 
 	"github.com/andybalholm/brotli"
 	"github.com/pkg/errors"
+	"go.alanpearce.eu/x/log"
 )
 
 type brotliReadCloser struct {
@@ -33,6 +33,7 @@ func (r *brotliReadCloser) Close() error {
 
 func fetchFileIfNeeded(
 	ctx context.Context,
+	log *log.Logger,
 	mtime time.Time,
 	url string,
 ) (body io.ReadCloser, newMtime time.Time, err error) {
@@ -68,7 +69,7 @@ func fetchFileIfNeeded(
 	case http.StatusOK:
 		newMtime, err = time.Parse(time.RFC1123, res.Header.Get("Last-Modified"))
 		if err != nil {
-			slog.Warn(
+			log.Warn(
 				"could not parse Last-Modified header from response",
 				"value",
 				res.Header.Get("Last-Modified"),
@@ -78,7 +79,7 @@ func fetchFileIfNeeded(
 
 		switch ce := res.Header.Get("Content-Encoding"); ce {
 		case "br":
-			slog.Debug("using brotli encoding")
+			log.Debug("using brotli encoding")
 			body = newBrotliReader(res.Body)
 		case "", "identity", "gzip":
 			body = res.Body
diff --git a/internal/fetcher/main.go b/internal/fetcher/main.go
index fcc04a9..ac40ead 100644
--- a/internal/fetcher/main.go
+++ b/internal/fetcher/main.go
@@ -3,10 +3,10 @@ package fetcher
 import (
 	"context"
 	"io"
-	"log/slog"
 
 	"go.alanpearce.eu/searchix/internal/config"
 	"go.alanpearce.eu/searchix/internal/index"
+	"go.alanpearce.eu/x/log"
 
 	"github.com/pkg/errors"
 )
@@ -23,7 +23,7 @@ type Fetcher interface {
 
 func New(
 	source *config.Source,
-	logger *slog.Logger,
+	logger *log.Logger,
 ) (fetcher Fetcher, err error) {
 	switch source.Fetcher {
 	case config.ChannelNixpkgs:
diff --git a/internal/fetcher/nixpkgs-channel.go b/internal/fetcher/nixpkgs-channel.go
index ca33ae6..6f8ca63 100644
--- a/internal/fetcher/nixpkgs-channel.go
+++ b/internal/fetcher/nixpkgs-channel.go
@@ -3,18 +3,18 @@ package fetcher
 import (
 	"context"
 	"fmt"
-	"log/slog"
 	"net/url"
 
 	"go.alanpearce.eu/searchix/internal/config"
 	"go.alanpearce.eu/searchix/internal/index"
 
 	"github.com/pkg/errors"
+	"go.alanpearce.eu/x/log"
 )
 
 type NixpkgsChannelFetcher struct {
 	Source *config.Source
-	Logger *slog.Logger
+	Logger *log.Logger
 }
 
 func makeChannelURL(channel string, subPath string) (string, error) {
@@ -25,7 +25,7 @@ func makeChannelURL(channel string, subPath string) (string, error) {
 
 func NewNixpkgsChannelFetcher(
 	source *config.Source,
-	logger *slog.Logger,
+	logger *log.Logger,
 ) (*NixpkgsChannelFetcher, error) {
 	switch source.Importer {
 	case config.Options, config.Packages:
@@ -66,7 +66,7 @@ func (i *NixpkgsChannelFetcher) FetchIfNeeded(
 		}
 
 		i.Logger.Debug("attempting to fetch file", "url", fetchURL)
-		body, mtime, err := fetchFileIfNeeded(ctx, sourceMeta.Updated, fetchURL)
+		body, mtime, err := fetchFileIfNeeded(ctx, i.Logger, sourceMeta.Updated, fetchURL)
 		if err != nil {
 			i.Logger.Warn("failed to fetch file", "url", fetchURL, "error", err)
 
diff --git a/internal/importer/importer.go b/internal/importer/importer.go
index 31d13c1..99f7e7a 100644
--- a/internal/importer/importer.go
+++ b/internal/importer/importer.go
@@ -2,11 +2,11 @@ package importer
 
 import (
 	"context"
-	"log/slog"
 	"sync"
 
 	"go.alanpearce.eu/searchix/internal/index"
 	"go.alanpearce.eu/searchix/internal/nix"
+	"go.alanpearce.eu/x/log"
 )
 
 type Importer interface {
@@ -21,7 +21,7 @@ func process(
 	ctx context.Context,
 	indexer *index.WriteIndex,
 	processor Processor,
-	logger *slog.Logger,
+	logger *log.Logger,
 ) (bool, error) {
 	wg := sync.WaitGroup{}
 
diff --git a/internal/importer/main.go b/internal/importer/main.go
index bbd8b6c..4c66501 100644
--- a/internal/importer/main.go
+++ b/internal/importer/main.go
@@ -3,7 +3,6 @@ package importer
 import (
 	"context"
 	"fmt"
-	"log/slog"
 	"os/exec"
 	"slices"
 	"strings"
@@ -12,18 +11,20 @@ import (
 	"go.alanpearce.eu/searchix/internal/config"
 	"go.alanpearce.eu/searchix/internal/fetcher"
 	"go.alanpearce.eu/searchix/internal/index"
+	"go.alanpearce.eu/x/log"
 
 	"github.com/pkg/errors"
 )
 
 func createSourceImporter(
 	parent context.Context,
+	log *log.Logger,
 	meta *index.Meta,
 	indexer *index.WriteIndex,
 	forceUpdate bool,
 ) func(*config.Source) error {
 	return func(source *config.Source) error {
-		logger := slog.With(
+		logger := log.With(
 			"name",
 			source.Key,
 			"fetcher",
@@ -94,9 +95,17 @@ func createSourceImporter(
 			switch source.Importer {
 			case config.Options:
 				logger.Debug("processor created", "file", fmt.Sprintf("%T", files.Options))
-				processor, err = NewOptionProcessor(files.Options, source)
+				processor, err = NewOptionProcessor(
+					files.Options,
+					source,
+					logger.Named("processor"),
+				)
 			case config.Packages:
-				processor, err = NewPackageProcessor(files.Packages, source)
+				processor, err = NewPackageProcessor(
+					files.Packages,
+					source,
+					logger.Named("processor"),
+				)
 			}
 			if err != nil {
 				return errors.WithMessagef(err, "failed to create processor")
@@ -123,17 +132,18 @@ func createSourceImporter(
 
 func Start(
 	cfg *config.Config,
+	log *log.Logger,
 	indexer *index.WriteIndex,
 	forceUpdate bool,
 	onlyUpdateSources *[]string,
 ) error {
 	if len(cfg.Importer.Sources) == 0 {
-		slog.Info("No sources enabled")
+		log.Info("No sources enabled")
 
 		return nil
 	}
 
-	slog.Debug("starting importer", "timeout", cfg.Importer.Timeout.Duration)
+	log.Debug("starting importer", "timeout", cfg.Importer.Timeout.Duration)
 	importCtx, cancelImport := context.WithTimeout(
 		context.Background(),
 		cfg.Importer.Timeout.Duration,
@@ -144,7 +154,7 @@ func Start(
 
 	meta := indexer.Meta
 
-	importSource := createSourceImporter(importCtx, meta, indexer, forceUpdate)
+	importSource := createSourceImporter(importCtx, log, meta, indexer, forceUpdate)
 	for name, source := range cfg.Importer.Sources {
 		if onlyUpdateSources != nil && len(*onlyUpdateSources) > 0 {
 			if !slices.Contains(*onlyUpdateSources, name) {
@@ -153,7 +163,7 @@ func Start(
 		}
 		err := importSource(source)
 		if err != nil {
-			slog.Error("import failed", "source", name, "error", err)
+			log.Error("import failed", "source", name, "error", err)
 		}
 	}
 
diff --git a/internal/importer/main_test.go b/internal/importer/main_test.go
index cb4d1db..0bc438d 100644
--- a/internal/importer/main_test.go
+++ b/internal/importer/main_test.go
@@ -1,26 +1,27 @@
 package importer
 
 import (
-	"log/slog"
 	"testing"
 
 	"go.alanpearce.eu/searchix/internal/config"
 	"go.alanpearce.eu/searchix/internal/index"
+	"go.alanpearce.eu/x/log"
 )
 
 var cfg = config.DefaultConfig
 
 func BenchmarkImporterLowMemory(b *testing.B) {
 	tmp := b.TempDir()
-	cfg.LogLevel = slog.LevelDebug
+	logger := log.Configure(false)
 	_, write, _, err := index.OpenOrCreate(tmp, false, &index.Options{
 		LowMemory: true,
+		Logger:    logger.Named("index"),
 	})
 	if err != nil {
 		b.Fatal(err)
 	}
 
-	err = Start(&cfg, write, false, &[]string{"nixpkgs"})
+	err = Start(&cfg, logger.Named("importer"), write, false, &[]string{"nixpkgs"})
 	if err != nil {
 		b.Fatal(err)
 	}
diff --git a/internal/importer/options.go b/internal/importer/options.go
index 290e2e3..763f57f 100644
--- a/internal/importer/options.go
+++ b/internal/importer/options.go
@@ -3,11 +3,11 @@ package importer
 import (
 	"context"
 	"io"
-	"log/slog"
 	"reflect"
 
 	"go.alanpearce.eu/searchix/internal/config"
 	"go.alanpearce.eu/searchix/internal/nix"
+	"go.alanpearce.eu/x/log"
 
 	"github.com/bcicen/jstream"
 	"github.com/mitchellh/mapstructure"
@@ -35,7 +35,7 @@ type nixOptionJSON struct {
 	Type            string
 }
 
-func convertValue(nj *nixValueJSON) *nix.Value {
+func (i *OptionIngester) convertValue(nj *nixValueJSON) *nix.Value {
 	if nj == nil {
 		return nil
 	}
@@ -49,7 +49,7 @@ func convertValue(nj *nixValueJSON) *nix.Value {
 			Markdown: nix.Markdown(nj.Text),
 		}
 	default:
-		slog.Warn("got unexpected Value type", "type", nj.Type, "text", nj.Text)
+		i.log.Warn("got unexpected Value type", "type", nj.Type, "text", nj.Text)
 
 		return nil
 	}
@@ -58,14 +58,20 @@ func convertValue(nj *nixValueJSON) *nix.Value {
 type OptionIngester struct {
 	dec     *jstream.Decoder
 	ms      *mapstructure.Decoder
+	log     *log.Logger
 	optJSON nixOptionJSON
 	infile  io.ReadCloser
 	source  *config.Source
 }
 
-func NewOptionProcessor(infile io.ReadCloser, source *config.Source) (*OptionIngester, error) {
+func NewOptionProcessor(
+	infile io.ReadCloser,
+	source *config.Source,
+	log *log.Logger,
+) (*OptionIngester, error) {
 	i := OptionIngester{
 		dec:     jstream.NewDecoder(infile, 1).EmitKV(),
+		log:     log,
 		optJSON: nixOptionJSON{},
 		infile:  infile,
 		source:  source,
@@ -163,14 +169,14 @@ func (i *OptionIngester) Process(ctx context.Context) (<-chan nix.Importable, <-
 				decs[i] = nix.Link(d)
 			}
 
-			// slog.Debug("sending option", "name", kv.Key)
+			// log.Debug("sending option", "name", kv.Key)
 			results <- nix.Option{
 				Name:            kv.Key,
 				Source:          i.source.Key,
 				Declarations:    decs,
-				Default:         convertValue(i.optJSON.Default),
+				Default:         i.convertValue(i.optJSON.Default),
 				Description:     nix.Markdown(i.optJSON.Description),
-				Example:         convertValue(i.optJSON.Example),
+				Example:         i.convertValue(i.optJSON.Example),
 				RelatedPackages: nix.Markdown(i.optJSON.RelatedPackages),
 				Loc:             i.optJSON.Loc,
 				Type:            i.optJSON.Type,
diff --git a/internal/importer/package.go b/internal/importer/package.go
index 5a0ea00..80adc38 100644
--- a/internal/importer/package.go
+++ b/internal/importer/package.go
@@ -9,6 +9,7 @@ import (
 
 	"go.alanpearce.eu/searchix/internal/config"
 	"go.alanpearce.eu/searchix/internal/nix"
+	"go.alanpearce.eu/x/log"
 
 	"github.com/bcicen/jstream"
 	"github.com/mitchellh/mapstructure"
@@ -40,6 +41,7 @@ type maintainerJSON struct {
 type PackageIngester struct {
 	dec    *jstream.Decoder
 	ms     *mapstructure.Decoder
+	log    *log.Logger
 	pkg    packageJSON
 	infile io.ReadCloser
 	source *config.Source
@@ -60,9 +62,14 @@ func makeAdhocPlatform(v any) string {
 	return string(s)
 }
 
-func NewPackageProcessor(infile io.ReadCloser, source *config.Source) (*PackageIngester, error) {
+func NewPackageProcessor(
+	infile io.ReadCloser,
+	source *config.Source,
+	log *log.Logger,
+) (*PackageIngester, error) {
 	i := &PackageIngester{
 		dec:    jstream.NewDecoder(infile, 2).EmitKV(),
+		log:    log,
 		pkg:    packageJSON{},
 		infile: infile,
 		source: source,
diff --git a/internal/index/index_meta.go b/internal/index/index_meta.go
index fe1b26c..e67c6f2 100644
--- a/internal/index/index_meta.go
+++ b/internal/index/index_meta.go
@@ -2,11 +2,11 @@ package index
 
 import (
 	"encoding/json"
-	"log/slog"
 	"os"
 	"time"
 
 	"go.alanpearce.eu/searchix/internal/file"
+	"go.alanpearce.eu/x/log"
 
 	"github.com/pkg/errors"
 )
@@ -26,10 +26,11 @@ type data struct {
 
 type Meta struct {
 	path string
+	log  *log.Logger
 	data
 }
 
-func createMeta(path string) (*Meta, error) {
+func createMeta(path string, log *log.Logger) (*Meta, error) {
 	exists, err := file.Exists(path)
 	if err != nil {
 		return nil, errors.WithMessage(err, "could not check for existence of index metadata")
@@ -40,19 +41,20 @@ func createMeta(path string) (*Meta, error) {
 
 	return &Meta{
 		path: path,
+		log:  log,
 		data: data{
 			SchemaVersion: CurrentSchemaVersion,
 		},
 	}, nil
 }
 
-func openMeta(path string) (*Meta, error) {
+func openMeta(path string, log *log.Logger) (*Meta, error) {
 	exists, err := file.Exists(path)
 	if err != nil {
 		return nil, errors.WithMessage(err, "could not check for existence of index metadata")
 	}
 	if !exists {
-		return createMeta(path)
+		return createMeta(path, log)
 	}
 
 	j, err := os.ReadFile(path)
@@ -61,6 +63,7 @@ func openMeta(path string) (*Meta, error) {
 	}
 	meta := Meta{
 		path: path,
+		log:  log,
 	}
 	err = json.Unmarshal(j, &meta.data)
 	if err != nil {
@@ -74,7 +77,7 @@ func openMeta(path string) (*Meta, error) {
 
 func (i *Meta) checkSchemaVersion() {
 	if i.SchemaVersion < CurrentSchemaVersion {
-		slog.Warn(
+		i.log.Warn(
 			"Index schema version out of date, suggest re-indexing",
 			"schema_version",
 			i.SchemaVersion,
@@ -90,7 +93,7 @@ func (i *Meta) Save() error {
 	if err != nil {
 		return errors.WithMessage(err, "could not prepare index metadata for saving")
 	}
-	slog.Debug("saving index metadata", "path", i.path)
+	i.log.Debug("saving index metadata", "path", i.path)
 	err = os.WriteFile(i.path, j, 0o600)
 	if err != nil {
 		return errors.WithMessage(err, "could not save index metadata")
diff --git a/internal/index/indexer.go b/internal/index/indexer.go
index 62edbc1..47701bd 100644
--- a/internal/index/indexer.go
+++ b/internal/index/indexer.go
@@ -5,8 +5,6 @@ import (
 	"context"
 	"encoding/gob"
 	"io/fs"
-	"log"
-	"log/slog"
 	"math"
 	"os"
 	"path"
@@ -14,6 +12,8 @@ import (
 
 	"go.alanpearce.eu/searchix/internal/file"
 	"go.alanpearce.eu/searchix/internal/nix"
+	"go.alanpearce.eu/x/log"
+	"go.uber.org/zap"
 
 	"github.com/blevesearch/bleve/v2"
 	"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
@@ -30,6 +30,7 @@ import (
 
 type WriteIndex struct {
 	index bleve.Index
+	log   *log.Logger
 	Meta  *Meta
 }
 
@@ -190,6 +191,7 @@ func deleteIndex(dataRoot string) error {
 
 type Options struct {
 	LowMemory bool
+	Logger    *log.Logger
 }
 
 func OpenOrCreate(
@@ -198,7 +200,7 @@ func OpenOrCreate(
 	options *Options,
 ) (*ReadIndex, *WriteIndex, bool, error) {
 	var err error
-	bleve.SetLog(log.Default())
+	bleve.SetLog(zap.NewStdLog(options.Logger.Named("bleve").GetLogger()))
 
 	indexPath := path.Join(dataRoot, indexBaseName)
 	metaPath := path.Join(dataRoot, metaBaseName)
@@ -226,7 +228,7 @@ func OpenOrCreate(
 			return nil, nil, false, err
 		}
 
-		meta, err = createMeta(metaPath)
+		meta, err = createMeta(metaPath, options.Logger)
 		if err != nil {
 			return nil, nil, false, err
 		}
@@ -237,7 +239,7 @@ func OpenOrCreate(
 			return nil, nil, exists, errors.WithMessagef(err, "could not open index at path %s", indexPath)
 		}
 
-		meta, err = openMeta(metaPath)
+		meta, err = openMeta(metaPath, options.Logger)
 		if err != nil {
 			return nil, nil, exists, err
 		}
@@ -248,12 +250,14 @@ func OpenOrCreate(
 	}
 
 	return &ReadIndex{
-			idx,
-			meta,
+			index: idx,
+			log:   options.Logger,
+			meta:  meta,
 		},
 		&WriteIndex{
-			idx,
-			meta,
+			index: idx,
+			log:   options.Logger,
+			Meta:  meta,
 		},
 		exists,
 		nil
@@ -280,7 +284,7 @@ func (i *WriteIndex) Import(
 		for obj := range objects {
 			select {
 			case <-ctx.Done():
-				slog.Warn("import aborted")
+				i.log.Warn("import aborted")
 
 				break outer
 			default:
@@ -305,7 +309,7 @@ func (i *WriteIndex) Import(
 			field := document.NewTextFieldWithIndexingOptions("_data", nil, data.Bytes(), indexAPI.StoreField)
 			newDoc := doc.AddField(field)
 
-			// slog.Debug("adding object to index", "name", opt.Name)
+			// log.Debug("adding object to index", "name", opt.Name)
 			err = batch.IndexAdvanced(newDoc)
 
 			if err != nil {
@@ -340,7 +344,7 @@ func (i *WriteIndex) Flush(batch *bleve.Batch) error {
 			error: errors.New("no documents to flush"),
 		}
 	}
-	slog.Debug("flushing batch", "size", size)
+	i.log.Debug("flushing batch", "size", size)
 
 	err := i.index.Batch(batch)
 	if err != nil {
diff --git a/internal/index/search.go b/internal/index/search.go
index dc19db4..03a8e60 100644
--- a/internal/index/search.go
+++ b/internal/index/search.go
@@ -7,6 +7,7 @@ import (
 
 	"go.alanpearce.eu/searchix/internal/config"
 	"go.alanpearce.eu/searchix/internal/nix"
+	"go.alanpearce.eu/x/log"
 
 	"github.com/blevesearch/bleve/v2"
 	"github.com/blevesearch/bleve/v2/analysis/analyzer/standard"
@@ -29,6 +30,7 @@ type Result struct {
 
 type ReadIndex struct {
 	index bleve.Index
+	log   *log.Logger
 	meta  *Meta
 }
 
diff --git a/internal/server/dev.go b/internal/server/dev.go
index 17489d3..f5fd4fd 100644
--- a/internal/server/dev.go
+++ b/internal/server/dev.go
@@ -3,37 +3,41 @@ package server
 import (
 	"fmt"
 	"io/fs"
-	"log/slog"
 	"os"
 	"path/filepath"
 	"time"
 
 	"github.com/fsnotify/fsnotify"
 	"github.com/pkg/errors"
+	"go.alanpearce.eu/x/log"
 )
 
 type FileWatcher struct {
-	*fsnotify.Watcher
+	watcher *fsnotify.Watcher
+	log     *log.Logger
 }
 
-func NewFileWatcher() (*FileWatcher, error) {
+func NewFileWatcher(log *log.Logger) (*FileWatcher, error) {
 	watcher, err := fsnotify.NewWatcher()
 	if err != nil {
 		return nil, errors.WithMessage(err, "could not create watcher")
 	}
 
-	return &FileWatcher{watcher}, nil
+	return &FileWatcher{
+		watcher,
+		log,
+	}, nil
 }
 
-func (watcher FileWatcher) AddRecursive(from string) error {
-	slog.Debug(fmt.Sprintf("watching files under %s", from))
+func (i FileWatcher) AddRecursive(from string) error {
+	i.log.Debug(fmt.Sprintf("watching files under %s", from))
 	err := filepath.WalkDir(from, func(path string, entry fs.DirEntry, err error) error {
 		if err != nil {
 			return errors.WithMessagef(err, "could not walk directory %s", path)
 		}
 		if entry.IsDir() {
-			slog.Debug(fmt.Sprintf("adding directory %s to watcher", path))
-			if err = watcher.Add(path); err != nil {
+			i.log.Debug(fmt.Sprintf("adding directory %s to watcher", path))
+			if err = i.watcher.Add(path); err != nil {
 				return errors.WithMessagef(err, "could not add directory %s to watcher", path)
 			}
 		}
@@ -44,18 +48,18 @@ func (watcher FileWatcher) AddRecursive(from string) error {
 	return errors.WithMessage(err, "error walking directory tree")
 }
 
-func (watcher FileWatcher) Start(callback func(string)) {
+func (i FileWatcher) Start(callback func(string)) {
 	for {
 		select {
-		case event := <-watcher.Events:
+		case event := <-i.watcher.Events:
 			if event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) {
 				f, err := os.Stat(event.Name)
 				if err != nil {
-					slog.Error(fmt.Sprintf("error handling %s event: %v", event.Op.String(), err))
+					i.log.Error(fmt.Sprintf("error handling %s event: %v", event.Op.String(), err))
 				} else if f.IsDir() {
-					err = watcher.Add(event.Name)
+					err = i.watcher.Add(event.Name)
 					if err != nil {
-						slog.Error(fmt.Sprintf("error adding new folder to watcher: %v", err))
+						i.log.Error(fmt.Sprintf("error adding new folder to watcher: %v", err))
 					}
 				}
 			}
@@ -63,8 +67,8 @@ func (watcher FileWatcher) Start(callback func(string)) {
 				callback(event.Name)
 				time.Sleep(500 * time.Millisecond)
 			}
-		case err := <-watcher.Errors:
-			slog.Error(fmt.Sprintf("error in watcher: %v", err))
+		case err := <-i.watcher.Errors:
+			i.log.Error(fmt.Sprintf("error in watcher: %v", err))
 		}
 	}
 }
diff --git a/internal/server/error.go b/internal/server/error.go
index d9d6778..1e04bbc 100644
--- a/internal/server/error.go
+++ b/internal/server/error.go
@@ -1,15 +1,16 @@
 package server
 
 import (
-	"log/slog"
 	"net/http"
 
 	"go.alanpearce.eu/searchix/internal/components"
 	"go.alanpearce.eu/searchix/internal/config"
+	"go.alanpearce.eu/x/log"
 )
 
 func createErrorHandler(
 	config *config.Config,
+	log *log.Logger,
 ) func(http.ResponseWriter, *http.Request, string, int) {
 	return func(w http.ResponseWriter, r *http.Request, message string, code int) {
 		var err error
@@ -31,7 +32,7 @@ func createErrorHandler(
 			err = components.ErrorPage(indexData).Render(r.Context(), w)
 		}
 		if err != nil {
-			slog.Error(
+			log.Error(
 				"error rendering error page template",
 				"error",
 				err,
diff --git a/internal/server/logging.go b/internal/server/logging.go
index 372972f..6e2f7c8 100644
--- a/internal/server/logging.go
+++ b/internal/server/logging.go
@@ -1,11 +1,10 @@
 package server
 
 import (
-	"fmt"
-	"io"
 	"net/http"
 
 	"github.com/pkg/errors"
+	"go.alanpearce.eu/x/log"
 )
 
 type LoggingResponseWriter struct {
@@ -42,7 +41,7 @@ func NewLoggingResponseWriter(w http.ResponseWriter) *LoggingResponseWriter {
 
 type wrappedHandlerOptions struct {
 	defaultHostname string
-	logger          io.Writer
+	logger          *log.Logger
 }
 
 func wrapHandlerWithLogging(wrappedHandler http.Handler, opts wrappedHandlerOptions) http.Handler {
@@ -54,13 +53,17 @@ func wrapHandlerWithLogging(wrappedHandler http.Handler, opts wrappedHandlerOpti
 		lw := NewLoggingResponseWriter(w)
 		wrappedHandler.ServeHTTP(lw, r)
 		statusCode := lw.statusCode
-		fmt.Fprintf(
-			opts.logger,
-			"%s %s %d %s %s\n",
+		opts.logger.Info(
+			"http request",
+			"scheme",
 			scheme,
+			"method",
 			r.Method,
+			"code",
 			statusCode,
+			"path",
 			r.URL.Path,
+			"location",
 			lw.Header().Get("Location"),
 		)
 	})
diff --git a/internal/server/mux.go b/internal/server/mux.go
index 16a0150..17445e5 100644
--- a/internal/server/mux.go
+++ b/internal/server/mux.go
@@ -4,12 +4,9 @@ import (
 	"context"
 	"encoding/xml"
 	"fmt"
-	"io"
-	"log/slog"
 	"math"
 	"net/http"
 	"net/url"
-	"os"
 	"path"
 	"strconv"
 	"time"
@@ -19,11 +16,11 @@ import (
 	"go.alanpearce.eu/searchix/internal/config"
 	search "go.alanpearce.eu/searchix/internal/index"
 	"go.alanpearce.eu/searchix/internal/opensearch"
+	"go.alanpearce.eu/x/log"
 
 	sentryhttp "github.com/getsentry/sentry-go/http"
 	"github.com/osdevisnot/sorvor/pkg/livereload"
 	"github.com/pkg/errors"
-	"github.com/shengyanli1982/law"
 )
 
 type HTTPError struct {
@@ -57,6 +54,7 @@ func sortSources(ss map[string]*config.Source) {
 func NewMux(
 	cfg *config.Config,
 	index *search.ReadIndex,
+	log *log.Logger,
 	liveReload bool,
 ) (*http.ServeMux, error) {
 	if cfg == nil {
@@ -70,7 +68,7 @@ func NewMux(
 	})
 	sortSources(cfg.Importer.Sources)
 
-	errorHandler := createErrorHandler(cfg)
+	errorHandler := createErrorHandler(cfg, log)
 
 	top := http.NewServeMux()
 	mux := http.NewServeMux()
@@ -118,7 +116,7 @@ func NewMux(
 
 						return
 					}
-					slog.Error("search error", "error", err)
+					log.Error("search error", "error", err)
 					errorHandler(w, r, err.Error(), http.StatusInternalServerError)
 
 					return
@@ -177,7 +175,7 @@ func NewMux(
 					err = components.ResultsPage(tdata).Render(r.Context(), w)
 				}
 				if err != nil {
-					slog.Error("template error", "template", importerType, "error", err)
+					log.Error("template error", "template", importerType, "error", err)
 					errorHandler(w, r, err.Error(), http.StatusInternalServerError)
 				}
 			} else {
@@ -258,7 +256,7 @@ func NewMux(
 				err = components.DetailPage(tdata.TemplateData, *doc).Render(r.Context(), w)
 			}
 			if err != nil {
-				slog.Error("template error", "template", importerSingular, "error", err)
+				log.Error("template error", "template", importerSingular, "error", err)
 				errorHandler(w, r, err.Error(), http.StatusInternalServerError)
 			}
 		}
@@ -332,7 +330,7 @@ func NewMux(
 		liveReload := livereload.New()
 		liveReload.Start()
 		top.Handle("/livereload", liveReload)
-		fw, err := NewFileWatcher()
+		fw, err := NewFileWatcher(log.Named("watcher"))
 		if err != nil {
 			return nil, errors.WithMessage(err, "could not create file watcher")
 		}
@@ -341,29 +339,23 @@ func NewMux(
 			return nil, errors.WithMessage(err, "could not add directory to file watcher")
 		}
 		go fw.Start(func(filename string) {
-			slog.Debug(fmt.Sprintf("got filename %s", filename))
+			log.Debug(fmt.Sprintf("got filename %s", filename))
 			if match, _ := path.Match("frontend/static/*", filename); match {
 				err := frontend.Rehash()
 				if err != nil {
-					slog.Error("failed to re-hash frontend assets", "error", err)
+					log.Error("failed to re-hash frontend assets", "error", err)
 				}
 			}
 			liveReload.Reload()
 		})
 	}
 
-	var logWriter io.Writer
-	if cfg.Web.Environment == "production" {
-		logWriter = law.NewWriteAsyncer(os.Stdout, nil)
-	} else {
-		logWriter = os.Stdout
-	}
 	top.Handle("/",
 		AddHeadersMiddleware(
 			sentryHandler.Handle(
 				wrapHandlerWithLogging(mux, wrappedHandlerOptions{
 					defaultHostname: cfg.Web.BaseURL.Hostname(),
-					logger:          logWriter,
+					logger:          log,
 				}),
 			),
 			cfg,
diff --git a/internal/server/server.go b/internal/server/server.go
index c3c2a4d..aacef30 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -2,7 +2,6 @@ package server
 
 import (
 	"context"
-	"log/slog"
 	"net"
 	"net/http"
 	"strconv"
@@ -10,6 +9,7 @@ import (
 
 	"go.alanpearce.eu/searchix/internal/config"
 	"go.alanpearce.eu/searchix/internal/index"
+	"go.alanpearce.eu/x/log"
 
 	"github.com/pkg/errors"
 	"golang.org/x/net/http2"
@@ -18,18 +18,25 @@ import (
 
 type Server struct {
 	cfg      *config.Config
+	log      *log.Logger
 	server   *http.Server
 	listener net.Listener
 }
 
-func New(conf *config.Config, index *index.ReadIndex, liveReload bool) (*Server, error) {
-	mux, err := NewMux(conf, index, liveReload)
+func New(
+	conf *config.Config,
+	index *index.ReadIndex,
+	log *log.Logger,
+	liveReload bool,
+) (*Server, error) {
+	mux, err := NewMux(conf, index, log, liveReload)
 	if err != nil {
 		return nil, err
 	}
 
 	return &Server{
 		cfg: conf,
+		log: log,
 		server: &http.Server{
 			Handler: http.MaxBytesHandler(
 				h2c.NewHandler(mux, &http2.Server{
@@ -56,7 +63,7 @@ func (s *Server) Start() error {
 	s.listener = l
 
 	if s.cfg.Web.Environment == "development" {
-		slog.Info(
+		s.log.Info(
 			"server listening on",
 			"url",
 			s.cfg.Web.BaseURL.String(),
@@ -71,19 +78,19 @@ func (s *Server) Start() error {
 }
 
 func (s *Server) Stop() chan struct{} {
-	slog.Debug("stop called")
+	s.log.Debug("stop called")
 
 	idleConnsClosed := make(chan struct{})
 
 	go func() {
-		slog.Debug("shutting down server")
+		s.log.Debug("shutting down server")
 		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 		defer cancel()
 		err := s.server.Shutdown(ctx)
-		slog.Debug("server shut down")
+		s.log.Debug("server shut down")
 		if err != nil {
 			// Error from closing listeners, or context timeout:
-			slog.Error("error shutting down server", "error", err)
+			s.log.Error("error shutting down server", "error", err)
 		}
 		s.listener.Close()
 		close(idleConnsClosed)