feat: Use slog instead of zap

This commit is contained in:
Paramtamtam 2024-06-24 19:28:03 +04:00
parent a52dbde00c
commit ceeb7f9384
No known key found for this signature in database
GPG Key ID: 366371698FAD0A2B
18 changed files with 525 additions and 166 deletions

View File

@ -37,15 +37,9 @@ linters-settings:
ignore-words: [cancelled] ignore-words: [cancelled]
lll: lll:
line-length: 120 line-length: 120
forbidigo: # forbidigo:
forbid: # forbid:
- '^(fmt\.Print(|f|ln)|print(|ln))(# it looks like a forgotten debugging printing call)?$' # - '^(fmt\.Print(|f|ln)|print(|ln))(# it looks like a forgotten debugging printing call)?$'
depguard:
rules:
logger:
deny:
- pkg: log
desc: 'logging is allowed only by zaplog'
prealloc: prealloc:
simple: true simple: true
range-loops: true range-loops: true
@ -108,7 +102,6 @@ linters: # All available linters list: <https://golangci-lint.run/usage/linters/
- durationcheck # Check for two durations multiplied together - durationcheck # Check for two durations multiplied together
- errchkjson # Checks types passed to the json encoding functions - errchkjson # Checks types passed to the json encoding functions
- errname # Checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error - errname # Checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error
- depguard # Go linter that checks if package imports are in a list of acceptable packages.
issues: issues:
exclude-dirs: exclude-dirs:

View File

@ -60,8 +60,6 @@ func NewApp(appName string) *cli.Command { //nolint:funlen
Usage: appName, Usage: appName,
Suggest: true, Suggest: true,
Before: func(ctx context.Context, c *cli.Command) error { Before: func(ctx context.Context, c *cli.Command) error {
_ = log.Sync() // sync previous logger instance
var ( var (
logLevel, _ = logger.ParseLevel(c.String(logLevelFlag.Name)) // error ignored because the flag validates itself logLevel, _ = logger.ParseLevel(c.String(logLevelFlag.Name)) // error ignored because the flag validates itself
logFormat, _ = logger.ParseFormat(c.String(logFormatFlag.Name)) // --//-- logFormat, _ = logger.ParseFormat(c.String(logFormatFlag.Name)) // --//--

View File

@ -5,9 +5,9 @@ import (
"fmt" "fmt"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
"go.uber.org/zap"
"gh.tarampamp.am/error-pages/internal/cli/shared" "gh.tarampamp.am/error-pages/internal/cli/shared"
"gh.tarampamp.am/error-pages/internal/logger"
) )
type checker interface { type checker interface {
@ -15,7 +15,7 @@ type checker interface {
} }
// NewCommand creates `healthcheck` command. // NewCommand creates `healthcheck` command.
func NewCommand(_ *zap.Logger, checker checker) *cli.Command { func NewCommand(_ *logger.Logger, checker checker) *cli.Command {
var portFlag = shared.ListenPortFlag var portFlag = shared.ListenPortFlag
return &cli.Command{ return &cli.Command{

View File

@ -6,15 +6,15 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/zap"
"gh.tarampamp.am/error-pages/internal/cli/healthcheck" "gh.tarampamp.am/error-pages/internal/cli/healthcheck"
"gh.tarampamp.am/error-pages/internal/logger"
) )
func TestNewCommand(t *testing.T) { func TestNewCommand(t *testing.T) {
t.Parallel() t.Parallel()
var cmd = healthcheck.NewCommand(zap.NewNop(), nil) var cmd = healthcheck.NewCommand(logger.NewNop(), nil)
assert.Equal(t, "healthcheck", cmd.Name) assert.Equal(t, "healthcheck", cmd.Name)
assert.Equal(t, []string{"chk", "health", "check"}, cmd.Aliases) assert.Equal(t, []string{"chk", "health", "check"}, cmd.Aliases)
@ -35,7 +35,7 @@ func (m *fakeHealthChecker) Check(_ context.Context, addr string) error {
func TestCommand_RunSuccess(t *testing.T) { func TestCommand_RunSuccess(t *testing.T) {
t.Parallel() t.Parallel()
var cmd = healthcheck.NewCommand(zap.NewNop(), &fakeHealthChecker{ var cmd = healthcheck.NewCommand(logger.NewNop(), &fakeHealthChecker{
t: t, t: t,
wantAddress: "http://127.0.0.1:1234", wantAddress: "http://127.0.0.1:1234",
}) })
@ -46,7 +46,7 @@ func TestCommand_RunSuccess(t *testing.T) {
func TestCommand_RunFail(t *testing.T) { func TestCommand_RunFail(t *testing.T) {
t.Parallel() t.Parallel()
cmd := healthcheck.NewCommand(zap.NewNop(), &fakeHealthChecker{ cmd := healthcheck.NewCommand(logger.NewNop(), &fakeHealthChecker{
t: t, t: t,
wantAddress: "http://127.0.0.1:4321", wantAddress: "http://127.0.0.1:4321",
giveErr: assert.AnError, giveErr: assert.AnError,

View File

@ -9,11 +9,11 @@ import (
"time" "time"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
"go.uber.org/zap"
"gh.tarampamp.am/error-pages/internal/cli/shared" "gh.tarampamp.am/error-pages/internal/cli/shared"
"gh.tarampamp.am/error-pages/internal/config" "gh.tarampamp.am/error-pages/internal/config"
appHttp "gh.tarampamp.am/error-pages/internal/http" appHttp "gh.tarampamp.am/error-pages/internal/http"
"gh.tarampamp.am/error-pages/internal/logger"
) )
type command struct { type command struct {
@ -29,7 +29,7 @@ type command struct {
} }
// NewCommand creates `serve` command. // NewCommand creates `serve` command.
func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
var ( var (
cmd command cmd command
cfg = config.New() cfg = config.New()
@ -182,8 +182,8 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
return fmt.Errorf("cannot add template from file %s: %w", templatePath, err) return fmt.Errorf("cannot add template from file %s: %w", templatePath, err)
} else { } else {
log.Info("Template added", log.Info("Template added",
zap.String("name", addedName), logger.String("name", addedName),
zap.String("path", templatePath), logger.String("path", templatePath),
) )
} }
} }
@ -207,9 +207,9 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
cfg.Codes[code] = desc cfg.Codes[code] = desc
log.Info("HTTP code added", log.Info("HTTP code added",
zap.String("code", code), logger.String("code", code),
zap.String("message", desc.Message), logger.String("message", desc.Message),
zap.String("description", desc.Description), logger.String("description", desc.Description),
) )
} }
} }
@ -225,16 +225,16 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
} }
log.Debug("Configuration", log.Debug("Configuration",
zap.Strings("loaded templates", cfg.Templates.Names()), logger.Strings("loaded templates", cfg.Templates.Names()...),
zap.Strings("described HTTP codes", cfg.Codes.Codes()), logger.Strings("described HTTP codes", cfg.Codes.Codes()...),
zap.String("JSON format", cfg.Formats.JSON), logger.String("JSON format", cfg.Formats.JSON),
zap.String("XML format", cfg.Formats.XML), logger.String("XML format", cfg.Formats.XML),
zap.String("template name", cfg.TemplateName), logger.String("template name", cfg.TemplateName),
zap.Bool("disable localization", cfg.L10n.Disable), logger.Bool("disable localization", cfg.L10n.Disable),
zap.Uint16("default code to render", cfg.DefaultCodeToRender), logger.Uint16("default code to render", cfg.DefaultCodeToRender),
zap.Bool("respond with the same HTTP code", cfg.RespondWithSameHTTPCode), logger.Bool("respond with the same HTTP code", cfg.RespondWithSameHTTPCode),
zap.Bool("show details", cfg.ShowDetails), logger.Bool("show details", cfg.ShowDetails),
zap.Strings("proxy HTTP headers", cfg.ProxyHeaders), logger.Strings("proxy HTTP headers", cfg.ProxyHeaders...),
) )
return cmd.Run(ctx, log, &cfg) return cmd.Run(ctx, log, &cfg)
@ -261,7 +261,7 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
} }
// Run current command. // Run current command.
func (cmd *command) Run(ctx context.Context, log *zap.Logger, cfg *config.Config) error { func (cmd *command) Run(ctx context.Context, log *logger.Logger, cfg *config.Config) error {
var srv = appHttp.NewServer(ctx, log) var srv = appHttp.NewServer(ctx, log)
if err := srv.Register(cfg); err != nil { if err := srv.Register(cfg); err != nil {
@ -276,12 +276,12 @@ func (cmd *command) Run(ctx context.Context, log *zap.Logger, cfg *config.Config
var now = time.Now() var now = time.Now()
defer func() { defer func() {
log.Info("HTTP server stopped", zap.Duration("uptime", time.Since(now).Round(time.Millisecond))) log.Info("HTTP server stopped", logger.Duration("uptime", time.Since(now).Round(time.Millisecond)))
}() }()
log.Info("HTTP server starting", log.Info("HTTP server starting",
zap.String("addr", cmd.opt.http.addr), logger.String("addr", cmd.opt.http.addr),
zap.Uint16("port", cmd.opt.http.port), logger.Uint16("port", cmd.opt.http.port),
) )
if err := srv.Start(cmd.opt.http.addr, cmd.opt.http.port); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := srv.Start(cmd.opt.http.addr, cmd.opt.http.port); err != nil && !errors.Is(err, http.ErrServerClosed) {
@ -297,7 +297,7 @@ func (cmd *command) Run(ctx context.Context, log *zap.Logger, cfg *config.Config
case <-ctx.Done(): // ..or context cancellation case <-ctx.Done(): // ..or context cancellation
const shutdownTimeout = 5 * time.Second const shutdownTimeout = 5 * time.Second
log.Info("HTTP server stopping", zap.Duration("with timeout", shutdownTimeout)) log.Info("HTTP server stopping", logger.Duration("with timeout", shutdownTimeout))
if err := srv.Stop(shutdownTimeout); err != nil { //nolint:contextcheck if err := srv.Stop(shutdownTimeout); err != nil { //nolint:contextcheck
return err return err

View File

@ -4,13 +4,13 @@ import (
"net/http" "net/http"
"time" "time"
"go.uber.org/zap" "gh.tarampamp.am/error-pages/internal/logger"
) )
// New creates a middleware for [http.ServeMux] that logs every incoming request. // New creates a middleware for [http.ServeMux] that logs every incoming request.
// //
// The skipper function should return true if the request should be skipped. It's ok to pass nil. // The skipper function should return true if the request should be skipped. It's ok to pass nil.
func New(log *zap.Logger, skipper func(*http.Request) bool) func(http.Handler) http.Handler { func New(log *logger.Logger, skipper func(*http.Request) bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if skipper != nil && skipper(r) { if skipper != nil && skipper(r) {
@ -22,21 +22,21 @@ func New(log *zap.Logger, skipper func(*http.Request) bool) func(http.Handler) h
var now = time.Now() var now = time.Now()
defer func() { defer func() {
var fields = []zap.Field{ var fields = []logger.Attr{
zap.String("useragent", r.UserAgent()), logger.String("useragent", r.UserAgent()),
zap.String("method", r.Method), logger.String("method", r.Method),
zap.String("url", r.URL.String()), logger.String("url", r.URL.String()),
zap.String("referer", r.Referer()), logger.String("referer", r.Referer()),
zap.String("content type", w.Header().Get("Content-Type")), logger.String("content type", w.Header().Get("Content-Type")),
zap.String("remote addr", r.RemoteAddr), logger.String("remote addr", r.RemoteAddr),
zap.String("method", r.Method), logger.String("method", r.Method),
zap.Duration("duration", time.Since(now).Round(time.Microsecond)), logger.Duration("duration", time.Since(now).Round(time.Microsecond)),
} }
if log.Level() <= zap.DebugLevel { if log.Level() <= logger.DebugLevel {
fields = append(fields, fields = append(fields,
zap.Any("request headers", r.Header.Clone()), logger.Any("request headers", r.Header.Clone()),
zap.Any("response headers", w.Header().Clone()), logger.Any("response headers", w.Header().Clone()),
) )
} }

View File

@ -8,24 +8,23 @@ import (
"strings" "strings"
"time" "time"
"go.uber.org/zap"
"gh.tarampamp.am/error-pages/internal/appmeta" "gh.tarampamp.am/error-pages/internal/appmeta"
"gh.tarampamp.am/error-pages/internal/config" "gh.tarampamp.am/error-pages/internal/config"
ep "gh.tarampamp.am/error-pages/internal/http/handlers/error_page" ep "gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
"gh.tarampamp.am/error-pages/internal/http/handlers/live" "gh.tarampamp.am/error-pages/internal/http/handlers/live"
"gh.tarampamp.am/error-pages/internal/http/handlers/version" "gh.tarampamp.am/error-pages/internal/http/handlers/version"
"gh.tarampamp.am/error-pages/internal/http/middleware/logreq" "gh.tarampamp.am/error-pages/internal/http/middleware/logreq"
"gh.tarampamp.am/error-pages/internal/logger"
) )
// Server is an HTTP server for serving error pages. // Server is an HTTP server for serving error pages.
type Server struct { type Server struct {
log *zap.Logger log *logger.Logger
server *http.Server server *http.Server
} }
// NewServer creates a new HTTP server. // NewServer creates a new HTTP server.
func NewServer(baseCtx context.Context, log *zap.Logger) Server { func NewServer(baseCtx context.Context, log *logger.Logger) Server {
const ( const (
readTimeout = 30 * time.Second readTimeout = 30 * time.Second
writeTimeout = readTimeout + 10*time.Second // should be bigger than the read timeout writeTimeout = readTimeout + 10*time.Second // should be bigger than the read timeout
@ -39,7 +38,7 @@ func NewServer(baseCtx context.Context, log *zap.Logger) Server {
WriteTimeout: writeTimeout, WriteTimeout: writeTimeout,
ReadHeaderTimeout: readTimeout, ReadHeaderTimeout: readTimeout,
MaxHeaderBytes: maxHeaderBytes, MaxHeaderBytes: maxHeaderBytes,
ErrorLog: zap.NewStdLog(log), ErrorLog: logger.NewStdLog(log),
BaseContext: func(net.Listener) context.Context { return baseCtx }, BaseContext: func(net.Listener) context.Context { return baseCtx },
}, },
} }

View File

@ -13,15 +13,15 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/zap"
"gh.tarampamp.am/error-pages/internal/config" "gh.tarampamp.am/error-pages/internal/config"
appHttp "gh.tarampamp.am/error-pages/internal/http" appHttp "gh.tarampamp.am/error-pages/internal/http"
"gh.tarampamp.am/error-pages/internal/logger"
) )
func TestRouting(t *testing.T) { func TestRouting(t *testing.T) {
var ( var (
srv = appHttp.NewServer(context.Background(), zap.NewNop()) srv = appHttp.NewServer(context.Background(), logger.NewNop())
cfg = config.New() cfg = config.New()
) )

42
internal/logger/attr.go Normal file
View File

@ -0,0 +1,42 @@
package logger
import (
"log/slog"
"time"
)
// An Attr is a key-value pair.
type Attr = slog.Attr
// String returns an Attr for a string value.
func String(key, value string) Attr { return slog.String(key, value) }
// Strings returns an Attr for a slice of strings.
func Strings(key string, value ...string) Attr { return slog.Any(key, value) }
// Int64 returns an Attr for an int64.
func Int64(key string, value int64) Attr { return slog.Int64(key, value) }
// Int converts an int to an int64 and returns an Attr with that value.
func Int(key string, value int) Attr { return slog.Int(key, value) }
// Uint64 returns an Attr for an uint64.
func Uint64(key string, v uint64) Attr { return slog.Uint64(key, v) }
// Uint16 returns an Attr for an uint16.
func Uint16(key string, v uint16) Attr { return slog.Uint64(key, uint64(v)) }
// Float64 returns an Attr for a floating-point number.
func Float64(key string, v float64) Attr { return slog.Float64(key, v) }
// Bool returns an Attr for a bool.
func Bool(key string, v bool) Attr { return slog.Bool(key, v) }
// Time returns an Attr for a [time.Time]. It discards the monotonic portion.
func Time(key string, v time.Time) Attr { return slog.Time(key, v) }
// Duration returns an Attr for a [time.Duration].
func Duration(key string, v time.Duration) Attr { return slog.Duration(key, v) }
// Any returns an Attr for any value.
func Any(key string, v any) Attr { return slog.Any(key, v) }

View File

@ -0,0 +1,44 @@
package logger_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/logger"
)
func TestAttrs(t *testing.T) {
t.Parallel()
var someTime, _ = time.Parse(time.RFC3339, "2021-01-01T00:00:00Z")
for name, _tt := range map[string]struct {
giveAttr logger.Attr
wantKey string
wantValue any
}{
"String": {logger.String("key", "value"), "key", "value"},
"Strings": {logger.Strings("key", "value1", "value2"), "key", []string{"value1", "value2"}},
"Int64": {logger.Int64("key", 42), "key", int64(42)},
"Int": {logger.Int("key", 42), "key", int64(42)},
"Uint64": {logger.Uint64("key", 42), "key", uint64(42)},
"Uint16": {logger.Uint16("key", 42), "key", uint64(42)},
"Float64": {logger.Float64("key", 42.42), "key", 42.42},
"Bool": {logger.Bool("key", true), "key", true},
"Time": {logger.Time("key", someTime), "key", someTime},
"Duration": {logger.Duration("key", time.Second), "key", time.Second},
"Any": {logger.Any("key", "value"), "key", "value"},
} {
tt := _tt
t.Run(name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.wantKey, tt.giveAttr.Key)
assert.Equal(t, tt.wantValue, tt.giveAttr.Value.Any())
})
}
}

View File

@ -60,3 +60,11 @@ func TestParseFormat(t *testing.T) {
}) })
} }
} }
func TestFormats(t *testing.T) {
require.Equal(t, []logger.Format{logger.ConsoleFormat, logger.JSONFormat}, logger.Formats())
}
func TestFormatStrings(t *testing.T) {
require.Equal(t, []string{"console", "json"}, logger.FormatStrings())
}

View File

@ -13,7 +13,6 @@ const (
InfoLevel // default level (zero-value) InfoLevel // default level (zero-value)
WarnLevel WarnLevel
ErrorLevel ErrorLevel
FatalLevel
) )
// String returns a lower-case ASCII representation of the log level. // String returns a lower-case ASCII representation of the log level.
@ -27,8 +26,6 @@ func (l Level) String() string {
return "warn" return "warn"
case ErrorLevel: case ErrorLevel:
return "error" return "error"
case FatalLevel:
return "fatal"
} }
return fmt.Sprintf("level(%d)", l) return fmt.Sprintf("level(%d)", l)
@ -36,7 +33,7 @@ func (l Level) String() string {
// Levels returns a slice of all logging levels. // Levels returns a slice of all logging levels.
func Levels() []Level { func Levels() []Level {
return []Level{DebugLevel, InfoLevel, WarnLevel, ErrorLevel, FatalLevel} return []Level{DebugLevel, InfoLevel, WarnLevel, ErrorLevel}
} }
// LevelStrings returns a slice of all logging levels as strings. // LevelStrings returns a slice of all logging levels as strings.
@ -75,8 +72,6 @@ func ParseLevel[T string | []byte](text T) (Level, error) {
return WarnLevel, nil return WarnLevel, nil
case "error": case "error":
return ErrorLevel, nil return ErrorLevel, nil
case "fatal":
return FatalLevel, nil
} }
return Level(0), fmt.Errorf("unrecognized logging level: %q", text) return Level(0), fmt.Errorf("unrecognized logging level: %q", text)

View File

@ -18,7 +18,6 @@ func TestLevel_String(t *testing.T) {
"info": {giveLevel: logger.InfoLevel, wantString: "info"}, "info": {giveLevel: logger.InfoLevel, wantString: "info"},
"warn": {giveLevel: logger.WarnLevel, wantString: "warn"}, "warn": {giveLevel: logger.WarnLevel, wantString: "warn"},
"error": {giveLevel: logger.ErrorLevel, wantString: "error"}, "error": {giveLevel: logger.ErrorLevel, wantString: "error"},
"fatal": {giveLevel: logger.FatalLevel, wantString: "fatal"},
"<unknown>": {giveLevel: logger.Level(127), wantString: "level(127)"}, "<unknown>": {giveLevel: logger.Level(127), wantString: "level(127)"},
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
@ -43,8 +42,6 @@ func TestParseLevel(t *testing.T) {
"info": {giveBytes: []byte("info"), wantLevel: logger.InfoLevel}, "info": {giveBytes: []byte("info"), wantLevel: logger.InfoLevel},
"warn": {giveBytes: []byte("warn"), wantLevel: logger.WarnLevel}, "warn": {giveBytes: []byte("warn"), wantLevel: logger.WarnLevel},
"error": {giveBytes: []byte("error"), wantLevel: logger.ErrorLevel}, "error": {giveBytes: []byte("error"), wantLevel: logger.ErrorLevel},
"fatal": {giveBytes: []byte("fatal"), wantLevel: logger.FatalLevel},
"fatal (string)": {giveString: "fatal", wantLevel: logger.FatalLevel},
"foobar": {giveBytes: []byte("foobar"), wantError: errors.New("unrecognized logging level: \"foobar\"")}, //nolint:lll "foobar": {giveBytes: []byte("foobar"), wantError: errors.New("unrecognized logging level: \"foobar\"")}, //nolint:lll
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
@ -75,10 +72,9 @@ func TestLevels(t *testing.T) {
logger.InfoLevel, logger.InfoLevel,
logger.WarnLevel, logger.WarnLevel,
logger.ErrorLevel, logger.ErrorLevel,
logger.FatalLevel,
}, logger.Levels()) }, logger.Levels())
} }
func TestLevelStrings(t *testing.T) { func TestLevelStrings(t *testing.T) {
require.Equal(t, []string{"debug", "info", "warn", "error", "fatal"}, logger.LevelStrings()) require.Equal(t, []string{"debug", "info", "warn", "error"}, logger.LevelStrings())
} }

View File

@ -1,60 +1,126 @@
package logger package logger
import ( import (
"context"
"errors" "errors"
"io"
"go.uber.org/zap" "log/slog"
"go.uber.org/zap/zapcore" "os"
"strings"
"time"
) )
// New creates new "zap" logger with a small customization. // internalAttrKeyLoggerName is used to store the logger name in the logger context (attributes).
func New(l Level, f Format) (*zap.Logger, error) { const internalAttrKeyLoggerName = "named_logger"
var config zap.Config
switch f { var (
case ConsoleFormat: // consoleFormatAttrReplacer is a replacer for console format. It replaces some attributes with more
config = zap.NewDevelopmentConfig() // human-readable ones.
config.EncoderConfig.EncodeLevel = zapcore.LowercaseColorLevelEncoder consoleFormatAttrReplacer = func(_ []string, a slog.Attr) slog.Attr { //nolint:gochecknoglobals
config.EncoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("15:04:05") switch a.Key {
case internalAttrKeyLoggerName:
return slog.String("logger", a.Value.String())
case "level":
return slog.String(a.Key, strings.ToLower(a.Value.String()))
default:
if ts, ok := a.Value.Any().(time.Time); ok && a.Key == "time" {
return slog.String(a.Key, ts.Format("15:04:05"))
}
}
case JSONFormat: return a
config = zap.NewProductionConfig() // json encoder is used by default
default:
return nil, errors.New("unsupported logging format")
} }
// default configuration for all encoders // jsonFormatAttrReplacer is a replacer for JSON format. It replaces some attributes with more
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel) // machine-readable ones.
config.Development = false jsonFormatAttrReplacer = func(_ []string, a slog.Attr) slog.Attr { //nolint:gochecknoglobals
config.DisableStacktrace = true switch a.Key {
config.DisableCaller = true case internalAttrKeyLoggerName:
return slog.String("logger", a.Value.String())
case "level":
return slog.String(a.Key, strings.ToLower(a.Value.String()))
default:
if ts, ok := a.Value.Any().(time.Time); ok && a.Key == "time" {
return slog.Float64("ts", float64(ts.Unix())+float64(ts.Nanosecond())/1e9)
}
}
// enable additional features for debugging return a
if l <= DebugLevel {
config.Development = true
config.DisableStacktrace = false
config.DisableCaller = false
} }
)
var zapLvl zapcore.Level // Logger is a simple logger that wraps [slog.Logger]. It provides a more convenient API for logging and
// formatting messages.
type Logger struct {
ctx context.Context
slog *slog.Logger
lvl Level
}
switch l { // convert level to zap.Level // New creates a new logger with the given level and format. Optionally, you can specify the writer to write logs to.
func New(l Level, f Format, writer ...io.Writer) (*Logger, error) {
var options slog.HandlerOptions
switch l {
case DebugLevel: case DebugLevel:
zapLvl = zap.DebugLevel options.Level = slog.LevelDebug
case InfoLevel: case InfoLevel:
zapLvl = zap.InfoLevel options.Level = slog.LevelInfo
case WarnLevel: case WarnLevel:
zapLvl = zap.WarnLevel options.Level = slog.LevelWarn
case ErrorLevel: case ErrorLevel:
zapLvl = zap.ErrorLevel options.Level = slog.LevelError
case FatalLevel:
zapLvl = zap.FatalLevel
default: default:
return nil, errors.New("unsupported logging level") return nil, errors.New("unsupported logging level")
} }
config.Level = zap.NewAtomicLevelAt(zapLvl) var (
handler slog.Handler
target io.Writer
)
return config.Build() if len(writer) > 0 && writer[0] != nil {
target = writer[0]
} else {
target = os.Stderr
}
switch f {
case ConsoleFormat:
options.ReplaceAttr = consoleFormatAttrReplacer
handler = slog.NewTextHandler(target, &options)
case JSONFormat:
options.ReplaceAttr = jsonFormatAttrReplacer
handler = slog.NewJSONHandler(target, &options)
default:
return nil, errors.New("unsupported logging format")
}
return &Logger{ctx: context.Background(), slog: slog.New(handler), lvl: l}, nil
} }
// Level returns the logger level.
func (l *Logger) Level() Level { return l.lvl }
// Named creates a new logger with the same properties as the original logger and the given name.
func (l *Logger) Named(name string) *Logger {
return &Logger{
ctx: l.ctx,
slog: l.slog.With(slog.String(internalAttrKeyLoggerName, name)),
lvl: l.lvl,
}
}
// Debug logs a message at DebugLevel.
func (l *Logger) Debug(msg string, f ...Attr) { l.slog.LogAttrs(l.ctx, slog.LevelDebug, msg, f...) }
// Info logs a message at InfoLevel.
func (l *Logger) Info(msg string, f ...Attr) { l.slog.LogAttrs(l.ctx, slog.LevelInfo, msg, f...) }
// Warn logs a message at WarnLevel.
func (l *Logger) Warn(msg string, f ...Attr) { l.slog.LogAttrs(l.ctx, slog.LevelWarn, msg, f...) }
// Error logs a message at ErrorLevel.
func (l *Logger) Error(msg string, f ...Attr) { l.slog.LogAttrs(l.ctx, slog.LevelError, msg, f...) }

View File

@ -0,0 +1,21 @@
package logger
import (
"context"
"log/slog"
)
// NewNop returns a no-op Logger. It never writes out logs or internal errors. The common use case is to use it
// in tests.
func NewNop() *Logger {
return &Logger{ctx: context.Background(), slog: slog.New(&noopHandler{}), lvl: DebugLevel}
}
type noopHandler struct{}
var _ slog.Handler = (*noopHandler)(nil) // verify interface implementation
func (noopHandler) Enabled(context.Context, slog.Level) bool { return true }
func (noopHandler) Handle(context.Context, slog.Record) error { return nil }
func (noopHandler) WithAttrs([]slog.Attr) slog.Handler { return noopHandler{} }
func (noopHandler) WithGroup(string) slog.Handler { return noopHandler{} }

View File

@ -1,75 +1,235 @@
package logger_test package logger_test
import ( import (
"regexp" "bytes"
"strings" "strconv"
"testing" "testing"
"time" "time"
"github.com/kami-zh/go-capturer"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gh.tarampamp.am/error-pages/internal/logger" "gh.tarampamp.am/error-pages/internal/logger"
) )
func TestNewDebugLevelConsoleFormat(t *testing.T) {
output := capturer.CaptureStderr(func() {
log, err := logger.New(logger.DebugLevel, logger.ConsoleFormat)
require.NoError(t, err)
log.Debug("dbg msg")
log.Info("inf msg")
log.Error("err msg")
})
assert.Contains(t, output, time.Now().Format("15:04:05"))
assert.Regexp(t, `\t.+info.+\tinf msg`, output)
assert.Regexp(t, `\t.+info.+\t.+logger_test\.go:\d+\tinf msg`, output)
assert.Contains(t, output, "dbg msg")
assert.Contains(t, output, "err msg")
}
func TestNewErrorLevelConsoleFormat(t *testing.T) {
output := capturer.CaptureStderr(func() {
log, err := logger.New(logger.ErrorLevel, logger.ConsoleFormat)
require.NoError(t, err)
log.Debug("dbg msg")
log.Info("inf msg")
log.Error("err msg")
})
assert.NotContains(t, output, "inf msg")
assert.NotContains(t, output, "dbg msg")
assert.Contains(t, output, "err msg")
}
func TestNewWarnLevelJSONFormat(t *testing.T) {
output := capturer.CaptureStderr(func() {
log, err := logger.New(logger.WarnLevel, logger.JSONFormat)
require.NoError(t, err)
log.Debug("dbg msg")
log.Info("inf msg")
log.Warn("warn msg")
log.Error("err msg")
})
// replace timestamp field with fixed value
fakeTimestamp := regexp.MustCompile(`"ts":\d+\.\d+,`)
output = fakeTimestamp.ReplaceAllString(output, `"ts":0.1,`)
lines := strings.Split(strings.Trim(output, "\n"), "\n")
assert.JSONEq(t, `{"level":"warn","ts":0.1,"msg":"warn msg"}`, lines[0])
assert.JSONEq(t, `{"level":"error","ts":0.1,"msg":"err msg"}`, lines[1])
}
func TestNewErrors(t *testing.T) { func TestNewErrors(t *testing.T) {
_, err := logger.New(logger.Level(127), logger.ConsoleFormat) log, err := logger.New(logger.Level(127), logger.ConsoleFormat)
require.Nil(t, log)
require.EqualError(t, err, "unsupported logging level") require.EqualError(t, err, "unsupported logging level")
_, err = logger.New(logger.WarnLevel, logger.Format(255)) log, err = logger.New(logger.WarnLevel, logger.Format(255))
require.Nil(t, log)
require.EqualError(t, err, "unsupported logging format") require.EqualError(t, err, "unsupported logging format")
} }
func TestLogger_ConsoleFormat(t *testing.T) {
var (
buf bytes.Buffer
log, logErr = logger.New(logger.DebugLevel, logger.ConsoleFormat, &buf)
now = time.Now()
)
require.NoError(t, logErr)
assert.Equal(t, logger.DebugLevel, log.Level())
log.Debug("debug message",
logger.String("String", "value"),
logger.Strings("Strings", "foo", "bar", ""),
logger.Int64("Int64", 0),
logger.Int("Int", 1),
logger.Uint64("Uint64", 2),
logger.Float64("Float64", 3.14),
logger.Bool("Bool", true),
logger.Time("Time", now),
logger.Duration("Duration", time.Millisecond),
)
var output = buf.String()
assert.Contains(t, output, `time=`+now.Format("15:04:")) // match without seconds
assert.Contains(t, output, `level=debug`)
assert.Contains(t, output, `msg="debug message"`)
assert.Contains(t, output, "String=value")
assert.Contains(t, output, `Strings="[foo bar ]"`)
assert.Contains(t, output, "Int64=0")
assert.Contains(t, output, "Int=1")
assert.Contains(t, output, "Uint64=2")
assert.Contains(t, output, "Float64=3.14")
assert.Contains(t, output, "Bool=true")
assert.Contains(t, output, "Time="+now.Format("2006-01-02T15:04:05.000Z07:00"))
assert.Contains(t, output, "Duration=1ms")
}
func TestLogger_JSONFormat(t *testing.T) {
var (
buf bytes.Buffer
log, logErr = logger.New(logger.DebugLevel, logger.JSONFormat, &buf)
now = time.Now()
)
require.NoError(t, logErr)
assert.Equal(t, logger.DebugLevel, log.Level())
log.Debug("debug message",
logger.String("String", "value"),
logger.Strings("Strings", "foo", "bar", ""),
logger.Int64("Int64", 0),
logger.Int("Int", 1),
logger.Uint64("Uint64", 2),
logger.Float64("Float64", 3.14),
logger.Bool("Bool", true),
logger.Time("Time", now),
logger.Duration("Duration", time.Millisecond),
)
var output = buf.String()
assert.Contains(t, output, `"ts":`+strconv.Itoa(int(now.Unix()))+".") // match without nanoseconds
assert.Contains(t, output, `"level":"debug"`)
assert.Contains(t, output, `"msg":"debug message"`)
assert.Contains(t, output, `"String":"value"`)
assert.Contains(t, output, `"Strings":["foo","bar",""]`)
assert.Contains(t, output, `"Int64":0`)
assert.Contains(t, output, `"Int":1`)
assert.Contains(t, output, `"Uint64":2`)
assert.Contains(t, output, `"Float64":3.14`)
assert.Contains(t, output, `"Bool":true`)
assert.Contains(t, output, `"Time":"`+now.Format("2006-01-02T15:04:05.000")) // omit nano seconds
assert.Contains(t, output, `"Duration":1000000`)
}
func TestLogger_Debug(t *testing.T) {
var (
buf bytes.Buffer
log, logErr = logger.New(logger.DebugLevel, logger.JSONFormat, &buf)
)
require.NoError(t, logErr)
assert.Equal(t, logger.DebugLevel, log.Level())
log.Debug("debug message")
log.Info("info message")
log.Warn("warn message")
log.Error("error message")
var output = buf.String()
assert.Contains(t, output, "debug message")
assert.Contains(t, output, "info message")
assert.Contains(t, output, "warn message")
assert.Contains(t, output, "error message")
}
func TestLogger_Info(t *testing.T) {
var (
buf bytes.Buffer
log, logErr = logger.New(logger.InfoLevel, logger.JSONFormat, &buf)
)
require.NoError(t, logErr)
assert.Equal(t, logger.InfoLevel, log.Level())
log.Debug("debug message")
log.Info("info message")
log.Warn("warn message")
log.Error("error message")
var output = buf.String()
assert.NotContains(t, output, "debug message")
assert.Contains(t, output, "info message")
assert.Contains(t, output, "warn message")
assert.Contains(t, output, "error message")
}
func TestLogger_Warn(t *testing.T) {
var (
buf bytes.Buffer
log, logErr = logger.New(logger.WarnLevel, logger.JSONFormat, &buf)
)
require.NoError(t, logErr)
assert.Equal(t, logger.WarnLevel, log.Level())
log.Debug("debug message")
log.Info("info message")
log.Warn("warn message")
log.Error("error message")
var output = buf.String()
assert.NotContains(t, output, "debug message")
assert.NotContains(t, output, "info message")
assert.Contains(t, output, "warn message")
assert.Contains(t, output, "error message")
}
func TestLogger_Error(t *testing.T) {
var (
buf bytes.Buffer
log, logErr = logger.New(logger.ErrorLevel, logger.JSONFormat, &buf)
)
require.NoError(t, logErr)
assert.Equal(t, logger.ErrorLevel, log.Level())
log.Debug("debug message")
log.Info("info message")
log.Warn("warn message")
log.Error("error message")
var output = buf.String()
assert.NotContains(t, output, "debug message")
assert.NotContains(t, output, "info message")
assert.NotContains(t, output, "warn message")
assert.Contains(t, output, "error message")
}
func TestLogger_Named_JSONFormat(t *testing.T) {
var (
buf bytes.Buffer
log, _ = logger.New(logger.DebugLevel, logger.JSONFormat, &buf)
named = log.Named("test_name")
)
log.Debug("debug message")
var output = buf.String()
assert.Contains(t, output, `"msg":"debug message"`)
assert.NotContains(t, output, `"logger":"`)
buf.Reset()
named.Debug("named log message")
output = buf.String()
assert.Contains(t, output, `"msg":"named log message"`)
assert.Contains(t, output, `"logger":"test_name"`)
}
func TestLogger_Named_ConsoleFormat(t *testing.T) {
var (
buf bytes.Buffer
log, _ = logger.New(logger.DebugLevel, logger.ConsoleFormat, &buf)
named = log.Named("test_name")
)
log.Debug("debug message")
var output = buf.String()
assert.Contains(t, output, `msg="debug message"`)
assert.NotContains(t, output, `logger=`)
buf.Reset()
named.Debug("named log message")
output = buf.String()
assert.Contains(t, output, `msg="named log message"`)
assert.Contains(t, output, `logger=test_name`)
}

14
internal/logger/std.go Normal file
View File

@ -0,0 +1,14 @@
package logger
import (
stdLog "log"
)
// NewStdLog returns a *[log.Logger] which writes to the supplied [Logger] at [InfoLevel].
func NewStdLog(log *Logger) *stdLog.Logger {
return stdLog.New(&loggerWriter{log} /* prefix */, "" /* flags */, 0)
}
type loggerWriter struct{ log *Logger }
func (lw *loggerWriter) Write(p []byte) (int, error) { lw.log.Info(string(p)); return len(p), nil } //nolint:nlreturn

View File

@ -0,0 +1,23 @@
package logger_test
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/logger"
)
func TestNewStdLog(t *testing.T) {
var (
buf bytes.Buffer
log, _ = logger.New(logger.InfoLevel, logger.JSONFormat, &buf)
std = logger.NewStdLog(log)
)
std.Print("test")
assert.Contains(t, buf.String(), "test")
}