Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Loggers: `logger.NewFmtLogger`, `logger.NewSlogLogger` (Go 1.21+), and the adapt
| `tinyconf.Subscribe(ctx, m, onChange) error` | calls `onChange(prev, next *T)` after the changes of the config or the sub config `T` |
| `tinyconf.Current[T](m) *T` | returns the current version of the config or the sub config `T` |
| `ReloadStatus() ReloadStatus` | state of watching and reloading for the health checks: the last error, stopped drivers |
| `SetLogger(l Logger)` | replaces the logger, safe for concurrent use; with `WithDeferredLogger()` the records of parsing before it are written to the new logger |

`Parse` parses all fields even if some of them have errors, all errors are returned as one error that can be checked
with `errors.Is` and `errors.As`.
Expand Down
33 changes: 33 additions & 0 deletions docs/features/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,39 @@ type Config struct {

Add the tag to the parent, `yaml:"http"`, to read the value from `http.host`.

## Logger from the config

The level, the format or the output of the logger are often a part of the config, but the config is parsed by the manager that already needs the logger. `WithDeferredLogger` breaks this cycle: the manager keeps the log records in memory, and `SetLogger` writes them to the logger created from the parsed config.

```go
config, _ := tinyconf.New(
tinyconf.WithDriver(tagDriver),
tinyconf.WithDriver(yamlDriver),
tinyconf.WithDeferredLogger(), // the records are kept until SetLogger
)
conf := &Config{}
_ = config.Register(conf)
err := config.Parse(ctx, conf)

log := newLogger(conf.Logging) // the logger of the application from its config
config.SetLogger(logger.NewSlogLogger(log)) // the kept records are written to it
if err != nil {
log.Error("config is not parsed", "error", err)
os.Exit(1)
}

// the logger follows the changes of the config without restart
_ = tinyconf.Subscribe(ctx, config, func(_, next *Logging) {
config.SetLogger(logger.NewSlogLogger(newLogger(*next)))
})
```

* The records of the first `Parse` get to the logger of the application in its format, its level filters them.
* `SetLogger` is safe for concurrent use with `Parse`, reloads and other goroutines. The records written during `SetLogger` wait for the kept ones, so nothing is lost and the order is kept.
* Up to 1000 records are kept, the oldest are dropped, and the logger gets a warning with the count of the dropped records.
* `SetLogger(nil)` disables logging, the kept records are dropped.
* `SetLogger` replaces the logger set by `WithLogger` too, without `WithDeferredLogger` nothing is kept.

## Bundled loggers

```go
Expand Down
2 changes: 2 additions & 0 deletions docs/features/manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ config, err := tinyconf.New(
|---|---|
| `WithDriver(driver)` | adds the driver, can be used many times |
| `WithLogger(logger)` | sets the logger, see [Logging](logging.md) |
| `WithDeferredLogger()` | keeps the log records until the logger is set by `SetLogger`, see [Logger from the config](logging.md#logger-from-the-config) |

## Priority of drivers

Expand Down Expand Up @@ -67,6 +68,7 @@ Every request of the drivers is limited to 30 seconds anyway. The reloads use th
| `tinyconf.Subscribe(ctx, m, onChange)` | calls `onChange` after the changes of the config or the sub config, see [Reloading](reloading.md) |
| `tinyconf.Current[T](m)` | returns the current version of the config or the sub config, see [Reloading](reloading.md) |
| `ReloadStatus()` | state of watching and reloading for the health checks, see [Health checks](reloading.md#health-checks) |
| `SetLogger(logger)` | replaces the logger, safe for concurrent use, see [Logger from the config](logging.md#logger-from-the-config) |

## Errors

Expand Down
3 changes: 2 additions & 1 deletion example/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ http:
database:
host: db.internal
logging:
level: info
# the logger is created from this config, debug shows the history of every field
level: error
hosts: [a.example.com]
15 changes: 14 additions & 1 deletion example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package main
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"time"
Expand Down Expand Up @@ -59,9 +60,19 @@ func must[T any](value T, err error) T {
return value
}

// newLogger creates the logger of the application from its config.
func newLogger(conf Logging) tinyconf.Logger {
var level slog.Level
if err := level.UnmarshalText([]byte(conf.Level)); err != nil {
level = slog.LevelInfo
}
return logger.NewSlogLogger(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
}

func main() {
config := must(tinyconf.New(
tinyconf.WithLogger(logger.NewFmtLogger(logger.ERROR)),
// the logger is configured by the config itself, the records of parsing are kept until SetLogger
tinyconf.WithDeferredLogger(),
// the next driver overrides the values of the previous one
tinyconf.WithDriver(must(tag.New("initial"))),
tinyconf.WithDriver(must(yaml.New("config.yaml", yaml.WithOptional()))),
Expand All @@ -78,6 +89,7 @@ func main() {
parseCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err := config.Parse(parseCtx, conf)
config.SetLogger(newLogger(conf.Logging))
if conf.Doc != "" {
fmt.Print(config.GenDoc(conf.Doc))
return
Expand All @@ -99,6 +111,7 @@ func main() {
}))
must(0, tinyconf.Subscribe(ctx, config, func(prev, next *Logging) {
fmt.Printf("log level is changed: %s -> %s\n", prev.Level, next.Level)
config.SetLogger(newLogger(*next))
}))
fmt.Println("change config.yaml to see the changes, stop by Ctrl+C")
<-ctx.Done()
Expand Down
65 changes: 65 additions & 0 deletions example_slog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//go:build go1.21

package tinyconf_test

import (
"context"
"fmt"
"log/slog"
"os"

"github.com/insei/tinyconf/v2"
"github.com/insei/tinyconf/v2/drivers/tag"
"github.com/insei/tinyconf/v2/logger"
)

type Logging struct {
Level string `initial:"debug" doc:"log level: debug, info, warn, error"`
}

type AppConfig struct {
Logging Logging
Port int `initial:"8080"`
}

// newLogger creates the logger of the application from its config, the time is removed for the stable output.
func newLogger(conf Logging) *slog.Logger {
var level slog.Level
_ = level.UnmarshalText([]byte(conf.Level))
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.TimeKey {
return slog.Attr{}
}
return attr
},
}))
}

// The logger of the application is configured by the config itself: the records of the parsing are kept
// until the logger is created from the parsed config.
func ExampleWithDeferredLogger() {
config := must(tinyconf.New(
tinyconf.WithDriver(must(tag.New("initial"))),
tinyconf.WithDeferredLogger(),
))
conf := &AppConfig{}
if err := config.Register(conf); err != nil {
panic(err)
}
err := config.Parse(context.Background(), conf)

// the kept records are written to the logger, the next records are written directly
config.SetLogger(logger.NewSlogLogger(newLogger(conf.Logging)))
if err != nil {
panic(err)
}
fmt.Println("port:", conf.Port)
// Output:
// level=DEBUG msg=initial config=*tinyconf_test.AppConfig field=Logging.Level value=""
// level=DEBUG msg=initial config=*tinyconf_test.AppConfig field=Port value=0
// level=DEBUG msg=override config=*tinyconf_test.AppConfig driver=tag field=Logging.Level key=initial value=debug
// level=DEBUG msg=override config=*tinyconf_test.AppConfig driver=tag field=Port key=initial value=8080
// port: 8080
}
145 changes: 145 additions & 0 deletions logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package tinyconf

import (
"sync"
"sync/atomic"
)

// deferredLimit is the maximum number of the records kept by the deferred logger, the oldest records are dropped.
const deferredLimit = 1000

// loggerBox stores the logger of the Manager in atomic.Pointer.
type loggerBox struct {
Logger
}

// managerLogger is the logger of the Manager, it is replaced by SetLogger concurrently with the parsing and reloads.
type managerLogger struct {
mu sync.Mutex
box atomic.Pointer[loggerBox]
}

var noop Logger = &noopLogger{}

// logger returns the current logger, noop logger if it is not set.
func (c *Manager) logger() Logger {
if box := c.log.box.Load(); box != nil {
return box.Logger
}
return noop
}

func (c *Manager) storeLogger(l Logger) {
c.log.box.Store(&loggerBox{Logger: l})
}

// SetLogger replaces the logger of the Manager, nil disables logging.
// It is safe for concurrent use: Parse, reloads and other goroutines switch to the new logger.
// The records kept since WithDeferredLogger are written to the logger by the first call,
// so the application can create its logger from the parsed config and get the logs of this parsing too.
func (c *Manager) SetLogger(l Logger) {
if l == nil {
l = noop
}
c.log.mu.Lock()
defer c.log.mu.Unlock()
if deferred, ok := c.logger().(*deferredLogger); ok {
deferred.buf.flush(c, l)
return
}
c.storeLogger(l)
}

type logLevel uint8

const (
debugLevel logLevel = iota
infoLevel
warnLevel
errorLevel
)

type logRecord struct {
level logLevel
msg string
with []Field
fields []Field
}

func (r logRecord) writeTo(l Logger) {
if len(r.with) > 0 {
l = l.With(r.with...)
}
switch r.level {
case debugLevel:
l.Debug(r.msg, r.fields...)
case infoLevel:
l.Info(r.msg, r.fields...)
case warnLevel:
l.Warn(r.msg, r.fields...)
default:
l.Error(r.msg, r.fields...)
}
}

// deferredBuffer keeps the records of the deferred logger until the logger is set by Manager.SetLogger.
type deferredBuffer struct {
mu sync.Mutex
records []logRecord
dropped int
// manager is set by the flush, the next records are written to the current logger of the manager
manager *Manager
}

func (b *deferredBuffer) write(record logRecord) {
b.mu.Lock()
if b.manager != nil {
b.mu.Unlock()
// the logger was taken before SetLogger, for example by Parse running concurrently
record.writeTo(b.manager.logger())
return
}
defer b.mu.Unlock()
if len(b.records) == deferredLimit {
b.records[0] = logRecord{}
b.records = b.records[1:]
b.dropped++
}
b.records = append(b.records, record)
}

// flush sets the logger of the manager and writes the kept records to it.
// The records written concurrently wait for the end of the flush, so they are written after the kept ones.
func (b *deferredBuffer) flush(c *Manager, l Logger) {
b.mu.Lock()
defer b.mu.Unlock()
c.storeLogger(l)
if b.dropped > 0 {
l.Warn("log records are dropped before the logger is set", LogField("count", b.dropped))
}
for _, record := range b.records {
record.writeTo(l)
}
b.records, b.dropped, b.manager = nil, 0, c
}

// deferredLogger keeps the records in memory until the logger is set by Manager.SetLogger, see WithDeferredLogger.
type deferredLogger struct {
buf *deferredBuffer
with []Field
}

func (l *deferredLogger) log(level logLevel, msg string, fields []Field) {
l.buf.write(logRecord{level: level, msg: msg, with: l.with, fields: append([]Field(nil), fields...)})
}

func (l *deferredLogger) Debug(msg string, fields ...Field) { l.log(debugLevel, msg, fields) }
func (l *deferredLogger) Info(msg string, fields ...Field) { l.log(infoLevel, msg, fields) }
func (l *deferredLogger) Warn(msg string, fields ...Field) { l.log(warnLevel, msg, fields) }
func (l *deferredLogger) Error(msg string, fields ...Field) { l.log(errorLevel, msg, fields) }

func (l *deferredLogger) With(fields ...Field) Logger {
with := make([]Field, 0, len(l.with)+len(fields))
with = append(append(with, l.with...), fields...)
return &deferredLogger{buf: l.buf, with: with}
}
Loading
Loading