package fologger
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
type FileSink struct {
name string
mu sync.Mutex
dir string
file *os.File
currentDate string
minLevel LoggingLevel
}
// NewFileSink opens (or creates/appends) the file at path and returns a reference to FileSink
func NewFileSink(name, logDir string, l LoggingLevel) (*FileSink, error) {
if err := os.MkdirAll(logDir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create logs directory %q: %w", logDir, err)
}
probe := logDir + "/.write_probe"
f, err := os.OpenFile(probe, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return nil, fmt.Errorf("directory %q is not writable: %w", logDir, err)
}
f.Close()
os.Remove(probe)
return &FileSink{name: name, dir: logDir, minLevel: l}, nil
}
func (s *FileSink) logFilePath(t time.Time) string {
return filepath.Join(
s.dir,
fmt.Sprintf("%d", t.Year()),
fmt.Sprintf("%02d", t.Month()),
fmt.Sprintf("%s.log", t.Format("02")),
)
}
func (s *FileSink) rotateFileIfNeeded(t time.Time) error {
dateStr := fmt.Sprintf("%d-%02d-%02d", t.Year(), t.Month(), t.Day())
if s.currentDate == dateStr {
return nil
}
if s.file != nil {
if err := s.file.Sync(); err != nil {
return fmt.Errorf("sync before rotate: %w", err)
}
if err := s.file.Close(); err != nil {
return fmt.Errorf("close before rotate: %w", err)
}
s.file = nil
}
path := s.logFilePath(t)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create log subdirectory %q: %w", filepath.Dir(path), err)
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open %q: %w", path, err)
}
s.file = f
s.currentDate = dateStr
return nil
}
func (s *FileSink) Write(_ context.Context, e LogEvent) error {
if e.Level < s.minLevel {
return nil
}
line := s.Format(e)
s.mu.Lock()
defer s.mu.Unlock()
if err := s.rotateFileIfNeeded(e.Time); err != nil {
return err
}
if _, err := fmt.Fprint(s.file, line); err != nil {
return fmt.Errorf("write: %w", err)
}
return nil
}
func (s *FileSink) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.file == nil {
return nil
}
if err := s.file.Sync(); err != nil {
return fmt.Errorf("sync: %w", err)
}
err := s.file.Close()
s.file = nil
return err
}
func (s *FileSink) Name() string {
return s.name
}
func (s *FileSink) Format(e LogEvent) string {
return DefaultFormatter(e)
}
package fologger
import (
"context"
"encoding/json"
"fmt"
"io"
"maps"
"os"
"sync"
"time"
)
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)
}
type Config struct {
EventBuffer int
Fallback io.Writer
TraceKey any
OnDropped func(chName string)
OnFallbackFailed func()
Now func() time.Time
}
type LoggingLevel int
const (
LevelDebug LoggingLevel = iota
LevelInfo
LevelWarning
LevelError
LevelCritical
)
var levelNames = map[LoggingLevel]string{
LevelDebug: "DEBUG",
LevelInfo: "INFO",
LevelWarning: "WARNING",
LevelError: "ERROR",
LevelCritical: "CRITICAL",
}
func (l LoggingLevel) String() string {
if name, ok := levelNames[l]; ok {
return name
}
return fmt.Sprintf("LEVEL(%d)", int(l))
}
type LogEvent struct {
Level LoggingLevel `json:"level"`
Msg string `json:"msg"`
Data map[string]any `json:"data,omitempty"`
Time time.Time `json:"time"`
TraceID string `json:"trace_id,omitempty"`
Service string `json:"service,omitempty"`
}
// AsyncLogger fans events from a single dispatch channel to N sink workers
// concurrently. Never blocks callers - full channels trigger the fallback.
type AsyncLogger struct {
ch chan LogEvent
sinkWorkers []*sinkWorker
fallback io.Writer // target when the main channel or a sink channel is full
traceIDKey any // context key used to extract a trace/request ID
now func() time.Time
// If nil will deafault to noop
onDropped func(chName string)
onFallbackFailed func()
dispatchWg sync.WaitGroup
shutdownOnce sync.Once
}
type SinkWithConfig struct {
Sink Sink
EventBuffer int
// If nil will deafault to noop
OnWriteFailed func(chName string)
}
var DefaultConfig = Config{
EventBuffer: 4096,
Fallback: os.Stderr,
TraceKey: nil,
OnDropped: func(_ string) {},
OnFallbackFailed: func() {},
Now: time.Now,
}
func DefaultFormatter(e LogEvent) string {
t := e.Time
line := fmt.Sprintf("[%d-%02d-%02d %02d:%02d:%02d,%03d] - %s - %s - %s\n",
t.Year(), t.Month(), t.Day(),
t.Hour(), t.Minute(), t.Second(),
t.Nanosecond()/1e6,
e.Level.String(),
e.Service,
e.Msg,
)
if len(e.Data) == 0 {
return line
}
b, err := json.Marshal(e.Data)
if err != nil {
return line
}
return line + " └─ " + string(b) + "\n"
}
func NewAsyncLogger(cfg Config, sinks []SinkWithConfig) *AsyncLogger {
if cfg.Fallback == nil {
cfg.Fallback = DefaultConfig.Fallback
}
if cfg.EventBuffer == 0 {
cfg.EventBuffer = DefaultConfig.EventBuffer
}
if cfg.OnDropped == nil {
cfg.OnDropped = DefaultConfig.OnDropped
}
if cfg.OnFallbackFailed == nil {
cfg.OnFallbackFailed = DefaultConfig.OnFallbackFailed
}
if cfg.Now == nil {
cfg.Now = DefaultConfig.Now
}
l := &AsyncLogger{
ch: make(chan LogEvent, cfg.EventBuffer),
fallback: cfg.Fallback,
traceIDKey: cfg.TraceKey,
onDropped: cfg.OnDropped,
onFallbackFailed: cfg.OnFallbackFailed,
now: cfg.Now,
}
for _, s := range sinks {
l.sinkWorkers = append(l.sinkWorkers, newSinkWorker(s.Sink, s.EventBuffer, s.OnWriteFailed))
}
l.dispatchWg.Add(1)
go l.dispatch()
return l
}
func (l *AsyncLogger) dispatch() {
defer l.dispatchWg.Done()
for event := range l.ch {
for _, sw := range l.sinkWorkers {
select {
case sw.ch <- event:
default:
// Sink worker's buffer is full; fall back rather than block.
l.onDropped(sw.sink.Name())
l.writeFallback(event)
}
}
}
}
func (l *AsyncLogger) enqueue(ctx context.Context, level LoggingLevel, service string, msg string, fields map[string]any) {
// Need a copy since concurrent map reads in sinks
// and writes in the caller are creating a race.
var fieldsCopy map[string]any
if len(fields) > 0 {
fieldsCopy = make(map[string]any, len(fields))
maps.Copy(fieldsCopy, fields)
}
event := LogEvent{
Level: level,
Msg: msg,
Service: service,
Data: fieldsCopy,
Time: l.now(), // injectable for testing
}
if l.traceIDKey != nil {
if id, ok := ctx.Value(l.traceIDKey).(string); ok {
event.TraceID = id
}
}
select {
case l.ch <- event:
default:
l.onDropped("dispatch")
l.writeFallback(event)
}
}
func (l *AsyncLogger) writeFallback(e LogEvent) {
b, _ := json.Marshal(e)
if _, err := fmt.Fprintf(l.fallback, "%s\n", b); err != nil {
l.onFallbackFailed()
fmt.Fprintf(os.Stderr, "LOG FALLBACK WRITE ERROR: %v event - %s\n", err, b)
}
}
// TODO: sinkWorker runtime removal
// Shutdown drains the logger and closes all sinks gracefully.
// Safe to call multiple times, only the first call has effect.
// Pass a context with a deadline to bound the shutdown time.
func (l *AsyncLogger) Shutdown(ctx context.Context) error {
var shutdownErr error
l.shutdownOnce.Do(func() {
close(l.ch)
done := make(chan struct{})
go func() {
l.dispatchWg.Wait()
for _, sw := range l.sinkWorkers {
sw.close(ctx)
}
close(done)
}()
select {
case <-done:
case <-ctx.Done():
shutdownErr = fmt.Errorf("logger shutdown timed out: %w", ctx.Err())
}
})
return shutdownErr
}
type namedLogger struct {
parent *AsyncLogger
service string
}
func (l *AsyncLogger) Named(service string) Logger {
return &namedLogger{parent: l, service: service}
}
func (l *namedLogger) Debug(ctx context.Context, msg string, fields map[string]any) {
l.parent.enqueue(ctx, LevelDebug, l.service, msg, fields)
}
func (l *namedLogger) Info(ctx context.Context, msg string, fields map[string]any) {
l.parent.enqueue(ctx, LevelInfo, l.service, msg, fields)
}
func (l *namedLogger) Warning(ctx context.Context, msg string, fields map[string]any) {
l.parent.enqueue(ctx, LevelWarning, l.service, msg, fields)
}
func (l *namedLogger) Error(ctx context.Context, msg string, fields map[string]any) {
l.parent.enqueue(ctx, LevelError, l.service, msg, fields)
}
func (l *namedLogger) Critical(ctx context.Context, msg string, fields map[string]any) {
l.parent.enqueue(ctx, LevelCritical, l.service, msg, fields)
}
package fologger
import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
)
// `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 it's context,
// because a `Write` that ignores cancellation will cause deadlined shutdown to block
// until all `Write` calls for events left in buffer complete naturally regardless of the context timeout.
//
// Close is called once during shutdown, after the sink's channel is drained.
// Implementations' Close should flush any buffers and release resources.
type Sink interface {
Write(ctx context.Context, event LogEvent) error
Close() error
Format(LogEvent) string
Name() string
}
type sinkWorker struct {
ctx context.Context
cancel context.CancelFunc
sink Sink
ch chan LogEvent
wg sync.WaitGroup
onWriteFailed func(sinkName string)
}
func newSinkWorker(sink Sink, bufSize int, onWriteFailed func(string)) *sinkWorker {
ctx, cancel := context.WithCancel(context.Background())
if onWriteFailed == nil {
onWriteFailed = func(_ string) {}
}
sw := &sinkWorker{
ctx: ctx,
cancel: cancel,
sink: sink,
ch: make(chan LogEvent, bufSize),
onWriteFailed: onWriteFailed,
}
sw.wg.Add(1)
go sw.run()
return sw
}
func (sw *sinkWorker) run() {
defer sw.wg.Done()
for event := range sw.ch {
if err := sw.sink.Write(sw.ctx, event); err != nil {
sw.onWriteFailed(sw.sink.Name())
b, _ := json.Marshal(event)
fmt.Fprintf(os.Stderr, "sink write error: %v - event: %s\n", err, b)
}
}
}
func (sw *sinkWorker) close(ctx context.Context) error {
defer sw.cancel()
close(sw.ch)
waitDone := make(chan struct{})
go func() {
sw.wg.Wait()
close(waitDone)
}()
select {
case <-waitDone:
case <-ctx.Done():
sw.cancel()
<-waitDone
}
return sw.sink.Close()
}
package fologger
import (
"context"
"fmt"
"os"
)
type StdoutSink struct {
name string
minLevel LoggingLevel
}
func NewStdoutSink(name string, l LoggingLevel) *StdoutSink {
return &StdoutSink{name: name, minLevel: l}
}
func (s *StdoutSink) Write(ctx context.Context, e LogEvent) error {
if e.Level < s.minLevel {
return nil
}
line := s.Format(e)
_, err := fmt.Fprint(os.Stdout, line)
if err != nil {
return fmt.Errorf("stdout sink: %w", err)
}
return nil
}
func (s *StdoutSink) Close() error { return nil }
func (s *StdoutSink) Name() string {
return s.name
}
func (s *StdoutSink) Format(e LogEvent) string {
return DefaultFormatter(e)
}