11
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Level classifies the severity of a log entry.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
LevelInfo Level = iota
|
||||
LevelWarn
|
||||
LevelError
|
||||
)
|
||||
|
||||
// Entry is a single log record.
|
||||
type Entry struct {
|
||||
Ts int64
|
||||
Level Level
|
||||
Source string
|
||||
Message string
|
||||
Details string
|
||||
}
|
||||
|
||||
// Logger is a thread-safe ring-buffer log with cap 500.
|
||||
type Logger struct {
|
||||
mu sync.Mutex
|
||||
buf []Entry
|
||||
listeners []func(Entry)
|
||||
now func() int64
|
||||
}
|
||||
|
||||
const bufCap = 500
|
||||
|
||||
// NewLogger creates a Logger with a 500-entry ring buffer.
|
||||
func NewLogger() *Logger {
|
||||
return &Logger{
|
||||
now: func() int64 { return time.Now().UnixMilli() },
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) add(level Level, source, message string, details []string) {
|
||||
var d string
|
||||
if len(details) > 0 {
|
||||
d = details[0]
|
||||
}
|
||||
e := Entry{
|
||||
Ts: l.now(),
|
||||
Level: level,
|
||||
Source: source,
|
||||
Message: message,
|
||||
Details: d,
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.buf = append(l.buf, e)
|
||||
if len(l.buf) > bufCap {
|
||||
l.buf = l.buf[len(l.buf)-bufCap:]
|
||||
}
|
||||
fns := l.listeners
|
||||
l.mu.Unlock()
|
||||
|
||||
for _, fn := range fns {
|
||||
fn(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Info records an info-level entry.
|
||||
func (l *Logger) Info(source, message string, details ...string) {
|
||||
l.add(LevelInfo, source, message, details)
|
||||
}
|
||||
|
||||
// Warn records a warn-level entry.
|
||||
func (l *Logger) Warn(source, message string, details ...string) {
|
||||
l.add(LevelWarn, source, message, details)
|
||||
}
|
||||
|
||||
// Error records an error-level entry.
|
||||
func (l *Logger) Error(source, message string, details ...string) {
|
||||
l.add(LevelError, source, message, details)
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of all buffered entries, oldest first.
|
||||
func (l *Logger) Snapshot() []Entry {
|
||||
l.mu.Lock()
|
||||
out := make([]Entry, len(l.buf))
|
||||
copy(out, l.buf)
|
||||
l.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// Subscribe registers fn to be called for every new entry.
|
||||
func (l *Logger) Subscribe(fn func(Entry)) {
|
||||
l.mu.Lock()
|
||||
l.listeners = append(l.listeners, fn)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// Default is the package-level logger instance.
|
||||
var Default = NewLogger()
|
||||
@@ -0,0 +1,142 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestLogger() *Logger {
|
||||
l := NewLogger()
|
||||
var ts int64
|
||||
l.now = func() int64 {
|
||||
ts++
|
||||
return ts
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func TestLogger_ringBuffer_evictsOldest(t *testing.T) {
|
||||
l := newTestLogger()
|
||||
for i := 1; i <= 600; i++ {
|
||||
l.Info("src", fmt.Sprintf("msg%d", i))
|
||||
}
|
||||
snap := l.Snapshot()
|
||||
if len(snap) != 500 {
|
||||
t.Fatalf("want 500 entries, got %d", len(snap))
|
||||
}
|
||||
if snap[0].Message != "msg101" {
|
||||
t.Errorf("oldest entry: want msg101, got %q", snap[0].Message)
|
||||
}
|
||||
if snap[499].Message != "msg600" {
|
||||
t.Errorf("newest entry: want msg600, got %q", snap[499].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_levels(t *testing.T) {
|
||||
l := newTestLogger()
|
||||
l.Info("s", "info-msg")
|
||||
l.Warn("s", "warn-msg")
|
||||
l.Error("s", "error-msg")
|
||||
snap := l.Snapshot()
|
||||
if len(snap) != 3 {
|
||||
t.Fatalf("want 3 entries, got %d", len(snap))
|
||||
}
|
||||
if snap[0].Level != LevelInfo {
|
||||
t.Errorf("entry 0: want LevelInfo, got %v", snap[0].Level)
|
||||
}
|
||||
if snap[1].Level != LevelWarn {
|
||||
t.Errorf("entry 1: want LevelWarn, got %v", snap[1].Level)
|
||||
}
|
||||
if snap[2].Level != LevelError {
|
||||
t.Errorf("entry 2: want LevelError, got %v", snap[2].Level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_snapshotOrder(t *testing.T) {
|
||||
l := newTestLogger()
|
||||
for i := 1; i <= 10; i++ {
|
||||
l.Info("s", fmt.Sprintf("msg%d", i))
|
||||
}
|
||||
snap := l.Snapshot()
|
||||
for i, e := range snap {
|
||||
want := fmt.Sprintf("msg%d", i+1)
|
||||
if e.Message != want {
|
||||
t.Errorf("snap[%d].Message = %q, want %q", i, e.Message, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_detailsOptional(t *testing.T) {
|
||||
l := newTestLogger()
|
||||
l.Info("s", "no-details")
|
||||
l.Warn("s", "with-details", "extra info")
|
||||
snap := l.Snapshot()
|
||||
if snap[0].Details != "" {
|
||||
t.Errorf("Details without arg should be empty, got %q", snap[0].Details)
|
||||
}
|
||||
if snap[1].Details != "extra info" {
|
||||
t.Errorf("Details = %q, want %q", snap[1].Details, "extra info")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_subscribe(t *testing.T) {
|
||||
l := newTestLogger()
|
||||
var received []Entry
|
||||
l.Subscribe(func(e Entry) {
|
||||
received = append(received, e)
|
||||
})
|
||||
l.Info("s", "one")
|
||||
l.Error("s", "two")
|
||||
if len(received) != 2 {
|
||||
t.Fatalf("want 2 callbacks, got %d", len(received))
|
||||
}
|
||||
if received[0].Message != "one" {
|
||||
t.Errorf("first callback message = %q, want 'one'", received[0].Message)
|
||||
}
|
||||
if received[1].Level != LevelError {
|
||||
t.Error("second callback should have LevelError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_concurrencySafe(t *testing.T) {
|
||||
l := NewLogger()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
l.Info("src", fmt.Sprintf("msg%d", n))
|
||||
_ = l.Snapshot()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestLogger_sourceAndMessage(t *testing.T) {
|
||||
l := newTestLogger()
|
||||
l.Info("webhook", "connected")
|
||||
snap := l.Snapshot()
|
||||
if snap[0].Source != "webhook" {
|
||||
t.Errorf("Source = %q, want 'webhook'", snap[0].Source)
|
||||
}
|
||||
if snap[0].Message != "connected" {
|
||||
t.Errorf("Message = %q, want 'connected'", snap[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_snapshotIsACopy(t *testing.T) {
|
||||
l := newTestLogger()
|
||||
l.Info("s", "first")
|
||||
snap := l.Snapshot()
|
||||
l.Info("s", "second")
|
||||
if len(snap) != 1 {
|
||||
t.Errorf("snapshot should be a copy; len = %d, want 1", len(snap))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefault_exists(t *testing.T) {
|
||||
if Default == nil {
|
||||
t.Error("Default logger should not be nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user