mirror of
https://github.com/tarampampam/error-pages.git
synced 2024-08-30 18:22:40 +00:00
feat: ✨ Use slog instead of zap
This commit is contained in:
parent
a52dbde00c
commit
ceeb7f9384
@ -37,15 +37,9 @@ linters-settings:
|
||||
ignore-words: [cancelled]
|
||||
lll:
|
||||
line-length: 120
|
||||
forbidigo:
|
||||
forbid:
|
||||
- '^(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'
|
||||
# forbidigo:
|
||||
# forbid:
|
||||
# - '^(fmt\.Print(|f|ln)|print(|ln))(# it looks like a forgotten debugging printing call)?$'
|
||||
prealloc:
|
||||
simple: 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
|
||||
- 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
|
||||
- depguard # Go linter that checks if package imports are in a list of acceptable packages.
|
||||
|
||||
issues:
|
||||
exclude-dirs:
|
||||
|
@ -60,8 +60,6 @@ func NewApp(appName string) *cli.Command { //nolint:funlen
|
||||
Usage: appName,
|
||||
Suggest: true,
|
||||
Before: func(ctx context.Context, c *cli.Command) error {
|
||||
_ = log.Sync() // sync previous logger instance
|
||||
|
||||
var (
|
||||
logLevel, _ = logger.ParseLevel(c.String(logLevelFlag.Name)) // error ignored because the flag validates itself
|
||||
logFormat, _ = logger.ParseFormat(c.String(logFormatFlag.Name)) // --//--
|
||||
|
@ -5,9 +5,9 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/cli/shared"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
type checker interface {
|
||||
@ -15,7 +15,7 @@ type checker interface {
|
||||
}
|
||||
|
||||
// NewCommand creates `healthcheck` command.
|
||||
func NewCommand(_ *zap.Logger, checker checker) *cli.Command {
|
||||
func NewCommand(_ *logger.Logger, checker checker) *cli.Command {
|
||||
var portFlag = shared.ListenPortFlag
|
||||
|
||||
return &cli.Command{
|
||||
|
@ -6,15 +6,15 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/cli/healthcheck"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
func TestNewCommand(t *testing.T) {
|
||||
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, []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) {
|
||||
t.Parallel()
|
||||
|
||||
var cmd = healthcheck.NewCommand(zap.NewNop(), &fakeHealthChecker{
|
||||
var cmd = healthcheck.NewCommand(logger.NewNop(), &fakeHealthChecker{
|
||||
t: t,
|
||||
wantAddress: "http://127.0.0.1:1234",
|
||||
})
|
||||
@ -46,7 +46,7 @@ func TestCommand_RunSuccess(t *testing.T) {
|
||||
func TestCommand_RunFail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := healthcheck.NewCommand(zap.NewNop(), &fakeHealthChecker{
|
||||
cmd := healthcheck.NewCommand(logger.NewNop(), &fakeHealthChecker{
|
||||
t: t,
|
||||
wantAddress: "http://127.0.0.1:4321",
|
||||
giveErr: assert.AnError,
|
||||
|
@ -9,11 +9,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/cli/shared"
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
appHttp "gh.tarampamp.am/error-pages/internal/http"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
type command struct {
|
||||
@ -29,7 +29,7 @@ type command struct {
|
||||
}
|
||||
|
||||
// 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 (
|
||||
cmd command
|
||||
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)
|
||||
} else {
|
||||
log.Info("Template added",
|
||||
zap.String("name", addedName),
|
||||
zap.String("path", templatePath),
|
||||
logger.String("name", addedName),
|
||||
logger.String("path", templatePath),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -207,9 +207,9 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
|
||||
cfg.Codes[code] = desc
|
||||
|
||||
log.Info("HTTP code added",
|
||||
zap.String("code", code),
|
||||
zap.String("message", desc.Message),
|
||||
zap.String("description", desc.Description),
|
||||
logger.String("code", code),
|
||||
logger.String("message", desc.Message),
|
||||
logger.String("description", desc.Description),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -225,16 +225,16 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
|
||||
}
|
||||
|
||||
log.Debug("Configuration",
|
||||
zap.Strings("loaded templates", cfg.Templates.Names()),
|
||||
zap.Strings("described HTTP codes", cfg.Codes.Codes()),
|
||||
zap.String("JSON format", cfg.Formats.JSON),
|
||||
zap.String("XML format", cfg.Formats.XML),
|
||||
zap.String("template name", cfg.TemplateName),
|
||||
zap.Bool("disable localization", cfg.L10n.Disable),
|
||||
zap.Uint16("default code to render", cfg.DefaultCodeToRender),
|
||||
zap.Bool("respond with the same HTTP code", cfg.RespondWithSameHTTPCode),
|
||||
zap.Bool("show details", cfg.ShowDetails),
|
||||
zap.Strings("proxy HTTP headers", cfg.ProxyHeaders),
|
||||
logger.Strings("loaded templates", cfg.Templates.Names()...),
|
||||
logger.Strings("described HTTP codes", cfg.Codes.Codes()...),
|
||||
logger.String("JSON format", cfg.Formats.JSON),
|
||||
logger.String("XML format", cfg.Formats.XML),
|
||||
logger.String("template name", cfg.TemplateName),
|
||||
logger.Bool("disable localization", cfg.L10n.Disable),
|
||||
logger.Uint16("default code to render", cfg.DefaultCodeToRender),
|
||||
logger.Bool("respond with the same HTTP code", cfg.RespondWithSameHTTPCode),
|
||||
logger.Bool("show details", cfg.ShowDetails),
|
||||
logger.Strings("proxy HTTP headers", cfg.ProxyHeaders...),
|
||||
)
|
||||
|
||||
return cmd.Run(ctx, log, &cfg)
|
||||
@ -261,7 +261,7 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
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()
|
||||
|
||||
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",
|
||||
zap.String("addr", cmd.opt.http.addr),
|
||||
zap.Uint16("port", cmd.opt.http.port),
|
||||
logger.String("addr", cmd.opt.http.addr),
|
||||
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) {
|
||||
@ -297,7 +297,7 @@ func (cmd *command) Run(ctx context.Context, log *zap.Logger, cfg *config.Config
|
||||
case <-ctx.Done(): // ..or context cancellation
|
||||
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
|
||||
return err
|
||||
|
@ -4,13 +4,13 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
// 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.
|
||||
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 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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()
|
||||
|
||||
defer func() {
|
||||
var fields = []zap.Field{
|
||||
zap.String("useragent", r.UserAgent()),
|
||||
zap.String("method", r.Method),
|
||||
zap.String("url", r.URL.String()),
|
||||
zap.String("referer", r.Referer()),
|
||||
zap.String("content type", w.Header().Get("Content-Type")),
|
||||
zap.String("remote addr", r.RemoteAddr),
|
||||
zap.String("method", r.Method),
|
||||
zap.Duration("duration", time.Since(now).Round(time.Microsecond)),
|
||||
var fields = []logger.Attr{
|
||||
logger.String("useragent", r.UserAgent()),
|
||||
logger.String("method", r.Method),
|
||||
logger.String("url", r.URL.String()),
|
||||
logger.String("referer", r.Referer()),
|
||||
logger.String("content type", w.Header().Get("Content-Type")),
|
||||
logger.String("remote addr", r.RemoteAddr),
|
||||
logger.String("method", r.Method),
|
||||
logger.Duration("duration", time.Since(now).Round(time.Microsecond)),
|
||||
}
|
||||
|
||||
if log.Level() <= zap.DebugLevel {
|
||||
if log.Level() <= logger.DebugLevel {
|
||||
fields = append(fields,
|
||||
zap.Any("request headers", r.Header.Clone()),
|
||||
zap.Any("response headers", w.Header().Clone()),
|
||||
logger.Any("request headers", r.Header.Clone()),
|
||||
logger.Any("response headers", w.Header().Clone()),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -8,24 +8,23 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/appmeta"
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
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/version"
|
||||
"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.
|
||||
type Server struct {
|
||||
log *zap.Logger
|
||||
log *logger.Logger
|
||||
server *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 (
|
||||
readTimeout = 30 * time.Second
|
||||
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,
|
||||
ReadHeaderTimeout: readTimeout,
|
||||
MaxHeaderBytes: maxHeaderBytes,
|
||||
ErrorLog: zap.NewStdLog(log),
|
||||
ErrorLog: logger.NewStdLog(log),
|
||||
BaseContext: func(net.Listener) context.Context { return baseCtx },
|
||||
},
|
||||
}
|
||||
|
@ -13,15 +13,15 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
appHttp "gh.tarampamp.am/error-pages/internal/http"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
func TestRouting(t *testing.T) {
|
||||
var (
|
||||
srv = appHttp.NewServer(context.Background(), zap.NewNop())
|
||||
srv = appHttp.NewServer(context.Background(), logger.NewNop())
|
||||
cfg = config.New()
|
||||
)
|
||||
|
||||
|
42
internal/logger/attr.go
Normal file
42
internal/logger/attr.go
Normal 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) }
|
44
internal/logger/attr_test.go
Normal file
44
internal/logger/attr_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
|
@ -13,7 +13,6 @@ const (
|
||||
InfoLevel // default level (zero-value)
|
||||
WarnLevel
|
||||
ErrorLevel
|
||||
FatalLevel
|
||||
)
|
||||
|
||||
// String returns a lower-case ASCII representation of the log level.
|
||||
@ -27,8 +26,6 @@ func (l Level) String() string {
|
||||
return "warn"
|
||||
case ErrorLevel:
|
||||
return "error"
|
||||
case FatalLevel:
|
||||
return "fatal"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("level(%d)", l)
|
||||
@ -36,7 +33,7 @@ func (l Level) String() string {
|
||||
|
||||
// Levels returns a slice of all logging levels.
|
||||
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.
|
||||
@ -75,8 +72,6 @@ func ParseLevel[T string | []byte](text T) (Level, error) {
|
||||
return WarnLevel, nil
|
||||
case "error":
|
||||
return ErrorLevel, nil
|
||||
case "fatal":
|
||||
return FatalLevel, nil
|
||||
}
|
||||
|
||||
return Level(0), fmt.Errorf("unrecognized logging level: %q", text)
|
||||
|
@ -18,7 +18,6 @@ func TestLevel_String(t *testing.T) {
|
||||
"info": {giveLevel: logger.InfoLevel, wantString: "info"},
|
||||
"warn": {giveLevel: logger.WarnLevel, wantString: "warn"},
|
||||
"error": {giveLevel: logger.ErrorLevel, wantString: "error"},
|
||||
"fatal": {giveLevel: logger.FatalLevel, wantString: "fatal"},
|
||||
"<unknown>": {giveLevel: logger.Level(127), wantString: "level(127)"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
@ -43,8 +42,6 @@ func TestParseLevel(t *testing.T) {
|
||||
"info": {giveBytes: []byte("info"), wantLevel: logger.InfoLevel},
|
||||
"warn": {giveBytes: []byte("warn"), wantLevel: logger.WarnLevel},
|
||||
"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
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
@ -75,10 +72,9 @@ func TestLevels(t *testing.T) {
|
||||
logger.InfoLevel,
|
||||
logger.WarnLevel,
|
||||
logger.ErrorLevel,
|
||||
logger.FatalLevel,
|
||||
}, logger.Levels())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
@ -1,60 +1,126 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// New creates new "zap" logger with a small customization.
|
||||
func New(l Level, f Format) (*zap.Logger, error) {
|
||||
var config zap.Config
|
||||
// internalAttrKeyLoggerName is used to store the logger name in the logger context (attributes).
|
||||
const internalAttrKeyLoggerName = "named_logger"
|
||||
|
||||
switch f {
|
||||
case ConsoleFormat:
|
||||
config = zap.NewDevelopmentConfig()
|
||||
config.EncoderConfig.EncodeLevel = zapcore.LowercaseColorLevelEncoder
|
||||
config.EncoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("15:04:05")
|
||||
var (
|
||||
// consoleFormatAttrReplacer is a replacer for console format. It replaces some attributes with more
|
||||
// human-readable ones.
|
||||
consoleFormatAttrReplacer = func(_ []string, a slog.Attr) slog.Attr { //nolint:gochecknoglobals
|
||||
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:
|
||||
config = zap.NewProductionConfig() // json encoder is used by default
|
||||
|
||||
default:
|
||||
return nil, errors.New("unsupported logging format")
|
||||
return a
|
||||
}
|
||||
|
||||
// default configuration for all encoders
|
||||
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
|
||||
config.Development = false
|
||||
config.DisableStacktrace = true
|
||||
config.DisableCaller = true
|
||||
// jsonFormatAttrReplacer is a replacer for JSON format. It replaces some attributes with more
|
||||
// machine-readable ones.
|
||||
jsonFormatAttrReplacer = func(_ []string, a slog.Attr) slog.Attr { //nolint:gochecknoglobals
|
||||
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.Float64("ts", float64(ts.Unix())+float64(ts.Nanosecond())/1e9)
|
||||
}
|
||||
}
|
||||
|
||||
// enable additional features for debugging
|
||||
if l <= DebugLevel {
|
||||
config.Development = true
|
||||
config.DisableStacktrace = false
|
||||
config.DisableCaller = false
|
||||
return a
|
||||
}
|
||||
)
|
||||
|
||||
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:
|
||||
zapLvl = zap.DebugLevel
|
||||
options.Level = slog.LevelDebug
|
||||
case InfoLevel:
|
||||
zapLvl = zap.InfoLevel
|
||||
options.Level = slog.LevelInfo
|
||||
case WarnLevel:
|
||||
zapLvl = zap.WarnLevel
|
||||
options.Level = slog.LevelWarn
|
||||
case ErrorLevel:
|
||||
zapLvl = zap.ErrorLevel
|
||||
case FatalLevel:
|
||||
zapLvl = zap.FatalLevel
|
||||
options.Level = slog.LevelError
|
||||
default:
|
||||
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...) }
|
||||
|
21
internal/logger/logger_noop.go
Normal file
21
internal/logger/logger_noop.go
Normal 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{} }
|
@ -1,75 +1,235 @@
|
||||
package logger_test
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"bytes"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami-zh/go-capturer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"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) {
|
||||
_, 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")
|
||||
|
||||
_, 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")
|
||||
}
|
||||
|
||||
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
14
internal/logger/std.go
Normal 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
|
23
internal/logger/std_test.go
Normal file
23
internal/logger/std_test.go
Normal 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")
|
||||
}
|
Loading…
Reference in New Issue
Block a user