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 @@ -14,6 +14,7 @@ one of them does not pull anything else in.
| [deferred](./deferred) | Helpers for the `defer` calls. |
| [dispatcher](./dispatcher) | Routing of keyed events to handlers, with priorities, propagation control and wildcards. |
| [protomap](./protomap) | Deserialization of protobuf messages by their descriptor name. |
| [slogger](./slogger) | A `log/slog` logger with a configurable format, a hot-reloadable level and a writer. |
| [strcase](./strcase) | Detection of the case of a string and conversion between the case styles. |
| [sync](./sync) | A `sync.Once` that keeps retrying until the guarded function succeeds. |
| [time](./time) | A duration type that is serialized as a human-readable string. |
Expand Down
6 changes: 3 additions & 3 deletions closer/closer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func TestCloser(t *testing.T) {
key := struct{ k string }{k: "testkey"}

t.Run("global", func(t *testing.T) {
goleak.VerifyNone(t, goleak.IgnoreCurrent())
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())

SetContext(context.WithValue(context.Background(), key, "testvalue"))

Expand Down Expand Up @@ -49,7 +49,7 @@ func TestCloser(t *testing.T) {
})

t.Run("with context cancel", func(t *testing.T) {
goleak.VerifyNone(t, goleak.IgnoreCurrent())
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())

ctx, cancel := context.WithCancel(context.Background())
_, c := New(ctx, WithContextCancel())
Expand Down Expand Up @@ -89,7 +89,7 @@ func TestCloser(t *testing.T) {
})

t.Run("with signals", func(t *testing.T) {
goleak.VerifyNone(t, goleak.IgnoreCurrent())
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())

ctx := context.Background()
_, c := New(ctx, WithSignals(syscall.SIGINT, syscall.SIGTERM))
Expand Down
1 change: 1 addition & 0 deletions go.work
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use (
./deferred
./dispatcher
./protomap
./slogger
./strcase
./sync
./time
Expand Down
350 changes: 350 additions & 0 deletions slogger/.golangci.yml

Large diffs are not rendered by default.

137 changes: 137 additions & 0 deletions slogger/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# slogger

A `log/slog` logger with a configurable format, a hot-reloadable log level and a
custom writer.

## Public API

```go
type Options struct {
// Leveler returns the current log level.
Leveler func() string
// LevelReloadCond is the condition variable that notifies the logger about
// a log level change.
LevelReloadCond *sync.Cond
// Writer is an optional writer the logs are written to.
// If it is not set, the logs are written to os.Stdout.
Writer io.Writer
// Format is the log output format. Allowed values: "json" and "text".
Format string
}

func New(ctx context.Context, opts Options) (*slog.Logger, error)
```

- `Leveler` — the current log level: `"DEBUG"`, `"INFO"`, `"WARN"` or `"ERROR"`
(case-insensitive, with an optional offset, e.g. `"INFO+2"`).
- `Format` — `"json"` or `"text"`; any other value is an error.
- `LevelReloadCond` — the condition variable that notifies the logger about a level
change. Without it the level is set once, at startup.
- `Writer` — the destination of the records; `os.Stdout` by default.

## Usage

```go
logger, err := slogger.New(ctx, slogger.Options{
Leveler: func() string { return "INFO" },
Format: "json",
})
if err != nil {
return err
}

logger.Info("Server started", slog.String("addr", ":8080"))
// {"time":"...","level":"INFO","msg":"Server started","addr":":8080"}
```

A custom writer replaces `os.Stdout`:

```go
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer file.Close()

logger, err := slogger.New(ctx, slogger.Options{
Leveler: func() string { return "INFO" },
Format: "text",
Writer: file,
})
```

The level is applied by the handler through a `slog.LevelVar`: the records below it
are dropped before they are formatted.

## Log level reload

With a `*sync.Cond` in `LevelReloadCond` the logger re-reads the level on every
notification:

```go
var (
levelMu sync.Mutex
level = "INFO"

cond = sync.NewCond(&sync.Mutex{})
)

func setLevel(l string) {
levelMu.Lock()
level = l
levelMu.Unlock()

cond.L.Lock()
cond.Broadcast()
cond.L.Unlock()
}

logger, err := slogger.New(ctx, slogger.Options{
Leveler: func() string {
levelMu.Lock()
defer levelMu.Unlock()

return level
},
Format: "text",
LevelReloadCond: cond,
})
```

Two rules:

- the level is changed before the notification is sent, and the notification is sent
under the lock of the condition variable: the logger reads the level and waits
atomically under that lock, so a notification sent outside it can be missed;
- the `Leveler` is called while the logger holds the lock of the condition variable,
so it must not acquire that lock itself. Use a separate mutex for the level, as
above.

The logger stops watching the level when the context passed to `New` is canceled.

On a level change the logger writes a notification record:

```
time=... level=INFO msg="Log level changed from INFO to DEBUG"
```

It is written directly through the handler, bypassing the level check, so it is
written even when the new level is `WARN` or `ERROR`.

If the level cannot be parsed, the error is written as an `ERROR` record and the
current level is kept:

```
time=... level=ERROR msg="Failed to parse log level while hot-reload update" error="slog: level string \"bogus\": unknown name"
```

## Errors

`New` returns an error if the level returned by `Leveler` cannot be parsed
(`slog: level string "bogus": unknown name`) or if the format is unknown
(`unknown log format "xml"`).

## Concurrency

The logger is safe for concurrent use, as any `slog.Logger` is. The `Leveler` is
called from the watcher goroutine, so it must be safe for concurrent use as well.
10 changes: 10 additions & 0 deletions slogger/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module github.com/nbgrp/pkg/slogger

go 1.25.0

require (
github.com/stretchr/testify v1.12.1
go.uber.org/goleak v1.3.0
)

require go.yaml.in/yaml/v3 v3.0.5 // indirect
6 changes: 6 additions & 0 deletions slogger/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
111 changes: 111 additions & 0 deletions slogger/slogger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: BSD-3-Clause

package slogger

import (
"context"
"fmt"
"io"
"log/slog"
"os"
"sync"
"time"
)

// Options configures the logger.
type Options struct {
// Leveler returns the current log level.
Leveler func() string
// LevelReloadCond is the condition variable that notifies the logger about
// a log level change.
LevelReloadCond *sync.Cond
// Writer is an optional writer the logs are written to.
// If it is not set, the logs are written to [os.Stdout].
Writer io.Writer
// Format is the log output format. Allowed values: "json" and "text".
Format string
}

// New initializes and returns a [slog.Logger] or an error.
func New(ctx context.Context, opts Options) (*slog.Logger, error) {
level := &slog.LevelVar{}
err := level.UnmarshalText([]byte(opts.Leveler()))
if err != nil {
return nil, err
}

var (
handler slog.Handler
options = &slog.HandlerOptions{Level: level}
)

var writer io.Writer = os.Stdout
if opts.Writer != nil {
writer = opts.Writer
}

logFormat := opts.Format
switch logFormat {
case "json":
handler = slog.NewJSONHandler(writer, options)
case "text":
handler = slog.NewTextHandler(writer, options)
default:
return nil, fmt.Errorf("unknown log format %q", logFormat)
}

l := slog.New(handler)
if opts.LevelReloadCond != nil {
go watchLogLevel(ctx, l, opts.LevelReloadCond, level, opts)
}

return l, nil
}

// watchLogLevel reloads the log level on every notification of the condition
// variable until the context is canceled.
func watchLogLevel(ctx context.Context, logger *slog.Logger, cond *sync.Cond, currentLevel *slog.LevelVar, opts Options) {
// A [sync.Cond] has no context support, so the watcher has to be woken up
// from the outside when the context is canceled.
//
// The lock is held during the broadcast: the watcher registers itself as a
// waiter before it releases the lock, so a broadcast sent without the lock
// could be lost and leave the watcher sleeping on a canceled context.
stop := context.AfterFunc(ctx, func() {
cond.L.Lock()
defer cond.L.Unlock()

cond.Broadcast()
})
defer stop()

cond.L.Lock()
defer cond.L.Unlock()

for {
select {
case <-ctx.Done():
return
default:
}

var newLevel slog.Level
err := newLevel.UnmarshalText([]byte(opts.Leveler()))

switch {
case err != nil:
logger.ErrorContext(ctx, "Failed to parse log level while hot-reload update", slog.Any("error", err))
case newLevel != currentLevel.Level():
oldLevel := currentLevel.Level().String()
currentLevel.Set(newLevel)
_ = logger.Handler().Handle(ctx, slog.NewRecord(
time.Now(),
slog.LevelInfo,
fmt.Sprintf("Log level changed from %s to %s", oldLevel, newLevel.String()),
0,
))
}

cond.Wait()
}
}
Loading
Loading