diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6b4085e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/Chicago" + commit-message: + prefix: "chore" + ignore: + # GitHub always delivers the latest versions for each major + # release tag, so handle updates manually + - dependency-name: "actions/*" + + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/Chicago" + commit-message: + prefix: "chore" + groups: + otel: + patterns: + - "go.opentelemetry.io/*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fef6141..0b453e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,37 +1,38 @@ name: ci -on: [push, pull_request] -jobs: - fmt: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - name: make fmt - uses: ./ci/image - with: - args: make fmt +on: + push: + branches: + - main - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - name: make lint - uses: ./ci/image - with: - args: make lint + pull_request: + branches: + - main + + workflow_dispatch: - test: +jobs: + go: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - name: make test - uses: ./ci/image + - uses: actions/checkout@v3 + - name: Cache npm + uses: actions/cache@v3 + with: + path: ~/.npm + key: "npm-cache" + - uses: actions/setup-go@v4 with: - args: make test + go-version: "1.20" + cache-dependency-path: go.sum + - name: "make" + run: | + git config --global --add safe.directory /github/workspace + make -O -j fmt lint test env: COVERALLS_TOKEN: ${{ secrets.github_token }} - name: Upload coverage.html - uses: actions/upload-artifact@master + uses: actions/upload-artifact@v4 with: name: coverage path: ci/out/coverage.html diff --git a/README.md b/README.md index 50550b8..d293a37 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ go get cdr.dev/slog - First class [testing.TB](https://godoc.org/cdr.dev/slog/sloggers/slogtest) support - Package [slogtest/assert](https://godoc.org/cdr.dev/slog/sloggers/slogtest/assert) provides test assertion helpers - Beautiful human readable logging output - - Prints multiline fields and errors nicely + - Prints multiline fields and errors nicely - Machine readable JSON output - [GCP Stackdriver](https://godoc.org/cdr.dev/slog/sloggers/slogstackdriver) support - [Stdlib](https://godoc.org/cdr.dev/slog#Stdlib) log adapter @@ -35,9 +35,9 @@ go get cdr.dev/slog Many more examples available at [godoc](https://godoc.org/cdr.dev/slog#pkg-examples). ```go -ctx := sloghuman.Make(ctx, os.Stdout) +log := slog.Make(sloghuman.Sink(os.Stdout)) -slog.Info(ctx, "my message here", +log.Info(context.Background(), "my message here", slog.F("field_name", "something or the other"), slog.F("some_map", slog.M( slog.F("nested_fields", time.Date(2000, time.February, 5, 4, 4, 4, 0, time.UTC)), @@ -59,61 +59,66 @@ slog.Info(ctx, "my message here", At [Coder](https://github.com/cdr) we’ve used Uber’s [zap](https://github.com/uber-go/zap) for several years. It is a fantastic library for performance. Thanks Uber! -However we felt the API and developer experience could be improved. +However we felt the API and developer experience could be improved. Here is a list of reasons how we improved on zap with slog. 1. `slog` has a minimal API surface - - Compare [slog](https://godoc.org/cdr.dev/slog) to [zap](https://godoc.org/go.uber.org/zap) and - [zapcore](https://godoc.org/go.uber.org/zap/zapcore). - - The sprawling API makes zap hard to understand, use and extend. + + - Compare [slog](https://godoc.org/cdr.dev/slog) to [zap](https://godoc.org/go.uber.org/zap) and + [zapcore](https://godoc.org/go.uber.org/zap/zapcore). + - The sprawling API makes zap hard to understand, use and extend. 1. `slog` has a concise semi typed API - - We found zap's fully typed API cumbersome. It does offer a - [sugared API](https://godoc.org/go.uber.org/zap#hdr-Choosing_a_Logger) - but it's too easy to pass an invalid fields list since there is no static type checking. - Furthermore, it's harder to read as there is no syntax grouping for each key value pair. - - We wanted an API that only accepted the equivalent of [zap.Any](https://godoc.org/go.uber.org/zap#Any) - for every field. This is [slog.F](https://godoc.org/cdr.dev/slog#F). + + - We found zap's fully typed API cumbersome. It does offer a + [sugared API](https://godoc.org/go.uber.org/zap#hdr-Choosing_a_Logger) + but it's too easy to pass an invalid fields list since there is no static type checking. + Furthermore, it's harder to read as there is no syntax grouping for each key value pair. + - We wanted an API that only accepted the equivalent of [zap.Any](https://godoc.org/go.uber.org/zap#Any) + for every field. This is [slog.F](https://godoc.org/cdr.dev/slog#F). 1. [`sloghuman`](https://godoc.org/cdr.dev/slog/sloggers/sloghuman) uses a very human readable format - - It colors distinct parts of each line to make it easier to scan logs. Even the JSON that represents - the fields in each log is syntax highlighted so that is very easy to scan. See the screenshot above. - - zap lacks appropriate colors for different levels and fields - - slog automatically prints one multiline field after the log to make errors and such much easier to read. - - zap logs multiline fields and errors stack traces as JSON strings which made them unreadable in a terminal. - - When logging to JSON, slog automatically converts a [`golang.org/x/xerrors`](https://golang.org/x/xerrors) chain - into an array with fields for the location and wrapping messages. + + - It colors distinct parts of each line to make it easier to scan logs. Even the JSON that represents + the fields in each log is syntax highlighted so that is very easy to scan. See the screenshot above. + - zap lacks appropriate colors for different levels and fields. + - slog automatically prints one multiline field after the log to make errors and such much easier to read. + - zap logs multiline fields and errors stack traces as JSON strings which made them unreadable in a terminal. + - When logging to JSON, slog automatically converts a [`golang.org/x/xerrors`](https://golang.org/x/xerrors) chain + into an array with fields for the location and wrapping messages. 1. Full [context.Context](https://blog.golang.org/context) support - - `slog` lets you set fields in a `context.Context` such that any log with the context prints those fields. - - `slog` stores the actual logger in the `context.Context`, following the example of - [the Go trace library](https://golang.org/pkg/runtime/trace/). Our logger doesn't bloat type and function signatures. - - We wanted to be able to pull up all relevant logs for a given trace, user or request. With zap, we were plugging - these fields in for every relevant log or passing around a logger with the fields set. This became very verbose. + + - `slog` lets you set fields in a `context.Context` such that any log with the context prints those fields. + - We wanted to be able to pull up all relevant logs for a given trace, user or request. With zap, we were plugging + these fields in for every relevant log or passing around a logger with the fields set. This became very verbose. 1. Simple and easy to extend - - A new backend only has to implement the simple Sink interface. - - The Logger type provides a nice API around Sink but also implements - Sink to allow for composition. - - zap is hard and confusing to extend. There are too many structures and configuration options. + + - A new backend only has to implement the simple Sink interface. + - The Logger type provides a nice API around Sink but also implements + Sink to allow for composition. + - zap is hard and confusing to extend. There are too many structures and configuration options. 1. Structured logging of Go structures with `json.Marshal` - - Entire encoding process is documented on [godoc](https://godoc.org/cdr.dev/slog#Map.MarshalJSON). - - With zap, We found ourselves often implementing zap's - [ObjectMarshaler](https://godoc.org/go.uber.org/zap/zapcore#ObjectMarshaler) to log Go structures. This was - verbose and most of the time we ended up only implementing `fmt.Stringer` and using `zap.Stringer` instead. + + - Entire encoding process is documented on [godoc](https://godoc.org/cdr.dev/slog#Map.MarshalJSON). + - With zap, We found ourselves often implementing zap's + [ObjectMarshaler](https://godoc.org/go.uber.org/zap/zapcore#ObjectMarshaler) to log Go structures. This was + verbose and most of the time we ended up only implementing `fmt.Stringer` and using `zap.Stringer` instead. 1. slog takes inspiration from Go's stdlib and implements [`slog.Helper`](https://godoc.org/cdr.dev/slog#Helper) which works just like [`t.Helper`](https://golang.org/pkg/testing/#T.Helper) - - It marks the calling function as a helper and skips it when reporting location info. - - We had many helper functions for logging but we wanted the line reported to be of the parent function. - zap has an [API](https://godoc.org/go.uber.org/zap#AddCallerSkip) for this but it's verbose and requires - passing the logger around explicitly. + + - It marks the calling function as a helper and skips it when reporting location info. + - We had many helper functions for logging but we wanted the line reported to be of the parent function. + zap has an [API](https://godoc.org/go.uber.org/zap#AddCallerSkip) for this but it's verbose and requires + passing the logger around explicitly. 1. Tight integration with stdlib's [`testing`](https://golang.org/pkg/testing) package - - You can configure [`slogtest`](https://godoc.org/cdr.dev/slog/sloggers/slogtest) to exit on any ERROR logs - and it has a global stateless API that takes a `testing.TB` so you do not need to create a logger first. - - Test assertion helpers are provided in [slogtest/assert](https://godoc.org/cdr.dev/slog/sloggers/slogtest/assert). - - zap has [zaptest](https://godoc.org/go.uber.org/zap/zaptest) but the API surface is large and doesn't - integrate well. It does not support any of the features described above. + - You can configure [`slogtest`](https://godoc.org/cdr.dev/slog/sloggers/slogtest) to exit on any ERROR logs + and it has a global stateless API that takes a `testing.TB` so you do not need to create a logger first. + - Test assertion helpers are provided in [slogtest/assert](https://godoc.org/cdr.dev/slog/sloggers/slogtest/assert). + - zap has [zaptest](https://godoc.org/go.uber.org/zap/zaptest) but the API surface is large and doesn't + integrate well. It does not support any of the features described above. diff --git a/ci/fmt.mk b/ci/fmt.mk index 026cc36..5519ed6 100644 --- a/ci/fmt.mk +++ b/ci/fmt.mk @@ -1,4 +1,4 @@ -fmt: modtidy gofmt goimports prettier +fmt: modtidy gofmt prettier ifdef CI if [[ $$(git ls-files --other --modified --exclude-standard) != "" ]]; then echo "Files need generation or are formatted incorrectly:" @@ -13,13 +13,11 @@ modtidy: gen go mod tidy gofmt: gen - gofmt -w -s . - -goimports: gen - goimports -w "-local=$$(go list -m)" . + # gofumpt v0.7.0 requires Go 1.22 or later. + go run mvdan.cc/gofumpt@v0.6.0 -w . prettier: - prettier --write --print-width=120 --no-semi --trailing-comma=all --loglevel=warn $$(git ls-files "*.yml") + npx prettier --write --print-width=120 --no-semi --trailing-comma=all --loglevel=warn $$(git ls-files "*.yml") gen: go generate ./... diff --git a/ci/image/Dockerfile b/ci/image/Dockerfile deleted file mode 100644 index 8a48a4a..0000000 --- a/ci/image/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM golang:1 - -RUN apt-get update -RUN apt-get install -y npm - -ENV GOFLAGS="-mod=readonly" -ENV PAGER=cat -ENV CI=true -ENV MAKEFLAGS="--jobs=8 --output-sync=target" - -RUN npm install -g prettier -RUN go get golang.org/x/tools/cmd/goimports -RUN go get golang.org/x/lint/golint -RUN go get github.com/mattn/goveralls diff --git a/ci/lint.mk b/ci/lint.mk index fbf42d2..e190817 100644 --- a/ci/lint.mk +++ b/ci/lint.mk @@ -4,4 +4,5 @@ govet: go vet ./... golint: - golint -set_exit_status ./... + # golangci-lint newer than v1.55.2 is not compatible with Go 1.20 when using go run. + go run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.2 run . diff --git a/ci/test.mk b/ci/test.mk index e503f6d..35bfd05 100644 --- a/ci/test.mk +++ b/ci/test.mk @@ -15,8 +15,8 @@ coveralls: gotest export CI_PULL_REQUEST="$$(jq .number "$$GITHUB_EVENT_PATH")" BUILD_NUMBER="$$BUILD_NUMBER-PR-$$CI_PULL_REQUEST" fi - goveralls -coverprofile=ci/out/coverage.prof -service=github + go run github.com/mattn/goveralls@latest -coverprofile=ci/out/coverage.prof -service=github gotest: go test -covermode=count -coverprofile=ci/out/coverage.prof -coverpkg=./... $${GOTESTFLAGS-} ./... - sed -i '/internal\/assert/d' ci/out/coverage.prof + sed -i.bak '/internal\/assert/d' ci/out/coverage.prof diff --git a/context.go b/context.go deleted file mode 100644 index d44a049..0000000 --- a/context.go +++ /dev/null @@ -1,33 +0,0 @@ -package slog - -import "context" - -type loggerCtxKey = struct{} - -type sinkContext struct { - context.Context - Sink -} - -// SinkContext is a context that implements Sink. -// It may be returned by log creators to allow for composition. -type SinkContext interface { - Sink - context.Context -} - -func contextWithLogger(ctx context.Context, l logger) SinkContext { - ctx = context.WithValue(ctx, loggerCtxKey{}, l) - return &sinkContext{ - Context: ctx, - Sink: l, - } -} - -func loggerFromContext(ctx context.Context) (logger, bool) { - v := ctx.Value(loggerCtxKey{}) - if v == nil { - return logger{}, false - } - return v.(logger), true -} diff --git a/example_helper_test.go b/example_helper_test.go index 853341d..549ac3d 100644 --- a/example_helper_test.go +++ b/example_helper_test.go @@ -12,12 +12,12 @@ import ( func httpLogHelper(ctx context.Context, status int) { slog.Helper() - slog.Info(ctx, "sending HTTP response", + l.Info(ctx, "sending HTTP response", slog.F("status", status), ) } -var l = sloghuman.Make(context.Background(), os.Stdout) +var l = slog.Make(sloghuman.Sink(os.Stdout)) func ExampleHelper() { ctx := context.Background() diff --git a/example_marshaller_test.go b/example_marshaller_test.go index 514ab62..0a03d74 100644 --- a/example_marshaller_test.go +++ b/example_marshaller_test.go @@ -21,9 +21,9 @@ func (s myStruct) MarshalJSON() ([]byte, error) { } func Example_marshaller() { - ctx := sloghuman.Make(context.Background(), os.Stdout) + l := slog.Make(sloghuman.Sink(os.Stdout)) - slog.Info(ctx, "wow", + l.Info(context.Background(), "wow", slog.F("myStruct", myStruct{ foo: 1, bar: 2, diff --git a/example_test.go b/example_test.go index 3627532..95131ee 100644 --- a/example_test.go +++ b/example_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "go.opencensus.io/trace" "golang.org/x/xerrors" "cdr.dev/slog" @@ -18,14 +17,14 @@ import ( ) func Example() { - ctx := sloghuman.Make(context.Background(), os.Stdout) + log := slog.Make(sloghuman.Sink(os.Stdout)) - slog.Info(ctx, "my message here", + log.Info(context.Background(), "my message here", slog.F("field_name", "something or the other"), slog.F("some_map", slog.M( slog.F("nested_fields", time.Date(2000, time.February, 5, 4, 4, 4, 0, time.UTC)), )), - slog.Err( + slog.Error( xerrors.Errorf("wrap1: %w", xerrors.Errorf("wrap2: %w", io.EOF, @@ -45,7 +44,7 @@ func Example() { } func Example_struct() { - ctx := sloghuman.Make(context.Background(), os.Stdout) + l := slog.Make(sloghuman.Sink(os.Stdout)) type hello struct { Meow int `json:"meow"` @@ -53,7 +52,7 @@ func Example_struct() { M time.Time `json:"m"` } - slog.Info(ctx, "check out my structure", + l.Info(context.Background(), "check out my structure", slog.F("hello", hello{ Meow: 1, Bar: "barbar", @@ -72,74 +71,63 @@ func Example_testing() { slog.F("field_name", "something or the other"), ) - // t.go:55: 2019-12-05 21:20:31.218 [INFO] my message here {"field_name": "something or the other"} -} - -func Example_tracing() { - var ctx context.Context - ctx = sloghuman.Make(context.Background(), os.Stdout) - - ctx, _ = trace.StartSpan(ctx, "spanName") - - slog.Info(ctx, "my msg", slog.F("hello", "hi")) - - // 2019-12-09 21:59:48.110 [INFO] my msg {"trace": "f143d018d00de835688453d8dc55c9fd", "span": "f214167bf550afc3", "hello": "hi"} + // t.go:55: 2019-12-05 21:20:31.218 [INFO] my message here field_name="something or the other" } func Example_multiple() { - ctx := sloghuman.Make(context.Background(), os.Stdout) + l := slog.Make(sloghuman.Sink(os.Stdout)) - f, err := os.OpenFile("stackdriver", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) + f, err := os.OpenFile("stackdriver", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644) if err != nil { - slog.Fatal(ctx, "failed to open stackdriver log file", slog.Err(err)) + l.Fatal(context.Background(), "failed to open stackdriver log file", slog.Error(err)) } - ctx = slog.Make(l, slogstackdriver.Make(ctx, f)) + l = l.AppendSinks(slogstackdriver.Sink(f)) - slog.Info(ctx, "log to stdout and stackdriver") + l.Info(context.Background(), "log to stdout and stackdriver") - // 2019-12-07 20:59:55.790 [INFO] log to stdout and stackdriver + // 2019-12-07 20:59:55.790 [INFO] log to stdout and stackdriver } func ExampleWith() { ctx := slog.With(context.Background(), slog.F("field", 1)) - ctx = sloghuman.Make(ctx, os.Stdout) - slog.Info(ctx, "msg") + l := slog.Make(sloghuman.Sink(os.Stdout)) + l.Info(ctx, "msg") - // 2019-12-07 20:54:23.986 [INFO] msg {"field": 1} + // 2019-12-07 20:54:23.986 [INFO] msg field=1} } func ExampleStdlib() { ctx := slog.With(context.Background(), slog.F("field", 1)) - l := slog.Stdlib(sloghuman.Make(ctx, os.Stdout)) + l := slog.Stdlib(ctx, slog.Make(sloghuman.Sink(os.Stdout)), slog.LevelInfo) l.Print("msg") - // 2019-12-07 20:54:23.986 [INFO] (stdlib) msg {"field": 1} + // 2019-12-07 20:54:23.986 [INFO] (stdlib) msg field=1 } -func ExampleNamed() { +func ExampleLogger_Named() { ctx := context.Background() - ctx = sloghuman.Make(ctx, os.Stdout) - ctx = slog.Named(ctx, "http") - slog.Info(ctx, "received request", slog.F("remote address", net.IPv4(127, 0, 0, 1))) + l := slog.Make(sloghuman.Sink(os.Stdout)) + l = l.Named("http") + l.Info(ctx, "received request", slog.F("remote address", net.IPv4(127, 0, 0, 1))) - // 2019-12-07 21:20:56.974 [INFO] (http) received request {"remote address": "127.0.0.1"} + // 2019-12-07 21:20:56.974 [INFO] (http) received request remote_address=127.0.0.1} } -func ExampleLeveled() { +func ExampleLogger_Leveled() { ctx := context.Background() - ctx = sloghuman.Make(ctx, os.Stdout) - slog.Debug(ctx, "testing1") - slog.Info(ctx, "received request") + l := slog.Make(sloghuman.Sink(os.Stdout)) + l.Debug(ctx, "testing1") + l.Info(ctx, "received request") - ctx = slog.Leveled(ctx, slog.LevelDebug) + l = l.Leveled(slog.LevelDebug) - slog.Debug(ctx, "testing2") + l.Debug(ctx, "testing2") - // 2019-12-07 21:26:20.945 [INFO] received request - // 2019-12-07 21:26:20.945 [DEBUG] testing2 + // 2019-12-07 21:26:20.945 [INFO] received request + // 2019-12-07 21:26:20.945 [DEBU] testing2 } diff --git a/export_test.go b/export_test.go index a682e20..1266811 100644 --- a/export_test.go +++ b/export_test.go @@ -1,12 +1,5 @@ package slog -import "context" - -func SetExit(ctx context.Context, fn func(int)) context.Context { - l, ok := loggerFromContext(ctx) - if !ok { - return ctx - } +func (l *Logger) SetExit(fn func(int)) { l.exit = fn - return contextWithLogger(ctx, l) } diff --git a/go.mod b/go.mod index bfca1fb..52efddb 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,40 @@ -module cdr.dev/slog/v2 +module cdr.dev/slog -go 1.13 +go 1.20 require ( - cloud.google.com/go v0.49.0 - github.com/alecthomas/chroma v0.7.0 - github.com/dlclark/regexp2 v1.2.0 // indirect - github.com/fatih/color v1.7.0 - github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 // indirect - github.com/google/go-cmp v0.3.2-0.20191216170541-340f1ebe299e - github.com/mattn/go-colorable v0.1.4 // indirect - github.com/mattn/go-isatty v0.0.11 // indirect - go.opencensus.io v0.22.2 - golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 - golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 // indirect - golang.org/x/sys v0.0.0-20191210023423-ac6580df4449 // indirect - golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 - google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1 - google.golang.org/grpc v1.25.1 // indirect + cloud.google.com/go/compute/metadata v0.2.3 + cloud.google.com/go/logging v1.8.1 + github.com/charmbracelet/lipgloss v0.7.1 + github.com/google/go-cmp v0.5.9 + github.com/muesli/termenv v0.15.2 + go.opentelemetry.io/otel/sdk v1.16.0 + go.opentelemetry.io/otel/trace v1.16.0 + go.uber.org/goleak v1.2.1 + golang.org/x/term v0.11.0 + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 + google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e +) + +require ( + cloud.google.com/go/compute v1.23.0 // indirect + cloud.google.com/go/longrunning v0.5.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/go-logr/logr v1.2.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + go.opentelemetry.io/otel v1.16.0 // indirect + go.opentelemetry.io/otel/metric v1.16.0 // indirect + golang.org/x/net v0.12.0 // indirect + golang.org/x/sys v0.11.0 // indirect + golang.org/x/text v0.11.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 // indirect + google.golang.org/grpc v1.57.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect ) diff --git a/go.sum b/go.sum index 6bd2924..9eb2fc3 100644 --- a/go.sum +++ b/go.sum @@ -1,260 +1,76 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.49.0 h1:CH+lkubJzcPYB1Ggupcq0+k8Ni2ILdG2lYjDIgavDBQ= -cloud.google.com/go v0.49.0/go.mod h1:hGvAdzcWNbyuxS3nWhD7H2cIJxjRRTRLQVB0bdputVY= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= -github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0= -github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= -github.com/alecthomas/chroma v0.7.0 h1:z+0HgTUmkpRDRz0SRSdMaqOLfJV4F+N1FPDZUZIDUzw= -github.com/alecthomas/chroma v0.7.0/go.mod h1:1U/PfCsTALWWYHDnsIQkxEBM0+6LLe0v8+RSVMOwxeY= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= -github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= -github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= -github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA= -github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= -github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY= +cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/logging v1.8.1 h1:26skQWPeYhvIasWKm48+Eq7oUqdcdbwsCVwz5Ys0FvU= +cloud.google.com/go/logging v1.8.1/go.mod h1:TJjR+SimHwuC8MZ9cjByQulAMgni+RkXeI3wwctHJEI= +cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI= +cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= +github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= 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/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg= -github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= -github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 h1:ZgQEtGgCBiWRM39fZuwSd1LwSqqSW0hOdXCYYDX0R3I= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 h1:uHTyIjqVhYRhLbJ8nIiOJHkEZZ+5YoOsAbD3sk82NiE= -github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.2-0.20191216170541-340f1ebe299e h1:4WfjkTUTsO6siF8ghDQQk6t7x/FPsv3w6MXkc47do7Q= -github.com/google/go-cmp v0.3.2-0.20191216170541-340f1ebe299e/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI= -github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= -github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= -github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= -github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2 h1:75k/FF0Q2YM8QYo07VPddOLBslDt1MZOdEslOHvmzAs= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5 h1:58fnuSXlxZmFdJyvtTFVmVhcMLU6v5fEb/ok4wyqtNU= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 h1:ULYEB3JvPRE/IfO+9uO7vKV/xzVTO7XPAwm8xbf4w2g= -golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3 h1:XQyxROzUlZH+WIQwySDgnISgOivlhjIEwaQaJEJrrN0= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422 h1:QzoH/1pFpZguR8NrRHLcO6jKqfv2zpuSqZLgdm7ZmjI= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 h1:bjcUS9ztw9kFmmIxJInhon/0Is3p+EHBKNgquIzo1OI= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd h1:r7DufRZuZbWB7j439YfAzP8RPDa9unLkpwQKUYbIMPI= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191210023423-ac6580df4449 h1:gSbV7h1NRL2G1xTg/owz62CST1oJBmxy4QpMMregXVQ= -golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd h1:/e+gpKk9r3dJobndpTytxS2gOy6m5uvpg+ISQoEcusQ= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0 h1:Dh6fw+p6FyRl5x/FvNswO1ji0lIGzm3KP8Y9VkS9PTE= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s= +go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4= +go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo= +go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4= +go.opentelemetry.io/otel/sdk v1.16.0 h1:Z1Ok1YsijYL0CSJpHt4cS3wDDh7p572grzNrBMiMWgE= +go.opentelemetry.io/otel/sdk v1.16.0/go.mod h1:tMsIuKXuuIWPBAOrH+eHtvhTL+SntFtXF9QD68aP6p4= +go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs= +go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0= +go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= +golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= +golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0 h1:9sdfJOzWlkqPltHAuzT2Cp+yrBeY1KRVYgms8soxMwM= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb h1:i1Ppqkc3WQXikh8bXiwHqAN5Rv3/qDCcRk0/Otx73BY= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1 h1:aQktFqmDE2yjveXJlVIfslDFmFnUXSqG0i6KRcJAeMc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1 h1:Hz2g2wirWK7H0qIIhGIqRGTuMwTE8HEKFnDZZ7lm9NU= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1 h1:j6XxA85m/6txkUCHvzlV5f+HBNl/1r5cZ2A/3IEFOO8= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1 h1:wdKvqQk7IttEw92GoRyKG2IDrUIpgpj6H6m81yfeMW0= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e h1:xIXmWJ303kJCuogpj0bHq+dcjcZHU+XFyc1I0Yl9cRg= +google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:0ggbjUrZYpy1q+ANUS30SEoGZ53cdfwtbuG7Ptgy108= +google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 h1:XVeBY8d/FaK4848myy41HBqnDwvxeV3zMZhwN1TvAMU= +google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:mPBs5jNgx2GuQGvFwUvVKqtn6HsUw9nP64BedgvqEsQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 h1:2FZP5XuJY9zQyGM5N0rtovnoXjiMUEIUMvw0m9wlpLc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:8mL13HKkDa+IuJ8yruA3ci0q+0vsUz4m//+ottjwS5o= +google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw= +google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/entryhuman/entry.go b/internal/entryhuman/entry.go index d17f80a..8af4d82 100644 --- a/internal/entryhuman/entry.go +++ b/internal/entryhuman/entry.go @@ -4,18 +4,21 @@ package entryhuman import ( "bytes" + "database/sql/driver" "encoding/json" "fmt" "io" "os" - "path/filepath" + "reflect" "strconv" "strings" + "syscall" "time" + "unicode" - "github.com/fatih/color" - "go.opencensus.io/trace" - "golang.org/x/crypto/ssh/terminal" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "golang.org/x/term" "golang.org/x/xerrors" "cdr.dev/slog" @@ -33,15 +36,58 @@ func StripTimestamp(ent string) (time.Time, string, error) { // TimeFormat is a simplified RFC3339 format. const TimeFormat = "2006-01-02 15:04:05.000" -func c(w io.Writer, attrs ...color.Attribute) *color.Color { - c := color.New(attrs...) - c.DisableColor() +var ( + renderer = lipgloss.NewRenderer(os.Stdout, termenv.WithUnsafe()) + + timeStyle = renderer.NewStyle().Foreground(lipgloss.Color("#606366")) +) + +func render(w io.Writer, st lipgloss.Style, s string) string { if shouldColor(w) { - c.EnableColor() + ss := st.Render(s) + return ss + } + return s +} + +func reset(w io.Writer, termW io.Writer) { + if shouldColor(termW) { + fmt.Fprintf(w, termenv.CSI+termenv.ResetSeq+"m") + } +} + +func formatValue(v interface{}) string { + if vr, ok := v.(driver.Valuer); ok { + var err error + v, err = vr.Value() + if err != nil { + return fmt.Sprintf("error calling Value: %v", err) + } + } + if v == nil { + return "" + } + typ := reflect.TypeOf(v) + switch typ.Kind() { + case reflect.Struct, reflect.Map: + byt, err := json.Marshal(v) + if err != nil { + panic(err) + } + return string(byt) + case reflect.Slice: + // Byte slices are optimistically readable. + if typ.Elem().Kind() == reflect.Uint8 { + return fmt.Sprintf("%q", v) + } + fallthrough + default: + return quote(fmt.Sprintf("%+v", v)) } - return c } +const tab = " " + // Fmt returns a human readable format for ent. // // We never return with a trailing newline because Go's testing framework adds one @@ -49,25 +95,30 @@ func c(w io.Writer, attrs ...color.Attribute) *color.Color { // We also do not indent the fields as go's test does that automatically // for extra lines in a log so if we did it here, the fields would be indented // twice in test logs. So the Stderr logger indents all the fields itself. -func Fmt(w io.Writer, ent slog.SinkEntry) string { - var ents string +func Fmt( + buf interface { + io.StringWriter + io.Writer + }, termW io.Writer, ent slog.SinkEntry, +) { + reset(buf, termW) ts := ent.Time.Format(TimeFormat) - ents += ts + " " + buf.WriteString(render(termW, timeStyle, ts+" ")) - level := "[" + ent.Level.String() + "]" - level = c(w, levelColor(ent.Level)).Sprint(level) - ents += fmt.Sprintf("%v\t", level) + level := ent.Level.String() + level = strings.ToLower(level) + if len(level) > 4 { + level = level[:4] + } + level = "[" + level + "]" + buf.WriteString(render(termW, levelStyle(ent.Level), level)) + buf.WriteString(" ") if len(ent.LoggerNames) > 0 { - loggerName := "(" + quoteKey(strings.Join(ent.LoggerNames, ".")) + ")" - loggerName = c(w, color.FgMagenta).Sprint(loggerName) - ents += fmt.Sprintf("%v\t", loggerName) + loggerName := quoteKey(strings.Join(ent.LoggerNames, ".")) + ": " + buf.WriteString(loggerName) } - loc := fmt.Sprintf("<%v:%v>", filepath.Base(ent.File), ent.Line) - loc = c(w, color.FgCyan).Sprint(loc) - ents += fmt.Sprintf("%v\t", loc) - var multilineKey string var multilineVal string msg := strings.TrimSpace(ent.Message) @@ -75,11 +126,11 @@ func Fmt(w io.Writer, ent slog.SinkEntry) string { multilineKey = "msg" multilineVal = msg msg = "..." + msg = quote(msg) } - msg = quote(msg) - ents += msg + buf.WriteString(msg) - if ent.SpanContext != (trace.SpanContext{}) { + if ent.SpanContext.IsValid() { ent.Fields = append(slog.M( slog.F("trace", ent.SpanContext.TraceID), slog.F("span", ent.SpanContext.SpanID), @@ -109,48 +160,61 @@ func Fmt(w io.Writer, ent slog.SinkEntry) string { multilineVal = s } - if len(ent.Fields) > 0 { - // No error is guaranteed due to slog.Map handling errors itself. - fields, _ := json.MarshalIndent(ent.Fields, "", "") - fields = bytes.ReplaceAll(fields, []byte(",\n"), []byte(", ")) - fields = bytes.ReplaceAll(fields, []byte("\n"), []byte("")) - fields = formatJSON(w, fields) - ents += "\t" + string(fields) + keyStyle := timeStyle + // Help users distinguish logs by keeping some color in the equal signs. + equalsStyle := timeStyle + + for i, f := range ent.Fields { + if i < len(ent.Fields) { + buf.WriteString(tab) + } + buf.WriteString(render(termW, keyStyle, quoteKey(f.Name))) + buf.WriteString(render(termW, equalsStyle, "=")) + valueStr := formatValue(f.Value) + buf.WriteString(valueStr) } if multilineVal != "" { if msg != "..." { - ents += " ..." + buf.WriteString(" ...") } // Proper indentation. lines := strings.Split(multilineVal, "\n") for i, line := range lines[1:] { if line != "" { - lines[i+1] = strings.Repeat(" ", len(multilineKey)+4) + line + lines[i+1] = strings.Repeat(" ", len(multilineKey)+2) + line } } multilineVal = strings.Join(lines, "\n") - multilineKey = c(w, color.FgBlue).Sprintf(`"%v"`, multilineKey) - ents += fmt.Sprintf("\n%v: %v", multilineKey, multilineVal) + multilineKey = render(termW, keyStyle, multilineKey) + buf.WriteString("\n") + buf.WriteString(multilineKey) + buf.WriteString("= ") + buf.WriteString(multilineVal) } - - return ents } -func levelColor(level slog.Level) color.Attribute { +var ( + levelDebugStyle = timeStyle.Copy() + levelInfoStyle = renderer.NewStyle().Foreground(lipgloss.Color("#0091FF")) + levelWarnStyle = renderer.NewStyle().Foreground(lipgloss.Color("#FFCF0D")) + levelErrorStyle = renderer.NewStyle().Foreground(lipgloss.Color("#FF5A0D")) +) + +func levelStyle(level slog.Level) lipgloss.Style { switch level { case slog.LevelDebug: - return color.Reset + return levelDebugStyle case slog.LevelInfo: - return color.FgBlue + return levelInfoStyle case slog.LevelWarn: - return color.FgYellow - case slog.LevelError: - return color.FgRed + return levelWarnStyle + case slog.LevelError, slog.LevelFatal, slog.LevelCritical: + return levelErrorStyle default: - return color.FgHiRed + panic("unknown level") } } @@ -161,10 +225,26 @@ func isTTY(w io.Writer) bool { if w == forceColorWriter { return true } - f, ok := w.(interface { - Fd() uintptr - }) - return ok && terminal.IsTerminal(int(f.Fd())) + // SyscallConn is safe during file close. + if sc, ok := w.(interface { + SyscallConn() (syscall.RawConn, error) + }); ok { + conn, err := sc.SyscallConn() + if err != nil { + return false + } + var isTerm bool + err = conn.Control(func(fd uintptr) { + isTerm = term.IsTerminal(int(fd)) + }) + if err != nil { + return false + } + return isTerm + } + // Fallback to unsafe Fd. + f, ok := w.(interface{ Fd() uintptr }) + return ok && term.IsTerminal(int(f.Fd())) } func shouldColor(w io.Writer) bool { @@ -180,11 +260,18 @@ func quote(key string) string { return `""` } + var hasSpace bool + for _, r := range key { + if unicode.IsSpace(r) { + hasSpace = true + break + } + } quoted := strconv.Quote(key) // If the key doesn't need to be quoted, don't quote it. // We do not use strconv.CanBackquote because it doesn't // account tabs. - if quoted[1:len(quoted)-1] == key { + if !hasSpace && quoted[1:len(quoted)-1] == key { return key } return quoted @@ -192,5 +279,5 @@ func quote(key string) string { func quoteKey(key string) string { // Replace spaces in the map keys with underscores. - return strings.ReplaceAll(key, " ", "_") + return quote(strings.ReplaceAll(key, " ", "_")) } diff --git a/internal/entryhuman/entry_test.go b/internal/entryhuman/entry_test.go index 8530bd7..45a885a 100644 --- a/internal/entryhuman/entry_test.go +++ b/internal/entryhuman/entry_test.go @@ -1,12 +1,15 @@ package entryhuman_test import ( - "io/ioutil" + "bytes" + "database/sql" + "flag" + "fmt" + "io" + "os" "testing" "time" - "go.opencensus.io/trace" - "cdr.dev/slog" "cdr.dev/slog/internal/assert" "cdr.dev/slog/internal/entryhuman" @@ -14,81 +17,228 @@ import ( var kt = time.Date(2000, time.February, 5, 4, 4, 4, 4, time.UTC) +var updateGoldenFiles = flag.Bool("update-golden-files", false, "update golden files in testdata") + +type testObj struct { + foo int + bar int + dra []byte +} + func TestEntry(t *testing.T) { t.Parallel() - test := func(t *testing.T, in slog.SinkEntry, exp string) { - act := entryhuman.Fmt(ioutil.Discard, in) - assert.Equal(t, "entry", exp, act) + type tcase struct { + name string + ent slog.SinkEntry } - t.Run("basic", func(t *testing.T) { - t.Parallel() - - test(t, slog.SinkEntry{ - Message: "wowowow\tizi", - Time: kt, - Level: slog.LevelDebug, - - File: "myfile", - Line: 100, - Func: "ignored", - }, `2000-02-05 04:04:04.000 [DEBUG] "wowowow\tizi"`) - }) - - t.Run("multilineMessage", func(t *testing.T) { - t.Parallel() - - test(t, slog.SinkEntry{ - Message: "line1\nline2", - Level: slog.LevelInfo, - }, `0001-01-01 00:00:00.000 [INFO] <.:0> ... -"msg": line1 - line2`) - }) - - t.Run("multilineField", func(t *testing.T) { - t.Parallel() - - test(t, slog.SinkEntry{ - Message: "msg", - Level: slog.LevelInfo, - Fields: slog.M(slog.F("field", "line1\nline2")), - }, `0001-01-01 00:00:00.000 [INFO] <.:0> msg ... -"field": line1 - line2`) - }) - - t.Run("named", func(t *testing.T) { - t.Parallel() + ents := []tcase{ + { + "simpleNoFields", + slog.SinkEntry{ + Message: "wowowow\tizi", + Time: kt, + Level: slog.LevelDebug, + + File: "myfile", + Line: 100, + Func: "mypkg.ignored", + }, + }, + { + "multilineMessage", + slog.SinkEntry{ + Message: "line1\nline2", + Level: slog.LevelInfo, + }, + }, + { + "multilineField", + slog.SinkEntry{ + Message: "msg", + Level: slog.LevelInfo, + Fields: slog.M(slog.F("field", "line1\nline2")), + }, + }, + { + "named", + slog.SinkEntry{ + Level: slog.LevelWarn, + LoggerNames: []string{"some", "cat"}, + Message: "meow", + Fields: slog.M( + slog.F("breath", "stinky"), + ), + }, + }, + { + "funky", + slog.SinkEntry{ + Level: slog.LevelWarn, + Fields: slog.M( + slog.F("funky^%&^&^key", "value"), + slog.F("funky^%&^&^key2", "@#\t \t \n"), + ), + }, + }, + { + "spacey", + slog.SinkEntry{ + Level: slog.LevelWarn, + Fields: slog.M( + slog.F("space in my key", "value in my value"), + ), + }, + }, + { + "nil", + slog.SinkEntry{ + Level: slog.LevelWarn, + Fields: slog.M( + slog.F("nan", nil), + ), + }, + }, + { + "bytes", + slog.SinkEntry{ + Level: slog.LevelWarn, + Fields: slog.M( + slog.F("somefile", []byte("blah bla\x01h blah")), + ), + }, + }, + { + "driverValue", + slog.SinkEntry{ + Level: slog.LevelWarn, + Fields: slog.M( + slog.F("val", sql.NullString{String: "dog", Valid: true}), + slog.F("inval", sql.NullString{String: "cat", Valid: false}), + ), + }, + }, + { + "object", + slog.SinkEntry{ + Level: slog.LevelWarn, + Fields: slog.M( + slog.F("obj", slog.M( + slog.F("obj1", testObj{ + foo: 1, + bar: 2, + dra: []byte("blah"), + }), + slog.F("obj2", testObj{ + foo: 3, + bar: 4, + dra: []byte("blah"), + }), + )), + slog.F("map", map[string]string{ + "key1": "value1", + }), + ), + }, + }, + } + if *updateGoldenFiles { + ents, err := os.ReadDir("testdata") + if err != nil { + t.Fatal(err) + } + for _, ent := range ents { + os.Remove("testdata/" + ent.Name()) + } + } - test(t, slog.SinkEntry{ - Level: slog.LevelWarn, - LoggerNames: []string{"named", "meow"}, - }, `0001-01-01 00:00:00.000 [WARN] (named.meow) <.:0> ""`) - }) + for _, tc := range ents { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + goldenPath := fmt.Sprintf("testdata/%s.golden", tc.name) + + var gotBuf bytes.Buffer + entryhuman.Fmt(&gotBuf, io.Discard, tc.ent) + + if *updateGoldenFiles { + err := os.WriteFile(goldenPath, gotBuf.Bytes(), 0o644) + if err != nil { + t.Fatal(err) + } + return + } + + wantByt, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "entry matches", string(wantByt), gotBuf.String()) + }) + } - t.Run("trace", func(t *testing.T) { + t.Run("isTTY during file close", func(t *testing.T) { t.Parallel() - test(t, slog.SinkEntry{ - Level: slog.LevelError, - SpanContext: trace.SpanContext{ - SpanID: trace.SpanID{0, 1, 2, 3, 4, 5, 6, 7}, - TraceID: trace.TraceID{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, - }, - }, `0001-01-01 00:00:00.000 [ERROR] <.:0> "" {"trace": "000102030405060708090a0b0c0d0e0f", "span": "0001020304050607"}`) + tmpdir := t.TempDir() + f, err := os.CreateTemp(tmpdir, "slog") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + done := make(chan struct{}, 2) + go func() { + entryhuman.Fmt(new(bytes.Buffer), f, slog.SinkEntry{ + Level: slog.LevelCritical, + Fields: slog.M( + slog.F("hey", "hi"), + ), + }) + done <- struct{}{} + }() + go func() { + _ = f.Close() + done <- struct{}{} + }() + <-done + <-done }) +} - t.Run("color", func(t *testing.T) { - t.Parallel() - - act := entryhuman.Fmt(entryhuman.ForceColorWriter, slog.SinkEntry{ - Level: slog.LevelCritical, - Fields: slog.M( - slog.F("hey", "hi"), - ), - }) - assert.Equal(t, "entry", "0001-01-01 00:00:00.000 \x1b[91m[CRITICAL]\x1b[0m\t\x1b[36m<.:0>\x1b[0m\t\"\"\t{\x1b[34m\"hey\"\x1b[0m: \x1b[32m\"hi\"\x1b[0m}", act) - }) +func BenchmarkFmt(b *testing.B) { + bench := func(b *testing.B, color bool) { + nfs := []int{1, 4, 16} + for _, nf := range nfs { + name := fmt.Sprintf("nf=%v", nf) + if color { + name = "Colored-" + name + } + b.Run(name, func(b *testing.B) { + fs := make([]slog.Field, nf) + for i := 0; i < nf; i++ { + fs[i] = slog.F("key", "value") + } + se := slog.SinkEntry{ + Level: slog.LevelCritical, + Fields: slog.M( + fs..., + ), + } + w := io.Discard + if color { + w = entryhuman.ForceColorWriter + } + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + entryhuman.Fmt(bytes.NewBuffer(nil), w, se) + } + }) + } + } + bench(b, true) + bench(b, false) } diff --git a/internal/entryhuman/json.go b/internal/entryhuman/json.go deleted file mode 100644 index 25f65f6..0000000 --- a/internal/entryhuman/json.go +++ /dev/null @@ -1,42 +0,0 @@ -package entryhuman - -import ( - "bytes" - "io" - - "github.com/alecthomas/chroma" - "github.com/alecthomas/chroma/formatters" - jlexers "github.com/alecthomas/chroma/lexers/j" -) - -// Adapted from https://github.com/alecthomas/chroma/blob/2f5349aa18927368dbec6f8c11608bf61c38b2dd/styles/bw.go#L7 -// https://github.com/alecthomas/chroma/blob/2f5349aa18927368dbec6f8c11608bf61c38b2dd/formatters/tty_indexed.go -// https://github.com/alecthomas/chroma/blob/2f5349aa18927368dbec6f8c11608bf61c38b2dd/lexers/j/json.go -var style = chroma.MustNewStyle("slog", chroma.StyleEntries{ - // Magenta. - chroma.Keyword: "#7f007f", - // Magenta. - chroma.Number: "#7f007f", - // Magenta. - chroma.Name: "#00007f", - // Green. - chroma.String: "#007f00", -}) - -var jsonLexer = chroma.Coalesce(jlexers.JSON) - -func formatJSON(w io.Writer, buf []byte) []byte { - if !shouldColor(w) { - return buf - } - - highlighted, _ := colorizeJSON(buf) - return highlighted -} - -func colorizeJSON(buf []byte) ([]byte, error) { - it, _ := jsonLexer.Tokenise(nil, string(buf)) - b := &bytes.Buffer{} - formatters.TTY8.Format(b, style, it) - return b.Bytes(), nil -} diff --git a/internal/entryhuman/testdata/bytes.golden b/internal/entryhuman/testdata/bytes.golden new file mode 100644 index 0000000..e4c5490 --- /dev/null +++ b/internal/entryhuman/testdata/bytes.golden @@ -0,0 +1 @@ +0001-01-01 00:00:00.000 [warn] somefile="blah bla\x01h blah" \ No newline at end of file diff --git a/internal/entryhuman/testdata/driverValue.golden b/internal/entryhuman/testdata/driverValue.golden new file mode 100644 index 0000000..e03ace2 --- /dev/null +++ b/internal/entryhuman/testdata/driverValue.golden @@ -0,0 +1 @@ +0001-01-01 00:00:00.000 [warn] val=dog inval= \ No newline at end of file diff --git a/internal/entryhuman/testdata/funky.golden b/internal/entryhuman/testdata/funky.golden new file mode 100644 index 0000000..fc6a460 --- /dev/null +++ b/internal/entryhuman/testdata/funky.golden @@ -0,0 +1 @@ +0001-01-01 00:00:00.000 [warn] funky^%&^&^key=value funky^%&^&^key2="@#\t \t \n" \ No newline at end of file diff --git a/internal/entryhuman/testdata/multilineField.golden b/internal/entryhuman/testdata/multilineField.golden new file mode 100644 index 0000000..a2777d8 --- /dev/null +++ b/internal/entryhuman/testdata/multilineField.golden @@ -0,0 +1,3 @@ +0001-01-01 00:00:00.000 [info] msg ... +field= line1 + line2 \ No newline at end of file diff --git a/internal/entryhuman/testdata/multilineMessage.golden b/internal/entryhuman/testdata/multilineMessage.golden new file mode 100644 index 0000000..233fda6 --- /dev/null +++ b/internal/entryhuman/testdata/multilineMessage.golden @@ -0,0 +1,3 @@ +0001-01-01 00:00:00.000 [info] ... +msg= line1 + line2 \ No newline at end of file diff --git a/internal/entryhuman/testdata/named.golden b/internal/entryhuman/testdata/named.golden new file mode 100644 index 0000000..83867bf --- /dev/null +++ b/internal/entryhuman/testdata/named.golden @@ -0,0 +1 @@ +0001-01-01 00:00:00.000 [warn] some.cat: meow breath=stinky \ No newline at end of file diff --git a/internal/entryhuman/testdata/nil.golden b/internal/entryhuman/testdata/nil.golden new file mode 100644 index 0000000..86b6330 --- /dev/null +++ b/internal/entryhuman/testdata/nil.golden @@ -0,0 +1 @@ +0001-01-01 00:00:00.000 [warn] nan= \ No newline at end of file diff --git a/internal/entryhuman/testdata/object.golden b/internal/entryhuman/testdata/object.golden new file mode 100644 index 0000000..855cb06 --- /dev/null +++ b/internal/entryhuman/testdata/object.golden @@ -0,0 +1 @@ +0001-01-01 00:00:00.000 [warn] obj="[{Name:obj1 Value:{foo:1 bar:2 dra:[98 108 97 104]}} {Name:obj2 Value:{foo:3 bar:4 dra:[98 108 97 104]}}]" map={"key1":"value1"} \ No newline at end of file diff --git a/internal/entryhuman/testdata/simpleNoFields.golden b/internal/entryhuman/testdata/simpleNoFields.golden new file mode 100644 index 0000000..db46f6a --- /dev/null +++ b/internal/entryhuman/testdata/simpleNoFields.golden @@ -0,0 +1 @@ +2000-02-05 04:04:04.000 [debu] wowowow izi \ No newline at end of file diff --git a/internal/entryhuman/testdata/spacey.golden b/internal/entryhuman/testdata/spacey.golden new file mode 100644 index 0000000..7135d8c --- /dev/null +++ b/internal/entryhuman/testdata/spacey.golden @@ -0,0 +1 @@ +0001-01-01 00:00:00.000 [warn] space_in_my_key="value in my value" \ No newline at end of file diff --git a/internal/syncwriter/syncwriter.go b/internal/syncwriter/syncwriter.go index 11b230f..acf886c 100644 --- a/internal/syncwriter/syncwriter.go +++ b/internal/syncwriter/syncwriter.go @@ -55,18 +55,20 @@ func (w *Writer) Sync(sinkName string) { return } err := s.Sync() - if _, ok := w.w.(*os.File); ok { - // Opened files do not necessarily support syncing. - // E.g. stdout and stderr both do not so we need - // to ignore these errors. - // See https://github.com/uber-go/zap/issues/370 - // See https://github.com/cdr/slog/pull/43 - if errorsIsAny(err, syscall.EINVAL, syscall.ENOTTY, syscall.EBADF) { - return + if err != nil { + if _, ok := w.w.(*os.File); ok { + // Opened files do not necessarily support syncing. + // E.g. stdout and stderr both do not so we need + // to ignore these errors. + // See https://github.com/uber-go/zap/issues/370 + // See https://github.com/cdr/slog/pull/43 + if errorsIsAny(err, syscall.EINVAL, syscall.ENOTTY, syscall.EBADF) { + return + } } - } - w.errorf("failed to sync %v: %+v", sinkName, err) + w.errorf("failed to sync %v: %+v", sinkName, err) + } } func errorsIsAny(err error, errs ...error) bool { diff --git a/map.go b/map.go index a1b594b..636f4ee 100644 --- a/map.go +++ b/map.go @@ -128,7 +128,7 @@ func encodeJSON(v interface{}) []byte { b, err := json.Marshal(v) if err != nil { return encode(M( - Err(xerrors.Errorf("failed to marshal to JSON: %w", err)), + Error(xerrors.Errorf("failed to marshal to JSON: %w", err)), F("type", reflect.TypeOf(v)), F("value", fmt.Sprintf("%+v", v)), )) diff --git a/map_test.go b/map_test.go index 03d7886..fce13b5 100644 --- a/map_test.go +++ b/map_test.go @@ -37,7 +37,7 @@ func TestMap(t *testing.T) { } test(t, slog.M( - slog.Err( + slog.Error( xerrors.Errorf("wrap1: %w", xerrors.Errorf("wrap2: %w", io.EOF, @@ -187,7 +187,7 @@ func TestMap(t *testing.T) { t.Parallel() test(t, slog.M( - slog.F("val", time.Date(2000, 02, 05, 4, 4, 4, 0, time.UTC)), + slog.F("val", time.Date(2000, 0o2, 0o5, 4, 4, 4, 0, time.UTC)), ), `{ "val": "2000-02-05T04:04:04Z" }`) diff --git a/s.go b/s.go index e85195c..c6d5df9 100644 --- a/s.go +++ b/s.go @@ -3,39 +3,36 @@ package slog import ( "context" "log" - "os" "strings" ) // Stdlib creates a standard library logger from the given logger. // -// All logs will be logged at the Info level and the given ctx -// will be passed to the logger's Info method, thereby logging -// all fields and tracing info in the context. +// All logs will be logged at the level set by the logger and the +// given ctx will be passed to the logger's Log method, thereby +// logging all fields and tracing info in the context. // // You can redirect the stdlib default logger with log.SetOutput // to the Writer on the logger returned by this function. // See the example. -func Stdlib(ctx context.Context) *log.Logger { - ctx = Named(ctx, "stdlib") +func Stdlib(ctx context.Context, l Logger, level Level) *log.Logger { + l.skip += 2 - l, ok := loggerFromContext(ctx) - if !ok { - // Give stderr logger if no slog. - return log.New(os.Stderr, "", 0) - } - l.skip += 3 - ctx = contextWithLogger(ctx, l) + l = l.Named("stdlib") w := &stdlogWriter{ - ctx: ctx, + ctx: ctx, + l: l, + level: level, } return log.New(w, "", 0) } type stdlogWriter struct { - ctx context.Context + ctx context.Context + l Logger + level Level } func (w stdlogWriter) Write(p []byte) (n int, err error) { @@ -44,7 +41,7 @@ func (w stdlogWriter) Write(p []byte) (n int, err error) { // we do not want. msg = strings.TrimSuffix(msg, "\n") - Info(w.ctx, msg) + w.l.log(w.ctx, w.level, msg, nil) return len(p), nil } diff --git a/s_test.go b/s_test.go index 5006c04..358282e 100644 --- a/s_test.go +++ b/s_test.go @@ -2,7 +2,6 @@ package slog_test import ( "bytes" - "context" "testing" "cdr.dev/slog" @@ -15,16 +14,14 @@ func TestStdlib(t *testing.T) { t.Parallel() b := &bytes.Buffer{} - ctx := context.Background() - ctx = slog.Make(sloghuman.Make(ctx, b)) - ctx = slog.With(ctx, + l := slog.Make(sloghuman.Sink(b)).With( slog.F("hi", "we"), ) - stdlibLog := slog.Stdlib(ctx) + stdlibLog := slog.Stdlib(bg, l, slog.LevelInfo) stdlibLog.Println("stdlib") et, rest, err := entryhuman.StripTimestamp(b.String()) assert.Success(t, "strip timestamp", err) assert.False(t, "timestamp", et.IsZero()) - assert.Equal(t, "entry", " [INFO]\t(stdlib)\t\tstdlib\t{\"hi\": \"we\"}\n", rest) + assert.Equal(t, "entry", " [info] stdlib: stdlib hi=we\n", rest) } diff --git a/slog.go b/slog.go index bfbecde..a3b705c 100644 --- a/slog.go +++ b/slog.go @@ -4,8 +4,8 @@ // // The examples are the best way to understand how to use this library effectively. // -// The logger type implements a high level API around the Sink interface. -// logger implements Sink as well to allow composition. +// The Logger type implements a high level API around the Sink interface. +// Logger implements Sink as well to allow composition. // // Implementations of the Sink interface are available in the sloggers subdirectory. package slog // import "cdr.dev/slog" @@ -18,10 +18,12 @@ import ( "sync" "time" - "go.opencensus.io/trace" + "go.opentelemetry.io/otel/trace" ) -// Sink is the destination of a logger. +var defaultExitFn = os.Exit + +// Sink is the destination of a Logger. // // All sinks must be safe for concurrent use. type Sink interface { @@ -29,11 +31,11 @@ type Sink interface { Sync() } -// LogEntry logs the given entry with the context to the +// Log logs the given entry with the context to the // underlying sinks. // // It extends the entry with the set fields and names. -func (l logger) LogEntry(ctx context.Context, e SinkEntry) { +func (l Logger) Log(ctx context.Context, e SinkEntry) { if e.Level < l.level { return } @@ -46,27 +48,17 @@ func (l logger) LogEntry(ctx context.Context, e SinkEntry) { } } -func (l logger) Sync() { +// Sync calls Sync on all the underlying sinks. +func (l Logger) Sync() { for _, s := range l.sinks { s.Sync() } } -// Sync calls Sync on all the underlying sinks. -func Sync(ctx context.Context) { - l, ok := loggerFromContext(ctx) - if !ok { - return - } - l.Sync() - return -} - -// logger wraps Sink with a nice API to log entries. +// Logger wraps Sink with a nice API to log entries. // -// logger is safe for concurrent use. -// It is unexported because callers should only log via a context. -type logger struct { +// Logger is safe for concurrent use. +type Logger struct { sinks []Sink level Level @@ -78,119 +70,155 @@ type logger struct { } // Make creates a logger that writes logs to the passed sinks at LevelInfo. -func Make(ctx context.Context, sinks ...Sink) SinkContext { - // Just in case the ctx has a logger, start with it. - l, _ := loggerFromContext(ctx) - l.sinks = append(l.sinks, sinks...) - if l.level == 0 { - l.level = LevelInfo - } - l.exit = os.Exit +func Make(sinks ...Sink) Logger { + return Logger{ + sinks: sinks, + level: LevelInfo, - return contextWithLogger(ctx, l) + exit: os.Exit, + } } // Debug logs the msg and fields at LevelDebug. -func Debug(ctx context.Context, msg string, fields ...Field) { - l, ok := loggerFromContext(ctx) - if !ok { - return - } +// See Info for information on the fields argument. +func (l Logger) Debug(ctx context.Context, msg string, fields ...any) { l.log(ctx, LevelDebug, msg, fields) } // Info logs the msg and fields at LevelInfo. -func Info(ctx context.Context, msg string, fields ...Field) { - l, ok := loggerFromContext(ctx) - if !ok { - return - } +// Fields may contain any combination of key value pairs, Field, and Map. +// For example: +// +// log.Info(ctx, "something happened", "user", "alex", slog.F("age", 20)) +// +// is equivalent to: +// +// log.Info(ctx, "something happened", slog.F("user", "alex"), slog.F("age", 20)) +// +// is equivalent to: +// +// log.Info(ctx, "something happened", slog.M( +// slog.F("user", "alex"), +// slog.F("age", 20), +// )) +// +// is equivalent to: +// +// log.Info(ctx, "something happened", "user", "alex", "age", 20) +// +// In general, prefer using key value pairs over Field and Map, as that is how +// the standard library's slog package works. +func (l Logger) Info(ctx context.Context, msg string, fields ...any) { l.log(ctx, LevelInfo, msg, fields) } // Warn logs the msg and fields at LevelWarn. -func Warn(ctx context.Context, msg string, fields ...Field) { - l, ok := loggerFromContext(ctx) - if !ok { - return - } +// See Info() for information on the fields argument. +func (l Logger) Warn(ctx context.Context, msg string, fields ...any) { l.log(ctx, LevelWarn, msg, fields) } // Error logs the msg and fields at LevelError. +// See Info() for information on the fields argument. // // It will then Sync(). -func Error(ctx context.Context, msg string, fields ...Field) { - l, ok := loggerFromContext(ctx) - if !ok { - return - } +func (l Logger) Error(ctx context.Context, msg string, fields ...any) { l.log(ctx, LevelError, msg, fields) l.Sync() } // Critical logs the msg and fields at LevelCritical. +// See Info() for information on the fields argument. // // It will then Sync(). -func Critical(ctx context.Context, msg string, fields ...Field) { - l, ok := loggerFromContext(ctx) - if !ok { - return - } +func (l Logger) Critical(ctx context.Context, msg string, fields ...any) { l.log(ctx, LevelCritical, msg, fields) l.Sync() } // Fatal logs the msg and fields at LevelFatal. +// See Info() for information on the fields argument. // // It will then Sync() and os.Exit(1). -func Fatal(ctx context.Context, msg string, fields ...Field) { - l, ok := loggerFromContext(ctx) - if !ok { - os.Stderr.WriteString("Fatal called but no Logger in context") - // The caller expects the program to terminate after Fatal no matter what. - l.exit(1) - return - } +func (l Logger) Fatal(ctx context.Context, msg string, fields ...any) { l.log(ctx, LevelFatal, msg, fields) l.Sync() + + if l.exit == nil { + l.exit = defaultExitFn + } + l.exit(1) } +// With returns a Logger that prepends the given fields on every +// logged entry. +// +// It will append to any fields already in the Logger. +func (l Logger) With(fields ...Field) Logger { + l.fields = l.fields.append(fields) + return l +} + // Named appends the name to the set names // on the logger. -func Named(ctx context.Context, name string) context.Context { - l, ok := loggerFromContext(ctx) - if !ok { - return ctx - } +func (l Logger) Named(name string) Logger { l.names = appendNames(l.names, name) - return contextWithLogger(ctx, l) + return l } -// Leveled returns a logger that only logs entries +// Leveled returns a Logger that only logs entries // equal to or above the given level. -func Leveled(ctx context.Context, level Level) context.Context { - l, ok := loggerFromContext(ctx) - if !ok { - return ctx - } +func (l Logger) Leveled(level Level) Logger { l.level = level - return contextWithLogger(ctx, l) + l.sinks = append([]Sink(nil), l.sinks...) + return l +} + +// AppendSinks appends the sinks to the set sink +// targets on the logger. +func (l Logger) AppendSinks(s ...Sink) Logger { + l.sinks = append(l.sinks, s...) + return l } -func (l logger) log(ctx context.Context, level Level, msg string, fields Map) { +func (l Logger) log(ctx context.Context, level Level, msg string, rawFields []any) { + fields := make(Map, 0, len(rawFields)) + var wipField Field + for i, f := range rawFields { + if wipField.Name != "" { + wipField.Value = f + fields = append(fields, wipField) + wipField = Field{} + continue + } + switch f := f.(type) { + case Field: + fields = append(fields, f) + case Map: + fields = append(fields, f...) + case string: + wipField.Name = f + default: + panic(fmt.Sprintf("unexpected field type %T at index %v (does it have a key?)", f, i)) + } + } + + if wipField.Name != "" { + panic(fmt.Sprintf("field %q has no value", wipField.Name)) + } + ent := l.entry(ctx, level, msg, fields) - l.LogEntry(ctx, ent) + l.Log(ctx, ent) } -func (l logger) entry(ctx context.Context, level Level, msg string, fields Map) SinkEntry { +func (l Logger) entry(ctx context.Context, level Level, msg string, fields Map) SinkEntry { ent := SinkEntry{ Time: time.Now().UTC(), Level: level, Message: msg, Fields: fieldsFromContext(ctx).append(fields), - SpanContext: trace.FromContext(ctx).SpanContext(), + SpanContext: trace.SpanContextFromContext(ctx), } ent = ent.fillLoc(l.skip + 3) return ent @@ -265,8 +293,8 @@ func M(fs ...Field) Map { return fs } -// Err is the standard key used for logging a Go error value. -func Err(err error) Field { +// Error is the standard key used for logging a Go error value. +func Error(err error) Field { return F("error", err) } diff --git a/slog_exit_test.go b/slog_exit_test.go new file mode 100644 index 0000000..56973ce --- /dev/null +++ b/slog_exit_test.go @@ -0,0 +1,35 @@ +package slog + +import ( + "context" + "testing" + + "cdr.dev/slog/internal/assert" +) + +func TestExit(t *testing.T) { + // This can't be parallel since it modifies a global variable. + t.Run("defaultExitFn", func(t *testing.T) { + var ( + ctx = context.Background() + log Logger + defaultExitFnCalled bool + ) + + prevExitFn := defaultExitFn + t.Cleanup(func() { defaultExitFn = prevExitFn }) + + defaultExitFn = func(_ int) { + defaultExitFnCalled = true + } + + log.Debug(ctx, "hi") + log.Info(ctx, "hi") + log.Warn(ctx, "hi") + log.Error(ctx, "hi") + log.Critical(ctx, "hi") + log.Fatal(ctx, "hi") + + assert.True(t, "default exit fn used", defaultExitFnCalled) + }) +} diff --git a/slog_test.go b/slog_test.go index 3c88989..277a9c3 100644 --- a/slog_test.go +++ b/slog_test.go @@ -2,11 +2,12 @@ package slog_test import ( "context" + "fmt" "io" "runtime" "testing" - "go.opencensus.io/trace" + sdktrace "go.opentelemetry.io/otel/sdk/trace" "cdr.dev/slog" "cdr.dev/slog/internal/assert" @@ -28,6 +29,8 @@ func (s *fakeSink) Sync() { s.syncs++ } +var bg = context.Background() + func TestLogger(t *testing.T) { t.Parallel() @@ -36,12 +39,12 @@ func TestLogger(t *testing.T) { s1 := &fakeSink{} s2 := &fakeSink{} - var ctx context.Context - ctx = slog.Make(context.Background(), s1, s2) - ctx = slog.Leveled(ctx, slog.LevelError) + l := slog.Make(s1) + l = l.Leveled(slog.LevelError) + l = l.AppendSinks(s2) - slog.Info(ctx, "wow", slog.Err(io.EOF)) - slog.Error(ctx, "meow", slog.Err(io.ErrUnexpectedEOF)) + l.Info(bg, "wow", slog.Error(io.EOF)) + l.Error(bg, "meow", slog.Error(io.ErrUnexpectedEOF)) assert.Equal(t, "syncs", 1, s1.syncs) assert.Len(t, "entries", 1, s1.entries) @@ -53,14 +56,13 @@ func TestLogger(t *testing.T) { t.Parallel() s := &fakeSink{} - var ctx context.Context - ctx = slog.Make(context.Background(), s) + l := slog.Make(s) h := func(ctx context.Context) { slog.Helper() - slog.Info(ctx, "logging in helper") + l.Info(ctx, "logging in helper") } - ctx = slog.With(ctx, slog.F( + ctx := slog.With(bg, slog.F( "ctx", 1024), ) h(ctx) @@ -74,7 +76,7 @@ func TestLogger(t *testing.T) { File: slogTestFile, Func: "cdr.dev/slog_test.TestLogger.func2", - Line: 66, + Line: 68, Fields: slog.M( slog.F("ctx", 1024), @@ -86,16 +88,19 @@ func TestLogger(t *testing.T) { t.Parallel() s := &fakeSink{} - var ctx context.Context - ctx = slog.Make(context.Background(), s) - ctx = slog.Named(ctx, "hello") - ctx = slog.Named(ctx, "hello2") - - ctx, span := trace.StartSpan(ctx, "trace") + l := slog.Make(s) + l = l.Named("hello") + l = l.Named("hello2") + + tp := sdktrace.NewTracerProvider() + tracer := tp.Tracer("tracer") + ctx, span := tracer.Start(bg, "trace") + span.End() + _ = tp.Shutdown(bg) ctx = slog.With(ctx, slog.F("ctx", io.EOF)) - ctx = slog.With(ctx, slog.F("with", 2)) + l = l.With(slog.F("with", 2)) - slog.Info(ctx, "meow", slog.F("hi", "xd")) + l.Info(ctx, "meow", slog.F("hi", "xd")) assert.Len(t, "entries", 1, s.entries) assert.Equal(t, "entry", slog.SinkEntry{ @@ -108,13 +113,13 @@ func TestLogger(t *testing.T) { File: slogTestFile, Func: "cdr.dev/slog_test.TestLogger.func3", - Line: 98, + Line: 103, SpanContext: span.SpanContext(), Fields: slog.M( - slog.F("ctx", io.EOF), slog.F("with", 2), + slog.F("ctx", io.EOF), slog.F("hi", "xd"), ), }, s.entries[0]) @@ -124,21 +129,20 @@ func TestLogger(t *testing.T) { t.Parallel() s := &fakeSink{} - var ctx context.Context - ctx = slog.Make(context.Background(), s) + l := slog.Make(s) exits := 0 - ctx = slog.SetExit(ctx, func(int) { + l.SetExit(func(int) { exits++ }) - ctx = slog.Leveled(ctx, slog.LevelDebug) - slog.Debug(ctx, "") - slog.Info(ctx, "") - slog.Warn(ctx, "") - slog.Error(ctx, "") - slog.Critical(ctx, "") - slog.Fatal(ctx, "") + l = l.Leveled(slog.LevelDebug) + l.Debug(bg, "") + l.Info(bg, "") + l.Warn(bg, "") + l.Error(bg, "") + l.Critical(bg, "") + l.Fatal(bg, "") assert.Len(t, "entries", 6, s.entries) assert.Equal(t, "syncs", 3, s.syncs) @@ -150,6 +154,36 @@ func TestLogger(t *testing.T) { assert.Equal(t, "level", slog.LevelFatal, s.entries[5].Level) assert.Equal(t, "exits", 1, exits) }) + + t.Run("kv", func(t *testing.T) { + s := &fakeSink{} + l := slog.Make(s) + + // All of these formats should be equivalent. + formats := [][]any{ + {"animal", "cat", "weight", 15}, + {slog.F("animal", "cat"), "weight", 15}, + {slog.M( + slog.F("animal", "cat"), + slog.F("weight", 15), + )}, + {slog.F("animal", "cat"), slog.F("weight", 15)}, + } + + for _, format := range formats { + l.Info(bg, "msg", format...) + } + + assert.Len(t, "entries", 4, s.entries) + + for i := range s.entries { + assert.Equal( + t, fmt.Sprintf("%v", i), + s.entries[0].Fields, + s.entries[i].Fields, + ) + } + }) } func TestLevel_String(t *testing.T) { diff --git a/sloggers/sloghuman/sloghuman.go b/sloggers/sloghuman/sloghuman.go index 18cbb58..5247d17 100644 --- a/sloggers/sloghuman/sloghuman.go +++ b/sloggers/sloghuman/sloghuman.go @@ -3,25 +3,27 @@ package sloghuman // import "cdr.dev/slog/sloggers/sloghuman" import ( + "bufio" + "bytes" "context" "io" - "strings" + "sync" "cdr.dev/slog" "cdr.dev/slog/internal/entryhuman" "cdr.dev/slog/internal/syncwriter" ) -// Make creates a logger that writes logs in a human +// Sink creates a slog.Sink that writes logs in a human // readable YAML like format to the given writer. // // If the writer implements Sync() error then // it will be called when syncing. -func Make(ctx context.Context, w io.Writer) slog.SinkContext { - return slog.Make(ctx, &humanSink{ +func Sink(w io.Writer) slog.Sink { + return &humanSink{ w: syncwriter.New(w), w2: w, - }) + } } type humanSink struct { @@ -29,24 +31,40 @@ type humanSink struct { w2 io.Writer } -func (s humanSink) LogEntry(_ context.Context, ent slog.SinkEntry) { - str := entryhuman.Fmt(s.w2, ent) - lines := strings.Split(str, "\n") +var bufPool = sync.Pool{ + New: func() interface{} { + return bytes.NewBuffer(make([]byte, 0, 256)) + }, +} + +func (s humanSink) LogEntry(ctx context.Context, ent slog.SinkEntry) { + buf1 := bufPool.Get().(*bytes.Buffer) + buf1.Reset() + defer bufPool.Put(buf1) + + buf2 := bufPool.Get().(*bytes.Buffer) + buf2.Reset() + defer bufPool.Put(buf2) + + entryhuman.Fmt(buf1, s.w2, ent) + + var ( + i int + sc = bufio.NewScanner(buf1) + ) // We need to add 4 spaces before every field line for readability. // humanfmt doesn't do it for us because the testSink doesn't want // it as *testing.T automatically does it. - fieldsLines := lines[1:] - for i, line := range fieldsLines { - if line == "" { - continue + for ; sc.Scan(); i++ { + if i > 0 && len(sc.Bytes()) > 0 { + buf2.Write([]byte(" ")) } - fieldsLines[i] = strings.Repeat(" ", 2) + line + buf2.Write(sc.Bytes()) + buf2.WriteByte('\n') } - str = strings.Join(lines, "\n") - - s.w.Write("sloghuman", []byte(str+"\n")) + s.w.Write("sloghuman", buf2.Bytes()) } func (s humanSink) Sync() { diff --git a/sloggers/sloghuman/sloghuman_test.go b/sloggers/sloghuman/sloghuman_test.go index 86fe888..9047161 100644 --- a/sloggers/sloghuman/sloghuman_test.go +++ b/sloggers/sloghuman/sloghuman_test.go @@ -3,6 +3,8 @@ package sloghuman_test import ( "bytes" "context" + "fmt" + "os" "testing" "cdr.dev/slog" @@ -11,17 +13,33 @@ import ( "cdr.dev/slog/sloggers/sloghuman" ) +var bg = context.Background() + func TestMake(t *testing.T) { t.Parallel() b := &bytes.Buffer{} - ctx := context.Background() - ctx = sloghuman.Make(ctx, b) - slog.Info(ctx, "line1\n\nline2", slog.F("wowow", "me\nyou")) - slog.Sync(ctx) + l := slog.Make(sloghuman.Sink(b)) + l.Info(bg, "line1\n\nline2", slog.F("wowow", "me\nyou")) + l.Sync() et, rest, err := entryhuman.StripTimestamp(b.String()) assert.Success(t, "strip timestamp", err) assert.False(t, "timestamp", et.IsZero()) - assert.Equal(t, "entry", " [INFO]\t\t...\t{\"wowow\": \"me\\nyou\"}\n \"msg\": line1\n\n line2\n", rest) + assert.Equal(t, "entry", " [info] ... wowow=\"me\\nyou\"\n msg= line1\n\n line2\n", rest) +} + +func TestVisual(t *testing.T) { + t.Setenv("FORCE_COLOR", "true") + if os.Getenv("TEST_VISUAL") == "" { + t.Skip("TEST_VISUAL not set") + } + + l := slog.Make(sloghuman.Sink(os.Stdout)).Leveled(slog.LevelDebug) + l.Debug(bg, "small potatos", slog.F("aaa", "mmm"), slog.F("bbb", "nnn"), slog.F("age", 24)) + l.Info(bg, "line1\n\nline2", slog.F("wowow", "me\nyou")) + l.Warn(bg, "oops", slog.F("aaa", "mmm")) + l = l.Named("sublogger") + l.Error(bg, "big oops", slog.F("aaa", "mmm"), slog.Error(fmt.Errorf("this happened\nand this"))) + l.Sync() } diff --git a/sloggers/slogjson/slogjson.go b/sloggers/slogjson/slogjson.go index 06c0446..d38ee89 100644 --- a/sloggers/slogjson/slogjson.go +++ b/sloggers/slogjson/slogjson.go @@ -2,19 +2,19 @@ // // Format // -// { -// "ts": "2019-09-10T20:19:07.159852-05:00", -// "level": "INFO", -// "logger_names": ["comp", "subcomp"], -// "msg": "hi", -// "caller": "slog/examples_test.go:62", -// "func": "cdr.dev/slog/sloggers/slogtest_test.TestExampleTest", -// "trace": "", -// "span": "", -// "fields": { -// "my_field": "field value" -// } -// } +// { +// "ts": "2019-09-10T20:19:07.159852-05:00", +// "level": "INFO", +// "logger_names": ["comp", "subcomp"], +// "msg": "hi", +// "caller": "slog/examples_test.go:62", +// "func": "cdr.dev/slog/sloggers/slogtest_test.TestExampleTest", +// "trace": "", +// "span": "", +// "fields": { +// "my_field": "field value" +// } +// } package slogjson // import "cdr.dev/slog/sloggers/slogjson" import ( @@ -23,28 +23,26 @@ import ( "fmt" "io" - "go.opencensus.io/trace" - "cdr.dev/slog" "cdr.dev/slog/internal/syncwriter" ) -// Make creates a logger that writes JSON logs +// Sink creates a slog.Sink that writes JSON logs // to the given writer. See package level docs // for the format. // If the writer implements Sync() error then // it will be called when syncing. -func Make(ctx context.Context, w io.Writer) context.Context { - return slog.Make(ctx, jsonSink{ +func Sink(w io.Writer) slog.Sink { + return jsonSink{ w: syncwriter.New(w), - }) + } } type jsonSink struct { w *syncwriter.Writer } -func (s jsonSink) LogEntry(_ context.Context, ent slog.SinkEntry) { +func (s jsonSink) LogEntry(ctx context.Context, ent slog.SinkEntry) { m := slog.M( slog.F("ts", ent.Time), slog.F("level", ent.Level), @@ -57,10 +55,10 @@ func (s jsonSink) LogEntry(_ context.Context, ent slog.SinkEntry) { m = append(m, slog.F("logger_names", ent.LoggerNames)) } - if ent.SpanContext != (trace.SpanContext{}) { + if ent.SpanContext.IsValid() { m = append(m, - slog.F("trace", ent.SpanContext.TraceID), - slog.F("span", ent.SpanContext.SpanID), + slog.F("trace", ent.SpanContext.TraceID()), + slog.F("span", ent.SpanContext.SpanID()), ) } diff --git a/sloggers/slogjson/slogjson_test.go b/sloggers/slogjson/slogjson_test.go index 103be7a..79a46e7 100644 --- a/sloggers/slogjson/slogjson_test.go +++ b/sloggers/slogjson/slogjson_test.go @@ -7,7 +7,7 @@ import ( "runtime" "testing" - "go.opencensus.io/trace" + sdktrace "go.opentelemetry.io/otel/sdk/trace" "cdr.dev/slog" "cdr.dev/slog/internal/assert" @@ -22,14 +22,18 @@ var bg = context.Background() func TestMake(t *testing.T) { t.Parallel() - ctx, s := trace.StartSpan(bg, "meow") + tp := sdktrace.NewTracerProvider() + tracer := tp.Tracer("tracer") + ctx, span := tracer.Start(bg, "trace") + span.End() + _ = tp.Shutdown(bg) b := &bytes.Buffer{} - ctx = slogjson.Make(ctx, b) - ctx = slog.Named(ctx, "named") - slog.Error(ctx, "line1\n\nline2", slog.F("wowow", "me\nyou")) + l := slog.Make(slogjson.Sink(b)) + l = l.Named("named") + l.Error(ctx, "line1\n\nline2", slog.F("wowow", "me\nyou")) j := entryjson.Filter(b.String(), "ts") - exp := fmt.Sprintf(`{"level":"ERROR","msg":"line1\n\nline2","caller":"%v:29","func":"cdr.dev/slog/sloggers/slogjson_test.TestMake","logger_names":["named"],"trace":"%v","span":"%v","fields":{"wowow":"me\nyou"}} -`, slogjsonTestFile, s.SpanContext().TraceID, s.SpanContext().SpanID) + exp := fmt.Sprintf(`{"level":"ERROR","msg":"line1\n\nline2","caller":"%v:33","func":"cdr.dev/slog/sloggers/slogjson_test.TestMake","logger_names":["named"],"trace":"%v","span":"%v","fields":{"wowow":"me\nyou"}} +`, slogjsonTestFile, span.SpanContext().TraceID().String(), span.SpanContext().SpanID().String()) assert.Equal(t, "entry", exp, j) } diff --git a/sloggers/slogstackdriver/slogstackdriver.go b/sloggers/slogstackdriver/slogstackdriver.go index bac1294..9772ed4 100644 --- a/sloggers/slogstackdriver/slogstackdriver.go +++ b/sloggers/slogstackdriver/slogstackdriver.go @@ -6,28 +6,42 @@ import ( "encoding/json" "fmt" "io" + "net/http" "strings" + "time" "cloud.google.com/go/compute/metadata" - "go.opencensus.io/trace" + "cloud.google.com/go/logging/apiv2/loggingpb" + "go.opentelemetry.io/otel/trace" logpbtype "google.golang.org/genproto/googleapis/logging/type" - logpb "google.golang.org/genproto/googleapis/logging/v2" "cdr.dev/slog" "cdr.dev/slog/internal/syncwriter" ) -// Make creates a slog.logger configured to write JSON logs +// Sink creates a slog.Sink configured to write JSON logs // to stdout for stackdriver. // // See https://cloud.google.com/logging/docs/agent -func Make(ctx context.Context, w io.Writer) slog.SinkContext { - projectID, _ := metadata.ProjectID() +func Sink(w io.Writer) slog.Sink { + // When not running in Google Cloud, the default metadata client will + // leak a goroutine. + // + // We use a very short timeout because the metadata server should be + // within the same datacenter as the cloud instance. + tp := http.DefaultTransport.(*http.Transport).Clone() + httpClient := &http.Client{ + Timeout: time.Second * 3, + Transport: tp, + } + client := metadata.NewClient(httpClient) + projectID, _ := client.ProjectID() + httpClient.CloseIdleConnections() - return slog.Make(ctx, stackdriverSink{ + return stackdriverSink{ projectID: projectID, w: syncwriter.New(w), - }) + } } type stackdriverSink struct { @@ -35,13 +49,19 @@ type stackdriverSink struct { w *syncwriter.Writer } -func (s stackdriverSink) LogEntry(_ context.Context, ent slog.SinkEntry) { +func (s stackdriverSink) LogEntry(ctx context.Context, ent slog.SinkEntry) { + // Note that these documents are inconsistent, so we only use the special + // keys described by both. // https://cloud.google.com/logging/docs/agent/configuration#special-fields + // https://cloud.google.com/stackdriver/docs/solutions/agents/ops-agent/configuration#special-fields e := slog.M( + slog.F("logging.googleapis.com/severity", sev(ent.Level)), slog.F("severity", sev(ent.Level)), slog.F("message", ent.Message), - slog.F("timestamp", ent.Time), - slog.F("logging.googleapis.com/sourceLocation", &logpb.LogEntrySourceLocation{ + // Unfortunately, both of these fields are required. + slog.F("timestampSeconds", ent.Time.Unix()), + slog.F("timestampNanos", ent.Time.UnixNano()%1e9), + slog.F("logging.googleapis.com/sourceLocation", &loggingpb.LogEntrySourceLocation{ File: ent.File, Line: int64(ent.Line), Function: ent.Func, @@ -49,15 +69,15 @@ func (s stackdriverSink) LogEntry(_ context.Context, ent slog.SinkEntry) { ) if len(ent.LoggerNames) > 0 { - e = append(e, slog.F("logging.googleapis.com/operation", &logpb.LogEntryOperation{ + e = append(e, slog.F("logging.googleapis.com/operation", &loggingpb.LogEntryOperation{ Producer: strings.Join(ent.LoggerNames, "."), })) } - if ent.SpanContext != (trace.SpanContext{}) { + if ent.SpanContext.IsValid() { e = append(e, - slog.F("logging.googleapis.com/trace", s.traceField(ent.SpanContext.TraceID)), - slog.F("logging.googleapis.com/spanId", ent.SpanContext.SpanID.String()), + slog.F("logging.googleapis.com/trace", s.traceField(ent.SpanContext.TraceID())), + slog.F("logging.googleapis.com/spanId", ent.SpanContext.SpanID().String()), slog.F("logging.googleapis.com/trace_sampled", ent.SpanContext.IsSampled()), ) } diff --git a/sloggers/slogstackdriver/slogstackdriver_test.go b/sloggers/slogstackdriver/slogstackdriver_test.go index 025ad9d..7a79985 100644 --- a/sloggers/slogstackdriver/slogstackdriver_test.go +++ b/sloggers/slogstackdriver/slogstackdriver_test.go @@ -4,10 +4,15 @@ import ( "bytes" "context" "fmt" + "net/http" "runtime" "testing" + "time" - "go.opencensus.io/trace" + "go.uber.org/goleak" + + "cloud.google.com/go/compute/metadata" + sdktrace "go.opentelemetry.io/otel/sdk/trace" logpbtype "google.golang.org/genproto/googleapis/logging/type" "cdr.dev/slog" @@ -16,21 +21,30 @@ import ( "cdr.dev/slog/sloggers/slogstackdriver" ) -var bg = context.Background() -var _, slogstackdriverTestFile, _, _ = runtime.Caller(0) +var ( + bg = context.Background() + _, slogstackdriverTestFile, _, _ = runtime.Caller(0) +) func TestStackdriver(t *testing.T) { t.Parallel() - ctx, s := trace.StartSpan(bg, "meow") + tp := sdktrace.NewTracerProvider() + tracer := tp.Tracer("tracer") + ctx, span := tracer.Start(bg, "trace") + span.End() + _ = tp.Shutdown(bg) b := &bytes.Buffer{} - ctx = slogstackdriver.Make(ctx, b) - ctx = slog.Named(ctx, "meow") - slog.Error(ctx, "line1\n\nline2", slog.F("wowow", "me\nyou")) + l := slog.Make(slogstackdriver.Sink(b)) + l = l.Named("meow") + l.Error(ctx, "line1\n\nline2", slog.F("wowow", "me\nyou")) - j := entryjson.Filter(b.String(), "timestamp") - exp := fmt.Sprintf(`{"severity":"ERROR","message":"line1\n\nline2","logging.googleapis.com/sourceLocation":{"file":"%v","line":29,"function":"cdr.dev/slog/sloggers/slogstackdriver_test.TestStackdriver"},"logging.googleapis.com/operation":{"producer":"meow"},"logging.googleapis.com/trace":"projects//traces/%v","logging.googleapis.com/spanId":"%v","logging.googleapis.com/trace_sampled":false,"wowow":"me\nyou"} -`, slogstackdriverTestFile, s.SpanContext().TraceID, s.SpanContext().SpanID) + projectID, _ := metadataClient(t).ProjectID() + + j := entryjson.Filter(b.String(), "timestampSeconds") + j = entryjson.Filter(j, "timestampNanos") + exp := fmt.Sprintf(`{"logging.googleapis.com/severity":"ERROR","severity":"ERROR","message":"line1\n\nline2","logging.googleapis.com/sourceLocation":{"file":"%v","line":40,"function":"cdr.dev/slog/sloggers/slogstackdriver_test.TestStackdriver"},"logging.googleapis.com/operation":{"producer":"meow"},"logging.googleapis.com/trace":"projects/%v/traces/%v","logging.googleapis.com/spanId":"%v","logging.googleapis.com/trace_sampled":%v,"wowow":"me\nyou"} +`, slogstackdriverTestFile, projectID, span.SpanContext().TraceID(), span.SpanContext().SpanID(), span.SpanContext().IsSampled()) assert.Equal(t, "entry", exp, j) } @@ -43,3 +57,23 @@ func TestSevMapping(t *testing.T) { assert.Equal(t, "level", logpbtype.LogSeverity_ERROR, slogstackdriver.Sev(slog.LevelError)) assert.Equal(t, "level", logpbtype.LogSeverity_CRITICAL, slogstackdriver.Sev(slog.LevelCritical)) } + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func metadataClient(t testing.TB) *metadata.Client { + // When not running in Google Cloud, the default metadata client will + // leak a goroutine. + // + // We use a very short timeout because the metadata server should be + // within the same datacenter as the cloud instance. + tp := http.DefaultTransport.(*http.Transport).Clone() + httpClient := &http.Client{ + Timeout: time.Second * 3, + Transport: tp, + } + client := metadata.NewClient(httpClient) + t.Cleanup(httpClient.CloseIdleConnections) + return client +} diff --git a/sloggers/slogtest/assert/assert.go b/sloggers/slogtest/assert/assert.go index 7aafdd4..1e3c456 100644 --- a/sloggers/slogtest/assert/assert.go +++ b/sloggers/slogtest/assert/assert.go @@ -39,7 +39,7 @@ func Success(t testing.TB, name string, err error) { if err != nil { slogtest.Fatal(t, "unexpected error", slog.F("name", name), - slog.Err(err), + slog.Error(err), ) } } @@ -50,6 +50,12 @@ func True(t testing.TB, name string, act bool) { Equal(t, name, true, act) } +// False asserts act == false. +func False(t testing.TB, name string, act bool) { + slog.Helper() + Equal(t, name, false, act) +} + // Error asserts err != nil. func Error(t testing.TB, name string, err error) { slog.Helper() @@ -83,5 +89,4 @@ func stringContainsFold(errs, sub string) bool { sub = strings.ToLower(sub) return strings.Contains(errs, sub) - } diff --git a/sloggers/slogtest/assert/assert_test.go b/sloggers/slogtest/assert/assert_test.go index 82d9a5d..b483cf3 100644 --- a/sloggers/slogtest/assert/assert_test.go +++ b/sloggers/slogtest/assert/assert_test.go @@ -44,11 +44,10 @@ func TestErrorContains(t *testing.T) { defer func() { recover() simpleassert.Equal(t, "fatals", 1, tb.fatals) - }() assert.ErrorContains(tb, "meow", io.ErrClosedPipe, "eof") - } + func TestSuccess(t *testing.T) { t.Parallel() @@ -75,6 +74,19 @@ func TestTrue(t *testing.T) { assert.True(tb, "meow", false) } +func TestFalse(t *testing.T) { + t.Parallel() + + tb := &fakeTB{} + assert.False(tb, "woof", false) + + defer func() { + recover() + simpleassert.Equal(t, "fatals", 1, tb.fatals) + }() + assert.False(tb, "woof", true) +} + func TestError(t *testing.T) { t.Parallel() diff --git a/sloggers/slogtest/t.go b/sloggers/slogtest/t.go index ffb972a..b1fbc86 100644 --- a/sloggers/slogtest/t.go +++ b/sloggers/slogtest/t.go @@ -7,10 +7,15 @@ package slogtest // import "cdr.dev/slog/sloggers/slogtest" import ( "context" + "fmt" "log" "os" + "strings" + "sync" "testing" + "golang.org/x/xerrors" + "cdr.dev/slog" "cdr.dev/slog/internal/entryhuman" "cdr.dev/slog/sloggers/sloghuman" @@ -18,8 +23,8 @@ import ( // Ensure all stdlib logs go through slog. func init() { - ctx := sloghuman.Make(ctx, os.Stderr) - log.SetOutput(slog.Stdlib(ctx).Writer()) + l := slog.Make(sloghuman.Sink(os.Stderr)) + log.SetOutput(slog.Stdlib(context.Background(), l, slog.LevelInfo).Writer()) } // Options represents the options for the logger returned @@ -28,74 +33,151 @@ type Options struct { // IgnoreErrors causes the test logger to not fatal the test // on Fatal and not error the test on Error or Critical. IgnoreErrors bool + // SkipCleanup skips adding a t.Cleanup call that prevents the logger from + // logging after a test has exited. This is necessary because race + // conditions exist when t.Log is called concurrently of a test exiting. Set + // to true if you don't need this behavior. + SkipCleanup bool + // IgnoredErrorIs causes the test logger not to error the test on Error + // if the SinkEntry contains one of the listed errors in its "error" Field. + // Errors are matched using xerrors.Is(). + // + // By default, context.Canceled and context.DeadlineExceeded are included, + // as these are nearly always benign in testing. Override to []error{} (zero + // length error slice) to disable the whitelist entirely. + IgnoredErrorIs []error + // IgnoreErrorFn, if non-nil, defines a function that should return true if + // the given SinkEntry should not error the test on Error or Critical. The + // result of this function is logically ORed with ignore directives defined + // by IgnoreErrors and IgnoredErrorIs. To depend exclusively on + // IgnoreErrorFn, set IgnoreErrors=false and IgnoredErrorIs=[]error{} (zero + // length error slice). + IgnoreErrorFn func(slog.SinkEntry) bool } -// Make creates a logger that writes logs to tb in a human readable format. -func Make(tb testing.TB, opts *Options) slog.SinkContext { +var DefaultIgnoredErrorIs = []error{context.Canceled, context.DeadlineExceeded} + +// Make creates a Logger that writes logs to tb in a human-readable format. +func Make(tb testing.TB, opts *Options) slog.Logger { if opts == nil { opts = &Options{} } - return slog.Make(context.Background(), testSink{ + if opts.IgnoredErrorIs == nil { + opts.IgnoredErrorIs = DefaultIgnoredErrorIs + } + + sink := &testSink{ tb: tb, opts: opts, - }) + } + if !opts.SkipCleanup { + tb.Cleanup(func() { + sink.mu.Lock() + defer sink.mu.Unlock() + sink.testDone = true + }) + } + + return slog.Make(sink) } type testSink struct { - tb testing.TB - opts *Options - stdlib bool + tb testing.TB + opts *Options + mu sync.RWMutex + testDone bool } -func (ts testSink) LogEntry(ctx context.Context, ent slog.SinkEntry) { +func (ts *testSink) LogEntry(_ context.Context, ent slog.SinkEntry) { + ts.mu.RLock() + defer ts.mu.RUnlock() + + // Don't log after the test this sink was created in has finished. + if ts.testDone { + return + } + + var sb strings.Builder // The testing package logs to stdout and not stderr. - s := entryhuman.Fmt(os.Stdout, ent) + entryhuman.Fmt(&sb, os.Stdout, ent) switch ent.Level { case slog.LevelDebug, slog.LevelInfo, slog.LevelWarn: - ts.tb.Log(s) + ts.tb.Log(sb.String()) case slog.LevelError, slog.LevelCritical: - if ts.opts.IgnoreErrors { - ts.tb.Log(s) + if ts.shouldIgnoreError(ent) { + ts.tb.Log(sb.String()) } else { - ts.tb.Error(s) + sb.WriteString(fmt.Sprintf( + "\n *** slogtest: log detected at level %s; TEST FAILURE ***", + ent.Level, + )) + ts.tb.Error(sb.String()) } case slog.LevelFatal: - if ts.opts.IgnoreErrors { - panic("slogtest: cannot fatal in tests when IgnoreErrors option is set") + sb.WriteString("\n *** slogtest: FATAL log detected; TEST FAILURE ***") + ts.tb.Fatal(sb.String()) + } +} + +func (ts *testSink) shouldIgnoreError(ent slog.SinkEntry) bool { + if ts.opts.IgnoreErrors { + return true + } + if err, ok := FindFirstError(ent); ok { + for _, ig := range ts.opts.IgnoredErrorIs { + if xerrors.Is(err, ig) { + return true + } } - ts.tb.Fatal(s) } + if ts.opts.IgnoreErrorFn != nil { + return ts.opts.IgnoreErrorFn(ent) + } + return false } -func (ts testSink) Sync() {} +func (ts *testSink) Sync() {} var ctx = context.Background() -func l(t testing.TB) context.Context { - return Make(t, nil) +func l(t testing.TB) slog.Logger { + return Make(t, &Options{SkipCleanup: true}) } // Debug logs the given msg and fields to t via t.Log at the debug level. -func Debug(t testing.TB, msg string, fields ...slog.Field) { +func Debug(t testing.TB, msg string, fields ...any) { slog.Helper() - slog.Debug(l(t), msg, fields...) + l(t).Debug(ctx, msg, fields...) } // Info logs the given msg and fields to t via t.Log at the info level. -func Info(t testing.TB, msg string, fields ...slog.Field) { +func Info(t testing.TB, msg string, fields ...any) { slog.Helper() - slog.Info(l(t), msg, fields...) + l(t).Info(ctx, msg, fields...) } // Error logs the given msg and fields to t via t.Error at the error level. -func Error(t testing.TB, msg string, fields ...slog.Field) { +func Error(t testing.TB, msg string, fields ...any) { slog.Helper() - slog.Error(l(t), msg, fields...) + l(t).Error(ctx, msg, fields...) } // Fatal logs the given msg and fields to t via t.Fatal at the fatal level. -func Fatal(t testing.TB, msg string, fields ...slog.Field) { +func Fatal(t testing.TB, msg string, fields ...any) { slog.Helper() - slog.Fatal(l(t), msg, fields...) + l(t).Fatal(ctx, msg, fields...) +} + +// FindFirstError finds the first slog.Field named "error" that contains an +// error value. +func FindFirstError(ent slog.SinkEntry) (err error, ok bool) { + for _, f := range ent.Fields { + if f.Name == "error" { + if err, ok = f.Value.(error); ok { + return err, true + } + } + } + return nil, false } diff --git a/sloggers/slogtest/t_test.go b/sloggers/slogtest/t_test.go index 77942a5..2aa09d5 100644 --- a/sloggers/slogtest/t_test.go +++ b/sloggers/slogtest/t_test.go @@ -2,8 +2,11 @@ package slogtest_test import ( "context" + "fmt" "testing" + "golang.org/x/xerrors" + "cdr.dev/slog" "cdr.dev/slog/internal/assert" "cdr.dev/slog/sloggers/slogtest" @@ -16,6 +19,12 @@ func TestStateless(t *testing.T) { slogtest.Debug(tb, "hello") slogtest.Info(tb, "hello") + slogtest.Error(tb, "canceled", slog.Error(xerrors.Errorf("test %w:", context.Canceled))) + assert.Equal(t, "errors", 0, tb.errors) + + slogtest.Error(tb, "deadline", slog.Error(xerrors.Errorf("test %w:", context.DeadlineExceeded))) + assert.Equal(t, "errors", 0, tb.errors) + slogtest.Error(tb, "hello") assert.Equal(t, "errors", 1, tb.errors) @@ -31,32 +40,157 @@ func TestIgnoreErrors(t *testing.T) { t.Parallel() tb := &fakeTB{} - ctx := context.Background() - ctx = slog.Make(ctx, slogtest.Make(tb, &slogtest.Options{ + l := slogtest.Make(tb, &slogtest.Options{ IgnoreErrors: true, - })) + }) + + l.Error(bg, "hello") + assert.Equal(t, "errors", 0, tb.errors) + + defer func() { + recover() + assert.Equal(t, "fatals", 1, tb.fatals) + }() + + l.Fatal(bg, "hello") +} + +func TestIgnoreErrorIs_Default(t *testing.T) { + t.Parallel() + + tb := &fakeTB{} + l := slogtest.Make(tb, nil) + + l.Error(bg, "canceled", slog.Error(xerrors.Errorf("test %w:", context.Canceled))) + assert.Equal(t, "errors", 0, tb.errors) - slog.Error(ctx, "hello") + l.Error(bg, "deadline", slog.Error(xerrors.Errorf("test %w:", context.DeadlineExceeded))) assert.Equal(t, "errors", 0, tb.errors) + l.Error(bg, "new", slog.Error(xerrors.New("test"))) + assert.Equal(t, "errors", 1, tb.errors) + defer func() { recover() - assert.Equal(t, "fatals", 0, tb.fatals) + assert.Equal(t, "fatals", 1, tb.fatals) }() - slog.Fatal(ctx, "hello") + l.Fatal(bg, "hello", slog.Error(xerrors.Errorf("fatal %w:", context.Canceled))) } +func TestIgnoreErrorIs_Explicit(t *testing.T) { + t.Parallel() + + tb := &fakeTB{} + ignored := xerrors.New("ignored") + notIgnored := xerrors.New("not ignored") + l := slogtest.Make(tb, &slogtest.Options{IgnoredErrorIs: []error{ignored}}) + + l.Error(bg, "ignored", slog.Error(xerrors.Errorf("test %w:", ignored))) + assert.Equal(t, "errors", 0, tb.errors) + + l.Error(bg, "not ignored", slog.Error(xerrors.Errorf("test %w:", notIgnored))) + assert.Equal(t, "errors", 1, tb.errors) + + l.Error(bg, "canceled", slog.Error(xerrors.Errorf("test %w:", context.Canceled))) + assert.Equal(t, "errors", 2, tb.errors) + + l.Error(bg, "deadline", slog.Error(xerrors.Errorf("test %w:", context.DeadlineExceeded))) + assert.Equal(t, "errors", 3, tb.errors) + + l.Error(bg, "new", slog.Error(xerrors.New("test"))) + assert.Equal(t, "errors", 4, tb.errors) + + defer func() { + recover() + assert.Equal(t, "fatals", 1, tb.fatals) + }() + + l.Fatal(bg, "hello", slog.Error(xerrors.Errorf("test %w:", ignored))) +} + +func TestIgnoreErrorFn(t *testing.T) { + t.Parallel() + + tb := &fakeTB{} + ignored := testCodedError{code: 777} + notIgnored := testCodedError{code: 911} + l := slogtest.Make(tb, &slogtest.Options{IgnoreErrorFn: func(ent slog.SinkEntry) bool { + err, ok := slogtest.FindFirstError(ent) + if !ok { + t.Error("did not contain an error") + return false + } + ce := testCodedError{} + if !xerrors.As(err, &ce) { + return false + } + return ce.code != 911 + }}) + + l.Error(bg, "ignored", slog.Error(xerrors.Errorf("test %w:", ignored))) + assert.Equal(t, "errors", 0, tb.errors) + + l.Error(bg, "not ignored", slog.Error(xerrors.Errorf("test %w:", notIgnored))) + assert.Equal(t, "errors", 1, tb.errors) + + // still ignored by default for IgnoredErrorIs + l.Error(bg, "canceled", slog.Error(xerrors.Errorf("test %w:", context.Canceled))) + assert.Equal(t, "errors", 1, tb.errors) + + l.Error(bg, "new", slog.Error(xerrors.New("test"))) + assert.Equal(t, "errors", 2, tb.errors) + + defer func() { + recover() + assert.Equal(t, "fatals", 1, tb.fatals) + }() + + l.Fatal(bg, "hello", slog.Error(xerrors.Errorf("test %w:", ignored))) +} + +func TestCleanup(t *testing.T) { + t.Parallel() + + tb := &fakeTB{} + l := slogtest.Make(tb, &slogtest.Options{}) + + for _, fn := range tb.cleanups { + fn() + } + + // This should not log since the logger was cleaned up. + l.Info(bg, "hello") + assert.Equal(t, "no logs", 0, tb.logs) +} + +func TestSkipCleanup(t *testing.T) { + t.Parallel() + + tb := &fakeTB{} + slogtest.Make(tb, &slogtest.Options{ + SkipCleanup: true, + }) + + assert.Len(t, "no cleanups", 0, tb.cleanups) +} + +var bg = context.Background() + type fakeTB struct { testing.TB - errors int - fatals int + logs int + errors int + fatals int + cleanups []func() } func (tb *fakeTB) Helper() {} -func (tb *fakeTB) Log(v ...interface{}) {} +func (tb *fakeTB) Log(v ...interface{}) { + tb.logs++ +} func (tb *fakeTB) Error(v ...interface{}) { tb.errors++ @@ -66,3 +200,15 @@ func (tb *fakeTB) Fatal(v ...interface{}) { tb.fatals++ panic("") } + +func (tb *fakeTB) Cleanup(fn func()) { + tb.cleanups = append(tb.cleanups, fn) +} + +type testCodedError struct { + code int +} + +func (e testCodedError) Error() string { + return fmt.Sprintf("code: %d", e.code) +}