diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0b4316 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +vendor diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..d60fea5 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: go + +go: + - "1.11.x" + - "1.12.x" + +env: + - GO111MODULE=on + +install: + - make install + +script: + - make test + - make check-fmt + +after_success: + - make report-coveralls diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..da962fd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] - 2019-08-09 +### Changes +- Initial version \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d1c44be --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +.PHONY: test help fmt check-fmt install report-coveralls + +help: ## Show the help text + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[93m %s\n", $$1, $$2}' + +test: ## Run unit tests + @go test -coverprofile=coverage.out -covermode=atomic -race ./... + +check-fmt: ## Check file forma + @GOIMP=$$(for f in $$(find . -type f -name "*.go" ! -path "./.cache/*" ! -path "./vendor/*" ! -name "bindata.go") ; do \ + goimports -l $$f ; \ + done) && echo $$GOIMP && test -z "$$GOIMP" + +fmt: ## Format files + @goimports -w $$(find . -name "*.go" -not -path "./vendor/*") + +install: ## Installs dependencies + GOPATH=$$GOPATH && go get -u -v \ + golang.org/x/tools/cmd/goimports + +report-coveralls: ## Reports generated coverage profile to coveralls.io. Intended to be used only from travis + go get github.com/mattn/goveralls && goveralls -coverprofile=coverage.out -service=travis-ci diff --git a/README.md b/README.md new file mode 100644 index 0000000..039aa04 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# logrusiowriter +## `io.Writer` implementation using logrus + +[![Travis CI build status](https://travis-ci.com/cabify/logrusiowriter.svg?branch=master)](https://travis-ci.com/cabify/logrusiowriter) +[![Coverage Status](https://coveralls.io/repos/github/cabify/logrusiowriter/badge.svg)](https://coveralls.io/github/cabify/logrusiowriter) +[![GoDoc](https://godoc.org/github.com/cabify/logrusiowriter?status.svg)](https://godoc.org/github.com/cabify/logrusiowriter) + +# Motivation + +Many golang libraries use the golang's `log` package to print their logs. This means that if your application +uses logrus to print structured logging, those packages will print a format that is (probably) incompatible with yours, +and you may end losing logs in your logs collector because they can't be parsed properly. + +# Solution + +Print the logs written using `log.Printf` through `logrus`, by setting `log.SetOutput` to an `io.Writer` implementation +that uses `logrus` as output, i.e.: + +```go + log.SetOutput(logrusiowriter.New()) +``` + +See `example_*_test.go` files to find testable examples that serve as documentation. \ No newline at end of file diff --git a/configs.go b/configs.go new file mode 100644 index 0000000..78f3ead --- /dev/null +++ b/configs.go @@ -0,0 +1,67 @@ +package logrusiowriter + +import "github.com/sirupsen/logrus" + +// Config holds the configuration to be used with WithConfig() configurer +// This struct is useful to embed into configuration structs parsed with libraries like envconfig +type Config struct { + Level string `default:"info"` + Fields logrus.Fields `default:"logger:stdlib"` +} + +// WithLogger configures the logger with the one provided +func WithLogger(logger logrus.FieldLogger) Configurer { + return func(w *writer) { + w.logger = logger + } +} + +// WithLevel configures the level with the one provided +func WithLevel(lvl logrus.Level) Configurer { + return func(w *writer) { + w.level = lvl + } +} + +// WithFields configures the fields with the ones provided +func WithFields(fields logrus.Fields) Configurer { + return func(w *writer) { + w.fields = fields + } +} + +// WithConfig creates a configurer from the configuration provided as a struct +// If it's unable to parse the Level provided as a string, it will invoke the OnLevelParseError function and set the +// level returned by that function (a default value) +func WithConfig(cfg Config) Configurer { + return func(w *writer) { + lvl, err := logrus.ParseLevel(cfg.Level) + if err != nil { + lvl = OnLevelParseError(err) + } + w.level = lvl + w.fields = cfg.Fields + } +} + +// WithConfigInterface creates a configurer from the configuration provided as an interface +func WithConfigInterface(cfg interface { + Level() logrus.Level + Fields() logrus.Fields + Logger() logrus.FieldLogger +}) Configurer { + return func(w *writer) { + w.logger = cfg.Logger() + w.level = cfg.Level() + w.fields = cfg.Fields() + } +} + +// OnLevelParseError will be invoked if logrus is unable to parse the string level provided in the configuration +// The default behavior is to log it with logrus and return a default Info level, +// you can change this to log in some other system or to panic +// Changing this is not thread safe, so it might be a good idea to change it in a init() function +var OnLevelParseError = func(err error) logrus.Level { + logrus.Errorf("Can't parse level: %s", err) + return logrus.InfoLevel +} diff --git a/configs_test.go b/configs_test.go new file mode 100644 index 0000000..def9e61 --- /dev/null +++ b/configs_test.go @@ -0,0 +1,54 @@ +package logrusiowriter + +import ( + "testing" + + "github.com/sirupsen/logrus" +) + +func TestWithConfig(t *testing.T) { + t.Run("can't parse level, configures info level by default", func(t *testing.T) { + expectedLevel := logrus.InfoLevel + + cfg := Config{ + Level: "none", + Fields: logrus.Fields{}, + } + + w := New(WithConfig(cfg)) + + configuredLevel := w.(*writer).level + if configuredLevel != expectedLevel { + t.Errorf("Configured level should be %s, but it was %s", expectedLevel, configuredLevel) + } + }) + + t.Run("custom OnLevelParseError", func(t *testing.T) { + originalOnLevelParseError := OnLevelParseError + defer func() { OnLevelParseError = originalOnLevelParseError }() + + expectedLevel := logrus.WarnLevel + + cfg := Config{ + Level: "none", + Fields: logrus.Fields{}, + } + + var providedErr error + OnLevelParseError = func(err error) logrus.Level { + providedErr = err + return expectedLevel + } + + w := New(WithConfig(cfg)) + + configuredLevel := w.(*writer).level + if configuredLevel != expectedLevel { + t.Errorf("Configured level should be %s, but it was %s", expectedLevel, configuredLevel) + } + + if providedErr == nil { + t.Errorf("Error provided to OnLevelParseError should not be nil") + } + }) +} diff --git a/example_configs_test.go b/example_configs_test.go new file mode 100644 index 0000000..a8627e3 --- /dev/null +++ b/example_configs_test.go @@ -0,0 +1,93 @@ +package logrusiowriter_test + +import ( + "fmt" + + "github.com/cabify/logrusiowriter" + "github.com/sirupsen/logrus" +) + +func ExampleWithConfig() { + removeTimestampAndSetOutputToStdout(logrus.StandardLogger()) + + config := logrusiowriter.Config{ + Level: "warning", + Fields: map[string]interface{}{ + "config": "struct", + }, + } + + writer := logrusiowriter.New( + logrusiowriter.WithConfig(config), + ) + + _, _ = fmt.Fprint(writer, "Hello World!") + // Output: + // level=warning msg="Hello World!" config=struct +} + +func ExampleWithFields() { + removeTimestampAndSetOutputToStdout(logrus.StandardLogger()) + + writer := logrusiowriter.New( + logrusiowriter.WithFields(logrus.Fields{ + "config": "fields", + "other": 288, + }), + ) + + _, _ = fmt.Fprint(writer, "Hello World!") + // Output: + // level=info msg="Hello World!" config=fields other=288 +} + +func ExampleWithLevel() { + removeTimestampAndSetOutputToStdout(logrus.StandardLogger()) + + writer := logrusiowriter.New( + logrusiowriter.WithLevel(logrus.ErrorLevel), + ) + + _, _ = fmt.Fprint(writer, "Hello World!") + // Output: + // level=error msg="Hello World!" +} + +func ExampleWithLogger() { + logger := logrus.New() + removeTimestampAndSetOutputToStdout(logger) + logger.SetLevel(logrus.TraceLevel) + + writer := logrusiowriter.New( + logrusiowriter.WithLogger(logger), + ) + + _, _ = fmt.Fprint(writer, "Hello World!") + // Output: + // level=info msg="Hello World!" +} + +func ExampleWithConfigInterface() { + removeTimestampAndSetOutputToStdout(logrus.StandardLogger()) + + writer := logrusiowriter.New( + logrusiowriter.WithConfigInterface(configProvider{}), + ) + + _, _ = fmt.Fprint(writer, "Hello World!") + // Output: + // level=trace msg="Hello World!" config=interface +} + +type configProvider struct{} + +func (configProvider) Level() logrus.Level { return logrus.TraceLevel } + +func (configProvider) Fields() logrus.Fields { return logrus.Fields{"config": "interface"} } + +func (configProvider) Logger() logrus.FieldLogger { + logger := logrus.New() + removeTimestampAndSetOutputToStdout(logger) + logger.SetLevel(logrus.TraceLevel) + return logger +} diff --git a/example_writer_test.go b/example_writer_test.go new file mode 100644 index 0000000..8a86e78 --- /dev/null +++ b/example_writer_test.go @@ -0,0 +1,20 @@ +package logrusiowriter_test + +import ( + "log" + + "github.com/cabify/logrusiowriter" + "github.com/sirupsen/logrus" +) + +func ExampleNew() { + removeTimestampAndSetOutputToStdout(logrus.StandardLogger()) + + log.SetOutput(logrusiowriter.New()) + log.SetFlags(0) // no date on standard logger + + log.Printf("Standard log") + + // Output: + // level=info msg="Standard log\n" +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9eec04d --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/cabify/logrusiowriter + +go 1.12 + +require ( + github.com/sirupsen/logrus v1.4.2 + golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1978bae --- /dev/null +++ b/go.sum @@ -0,0 +1,15 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa h1:KIDDMLT1O0Nr7TSxp8xM5tJcdn8tgyAONntO829og1M= +golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/init_test.go b/init_test.go new file mode 100644 index 0000000..01449f3 --- /dev/null +++ b/init_test.go @@ -0,0 +1,21 @@ +package logrusiowriter_test + +import ( + "os" + + "github.com/sirupsen/logrus" +) + +var datelessFormatter = new(logrus.TextFormatter) + +func init() { + datelessFormatter.DisableTimestamp = true +} + +// removeTimestampAndSetOutputToStdout removes date from logrus logs, and redirects them to os.Stdout +// this can't be done in init() because os.Stdout changes after calling init() in examples: +// see: https://unexpected-go.com/os-stdout-changes-after-init-in-examples.html +func removeTimestampAndSetOutputToStdout(logger *logrus.Logger) { + logger.SetFormatter(datelessFormatter) + logger.SetOutput(os.Stdout) +} diff --git a/writer.go b/writer.go new file mode 100644 index 0000000..506a091 --- /dev/null +++ b/writer.go @@ -0,0 +1,39 @@ +package logrusiowriter + +import ( + "io" + + "github.com/sirupsen/logrus" +) + +// New creates a new io.Writer, and configures it with the provided configurers +// Provided Configurers overwrite previous values as they're applied +// If no Configurers provided, the writer will log with Info level and no fields using the logrus.StandardLogger +func New(cfg ...Configurer) io.Writer { + w := &writer{ + logger: logrus.StandardLogger(), + level: logrus.InfoLevel, + fields: make(map[string]interface{}), + } + for _, c := range cfg { + c(w) + } + + return w +} + +// Configurer configures the writer, use one of the With* functions to obtain one +type Configurer func(*writer) + +// writer implements io.Writer +type writer struct { + logger logrus.FieldLogger + level logrus.Level + fields map[string]interface{} +} + +// Write will write with the logger, level and fields set in the writer +func (w *writer) Write(bytes []byte) (int, error) { + w.logger.WithFields(w.fields).Log(w.level, string(bytes)) + return len(bytes), nil +}