Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+307
View File
@@ -0,0 +1,307 @@
package lh
import (
"fmt"
"io"
"os"
"runtime"
"sync"
"time"
"github.com/olekukonko/ll/lx"
)
// Buffering holds configuration for the Buffered handler.
type Buffering struct {
BatchSize int // Flush when this many entries are buffered (default: 100)
FlushInterval time.Duration // Maximum time between flushes (default: 10s)
MaxBuffer int // Maximum buffer size before applying backpressure (default: 1000)
OnOverflow func(int) // Called when buffer reaches MaxBuffer (default: logs warning)
ErrorOutput io.Writer // Destination for internal errors like flush failures (default: os.Stderr)
}
// BufferingOpt configures Buffered handler.
type BufferingOpt func(*Buffering)
// WithBatchSize sets the batch size for flushing.
// It specifies the number of log entries to buffer before flushing to the underlying handler.
// Example:
//
// handler := NewBuffered(textHandler, WithBatchSize(50)) // Flush every 50 entries
func WithBatchSize(size int) BufferingOpt {
return func(c *Buffering) {
c.BatchSize = size
}
}
// WithFlushInterval sets the maximum time between flushes.
// It defines the interval at which buffered entries are flushed, even if the batch size is not reached.
// Example:
//
// handler := NewBuffered(textHandler, WithFlushInterval(5*time.Second)) // Flush every 5 seconds
func WithFlushInterval(d time.Duration) BufferingOpt {
return func(c *Buffering) {
c.FlushInterval = d
}
}
// WithMaxBuffer sets the maximum buffer size before backpressure.
// It limits the number of entries that can be queued in the channel, triggering overflow handling if exceeded.
// Example:
//
// handler := NewBuffered(textHandler, WithMaxBuffer(500)) // Allow up to 500 buffered entries
func WithMaxBuffer(size int) BufferingOpt {
return func(c *Buffering) {
c.MaxBuffer = size
}
}
// WithOverflowHandler sets the overflow callback.
// It specifies a function to call when the buffer reaches MaxBuffer, typically for logging or metrics.
// Example:
//
// handler := NewBuffered(textHandler, WithOverflowHandler(func(n int) { fmt.Printf("Overflow: %d entries\n", n) }))
func WithOverflowHandler(fn func(int)) BufferingOpt {
return func(c *Buffering) {
c.OnOverflow = fn
}
}
// WithErrorOutput sets the destination for internal errors (e.g., downstream handler failures).
// Defaults to os.Stderr if not set.
// Example:
//
// // Redirect internal errors to a file or discard them
// handler := NewBuffered(textHandler, WithErrorOutput(os.Stdout))
func WithErrorOutput(w io.Writer) BufferingOpt {
return func(c *Buffering) {
c.ErrorOutput = w
}
}
// Buffered wraps any Handler to provide buffering capabilities.
// It buffers log entries in a channel and flushes them based on batch size, time interval, or explicit flush.
// The generic type H ensures compatibility with any lx.Handler implementation.
// Thread-safe via channels and sync primitives.
type Buffered[H lx.Handler] struct {
handler H // Underlying handler to process log entries
config *Buffering // Configuration for batching and flushing
entries chan *lx.Entry // Channel for buffering log entries
flushSignal chan struct{} // Channel to trigger explicit flushes
shutdown chan struct{} // Channel to signal worker shutdown
shutdownOnce sync.Once // Ensures Close is called only once
wg sync.WaitGroup // Waits for worker goroutine to finish
}
// NewBuffered creates a new buffered handler that wraps another handler.
// It initializes the handler with default or provided configuration options and starts a worker goroutine.
// Thread-safe via channel operations and finalizer for cleanup.
// Example:
//
// textHandler := lh.NewTextHandler(os.Stdout)
// buffered := NewBuffered(textHandler, WithBatchSize(50))
func NewBuffered[H lx.Handler](handler H, opts ...BufferingOpt) *Buffered[H] {
// Initialize default configuration
config := &Buffering{
BatchSize: 100, // Default: flush every 100 entries
FlushInterval: 10 * time.Second, // Default: flush every 10 seconds
MaxBuffer: 1000, // Default: max 1000 entries in buffer
ErrorOutput: os.Stderr, // Default: report errors to stderr
OnOverflow: func(count int) { // Default: log overflow to io.Discard (silent by default for overflow)
fmt.Fprintf(io.Discard, "log buffer overflow: %d entries\n", count)
},
}
// Apply provided options
for _, opt := range opts {
opt(config)
}
// Ensure sane configuration values
if config.BatchSize < 1 {
config.BatchSize = 1 // Minimum batch size is 1
}
if config.MaxBuffer < config.BatchSize {
config.MaxBuffer = config.BatchSize * 10 // Ensure buffer is at least 10x batch size
}
if config.FlushInterval <= 0 {
config.FlushInterval = 10 * time.Second // Minimum flush interval is 10s
}
if config.ErrorOutput == nil {
config.ErrorOutput = os.Stderr
}
// Initialize Buffered handler
b := &Buffered[H]{
handler: handler, // Set underlying handler
config: config, // Set configuration
entries: make(chan *lx.Entry, config.MaxBuffer), // Create buffered channel
flushSignal: make(chan struct{}, 1), // Create single-slot flush signal channel
shutdown: make(chan struct{}), // Create shutdown signal channel
}
// Start worker goroutine
b.wg.Add(1)
go b.worker()
// Set finalizer for cleanup during garbage collection
runtime.SetFinalizer(b, (*Buffered[H]).Final)
return b
}
// Handle implements the lx.Handler interface.
// It buffers log entries in the entries channel or triggers a flush on overflow.
// Returns an error if the buffer is full and flush cannot be triggered.
// Thread-safe via non-blocking channel operations.
// Example:
//
// buffered.Handle(&lx.Entry{Message: "test"}) // Buffers entry or triggers flush
func (b *Buffered[H]) Handle(e *lx.Entry) error {
select {
case b.entries <- e: // Buffer entry if channel has space
return nil
default: // Handle buffer overflow
if b.config.OnOverflow != nil {
b.config.OnOverflow(len(b.entries)) // Call overflow handler
}
select {
case b.flushSignal <- struct{}{}: // Trigger flush if possible
return fmt.Errorf("log buffer overflow, triggering flush")
default: // Flush already in progress
return fmt.Errorf("log buffer overflow and flush already in progress")
}
}
}
// Flush triggers an immediate flush of buffered entries.
// It sends a signal to the worker to process all buffered entries.
// If a flush is already pending, it waits briefly and may exit without flushing.
// Thread-safe via non-blocking channel operations.
// Example:
//
// buffered.Flush() // Flushes all buffered entries
func (b *Buffered[H]) Flush() {
select {
case b.flushSignal <- struct{}{}: // Signal worker to flush
case <-time.After(100 * time.Millisecond): // Timeout if flush is pending
// Flush already pending
}
}
// Close flushes any remaining entries and stops the worker.
// It ensures shutdown is performed only once and waits for the worker to finish.
// If the underlying handler implements a Close() error method, it will be called to release resources.
// Thread-safe via sync.Once and WaitGroup.
// Returns any error from the underlying handler's Close, or nil.
// Example:
//
// buffered.Close() // Flushes entries and stops worker
func (b *Buffered[H]) Close() error {
var closeErr error
b.shutdownOnce.Do(func() {
close(b.shutdown) // Signal worker to shut down
b.wg.Wait() // Wait for worker to finish
runtime.SetFinalizer(b, nil) // Remove finalizer
// Check if underlying handler has a Close method and call it
if closer, ok := any(b.handler).(interface{ Close() error }); ok {
closeErr = closer.Close()
}
})
return closeErr
}
// Final ensures remaining entries are flushed during garbage collection.
// It calls Close to flush entries and stop the worker.
// Used as a runtime finalizer to prevent log loss.
// Example (internal usage):
//
// runtime.SetFinalizer(buffered, (*Buffered[H]).Final)
func (b *Buffered[H]) Final() {
b.Close()
}
// Config returns the current configuration of the Buffered handler.
// It provides access to BatchSize, FlushInterval, MaxBuffer, and OnOverflow settings.
// Example:
//
// config := buffered.Config() // Access configuration
func (b *Buffered[H]) Config() *Buffering {
return b.config
}
// worker processes entries and handles flushing.
// It runs in a goroutine, buffering entries, flushing on batch size, timer, or explicit signal,
// and shutting down cleanly when signaled.
// Thread-safe via channel operations and WaitGroup.
func (b *Buffered[H]) worker() {
defer b.wg.Done() // Signal completion when worker exits
batch := make([]*lx.Entry, 0, b.config.BatchSize) // Buffer for batching entries
ticker := time.NewTicker(b.config.FlushInterval) // Timer for periodic flushes
defer ticker.Stop() // Clean up ticker
for {
select {
case entry := <-b.entries: // Receive new entry
batch = append(batch, entry)
// Flush if batch size is reached
if len(batch) >= b.config.BatchSize {
b.flushBatch(batch)
batch = batch[:0]
}
case <-ticker.C: // Periodic flush
if len(batch) > 0 {
b.flushBatch(batch)
batch = batch[:0]
}
case <-b.flushSignal: // Explicit flush
if len(batch) > 0 {
b.flushBatch(batch)
batch = batch[:0]
}
b.drainRemaining() // Drain all entries from the channel
case <-b.shutdown: // Shutdown signal
if len(batch) > 0 {
b.flushBatch(batch)
}
b.drainRemaining() // Flush remaining entries
return
}
}
}
// flushBatch processes a batch of entries through the wrapped handler.
// It writes each entry to the underlying handler, logging any errors to the configured ErrorOutput.
// Example (internal usage):
//
// b.flushBatch([]*lx.Entry{entry1, entry2})
func (b *Buffered[H]) flushBatch(batch []*lx.Entry) {
for _, entry := range batch {
// Process each entry through the handler
if err := b.handler.Handle(entry); err != nil {
if b.config.ErrorOutput != nil {
fmt.Fprintf(b.config.ErrorOutput, "log flush error: %v\n", err)
}
}
}
}
// drainRemaining processes any remaining entries in the channel.
// It flushes all entries from the entries channel to the underlying handler,
// logging any errors to the configured ErrorOutput. Used during flush or shutdown.
// Example (internal usage):
//
// b.drainRemaining() // Flushes all pending entries
func (b *Buffered[H]) drainRemaining() {
for {
select {
case entry := <-b.entries: // Process next entry
if err := b.handler.Handle(entry); err != nil {
if b.config.ErrorOutput != nil {
fmt.Fprintf(b.config.ErrorOutput, "log drain error: %v\n", err)
}
}
default: // Exit when channel is empty
return
}
}
}
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
package lh
import (
"sync"
"time"
"github.com/olekukonko/ll/lx"
)
// Dedup is a log handler that suppresses duplicate entries within a TTL window.
// It wraps another handler (H) and filters out repeated log entries that match
// within the deduplication period.
type Dedup[H lx.Handler] struct {
next H
ttl time.Duration
cleanupEvery time.Duration
keyFn func(*lx.Entry) uint64
maxKeys int
// shards reduce lock contention by partitioning the key space
shards [32]dedupShard
done chan struct{}
wg sync.WaitGroup
once sync.Once
}
type dedupShard struct {
mu sync.Mutex
seen map[uint64]int64
}
// DedupOpt configures a Dedup handler.
type DedupOpt[H lx.Handler] func(*Dedup[H])
// WithDedupKeyFunc customizes how deduplication keys are generated.
func WithDedupKeyFunc[H lx.Handler](fn func(*lx.Entry) uint64) DedupOpt[H] {
return func(d *Dedup[H]) { d.keyFn = fn }
}
// WithDedupCleanupInterval sets how often expired deduplication keys are purged.
func WithDedupCleanupInterval[H lx.Handler](every time.Duration) DedupOpt[H] {
return func(d *Dedup[H]) {
if every > 0 {
d.cleanupEvery = every
}
}
}
// WithDedupMaxKeys sets a soft limit on tracked deduplication keys.
func WithDedupMaxKeys[H lx.Handler](max int) DedupOpt[H] {
return func(d *Dedup[H]) {
if max > 0 {
d.maxKeys = max
}
}
}
// NewDedup creates a deduplicating handler wrapper.
func NewDedup[H lx.Handler](next H, ttl time.Duration, opts ...DedupOpt[H]) *Dedup[H] {
if ttl <= 0 {
ttl = 2 * time.Second
}
d := &Dedup[H]{
next: next,
ttl: ttl,
cleanupEvery: time.Minute,
keyFn: defaultDedupKey,
done: make(chan struct{}),
}
// Initialize shards
for i := 0; i < len(d.shards); i++ {
d.shards[i].seen = make(map[uint64]int64, 64)
}
for _, opt := range opts {
opt(d)
}
d.wg.Add(1)
go d.cleanupLoop()
return d
}
// Handle processes a log entry, suppressing duplicates within the TTL window.
func (d *Dedup[H]) Handle(e *lx.Entry) error {
now := time.Now().UnixNano()
key := d.keyFn(e)
// Select shard based on key hash
shardIdx := key % uint64(len(d.shards))
shard := &d.shards[shardIdx]
shard.mu.Lock()
exp, ok := shard.seen[key]
if ok && now < exp {
shard.mu.Unlock()
return nil
}
// Basic guard against unbounded growth per shard
// Using strict limits per shard avoids global atomic counters
limitPerShard := d.maxKeys / len(d.shards)
if d.maxKeys > 0 && len(shard.seen) >= limitPerShard {
// Opportunistic cleanup of current shard
d.cleanupShard(shard, now)
}
shard.seen[key] = now + d.ttl.Nanoseconds()
shard.mu.Unlock()
return d.next.Handle(e)
}
// Close stops the cleanup goroutine and closes the underlying handler.
func (d *Dedup[H]) Close() error {
var err error
d.once.Do(func() {
close(d.done)
d.wg.Wait()
if c, ok := any(d.next).(interface{ Close() error }); ok {
err = c.Close()
}
})
return err
}
// cleanupLoop runs periodically to purge expired deduplication keys.
func (d *Dedup[H]) cleanupLoop() {
defer d.wg.Done()
t := time.NewTicker(d.cleanupEvery)
defer t.Stop()
for {
select {
case <-t.C:
now := time.Now().UnixNano()
// Cleanup all shards sequentially to avoid massive CPU spike
for i := 0; i < len(d.shards); i++ {
d.shards[i].mu.Lock()
d.cleanupShard(&d.shards[i], now)
d.shards[i].mu.Unlock()
}
case <-d.done:
return
}
}
}
// cleanupShard removes expired keys from a specific shard.
func (d *Dedup[H]) cleanupShard(shard *dedupShard, now int64) {
for k, exp := range shard.seen {
if now > exp {
delete(shard.seen, k)
}
}
}
+225
View File
@@ -0,0 +1,225 @@
package lh
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"sync"
"time"
"github.com/olekukonko/ll/lx"
)
var jsonBufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
// JSONHandler is a handler that outputs log entries as JSON objects.
// It formats log entries with timestamp, level, message, namespace, fields, and optional
// stack traces or dump segments, writing the result to the provided writer.
// Thread-safe with a mutex to protect concurrent writes.
type JSONHandler struct {
writer io.Writer // Destination for JSON output
timeFmt string // Format for timestamp (default: RFC3339Nano)
pretty bool // Enable pretty printing with indentation if true
//fieldMap map[string]string // Optional mapping for field names (not used in provided code)
mu sync.Mutex // Protects concurrent access to writer
}
// JsonOutput represents the JSON structure for a log entry.
// It includes all relevant log data, such as timestamp, level, message, and optional
// stack trace or dump segments, serialized as a JSON object.
type JsonOutput struct {
Time string `json:"ts"` // Timestamp in specified format
Level string `json:"lvl"` // Log level (e.g., "INFO")
Class string `json:"class"` // Entry class (e.g., "Text", "Dump")
Msg string `json:"msg"` // Log message
Namespace string `json:"ns"` // Namespace path
Stack []byte `json:"stack"` // Stack trace (if present)
Dump []dumpSegment `json:"dump"` // Hex/ASCII dump segments (for ClassDump)
Fields map[string]interface{} `json:"fields"` // Custom fields
}
// dumpSegment represents a single segment of a hex/ASCII dump.
// Used for ClassDump entries to structure position, hex values, and ASCII representation.
type dumpSegment struct {
Offset int `json:"offset"` // Starting byte offset of the segment
Hex []string `json:"hex"` // Hexadecimal values of bytes
ASCII string `json:"ascii"` // ASCII representation of bytes
}
// NewJSONHandler creates a new JSONHandler writing to the specified writer.
// It initializes the handler with a default timestamp format (RFC3339Nano) and optional
// configuration functions to customize settings like pretty printing.
// Example:
//
// handler := NewJSONHandler(os.Stdout)
// logger := ll.New("app").Enable().Handler(handler)
// logger.Info("Test") // Output: {"ts":"...","lvl":"INFO","class":"Text","msg":"Test","ns":"app","stack":null,"dump":null,"fields":null}
func NewJSONHandler(w io.Writer, opts ...func(*JSONHandler)) *JSONHandler {
h := &JSONHandler{
writer: w, // Set output writer
timeFmt: time.RFC3339Nano, // Default timestamp format
}
// Apply configuration options
for _, opt := range opts {
opt(h)
}
return h
}
// Handle processes a log entry and writes it as JSON.
// It delegates to specialized methods based on the entry's class (Dump or regular),
// ensuring thread-safety with a mutex.
// Returns an error if JSON encoding or writing fails.
// Example:
//
// handler.Handle(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Writes JSON object
func (h *JSONHandler) Handle(e *lx.Entry) error {
h.mu.Lock()
defer h.mu.Unlock()
// Handle dump entries separately
if e.Class == lx.ClassDump {
return h.handleDump(e)
}
// Handle standard log entries
return h.handleRegular(e)
}
// Output sets the Writer destination for JSONHandler's output, ensuring thread safety with a mutex lock.
func (h *JSONHandler) Output(w io.Writer) {
h.mu.Lock()
defer h.mu.Unlock()
h.writer = w
}
// handleRegular handles standard log entries (non-dump).
// It converts the entry to a JsonOutput struct and encodes it as JSON,
// applying pretty printing if enabled. Logs encoding errors to stderr for debugging.
// Returns an error if encoding or writing fails.
// Example (internal usage):
//
// h.handleRegular(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Writes JSON object
func (h *JSONHandler) handleRegular(e *lx.Entry) error {
// Convert ordered fields to map for JSON output
fieldsMap := make(map[string]interface{}, len(e.Fields))
for _, pair := range e.Fields {
fieldsMap[pair.Key] = pair.Value
}
// Create JSON output structure
entry := JsonOutput{
Time: e.Timestamp.Format(h.timeFmt), // Format timestamp
Level: e.Level.String(), // Convert level to string
Class: e.Class.String(), // Convert class to string
Msg: e.Message, // Set message
Namespace: e.Namespace, // Set namespace
Dump: nil, // No dump for regular entries
Fields: fieldsMap, // Copy fields as map
Stack: e.Stack, // Include stack trace if present
}
// Acquire buffer from pool to avoid allocation and reduce syscalls
buf := jsonBufPool.Get().(*bytes.Buffer)
buf.Reset()
defer jsonBufPool.Put(buf)
// Create JSON encoder writing to buffer
enc := json.NewEncoder(buf)
if h.pretty {
// Enable indentation for pretty printing
enc.SetIndent("", " ")
}
// Encode JSON to buffer
err := enc.Encode(entry)
if err != nil {
// Log encoding error for debugging
fmt.Fprintf(os.Stderr, "JSON encode error: %v\n", err)
return err
}
// Write buffer to underlying writer in one go
_, err = h.writer.Write(buf.Bytes())
return err
}
// handleDump processes ClassDump entries, converting hex dump output to JSON segments.
// It parses the dump message into structured segments with offset, hex, and ASCII data,
// encoding them as a JsonOutput struct.
// Returns an error if parsing or encoding fails.
// Example (internal usage):
//
// h.handleDump(&lx.Entry{Class: lx.ClassDump, Message: "pos 00 hex: 61 62 'ab'"}) // Writes JSON with dump segments
func (h *JSONHandler) handleDump(e *lx.Entry) error {
var segments []dumpSegment
lines := strings.Split(e.Message, "\n")
// Parse each line of the dump message
for _, line := range lines {
if !strings.HasPrefix(line, "pos") {
continue // Skip non-dump lines
}
parts := strings.SplitN(line, "hex:", 2)
if len(parts) != 2 {
continue // Skip invalid lines
}
// Parse position
var offset int
fmt.Sscanf(parts[0], "pos %d", &offset)
// Parse hex and ASCII
hexAscii := strings.SplitN(parts[1], "'", 2)
hexStr := strings.Fields(strings.TrimSpace(hexAscii[0]))
// Create dump segment
segments = append(segments, dumpSegment{
Offset: offset, // Set byte offset
Hex: hexStr, // Set hex values
ASCII: strings.Trim(hexAscii[1], "'"), // Set ASCII representation
})
}
// Convert ordered fields to map for JSON output
fieldsMap := make(map[string]interface{}, len(e.Fields))
for _, pair := range e.Fields {
fieldsMap[pair.Key] = pair.Value
}
// Acquire buffer from pool
buf := jsonBufPool.Get().(*bytes.Buffer)
buf.Reset()
defer jsonBufPool.Put(buf)
// Encode JSON output with dump segments to buffer
enc := json.NewEncoder(buf)
if h.pretty {
enc.SetIndent("", " ")
}
err := enc.Encode(JsonOutput{
Time: e.Timestamp.Format(h.timeFmt), // Format timestamp
Level: e.Level.String(), // Convert level to string
Class: e.Class.String(), // Convert class to string
Msg: "dumping segments", // Fixed message for dumps
Namespace: e.Namespace, // Set namespace
Dump: segments, // Include parsed segments
Fields: fieldsMap, // Copy fields as map
Stack: e.Stack, // Include stack trace if present
})
if err != nil {
fmt.Fprintf(os.Stderr, "JSON dump encode error: %v\n", err)
return err
}
// Write buffer to underlying writer
_, err = h.writer.Write(buf.Bytes())
return err
}
+67
View File
@@ -0,0 +1,67 @@
package lh
import (
"bytes"
"fmt"
"sort"
"strings"
"sync"
"github.com/cespare/xxhash/v2"
"github.com/olekukonko/ll/lx"
)
// rightPad pads a string with spaces on the right to reach the specified length.
// Returns the original string if it's already at or exceeds the target length.
// Uses strings.Builder for efficient memory allocation.
func rightPad(str string, length int) string {
if len(str) >= length {
return str
}
var sb strings.Builder
sb.Grow(length)
sb.WriteString(str)
sb.WriteString(strings.Repeat(" ", length-len(str)))
return sb.String()
}
var dedupBufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
// defaultDedupKey generates a deduplication key from log level and message.
// Uses FNV-1a hash for speed and good distribution. Override with WithDedupKeyFunc
// to include additional fields like namespace, caller, or structured fields.
func defaultDedupKey(e *lx.Entry) uint64 {
h := xxhash.New()
_, _ = h.Write([]byte(e.Level.String()))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(e.Message))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(e.Namespace))
_, _ = h.Write([]byte{0})
if len(e.Fields) > 0 {
m := e.Fields.Map()
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
buf := dedupBufPool.Get().(*bytes.Buffer)
buf.Reset()
defer dedupBufPool.Put(buf)
for _, k := range keys {
fmt.Fprint(buf, k)
buf.WriteByte('=')
fmt.Fprint(buf, m[k])
buf.WriteByte(0)
}
_, _ = h.Write(buf.Bytes())
}
return h.Sum64()
}
+114
View File
@@ -0,0 +1,114 @@
package lh
import (
"fmt"
"io"
"sync"
"github.com/olekukonko/ll/lx"
)
// MemoryHandler is an lx.Handler that stores log entries in memory.
// Useful for testing or buffering logs for later inspection.
// It maintains a thread-safe slice of log entries, protected by a read-write mutex.
type MemoryHandler struct {
mu sync.RWMutex // Protects concurrent access to entries
entries []*lx.Entry // Slice of stored log entries
showTime bool // Whether to show timestamps when dumping
timeFormat string // Time format for dumping
}
// NewMemoryHandler creates a new MemoryHandler.
// It initializes an empty slice for storing log entries, ready for use in logging or testing.
// Example:
//
// handler := NewMemoryHandler()
// logger := ll.New("app").Enable().Handler(handler)
// logger.Info("Test") // Stores entry in memory
func NewMemoryHandler() *MemoryHandler {
return &MemoryHandler{
entries: make([]*lx.Entry, 0), // Initialize empty slice for entries
}
}
// Timestamped enables/disables timestamp display when dumping and optionally sets a time format.
// Consistent with TextHandler and ColorizedHandler signature.
// Example:
//
// handler.Timestamped(true) // Enable with default format
// handler.Timestamped(true, time.StampMilli) // Enable with custom format
// handler.Timestamped(false) // Disable
func (h *MemoryHandler) Timestamped(enable bool, format ...string) {
h.mu.Lock()
defer h.mu.Unlock()
h.showTime = enable
if len(format) > 0 && format[0] != "" {
h.timeFormat = format[0]
}
}
// Handle stores the log entry in memory.
// It appends the provided entry to the entries slice, ensuring thread-safety with a write lock.
// Always returns nil, as it does not perform I/O operations.
// Example:
//
// handler.Handle(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Stores entry
func (h *MemoryHandler) Handle(entry *lx.Entry) error {
h.mu.Lock()
defer h.mu.Unlock()
h.entries = append(h.entries, entry) // Append entry to slice
return nil
}
// Entries returns a copy of the stored log entries.
// It creates a new slice with copies of all entries, ensuring thread-safety with a read lock.
// The returned slice is safe for external use without affecting the handler's internal state.
// Example:
//
// entries := handler.Entries() // Returns copy of stored entries
func (h *MemoryHandler) Entries() []*lx.Entry {
h.mu.RLock()
defer h.mu.RUnlock()
entries := make([]*lx.Entry, len(h.entries)) // Create new slice for copy
copy(entries, h.entries) // Copy entries to new slice
return entries
}
// Reset clears all stored entries.
// It truncates the entries slice to zero length, preserving capacity, using a write lock for thread-safety.
// Example:
//
// handler.Reset() // Clears all stored entries
func (h *MemoryHandler) Reset() {
h.mu.Lock()
defer h.mu.Unlock()
h.entries = h.entries[:0] // Truncate slice to zero length
}
// Dump writes all stored log entries to the provided io.Writer in text format.
// Entries are formatted as they would be by a TextHandler, including namespace, level,
// message, and fields. Thread-safe with read lock.
// Returns an error if writing fails.
// Example:
//
// logger := ll.New("test", ll.WithHandler(NewMemoryHandler())).Enable()
// logger.Info("Test message")
// handler := logger.handler.(*MemoryHandler)
// handler.Dump(os.Stdout) // Output: [test] INFO: Test message
func (h *MemoryHandler) Dump(w io.Writer) error {
h.mu.RLock()
defer h.mu.RUnlock()
// Create a temporary TextHandler to format entries
tempHandler := NewTextHandler(w)
tempHandler.Timestamped(h.showTime, h.timeFormat)
// Process each entry through the TextHandler
for _, entry := range h.entries {
if err := tempHandler.Handle(entry); err != nil {
return fmt.Errorf("failed to dump entry: %writer", err) // Wrap and return write errors
}
}
return nil
}
+80
View File
@@ -0,0 +1,80 @@
package lh
import (
"errors"
"fmt"
"github.com/olekukonko/ll/lx"
)
// MultiHandler combines multiple handlers to process log entries concurrently.
// It holds a list of lx.Handler instances and delegates each log entry to all handlers,
// collecting any errors into a single combined error.
// Thread-safe if the underlying handlers are thread-safe.
type MultiHandler struct {
Handlers []lx.Handler // List of handlers to process each log entry
}
// NewMultiHandler creates a new MultiHandler with the specified handlers.
// It accepts a variadic list of handlers to be executed in order.
// The returned handler processes log entries by passing them to each handler in sequence.
// Example:
//
// textHandler := NewTextHandler(os.Stdout)
// jsonHandler := NewJSONHandler(os.Stdout)
// multi := NewMultiHandler(textHandler, jsonHandler)
// logger := ll.New("app").Enable().Handler(multi)
// logger.Info("Test") // Processed by both text and JSON handlers
func NewMultiHandler(h ...lx.Handler) *MultiHandler {
return &MultiHandler{
Handlers: h, // Initialize with provided handlers
}
}
// Len returns the number of handlers in the MultiHandler.
// Useful for monitoring or debugging handler composition.
//
// Example:
//
// multi := &MultiHandler{}
// multi.Append(h1, h2, h3)
// count := multi.Len() // Returns 3
func (h *MultiHandler) Len() int {
return len(h.Handlers)
}
// Append adds one or more handlers to the MultiHandler.
// Handlers will receive log entries in the order they were appended.
// This method modifies the MultiHandler in place.
//
// Example:
//
// multi := &MultiHandler{}
// multi.Append(
// lx.NewJSONHandler(os.Stdout),
// lx.NewTextHandler(logFile),
// )
// // Now multi broadcasts to both stdout and file
func (h *MultiHandler) Append(handlers ...lx.Handler) {
h.Handlers = append(h.Handlers, handlers...)
}
// Handle implements the Handler interface, calling Handle on each handler in sequence.
// It collects any errors from handlers and combines them into a single error using errors.Join.
// If no errors occur, it returns nil. Thread-safe if the underlying handlers are thread-safe.
// Example:
//
// multi.Handle(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Calls Handle on all handlers
func (h *MultiHandler) Handle(e *lx.Entry) error {
var errs []error // Collect errors from handlers
for i, handler := range h.Handlers {
// Process entry with each handler
if err := handler.Handle(e); err != nil {
// fmt.Fprintf(os.Stderr, "MultiHandler error for handler %d: %v\n", i, err)
// Wrap error with handler index for context
errs = append(errs, fmt.Errorf("handler %d: %writer", i, err))
}
}
// Combine errors into a single error, or return nil if no errors
return errors.Join(errs...)
}
+76
View File
@@ -0,0 +1,76 @@
package lh
import (
"fmt"
"os"
"time"
"github.com/olekukonko/ll/lx"
)
// Pipe chains multiple handler wrappers together, applying them from left to right.
// The wrappers are composed such that the first wrapper in the list becomes
// the innermost layer, and the last wrapper becomes the outermost layer.
//
// Usage pattern: Pipe(baseHandler, wrapper1, wrapper2, wrapper3)
// Result: wrapper3(wrapper2(wrapper1(baseHandler)))
//
// This enables clean, declarative construction of handler middleware chains.
//
// Example - building a processing pipeline:
//
// base := lx.NewJSONHandler(os.Stdout)
// handler := lh.Pipe(base,
// lh.NewDedup(2*time.Second), // 1. Deduplicate first
// lh.NewRateLimit(10, time.Second), // 2. Then rate limit
// )
// logger := lx.NewLogger(handler)
//
// In this example, logs flow: Dedup → RateLimit → AddTimestamp → JSONHandler
func Pipe(h lx.Handler, wraps ...lx.Wrap) lx.Handler {
for _, w := range wraps {
if w != nil {
h = w(h)
}
}
return h
}
// PipeDedup returns a wrapper that applies deduplication to the handler.
func PipeDedup(ttl time.Duration, opts ...DedupOpt[lx.Handler]) lx.Wrap {
return func(next lx.Handler) lx.Handler {
return NewDedup(next, ttl, opts...)
}
}
// PipeBuffer returns a wrapper that applies buffering to the handler.
func PipeBuffer(opts ...BufferingOpt) lx.Wrap {
return func(next lx.Handler) lx.Handler {
return NewBuffered(next, opts...)
}
}
// PipeRotate returns a wrapper that applies log rotation.
// Ideally, the 'next' handler should be one that writes to a file (like TextHandler or JSONHandler).
//
// If the underlying handler does not implement lx.HandlerOutputter (cannot change output destination),
// or if rotation initialization fails, this will log a warning to stderr and return the
// original handler unmodified to prevent application crashes.
func PipeRotate(maxSizeBytes int64, src RotateSource) lx.Wrap {
return func(next lx.Handler) lx.Handler {
// Attempt to cast to HandlerOutputter (Handler + Outputter interface)
h, ok := next.(lx.HandlerOutputter)
if !ok {
fmt.Fprintf(os.Stderr, "ll/lh: PipeRotate skipped - handler does not implement SetOutput(io.Writer)\n")
return next
}
// Initialize the rotating handler
r, err := NewRotating(h, maxSizeBytes, src)
if err != nil {
fmt.Fprintf(os.Stderr, "ll/lh: PipeRotate initialization failed: %v\n", err)
return next
}
return r
}
}
+176
View File
@@ -0,0 +1,176 @@
package lh
import (
"io"
"sync"
"github.com/olekukonko/ll/lx"
)
// RotateSource defines the callbacks needed to implement log rotation.
// It abstracts the destination lifecycle: opening, sizing, and rotating.
//
// Example for file rotation:
//
// src := lh.RotateSource{
// Open: func() (io.WriteCloser, error) {
// return os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
// },
// Size: func() (int64, error) {
// if fi, err := os.Stat("app.log"); err == nil {
// return fi.Size(), nil
// }
// return 0, nil // File doesn't exist yet
// },
// Rotate: func() error {
// // Rename current log before creating new one
// return os.Rename("app.log", "app.log."+time.Now().Format("20060102-150405"))
// },
// }
type RotateSource struct {
// Open returns a fresh destination for log output.
// Called on initialization and after rotation.
Open func() (io.WriteCloser, error)
// Size returns the current size in bytes of the active destination.
// Return an error if size cannot be determined (rotation will be skipped).
Size func() (int64, error)
// Rotate performs cleanup/rotation actions before opening a new destination.
// For files: rename or move the current log. Optional for other destinations.
Rotate func() error
}
// Rotating wraps a handler to rotate its output when maxSize is exceeded.
// The wrapped handler must implement both Handler and Outputter interfaces.
// Rotation is triggered on each Handle call if the current size >= maxSize.
//
// Example:
//
// handler := lx.NewJSONHandler(os.Stdout)
// src := lh.RotateSource{...} // see RotateSource example
// rotator, err := lh.NewRotating(handler, 10*1024*1024, src) // 10 MB
// logger := lx.NewLogger(rotator)
// logger.Info("This log may trigger rotation when file reaches 10MB")
type Rotating[H interface {
lx.Handler
lx.Outputter
}] struct {
mu sync.Mutex
maxSize int64
src RotateSource
out io.WriteCloser
handler H
}
// NewRotating creates a rotating wrapper around handler.
// Handler's output will be replaced with destinations from src.Open.
// If maxSizeBytes <= 0, rotation is disabled.
// src.Rotate may be nil if no pre-open actions are needed.
//
// Example:
//
// // Create a JSON handler that rotates at 5MB
// handler := lx.NewJSONHandler(os.Stdout)
// rotator, err := lh.NewRotating(handler, 5*1024*1024, src)
// if err != nil {
// log.Fatal(err)
// }
// // Use rotator as your logger's handler
// logger := lx.NewLogger(rotator)
func NewRotating[H interface {
lx.Handler
lx.Outputter
}](handler H, maxSizeBytes int64, src RotateSource) (*Rotating[H], error) {
r := &Rotating[H]{
maxSize: maxSizeBytes,
src: src,
handler: handler,
}
if err := r.reopenLocked(); err != nil {
return nil, err
}
return r, nil
}
// Handle processes a log entry, rotating output if necessary.
// Thread-safe: can be called concurrently.
//
// Example:
//
// rotator.Handle(&lx.Entry{
// Level: lx.InfoLevel,
// Message: "Processing request",
// Namespace: "api",
// })
func (r *Rotating[H]) Handle(e *lx.Entry) error {
r.mu.Lock()
defer r.mu.Unlock()
if err := r.rotateIfNeededLocked(); err != nil {
return err
}
return r.handler.Handle(e)
}
// Close releases resources (closes the current output).
// Safe to call multiple times.
//
// Example:
//
// defer rotator.Close()
func (r *Rotating[H]) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.out != nil {
return r.out.Close()
}
return nil
}
// rotateIfNeededLocked checks current size and rotates if maxSize exceeded.
// Called with mu already held.
func (r *Rotating[H]) rotateIfNeededLocked() error {
if r.maxSize <= 0 || r.src.Size == nil || r.src.Open == nil {
return nil
}
size, err := r.src.Size()
if err != nil {
// Size unknown - skip rotation
return nil
}
if size < r.maxSize {
return nil
}
// Close current output
if r.out != nil {
_ = r.out.Close()
r.out = nil
}
// Run rotation hook (rename/move/commit)
if r.src.Rotate != nil {
if err := r.src.Rotate(); err != nil {
return err
}
}
// Open fresh output
return r.reopenLocked()
}
// reopenLocked opens a new destination and sets it on the handler.
// Called with mu already held.
func (r *Rotating[H]) reopenLocked() error {
out, err := r.src.Open()
if err != nil {
return err
}
r.out = out
r.handler.Output(out)
return nil
}
+99
View File
@@ -0,0 +1,99 @@
package lh
import (
"context"
"log/slog"
"github.com/olekukonko/ll/lx"
)
// SlogHandler adapts a slog.Handler to implement lx.Handler.
// It converts lx.Entry objects to slog.Record objects and delegates to an underlying
// slog.Handler for processing, enabling compatibility with Go's standard slog package.
// Thread-safe if the underlying slog.Handler is thread-safe.
type SlogHandler struct {
slogHandler slog.Handler // Underlying slog.Handler for processing log records
}
// NewSlogHandler creates a new SlogHandler wrapping the provided slog.Handler.
// It initializes the handler with the given slog.Handler, allowing lx.Entry logs to be
// processed by slog's logging infrastructure.
// Example:
//
// slogText := slog.NewTextHandler(os.Stdout, nil)
// handler := NewSlogHandler(slogText)
// logger := ll.New("app").Enable().Handler(handler)
// logger.Info("Test") // Output: level=INFO msg=Test namespace=app class=Text
func NewSlogHandler(h slog.Handler) *SlogHandler {
return &SlogHandler{slogHandler: h}
}
// Handle converts an lx.Entry to slog.Record and delegates to the slog.Handler.
// It maps the entry's fields, level, namespace, class, and stack trace to slog attributes,
// passing the resulting record to the underlying slog.Handler.
// Returns an error if the slog.Handler fails to process the record.
// Thread-safe if the underlying slog.Handler is thread-safe.
// Example:
//
// handler.Handle(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Processes as slog record
//
// Handle converts an lx.Entry to slog.Record and delegates to the slog.Handler.
// It maps the entry's fields, level, namespace, class, and stack trace to slog attributes,
// passing the resulting record to the underlying slog.Handler.
// Returns an error if the slog.Handler fails to process the record.
// Thread-safe if the underlying slog.Handler is thread-safe.
// Example:
//
// handler.Handle(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Processes as slog record
func (h *SlogHandler) Handle(e *lx.Entry) error {
// Convert lx.LevelType to slog.Level
level := toSlogLevel(e.Level)
// Create a slog.Record with the entry's data
record := slog.NewRecord(
e.Timestamp, // time.Time for log timestamp
level, // slog.Level for log severity
e.Message, // string for log message
0, // pc (program counter, optional, not used)
)
// Add standard fields as attributes
record.AddAttrs(
slog.String("namespace", e.Namespace), // Add namespace as string attribute
slog.String("class", e.Class.String()), // Add class as string attribute
)
// Add stack trace if present
if len(e.Stack) > 0 {
record.AddAttrs(slog.String("stack", string(e.Stack))) // Add stack trace as string
}
// Add custom fields in order (preserving insertion order)
for _, pair := range e.Fields {
record.AddAttrs(slog.Any(pair.Key, pair.Value)) // Add each field as a key-value attribute
}
// Handle the record with the underlying slog.Handler
return h.slogHandler.Handle(context.Background(), record)
}
// toSlogLevel converts lx.LevelType to slog.Level.
// It maps the logging levels used by the lx package to those used by slog,
// defaulting to slog.LevelInfo for unknown levels.
// Example (internal usage):
//
// level := toSlogLevel(lx.LevelDebug) // Returns slog.LevelDebug
func toSlogLevel(level lx.LevelType) slog.Level {
switch level {
case lx.LevelDebug:
return slog.LevelDebug
case lx.LevelInfo:
return slog.LevelInfo
case lx.LevelWarn:
return slog.LevelWarn
case lx.LevelError, lx.LevelFatal:
return slog.LevelError
default:
return slog.LevelInfo // Default for unknown levels
}
}
+249
View File
@@ -0,0 +1,249 @@
package lh
import (
"bytes"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/olekukonko/ll/lx"
)
type TextOption func(*TextHandler)
var textBufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
// WithTextTimeFormat enables timestamp display and optionally sets a custom time format.
// It configures the TextHandler to include temporal information in each log entry,
// allowing for precise tracking of when log events occur.
// If the format string is empty, it defaults to time.RFC3339.
func WithTextTimeFormat(format string) TextOption {
return func(t *TextHandler) {
t.Timestamped(true, format)
}
}
// WithTextShowTime enables or disables timestamp display in log entries.
// This option provides direct control over the visibility of the time prefix
// without altering the underlying time format configured in the handler.
// Setting show to true will prepend timestamps to all subsequent regular log outputs.
func WithTextShowTime(show bool) TextOption {
return func(t *TextHandler) {
t.showTime = show
}
}
// TextHandler is a handler that outputs log entries as plain text.
// It formats log entries with namespace, level, message, fields, and optional stack traces,
// writing the result to the provided writer.
// Thread-safe if the underlying writer is thread-safe.
type TextHandler struct {
writer io.Writer // Destination for formatted log output
showTime bool // Whether to display timestamps
timeFormat string // Format for timestamps (defaults to time.RFC3339)
mu sync.Mutex
}
// NewTextHandler creates a new TextHandler writing to the specified writer.
// It initializes the handler with the given writer, suitable for outputs like stdout or files.
// Example:
//
// handler := NewTextHandler(os.Stdout)
// logger := ll.New("app").Enable().Handler(handler)
// logger.Info("Test") // Output: [app] INFO: Test
func NewTextHandler(w io.Writer, opts ...TextOption) *TextHandler {
t := &TextHandler{
writer: w,
showTime: false,
timeFormat: time.RFC3339,
}
for _, opt := range opts {
opt(t)
}
return t
}
// Timestamped enables or disables timestamp display and optionally sets a custom time format.
// If format is empty, defaults to RFC3339.
// Example:
//
// handler := NewTextHandler(os.Stdout).TextWithTime(true, time.StampMilli)
// // Output: Jan 02 15:04:05.000 [app] INFO: Test
func (h *TextHandler) Timestamped(enable bool, format ...string) {
h.showTime = enable
if len(format) > 0 && format[0] != "" {
h.timeFormat = format[0]
}
}
// Output sets a new writer for the TextHandler.
// Thread-safe - safe for concurrent use.
func (h *TextHandler) Output(w io.Writer) {
h.mu.Lock()
defer h.mu.Unlock()
h.writer = w
}
// Handle processes a log entry and writes it as plain text.
// It delegates to specialized methods based on the entry's class (Dump, Raw, or regular).
// Returns an error if writing to the underlying writer fails.
// Thread-safe if the writer is thread-safe.
// Example:
//
// handler.Handle(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Writes "INFO: test"
func (h *TextHandler) Handle(e *lx.Entry) error {
h.mu.Lock()
defer h.mu.Unlock()
if e.Class == lx.ClassDump {
return h.handleDumpOutput(e)
}
if e.Class == lx.ClassRaw {
_, err := h.writer.Write([]byte(e.Message))
return err
}
return h.handleRegularOutput(e)
}
// handleRegularOutput handles normal log entries.
// It formats the entry with namespace, level, message, fields, and stack trace (if present),
// writing the result to the handler's writer.
// Returns an error if writing fails.
// Example (internal usage):
//
// h.handleRegularOutput(&lx.Entry{Message: "test", Level: lx.LevelInfo}) // Writes "INFO: test"
func (h *TextHandler) handleRegularOutput(e *lx.Entry) error {
buf := textBufPool.Get().(*bytes.Buffer)
buf.Reset()
defer textBufPool.Put(buf)
if h.showTime {
buf.WriteString(e.Timestamp.Format(h.timeFormat))
buf.WriteString(lx.Space)
}
switch e.Style {
case lx.NestedPath:
if e.Namespace != "" {
parts := strings.Split(e.Namespace, lx.Slash)
for i, part := range parts {
buf.WriteString(lx.LeftBracket)
buf.WriteString(part)
buf.WriteString(lx.RightBracket)
if i < len(parts)-1 {
buf.WriteString(lx.Arrow)
}
}
buf.WriteString(lx.Colon)
buf.WriteString(lx.Space)
}
default: // FlatPath
if e.Namespace != "" {
buf.WriteString(lx.LeftBracket)
buf.WriteString(e.Namespace)
buf.WriteString(lx.RightBracket)
buf.WriteString(lx.Space)
}
}
buf.WriteString(e.Level.Name(e.Class))
// buf.WriteString(lx.Space)
buf.WriteString(lx.Colon)
buf.WriteString(lx.Space)
buf.WriteString(e.Message)
if len(e.Fields) > 0 {
buf.WriteString(lx.Space)
buf.WriteString(lx.LeftBracket)
for i, pair := range e.Fields {
if i > 0 {
buf.WriteString(lx.Space)
}
buf.WriteString(pair.Key)
buf.WriteString("=")
fmt.Fprint(buf, pair.Value)
}
buf.WriteString(lx.RightBracket)
}
if len(e.Stack) > 0 {
h.formatStack(buf, e.Stack)
}
if e.Level != lx.LevelNone {
buf.WriteString(lx.Newline)
}
_, err := h.writer.Write(buf.Bytes())
return err
}
// handleDumpOutput specially formats hex dump output (plain text version).
// It wraps the dump message with BEGIN/END separators for clarity.
// Returns an error if writing fails.
// Example (internal usage):
//
// h.handleDumpOutput(&lx.Entry{Class: lx.ClassDump, Message: "pos 00 hex: 61"}) // Writes "---- BEGIN DUMP ----\npos 00 hex: 61\n---- END DUMP ----\n"
func (h *TextHandler) handleDumpOutput(e *lx.Entry) error {
buf := textBufPool.Get().(*bytes.Buffer)
buf.Reset()
defer textBufPool.Put(buf)
if h.showTime {
buf.WriteString(e.Timestamp.Format(h.timeFormat))
buf.WriteString(lx.Newline)
}
buf.WriteString("---- BEGIN DUMP ----\n")
buf.WriteString(e.Message)
buf.WriteString("---- END DUMP ----\n\n")
_, err := h.writer.Write(buf.Bytes())
return err
}
// formatStack formats a stack trace for plain text output.
// It structures the stack trace with indentation and separators for readability,
// including goroutine and function/file details.
// Example (internal usage):
//
// h.formatStack(&builder, []byte("goroutine 1 [running]:\nmain.main()\n\tmain.go:10")) // Appends formatted stack trace
func (h *TextHandler) formatStack(b *bytes.Buffer, stack []byte) {
lines := strings.Split(string(stack), "\n")
if len(lines) == 0 {
return
}
b.WriteString("\n[stack]\n")
b.WriteString(" ┌─ ")
b.WriteString(lines[0])
b.WriteString("\n")
for i := 1; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
if strings.Contains(line, ".go") {
b.WriteString(" ├ ")
} else {
b.WriteString(" │ ")
}
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString(" └\n")
}