From 672044ae3fe1ef67cf217697ae2b168f0f5d3267 Mon Sep 17 00:00:00 2001 From: comicrime Date: Sun, 30 Aug 2026 15:10:59 +0300 Subject: [PATCH 1/2] fixes and ci --- .gitattributes | 1 + .github/workflows/ci.yml | 46 ++++ .github/workflows/release.yml | 61 +++++ .golangci.yml | 11 + Makefile | 20 ++ builder.go | 392 ++++++++++++++++---------------- discard.go | 64 +++--- logger.go | 365 +++++++++++++++--------------- logger_test.go | 414 ++++++++++++++++++++++++++++++++++ slogpretty.go | 232 +++++++++++-------- split.go | 37 +++ 11 files changed, 1137 insertions(+), 506 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .golangci.yml create mode 100644 Makefile create mode 100644 logger_test.go create mode 100644 split.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..86f116f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: ci + +on: + push: + branches-ignore: [master] + workflow_call: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Tool version + id: tools + run: echo "golangci=$(make -s print-GOLANGCI_VERSION)" >> "$GITHUB_OUTPUT" + - uses: golangci/golangci-lint-action@v9 + with: + version: ${{ steps.tools.outputs.golangci }} + - name: Lint the workflows + run: | + set -euo pipefail + go install "github.com/rhysd/actionlint/cmd/actionlint@$(make -s print-ACTIONLINT_VERSION)" + "$(go env GOPATH)/bin/actionlint" + - name: go.mod and go.sum are tidy + run: | + go mod tidy + git diff --exit-code go.mod go.sum + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - run: go build ./... + - run: go test -race -count=1 ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0f16391 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: release + +on: + push: + branches: [master] + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + ci: + uses: ./.github/workflows/ci.yml + + tag: + needs: ci + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Tag the merge + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # A re-run of this workflow, or a tag pushed by hand, must not publish a + # second version of the same code. + existing=$(git tag --points-at HEAD --list 'v*' | head -1) + if [ -n "$existing" ]; then + echo "HEAD is already released as $existing, nothing to do" + exit 0 + fi + + latest=$(git tag --list 'v*' --sort=-v:refname \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true) + + if [ -z "$latest" ]; then + next=v0.1.0 + elif git diff --quiet "$latest" HEAD; then + echo "unchanged since $latest, nothing to release" + exit 0 + else + IFS=. read -r major minor patch <<<"${latest#v}" + next="v${major}.${minor}.$((patch + 1))" + fi + + echo "releasing $next (previous: ${latest:-none})" + git tag -a "$next" -m "$next" + git push origin "$next" + gh release create "$next" --title "$next" --generate-notes diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..1aa3c06 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,11 @@ +version: "2" + +linters: + default: standard # errcheck, govet, ineffassign, staticcheck, unused + enable: + - errorlint + - misspell # exported names have shipped as Critial and Falalf before + +formatters: + enable: + - gofmt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d5f4ea3 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +GOLANGCI_VERSION ?= v2.13.2 +ACTIONLINT_VERSION ?= v1.7.12 + +GOBIN ?= $(or $(shell go env GOBIN),$(shell go env GOPATH)/bin) + +.PHONY: test lint install-tools + +test: + go test -race -count=1 ./... + +lint: + $(GOBIN)/golangci-lint run ./... + $(GOBIN)/actionlint + +install-tools: + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION) + go install github.com/rhysd/actionlint/cmd/actionlint@$(ACTIONLINT_VERSION) + +print-%: + @echo $($*) diff --git a/builder.go b/builder.go index 57b0ec1..8491809 100644 --- a/builder.go +++ b/builder.go @@ -1,201 +1,191 @@ -package logger - -import ( - "context" - "io" - "log/slog" - "os" - "strings" -) - -type _key string - -//nolint:gochecknoglobals // ... -var loggerKey _key = "_core_logger" - -// LoggerOpt options for logger builder. -type LoggerOpt func(p *loggerParams) - -// NewLoggerContext creates a new context woth logger. -func NewLoggerContext(ctx context.Context, opts ...LoggerOpt) context.Context { - p := new(loggerParams) - - for _, o := range opts { - o(p) - } - - log := p.build() - - return context.WithValue(ctx, loggerKey, log) -} - -type loggerParams struct { - local bool - addSource bool - lvl slog.Level - writers []io.Writer - handler slog.Handler -} - -// WithWriter sets a writer. -func WithWriter(w io.Writer) LoggerOpt { - return func(p *loggerParams) { - p.writers = append(p.writers, w) - } -} - -// WithLevel sets logging level. -func WithLevel(l slog.Level) LoggerOpt { - return func(p *loggerParams) { - p.lvl = l - } -} - -// Local sets a pretty handler for a logger. -func Local() LoggerOpt { - return func(p *loggerParams) { - p.local = true - } -} - -// WithSource adds caller to a logging entry. -func WithSource() LoggerOpt { - return func(p *loggerParams) { - p.addSource = true - } -} - -// WithHandler sets custom handler. -func WithHandler(h slog.Handler) LoggerOpt { - return func(p *loggerParams) { - p.handler = h - } -} - -// Err is an easy to use error logging attribute. -func Err(err error) slog.Attr { - return slog.Attr{ - Key: "error", - Value: slog.StringValue(err.Error()), - } -} - -// MapLevel maps string level to slog. -func MapLevel(lvl string) slog.Level { - switch strings.ToLower(lvl) { - case "debug": - return LevelDebug - case "info": - return LevelInfo - case "notice": - return LevelNotice - case "warn": - return LevelWarn - case "error": - return LevelError - case "critical": - return LevelCritial - case "alert": - return LevelAlert - case "emergency": - return LevelEmergency - default: - return LevelInfo - } -} - -func (b *loggerParams) build() *slog.Logger { - if len(b.writers) == 0 { - b.writers = append(b.writers, os.Stdout) - } - - w := io.MultiWriter(b.writers...) - - var handler slog.Handler - - if b.local { - opts := prettyHandlerOptions{ - SlogOpts: &slog.HandlerOptions{ - Level: b.lvl, - AddSource: b.addSource, - }, - } - - if b.handler != nil { - handler = b.handler - } else { - handler = opts.newPrettyHandler(w) - } - - return slog.New(handler) - } - - if b.handler != nil { - handler = b.handler - } else { - handler = newLoggerHandler(b.lvl, w) - } - - return slog.New(handler) -} - -func newLoggerHandler(lvl slog.Level, w io.Writer) slog.Handler { - return slog.NewJSONHandler(w, &slog.HandlerOptions{ - Level: lvl, - ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { - if a.Key == slog.LevelKey { - level := a.Value.Any().(slog.Level) - - switch { - case level < LevelInfo: - a.Value = slog.StringValue("DEBUG") - case level < LevelNotice: - a.Value = slog.StringValue("INFO") - case level < LevelWarn: - a.Value = slog.StringValue("NOTICE") - case level < LevelError: - a.Value = slog.StringValue("WARNING") - case level < LevelCritial: - a.Value = slog.StringValue("ERROR") - case level < LevelAlert: - a.Value = slog.StringValue("CRITICAL") - case level < LevelEmergency: - a.Value = slog.StringValue("ALERT") - default: - a.Value = slog.StringValue("EMERGENCY") - } - } - - return a - }, - }) -} - -// FromContext returns logger from context. -func FromContext(ctx context.Context) *slog.Logger { - return loggerFromCtx(ctx) -} - -// WithLogger returns context with logger l. -func WithLogger(ctx context.Context, l *slog.Logger) context.Context { - return context.WithValue(ctx, loggerKey, l) -} - -// GetLoggerSafe returns logger from context. If ctx.Logger == globalLogger, globalLogger will be copied. -func GetLoggerSafe(ctx context.Context) *slog.Logger { - l := loggerFromCtx(ctx) - if l == globalLogger { - lcopy := *l - l = &lcopy - } - - return l -} - -func loggerFromCtx(ctx context.Context) *slog.Logger { - if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok { - return l - } - - return globalLogger -} +package logger + +import ( + "context" + "io" + "log/slog" + "os" + "strings" +) + +type _key string + +//nolint:gochecknoglobals // ... +var loggerKey _key = "_core_logger" + +// LoggerOpt options for logger builder. +type LoggerOpt func(p *loggerParams) + +// NewLoggerContext creates a new context woth logger. +func NewLoggerContext(ctx context.Context, opts ...LoggerOpt) context.Context { + p := new(loggerParams) + + for _, o := range opts { + o(p) + } + + log := p.build() + + return context.WithValue(ctx, loggerKey, log) +} + +type loggerParams struct { + local bool + addSource bool + lvl slog.Leveler + writers []io.Writer + errWriter io.Writer + handler slog.Handler +} + +// WithWriter sets a writer. +func WithWriter(w io.Writer) LoggerOpt { + return func(p *loggerParams) { + p.writers = append(p.writers, w) + } +} + +// WithLevel sets logging level. Pass a *slog.LevelVar instead of a slog.Level to +// keep control of the level after the logger is built. +func WithLevel(l slog.Leveler) LoggerOpt { + return func(p *loggerParams) { + p.lvl = l + } +} + +// WithErrorWriter sends records at error level and above to w, and everything below +// it to the writers set by WithWriter. Ignored when WithHandler supplies a handler. +func WithErrorWriter(w io.Writer) LoggerOpt { + return func(p *loggerParams) { + p.errWriter = w + } +} + +// Local sets a pretty handler for a logger. +func Local() LoggerOpt { + return func(p *loggerParams) { + p.local = true + } +} + +// WithSource adds caller to a logging entry. +func WithSource() LoggerOpt { + return func(p *loggerParams) { + p.addSource = true + } +} + +// WithHandler sets custom handler. +func WithHandler(h slog.Handler) LoggerOpt { + return func(p *loggerParams) { + p.handler = h + } +} + +// Err is an easy to use error logging attribute. +func Err(err error) slog.Attr { + return slog.Attr{ + Key: "error", + Value: slog.StringValue(err.Error()), + } +} + +// MapLevel maps string level to slog. +func MapLevel(lvl string) slog.Level { + switch strings.ToLower(lvl) { + case "debug": + return LevelDebug + case "info": + return LevelInfo + case "notice": + return LevelNotice + case "warn", "warning": + return LevelWarn + case "error": + return LevelError + case "critical": + return LevelCritical + case "alert": + return LevelAlert + case "emergency": + return LevelEmergency + default: + return LevelInfo + } +} + +func (b *loggerParams) build() *slog.Logger { + if b.handler != nil { + return slog.New(b.handler) + } + + if len(b.writers) == 0 { + b.writers = append(b.writers, os.Stdout) + } + + if b.lvl == nil { + b.lvl = LevelInfo + } + + w := io.MultiWriter(b.writers...) + + if b.local { + opts := prettyHandlerOptions{ + SlogOpts: &slog.HandlerOptions{ + Level: b.lvl, + AddSource: b.addSource, + }, + } + + build := func(w io.Writer) slog.Handler { return opts.newPrettyHandler(w) } + + return slog.New(b.route(build, w)) + } + + build := func(w io.Writer) slog.Handler { return newLoggerHandler(b.lvl, b.addSource, w) } + + return slog.New(b.route(build, w)) +} + +// route pairs the handler for w with one for the error writer, when there is one. +func (b *loggerParams) route(build func(io.Writer) slog.Handler, w io.Writer) slog.Handler { + if b.errWriter == nil { + return build(w) + } + + return &splitHandler{low: build(w), high: build(b.errWriter)} +} + +func newLoggerHandler(lvl slog.Leveler, addSource bool, w io.Writer) slog.Handler { + return slog.NewJSONHandler(w, &slog.HandlerOptions{ + Level: lvl, + AddSource: addSource, + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.LevelKey { + if level, ok := a.Value.Any().(slog.Level); ok { + a.Value = slog.StringValue(LevelName(level)) + } + } + + return a + }, + }) +} + +// FromContext returns logger from context. +func FromContext(ctx context.Context) *slog.Logger { + return loggerFromCtx(ctx) +} + +// WithLogger returns context with logger l. +func WithLogger(ctx context.Context, l *slog.Logger) context.Context { + return context.WithValue(ctx, loggerKey, l) +} + +func loggerFromCtx(ctx context.Context) *slog.Logger { + if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok && l != nil { + return l + } + + return globalLogger.Load() +} diff --git a/discard.go b/discard.go index eb48d15..e756ff4 100644 --- a/discard.go +++ b/discard.go @@ -1,32 +1,32 @@ -package logger - -import ( - "context" - "log/slog" -) - -func NewDiscardLogger() *slog.Logger { - return slog.New(newDiscardHandler()) -} - -type DiscardHandler struct{} - -func newDiscardHandler() *DiscardHandler { - return &DiscardHandler{} -} - -func (h *DiscardHandler) Handle(_ context.Context, _ slog.Record) error { - return nil -} - -func (h *DiscardHandler) WithAttrs(_ []slog.Attr) slog.Handler { - return h -} - -func (h *DiscardHandler) WithGroup(_ string) slog.Handler { - return h -} - -func (h *DiscardHandler) Enabled(_ context.Context, _ slog.Level) bool { - return false -} +package logger + +import ( + "context" + "log/slog" +) + +func NewDiscardLogger() *slog.Logger { + return slog.New(newDiscardHandler()) +} + +type DiscardHandler struct{} + +func newDiscardHandler() *DiscardHandler { + return &DiscardHandler{} +} + +func (h *DiscardHandler) Handle(_ context.Context, _ slog.Record) error { + return nil +} + +func (h *DiscardHandler) WithAttrs(_ []slog.Attr) slog.Handler { + return h +} + +func (h *DiscardHandler) WithGroup(_ string) slog.Handler { + return h +} + +func (h *DiscardHandler) Enabled(_ context.Context, _ slog.Level) bool { + return false +} diff --git a/logger.go b/logger.go index c1930f0..e8cdcac 100644 --- a/logger.go +++ b/logger.go @@ -1,176 +1,189 @@ -package logger - -import ( - "context" - "fmt" - "log/slog" - "os" -) - -//nolint:gochecknoglobals // ... -var globalLogger *slog.Logger = slog.New(newLoggerHandler(LevelDebug, os.Stdout)) - -func SetGlobalLogger(l *slog.Logger) { - globalLogger = l -} - -func SetLevel(l slog.Level) { - globalLogger = slog.New(newLoggerHandler(l, os.Stdout)) -} - -const ( - LevelEmergency = slog.Level(10000) - LevelAlert = slog.Level(1000) - LevelCritial = slog.Level(100) - LevelError = slog.LevelError - LevelWarn = slog.LevelWarn - LevelNotice = slog.Level(2) - LevelInfo = slog.LevelInfo - LevelDebug = slog.LevelDebug -) - -type LogFunc func(context.Context, string, ...any) - -var ( - Falalf LogFunc = FatalKV - Emergencyf LogFunc = EmergencyKV - Alertf LogFunc = AlertKV - Critialf LogFunc = CritialKV - Errorf LogFunc = ErrorKV - Warnf LogFunc = WarnKV - Noticef LogFunc = NoticeKV - Infof LogFunc = InfoKV - Debugf LogFunc = DebugKV -) - -func WithAttrs(ctx context.Context, attrs ...slog.Attr) context.Context { - l := loggerFromCtx(ctx) - if l == globalLogger { - lcopy := *l - l = &lcopy - } - for _, a := range attrs { - l = l.With(a) - } - return context.WithValue(ctx, loggerKey, l) -} - -func WithGroup(ctx context.Context, name string) context.Context { - l := loggerFromCtx(ctx) - if l == globalLogger { - lcopy := *l - l = &lcopy - } - - return context.WithValue(ctx, loggerKey, l.WithGroup(name)) -} - -func Fatal(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.Log(ctx, LevelEmergency, message, attrs...) - panic(fmt.Sprintf(message, attrs...)) -} - -func Emergency(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.Log(ctx, LevelEmergency, message, attrs...) -} - -func Alert(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.Log(ctx, LevelAlert, message, attrs...) -} - -func Critial(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.Log(ctx, LevelCritial, message, attrs...) -} - -func Error(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.ErrorContext(ctx, message, attrs...) -} - -func Warn(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.WarnContext(ctx, message, attrs...) -} - -func Notice(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.Log(ctx, LevelNotice, message, attrs...) -} - -func Info(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.InfoContext(ctx, message, attrs...) -} - -func Debug(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.DebugContext(ctx, message, attrs...) -} - -func FatalKV(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.Log(ctx, LevelEmergency, fmt.Sprintf(message, attrs...)) - panic(fmt.Sprintf(message, attrs...)) -} - -func EmergencyKV(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.Log(ctx, LevelEmergency, fmt.Sprintf(message, attrs...)) -} - -func AlertKV(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.Log(ctx, LevelAlert, fmt.Sprintf(message, attrs...)) -} - -func CritialKV(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.Log(ctx, LevelCritial, fmt.Sprintf(message, attrs...)) -} - -func ErrorKV(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.ErrorContext(ctx, fmt.Sprintf(message, attrs...)) -} - -func WarnKV(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.WarnContext(ctx, fmt.Sprintf(message, attrs...)) -} - -func NoticeKV(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.Log(ctx, LevelNotice, fmt.Sprintf(message, attrs...)) -} - -func InfoKV(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.InfoContext(ctx, fmt.Sprintf(message, attrs...)) -} - -func DebugKV(ctx context.Context, message string, attrs ...any) { - l := loggerFromCtx(ctx) - - l.DebugContext(ctx, fmt.Sprintf(message, attrs...)) -} +package logger + +import ( + "context" + "fmt" + "log/slog" + "os" + "runtime" + "sync/atomic" + "time" +) + +//nolint:gochecknoglobals // ... +var ( + globalLevel = new(slog.LevelVar) + globalLogger = newGlobalLogger() +) + +func newGlobalLogger() *atomic.Pointer[slog.Logger] { + p := new(atomic.Pointer[slog.Logger]) + p.Store(slog.New(newLoggerHandler(globalLevel, false, os.Stdout))) + + return p +} + +func SetGlobalLogger(l *slog.Logger) { + if l == nil { + return + } + + globalLogger.Store(l) + slog.SetDefault(l) +} + +// SetLevel sets the level of the logger +func SetLevel(l slog.Level) { + globalLevel.Set(l) +} + +const ( + LevelEmergency = slog.Level(10000) + LevelAlert = slog.Level(1000) + LevelCritical = slog.Level(100) + LevelError = slog.LevelError + LevelWarn = slog.LevelWarn + LevelNotice = slog.Level(2) + LevelInfo = slog.LevelInfo + LevelDebug = slog.LevelDebug +) + +// LevelName renders lvl under this package's level names. +func LevelName(lvl slog.Level) string { + switch { + case lvl < LevelInfo: + return "DEBUG" + case lvl < LevelNotice: + return "INFO" + case lvl < LevelWarn: + return "NOTICE" + case lvl < LevelError: + return "WARNING" + case lvl < LevelCritical: + return "ERROR" + case lvl < LevelAlert: + return "CRITICAL" + case lvl < LevelEmergency: + return "ALERT" + default: + return "EMERGENCY" + } +} + +// Enabled reports whether the context's logger emits records at level. +func Enabled(ctx context.Context, level slog.Level) bool { + return loggerFromCtx(ctx).Enabled(ctx, level) +} + +func WithAttrs(ctx context.Context, attrs ...slog.Attr) context.Context { + l := loggerFromCtx(ctx) + for _, a := range attrs { + l = l.With(a) + } + + return context.WithValue(ctx, loggerKey, l) +} + +func WithGroup(ctx context.Context, name string) context.Context { + l := loggerFromCtx(ctx) + + return context.WithValue(ctx, loggerKey, l.WithGroup(name)) +} + +func Fatal(ctx context.Context, message string, attrs ...any) { + log(ctx, LevelEmergency, message, attrs...) + os.Exit(1) +} + +func Emergency(ctx context.Context, message string, attrs ...any) { + log(ctx, LevelEmergency, message, attrs...) +} + +func Alert(ctx context.Context, message string, attrs ...any) { + log(ctx, LevelAlert, message, attrs...) +} + +func Critical(ctx context.Context, message string, attrs ...any) { + log(ctx, LevelCritical, message, attrs...) +} + +func Error(ctx context.Context, message string, attrs ...any) { + log(ctx, LevelError, message, attrs...) +} + +func Warn(ctx context.Context, message string, attrs ...any) { + log(ctx, LevelWarn, message, attrs...) +} + +func Notice(ctx context.Context, message string, attrs ...any) { + log(ctx, LevelNotice, message, attrs...) +} + +func Info(ctx context.Context, message string, attrs ...any) { + log(ctx, LevelInfo, message, attrs...) +} + +func Debug(ctx context.Context, message string, attrs ...any) { + log(ctx, LevelDebug, message, attrs...) +} + +func Fatalf(ctx context.Context, format string, args ...any) { + log(ctx, LevelEmergency, sprintf(format, args...)) + os.Exit(1) +} + +func Emergencyf(ctx context.Context, format string, args ...any) { + log(ctx, LevelEmergency, sprintf(format, args...)) +} + +func Alertf(ctx context.Context, format string, args ...any) { + log(ctx, LevelAlert, sprintf(format, args...)) +} + +func Criticalf(ctx context.Context, format string, args ...any) { + log(ctx, LevelCritical, sprintf(format, args...)) +} + +func Errorf(ctx context.Context, format string, args ...any) { + log(ctx, LevelError, sprintf(format, args...)) +} + +func Warnf(ctx context.Context, format string, args ...any) { + log(ctx, LevelWarn, sprintf(format, args...)) +} + +func Noticef(ctx context.Context, format string, args ...any) { + log(ctx, LevelNotice, sprintf(format, args...)) +} + +func Infof(ctx context.Context, format string, args ...any) { + log(ctx, LevelInfo, sprintf(format, args...)) +} + +func Debugf(ctx context.Context, format string, args ...any) { + log(ctx, LevelDebug, sprintf(format, args...)) +} + +func log(ctx context.Context, level slog.Level, message string, attrs ...any) { + l := loggerFromCtx(ctx) + if !l.Enabled(ctx, level) { + return + } + + var pcs [1]uintptr + + runtime.Callers(3, pcs[:]) + + r := slog.NewRecord(time.Now(), level, message, pcs[0]) + r.Add(attrs...) + + _ = l.Handler().Handle(ctx, r) +} + +func sprintf(format string, args ...any) string { + if len(args) == 0 { + return format + } + + return fmt.Sprintf(format, args...) +} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..0fddb76 --- /dev/null +++ b/logger_test.go @@ -0,0 +1,414 @@ +package logger + +import ( + "bytes" + "context" + "errors" + "log/slog" + "os" + "os/exec" + "strings" + "sync" + "testing" + "time" + + "github.com/fatih/color" +) + +func TestMain(m *testing.M) { + color.NoColor = true // the pretty handler's output is compared as plain text + + os.Exit(m.Run()) +} + +func jsonCtx(t *testing.T, opts ...LoggerOpt) (context.Context, *bytes.Buffer) { + t.Helper() + + buf := new(bytes.Buffer) + opts = append([]LoggerOpt{WithWriter(buf), WithLevel(LevelDebug)}, opts...) + + return NewLoggerContext(context.Background(), opts...), buf +} + +func prettyCtx(t *testing.T, opts ...LoggerOpt) (context.Context, *bytes.Buffer) { + t.Helper() + + return jsonCtx(t, append([]LoggerOpt{Local()}, opts...)...) +} + +// Fatal must terminate the process: a panic would be recoverable and would +// change the exit code. +func TestFatalExitsWithCode1(t *testing.T) { + if os.Getenv("LOGGER_TEST_FATAL") == "1" { + Fatal(context.Background(), "boom") + + return + } + + cmd := exec.Command(os.Args[0], "-test.run=TestFatalExitsWithCode1") + cmd.Env = append(os.Environ(), "LOGGER_TEST_FATAL=1") + + var exitErr *exec.ExitError + if err := cmd.Run(); !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 { + t.Fatalf("want exit status 1, got %v", err) + } +} + +// A message carrying a literal % must not be run through printf when no +// formatting arguments are supplied. +func TestFormattedLiteralPercent(t *testing.T) { + ctx, buf := jsonCtx(t) + + // Called through a func value so that vet's printf check, which now covers + // Infof directly, does not reject the deliberately arg-less format string. + logf := Infof + logf(ctx, "disk usage 90% of quota") + + if !strings.Contains(buf.String(), "disk usage 90% of quota") { + t.Fatalf("message was mangled: %s", buf.String()) + } +} + +func TestFormattedWithArgs(t *testing.T) { + ctx, buf := jsonCtx(t) + + Errorf(ctx, "user %d not found", 42) + + if !strings.Contains(buf.String(), "user 42 not found") { + t.Fatalf("message was not formatted: %s", buf.String()) + } +} + +// The attribute family must emit structured fields, not printf noise. +func TestAttrsReachOutput(t *testing.T) { + ctx, buf := jsonCtx(t) + + Info(ctx, "user login", "user_id", 42) + + out := buf.String() + if !strings.Contains(out, `"user_id":42`) || strings.Contains(out, "EXTRA") { + t.Fatalf("attrs did not reach structured output: %s", out) + } +} + +func TestContextAttrsAccumulate(t *testing.T) { + for _, tc := range []struct { + name string + opts []LoggerOpt + }{ + {"json", nil}, + {"pretty", []LoggerOpt{Local()}}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, buf := jsonCtx(t, tc.opts...) + ctx = WithAttrs(ctx, slog.String("a", "1"), slog.String("b", "2")) + + Info(ctx, "two attrs") + + out := buf.String() + if !strings.Contains(out, `"a"`) || !strings.Contains(out, `"b"`) { + t.Fatalf("an earlier context attr was dropped: %s", out) + } + }) + } +} + +func TestPrettyGroupKeepsEarlierAttrs(t *testing.T) { + ctx, buf := prettyCtx(t) + ctx = WithAttrs(ctx, slog.String("outer", "1")) + ctx = WithGroup(ctx, "req") + ctx = WithAttrs(ctx, slog.String("id", "abc")) + + Info(ctx, "grouped") + + out := buf.String() + if !strings.Contains(out, `"outer"`) { + t.Fatalf("attr set before the group was dropped: %s", out) + } + + if !strings.Contains(out, `"req.id"`) { + t.Fatalf("attr set inside the group was not prefixed: %s", out) + } +} + +// A record's own attrs must be nested under the group the context is in. +func TestPrettyRecordAttrsTakeGroupPrefix(t *testing.T) { + ctx, buf := prettyCtx(t) + ctx = WithGroup(ctx, "req") + + Info(ctx, "grouped", "id", "abc") + + if out := buf.String(); !strings.Contains(out, `"req.id"`) { + t.Fatalf("record attr was not prefixed by the group: %s", out) + } +} + +// The levels this package adds must be named, not printed as slog offsets such as +// ERROR+92 or INFO+2. +func TestCustomLevelNames(t *testing.T) { + levels := map[slog.Level]string{ + LevelDebug: "DEBUG", + LevelInfo: "INFO", + LevelNotice: "NOTICE", + LevelWarn: "WARNING", + LevelError: "ERROR", + LevelCritical: "CRITICAL", + LevelAlert: "ALERT", + LevelEmergency: "EMERGENCY", + } + + for lvl, want := range levels { + if got := LevelName(lvl); got != want { + t.Errorf("LevelName(%v) = %q, want %q", lvl, got, want) + } + + if got := MapLevel(want); got != lvl { + t.Errorf("MapLevel(%q) = %v, want %v (LevelName and MapLevel must round-trip)", want, got, lvl) + } + + ctx, buf := jsonCtx(t) + log(ctx, lvl, "msg") + + if out := buf.String(); !strings.Contains(out, `"level":"`+want+`"`) { + t.Errorf("json handler labelled %v as %s, want %s", lvl, out, want) + } + + pctx, pbuf := prettyCtx(t) + log(pctx, lvl, "msg") + + if out := pbuf.String(); !strings.Contains(out, want+":") { + t.Errorf("pretty handler labelled %v as %s, want %s", lvl, out, want) + } + } +} + +// The pretty timestamp used 15:05:05, which prints the second twice and never the +// minute. +func TestPrettyTimestampLayout(t *testing.T) { + buf := new(bytes.Buffer) + h := prettyHandlerOptions{SlogOpts: &slog.HandlerOptions{Level: LevelDebug}}.newPrettyHandler(buf) + at := time.Date(2026, time.August, 30, 15, 4, 5, 0, time.UTC) + + if err := h.Handle(context.Background(), slog.NewRecord(at, LevelInfo, "msg", 0)); err != nil { + t.Fatal(err) + } + + if out := buf.String(); !strings.Contains(out, "[15:04:05.000]") { + t.Fatalf("want [15:04:05.000] in %q", out) + } +} + +// The source must name the caller of the exported function, not this package. +func TestSourceNamesTheCaller(t *testing.T) { + for _, tc := range []struct { + name string + opts []LoggerOpt + }{ + {"json", nil}, + {"pretty", []LoggerOpt{Local()}}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, buf := jsonCtx(t, append(tc.opts, WithSource())...) + + Info(ctx, "who is the caller?") + + out := buf.String() + if !strings.Contains(out, "logger_test.go") { + t.Fatalf("source does not name the caller: %s", out) + } + + if strings.Contains(out, "logger.go") { + t.Fatalf("source names this package instead of the caller: %s", out) + } + }) + } +} + +// WithSource was accepted and then ignored by the JSON handler. +func TestWithSourceIsHonoured(t *testing.T) { + ctx, buf := jsonCtx(t, WithSource()) + Info(ctx, "sourced") + + if out := buf.String(); !strings.Contains(out, `"source"`) { + t.Fatalf("WithSource emitted no source field: %s", out) + } + + off, offBuf := jsonCtx(t) + Info(off, "not sourced") + + if out := offBuf.String(); strings.Contains(out, `"source"`) { + t.Fatalf("source was emitted without WithSource: %s", out) + } +} + +func TestEnabled(t *testing.T) { + ctx, _ := jsonCtx(t, WithLevel(LevelWarn)) + + if Enabled(ctx, LevelInfo) { + t.Error("info must not be enabled at warn level") + } + + if !Enabled(ctx, LevelError) { + t.Error("error must be enabled at warn level") + } +} + +func TestBuilderDefaultsToInfo(t *testing.T) { + buf := new(bytes.Buffer) + ctx := NewLoggerContext(context.Background(), WithWriter(buf)) + + Debug(ctx, "dropped") + + if buf.Len() != 0 { + t.Fatalf("debug must be filtered at the default level, got %s", buf.String()) + } + + Info(ctx, "kept") + + if buf.Len() == 0 { + t.Fatal("info must pass at the default level") + } +} + +// A *slog.LevelVar passed to WithLevel keeps the level adjustable after the logger +// is built. +func TestWithLevelVarIsAdjustable(t *testing.T) { + buf := new(bytes.Buffer) + lvl := new(slog.LevelVar) + lvl.Set(LevelError) + ctx := NewLoggerContext(context.Background(), WithWriter(buf), WithLevel(lvl)) + + Info(ctx, "dropped") + + if buf.Len() != 0 { + t.Fatalf("info must be filtered at error level, got %s", buf.String()) + } + + lvl.Set(LevelDebug) + Info(ctx, "kept") + + if buf.Len() == 0 { + t.Fatal("info must pass once the level var is lowered") + } +} + +func TestWithErrorWriterRoutesByLevel(t *testing.T) { + out, errOut := new(bytes.Buffer), new(bytes.Buffer) + ctx := NewLoggerContext(context.Background(), + WithWriter(out), WithErrorWriter(errOut), WithLevel(LevelDebug)) + + Info(ctx, "ordinary") + Error(ctx, "broken") + + if !strings.Contains(out.String(), "ordinary") || strings.Contains(out.String(), "broken") { + t.Fatalf("stdout writer got the wrong records: %s", out.String()) + } + + if !strings.Contains(errOut.String(), "broken") || strings.Contains(errOut.String(), "ordinary") { + t.Fatalf("error writer got the wrong records: %s", errOut.String()) + } +} + +// SetLevel rebuilt the global logger over os.Stdout, silently discarding whatever +// SetGlobalLogger had installed. +func TestSetLevelKeepsTheInstalledLogger(t *testing.T) { + restoreGlobal(t) + + buf := new(bytes.Buffer) + lvl := new(slog.LevelVar) + lvl.Set(LevelDebug) + SetGlobalLogger(slog.New(newLoggerHandler(lvl, false, buf))) + + SetLevel(LevelWarn) + Error(context.Background(), "does this reach my buffer?") + + if !strings.Contains(buf.String(), "does this reach my buffer?") { + t.Fatalf("SetLevel redirected the installed logger, buffer holds %q", buf.String()) + } +} + +func TestSetLevelFiltersTheDefaultLogger(t *testing.T) { + restoreGlobal(t) + + buf := new(bytes.Buffer) + globalLogger.Store(slog.New(newLoggerHandler(globalLevel, false, buf))) + + SetLevel(LevelError) + Info(context.Background(), "dropped") + + if buf.Len() != 0 { + t.Fatalf("SetLevel did not raise the level, got %s", buf.String()) + } + + SetLevel(LevelDebug) + Info(context.Background(), "kept") + + if buf.Len() == 0 { + t.Fatal("SetLevel did not lower the level") + } +} + +// Dependencies logging through slog.Default must reach the installed logger. +func TestSetGlobalLoggerBridgesSlogDefault(t *testing.T) { + restoreGlobal(t) + + buf := new(bytes.Buffer) + SetGlobalLogger(slog.New(newLoggerHandler(LevelDebug, false, buf))) + + slog.Info("from a dependency") + + if !strings.Contains(buf.String(), "from a dependency") { + t.Fatalf("slog.Default was not bridged: %q", buf.String()) + } +} + +// A nil logger in the context must fall back to the global logger. +func TestWithLoggerNilFallsBack(t *testing.T) { + Info(WithLogger(context.Background(), nil), "hello") +} + +func TestSetGlobalLoggerConcurrent(t *testing.T) { + restoreGlobal(t) + + SetGlobalLogger(NewDiscardLogger()) + + var wg sync.WaitGroup + + wg.Add(2) + + go func() { + defer wg.Done() + + for range 100 { + SetGlobalLogger(NewDiscardLogger()) + } + }() + + go func() { + defer wg.Done() + + for range 100 { + Info(context.Background(), "concurrent") + } + }() + + wg.Wait() + + SetGlobalLogger(nil) + + if globalLogger.Load() == nil { + t.Fatal("SetGlobalLogger(nil) must not clear the global logger") + } +} + +func restoreGlobal(t *testing.T) { + t.Helper() + + logger, level, def := globalLogger.Load(), globalLevel.Level(), slog.Default() + + t.Cleanup(func() { + globalLogger.Store(logger) + globalLevel.Set(level) + slog.SetDefault(def) + }) +} diff --git a/slogpretty.go b/slogpretty.go index 14457d7..ed95fd6 100644 --- a/slogpretty.go +++ b/slogpretty.go @@ -1,97 +1,135 @@ -package logger - -import ( - "context" - "encoding/json" - "io" - stdlog "log" - "log/slog" - - "github.com/fatih/color" -) - -type prettyHandlerOptions struct { - SlogOpts *slog.HandlerOptions -} - -type prettyHandler struct { - opts prettyHandlerOptions - slog.Handler - l *stdlog.Logger - attrs []slog.Attr -} - -func (opts prettyHandlerOptions) newPrettyHandler( - out io.Writer, -) *prettyHandler { - h := &prettyHandler{ - Handler: slog.NewJSONHandler(out, opts.SlogOpts), - l: stdlog.New(out, "", 0), - } - - return h -} - -func (h *prettyHandler) Handle(_ context.Context, r slog.Record) error { - level := r.Level.String() + ":" - - switch r.Level { - case slog.LevelDebug: - level = color.MagentaString(level) - case slog.LevelInfo: - level = color.BlueString(level) - case slog.LevelWarn: - level = color.YellowString(level) - case slog.LevelError: - level = color.RedString(level) - } - - fields := make(map[string]interface{}, r.NumAttrs()) - - r.Attrs(func(a slog.Attr) bool { - fields[a.Key] = a.Value.Any() - - return true - }) - - for _, a := range h.attrs { - fields[a.Key] = a.Value.Any() - } - - var b []byte - var err error - - if len(fields) > 0 { - b, err = json.MarshalIndent(fields, "", " ") - if err != nil { - return err - } - } - - timeStr := r.Time.Format("[15:05:05.000]") - msg := color.CyanString(r.Message) - - h.l.Println( - timeStr, - level, - msg, - color.WhiteString(string(b)), - ) - - return nil -} - -func (h *prettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return &prettyHandler{ - Handler: h.Handler, - l: h.l, - attrs: attrs, - } -} - -func (h *prettyHandler) WithGroup(name string) slog.Handler { - return &prettyHandler{ - Handler: h.Handler.WithGroup(name), - l: h.l, - } -} +package logger + +import ( + "context" + "encoding/json" + "fmt" + "io" + stdlog "log" + "log/slog" + "runtime" + + "github.com/fatih/color" +) + +type prettyHandlerOptions struct { + SlogOpts *slog.HandlerOptions +} + +type prettyHandler struct { + slog.Handler + l *stdlog.Logger + attrs []slog.Attr + prefix string + addSource bool +} + +func (opts prettyHandlerOptions) newPrettyHandler( + out io.Writer, +) *prettyHandler { + h := &prettyHandler{ + Handler: slog.NewJSONHandler(out, opts.SlogOpts), + l: stdlog.New(out, "", 0), + addSource: opts.SlogOpts != nil && opts.SlogOpts.AddSource, + } + + return h +} + +func (h *prettyHandler) Handle(_ context.Context, r slog.Record) error { + fields := make(map[string]any, len(h.attrs)+r.NumAttrs()) + + for _, a := range h.attrs { + fields[a.Key] = a.Value.Any() + } + + r.Attrs(func(a slog.Attr) bool { + fields[h.prefix+a.Key] = a.Value.Any() + + return true + }) + + var ( + b []byte + err error + ) + + if len(fields) > 0 { + b, err = json.MarshalIndent(fields, "", " ") + if err != nil { + return fmt.Errorf("pretty handler: marshal attrs: %w", err) + } + } + + h.l.Println( + r.Time.Format("[15:04:05.000]"), + colorLevel(r.Level), + color.CyanString(r.Message), + h.source(r), + color.WhiteString(string(b)), + ) + + return nil +} + +// source renders the caller of the logging call, when the handler was built with +// AddSource. +func (h *prettyHandler) source(r slog.Record) string { + if !h.addSource || r.PC == 0 { + return "" + } + + f, _ := runtime.CallersFrames([]uintptr{r.PC}).Next() + + return color.HiBlackString("(%s:%d)", f.File, f.Line) +} + +func (h *prettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + // the attrs already held were captured under their own groups, so only the new + // ones take the current prefix + merged := make([]slog.Attr, 0, len(h.attrs)+len(attrs)) + merged = append(merged, h.attrs...) + + for _, a := range attrs { + merged = append(merged, slog.Attr{Key: h.prefix + a.Key, Value: a.Value}) + } + + return &prettyHandler{ + Handler: h.Handler, + l: h.l, + attrs: merged, + prefix: h.prefix, + addSource: h.addSource, + } +} + +func (h *prettyHandler) WithGroup(name string) slog.Handler { + return &prettyHandler{ + Handler: h.Handler.WithGroup(name), + l: h.l, + attrs: h.attrs, + prefix: h.prefix + name + ".", + addSource: h.addSource, + } +} + +// colorLevel labels a record with the same name the JSON handler uses, coloured by +// severity, so the levels this package adds are not printed as slog offsets. +func colorLevel(lvl slog.Level) string { + name := LevelName(lvl) + ":" + + switch { + case lvl < LevelInfo: + return color.MagentaString(name) + case lvl < LevelNotice: + return color.BlueString(name) + case lvl < LevelWarn: + return color.GreenString(name) + case lvl < LevelError: + return color.YellowString(name) + case lvl < LevelCritical: + return color.RedString(name) + default: + return color.HiRedString(name) + } +} diff --git a/split.go b/split.go new file mode 100644 index 0000000..a906d4c --- /dev/null +++ b/split.go @@ -0,0 +1,37 @@ +package logger + +import ( + "context" + "log/slog" +) + +// splitHandler routes a record to one of two handlers by level, so that errors can +// go to a different destination than ordinary output. +type splitHandler struct { + low, high slog.Handler +} + +func (h *splitHandler) Enabled(ctx context.Context, lvl slog.Level) bool { + return h.pick(lvl).Enabled(ctx, lvl) +} + +func (h *splitHandler) Handle(ctx context.Context, r slog.Record) error { + //nolint:wrapcheck // the chosen handler already names what went wrong + return h.pick(r.Level).Handle(ctx, r) +} + +func (h *splitHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &splitHandler{low: h.low.WithAttrs(attrs), high: h.high.WithAttrs(attrs)} +} + +func (h *splitHandler) WithGroup(name string) slog.Handler { + return &splitHandler{low: h.low.WithGroup(name), high: h.high.WithGroup(name)} +} + +func (h *splitHandler) pick(lvl slog.Level) slog.Handler { + if lvl >= LevelError { + return h.high + } + + return h.low +} From 51496b691a8b8c44da7789e7197a28f371780b3f Mon Sep 17 00:00:00 2001 From: comicrime Date: Sun, 30 Aug 2026 15:13:23 +0300 Subject: [PATCH 2/2] bump go version --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 78401c6..04189b8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/draincloud/logger -go 1.24.1 +go 1.25 require github.com/fatih/color v1.18.0