Logger

Overview
Fan-out logger with pluggable output sinks. Main properties:
- Each sink runs in its own goroutine, so a slow sink (e.g. saturated DB) doesn't delay a fast one (e.g. stdout).
- Named logger (
Named(service string)) is a wrapper that holds a pointer to the logger instance and stamps every event with a service tag without allocating a new channel pipeline.
- If the main dispatch channel's buffer is full - falls back to a
Fallback io.Writer rather than stalling the caller. Same strategy on event fan-out to sink workers' channels.
- Dropped/failed log writes are handled with configurable callbacks (
onDropped, onWriteFailed, onFallbackFailed) - attach metrics, logging, or noops as needed.
- Graceful shutdown drains all in-flight events before closing sinks, with an optional deadline.
Log Event Flow
Core Types
Logger interface
type Logger interface {
Debug(ctx context.Context, msg string, fields map[string]any)
Info(ctx context.Context, msg string, fields map[string]any)
Warning(ctx context.Context, msg string, fields map[string]any)
Error(ctx context.Context, msg string, fields map[string]any)
Critical(ctx context.Context, msg string, fields map[string]any)
}
LogEvent
| Field |
Type |
Description |
Level |
LoggingLevel |
Severity level |
Msg |
string |
Log message |
Data |
map[string]any |
Arbitrary structured fields (copied on enqueue) |
Time |
time.Time |
Event timestamp (Config.Now(), injectable for tests) |
TraceID |
string |
Extracted from context, configured with Config.TraceKey; omitted if absent |
Service |
string |
Set by Named() |
Levels
| Constant |
String |
LevelDebug |
DEBUG |
LevelInfo |
INFO |
LevelWarning |
WARNING |
LevelError |
ERROR |
LevelCritical |
CRITICAL |
Creating a Logger
The logger is created via NewAsyncLogger with a Config struct and a slice of SinkWithConfig wrappers.
Config
type Config struct {
EventBuffer int // capacity of the main dispatch channel (default: 4096)
Fallback io.Writer // target when a channel is full (default: os.Stderr)
TraceKey any // context key used to extract a trace/request ID
OnDropped func(chName string) // called when an event is dropped due to a full channel (default is noop)
OnFallbackFailed func() // called when the fallback writer itself returns an error (default is noop)
Now func() time.Time // timestamp function (default: time.Now)
}
DefaultConfig:
var DefaultConfig = Config{
EventBuffer: 4096,
Fallback: os.Stderr,
TraceKey: nil,
OnDropped: func(_ string) {},
OnFallbackFailed: func() {},
Now: time.Now,
}
Any field left at its zero value in a custom Config falls back to DefaultConfig.
SinkWithConfig
Each sink is paired with its own buffer capacity and an optional per-sink write-failure callback:
type SinkWithConfig struct {
Sink Sink
EventBuffer int // buffer size for this sink's channel
OnWriteFailed func(chName string) // called when this sink's Write returns an error (default is noop)
}
OnWriteFailed is per-sink so different sinks can have different failure handling (e.g. one increments a metric counter, another sends an alert). If nil it defaults to a noop.
Example
import (
"context"
"os"
"time"
"codeberg.org/at375/fologger"
)
type ctxKey struct{}
func main() {
// --- Config ---
cfg := fologger.Config{
EventBuffer: 4096,
Fallback: os.Stderr,
TraceKey: ctxKey{},
OnDropped: func(sinkName string) {
// e.g. increment a metric counter
},
OnFallbackFailed: func() {
// e.g. increment a metric counter
},
}
// --- Build sinks ---
stdoutSink := fologger.NewStdoutSink("stdout", fologger.LevelDebug)
fileSink, err := fologger.NewFileSink("file", "/var/log/myapp", fologger.LevelInfo)
if err != nil {
// handle error (e.g. directory not writable)
panic(err)
}
sinks := []fologger.SinkWithConfig{
{Sink: stdoutSink, EventBuffer: 64},
{Sink: fileSink, EventBuffer: 128, OnWriteFailed: func(name string) {
// e.g. increment a metric counter
}},
}
// --- Create logger ---
l := fologger.NewAsyncLogger(cfg, sinks)
log := l.Named("my-service")
ctx := context.WithValue(context.Background(), ctxKey{}, "trace-abc-123")
log.Info(ctx, "started", map[string]any{"port": 8080})
// --- Graceful shutdown ---
timeoutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := l.Shutdown(timeoutCtx); err != nil {
// deadline exceeded
}
}
Sinks
All sinks implement the Sink interface:
type Sink interface {
Write(ctx context.Context, event LogEvent) error
Format(LogEvent) string
Close() error
Name() string
}
Write is called from the sink's dedicated goroutine only - implementations do not need to be safe against concurrent Write calls (unless you need a direct Write call to the sink from outside the sinkWorker for some reason). Close is always called after the last Write has returned (guaranteed by sinkWorker waiting on run() to exit), so no Write + Close race is possible.
This means though, that Write should respect its context, because a Write that ignores cancellation will cause a deadlined shutdown to block until all Write calls for events left in the buffer complete naturally regardless of the context timeout.
Implementations' Close should flush any buffers and release resources.
Name is used to label sinks (e.g. for metrics or error reporting).
Format returns the string representation of a log event. Package provides DefaultFormatter.
StdoutSink
Writes human-readable lines to os.Stdout. Close is a noop.
s := fologger.NewStdoutSink("stdout", fologger.LevelInfo)
FileSink
Appends log lines to daily files under a configurable root directory, organized into subdirectories by year and month. Rotates automatically at midnight. Subdirectories are created on the first write of each month. Validates root directory writability at construction time with a probe write.
s, err := fologger.NewFileSink("file", "/var/log/app", fologger.LevelDebug)
Output layout:
<log_path>/2026/01/15.log
<log_path>/2026/01/16.log
<log_path>/2026/02/01.log
Each sink has a Format(LogEvent) string method. The default implementation (DefaultFormatter) produces:
[YYYY-MM-DD HH:MM:SS,mmm] - LEVEL - service - message
└─ {"field":"value",...}
The data line is omitted when there are no fields.
Backpressure & Fallback
The logger has two points where it can drop to the fallback writer rather than block:
- Dispatch channel full - the calling goroutine writes the event directly to the fallback and calls
onDropped("dispatch").
- Sink channel full - the dispatch goroutine writes the event to the fallback and calls
onDropped("<sink name>").
If the fallback write itself fails, onFallbackFailed() is called and a last-resort line is printed to os.Stderr.
Time Injection
Config.Now is a func() time.Time that defaults to time.Now.
cfg := fologger.Config{
Now: func() time.Time {
return time.Date(2026, 1, 15, 10, 30, 45, 123000000, time.UTC)
},
}
Error Callbacks
In Config
| Field |
Signature |
When called |
OnDropped |
func(chName string) |
An event was dropped because a channel was full |
OnFallbackFailed |
func() |
The fallback writer itself returned an error |
Both are initialised to noops in DefaultConfig.
In SinkWithConfig
| Field |
Signature |
When called |
OnWriteFailed |
func(chName string) |
This sink's Write returned a non-nil error |
Per-sink, so different sinks can have independent failure handling.