Skip to content

Commit

Permalink
feat(logger): add slog support (#407)
Browse files Browse the repository at this point in the history
* feat(logger): add slog support

* fix(logger): resolve codacy

* doc(logger): update document for logger

* fix(logger): write log to file

* doc: fix log doc

* refactor(logger): upgrade slog version
  • Loading branch information
woorui committed Dec 16, 2022
1 parent e2b4da3 commit 1c41ec0
Show file tree
Hide file tree
Showing 8 changed files with 395 additions and 97 deletions.
42 changes: 42 additions & 0 deletions core/ylog/example_test.go
@@ -0,0 +1,42 @@
package ylog_test

import (
"io"
"net"

"github.com/yomorun/yomo/core/ylog"
)

func Example() {
// text format logger
logger := ylog.NewFromConfig(ylog.Config{
Level: "warn",
Format: "text",
ErrorOutput: "stdout",
DisableTime: true,
})

ylog.SetDefault(logger.With("hello", "yomo").WithGroup("syslog"))

ylog.Debug("debug", "aaa", "bbb")
ylog.Info("info", "ccc", "ddd")
ylog.Warn("warn", "eee", "fff")
ylog.Error("error", io.EOF, "eee", "fff")

// json format logger
sysLogger := ylog.NewFromConfig(ylog.Config{
Level: "error",
Format: "json",
ErrorOutput: "stdout",
DisableTime: true,
})

sysLogger = sysLogger.WithGroup("syslog")

sysLogger.Error("sys error", net.ErrClosed, "ggg", "hhh")

// Output:
// level=WARN msg=warn hello=yomo syslog.eee=fff
// level=ERROR msg=error hello=yomo syslog.eee=fff syslog.err=EOF
// {"level":"ERROR","msg":"sys error","syslog":{"ggg":"hhh","err":"use of closed network connection"}}
}
134 changes: 134 additions & 0 deletions core/ylog/logger.go
@@ -0,0 +1,134 @@
// Package ylog provides a slog.Logger instance for logging.
// ylog also provides a default slog.Logger, the default logger is build from environment.
//
// ylog allows to call log api directly, like:
//
// ylog.Debug("test", "name", "yomo")
// ylog.Info("test", "name", "yomo")
// ylog.Warn("test", "name", "yomo")
// ylog.Error("test", "name", "yomo")
package ylog

import (
"io"
"log"
"os"
"strconv"
"strings"

"github.com/caarlos0/env/v6"
"golang.org/x/exp/slog"
)

var defaultLogger = Default()

// SetDefault set global logger.
func SetDefault(logger *slog.Logger) { defaultLogger = logger }

// Debug logs a message at debug level.
func Debug(msg string, keyvals ...interface{}) {
defaultLogger.Debug(msg, keyvals...)
}

// Info logs a message at info level.
func Info(msg string, keyvals ...interface{}) {
defaultLogger.Info(msg, keyvals...)
}

// Warn logs a message at warn level.
func Warn(msg string, keyvals ...interface{}) {
defaultLogger.Warn(msg, keyvals...)
}

// Error logs a message at error level.
func Error(msg string, err error, keyvals ...interface{}) {
defaultLogger.Error(msg, err, keyvals...)
}

// Config is the config of slog, the config is from environment.
type Config struct {
// Verbose indicates if logger log code line, use false for production.
Verbose bool `env:"YOMO_LOG_VERBOSE" envDefault:"false"`

// the log level, It can be one of `debug`, `info`, `warn`, `error`
Level string `env:"YOMO_LOG_LEVEL" envDefault:"info"`

// log output file path, It's stdout if not set.
Output string `env:"YOMO_LOG_OUTPUT"`

// error log output file path, It's stderr if not set.
ErrorOutput string `env:"YOMO_LOG_ERROR_OUTPUT"`

// log format, support text and json.
Format string `env:"YOMO_LOG_FORMAT" envDefault:"text"`

// DisableTime disable time key, It's a pravited field, Just for testing.
DisableTime bool
}

// DebugFrameSize is use for log dataFrame,
// It means that only logs the first DebugFrameSize bytes if the data is large than DebugFrameSize bytes.
//
// DebugFrameSize is default to 16,
// if env `YOMO_DEBUG_FRAME_SIZE` is setted and It's an int number, Set the env value to DebugFrameSize.
var DebugFrameSize = 16

func init() {
if e := os.Getenv("YOMO_DEBUG_FRAME_SIZE"); e != "" {
if val, err := strconv.Atoi(e); err == nil {
DebugFrameSize = val
}
}
}

// Default returns a slog.Logger according to enviroment.
func Default() *slog.Logger {
var conf Config
if err := env.Parse(&conf); err != nil {
log.Fatalf("%+v\n", err)
}
return NewFromConfig(conf)
}

// NewFromConfig returns a slog.Logger according to conf.
func NewFromConfig(conf Config) *slog.Logger {
return slog.New(NewHandlerFromConfig(conf))
}

func parseToWriter(path string, defaultWriter io.Writer) (io.Writer, error) {
switch strings.ToLower(path) {
case "stdout":
return os.Stdout, nil
case "stderr":
return os.Stderr, nil
default:
if path != "" {
return os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
}
return defaultWriter, nil
}
}

func mustParseToWriter(path string, defaultWriter io.Writer) io.Writer {
w, err := parseToWriter(path, defaultWriter)
if err != nil {
panic(err)
}
return w
}

func parseToSlogLevel(stringLevel string) slog.Level {
var level = slog.LevelDebug
switch strings.ToLower(stringLevel) {
case "debug":
level = slog.LevelDebug
case "info":
level = slog.LevelInfo
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}

return level
}
50 changes: 50 additions & 0 deletions core/ylog/logger_test.go
@@ -0,0 +1,50 @@
package ylog

import (
"io"
"os"
"path"
"testing"

"github.com/stretchr/testify/assert"
"golang.org/x/exp/slog"
)

func TestLogger(t *testing.T) {
testdir := t.TempDir()

var (
output = path.Join(testdir, "output.log")
errOutput = path.Join(testdir, "err_output.log")
)

conf := Config{
Level: "info",
Output: output,
ErrorOutput: errOutput,
DisableTime: true,
}

logger := slog.New(NewHandlerFromConfig(conf))

logger.Debug("some debug", "hello", "yomo")
logger.Info("some info", "hello", "yomo")
logger.Warn("some waring", "hello", "yomo")

logger.Error("error", io.EOF, "hello", "yomo")

log, err := os.ReadFile(output)

assert.NoError(t, err)
assert.FileExists(t, output)
assert.Equal(t, "level=INFO msg=\"some info\" hello=yomo\nlevel=WARN msg=\"some waring\" hello=yomo\n", string(log))

errlog, err := os.ReadFile(errOutput)

assert.NoError(t, err)
assert.FileExists(t, errOutput)
assert.Equal(t, "level=ERROR msg=error hello=yomo err=EOF\n", string(errlog))

os.Remove(output)
os.Remove(errOutput)
}
136 changes: 136 additions & 0 deletions core/ylog/slog_handler.go
@@ -0,0 +1,136 @@
// Package provides handler that supports spliting log stream to common log stream and error log stream.
package ylog

import (
"bytes"
"io"
"os"
"strings"
"sync"

"golang.org/x/exp/slog"
)

// handler supports spliting log stream to common log stream and error log stream.
type handler struct {
slog.Handler

buf *asyncBuffer

writer io.Writer
errWriter io.Writer
}

type asyncBuffer struct {
sync.Mutex
underlying *bytes.Buffer
}

func newAsyncBuffer(cap int) *asyncBuffer {
return &asyncBuffer{
underlying: bytes.NewBuffer(make([]byte, cap)),
}
}

func (buf *asyncBuffer) Write(b []byte) (int, error) {
buf.Lock()
defer buf.Unlock()

return buf.underlying.Write(b)
}

func (buf *asyncBuffer) Read(p []byte) (int, error) {
buf.Lock()
defer buf.Unlock()

return buf.underlying.Read(p)
}

func (buf *asyncBuffer) Reset() {
buf.Lock()
defer buf.Unlock()

buf.underlying.Reset()
}

// NewHandlerFromConfig creates a slog.Handler from conf
func NewHandlerFromConfig(conf Config) slog.Handler {
buf := newAsyncBuffer(0)

h := bufferedSlogHandler(
buf,
conf.Format,
parseToSlogLevel(conf.Level),
conf.Verbose,
conf.DisableTime,
)

return &handler{
Handler: h,
buf: buf,
writer: mustParseToWriter(conf.Output, os.Stdout),
errWriter: mustParseToWriter(conf.ErrorOutput, os.Stderr),
}
}

func (h *handler) Enabled(level slog.Level) bool {
return h.Handler.Enabled(level)
}

func (h *handler) Handle(r slog.Record) error {
err := h.Handler.Handle(r)
if err != nil {
return err
}

if r.Level == slog.LevelError {
_, err = io.Copy(h.errWriter, h.buf)
} else {
_, err = io.Copy(h.writer, h.buf)
}
h.buf.Reset()

return err
}

func (h *handler) WithAttrs(as []slog.Attr) slog.Handler {
return &handler{
buf: h.buf,
writer: h.writer,
errWriter: h.errWriter,
Handler: h.Handler.WithAttrs(as),
}
}

func (h *handler) WithGroup(name string) slog.Handler {
return &handler{
buf: h.buf,
writer: h.writer,
errWriter: h.errWriter,
Handler: h.Handler.WithGroup(name),
}
}

func bufferedSlogHandler(buf io.Writer, format string, level slog.Level, verbose, disableTime bool) slog.Handler {
opt := slog.HandlerOptions{
AddSource: verbose,
Level: level,
}
if disableTime {
opt.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
if a.Key == "time" {
return slog.Attr{}
}
return a
}
}

var h slog.Handler
if strings.ToLower(format) == "json" {
h = opt.NewJSONHandler(buf)
} else {
h = opt.NewTextHandler(buf)
}

return h
}

0 comments on commit 1c41ec0

Please sign in to comment.