diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..9172556 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,47 @@ +name: build-and-test +on: [push, pull_request] +jobs: + build: + strategy: + matrix: + version: [1.21] + os: [ubuntu-latest, windows-latest, macos-latest] + name: Build + runs-on: ${{ matrix.os }} + steps: + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.version }} + id: go + + - name: checkout + uses: actions/checkout@v3 + + - name: build + run: go build + + - name: test + run: make test + + - name: Update coverage report + uses: ncruces/go-coverage-report@main + with: + report: true + chart: true + amend: true + if: | + matrix.os == 'ubuntu-latest' && + github.event_name == 'push' + continue-on-error: true + + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v3 + with: + go-version: 1.21 + - uses: actions/checkout@v3 + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d83068 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +coverage.out diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fe4f21e --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +# Copyright © 2023 Thomas von Dein + +# This module is published under the terms of the BSD 3-Clause +# License. Please read the file LICENSE for details. + +# +# no need to modify anything below + +all: buildlocal + +buildlocal: + go build + +clean: + rm -rf $(tool) coverage.out testdata t/out + +test: clean + go test $(ARGS) + +singletest: + @echo "Call like this: make singletest TEST=TestPrepareColumns ARGS=-v" + go test -run $(TEST) $(ARGS) + +cover-report: + go test -cover -coverprofile=coverage.out + go tool cover -html=coverage.out + +goupdate: + go get -t -u=patch ./... + +lint: + golangci-lint run -p bugs -p unused diff --git a/README.md b/README.md index 12a30a2..e6c049f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,48 @@ -# YamlDumpHandler - a human readable yaml based slog.Handler +[![Go Report Card](https://goreportcard.com/badge/github.com/tlinden/yadu)](https://goreportcard.com/report/github.com/tlinden/yadu) +[![Actions](https://github.com/tlinden/yadu/actions/workflows/ci.yaml/badge.svg)](https://github.com/tlinden/yadu/actions) +[![Go Coverage](https://github.com/tlinden/yadu/wiki/coverage.svg)](https://raw.githack.com/wiki/tlinden/yadu/coverage.html) +![GitHub License](https://img.shields.io/github/license/tlinden/yadu) -Example output: +# yadu - a human readable yaml based slog.Handler + +## Introduction + +Package yadu provides a handler for the log/slog logging framework. + +It generates a mixture of text lines containing the timestamp and +log message and a YAML dump of the provided attibutes. + +## Log format + +The log format generated by yadu looks like this: + +``` +2023-04-02T10:50.09 EDT LEVEL Message text + foo: value + bar: 12345 +``` + +## Example + +```go +logger := slog.New(yadu.NewHandler(os.Stdout, nil)) + +type Enemy struct { + Alive bool + Health int + Name string + Body body `yaml:"-"` // not printed + Ammo []Ammo +} + +e := &Enemy{Alive: true, Health: 10, Name: "Bodo", Body: "body\nbody\n", + Ammo: []Ammo{{Forweapon: "Railgun", Range: 400, Impact: 100, Cost: 100000}}, +} + +slog.Info("info", "enemy", e, "spawn", 199) +``` + +Output: ```sh 2024-01-18T02:57.41 CET INFO: info @@ -14,12 +56,76 @@ Example output: cost: 100000 range: 400 spawn: 199 -2024-01-18T02:57.41 CET INFO: connecting - enemies: 100 - players: 2 - world: 600x800 -2024-01-18T02:57.41 CET DEBUG: debug text -2024-01-18T02:57.41 CET ERROR: error ``` -See `example.go` for usage. +See `example/example.go` for usage. + +## Installation + +Execute this to add the module to your project: +```sh +go get github.com/tlinden/yadu +``` + +## Configuration + +You can tweak the behavior of the handler as any other handler by using the Options struct: + +```go +func removeTime(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + return slog.Attr{} + } + return a +} + +opts := &yadu.Options{ + Level: slog.LevelDebug, + ReplaceAttr: removeTime, + } +``` + +Pass this object to `yadu.NewHandler()`. + +Because you can pass whole structs to the logger which will be dumped +using YAML, there's also a way to exclude fields from being printed: + +```go +type User struct { + Id int + User string + Pass string `yaml:"-"` +} +``` + +If you're already using YAML tags for other purposes you can also just +add a `LogValue()` method to your struct, which will be called by +slog. Refer to the slog documentation how to use it. + +You can also modify the time format using `yadu.Options.TimeFormat`. + +## Acknowledgements + +I wrote most of the code with the help of the [humane slog +handler][humane]. Also helpfull was the [guide to writing `slog` handlers][guide]. + ++ [humane slog handler][humane] ++ [A Guide to Writing `slog` Handlers][guide] ++ [`slog`: Golang's official structured logging package][sobyte] ++ [A Comprehensive Guide to Structured Logging in Go][betterstack] + +[humane]: https://github.com/telemachus/humane/tree/main +[guide]: https://github.com/golang/example/tree/master/slog-handler-guide +[mrkaran]: https://mrkaran.dev/posts/structured-logging-in-go-with-slog/ +[betterstack]: https://betterstack.com/community/guides/logging/logging-in-go/ + + +## LICENSE + +This module is published under the terms of the BSD 3-Clause +License. Please read the file LICENSE for details. + +## Author + +Thomas von Dein `` + diff --git a/go.mod b/go.mod index 0d2e4a2..7333b76 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/tlinden/yamldumphandler +module github.com/tlinden/yadu go 1.21 diff --git a/handler.go b/handler.go index 5d6b534..3562f7c 100644 --- a/handler.go +++ b/handler.go @@ -1,4 +1,4 @@ -package yamldumphandler +package yadu import ( "bytes" @@ -15,9 +15,13 @@ import ( "github.com/fatih/color" ) -const defaultTimeFormat = "2006-01-02T03:04.05 MST" +// We use RFC datestring by default +const DefaultTimeFormat = "2006-01-02T03:04.05 MST" -type YamlDumpHandler struct { +// Default log level is INFO: +const defaultLevel = slog.LevelInfo + +type Handler struct { writer io.Writer mu *sync.Mutex level slog.Leveler @@ -29,14 +33,25 @@ type YamlDumpHandler struct { indenter *regexp.Regexp } -type YamlDumpHandlerOptions struct { +// Options are options for the Yadu [log/slog.Handler]. +// +// Level sets the minimum log level. +// +// ReplaceAttr is a function you can define to customize how supplied +// attrs are being handled. It is empty by default, so nothing will be +// altered. +// +// Loglevel and message cannot be altered using ReplaceAttr. Timestamp +// can only be removed, see example. Keep in mind that everything will +// be passed to yaml.Marshal() in the end. +type Options struct { Level slog.Leveler ReplaceAttr func(groups []string, a slog.Attr) slog.Attr TimeFormat string AddSource bool } -func (h *YamlDumpHandler) Handle(ctx context.Context, r slog.Record) error { +func (h *Handler) Handle(ctx context.Context, r slog.Record) error { level := r.Level.String() + ":" switch r.Level { @@ -97,21 +112,21 @@ func (h *YamlDumpHandler) Handle(ctx context.Context, r slog.Record) error { defer h.mu.Unlock() _, err := h.writer.Write(buf.Bytes()) - // h.l.Println(timeStr, level, msg, color.WhiteString(tree)) - return err } -func (h *YamlDumpHandler) Postprocess(yamlstr []byte) string { +func (h *Handler) Postprocess(yamlstr []byte) string { return "\n " + strings.TrimSpace(h.indenter.ReplaceAllString(string(yamlstr), " ")) } -func NewYamlDumpHandler(out io.Writer, opts *YamlDumpHandlerOptions) *YamlDumpHandler { +// NewHandler returns a [log/slog.Handler] using the receiver's options. +// Default options are used if opts is nil. +func NewHandler(out io.Writer, opts *Options) *Handler { if opts == nil { - opts = &YamlDumpHandlerOptions{} + opts = &Options{} } - h := &YamlDumpHandler{ + h := &Handler{ writer: out, mu: &sync.Mutex{}, level: opts.Level, @@ -121,20 +136,24 @@ func NewYamlDumpHandler(out io.Writer, opts *YamlDumpHandlerOptions) *YamlDumpHa indenter: regexp.MustCompile(`(?m)^`), } + if opts.Level == nil { + h.level = defaultLevel + } + if h.timeFormat == "" { - h.timeFormat = defaultTimeFormat + h.timeFormat = DefaultTimeFormat } return h } // Enabled indicates whether the receiver logs at the given level. -func (h *YamlDumpHandler) Enabled(_ context.Context, l slog.Level) bool { +func (h *Handler) Enabled(_ context.Context, l slog.Level) bool { return l >= h.level.Level() } // attributes plus attrs. -func (h *YamlDumpHandler) WithAttrs(attrs []slog.Attr) slog.Handler { +func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { if len(attrs) == 0 { return h } @@ -157,7 +176,7 @@ func (h *YamlDumpHandler) WithAttrs(attrs []slog.Attr) slog.Handler { // WithGroup returns a new [log/slog.Handler] with name appended to the // receiver's groups. -func (h *YamlDumpHandler) WithGroup(name string) slog.Handler { +func (h *Handler) WithGroup(name string) slog.Handler { if name == "" { return h } @@ -166,8 +185,8 @@ func (h *YamlDumpHandler) WithGroup(name string) slog.Handler { return h2 } -func (h *YamlDumpHandler) clone() *YamlDumpHandler { - return &YamlDumpHandler{ +func (h *Handler) clone() *Handler { + return &Handler{ writer: h.writer, mu: h.mu, level: h.level, diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..2fdd763 --- /dev/null +++ b/handler_test.go @@ -0,0 +1,115 @@ +package yadu_test + +import ( + "bytes" + "log/slog" + "strings" + "testing" + "time" + + "github.com/tlinden/yadu" +) + +type body string + +type Ammo struct { + Forweapon string + Impact int + Cost int + Range float32 +} + +type Enemy struct { + Alive bool + Health int + Name string + Body body `yaml:"-"` + Ammo []Ammo +} + +type Tests struct { + name string + want string + negate bool + opts *yadu.Options +} + +const testTimeFormat = "03:04.05" + +var tests = []Tests{ + { + name: "has-railgun", + want: "forweapon: Railgun", + negate: false, + }, + { + name: "has-ammo", + want: "ammo:", + negate: false, + }, + { + name: "has-alive", + want: "alive: true", + negate: false, + }, + { + name: "has-no-body", + want: "body:", + negate: true, + }, + { + name: "has-time", + want: time.Now().Format(yadu.DefaultTimeFormat), + negate: false, + }, + { + name: "has-no-time", + want: time.Now().Format(yadu.DefaultTimeFormat), + opts: &yadu.Options{ + ReplaceAttr: removeTime, + }, + negate: true, + }, + { + name: "has-custom-time", + want: time.Now().Format(testTimeFormat), + opts: &yadu.Options{ + TimeFormat: testTimeFormat, + }, + negate: false, + }, + // FIXME: add WithGroup + WithAttr tests +} + +func GetEnemy() *Enemy { + return &Enemy{Alive: true, Health: 10, Name: "Bodo", Body: "body\nbody\n", + Ammo: []Ammo{{Forweapon: "Railgun", Range: 400, Impact: 100, Cost: 100000}}, + } + +} + +func removeTime(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + return slog.Attr{} + } + return a +} + +func Test(t *testing.T) { + t.Parallel() + + for _, tt := range tests { + var buf bytes.Buffer + + logger := slog.New(yadu.NewHandler(&buf, tt.opts)) + + logger.Info("attack", "enemy", GetEnemy()) + got := buf.String() + + if strings.Contains(got, tt.want) == tt.negate { + t.Errorf("test %s failed.\n want:\n%s\n got: %s\n", tt.name, tt.want, got) + } + + buf.Reset() + } +}