Compare commits

...

8 commits

12 changed files with 1940 additions and 130 deletions

View file

@ -1,21 +1,31 @@
# This file is a template, and might need editing before it works on your project.
image: golang:latest
stages:
- test
#- build
- deploy
test:
stage: test
before_script:
- export PATH=$PATH:$GOPATH/bin
- go install gotest.tools/gotestsum@latest
script:
- go test -race
pages:
stage: deploy
script:
- mkdir public
- echo "<body>go/log page</body>" >public/index.html
- gotestsum --junitfile tests.xml -- -coverprofile=coverage.txt -covermode atomic .
after_script:
- export PATH=$PATH:$GOPATH/bin
- go install github.com/boumenot/gocover-cobertura@latest
- gocover-cobertura < coverage.txt > coverage.xml
- go tool cover -func=coverage.txt | grep "total:"
coverage: '/total:\s+\(statements\)\s+(\d+.\d+\%)/'
artifacts:
paths:
- public
reports:
junit: tests.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
lint:
stage: test
image: golangci/golangci-lint
script:
- golangci-lint run

1625
.golangci.yml Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,33 @@
# go/logging
# go/logging Changelog
## Unreleased
- Uncompatible: log level names hace changed. They were fully
capitalized, only their first letter is capitalized now: DEBUG -> Debug,
INFO -> Info, etc.
- Uncompatible NoopBackend.Level() now returns DefaultLevel instead of Fatal
- Fix: creates new logger with level DefaultLevel instead of Debug
- Fix: FileBackend now properly closes the file befor reopening it (fixes a
## v0.4.1 (2022-06-03)
- Ensure all backends implement the interface `BACKEND`.
- `FileBackend` and `SyslogBackend` always returned errors for `Write`
operations.
## v0.4.0 (2022-05-31)
- Add three new log levels: `Trace`, `Notice` and `Alert` with the following
order: `Trace`, `Debug`, `Info`, `Notice`, `Warning`, `Error`, `Critical`,
`Alert`, `Fatal`.
As Syslog has no equivalent of `Trace`, it is mapped to `Debug`.
## v0.3.0 (2020-05-17)
### Incompatible Changes
- Log level names have changed. They were fully capitalized, only their first
letter is capitalized now: DEBUG -> Debug, INFO -> Info, etc.
- NoopBackend.Level() now returns DefaultLevel instead of Fatal
- New loggers are created with level `DefaultLevel` instead of `Debug`
- The `Backend` interface now has a `Close()` method, so that backends can free
the resources they use
### Fixes
- FileBackend now properly closes the file before reopening it (fixes a
potential file descriptor leak)
- Logger methods did not always acquire locks, causing race conditions

View file

@ -4,13 +4,14 @@ import (
"fmt"
"io"
"os"
"path/filepath"
)
// Backend is the interface that specifies the methods that a backend must
// implement
// implement.
type Backend interface {
Write(*Record) error
SetFormatter(*Formatter)
SetFormatter(Formatter)
SetLevel(Level)
Level() Level
Reopen() error
@ -21,88 +22,94 @@ type Backend interface {
// Backend to write in file-like objects
//
var _ Backend = &FileBackend{}
// FileBackend is a backend that writes to a file.
type FileBackend struct {
formatter Formatter
l io.Writer
formatter *Formatter
level Level
filepath string
level Level
}
// NewStdoutBackend creates a new backend to write the logs on the standard
// output
func NewStdoutBackend() (b *FileBackend) {
b = &FileBackend{
// output.
func NewStdoutBackend() *FileBackend {
return &FileBackend{
l: os.Stdout,
formatter: &defaultFormatter,
formatter: defaultFormatter,
}
return
}
// NewStderrBackend creates a new backend to write the logs on the error output
func NewStderrBackend() (b *FileBackend) {
b = &FileBackend{
// NewStderrBackend creates a new backend to write the logs on the error output.
func NewStderrBackend() *FileBackend {
return &FileBackend{
l: os.Stderr,
formatter: &defaultFormatter,
formatter: defaultFormatter,
}
return
}
// NewFileBackend creates a new backend to write the logs in a given file
// NewFileBackend creates a new backend to write the logs in a given file.
func NewFileBackend(filename string) (*FileBackend, error) {
fd, err := os.OpenFile(filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) //nolint: gosec
fd, err := os.OpenFile(filepath.Clean(filename),
os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("Cannot open log file %s: %w", filename, err)
return nil, fmt.Errorf("cannot open log file %s: %w", filename, err)
}
b := &FileBackend{
l: fd,
formatter: &defaultFormatter,
formatter: defaultFormatter,
filepath: filename,
}
return b, nil
}
// NewIoBackend creates a new backend to write the logs in a given io.Writer
func NewIoBackend(buf io.Writer) (b *FileBackend) {
// NewIoBackend creates a new backend to write the logs in a given io.Writer.
func NewIoBackend(buf io.Writer) *FileBackend {
return &FileBackend{
l: buf,
formatter: &defaultFormatter,
formatter: defaultFormatter,
}
}
func (b FileBackend) Write(r *Record) error {
text := (*b.formatter)(r)
text := b.formatter(r)
_, err := io.WriteString(b.l, text)
return err
if err != nil {
return fmt.Errorf("cannot write logs: %w", err)
}
return nil
}
// SetLevel changes the log level of the backend
// SetLevel changes the log level of the backend.
func (b *FileBackend) SetLevel(l Level) {
b.level = l
}
// Level returns the log level set for this backend
// Level returns the log level set for this backend.
func (b *FileBackend) Level() Level {
return b.level
}
// SetFormatter defines the formatter for this backend
func (b *FileBackend) SetFormatter(f *Formatter) {
// SetFormatter defines the formatter for this backend.
func (b *FileBackend) SetFormatter(f Formatter) {
b.formatter = f
}
// Reopen closes and reopens the file it writes to. It should be used after log
// rotation
// rotation.
func (b *FileBackend) Reopen() error {
if err := b.Close(); err != nil {
return err
}
fd, err := os.OpenFile(b.filepath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) //nolint: gosec
fd, err := os.OpenFile(b.filepath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
if err != nil {
return fmt.Errorf("Cannot open log file %s: %w", b.filepath, err)
return fmt.Errorf("cannot open log file %s: %w", b.filepath, err)
}
b.l = fd
@ -110,7 +117,7 @@ func (b *FileBackend) Reopen() error {
return nil
}
// Close closes the underlying file used by the backend
// Close closes the underlying file used by the backend.
func (b *FileBackend) Close() error {
if b.filepath == "" {
return nil
@ -118,7 +125,7 @@ func (b *FileBackend) Close() error {
if c, ok := b.l.(io.Closer); ok {
if err := c.Close(); err != nil {
return err
return fmt.Errorf("cannot close log file: %w", err)
}
}
@ -129,36 +136,38 @@ func (b *FileBackend) Close() error {
// Noop Backend
//
// NoopBackend does nothing and discards all log entries without writing them anywhere
var _ Backend = &NoopBackend{}
// NoopBackend does nothing and discards all log entries without writing them anywhere.
type NoopBackend struct{}
// NewNoopBackend creates a noop backend
func NewNoopBackend() (Backend, error) {
// NewNoopBackend creates a noop backend.
func NewNoopBackend() (*NoopBackend, error) {
return &NoopBackend{}, nil
}
// Write is a noop
func (nb *NoopBackend) Write(r *Record) error {
// Write is a noop.
func (*NoopBackend) Write(_ *Record) error {
return nil
}
// SetFormatter is a noop
func (nb *NoopBackend) SetFormatter(f *Formatter) {}
// SetFormatter is a noop.
func (*NoopBackend) SetFormatter(_ Formatter) {}
// SetLevel is a noop
func (nb *NoopBackend) SetLevel(level Level) {}
// SetLevel is a noop.
func (*NoopBackend) SetLevel(_ Level) {}
// Level always returns DefeultLevel
func (nb *NoopBackend) Level() Level {
// Level always returns DefeultLevel.
func (*NoopBackend) Level() Level {
return DefaultLevel
}
// Reopen is a noop
func (nb *NoopBackend) Reopen() error {
// Reopen is a noop.
func (*NoopBackend) Reopen() error {
return nil
}
// Close is a noop
func (nb *NoopBackend) Close() error {
// Close is a noop.
func (*NoopBackend) Close() error {
return nil
}

View file

@ -1,88 +1,114 @@
//go:build !windows && !nacl && !plan9
// +build !windows,!nacl,!plan9
package logging
import (
"errors"
"fmt"
"log/syslog"
"strings"
)
var errUnknownFacility = errors.New("unknown facility")
//
// Syslog Backend
//
// SyslogBackend writes the logs to a syslog system
var _ Backend = &SyslogBackend{}
// SyslogBackend writes the logs to a syslog system.
type SyslogBackend struct {
w *syslog.Writer
formatter *Formatter
formatter Formatter
level Level
}
// NewSyslogBackend initializes a new connection to a syslog server with the
// given facility.
// tag can contain an identifier for the log stream. It defaults to os.Arg[0].
func NewSyslogBackend(facilityName string, tag string) (Backend, error) {
func NewSyslogBackend(facilityName, tag string) (*SyslogBackend, error) {
f, err := facility(facilityName)
if err != nil {
return nil, err
}
w, err := syslog.New(f, tag)
if err != nil {
return nil, err
return nil, fmt.Errorf("cannot initialize syslog: %w", err)
}
sb := &SyslogBackend{
w: w,
formatter: &basicFormatter,
formatter: basicFormatter,
}
return sb, nil
}
// Write sends an entry to the syslog server
func (sb *SyslogBackend) Write(r *Record) (err error) {
text := (*sb.formatter)(r)
// Write sends an entry to the syslog server.
func (sb *SyslogBackend) Write(r *Record) error {
var err error
text := sb.formatter(r)
switch r.Level {
case Debug:
case Trace, Debug:
err = sb.w.Debug(text)
case Info:
err = sb.w.Info(text)
case Notice:
err = sb.w.Notice(text)
case Warning:
err = sb.w.Warning(text)
case Error:
err = sb.w.Err(text)
case Critical:
err = sb.w.Crit(text)
case Alert:
err = sb.w.Alert(text)
case Fatal:
err = sb.w.Emerg(text)
}
return err
if err != nil {
return fmt.Errorf("cannot log to syslog: %w", err)
}
return nil
}
// SetFormatter defines the formatter for this backend
func (sb *SyslogBackend) SetFormatter(f *Formatter) {
// SetFormatter defines the formatter for this backend.
func (sb *SyslogBackend) SetFormatter(f Formatter) {
sb.formatter = f
}
// SetLevel changes the log level of the backend
// SetLevel changes the log level of the backend.
func (sb *SyslogBackend) SetLevel(level Level) {
sb.level = level
}
// Level returns the log level set for this backend
// Level returns the log level set for this backend.
func (sb *SyslogBackend) Level() Level {
return sb.level
}
// Reopen is a no-op
func (sb *SyslogBackend) Reopen() error {
// Reopen is a no-op.
func (*SyslogBackend) Reopen() error {
return nil
}
// Close closes the connection to the syslog daemon
// Close closes the connection to the syslog daemon.
func (sb *SyslogBackend) Close() error {
return sb.w.Close()
if err := sb.w.Close(); err != nil {
return fmt.Errorf("cannot close syslog: %w", err)
}
return nil
}
//nolint:gochecknoglobals // global var is used by design
var facilities = map[string]syslog.Priority{
"kern": syslog.LOG_KERN,
"user": syslog.LOG_USER,
@ -109,7 +135,7 @@ var facilities = map[string]syslog.Priority{
func facility(name string) (syslog.Priority, error) {
p, ok := facilities[strings.ToLower(name)]
if !ok {
return 0, fmt.Errorf("Facility '%s' does not exist", name)
return 0, fmt.Errorf("facility '%s' does not exist: %w", name, errUnknownFacility)
}
return p, nil

48
backend_test.go Normal file
View file

@ -0,0 +1,48 @@
package logging
import (
"bytes"
"errors"
"testing"
"github.com/stretchr/testify/require"
)
type WriteErrorBuffer struct{}
func (*WriteErrorBuffer) Write(_ []byte) (int, error) {
return 0, errors.New("cannot write")
}
func TestFileBackendWrite(t *testing.T) {
t.Parallel()
t.Run("It should write the logs to the buffer", func(t *testing.T) {
t.Parallel()
buf := new(bytes.Buffer)
b := NewIoBackend(buf)
err := b.Write(&Record{
Level: Info,
Message: "my log line",
})
require.NoError(t, err)
require.Contains(t, buf.String(), "my log line")
})
t.Run("It should return an error if it cannot write the log", func(t *testing.T) {
t.Parallel()
buf := new(WriteErrorBuffer)
b := NewIoBackend(buf)
err := b.Write(&Record{
Level: Info,
Message: "my log line",
})
require.Error(t, err)
})
}

2
go.mod
View file

@ -1,3 +1,5 @@
module code.bcarlin.xyz/go/logging
go 1.13
require github.com/stretchr/testify v1.7.1 // indirect

10
go.sum Normal file
View file

@ -0,0 +1,10 @@
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/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.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -7,37 +7,39 @@ import (
"sync"
)
const exitCodeFatal = 100
// Logger is a facility that writes logs to one or more backands (files,
// stdout/stderr, syslog, etc.) which can be configured independently
//
// Loggers are concurrent-safe.
type Logger struct {
sync.Mutex
mutex sync.Mutex
name string
backends []Backend
}
// NewLogger initializes a new Logger with no backend and with the default log level.
func NewLogger(name string) (l *Logger) {
l = &Logger{
func NewLogger(name string) *Logger {
l := &Logger{
name: name,
}
return
return l
}
// AddBackend add a new Backend to the logger. All set backends are kept.
func (l *Logger) AddBackend(b Backend) {
l.Lock()
defer l.Unlock()
l.mutex.Lock()
defer l.mutex.Unlock()
l.backends = append(l.backends, b)
}
// SetBackend sets the backend list to the logger. Any existing backend will be lost.
func (l *Logger) SetBackend(b ...Backend) {
l.Lock()
defer l.Unlock()
l.mutex.Lock()
defer l.mutex.Unlock()
l.backends = b
}
@ -50,11 +52,11 @@ func (l *Logger) SetLevel(level Level) {
}
type buffer struct {
level Level
logger *Logger
level Level
}
func (b *buffer) Write(p []byte) (n int, err error) {
func (b *buffer) Write(p []byte) (int, error) {
b.logger.Log(b.level, string(p))
return len(p), nil
}
@ -70,82 +72,119 @@ func (l *Logger) AsStdLog(level Level) *log.Logger {
}
// Log sends a record containing the message `m` to the registered backends
// whose level is at least `level`
// whose level is at least `level`.
func (l *Logger) Log(level Level, m string) {
l.Lock()
defer l.Unlock()
l.mutex.Lock()
defer l.mutex.Unlock()
r := NewRecord(l.name, level, m)
for _, backend := range l.backends {
if r.Level >= backend.Level() {
_ = backend.Write(r)
if err := backend.Write(r); err != nil {
//revive:disable-next-line:unhandled-error stop error handling recursion!
fmt.Fprintf(os.Stderr, "Cannot write logs: %v", err)
}
}
}
}
// Debug logs a message with the Debug level
// Trace logs a message with the Trace level.
func (l *Logger) Trace(text string) {
l.Log(Trace, text)
}
// Tracef formats the message with given args and logs the result with the
// Trace level.
func (l *Logger) Tracef(text string, args ...interface{}) {
l.Trace(fmt.Sprintf(text, args...))
}
// Debug logs a message with the Debug level.
func (l *Logger) Debug(text string) {
l.Log(Debug, text)
}
// Debugf formats the message with given args and logs the result with the
// Debug level
// Debug level.
func (l *Logger) Debugf(text string, args ...interface{}) {
l.Debug(fmt.Sprintf(text, args...))
}
// Info logs a message with the Info level
// Info logs a message with the Info level.
func (l *Logger) Info(text string) {
l.Log(Info, text)
}
// Infof formats the message with given args and logs the result with the
// Info level
// Info level.
func (l *Logger) Infof(text string, args ...interface{}) {
l.Info(fmt.Sprintf(text, args...))
}
// Warning logs a message with the Warning level
// Notice logs a message with the Notice level.
func (l *Logger) Notice(text string) {
l.Log(Notice, text)
}
// Noticef formats the message with given args and logs the result with the
// Notice level.
func (l *Logger) Noticef(text string, args ...interface{}) {
l.Notice(fmt.Sprintf(text, args...))
}
// Warning logs a message with the Warning level.
func (l *Logger) Warning(text string) {
l.Log(Warning, text)
}
// Warningf formats the message with given args and logs the result with the
// Warning level
// Warning level.
func (l *Logger) Warningf(text string, args ...interface{}) {
l.Warning(fmt.Sprintf(text, args...))
}
// Error logs a message with the Error level
// Error logs a message with the Error level.
func (l *Logger) Error(text string) {
l.Log(Error, text)
}
// Errorf formats the message with given args and logs the result with the
// Error level
// Error level.
func (l *Logger) Errorf(text string, args ...interface{}) {
l.Error(fmt.Sprintf(text, args...))
}
// Critical logs a message with the Critical level
// Critical logs a message with the Critical level.
func (l *Logger) Critical(text string) {
l.Log(Critical, text)
}
// Criticalf formats the message with given args and logs the result with the
// Critical level
// Criticalf formats the message with given args and logs the result with the.
// Critical level.
func (l *Logger) Criticalf(text string, args ...interface{}) {
l.Critical(fmt.Sprintf(text, args...))
}
// Fatal logs a message with the Fatal level
// Alert logs a message with the Alert level.
func (l *Logger) Alert(text string) {
l.Log(Alert, text)
}
// Alertf formats the message with given args and logs the result with the.
// Alert level.
func (l *Logger) Alertf(text string, args ...interface{}) {
l.Alert(fmt.Sprintf(text, args...))
}
// Fatal logs a message with the Fatal level.
func (l *Logger) Fatal(text string) {
l.Log(Debug, text)
os.Exit(100)
os.Exit(exitCodeFatal) //nolint:revive // This is wanted if fatal is called
}
// Fatalf formats the message with given args and logs the result with the
// Fatal level
// Fatal level.
func (l *Logger) Fatalf(text string, args ...interface{}) {
l.Fatal(fmt.Sprintf(text, args...))
}

View file

@ -12,40 +12,53 @@ package logging defines the following builtin backends:
A backend can safely be used by multiple loggers.
It is the caller's responsability to call Close on backends when they are not
It is the caller's responsibility to call Close on backends when they are not
used anymore to free their resources.
*/
package logging
import (
"errors"
"fmt"
"strings"
"sync"
)
//nolint:gochecknoglobals // designed this way
var (
loggers map[string]*Logger
lock sync.Mutex
errInvalidLogLevel = errors.New("invalid log level")
)
// Level is the type of log levels
// Level is the type of log levels.
type Level byte
//nolint: golint
//revive:disable:exported
const (
Debug Level = iota
Trace Level = iota
Debug
Info
Notice
Warning
Error
Critical
Alert
Fatal
DefaultLevel = Info
)
var levelNames = [6]string{"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "FATAL"}
//revive:enable:exported
// Name returns the name of the log level
//nolint:gochecknoglobals // designed this way
var levelNames = [...]string{
"TRACE", "DEBUG", "INFO", "NOTICE", "WARNING",
"ERROR", "CRITICAL", "ALERT", "FATAL",
}
// Name returns the name of the log level.
func (l Level) Name() string {
return levelNames[l]
}
@ -58,7 +71,8 @@ func LevelByName(l string) (Level, error) {
return Level(pos), nil
}
}
return Debug, fmt.Errorf("Invalid log level %s", l)
return Debug, fmt.Errorf("unknown log level %q: %w", l, errInvalidLogLevel)
}
// Formatter is the types of the functions that can be used to format a log
@ -66,14 +80,15 @@ func LevelByName(l string) (Level, error) {
type Formatter func(*Record) string
// GetLogger returns a logger given its name. if the logger does not exist, it
// initializes one with the defaults (it logs to stdout with level debug)
func GetLogger(name string) (l *Logger) {
// initializes one with the defaults (it logs to stdout with level INFO).
func GetLogger(name string) *Logger {
lock.Lock()
defer lock.Unlock()
if name == "" {
name = "default"
}
l, ok := loggers[name]
if !ok {
l = NewLogger(name)
@ -82,21 +97,23 @@ func GetLogger(name string) (l *Logger) {
l.SetLevel(DefaultLevel)
loggers[name] = l
}
return l
}
var defaultFormatter Formatter = func(r *Record) string {
func defaultFormatter(r *Record) string {
return fmt.Sprintf("%s [%-8s] %s: %s\n",
r.Timestamp.Format("2006/01/02 15:04:05"), r.Level.Name(), r.Logger,
strings.TrimSpace(r.Message))
}
var basicFormatter Formatter = func(r *Record) string {
func basicFormatter(r *Record) string {
return fmt.Sprintf("%s: %s", r.Logger, strings.TrimSpace(r.Message))
}
//nolint:gochecknoinits // init is used by design
func init() {
loggers = make(map[string]*Logger, 3)
loggers = map[string]*Logger{}
logger := NewLogger("default")
backend := NewStdoutBackend()

View file

@ -3,11 +3,14 @@ package logging
import "testing"
func Test_LevelByName(t *testing.T) {
t.Parallel()
for _, levelName := range levelNames {
l, e := LevelByName(levelName)
if e != nil {
t.Errorf("level %s not recognized", levelName)
}
if l.Name() != levelName {
t.Errorf("expected '%s', got '%s'", levelName, l.Name())
}

View file

@ -5,7 +5,7 @@ import (
)
// Record contains the data to be logged. It is passed to a formatter to
// generate the logged message
// generate the logged message.
type Record struct {
Logger string
Timestamp time.Time
@ -13,13 +13,12 @@ type Record struct {
Message string
}
// NewRecord creates a new record, setting its timestamp to time.Now()
func NewRecord(name string, l Level, m string) (r *Record) {
r = &Record{
// NewRecord creates a new record, setting its timestamp to time.Now().
func NewRecord(name string, l Level, m string) *Record {
return &Record{
Logger: name,
Level: l,
Message: m,
Timestamp: time.Now(),
}
return
}