From ca026a4bdc84e65e650bb3fd79a10414e4a593da Mon Sep 17 00:00:00 2001 From: Unknwon Date: Tue, 5 Nov 2019 19:26:05 -0800 Subject: [PATCH] V2 (#10) * LICENSE: change to MIT * ci: only build Go 1.11 or above * v2: basic logic and tests * v2: add console mode * v2: remove custom errors * v2: add slack mode * v2: add discord mode * v2: add file mode * v2: enable Go Modules * v2: make channel loop as a manager method * v2: add test for file mode * README: update for v2 * Update README.md * Update .travis.yml * Update .travis.yml * Update README.md * v2: add more tests to slack and discord loggers * Update codecov.yml * Rename codecov.yml to .codecov.yml * clog: use chanLogger to replace bufLogger * clog_test: remove debug lines * Fix tests * logger: print error message when no logger is available * v2: improve comments and golint * Update README.md * v2: fix module path --- .codecov.yml | 2 + .gitignore | 2 +- .travis.yml | 17 +-- LICENSE | 222 +++---------------------------- Makefile | 11 +- README.md | 71 +++++----- clog.go | 176 +++++++++---------------- clog_test.go | 246 +++++++++++++++------------------- console.go | 106 ++++----------- console_test.go | 66 +++++----- discord.go | 200 +++++++++++++--------------- discord_test.go | 341 ++++++++++++++++++++++++++++++++++++++++-------- error.go | 32 ----- file.go | 294 +++++++++++++++++++---------------------- file_test.go | 105 ++++++++------- go.mod | 10 ++ go.sum | 21 +++ logger.go | 260 ++++++++++++++++++++++-------------- logger_test.go | 193 ++++++++++++++++++++++----- message.go | 56 ++++++++ message_test.go | 115 ++++++++++++++++ slack.go | 154 +++++++++------------- slack_test.go | 263 +++++++++++++++++++++++++++++++------ 23 files changed, 1665 insertions(+), 1298 deletions(-) create mode 100644 .codecov.yml delete mode 100644 error.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 message.go create mode 100644 message_test.go diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..285498b --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,2 @@ +comment: + layout: 'diff, files' diff --git a/.gitignore b/.gitignore index d8a7d72..dc1d402 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ /.idea -/test +/.vscode diff --git a/.travis.yml b/.travis.yml index e6a04b2..d740459 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,18 +1,19 @@ sudo: false language: go go: - - 1.6.x - - 1.7.x - - 1.8.x - - 1.9.x - - 1.10.x - 1.11.x - 1.12.x - - master + - 1.13.x +go_import_path: unknwon.dev/clog/v2 + +env: + - GO111MODULE=on install: - go get -t -v ./... script: - - go get golang.org/x/tools/cmd/cover - - go test -v -cover -race \ No newline at end of file + - go test -v -race -coverprofile=coverage.txt -covermode=atomic + +after_success: + - bash <(curl -s https://codecov.io/bash) diff --git a/LICENSE b/LICENSE index 8dada3e..9a884c8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,21 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {yyyy} {name of copyright owner} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +MIT License + +Copyright (c) 2019 Unknwon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index 0c1392f..3005ff6 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,17 @@ -.PHONY: build test vet +.PHONY: build test vet coverage build: vet go install -v test: - mkdir -p test - go test -v -cover -race -coverprofile=test/coverage.out + go test -v -cover -race -coverprofile=coverage.out vet: go vet coverage: - go tool cover -html=test/coverage.out \ No newline at end of file + go tool cover -html=coverage.out + +clean: + go clean + rm -f coverage.out diff --git a/README.md b/README.md index 9025d59..6d31bb4 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,26 @@ -# Clog [![Build Status](https://travis-ci.org/go-clog/clog.svg?branch=master)](https://travis-ci.org/go-clog/clog) [![GoDoc](https://godoc.org/gopkg.in/clog.v1?status.svg)](https://godoc.org/gopkg.in/clog.v1) [![Sourcegraph](https://sourcegraph.com/github.com/go-clog/clog/-/badge.svg)](https://sourcegraph.com/github.com/go-clog/clog?badge) +# Clog + +[![Build Status](https://img.shields.io/travis/go-clog/clog/master.svg?style=for-the-badge&logo=travis)](https://travis-ci.org/go-clog/clog) [![codecov](https://img.shields.io/codecov/c/github/go-clog/clog/master?logo=codecov&style=for-the-badge)](https://codecov.io/gh/go-clog/clog) [![GoDoc](https://img.shields.io/badge/GoDoc-Reference-blue?style=for-the-badge&logo=go)](https://godoc.org/unknwon.dev/clog/v2) [![Sourcegraph](https://img.shields.io/badge/view%20on-Sourcegraph-brightgreen.svg?style=for-the-badge&logo=sourcegraph)](https://sourcegraph.com/github.com/go-clog/clog) ![](https://avatars1.githubusercontent.com/u/25576866?v=3&s=200) -Clog is a channel-based logging package for Go. +Package clog is a channel-based logging package for Go. -This package supports multiple logger adapters across different levels of logging. It uses Go's native channel feature to provide goroutine-safe mechanism on large concurrency. +This package supports multiple loggers across different levels of logging. It uses Go's native channel feature to provide goroutine-safe mechanism on large concurrency. ## Installation To use a tagged revision: - go get gopkg.in/clog.v1 - + go get unknwon.dev/clog/v2 + Please apply `-u` flag to update in the future. ### Testing If you want to test on your machine, please apply `-t` flag: - go get -t gopkg.in/clog.v1 + go get -t unknwon.dev/clog/v2 Please apply `-u` flag to update in the future. @@ -35,11 +37,12 @@ import ( "fmt" "os" - log "gopkg.in/clog.v1" + log "unknwon.dev/clog/v2" ) func init() { - err := log.New(log.CONSOLE, log.ConsoleConfig{}) + // 0 means logging synchronously + err := log.New(log.ModeConsole, 0, log.ConsoleConfig{}) if err != nil { fmt.Printf("Fail to create new logger: %v\n", err) os.Exit(1) @@ -51,6 +54,9 @@ func init() { log.Info("Hello %s!", "Clog") log.Warn("Hello %s!", "Clog") ... + + // Graceful stopping all loggers before exiting the program. + log.Stop() } ... @@ -60,9 +66,8 @@ The above code is equivalent to the follow settings: ```go ... - err := log.New(log.CONSOLE, log.ConsoleConfig{ - Level: log.TRACE, // Record all logs - BufferSize: 0, // 0 means logging synchronously + err := log.New(log.ModeConsole, 0, log.ConsoleConfig{ + Level: log.LevelTrace, // Record all logs }) ... ``` @@ -71,11 +76,11 @@ In production, you may want to make log less verbose and asynchronous: ```go ... - err := log.New(log.CONSOLE, log.ConsoleConfig{ - // Logs under INFO level (in this case TRACE) will be discarded - Level: log.INFO, - // Number mainly depends on how many logs will be produced by program, 100 is good enough - BufferSize: 100, + // The buffer size mainly depends on how many logs will be produced at the same time, + // 100 is a good default. + err := log.New(log.ModeConsole, 100, log.ConsoleConfig{ + // Logs under Info level (in this case Trace) will be discarded. + Level: log.LevelInfo, }) ... ``` @@ -84,27 +89,24 @@ Console logger comes with color output, but for non-colorable destination, the c ### Error Location -When using `log.Error` and `log.Fatal` functions, the first argument allows you to indicate whether to print the code location or not. +When using `log.Error` and `log.Fatal` functions, the caller location is printed along with the message. ```go ... - // 0 means disable printing code location - log.Error(0, "So bad... %v", err) - - // To print appropriate code location mainly depends on how deep your call stack is, - // you need to try and verify - log.Error(2, "So bad... %v", err) + log.Error("So bad... %v", err) // Output: 2017/02/09 01:06:16 [ERROR] [...uban-builder/main.go:64 main()] ... - log.Fatal(2, "Boom! %v", err) + log.Fatal("Boom! %v", err) // Output: 2017/02/09 01:06:16 [FATAL] [...uban-builder/main.go:64 main()] ... ... ``` Calling `log.Fatal` will exit the program. -### Clean Shutdown +If you want to have different skip depth than the default, you can use `log.ErrorDepth` or `log.FatalDepth`. + +### Clean Exit -If you set `BufferSize` greater than `0`, you should always call `log.Shutdown()` to wait until all messages are processed before program exit. +You should always call `log.Stop()` to wait until all messages are processed before program exits. ## File @@ -112,9 +114,8 @@ File logger is more complex than console, and it has ability to rotate: ```go ... - err := log.New(log.FILE, log.FileConfig{ - Level: log.INFO, - BufferSize: 100, + err := log.New(log.ModeFile, 100, log.FileConfig{ + Level: log.LevelInfo, Filename: "clog.log", FileRotationConfig: log.FileRotationConfig { Rotate: true, @@ -130,9 +131,8 @@ Slack logger is also supported in a simple way: ```go ... - err := log.New(log.SLACK, log.SlackConfig{ - Level: log.INFO, - BufferSize: 100, + err := log.New(log.ModeSlack, 100, log.SlackConfig{ + Level: log.LevelInfo, URL: "https://url-to-slack-webhook", }) ... @@ -146,9 +146,8 @@ Discord logger is supported in rich format via [Embed Object](https://discordapp ```go ... - err := log.New(log.DISCORD, log.DiscordConfig{ - Level: log.INFO, - BufferSize: 100, + err := log.New(log.ModeDiscord, 100, log.DiscordConfig{ + Level: log.LevelInfo, URL: "https://url-to-discord-webhook", }) ... @@ -162,4 +161,4 @@ This logger also retries automatically if hits rate limit after `retry_after`. ## License -This project is under Apache v2 License. See the [LICENSE](LICENSE) file for the full license text. +This project is under MIT License. See the [LICENSE](LICENSE) file for the full license text. diff --git a/clog.go b/clog.go index eb9325c..efce2f9 100644 --- a/clog.go +++ b/clog.go @@ -1,148 +1,92 @@ -// Copyright 2017 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - -// Clog is a channel-based logging package for Go. +// Package clog is a channel-based logging package for Go. package clog import ( "fmt" "os" - "path/filepath" - "runtime" - "strings" ) -const ( - _VERSION = "1.2.0" -) - -// Version returns current version of the package. -func Version() string { - return _VERSION -} +// Mode is the output source. +type Mode string -type ( - MODE string - LEVEL int -) - -const ( - CONSOLE MODE = "console" - FILE MODE = "file" - SLACK MODE = "slack" - DISCORD MODE = "discord" -) +// Level is the logging level. +type Level int +// Available logging levels. const ( - TRACE LEVEL = iota - INFO - WARN - ERROR - FATAL + LevelTrace Level = iota + LevelInfo + LevelWarn + LevelError + LevelFatal ) -var formats = map[LEVEL]string{ - TRACE: "[TRACE] ", - INFO: "[ INFO] ", - WARN: "[ WARN] ", - ERROR: "[ERROR] ", - FATAL: "[FATAL] ", -} - -// isValidLevel returns true if given level is in the valid range. -func isValidLevel(level LEVEL) bool { - return level >= TRACE && level <= FATAL -} - -// Message represents a log message to be processed. -type Message struct { - Level LEVEL - Body string -} - -func Write(level LEVEL, skip int, format string, v ...interface{}) { - msg := &Message{ - Level: level, - } - - // Only error and fatal information needs locate position for debugging. - // But if skip is 0 means caller doesn't care so we can skip. - if msg.Level >= ERROR && skip > 0 { - pc, file, line, ok := runtime.Caller(skip) - if ok { - // Get caller function name. - fn := runtime.FuncForPC(pc) - var fnName string - if fn == nil { - fnName = "?()" - } else { - fnName = strings.TrimLeft(filepath.Ext(fn.Name()), ".") + "()" - } - - if len(file) > 20 { - file = "..." + file[len(file)-20:] - } - msg.Body = formats[level] + fmt.Sprintf("[%s:%d %s] ", file, line, fnName) + fmt.Sprintf(format, v...) - } - } - if len(msg.Body) == 0 { - msg.Body = formats[level] + fmt.Sprintf(format, v...) - } - - for i := range receivers { - if receivers[i].Level() > level { - continue - } - - receivers[i].msgChan <- msg +func (l Level) String() string { + switch l { + case LevelTrace: + return "TRACE" + case LevelInfo: + return "INFO" + case LevelWarn: + return "WARN" + case LevelError: + return "ERROR" + case LevelFatal: + return "FATAL" + default: + fmt.Printf("Unexpected Level value: %v\n", int(l)) + panic("unreachable") } } +// Trace writes formatted log in Trace level. func Trace(format string, v ...interface{}) { - Write(TRACE, 0, format, v...) + mgr.write(LevelTrace, 0, format, v...) } +// Info writes formatted log in Info level. func Info(format string, v ...interface{}) { - Write(INFO, 0, format, v...) + mgr.write(LevelInfo, 0, format, v...) } +// Warn writes formatted log in Warn level. func Warn(format string, v ...interface{}) { - Write(WARN, 0, format, v...) + mgr.write(LevelWarn, 0, format, v...) } -func Error(skip int, format string, v ...interface{}) { - Write(ERROR, skip, format, v...) +// Error writes formatted log in Error level. +func Error(format string, v ...interface{}) { + ErrorDepth(4, format, v...) } -func Fatal(skip int, format string, v ...interface{}) { - Write(FATAL, skip, format, v...) - Shutdown() - os.Exit(1) +// ErrorDepth writes formatted log with given skip depth in Error level. +func ErrorDepth(skip int, format string, v ...interface{}) { + mgr.write(LevelError, skip, format, v...) } -func Shutdown() { - for i := range receivers { - receivers[i].Destroy() - } +// Fatal writes formatted log in Fatal level then exits. +func Fatal(format string, v ...interface{}) { + FatalDepth(4, format, v...) +} - // Shutdown the error handling goroutine. - quitChan <- struct{}{} - for { - if len(errorChan) == 0 { - break - } +// isTestEnv is true when running tests. +// In test environment, Fatal or FatalDepth won't stop the manager or exit the program. +var isTestEnv = false - fmt.Printf("clog: unable to write message: %v\n", <-errorChan) +// FatalDepth writes formatted log with given skip depth in Fatal level then exits. +func FatalDepth(skip int, format string, v ...interface{}) { + mgr.write(LevelFatal, skip, format, v...) + + if isTestEnv { + return } + + Stop() + os.Exit(1) +} + +// Stop propagates cancellation to all loggers and waits for completion. +// This function should always be called before exiting the program. +func Stop() { + mgr.stop() } diff --git a/clog_test.go b/clog_test.go index 94c166e..8c9c3b6 100644 --- a/clog_test.go +++ b/clog_test.go @@ -1,173 +1,135 @@ -// Copyright 2017 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package clog import ( - "bytes" - "sync" + "fmt" "testing" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/assert" ) -func Test_Version(t *testing.T) { - Convey("Get version", t, func() { - So(Version(), ShouldEqual, _VERSION) - }) -} - -func Test_isValidLevel(t *testing.T) { - Convey("Validate log level", t, func() { - So(isValidLevel(LEVEL(-1)), ShouldBeFalse) - So(isValidLevel(LEVEL(5)), ShouldBeFalse) - So(isValidLevel(TRACE), ShouldBeTrue) - So(isValidLevel(FATAL), ShouldBeTrue) - }) +func init() { + isTestEnv = true } -const _MEMORY MODE = "memory" +func TestLevel_String(t *testing.T) { + invalidLevel := Level(-1) + defer func() { + assert.NotNil(t, recover()) + }() -type memoryConfig struct { - // Minimum level of messages to be processed. - Level LEVEL - // Buffer size defines how many messages can be queued before hangs. - BufferSize int64 + _ = invalidLevel.String() } -var ( - buf bytes.Buffer - wg sync.WaitGroup -) - -type memory struct { - Adapter +type chanConfig struct { + c chan string } -func newMemory() Logger { - return &memory{ - Adapter: Adapter{ - quitChan: make(chan struct{}), - }, - } -} - -func (m *memory) Level() LEVEL { return m.level } - -func (m *memory) Init(v interface{}) error { - cfg, ok := v.(memoryConfig) - if !ok { - return ErrConfigObject{"memoryConfig", v} - } - - if !isValidLevel(cfg.Level) { - return ErrInvalidLevel{} - } - m.level = cfg.Level - - m.msgChan = make(chan *Message, cfg.BufferSize) - return nil -} +var _ Logger = (*chanLogger)(nil) -func (m *memory) ExchangeChans(errorChan chan<- error) chan *Message { - m.errorChan = errorChan - return m.msgChan +type chanLogger struct { + c chan string + *noopLogger } -func (m *memory) write(msg *Message) { - buf.WriteString(msg.Body) - wg.Done() +func (l *chanLogger) Write(m Messager) error { + l.c <- m.String() + return nil } -func (m *memory) Start() { -LOOP: - for { - select { - case msg := <-m.msgChan: - m.write(msg) - case <-m.quitChan: - break LOOP +func Test_chanLogger(t *testing.T) { + mode1 := Mode("mode1") + level1 := LevelTrace + NewRegister(mode1, func(v interface{}) (Logger, error) { + cfg, ok := v.(chanConfig) + if !ok { + return nil, fmt.Errorf("invalid config object: want %T got %T", chanConfig{}, v) } - } + return &chanLogger{ + c: cfg.c, + noopLogger: &noopLogger{ + mode: mode1, + level: level1, + }, + }, nil + }) - for { - if len(m.msgChan) == 0 { - break + mode2 := Mode("mode2") + level2 := LevelError + NewRegister(mode2, func(v interface{}) (Logger, error) { + cfg, ok := v.(chanConfig) + if !ok { + return nil, fmt.Errorf("invalid config object: want %T got %T", &chanConfig{}, v) } + return &chanLogger{ + c: cfg.c, + noopLogger: &noopLogger{ + mode: mode2, + level: level2, + }, + }, nil + }) - m.write(<-m.msgChan) + tests := []struct { + name string + fn func(string, ...interface{}) + containsStr1 string + containsStr2 string + }{ + { + name: "trace", + fn: Trace, + containsStr1: "[TRACE] log message", + containsStr2: "", + }, + { + name: "info", + fn: Info, + containsStr1: "[ INFO] log message", + containsStr2: "", + }, + { + name: "warn", + fn: Warn, + containsStr1: "[ WARN] log message", + containsStr2: "", + }, + { + name: "error", + fn: Error, + containsStr1: "()] log message", + containsStr2: "()] log message", + }, + { + name: "fatal", + fn: Fatal, + containsStr1: "()] log message", + containsStr2: "()] log message", + }, } - m.quitChan <- struct{}{} // Notify the cleanup is done. -} -func (m *memory) Destroy() { - m.quitChan <- struct{}{} - <-m.quitChan + c1 := make(chan string) + c2 := make(chan string) - close(m.msgChan) - close(m.quitChan) -} + defer Remove(mode1) + defer Remove(mode2) + assert.Nil(t, New(mode1, 1, chanConfig{ + c: c1, + })) + assert.Nil(t, New(mode2, 1, chanConfig{ + c: c2, + })) -func init() { - Register(_MEMORY, newMemory) -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, 2, mgr.len()) -func Test_Clog(t *testing.T) { - Convey("In-memory logging", t, func() { - So(New(_MEMORY, memoryConfig{}), ShouldBeNil) - - Convey("Basic logging", func() { - buf.Reset() - wg.Add(1) - Trace("Level: %v", TRACE) - wg.Wait() - So(buf.String(), ShouldEqual, "[TRACE] Level: 0") - - buf.Reset() - wg.Add(1) - Info("Level: %v", INFO) - wg.Wait() - So(buf.String(), ShouldEqual, "[ INFO] Level: 1") - - buf.Reset() - wg.Add(1) - Warn("Level: %v", WARN) - wg.Wait() - So(buf.String(), ShouldEqual, "[ WARN] Level: 2") - - buf.Reset() - wg.Add(1) - Error(0, "Level: %v", ERROR) - wg.Wait() - So(buf.String(), ShouldEqual, "[ERROR] Level: 3") - - buf.Reset() - wg.Add(1) - Error(2, "Level: %v", ERROR) - wg.Wait() - So(buf.String(), ShouldContainSubstring, "clog_test.go") - }) - }) + tt.fn("log message") - Convey("Skip logs has lower level", t, func() { - So(New(_MEMORY, memoryConfig{ - Level: ERROR, - }), ShouldBeNil) + assert.Contains(t, <-c1, tt.containsStr1) - buf.Reset() - Trace("Level: %v", TRACE) - Trace("Level: %v", INFO) - So(buf.String(), ShouldEqual, "") - }) + if tt.containsStr2 != "" { + assert.Contains(t, <-c2, tt.containsStr2) + } + }) + } } diff --git a/console.go b/console.go index df81b77..9ed3d01 100644 --- a/console.go +++ b/console.go @@ -1,25 +1,15 @@ -// Copyright 2017 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package clog import ( + "fmt" "log" "github.com/fatih/color" ) +// ModeConsole is used to indicate console logger. +const ModeConsole Mode = "console" + // Console color set for different levels. var consoleColors = []func(a ...interface{}) string{ color.New(color.FgBlue).SprintFunc(), // Trace @@ -29,82 +19,42 @@ var consoleColors = []func(a ...interface{}) string{ color.New(color.FgHiRed).SprintFunc(), // Fatal } +// ConsoleConfig is the config object for the console logger. type ConsoleConfig struct { - // Minimum level of messages to be processed. - Level LEVEL - // Buffer size defines how many messages can be queued before hangs. - BufferSize int64 + // Minimum logging level of messages to be processed. + Level Level } -type console struct { - *log.Logger - Adapter -} +var _ Logger = (*consoleLogger)(nil) -func newConsole() Logger { - return &console{ - Logger: log.New(color.Output, "", log.Ldate|log.Ltime), - Adapter: Adapter{ - quitChan: make(chan struct{}), - }, - } +type consoleLogger struct { + level Level + *log.Logger } -func (c *console) Level() LEVEL { return c.level } - -func (c *console) Init(v interface{}) error { - cfg, ok := v.(ConsoleConfig) - if !ok { - return ErrConfigObject{"ConsoleConfig", v} - } - - if !isValidLevel(cfg.Level) { - return ErrInvalidLevel{} - } - c.level = cfg.Level - - c.msgChan = make(chan *Message, cfg.BufferSize) - return nil +func (*consoleLogger) Mode() Mode { + return ModeConsole } -func (c *console) ExchangeChans(errorChan chan<- error) chan *Message { - c.errorChan = errorChan - return c.msgChan +func (l *consoleLogger) Level() Level { + return l.level } -func (c *console) write(msg *Message) { - c.Logger.Print(consoleColors[msg.Level](msg.Body)) +func (l *consoleLogger) Write(m Messager) error { + l.Print(consoleColors[m.Level()](m.String())) + return nil } -func (c *console) Start() { -LOOP: - for { - select { - case msg := <-c.msgChan: - c.write(msg) - case <-c.quitChan: - break LOOP - } - } - - for { - if len(c.msgChan) == 0 { - break +func init() { + NewRegister(ModeConsole, func(v interface{}) (Logger, error) { + cfg, ok := v.(ConsoleConfig) + if !ok { + return nil, fmt.Errorf("invalid config object: want %T got %T", ConsoleConfig{}, v) } - c.write(<-c.msgChan) - } - c.quitChan <- struct{}{} // Notify the cleanup is done. -} - -func (c *console) Destroy() { - c.quitChan <- struct{}{} - <-c.quitChan - - close(c.msgChan) - close(c.quitChan) -} - -func init() { - Register(CONSOLE, newConsole) + return &consoleLogger{ + level: cfg.Level, + Logger: log.New(color.Output, "", log.Ldate|log.Ltime), + }, nil + }) } diff --git a/console_test.go b/console_test.go index 63f3a75..66cd74f 100644 --- a/console_test.go +++ b/console_test.go @@ -1,45 +1,41 @@ -// Copyright 2017 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package clog import ( + "errors" "testing" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/assert" ) -func Test_console_Init(t *testing.T) { - Convey("Init console logger", t, func() { - Convey("Mismatched config object", func() { - err := New(CONSOLE, struct{}{}) - So(err, ShouldNotBeNil) - _, ok := err.(ErrConfigObject) - So(ok, ShouldBeTrue) - }) - - Convey("Valid config object", func() { - So(New(CONSOLE, ConsoleConfig{}), ShouldBeNil) +func Test_ModeConsole(t *testing.T) { + defer Remove(ModeConsole) - Convey("Incorrect level", func() { - err := New(CONSOLE, ConsoleConfig{ - Level: LEVEL(-1), - }) - So(err, ShouldNotBeNil) - _, ok := err.(ErrInvalidLevel) - So(ok, ShouldBeTrue) - }) + tests := []struct { + name string + config interface{} + wantLevel Level + wantErr error + }{ + { + name: "valid config", + config: ConsoleConfig{ + Level: LevelInfo, + }, + wantErr: nil, + }, + { + name: "invalid config", + config: "random things", + wantErr: errors.New("initialize logger: invalid config object: want clog.ConsoleConfig got string"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantErr, New(ModeConsole, 10, tt.config)) }) - }) + } + + assert.Equal(t, 1, mgr.len()) + assert.Equal(t, ModeConsole, mgr.loggers[0].Mode()) + assert.Equal(t, LevelInfo, mgr.loggers[0].Level()) } diff --git a/discord.go b/discord.go index abc7521..af1b737 100644 --- a/discord.go +++ b/discord.go @@ -1,17 +1,3 @@ -// Copyright 2018 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package clog import ( @@ -22,9 +8,13 @@ import ( "io" "io/ioutil" "net/http" + "strings" "time" ) +// ModeDiscord is used to indicate Discord logger. +const ModeDiscord Mode = "discord" + type ( discordEmbed struct { Title string `json:"title"` @@ -41,7 +31,7 @@ type ( var ( discordTitles = []string{ - "Tracing", + "Trace", "Information", "Warning", "Error", @@ -57,70 +47,59 @@ var ( } ) +// DiscordConfig is the config object for the Discord logger. type DiscordConfig struct { - // Minimum level of messages to be processed. - Level LEVEL - // Buffer size defines how many messages can be queued before hangs. - BufferSize int64 + // Minimum logging level of messages to be processed. + Level Level // Discord webhook URL. URL string - // Username to be shown for the message. + // Username to be shown in the message. // Leave empty to use default as set in the Discord. Username string + // Title for different levels, must have exact 5 elements in the order of + // Trace, Info, Warn, Error, and Fatal. + Titles []string + // Colors for different levels, must have exact 5 elements in the order of + // Trace, Info, Warn, Error, and Fatal. + Colors []int } -type discord struct { - Adapter +var _ Logger = (*discordLogger)(nil) +type discordLogger struct { + level Level url string username string -} + titles []string + colors []int -func newDiscord() Logger { - return &discord{ - Adapter: Adapter{ - quitChan: make(chan struct{}), - }, - } + client *http.Client } -func (d *discord) Level() LEVEL { return d.level } - -func (d *discord) Init(v interface{}) error { - cfg, ok := v.(DiscordConfig) - if !ok { - return ErrConfigObject{"DiscordConfig", v} - } - - if !isValidLevel(cfg.Level) { - return ErrInvalidLevel{} - } - d.level = cfg.Level - - if len(cfg.URL) == 0 { - return errors.New("URL cannot be empty") - } - d.url = cfg.URL - d.username = cfg.Username - - d.msgChan = make(chan *Message, cfg.BufferSize) - return nil +func (*discordLogger) Mode() Mode { + return ModeDiscord } -func (d *discord) ExchangeChans(errorChan chan<- error) chan *Message { - d.errorChan = errorChan - return d.msgChan +func (l *discordLogger) Level() Level { + return l.level } -func buildDiscordPayload(username string, msg *Message) (string, error) { +func (l *discordLogger) buildPayload(m Messager) (string, error) { + descPrefixLen := strings.Index(m.String(), "] ") + if descPrefixLen == -1 { + descPrefixLen = 0 + } else { + descPrefixLen += 2 + } + payload := discordPayload{ - Username: username, + Username: l.username, Embeds: []*discordEmbed{ { - Title: discordTitles[msg.Level], - Description: msg.Body[8:], + Title: l.titles[m.Level()], + Description: m.String()[descPrefixLen:], Timestamp: time.Now().Format(time.RFC3339), - Color: discordColors[msg.Level], + Color: l.colors[m.Level()], }, }, } @@ -131,46 +110,46 @@ func buildDiscordPayload(username string, msg *Message) (string, error) { return string(p), nil } -type rateLimitMsg struct { - RetryAfter int64 `json:"retry_after"` -} - -func (d *discord) postMessage(r io.Reader) (int64, error) { - resp, err := http.Post(d.url, "application/json", r) +func (l *discordLogger) postMessage(r io.Reader) (int64, error) { + resp, err := l.client.Post(l.url, "application/json", r) if err != nil { - return -1, fmt.Errorf("HTTP Post: %v", err) + return -1, fmt.Errorf("HTTP request: %v", err) } defer resp.Body.Close() - if resp.StatusCode == 429 { - rlMsg := &rateLimitMsg{} - if err = json.NewDecoder(resp.Body).Decode(&rlMsg); err != nil { + if resp.StatusCode == http.StatusTooManyRequests { + rateLimitMsg := struct { + RetryAfter int64 `json:"retry_after"` + }{} + if err = json.NewDecoder(resp.Body).Decode(&rateLimitMsg); err != nil { return -1, fmt.Errorf("decode rate limit message: %v", err) } - return rlMsg.RetryAfter, nil + return rateLimitMsg.RetryAfter, nil } else if resp.StatusCode/100 != 2 { - data, _ := ioutil.ReadAll(resp.Body) - return -1, fmt.Errorf("%s", data) + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return -1, fmt.Errorf("read HTTP response body: %v", err) + } + return -1, fmt.Errorf("non-success response status code %d with body: %s", resp.StatusCode, data) } return -1, nil } -func (d *discord) write(msg *Message) { - payload, err := buildDiscordPayload(d.username, msg) +func (l *discordLogger) Write(m Messager) error { + payload, err := l.buildPayload(m) if err != nil { - d.errorChan <- fmt.Errorf("discord: builddiscordPayload: %v", err) - return + return fmt.Errorf("build payload: %v", err) } - const RETRY_TIMES = 3 - // Due to discord limit, try at most x times with respect to "retry_after" parameter. - for i := 1; i <= 3; i++ { - retryAfter, err := d.postMessage(bytes.NewReader([]byte(payload))) + const retryTimes = 3 + + // Try at most X times with respect to "retry_after" parameter. + for i := 1; i <= retryTimes; i++ { + retryAfter, err := l.postMessage(bytes.NewReader([]byte(payload))) if err != nil { - d.errorChan <- fmt.Errorf("discord: postMessage: %v", err) - return + return fmt.Errorf("post message: %v", err) } if retryAfter > 0 { @@ -178,41 +157,46 @@ func (d *discord) write(msg *Message) { continue } - return + return nil } - d.errorChan <- fmt.Errorf("discord: failed to send message after %d retries", RETRY_TIMES) + return fmt.Errorf("gave up after %d retries", retryTimes) } -func (d *discord) Start() { -LOOP: - for { - select { - case msg := <-d.msgChan: - d.write(msg) - case <-d.quitChan: - break LOOP +func init() { + NewRegister(ModeDiscord, func(v interface{}) (Logger, error) { + cfg, ok := v.(DiscordConfig) + if !ok { + return nil, fmt.Errorf("invalid config object: want %T got %T", DiscordConfig{}, v) } - } - for { - if len(d.msgChan) == 0 { - break + if cfg.URL == "" { + return nil, errors.New("empty URL") } - d.write(<-d.msgChan) - } - d.quitChan <- struct{}{} // Notify the cleanup is done. -} - -func (d *discord) Destroy() { - d.quitChan <- struct{}{} - <-d.quitChan + titles := discordTitles + if cfg.Titles != nil { + if len(cfg.Titles) != 5 { + return nil, fmt.Errorf("titles must have exact 5 elements, but got %d", len(cfg.Titles)) + } + titles = cfg.Titles + } - close(d.msgChan) - close(d.quitChan) -} + colors := discordColors + if cfg.Colors != nil { + if len(cfg.Colors) != 5 { + return nil, fmt.Errorf("colors must have exact 5 elements, but got %d", len(cfg.Colors)) + } + colors = cfg.Colors + } -func init() { - Register(DISCORD, newDiscord) + return &discordLogger{ + level: cfg.Level, + url: cfg.URL, + username: cfg.Username, + titles: titles, + colors: colors, + client: http.DefaultClient, + }, nil + }) } diff --git a/discord_test.go b/discord_test.go index 631aadb..e1a55da 100644 --- a/discord_test.go +++ b/discord_test.go @@ -1,67 +1,306 @@ -// Copyright 2018 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package clog import ( + "bytes" "encoding/json" + "errors" + "io/ioutil" + "net/http" "testing" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/assert" ) -func Test_discord_Init(t *testing.T) { - Convey("Init Discord logger", t, func() { - Convey("Mismatched config object", func() { - err := New(DISCORD, struct{}{}) - So(err, ShouldNotBeNil) - _, ok := err.(ErrConfigObject) - So(ok, ShouldBeTrue) +func Test_ModeDiscord(t *testing.T) { + defer Remove(ModeDiscord) + + tests := []struct { + name string + config interface{} + wantLevel Level + wantErr error + }{ + { + name: "valid config", + config: DiscordConfig{ + Level: LevelInfo, + URL: "https://discordapp.com", + Titles: discordTitles, + Colors: discordColors, + }, + wantErr: nil, + }, + { + name: "invalid config", + config: "random things", + wantErr: errors.New("initialize logger: invalid config object: want clog.DiscordConfig got string"), + }, + { + name: "invalid URL", + config: DiscordConfig{}, + wantErr: errors.New("initialize logger: empty URL"), + }, + { + name: "incorrect number of titles", + config: DiscordConfig{ + URL: "https://discordapp.com", + Titles: []string{}, + }, + wantErr: errors.New("initialize logger: titles must have exact 5 elements, but got 0"), + }, + { + name: "incorrect number of colors", + config: DiscordConfig{ + URL: "https://discordapp.com", + Colors: []int{}, + }, + wantErr: errors.New("initialize logger: colors must have exact 5 elements, but got 0"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantErr, New(ModeDiscord, 10, tt.config)) }) + } + + assert.Equal(t, 1, mgr.len()) + assert.Equal(t, ModeDiscord, mgr.loggers[0].Mode()) + assert.Equal(t, LevelInfo, mgr.loggers[0].Level()) +} + +func Test_discordLogger_buildPayload(t *testing.T) { + t.Run("default titles and colors", func(t *testing.T) { + l := &discordLogger{ + titles: discordTitles, + colors: discordColors, + } - Convey("Valid config object", func() { - So(New(DISCORD, DiscordConfig{ - URL: "https://discordapp.com", - }), ShouldBeNil) - - Convey("Incorrect level", func() { - err := New(SLACK, SlackConfig{ - Level: LEVEL(-1), - }) - So(err, ShouldNotBeNil) - _, ok := err.(ErrInvalidLevel) - So(ok, ShouldBeTrue) + tests := []struct { + name string + msg *message + wantTitle string + wantDesc string + wantColor int + }{ + { + name: "trace", + msg: &message{ + level: LevelTrace, + body: "[TRACE] test message", + }, + wantTitle: discordTitles[0], + wantDesc: "test message", + wantColor: discordColors[0], + }, + { + name: "info", + msg: &message{ + level: LevelInfo, + body: "[ INFO] test message", + }, + wantTitle: discordTitles[1], + wantDesc: "test message", + wantColor: discordColors[1], + }, + { + name: "warn", + msg: &message{ + level: LevelWarn, + body: "[ WARN] test message", + }, + wantTitle: discordTitles[2], + wantDesc: "test message", + wantColor: discordColors[2], + }, + { + name: "error", + msg: &message{ + level: LevelError, + body: "[ERROR] test message", + }, + wantTitle: discordTitles[3], + wantDesc: "test message", + wantColor: discordColors[3], + }, + { + name: "fatal", + msg: &message{ + level: LevelFatal, + body: "[FATAL] test message", + }, + wantTitle: discordTitles[4], + wantDesc: "test message", + wantColor: discordColors[4], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload, err := l.buildPayload(tt.msg) + assert.Nil(t, err) + + obj := &discordPayload{} + assert.Nil(t, json.Unmarshal([]byte(payload), obj)) + assert.Len(t, obj.Embeds, 1) + + assert.Equal(t, tt.wantTitle, obj.Embeds[0].Title) + assert.Equal(t, tt.wantDesc, obj.Embeds[0].Description) + assert.NotEmpty(t, obj.Embeds[0].Timestamp) + assert.Equal(t, tt.wantColor, obj.Embeds[0].Color) }) - }) + } + }) + + t.Run("custom titles and colors", func(t *testing.T) { + l := &discordLogger{ + titles: []string{"1", "2", "3", "4", "5"}, + colors: []int{1, 2, 3, 4, 5}, + } + + tests := []struct { + name string + msg *message + wantTitle string + wantDesc string + wantColor int + }{ + { + name: "trace", + msg: &message{ + level: LevelTrace, + body: "[TRACE] test message", + }, + wantTitle: l.titles[0], + wantDesc: "test message", + wantColor: l.colors[0], + }, + { + name: "info", + msg: &message{ + level: LevelInfo, + body: "[ INFO] test message", + }, + wantTitle: l.titles[1], + wantDesc: "test message", + wantColor: l.colors[1], + }, + { + name: "warn", + msg: &message{ + level: LevelWarn, + body: "[ WARN] test message", + }, + wantTitle: l.titles[2], + wantDesc: "test message", + wantColor: l.colors[2], + }, + { + name: "error", + msg: &message{ + level: LevelError, + body: "[ERROR] test message", + }, + wantTitle: l.titles[3], + wantDesc: "test message", + wantColor: l.colors[3], + }, + { + name: "fatal", + msg: &message{ + level: LevelFatal, + body: "[FATAL] test message", + }, + wantTitle: l.titles[4], + wantDesc: "test message", + wantColor: l.colors[4], + }, + + { + name: "trace", + msg: &message{ + level: LevelTrace, + body: "test message", + }, + wantTitle: l.titles[0], + wantDesc: "test message", + wantColor: l.colors[0], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload, err := l.buildPayload(tt.msg) + assert.Nil(t, err) + + obj := &discordPayload{} + assert.Nil(t, json.Unmarshal([]byte(payload), obj)) + assert.Len(t, obj.Embeds, 1) + + assert.Equal(t, tt.wantTitle, obj.Embeds[0].Title) + assert.Equal(t, tt.wantDesc, obj.Embeds[0].Description) + assert.NotEmpty(t, obj.Embeds[0].Timestamp) + assert.Equal(t, tt.wantColor, obj.Embeds[0].Color) + }) + } }) } -func Test_buildDiscordPayload(t *testing.T) { - Convey("Build Discord payload", t, func() { - payload, err := buildDiscordPayload("clog", &Message{ - Level: INFO, - Body: "[ INFO] test message", +func Test_discordLogger_postMessage(t *testing.T) { + l := &discordLogger{ + client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) *http.Response { + statusCode := 500 + respBody := "" + switch req.URL.String() { + case "https://discordapp.com/success": + statusCode = 200 + respBody = `OK` + case "https://discordapp.com/non-success-response-status-code": + statusCode = 404 + respBody = `Page Not Found` + case "https://discordapp.com/retry-after": + statusCode = 429 + respBody = `{"retry_after": 123456}` + } + + return &http.Response{ + StatusCode: statusCode, + Body: ioutil.NopCloser(bytes.NewBufferString(respBody)), + Header: make(http.Header), + } + }), + }, + } + + tests := []struct { + name string + url string + wantRetry int64 + wantErr error + }{ + { + name: "success", + url: "https://discordapp.com/success", + wantRetry: -1, + wantErr: nil, + }, + { + name: "non-success response status code", + url: "https://discordapp.com/non-success-response-status-code", + wantRetry: -1, + wantErr: errors.New("non-success response status code 404 with body: Page Not Found"), + }, + { + name: "retry after", + url: "https://discordapp.com/retry-after", + wantRetry: 123456, + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l.url = tt.url + retryAfter, err := l.postMessage(bytes.NewReader([]byte("payload"))) + assert.Equal(t, tt.wantRetry, retryAfter) + assert.Equal(t, tt.wantErr, err) }) - So(err, ShouldBeNil) - - obj := &discordPayload{} - So(json.Unmarshal([]byte(payload), obj), ShouldBeNil) - So(obj.Username, ShouldEqual, "clog") - So(len(obj.Embeds), ShouldEqual, 1) - So(obj.Embeds[0].Title, ShouldEqual, "Information") - So(obj.Embeds[0].Description, ShouldEqual, "test message") - So(obj.Embeds[0].Timestamp, ShouldNotBeEmpty) - So(obj.Embeds[0].Color, ShouldEqual, 3843043) - }) + } } diff --git a/error.go b/error.go deleted file mode 100644 index b0612dd..0000000 --- a/error.go +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2017 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - -package clog - -import "fmt" - -type ErrConfigObject struct { - expect string - got interface{} -} - -func (err ErrConfigObject) Error() string { - return fmt.Sprintf("config object is not an instance of %s, instead got '%T'", err.expect, err.got) -} - -type ErrInvalidLevel struct{} - -func (err ErrInvalidLevel) Error() string { - return "input level is not one of: TRACE, INFO, WARN, ERROR or FATAL" -} diff --git a/file.go b/file.go index 6b490f9..0f900bd 100644 --- a/file.go +++ b/file.go @@ -1,17 +1,3 @@ -// Copyright 2017 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package clog import ( @@ -26,9 +12,12 @@ import ( "time" ) +// ModeFile is used to indicate file logger. +const ModeFile Mode = "file" + const ( - SIMPLE_DATE_FORMAT = "2006-01-02" - LOG_PREFIX_LENGTH = len("2017/02/06 21:20:08 ") + simpleDateFormat = "2006-01-02" + logPrefixLength = len("2017/02/06 21:20:08 ") ) // FileRotationConfig represents rotation related configurations for file mode logger. @@ -46,79 +35,66 @@ type FileRotationConfig struct { MaxDays int64 } +// FileConfig is the config object for the file logger. type FileConfig struct { // Minimum level of messages to be processed. - Level LEVEL - // Buffer size defines how many messages can be queued before hangs. - BufferSize int64 - // File name to outout messages. + Level Level + // File name to output messages. Filename string // Rotation related configurations. FileRotationConfig } -type file struct { - // Indicates whether object is been used in standalone mode. +var _ Logger = (*fileLogger)(nil) + +type fileLogger struct { + // Indicates whether it is being used as standalone logger. + // It is only true when the logger is created by NewFileWriter. standalone bool - *log.Logger - Adapter + level Level + filename string + rotationConfig FileRotationConfig + + // Rotation metadata file *os.File - filename string openDay int currentSize int64 currentLines int64 - rotate FileRotationConfig -} -func newFile() Logger { - return &file{ - Adapter: Adapter{ - quitChan: make(chan struct{}), - }, - } + *log.Logger } -// NewFileWriter returns an io.Writer for synchronized file logger in standalone mode. -func NewFileWriter(filename string, cfg FileRotationConfig) (io.Writer, error) { - f := &file{ - standalone: true, - } - if err := f.Init(FileConfig{ - Filename: filename, - FileRotationConfig: cfg, - }); err != nil { - return nil, err - } - - return f, nil +func (*fileLogger) Mode() Mode { + return ModeFile } -func (f *file) Level() LEVEL { return f.level } +func (l *fileLogger) Level() Level { + return l.level +} var newLineBytes = []byte("\n") -func (f *file) initFile() (err error) { - f.file, err = os.OpenFile(f.filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0660) +func (l *fileLogger) initFile() (err error) { + l.file, err = os.OpenFile(l.filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0660) if err != nil { - return fmt.Errorf("OpenFile '%s': %v", f.filename, err) + return fmt.Errorf("open file %q: %v", l.filename, err) } - f.Logger = log.New(f.file, "", log.Ldate|log.Ltime) + l.Logger = log.New(l.file, "", log.Ldate|log.Ltime) return nil } -// isExist checks whether a file or directory exists. -// It returns false when the file or directory does not exist. +// isExist returns true if the file or directory exists. func isExist(path string) bool { _, err := os.Stat(path) return err == nil || os.IsExist(err) } -// rotateFilename returns next available rotate filename with given date. -func (f *file) rotateFilename(date string) string { - filename := fmt.Sprintf("%s.%s", f.filename, date) +// rotateFilename returns next available rotate filename in given date. +func rotateFilename(filename, date string) string { + filename = fmt.Sprintf("%s.%s", filename, date) if !isExist(filename) { return filename } @@ -131,110 +107,79 @@ func (f *file) rotateFilename(date string) string { } } - panic("too many log files for yesterday") + panic("too many log files for yesterday, already reached 999") } -func (f *file) deleteOutdatedFiles() { - filepath.Walk(filepath.Dir(f.filename), func(path string, info os.FileInfo, err error) error { - if !info.IsDir() && - info.ModTime().Before(time.Now().Add(-24*time.Hour*time.Duration(f.rotate.MaxDays))) && - strings.HasPrefix(filepath.Base(path), filepath.Base(f.filename)) { - os.Remove(path) +func (l *fileLogger) deleteOutdatedFiles() error { + return filepath.Walk(filepath.Dir(l.filename), func(path string, fi os.FileInfo, _ error) error { + if !fi.IsDir() && + fi.ModTime().Before(time.Now().Add(-24*time.Hour*time.Duration(l.rotationConfig.MaxDays))) && + strings.HasPrefix(filepath.Base(path), filepath.Base(l.filename)) { + return os.Remove(path) } return nil }) } -func (f *file) initRotate() error { +func (l *fileLogger) initRotation() error { // Gather basic file info for rotation. - fi, err := f.file.Stat() + fi, err := l.file.Stat() if err != nil { - return fmt.Errorf("Stat: %v", err) + return fmt.Errorf("stat: %v", err) } - f.currentSize = fi.Size() + l.currentSize = fi.Size() // If there is any content in the file, count the number of lines. - if f.rotate.MaxLines > 0 && f.currentSize > 0 { - data, err := ioutil.ReadFile(f.filename) + if l.rotationConfig.MaxLines > 0 && l.currentSize > 0 { + data, err := ioutil.ReadFile(l.filename) if err != nil { - return fmt.Errorf("ReadFile '%s': %v", f.filename, err) + return fmt.Errorf("read file %q: %v", l.filename, err) } - f.currentLines = int64(bytes.Count(data, newLineBytes)) + 1 + l.currentLines = int64(bytes.Count(data, newLineBytes)) + 1 } - if f.rotate.Daily { + if l.rotationConfig.Daily { now := time.Now() - f.openDay = now.Day() + l.openDay = now.Day() lastWriteTime := fi.ModTime() if lastWriteTime.Year() != now.Year() || lastWriteTime.Month() != now.Month() || lastWriteTime.Day() != now.Day() { - if err = f.file.Close(); err != nil { - return fmt.Errorf("Close: %v", err) + if err = l.file.Close(); err != nil { + return fmt.Errorf("close current file: %v", err) } - if err = os.Rename(f.filename, f.rotateFilename(lastWriteTime.Format(SIMPLE_DATE_FORMAT))); err != nil { - return fmt.Errorf("Rename: %v", err) + if err = os.Rename(l.filename, rotateFilename(l.filename, lastWriteTime.Format(simpleDateFormat))); err != nil { + return fmt.Errorf("rename rotate file: %v", err) } - if err = f.initFile(); err != nil { - return fmt.Errorf("initFile: %v", err) + if err = l.initFile(); err != nil { + return fmt.Errorf("init file: %v", err) } } } - if f.rotate.MaxDays > 0 { - f.deleteOutdatedFiles() - } - return nil -} - -func (f *file) Init(v interface{}) (err error) { - cfg, ok := v.(FileConfig) - if !ok { - return ErrConfigObject{"FileConfig", v} - } - - if !isValidLevel(cfg.Level) { - return ErrInvalidLevel{} - } - f.level = cfg.Level - - f.filename = cfg.Filename - os.MkdirAll(filepath.Dir(f.filename), os.ModePerm) - if err = f.initFile(); err != nil { - return fmt.Errorf("initFile: %v", err) - } - - f.rotate = cfg.FileRotationConfig - if f.rotate.Rotate { - f.initRotate() - } - - if !f.standalone { - f.msgChan = make(chan *Message, cfg.BufferSize) + if l.rotationConfig.MaxDays > 0 { + if err = l.deleteOutdatedFiles(); err != nil { + return fmt.Errorf("delete outdated files: %v", err) + } } return nil } -func (f *file) ExchangeChans(errorChan chan<- error) chan *Message { - f.errorChan = errorChan - return f.msgChan -} - -func (f *file) write(msg *Message) int { - f.Logger.Print(msg.Body) +func (l *fileLogger) write(m Messager) (int, error) { + l.Logger.Print(m.String()) - bytesWrote := len(msg.Body) - if !f.standalone { - bytesWrote += LOG_PREFIX_LENGTH + bytesWrote := len(m.String()) + if !l.standalone { + bytesWrote += logPrefixLength } - if f.rotate.Rotate { - f.currentSize += int64(bytesWrote) - f.currentLines++ // TODO: should I care if log message itself contains new lines? + if l.rotationConfig.Rotate { + l.currentSize += int64(bytesWrote) + l.currentLines += int64(strings.Count(m.String(), "\n")) + 1 var ( needsRotate = false @@ -242,74 +187,97 @@ func (f *file) write(msg *Message) int { ) now := time.Now() - if f.rotate.Daily && now.Day() != f.openDay { + if l.rotationConfig.Daily && now.Day() != l.openDay { needsRotate = true rotateDate = now.Add(-24 * time.Hour) - } else if (f.rotate.MaxSize > 0 && f.currentSize >= f.rotate.MaxSize) || - (f.rotate.MaxLines > 0 && f.currentLines >= f.rotate.MaxLines) { + } else if (l.rotationConfig.MaxSize > 0 && l.currentSize >= l.rotationConfig.MaxSize) || + (l.rotationConfig.MaxLines > 0 && l.currentLines >= l.rotationConfig.MaxLines) { needsRotate = true rotateDate = now } if needsRotate { - f.file.Close() - if err := os.Rename(f.filename, f.rotateFilename(rotateDate.Format(SIMPLE_DATE_FORMAT))); err != nil { - f.errorChan <- fmt.Errorf("fail to rename rotate file '%s': %v", f.filename, err) + _ = l.file.Close() + if err := os.Rename(l.filename, rotateFilename(l.filename, rotateDate.Format(simpleDateFormat))); err != nil { + return bytesWrote, fmt.Errorf("rename rotated file %q: %v", l.filename, err) } - if err := f.initFile(); err != nil { - f.errorChan <- fmt.Errorf("fail to init log file '%s': %v", f.filename, err) + if err := l.initFile(); err != nil { + return bytesWrote, fmt.Errorf("init file %q: %v", l.filename, err) } - f.openDay = now.Day() - f.currentSize = 0 - f.currentLines = 0 + l.openDay = now.Day() + l.currentSize = 0 + l.currentLines = 0 } } - return bytesWrote + return bytesWrote, nil } -var _ io.Writer = new(file) - -// Write implements method of io.Writer interface. -func (f *file) Write(p []byte) (int, error) { - return f.write(&Message{ - Body: string(p), - }), nil +func (l *fileLogger) Write(m Messager) error { + _, err := l.write(m) + return err } -func (f *file) Start() { -LOOP: - for { - select { - case msg := <-f.msgChan: - f.write(msg) - case <-f.quitChan: - break LOOP +func (l *fileLogger) init() error { + _ = os.MkdirAll(filepath.Dir(l.filename), os.ModePerm) + if err := l.initFile(); err != nil { + return fmt.Errorf("init file %q: %v", l.filename, err) + } + + if l.rotationConfig.Rotate { + if err := l.initRotation(); err != nil { + return fmt.Errorf("init rotation: %v", err) } } + return nil +} - for { - if len(f.msgChan) == 0 { - break +func init() { + NewRegister(ModeFile, func(v interface{}) (Logger, error) { + cfg, ok := v.(FileConfig) + if !ok { + return nil, fmt.Errorf("invalid config object: want %T got %T", FileConfig{}, v) } - f.write(<-f.msgChan) - } - f.quitChan <- struct{}{} // Notify the cleanup is done. + l := &fileLogger{ + level: cfg.Level, + filename: cfg.Filename, + rotationConfig: cfg.FileRotationConfig, + } + + if err := l.init(); err != nil { + return nil, err + } + + return l, nil + }) } -func (f *file) Destroy() { - f.quitChan <- struct{}{} - <-f.quitChan +var _ io.Writer = (*fileWriter)(nil) + +type fileWriter struct { + *fileLogger +} - close(f.msgChan) - close(f.quitChan) +// NewFileWriter returns an io.Writer for synchronized file logger. +func NewFileWriter(filename string, cfg FileRotationConfig) (io.Writer, error) { + f := &fileLogger{ + standalone: true, + filename: filename, + rotationConfig: cfg, + } + if err := f.init(); err != nil { + return nil, fmt.Errorf("init: %v", err) + } - f.file.Close() + return &fileWriter{f}, nil } -func init() { - Register(FILE, newFile) +// Write implements method of io.Writer interface. +func (w *fileWriter) Write(p []byte) (int, error) { + return w.write(&message{ + body: string(p), + }) } diff --git a/file_test.go b/file_test.go index da617aa..308b88a 100644 --- a/file_test.go +++ b/file_test.go @@ -1,69 +1,68 @@ -// Copyright 2017 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package clog import ( + "errors" "io/ioutil" "os" + "path/filepath" "testing" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/assert" ) -func Test_file_Init(t *testing.T) { - Convey("Init file logger", t, func() { - Convey("With mismatched config object", func() { - err := New(FILE, struct{}{}) - So(err, ShouldNotBeNil) - _, ok := err.(ErrConfigObject) - So(ok, ShouldBeTrue) +func Test_ModeFile(t *testing.T) { + defer Remove(ModeFile) + + tests := []struct { + name string + config interface{} + wantLevel Level + wantErr error + }{ + { + name: "valid config", + config: FileConfig{ + Level: LevelInfo, + Filename: filepath.Join(os.TempDir(), "Test_ModeFile"), + }, + wantErr: nil, + }, + { + name: "invalid config", + config: "random things", + wantErr: errors.New("initialize logger: invalid config object: want clog.FileConfig got string"), + }, + { + name: "invalid filename", + config: FileConfig{ + Level: LevelInfo, + }, + wantErr: errors.New(`initialize logger: init file "": open file "": open : no such file or directory`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantErr, New(ModeFile, 10, tt.config)) }) + } - Convey("With valid config object", func() { - So(New(FILE, FileConfig{ - Filename: "test/test.log", - }), ShouldBeNil) + assert.Equal(t, 1, mgr.len()) + assert.Equal(t, ModeFile, mgr.loggers[0].Mode()) + assert.Equal(t, LevelInfo, mgr.loggers[0].Level()) +} - Convey("Incorrect level", func() { - err := New(FILE, FileConfig{ - Level: LEVEL(-1), - }) - So(err, ShouldNotBeNil) - _, ok := err.(ErrInvalidLevel) - So(ok, ShouldBeTrue) - }) +func Test_rotateFilename(t *testing.T) { + _ = os.MkdirAll("test", os.ModePerm) + defer os.RemoveAll("test") - Convey("Empty file name", func() { - err := New(FILE, FileConfig{}) - So(err, ShouldNotBeNil) - So(err.Error(), ShouldEqual, "initFile: OpenFile '': open : no such file or directory") - }) - }) - }) -} + filename := rotateFilename("test/Test_rotateFilename.log", "2017-03-05") + assert.Equal(t, "test/Test_rotateFilename.log.2017-03-05", filename) + assert.Nil(t, ioutil.WriteFile(filename, []byte(""), os.ModePerm)) -func Test_file_rotateFilename(t *testing.T) { - Convey("Get rotate filename", t, func() { - f := &file{ - filename: "test/test.log", - } - os.Remove("test/test.log.2017-03-05") - So(f.rotateFilename("2017-03-05"), ShouldEqual, "test/test.log.2017-03-05") + filename = rotateFilename("test/Test_rotateFilename.log", "2017-03-05") + assert.Equal(t, "test/Test_rotateFilename.log.2017-03-05.001", filename) + assert.Nil(t, ioutil.WriteFile(filename, []byte(""), os.ModePerm)) - // Pretend one log file already exists - ioutil.WriteFile("test/test.log.2017-03-05", []byte(""), os.ModePerm) - So(f.rotateFilename("2017-03-05"), ShouldEqual, "test/test.log.2017-03-05.001") - }) + filename = rotateFilename("test/Test_rotateFilename.log", "2017-03-05") + assert.Equal(t, "test/Test_rotateFilename.log.2017-03-05.002", filename) } diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..47ba7d4 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module unknwon.dev/clog/v2 + +go 1.12 + +require ( + github.com/fatih/color v1.7.0 + github.com/mattn/go-colorable v0.1.4 // indirect + github.com/mattn/go-isatty v0.0.10 // indirect + github.com/stretchr/testify v1.4.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..db9228a --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +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= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +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.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191008105621-543471e840be h1:QAcqgptGM8IQBC9K/RC4o+O9YmqEm0diQn9QmZw/0mU= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/logger.go b/logger.go index be8ba8f..17c5135 100644 --- a/logger.go +++ b/logger.go @@ -1,141 +1,199 @@ -// Copyright 2017 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package clog -import "fmt" +import ( + "context" + "fmt" + "log" + "sync/atomic" -// Logger is an interface for a logger adapter with specific mode and level. + "github.com/fatih/color" +) + +// Logger is an interface for a logger with a specific mode and level. type Logger interface { - // Level returns minimum level of given logger. - Level() LEVEL - // Init accepts a config struct specific for given logger and performs any necessary initialization. - Init(interface{}) error - // ExchangeChans accepts error channel, and returns message receive channel. - ExchangeChans(chan<- error) chan *Message - // Start starts message processing. - Start() - // Destroy releases all resources. - Destroy() + // Mode returns the mode of the logger. + Mode() Mode + // Level returns the minimum logging level of the logger. + Level() Level + // Write processes a Messager entry. + Write(Messager) error } -// Adapter contains common fields for any logger adapter. This struct should be used as embedded struct. -type Adapter struct { - level LEVEL - msgChan chan *Message - quitChan chan struct{} - errorChan chan<- error +// Register is a factory function taht returns a new logger. +// It accepts a configuration struct specifically for the logger. +type Register func(interface{}) (Logger, error) + +var registers = map[Mode]Register{} + +// NewRegister adds a new factory function as a Register to the global map. +// +// This function is not concurrent safe. +func NewRegister(mode Mode, r Register) { + if r == nil { + panic("register is nil") + } + if registers[mode] != nil { + panic(fmt.Sprintf("register with mode %q already exists", mode)) + } + registers[mode] = r } -type Factory func() Logger +type cancelableLogger struct { + cancel context.CancelFunc + msgChan chan Messager + done chan struct{} + Logger +} -// factories keeps factory function of registered loggers. -var factories = map[MODE]Factory{} +var errLogger = log.New(color.Output, "", log.Ldate|log.Ltime) +var errSprintf = color.New(color.FgRed).Sprintf -func Register(mode MODE, f Factory) { - if f == nil { - panic("clog: register function is nil") +func (l *cancelableLogger) error(err error) { + if err == nil { + return } - if factories[mode] != nil { - panic("clog: register duplicated mode '" + mode + "'") + + errLogger.Print(errSprintf("[clog] [%s]: %v", l.Mode(), err)) +} + +type manager struct { + state int64 // 0=stopping, 1=running + ctx context.Context + cancel context.CancelFunc + loggers []*cancelableLogger +} + +func (m *manager) len() int { + return len(m.loggers) +} + +func (m *manager) write(level Level, skip int, format string, v ...interface{}) { + var msg *message + for i := range mgr.loggers { + if mgr.loggers[i].Level() > level { + continue + } + + if msg == nil { + msg = newMessage(level, skip, format, v...) + } + + mgr.loggers[i].msgChan <- msg + } + + if msg == nil { + errLogger.Print(errSprintf("[clog] no logger is available")) } - factories[mode] = f } -type receiver struct { - Logger - mode MODE - msgChan chan *Message +func (m *manager) stop() { + // Make sure cancellation is only propagated once to prevent deadlock of WaitForStop. + if !atomic.CompareAndSwapInt64(&m.state, 1, 0) { + return + } + + m.cancel() + for _, l := range m.loggers { + <-l.done + } } -var ( - // receivers is a list of loggers with their message channel for broadcasting. - receivers []*receiver +var mgr *manager - errorChan = make(chan error, 5) - quitChan = make(chan struct{}) -) +func initManager() { + ctx, cancel := context.WithCancel(context.Background()) + mgr = &manager{ + state: 1, + ctx: ctx, + cancel: cancel, + } +} func init() { - // Start background error handling goroutine. - go func() { - for { - select { - case err := <-errorChan: - fmt.Printf("clog: unable to write message: %v\n", err) - case <-quitChan: - return - } - } - }() + initManager() } -// New initializes and appends a new logger to the receiver list. +// New initializes and appends a new logger to the managed list. // Calling this function multiple times will overwrite previous logger with same mode. -func New(mode MODE, cfg interface{}) error { - factory, ok := factories[mode] +// +// This function is not concurrent safe. +func New(mode Mode, bufferSize int64, cfg interface{}) error { + r, ok := registers[mode] if !ok { - return fmt.Errorf("unknown mode '%s'", mode) + return fmt.Errorf("no register for %q", mode) } - logger := factory() - if err := logger.Init(cfg); err != nil { - return err + l, err := r(cfg) + if err != nil { + return fmt.Errorf("initialize logger: %v", err) } - msgChan := logger.ExchangeChans(errorChan) - // Check and replace previous logger. - hasFound := false - for i := range receivers { - if receivers[i].mode == mode { - hasFound = true + ctx, cancel := context.WithCancel(mgr.ctx) + cl := &cancelableLogger{ + cancel: cancel, + msgChan: make(chan Messager, bufferSize), + done: make(chan struct{}), + Logger: l, + } + + // Check and replace previous logger + found := false + for i, l := range mgr.loggers { + if l.Mode() == mode { + found = true - // Release previous logger. - receivers[i].Destroy() + // Release previous logger + l.cancel() + <-l.done - // Update info to new one. - receivers[i].Logger = logger - receivers[i].msgChan = msgChan + mgr.loggers[i] = cl break } } - if !hasFound { - receivers = append(receivers, &receiver{ - Logger: logger, - mode: mode, - msgChan: msgChan, - }) + if !found { + mgr.loggers = append(mgr.loggers, cl) } - go logger.Start() + go func() { + loop: + for { + select { + case m := <-cl.msgChan: + cl.error(cl.Write(m)) + case <-ctx.Done(): + break loop + } + } + + // Drain the msgChan at best effort + for { + if len(cl.msgChan) == 0 { + break + } + + cl.error(cl.Write(<-cl.msgChan)) + } + + // Notify the cleanup is done + cl.done <- struct{}{} + }() return nil } -// Delete removes logger from the receiver list. -func Delete(mode MODE) { - foundIdx := -1 - for i := range receivers { - if receivers[i].mode == mode { - foundIdx = i - receivers[i].Destroy() +// Remove removes a logger with given mode from the managed list. +// +// This function is not concurrent safe. +func Remove(mode Mode) { + loggers := mgr.loggers[:0] + for _, l := range mgr.loggers { + if l.Mode() == mode { + go func(l *cancelableLogger) { + l.cancel() + <-l.done + }(l) + continue } + loggers = append(loggers, l) } - - if foundIdx >= 0 { - newList := make([]*receiver, len(receivers)-1) - copy(newList, receivers[:foundIdx]) - copy(newList[foundIdx:], receivers[foundIdx+1:]) - receivers = newList - } + mgr.loggers = loggers } diff --git a/logger_test.go b/logger_test.go index e82bc7a..da3ee1e 100644 --- a/logger_test.go +++ b/logger_test.go @@ -1,48 +1,169 @@ -// Copyright 2017 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package clog import ( + "errors" "testing" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/assert" ) -func Test_Register(t *testing.T) { - Convey("Register with nil function", t, func() { - defer func() { - err := recover() - So(err, ShouldNotBeNil) - So(err, ShouldEqual, "clog: register function is nil") - }() - Register("test", nil) - }) +func TestNewRegister(t *testing.T) { + tests := []struct { + name string + run func() + want string + }{ + { + name: "success", + run: func() { + NewRegister("TestNewRegister_success", + func(_ interface{}) (Logger, error) { return nil, nil }, + ) + }, + want: "", + }, + { + name: "nil register", + run: func() { + NewRegister("", nil) + }, + want: "register is nil", + }, + { + name: "duplicated register", + run: func() { + NewRegister("TestNewRegister_duplicated_register", + func(_ interface{}) (Logger, error) { return nil, nil }, + ) + NewRegister("TestNewRegister_duplicated_register", + func(_ interface{}) (Logger, error) { return nil, nil }, + ) + }, + want: `register with mode "TestNewRegister_duplicated_register" already exists`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + err := recover() + if tt.want == "" { + assert.Nil(t, err) + } else { + assert.Equal(t, tt.want, err) + } + }() + + tt.run() + }) + } +} + +var _ Logger = (*noopLogger)(nil) + +type noopLogger struct { + mode Mode + level Level +} - Convey("Register duplicated mode", t, func() { - defer func() { - err := recover() - So(err, ShouldNotBeNil) - So(err, ShouldEqual, "clog: register duplicated mode 'test'") - }() - Register("test", newConsole) - Register("test", newConsole) +func (l *noopLogger) Mode() Mode { return l.mode } +func (l *noopLogger) Level() Level { return l.level } +func (l *noopLogger) Write(_ Messager) error { return nil } + +func TestNew(t *testing.T) { + testModeGood := Mode("TestNew_good") + testModeBad := Mode("TestNew_bad") + defer Remove(testModeGood) + defer Remove(testModeBad) + + NewRegister(testModeGood, + func(_ interface{}) (Logger, error) { + return &noopLogger{ + mode: testModeGood, + }, nil + }, + ) + NewRegister(testModeBad, + func(_ interface{}) (Logger, error) { + return nil, errors.New("random error") + }, + ) + + tests := []struct { + name string + mode Mode + want error + }{ + { + name: "success", + mode: testModeGood, + want: nil, + }, + { + name: "no register", + mode: "no_register", + want: errors.New(`no register for "no_register"`), + }, + { + name: "initialize error", + mode: testModeBad, + want: errors.New("initialize logger: random error"), + }, + { + name: "success overwrite", + mode: testModeGood, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := New(tt.mode, 10, nil) + assert.Equal(t, tt.want, err) + }) + } +} + +func TestRemove(t *testing.T) { + testMode1 := Mode("TestRemove1") + NewRegister(testMode1, func(_ interface{}) (Logger, error) { + return &noopLogger{ + mode: testMode1, + }, nil }) + assert.Nil(t, New(testMode1, 10, nil)) - Convey("Create non-registered logger", t, func() { - err := New(MODE("404"), nil) - So(err, ShouldNotBeNil) - So(err.Error(), ShouldContainSubstring, "unknown mode") + testMode2 := Mode("TestRemove2") + NewRegister(testMode2, func(_ interface{}) (Logger, error) { + return &noopLogger{ + mode: testMode2, + }, nil }) + assert.Nil(t, New(testMode2, 10, nil)) + + tests := []struct { + name string + mode Mode + numLoggers int + }{ + { + name: "remove nothing", + mode: "TestRemove_nothing", + numLoggers: 2, + }, + { + name: "remove one", + mode: testMode1, + numLoggers: 1, + }, + { + name: "remove two", + mode: testMode2, + numLoggers: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + Remove(tt.mode) + assert.Equal(t, tt.numLoggers, mgr.len()) + }) + } } diff --git a/message.go b/message.go new file mode 100644 index 0000000..7b71e4e --- /dev/null +++ b/message.go @@ -0,0 +1,56 @@ +package clog + +import ( + "fmt" + "path/filepath" + "runtime" + "strings" +) + +var _ Messager = (*message)(nil) + +// Messager is a message entry to be processed by logger. +type Messager interface { + // Level returns the level of the message. + Level() Level + fmt.Stringer +} + +type message struct { + level Level + body string +} + +func newMessage(level Level, skip int, format string, v ...interface{}) *message { + var body string + // Only error and fatal information needs locate position for debugging. + // But if skip is 0 means caller doesn't care so we can skip. + if level >= LevelError && skip > 0 { + pc, file, line, ok := runtime.Caller(skip) + if ok { + // Get caller function name + fn := runtime.FuncForPC(pc) + var fnName string + if fn == nil { + fnName = "?()" + } else { + fnName = strings.TrimLeft(filepath.Ext(fn.Name()), ".") + "()" + } + + if len(file) > 32 { + file = "..." + file[len(file)-32:] + } + body = fmt.Sprintf("[%s:%d %s] %s", file, line, fnName, fmt.Sprintf(format, v...)) + } + } + if len(body) == 0 { + body = fmt.Sprintf(format, v...) + } + return &message{ + level: level, + body: fmt.Sprintf("[%5s] %s", level, body), + } +} + +func (m *message) Level() Level { return m.level } +func (m *message) String() string { return m.body } diff --git a/message_test.go b/message_test.go new file mode 100644 index 0000000..6355df2 --- /dev/null +++ b/message_test.go @@ -0,0 +1,115 @@ +package clog + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_newMessage(t *testing.T) { + t.Run("no skip", func(t *testing.T) { + tests := []struct { + name string + level Level + format string + v []interface{} + want string + }{ + { + name: "trace", + level: LevelTrace, + format: "a trace log: %v", + v: []interface{}{"value"}, + want: "[TRACE] a trace log: value", + }, + { + name: "info", + level: LevelInfo, + format: "a info log: %v", + v: []interface{}{"value"}, + want: "[ INFO] a info log: value", + }, + { + name: "warn", + level: LevelWarn, + format: "a warn log: %v", + v: []interface{}{"value"}, + want: "[ WARN] a warn log: value", + }, + { + name: "error", + level: LevelError, + format: "an error log: %v", + v: []interface{}{"value"}, + want: "[ERROR] an error log: value", + }, + { + name: "fatal", + level: LevelFatal, + format: "a fatal log: %v", + v: []interface{}{"value"}, + want: "[FATAL] a fatal log: value", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newMessage(tt.level, 0, tt.format, tt.v...) + assert.Equal(t, tt.level, m.Level()) + assert.Equal(t, tt.want, m.String()) + }) + } + }) + + t.Run("has skip", func(t *testing.T) { + tests := []struct { + name string + level Level + format string + v []interface{} + contains string + }{ + { + name: "trace", + level: LevelTrace, + format: "a trace log: %v", + v: []interface{}{"value"}, + contains: "[TRACE] a trace log: value", + }, + { + name: "info", + level: LevelInfo, + format: "a info log: %v", + v: []interface{}{"value"}, + contains: "[ INFO] a info log: value", + }, + { + name: "warn", + level: LevelWarn, + format: "a warn log: %v", + v: []interface{}{"value"}, + contains: "[ WARN] a warn log: value", + }, + { + name: "error", + level: LevelError, + format: "an error log: %v", + v: []interface{}{"value"}, + contains: "an error log: value", + }, + { + name: "fatal", + level: LevelFatal, + format: "a fatal log: %v", + v: []interface{}{"value"}, + contains: "()] a fatal log: value", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newMessage(tt.level, 1, tt.format, tt.v...) + assert.Equal(t, tt.level, m.Level()) + assert.Contains(t, m.String(), tt.contains) + }) + } + }) +} diff --git a/slack.go b/slack.go index d6c5de1..900fa50 100644 --- a/slack.go +++ b/slack.go @@ -1,17 +1,3 @@ -// Copyright 2017 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package clog import ( @@ -19,10 +5,14 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/ioutil" "net/http" ) +// ModeSlack is used to indicate Slack logger. +const ModeSlack Mode = "slack" + type slackAttachment struct { Text string `json:"text"` Color string `json:"color"` @@ -40,62 +30,41 @@ var slackColors = []string{ "#ff0200", // Fatal } +// SlackConfig is the config object for the Slack logger. type SlackConfig struct { - // Minimum level of messages to be processed. - Level LEVEL - // Buffer size defines how many messages can be queued before hangs. - BufferSize int64 + // Minimum logging level of messages to be processed. + Level Level // Slack webhook URL. URL string + // Colors for different levels, must have exact 5 elements in the order of + // Trace, Info, Warn, Error, and Fatal. + Colors []string } -type slack struct { - Adapter +var _ Logger = (*slackLogger)(nil) - url string -} +type slackLogger struct { + level Level + url string + colors []string -func newSlack() Logger { - return &slack{ - Adapter: Adapter{ - quitChan: make(chan struct{}), - }, - } + client *http.Client } -func (s *slack) Level() LEVEL { return s.level } - -func (s *slack) Init(v interface{}) error { - cfg, ok := v.(SlackConfig) - if !ok { - return ErrConfigObject{"SlackConfig", v} - } - - if !isValidLevel(cfg.Level) { - return ErrInvalidLevel{} - } - s.level = cfg.Level - - if len(cfg.URL) == 0 { - return errors.New("URL cannot be empty") - } - s.url = cfg.URL - - s.msgChan = make(chan *Message, cfg.BufferSize) - return nil +func (*slackLogger) Mode() Mode { + return ModeSlack } -func (s *slack) ExchangeChans(errorChan chan<- error) chan *Message { - s.errorChan = errorChan - return s.msgChan +func (l *slackLogger) Level() Level { + return l.level } -func buildSlackPayload(msg *Message) (string, error) { +func (l *slackLogger) buildPayload(m Messager) (string, error) { payload := slackPayload{ Attachments: []slackAttachment{ { - Text: msg.Body, - Color: slackColors[msg.Level], + Text: m.String(), + Color: l.colors[m.Level()], }, }, } @@ -106,55 +75,60 @@ func buildSlackPayload(msg *Message) (string, error) { return string(p), nil } -func (s *slack) write(msg *Message) { - payload, err := buildSlackPayload(msg) - if err != nil { - s.errorChan <- fmt.Errorf("slack: buildSlackPayload: %v", err) - return - } - - resp, err := http.Post(s.url, "application/json", bytes.NewReader([]byte(payload))) +func (l *slackLogger) postMessage(r io.Reader) error { + resp, err := l.client.Post(l.url, "application/json", r) if err != nil { - s.errorChan <- fmt.Errorf("slack: %v", err) - return + return fmt.Errorf("HTTP request: %v", err) } defer resp.Body.Close() if resp.StatusCode/100 != 2 { - data, _ := ioutil.ReadAll(resp.Body) - s.errorChan <- fmt.Errorf("slack: %s", data) + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read HTTP response body: %v", err) + } + return fmt.Errorf("non-success response status code %d with body: %s", resp.StatusCode, data) } + return nil } -func (s *slack) Start() { -LOOP: - for { - select { - case msg := <-s.msgChan: - s.write(msg) - case <-s.quitChan: - break LOOP - } +func (l *slackLogger) Write(m Messager) error { + payload, err := l.buildPayload(m) + if err != nil { + return fmt.Errorf("build payload: %v", err) } - for { - if len(s.msgChan) == 0 { - break - } - - s.write(<-s.msgChan) + err = l.postMessage(bytes.NewReader([]byte(payload))) + if err != nil { + return fmt.Errorf("post message: %v", err) } - s.quitChan <- struct{}{} // Notify the cleanup is done. + return nil } -func (s *slack) Destroy() { - s.quitChan <- struct{}{} - <-s.quitChan +func init() { + NewRegister(ModeSlack, func(v interface{}) (Logger, error) { + cfg, ok := v.(SlackConfig) + if !ok { + return nil, fmt.Errorf("invalid config object: want %T got %T", SlackConfig{}, v) + } - close(s.msgChan) - close(s.quitChan) -} + if cfg.URL == "" { + return nil, errors.New("empty URL") + } -func init() { - Register(SLACK, newSlack) + colors := slackColors + if cfg.Colors != nil { + if len(cfg.Colors) != 5 { + return nil, fmt.Errorf("colors must have exact 5 elements, but got %d", len(cfg.Colors)) + } + colors = cfg.Colors + } + + return &slackLogger{ + level: cfg.Level, + url: cfg.URL, + colors: colors, + client: http.DefaultClient, + }, nil + }) } diff --git a/slack_test.go b/slack_test.go index 2da21a5..ec4236a 100644 --- a/slack_test.go +++ b/slack_test.go @@ -1,58 +1,235 @@ -// Copyright 2017 Unknwon -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. - package clog import ( + "bytes" + "errors" + "io/ioutil" + "net/http" "testing" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/assert" ) -func Test_slack_Init(t *testing.T) { - Convey("Init Slack logger", t, func() { - Convey("Mismatched config object", func() { - err := New(SLACK, struct{}{}) - So(err, ShouldNotBeNil) - _, ok := err.(ErrConfigObject) - So(ok, ShouldBeTrue) +func Test_ModeSlack(t *testing.T) { + defer Remove(ModeSlack) + + tests := []struct { + name string + config interface{} + wantLevel Level + wantErr error + }{ + { + name: "valid config", + config: SlackConfig{ + Level: LevelInfo, + URL: "https://slack.com", + Colors: slackColors, + }, + wantErr: nil, + }, + { + name: "invalid config", + config: "random things", + wantErr: errors.New("initialize logger: invalid config object: want clog.SlackConfig got string"), + }, + { + name: "invalid URL", + config: SlackConfig{}, + wantErr: errors.New("initialize logger: empty URL"), + }, + { + name: "incorrect number of colors", + config: SlackConfig{ + URL: "https://slack.com", + Colors: []string{}, + }, + wantErr: errors.New("initialize logger: colors must have exact 5 elements, but got 0"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantErr, New(ModeSlack, 10, tt.config)) }) + } + + assert.Equal(t, 1, mgr.len()) + assert.Equal(t, ModeSlack, mgr.loggers[0].Mode()) + assert.Equal(t, LevelInfo, mgr.loggers[0].Level()) +} - Convey("Valid config object", func() { - So(New(SLACK, SlackConfig{ - URL: "https://slack.com", - }), ShouldBeNil) - - Convey("Incorrect level", func() { - err := New(SLACK, SlackConfig{ - Level: LEVEL(-1), - }) - So(err, ShouldNotBeNil) - _, ok := err.(ErrInvalidLevel) - So(ok, ShouldBeTrue) +func Test_slackLogger_buildPayload(t *testing.T) { + t.Run("default colors", func(t *testing.T) { + l := &slackLogger{ + colors: slackColors, + } + + tests := []struct { + name string + msg *message + want string + }{ + { + name: "trace", + msg: &message{ + level: LevelTrace, + body: "test message", + }, + want: `{"attachments":[{"text":"test message","color":""}]}`, + }, + { + name: "info", + msg: &message{ + level: LevelInfo, + body: "test message", + }, + want: `{"attachments":[{"text":"test message","color":"#3aa3e3"}]}`, + }, + { + name: "warn", + msg: &message{ + level: LevelWarn, + body: "test message", + }, + want: `{"attachments":[{"text":"test message","color":"warning"}]}`, + }, + { + name: "error", + msg: &message{ + level: LevelError, + body: "test message", + }, + want: `{"attachments":[{"text":"test message","color":"danger"}]}`, + }, + { + name: "fatal", + msg: &message{ + level: LevelFatal, + body: "test message", + }, + want: `{"attachments":[{"text":"test message","color":"#ff0200"}]}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload, err := l.buildPayload(tt.msg) + assert.Nil(t, err) + assert.Equal(t, tt.want, payload) }) - }) + } + }) + + t.Run("custom colors", func(t *testing.T) { + l := &slackLogger{ + colors: []string{"#1", "#2", "#3", "#4", "#5"}, + } + + tests := []struct { + name string + msg *message + want string + }{ + { + name: "trace", + msg: &message{ + level: LevelTrace, + body: "test message", + }, + want: `{"attachments":[{"text":"test message","color":"#1"}]}`, + }, + { + name: "info", + msg: &message{ + level: LevelInfo, + body: "test message", + }, + want: `{"attachments":[{"text":"test message","color":"#2"}]}`, + }, + { + name: "warn", + msg: &message{ + level: LevelWarn, + body: "test message", + }, + want: `{"attachments":[{"text":"test message","color":"#3"}]}`, + }, + { + name: "error", + msg: &message{ + level: LevelError, + body: "test message", + }, + want: `{"attachments":[{"text":"test message","color":"#4"}]}`, + }, + { + name: "fatal", + msg: &message{ + level: LevelFatal, + body: "test message", + }, + want: `{"attachments":[{"text":"test message","color":"#5"}]}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload, err := l.buildPayload(tt.msg) + assert.Nil(t, err) + assert.Equal(t, tt.want, payload) + }) + } }) } -func Test_buildSlackPayload(t *testing.T) { - Convey("Build Slack payload", t, func() { - payload, err := buildSlackPayload(&Message{ - Level: INFO, - Body: "test message", +type roundTripFunc func(req *http.Request) *http.Response + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req), nil +} + +func Test_slackLogger_postMessage(t *testing.T) { + l := &slackLogger{ + client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) *http.Response { + statusCode := 500 + respBody := "" + switch req.URL.String() { + case "https://slack.com/success": + statusCode = 200 + respBody = `OK` + case "https://slack.com/non-success-response-status-code": + statusCode = 404 + respBody = `Page Not Found` + } + + return &http.Response{ + StatusCode: statusCode, + Body: ioutil.NopCloser(bytes.NewBufferString(respBody)), + Header: make(http.Header), + } + }), + }, + } + + tests := []struct { + name string + url string + wantErr error + }{ + { + name: "success", + url: "https://slack.com/success", + wantErr: nil, + }, + { + name: "non-success response status code", + url: "https://slack.com/non-success-response-status-code", + wantErr: errors.New("non-success response status code 404 with body: Page Not Found"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l.url = tt.url + assert.Equal(t, tt.wantErr, l.postMessage(bytes.NewReader([]byte("payload")))) }) - So(err, ShouldBeNil) - So(payload, ShouldEqual, `{"attachments":[{"text":"test message","color":"#3aa3e3"}]}`) - }) + } }