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
+5
View File
@@ -0,0 +1,5 @@
.idea
lab
tmp
#_*
_test/
+37
View File
@@ -0,0 +1,37 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
project_name: ll
# For a library repo, publish source archives instead of binaries.
source:
enabled: true
name_template: "{{ .ProjectName }}_{{ .Version }}"
# Optional: include/exclude files in the source archive (defaults are usually fine)
# files:
# - README.md
# - LICENSE
# - go.mod
# - go.sum
# - "**/*.go"
# No binaries to build.
builds: []
## Other Information
checksum:
name_template: "checksums.txt"
snapshot:
version_template: "{{ .Tag }}-next"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
- "^ci:"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Oleku Konko
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+99
View File
@@ -0,0 +1,99 @@
# Git remote for pushing tags
REMOTE ?= origin
# Version for release tagging (required for tag/release targets)
RELEASE_VERSION ?=
# Convenience
GO ?= go
GOLANGCI ?= golangci-lint
GORELEASER?= goreleaser
.PHONY: help \
test race bench fmt tidy lint check \
ensure-clean ensure-release-version tag tag-delete \
release release-dry
help:
@echo "Targets:"
@echo " fmt - gofmt + go fmt"
@echo " tidy - go mod tidy"
@echo " test - go test ./..."
@echo " race - go test -race ./..."
@echo " bench - go test -bench=. ./..."
@echo " lint - golangci-lint run ./... (if installed)"
@echo " check - fmt + tidy + test + race"
@echo ""
@echo "Release targets:"
@echo " tag - Create annotated tag RELEASE_VERSION and push"
@echo " tag-delete - Delete tag RELEASE_VERSION locally + remote"
@echo " release - tag + goreleaser release --clean (if you use goreleaser)"
@echo " release-dry - tag + goreleaser release --clean --skip=publish"
@echo ""
@echo "Usage:"
@echo " make check"
@echo " make tag RELEASE_VERSION=v0.1.2"
@echo " make release RELEASE_VERSION=v0.1.2"
fmt:
@echo "Formatting..."
gofmt -w -s .
$(GO) fmt ./...
tidy:
@echo "Tidying..."
$(GO) mod tidy
test:
@echo "Testing..."
$(GO) test ./... -count=1
race:
@echo "Race testing..."
$(GO) test ./... -race -count=1
bench:
@echo "Bench..."
$(GO) test ./... -bench=. -run=^$$
lint:
@echo "Linting..."
@command -v $(GOLANGCI) >/dev/null 2>&1 || { echo "golangci-lint not found"; exit 1; }
$(GOLANGCI) run ./...
check: fmt tidy test race
# --------------------------
# Release helpers
# --------------------------
ensure-clean:
@echo "Checking git working tree..."
@git diff --quiet || (echo "Error: tracked changes exist. Commit/stash them."; exit 1)
@test -z "$$(git status --porcelain)" || (echo "Error: uncommitted/untracked files:"; git status --porcelain; exit 1)
@echo "OK: working tree clean"
ensure-release-version:
@test -n "$(RELEASE_VERSION)" || (echo "Error: set RELEASE_VERSION, e.g. make tag RELEASE_VERSION=v0.1.2"; exit 1)
tag: ensure-clean ensure-release-version
@if git rev-parse "$(RELEASE_VERSION)" >/dev/null 2>&1; then \
echo "Error: tag $(RELEASE_VERSION) already exists. Bump version."; \
exit 1; \
fi
@echo "Tagging $(RELEASE_VERSION) at HEAD $$(git rev-parse --short HEAD)"
@git tag -a $(RELEASE_VERSION) -m "$(RELEASE_VERSION)"
@git push $(REMOTE) $(RELEASE_VERSION)
tag-delete: ensure-release-version
@echo "Deleting tag $(RELEASE_VERSION) locally + remote..."
@git tag -d $(RELEASE_VERSION) 2>/dev/null || true
@git push $(REMOTE) :refs/tags/$(RELEASE_VERSION) || true
release: tag
@command -v $(GORELEASER) >/dev/null 2>&1 || { echo "goreleaser not found"; exit 1; }
$(GORELEASER) release --clean
release-dry: tag
@command -v $(GORELEASER) >/dev/null 2>&1 || { echo "goreleaser not found"; exit 1; }
$(GORELEASER) release --clean --skip=publish
+417
View File
@@ -0,0 +1,417 @@
# ll - A Modern Structured Logging Library for Go
`ll` is a high-performance, production-ready logging library for Go, designed to provide **hierarchical namespaces**, **structured logging**, **middleware pipelines**, **conditional logging**, and support for multiple output formats, including text, JSON, colorized logs, syslog, VictoriaLogs, and compatibility with Go's `slog`. It's ideal for applications requiring fine-grained log control, extensibility, and scalability.
## Key Features
- **Logging Enabled by Default** - Zero configuration to start logging
- **Hierarchical Namespaces** - Organize logs with fine-grained control over subsystems (e.g., "app/db")
- **Structured Logging** - Add key-value metadata for machine-readable logs
- **Middleware Pipeline** - Customize log processing with rate limiting, sampling, and deduplication
- **Conditional & Error-Based Logging** - Optimize performance with fluent `If`, `IfErr`, `IfAny`, `IfOne` chains
- **Multiple Output Formats** - Text, JSON, colorized ANSI, syslog, VictoriaLogs, and `slog` integration
- **Advanced Debugging Utilities** - Source-aware `Dbg()`, hex/ASCII `Dump()`, private field `Inspect()`, and stack traces
- **Production Ready** - Buffered batching, log rotation, duplicate suppression, and rate limiting
- **Thread-Safe** - Built for high-concurrency with atomic operations, sharded mutexes, and lock-free fast paths
- **Performance Optimized** - Zero allocations for disabled logs, sync.Pool buffers, LRU caching for source files
## Installation
Install `ll` using Go modules:
```bash
go get github.com/olekukonko/ll
```
Requires Go 1.21 or later.
## Quick Start
```go
package main
import "github.com/olekukonko/ll"
func main() {
// Logger is ENABLED by default - no .Enable() needed!
logger := ll.New("app")
// Basic logging - works immediately
logger.Info("Server starting") // Output: [app] INFO: Server starting
logger.Warn("Memory high") // Output: [app] WARN: Memory high
logger.Error("Connection failed") // Output: [app] ERROR: Connection failed
// Structured fields
logger.Fields("user", "alice", "status", 200).Info("Login successful")
// Output: [app] INFO: Login successful [user=alice status=200]
}
```
**That's it. No `.Enable()`, no handlers to configure—it just works.**
## Core Concepts
### 1. Enabled by Default, Configurable When Needed
Unlike many logging libraries that require explicit enabling, `ll` **logs immediately**. This eliminates boilerplate and reduces the chance of missing logs in production.
```go
// This works out of the box:
ll.Info("Service started") // Output: [] INFO: Service started
// But you still have full control:
ll.Disable() // Global shutdown
ll.Enable() // Reactivate
```
### 2. Hierarchical Namespaces
Organize logs hierarchically with precise control over subsystems:
```go
// Create a logger hierarchy
root := ll.New("app")
db := root.Namespace("database")
cache := root.Namespace("cache").Style(lx.NestedPath)
// Control logging per namespace
root.NamespaceEnable("app/database") // Enable database logs
root.NamespaceDisable("app/cache") // Disable cache logs
db.Info("Connected") // Output: [app/database] INFO: Connected
cache.Info("Hit") // No output (disabled)
```
### 3. Structured Logging with Ordered Fields
Fields maintain insertion order and support fluent chaining:
```go
// Fluent key-value pairs
logger.
Fields("request_id", "req-123").
Fields("user", "alice").
Fields("duration_ms", 42).
Info("Request processed")
// Map-based fields
logger.Field(map[string]interface{}{
"method": "POST",
"path": "/api/users",
}).Debug("API call")
// Persistent context (included in ALL subsequent logs)
logger.AddContext("environment", "production", "version", "1.2.3")
logger.Info("Deployed") // Output: ... [environment=production version=1.2.3]
```
### 4. Conditional & Error-Based Logging
Optimize performance with fluent conditional chains that **completely skip processing** when conditions are false:
```go
// Boolean conditions
logger.If(debugMode).Debug("Detailed diagnostics") // No overhead when false
logger.If(featureEnabled).Info("Feature used")
// Error conditions
err := db.Query()
logger.IfErr(err).Error("Query failed") // Logs only if err != nil
// Multiple conditions - ANY true
logger.IfErrAny(err1, err2, err3).Fatal("System failure")
// Multiple conditions - ALL true
logger.IfErrOne(validateErr, authErr).Error("Both checks failed")
// Chain conditions
logger.
If(debugMode).
IfErr(queryErr).
Fields("query", sql).
Debug("Query debug")
```
**Performance**: When conditions are false, the logger returns immediately with zero allocations.
### 5. Powerful Debugging Toolkit
`ll` includes advanced debugging utilities not found in standard logging libraries:
#### Dbg() - Source-Aware Variable Inspection
Captures both variable name AND value from your source code:
```go
x := 42
user := &User{Name: "Alice"}
ll.Dbg(x, user)
// Output: [file.go:123] x = 42, *user = &{Name:Alice}
```
#### Dump() - Hex/ASCII Binary Inspection
Perfect for protocol debugging and binary data:
```go
ll.Handler(lh.NewColorizedHandler(os.Stdout))
ll.Dump([]byte("hello\nworld"))
// Output: Colorized hex/ASCII dump with offset markers
```
#### Inspect() - Private Field Reflection
Reveals unexported fields, embedded structs, and pointer internals:
```go
type secret struct {
password string // unexported!
}
s := secret{password: "hunter2"}
ll.Inspect(s)
// Output: [file.go:123] INSPECT: {
// "(password)": "hunter2" // Note the parentheses
// }
```
#### Stack() - Configurable Stack Traces
```go
ll.StackSize(8192) // Larger buffer for deep stacks
ll.Stack("Critical failure")
// Output: ERROR: Critical failure [stack=goroutine 1 [running]...]
```
#### Mark() - Execution Flow Tracing
```go
func process() {
ll.Mark() // *MARK*: [file.go:123]
ll.Mark("phase1") // *phase1*: [file.go:124]
// ... work ...
}
```
### 6. Production-Ready Handlers
```go
import (
"github.com/olekukonko/ll"
"github.com/olekukonko/ll/lh"
"github.com/olekukonko/ll/l3rd/syslog"
"github.com/olekukonko/ll/l3rd/victoria"
)
// JSON for structured logging
logger.Handler(lh.NewJSONHandler(os.Stdout))
// Colorized for development
logger.Handler(lh.NewColorizedHandler(os.Stdout,
lh.WithColorTheme("dark"),
lh.WithColorIntensity(lh.IntensityVibrant),
))
// Buffered for high throughput (100 entries or 10 seconds)
buffered := lh.NewBuffered(
lh.NewJSONHandler(os.Stdout),
lh.WithBatchSize(100),
lh.WithFlushInterval(10 * time.Second),
)
logger.Handler(buffered)
defer buffered.Close() // Ensures flush on exit
// Syslog integration
syslogHandler, _ := syslog.New(
syslog.WithTag("myapp"),
syslog.WithFacility(syslog.LOG_LOCAL0),
)
logger.Handler(syslogHandler)
// VictoriaLogs (cloud-native)
victoriaHandler, _ := victoria.New(
victoria.WithURL("http://victoria-logs:9428"),
victoria.WithAppName("payment-service"),
victoria.WithEnvironment("production"),
victoria.WithBatching(200, 5*time.Second),
)
logger.Handler(victoriaHandler)
```
### 7. Middleware Pipeline
Transform, filter, or reject logs with a middleware pipeline:
```go
// Rate limiting - 10 logs per second maximum
rateLimiter := lm.NewRateLimiter(lx.LevelInfo, 10, time.Second)
logger.Use(rateLimiter)
// Sampling - 10% of debug logs
sampler := lm.NewSampling(lx.LevelDebug, 0.1)
logger.Use(sampler)
// Deduplication - suppress identical logs for 2 seconds
deduper := lh.NewDedup(logger.GetHandler(), 2*time.Second)
logger.Handler(deduper)
// Custom middleware
logger.Use(ll.Middle(func(e *lx.Entry) error {
if strings.Contains(e.Message, "password") {
return fmt.Errorf("sensitive information redacted")
}
return nil
}))
```
### 8. Global Convenience API
Use package-level functions for quick logging without creating loggers:
```go
import "github.com/olekukonko/ll"
func main() {
ll.Info("Server starting") // Global logger
ll.Fields("port", 8080).Info("Listening")
// Conditional logging at package level
ll.If(simulation).Debug("Test mode")
ll.IfErr(err).Error("Startup failed")
// Debug utilities
ll.Dbg(config)
ll.Dump(requestBody)
ll.Inspect(complexStruct)
}
```
## Real-World Examples
### Web Server with Structured Logging
```go
package main
import (
"github.com/olekukonko/ll"
"github.com/olekukonko/ll/lh"
"net/http"
"time"
)
func main() {
// Root logger - enabled by default
log := ll.New("server")
// JSON output for production
log.Handler(lh.NewJSONHandler(os.Stdout))
// Request logger with context
http.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
reqLog := log.Namespace("http").Fields(
"method", r.Method,
"path", r.URL.Path,
"request_id", r.Header.Get("X-Request-ID"),
)
start := time.Now()
reqLog.Info("request started")
// ... handle request ...
reqLog.Fields(
"status", 200,
"duration_ms", time.Since(start).Milliseconds(),
).Info("request completed")
})
log.Info("Server listening on :8080")
http.ListenAndServe(":8080", nil)
}
```
### Microservice with VictoriaLogs
```go
package main
import (
"github.com/olekukonko/ll"
"github.com/olekukonko/ll/l3rd/victoria"
)
func main() {
// Production setup
vlHandler, _ := victoria.New(
victoria.WithURL("http://logs.internal:9428"),
victoria.WithAppName("payment-api"),
victoria.WithEnvironment("production"),
victoria.WithVersion("1.2.3"),
victoria.WithBatching(500, 2*time.Second),
victoria.WithRetry(3),
)
defer vlHandler.Close()
logger := ll.New("payment").
Handler(vlHandler).
AddContext("region", "us-east-1")
logger.Info("Payment service initialized")
// Conditional error handling
if err := processPayment(); err != nil {
logger.IfErr(err).
Fields("payment_id", paymentID).
Error("Payment processing failed")
}
}
```
## Performance
`ll` is engineered for high-performance environments:
| Operation | Time/op | Allocations |
|-----------|---------|-------------|
| **Disabled log** | **15.9 ns** | **0 allocs** |
| Simple text log | 176 ns | 2 allocs |
| With 2 fields | 383 ns | 4 allocs |
| JSON output | 1006 ns | 13 allocs |
| Namespace lookup (cached) | 550 ns | 6 allocs |
| Deduplication | 214 ns | 2 allocs |
**Key optimizations**:
- Zero allocations when logs are skipped (conditional, disabled)
- Atomic operations for hot paths
- Sync.Pool for buffer reuse
- LRU cache for source file lines (Dbg)
- Sharded mutexes for deduplication
## Why Choose `ll`?
| Feature | `ll` | `slog` | `zap` | `logrus` |
|---------|------|--------|-------|----------|
| **Enabled by default** | ✅ | ❌ | ❌ | ❌ |
| Hierarchical namespaces | ✅ | ❌ | ❌ | ❌ |
| Conditional logging | ✅ | ❌ | ❌ | ❌ |
| Error-based conditions | ✅ | ❌ | ❌ | ❌ |
| Source-aware Dbg() | ✅ | ❌ | ❌ | ❌ |
| Private field inspection | ✅ | ❌ | ❌ | ❌ |
| Hex/ASCII Dump() | ✅ | ❌ | ❌ | ❌ |
| Middleware pipeline | ✅ | ❌ | ✅ (limited) | ❌ |
| Deduplication | ✅ | ❌ | ❌ | ❌ |
| Rate limiting | ✅ | ❌ | ❌ | ❌ |
| VictoriaLogs support | ✅ | ❌ | ❌ | ❌ |
| Syslog support | ✅ | ❌ | ❌ | ✅ |
| Zero-allocs disabled logs | ✅ | ❌ | ❌ | ❌ |
| Thread-safe | ✅ | ✅ | ✅ | ✅ |
## Documentation
- [GoDoc](https://pkg.go.dev/github.com/olekukonko/ll) - Full API documentation
- [Examples](_example/) - Runable example code
- [Benchmarks](tests/ll_bench_test.go) - Performance benchmarks
## Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## License
MIT License - see [LICENSE](LICENSE) for details.
+7
View File
@@ -0,0 +1,7 @@
recursive = true
output_file = "all.txt"
extensions = [".go"]
exclude_dirs = ["_examples", "_lab", "_tmp", "pkg", "lab","bin","dist","assets","oppor"]
exclude_files = [""]
use_gitignore = true
detailed = true
+427
View File
@@ -0,0 +1,427 @@
package ll
// Conditional enables conditional logging based on a boolean condition.
// It wraps a logger with a condition that determines whether logging operations are executed,
// optimizing performance by skipping expensive operations (e.g., field computation, message formatting)
// when the condition is false. The struct supports fluent chaining for adding fields and logging.
type Conditional struct {
logger *Logger // Associated logger instance for logging operations
condition bool // Whether logging is allowed (true to log, false to skip)
}
// If creates a conditional logger that logs only if the condition is true.
// It returns a Conditional struct that wraps the logger, enabling conditional logging methods.
// This method is typically called on a Logger instance to start a conditional chain.
// Thread-safe via the underlying logger's mutex.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Info("Logged") // Output: [app] INFO: Logged
// logger.If(false).Info("Ignored") // No output
func (l *Logger) If(condition bool) *Conditional {
return &Conditional{logger: l, condition: condition}
}
// IfAny creates a conditional logger that logs only if at least one condition is true.
// It evaluates a variadic list of boolean conditions, setting the condition to true if any
// is true (logical OR). Returns a new Conditional with the result. Thread-safe via the
// underlying logger.
// Example:
//
// logger := New("app").Enable()
// logger.IfAny(false, true).Info("Logged") // Output: [app] INFO: Logged
// logger.IfAny(false, false).Info("Ignored") // No output
func (cl *Conditional) IfAny(conditions ...bool) *Conditional {
result := false
// Check each condition; set result to true if any is true
for _, cond := range conditions {
if cond {
result = true
break
}
}
return &Conditional{logger: cl.logger, condition: result}
}
// IfErr creates a conditional logger that logs only if the error is non-nil.
// It's designed for the common pattern of checking errors before logging.
// Example:
//
// err := doSomething()
// logger.IfErr(err).Error("Operation failed") // Only logs if err != nil
func (l *Logger) IfErr(err error) *Conditional {
return l.If(err != nil)
}
// IfErrAny creates a conditional logger that logs only if AT LEAST ONE error is non-nil.
// It evaluates a variadic list of errors, setting the condition to true if any
// is non-nil (logical OR). Useful when any error should trigger logging.
// Example:
//
// err1 := validate(input)
// err2 := authorize(user)
// logger.IfErrAny(err1, err2).Error("Either check failed") // Logs if EITHER error exists
func (l *Logger) IfErrAny(errs ...error) *Conditional {
for _, err := range errs {
if err != nil {
return l.If(true) // Any non-nil error makes it true
}
}
return l.If(false) // False only if all errors are nil
}
// IfErrOne creates a conditional logger that logs only if ALL errors are non-nil.
// It evaluates a variadic list of errors, setting the condition to true only if
// all are non-nil (logical AND). Useful when you need all errors to be present.
// Example:
//
// err1 := validate(input)
// err2 := authorize(user)
// logger.IfErrOne(err1, err2).Error("Both checks failed") // Logs only if BOTH errors exist
func (l *Logger) IfErrOne(errs ...error) *Conditional {
for _, err := range errs {
if err == nil {
return l.If(false) // Any nil error makes it false
}
}
return l.If(len(errs) > 0) // True only if we have at least one error and all are non-nil
}
// IfErr creates a conditional logger that logs only if the error is non-nil.
// Returns a new Conditional with the error check result.
// Example:
//
// err := doSomething()
// logger.If(true).IfErr(err).Error("Failed") // Only logs if condition true AND err != nil
func (cl *Conditional) IfErr(err error) *Conditional {
return cl.IfOne(err != nil)
}
// IfErrAny creates a conditional logger that logs only if AT LEAST ONE error is non-nil.
// Returns a new Conditional with the logical OR result of error checks.
// Example:
//
// err1 := validate(input)
// err2 := authorize(user)
// logger.If(true).IfErrAny(err1, err2).Error("Either failed") // Logs if condition true AND either error exists
func (cl *Conditional) IfErrAny(errs ...error) *Conditional {
for _, err := range errs {
if err != nil {
return &Conditional{logger: cl.logger, condition: cl.condition && true}
}
}
return &Conditional{logger: cl.logger, condition: false}
}
// IfErrOne creates a conditional logger that logs only if ALL errors are non-nil.
// Returns a new Conditional with the logical AND result of error checks.
// Example:
//
// err1 := validate(input)
// err2 := authorize(user)
// logger.If(true).IfErrOne(err1, err2).Error("Both failed") // Logs if condition true AND both errors exist
func (cl *Conditional) IfErrOne(errs ...error) *Conditional {
for _, err := range errs {
if err == nil {
return &Conditional{logger: cl.logger, condition: false}
}
}
return &Conditional{logger: cl.logger, condition: cl.condition && len(errs) > 0}
}
// IfOne creates a conditional logger that logs only if all conditions are true.
// It evaluates a variadic list of boolean conditions, setting the condition to true only if
// all are true (logical AND). Returns a new Conditional with the result. Thread-safe via the
// underlying logger.
// Example:
//
// logger := New("app").Enable()
// logger.IfOne(true, true).Info("Logged") // Output: [app] INFO: Logged
// logger.IfOne(true, false).Info("Ignored") // No output
func (cl *Conditional) IfOne(conditions ...bool) *Conditional {
result := true
// Check each condition; set result to false if any is false
for _, cond := range conditions {
if !cond {
result = false
break
}
}
return &Conditional{logger: cl.logger, condition: result}
}
// Debug logs a message at Debug level with variadic arguments if the condition is true.
// It concatenates the arguments with spaces and delegates to the logger's Debug method if the
// condition is true. Skips processing if false, optimizing performance. Thread-safe via the
// logger's log method.
// Example:
//
// logger := New("app").Enable().Level(lx.LevelDebug)
// logger.If(true).Debug("Debugging", "mode") // Output: [app] DEBUG: Debugging mode
// logger.If(false).Debug("Debugging", "ignored") // No output
func (cl *Conditional) Debug(args ...any) {
// Skip logging if condition is false
if !cl.condition {
return
}
// Delegate to logger's Debug method
cl.logger.Debug(args...)
}
// Debugf logs a message at Debug level with a format string if the condition is true.
// It formats the message and delegates to the logger's Debugf method if the condition is true.
// Skips processing if false. Thread-safe via the logger's log method.
// Example:
//
// logger := New("app").Enable().Level(lx.LevelDebug)
// logger.If(true).Debugf("Debug %s", "mode") // Output: [app] DEBUG: Debug mode
// logger.If(false).Debugf("Debug %s", "ignored") // No output
func (cl *Conditional) Debugf(format string, args ...any) {
// Skip logging if condition is false
if !cl.condition {
return
}
// Delegate to logger's Debugf method
cl.logger.Debugf(format, args...)
}
// Error logs a message at Error level with variadic arguments if the condition is true.
// It concatenates the arguments with spaces and delegates to the logger's Error method if the
// condition is true. Skips processing if false. Thread-safe via the logger's log method.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Error("Error", "occurred") // Output: [app] ERROR: Error occurred
// logger.If(false).Error("Error", "ignored") // No output
func (cl *Conditional) Error(args ...any) {
// Skip logging if condition is false
if !cl.condition {
return
}
// Delegate to logger's Error method
cl.logger.Error(args...)
}
// Errorf logs a message at Error level with a format string if the condition is true.
// It formats the message and delegates to the logger's Errorf method if the condition is true.
// Skips processing if false. Thread-safe via the logger's log method.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Errorf("Error %s", "occurred") // Output: [app] ERROR: Error occurred
// logger.If(false).Errorf("Error %s", "ignored") // No output
func (cl *Conditional) Errorf(format string, args ...any) {
// Skip logging if condition is false
if !cl.condition {
return
}
// Delegate to logger's Errorf method
cl.logger.Errorf(format, args...)
}
// Fatal logs a message at Error level with a stack trace and variadic arguments if the condition is true,
// then exits. It concatenates the arguments with spaces and delegates to the logger's Fatal method
// if the condition is true, terminating the program with exit code 1. Skips processing if false.
// Thread-safe via the logger's log method.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Fatal("Fatal", "error") // Output: [app] ERROR: Fatal error [stack=...], then exits
// logger.If(false).Fatal("Fatal", "ignored") // No output, no exit
func (cl *Conditional) Fatal(args ...any) {
// Skip logging if condition is false
if !cl.condition {
return
}
// Delegate to logger's Fatal method
cl.logger.Fatal(args...)
}
// Fatalf logs a formatted message at Error level with a stack trace if the condition is true, then exits.
// It formats the message and delegates to the logger's Fatalf method if the condition is true,
// terminating the program with exit code 1. Skips processing if false. Thread-safe via the logger's log method.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Fatalf("Fatal %s", "error") // Output: [app] ERROR: Fatal error [stack=...], then exits
// logger.If(false).Fatalf("Fatal %s", "ignored") // No output, no exit
func (cl *Conditional) Fatalf(format string, args ...any) {
// Skip logging if condition is false
if !cl.condition {
return
}
// Delegate to logger's Fatalf method
cl.logger.Fatalf(format, args...)
}
// Field starts a fluent chain for adding fields from a map, if the condition is true.
// It returns a FieldBuilder to attach fields from a map, skipping processing if the condition
// is false. Thread-safe via the FieldBuilder's logger.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Field(map[string]interface{}{"user": "alice"}).Info("Logged") // Output: [app] INFO: Logged [user=alice]
// logger.If(false).Field(map[string]interface{}{"user": "alice"}).Info("Ignored") // No output
func (cl *Conditional) Field(fields map[string]interface{}) *FieldBuilder {
// Skip field processing if condition is false
if !cl.condition {
return &FieldBuilder{logger: cl.logger, fields: nil}
}
// Delegate to logger's Field method
return cl.logger.Field(fields)
}
// Fields starts a fluent chain for adding fields using variadic key-value pairs, if the condition is true.
// It returns a FieldBuilder to attach fields, skipping field processing if the condition is false
// to optimize performance. Thread-safe via the FieldBuilder's logger.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Fields("user", "alice").Info("Logged") // Output: [app] INFO: Logged [user=alice]
// logger.If(false).Fields("user", "alice").Info("Ignored") // No output, no field processing
func (cl *Conditional) Fields(pairs ...any) *FieldBuilder {
// Skip field processing if condition is false
if !cl.condition {
return &FieldBuilder{logger: cl.logger, fields: nil}
}
// Delegate to logger's Fields method
return cl.logger.Fields(pairs...)
}
// Info logs a message at Info level with variadic arguments if the condition is true.
// It concatenates the arguments with spaces and delegates to the logger's Info method if the
// condition is true. Skips processing if false, optimizing performance. Thread-safe via the
// logger's log method.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Info("Action", "started") // Output: [app] INFO: Action started
// logger.If(false).Info("Action", "ignored") // No output
func (cl *Conditional) Info(args ...any) {
// Skip logging if condition is false
if !cl.condition {
return
}
// Delegate to logger's Info method
cl.logger.Info(args...)
}
// Infof logs a message at Info level with a format string if the condition is true.
// It formats the message using the provided format string and arguments, delegating to the
// logger's Infof method if the condition is true. Skips processing if false, optimizing performance.
// Thread-safe via the logger's log method.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Infof("Action %s", "started") // Output: [app] INFO: Action started
// logger.If(false).Infof("Action %s", "ignored") // No output
func (cl *Conditional) Infof(format string, args ...any) {
// Skip logging if condition is false
if !cl.condition {
return
}
// Delegate to logger's Infof method
cl.logger.Infof(format, args...)
}
// Panic logs a message at Error level with a stack trace and variadic arguments if the condition is true,
// then panics. It concatenates the arguments with spaces and delegates to the logger's Panic method
// if the condition is true, triggering a panic. Skips processing if false. Thread-safe via the logger's log method.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Panic("Panic", "error") // Output: [app] ERROR: Panic error [stack=...], then panics
// logger.If(false).Panic("Panic", "ignored") // No output, no panic
func (cl *Conditional) Panic(args ...any) {
// Skip logging if condition is false
if !cl.condition {
return
}
// Delegate to logger's Panic method
cl.logger.Panic(args...)
}
// Panicf logs a formatted message at Error level with a stack trace if the condition is true, then panics.
// It formats the message and delegates to the logger's Panicf method if the condition is true,
// triggering a panic. Skips processing if false. Thread-safe via the logger's log method.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Panicf("Panic %s", "error") // Output: [app] ERROR: Panic error [stack=...], then panics
// logger.If(false).Panicf("Panic %s", "ignored") // No output, no panic
func (cl *Conditional) Panicf(format string, args ...any) {
// Skip logging if condition is false
if !cl.condition {
return
}
// Delegate to logger's Panicf method
cl.logger.Panicf(format, args...)
}
// Stack logs a message at Error level with a stack trace and variadic arguments if the condition is true.
// It concatenates the arguments with spaces and delegates to the logger's Stack method if the
// condition is true. Skips processing if false. Thread-safe via the logger's log method.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Stack("Critical", "error") // Output: [app] ERROR: Critical error [stack=...]
// logger.If(false).Stack("Critical", "ignored") // No output
func (cl *Conditional) Stack(args ...any) {
// Skip logging if condition is false
if !cl.condition {
return
}
// Delegate to logger's Stack method
cl.logger.Stack(args...)
}
// Stackf logs a message at Error level with a stack trace and a format string if the condition is true.
// It formats the message and delegates to the logger's Stackf method if the condition is true.
// Skips processing if false. Thread-safe via the logger's log method.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Stackf("Critical %s", "error") // Output: [app] ERROR: Critical error [stack=...]
// logger.If(false).Stackf("Critical %s", "ignored") // No output
func (cl *Conditional) Stackf(format string, args ...any) {
// Skip logging if condition is false
if !cl.condition {
return
}
// Delegate to logger's Stackf method
cl.logger.Stackf(format, args...)
}
// Warn logs a message at Warn level with variadic arguments if the condition is true.
// It concatenates the arguments with spaces and delegates to the logger's Warn method if the
// condition is true. Skips processing if false. Thread-safe via the logger's log method.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Warn("Warning", "issued") // Output: [app] WARN: Warning issued
// logger.If(false).Warn("Warning", "ignored") // No output
func (cl *Conditional) Warn(args ...any) {
// Skip logging if condition is false
if !cl.condition {
return
}
// Delegate to logger's Warn method
cl.logger.Warn(args...)
}
// Warnf logs a message at Warn level with a format string if the condition is true.
// It formats the message and delegates to the logger's Warnf method if the condition is true.
// Skips processing if false. Thread-safe via the logger's log method.
// Example:
//
// logger := New("app").Enable()
// logger.If(true).Warnf("Warning %s", "issued") // Output: [app] WARN: Warning issued
// logger.If(false).Warnf("Warning %s", "ignored") // No output
func (cl *Conditional) Warnf(format string, args ...any) {
// Skip logging if condition is false
if !cl.condition {
return
}
// Delegate to logger's Warnf method
cl.logger.Warnf(format, args...)
}
+282
View File
@@ -0,0 +1,282 @@
package ll
import (
"container/list"
"fmt"
"os"
"runtime"
"strings"
"sync"
"github.com/olekukonko/ll/lx"
)
// -----------------------------------------------------------------------------
// Global Cache Instance
// -----------------------------------------------------------------------------
// sourceCache caches up to 128 source files using LRU eviction.
var sourceCache = newFileLRU(128)
// -----------------------------------------------------------------------------
// File-Level LRU Cache
// -----------------------------------------------------------------------------
type fileLRU struct {
capacity int
mu sync.Mutex
list *list.List
items map[string]*list.Element
}
type fileItem struct {
key string
lines []string
}
func newFileLRU(capacity int) *fileLRU {
if capacity <= 0 {
capacity = 1
}
return &fileLRU{
capacity: capacity,
list: list.New(),
items: make(map[string]*list.Element, capacity),
}
}
// getLine retrieves a specific 1-indexed line from a file.
func (c *fileLRU) getLine(file string, line int) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
// 1. Cache Hit
if elem, ok := c.items[file]; ok {
c.list.MoveToFront(elem)
item := elem.Value.(*fileItem)
if item.lines == nil {
return "", false
}
return nthLine(item.lines, line)
}
// 2. Cache Miss - Read File
// Release lock during I/O to avoid blocking other loggers
c.mu.Unlock()
data, err := os.ReadFile(file)
c.mu.Lock()
// 3. Double-check (another goroutine might have loaded it while unlocked)
if elem, ok := c.items[file]; ok {
c.list.MoveToFront(elem)
item := elem.Value.(*fileItem)
if item.lines == nil {
return "", false
}
return nthLine(item.lines, line)
}
var lines []string
if err == nil {
lines = strings.Split(string(data), "\n")
}
// 4. Store (Positive or Negative Cache)
item := &fileItem{
key: file,
lines: lines,
}
elem := c.list.PushFront(item)
c.items[file] = elem
// 5. Evict if needed
if c.list.Len() > c.capacity {
old := c.list.Back()
if old != nil {
c.list.Remove(old)
delete(c.items, old.Value.(*fileItem).key)
}
}
if lines == nil {
return "", false
}
return nthLine(lines, line)
}
// nthLine returns the 1-indexed line from slice.
func nthLine(lines []string, n int) (string, bool) {
if n <= 0 || n > len(lines) {
return "", false
}
return strings.TrimSuffix(lines[n-1], "\r"), true
}
// -----------------------------------------------------------------------------
// Logger Debug Implementation
// -----------------------------------------------------------------------------
// Dbg logs debug information including source file, line number,
// and the best-effort extracted expression.
//
// Example:
//
// x := 42
// logger.Dbg("val", x)
// Output: [file.go:123] "val" = "val", x = 42
func (l *Logger) Dbg(values ...interface{}) {
if !l.shouldLog(lx.LevelInfo) {
return
}
l.dbg(2, values...)
}
func (l *Logger) dbg(skip int, values ...interface{}) {
file, line, ok := callerFrame(skip)
if !ok {
// Fallback if we can't get frame
var sb strings.Builder
sb.WriteString("[?:?] ")
for i, v := range values {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("%+v", v))
}
l.log(lx.LevelInfo, lx.ClassText, sb.String(), nil, false)
return
}
shortFile := file
if idx := strings.LastIndex(file, "/"); idx >= 0 {
shortFile = file[idx+1:]
}
srcLine, hit := sourceCache.getLine(file, line)
var expr string
if hit && srcLine != "" {
// Attempt to extract the text inside Dbg(...)
if a := strings.Index(srcLine, "Dbg("); a >= 0 {
rest := srcLine[a+len("Dbg("):]
if b := strings.LastIndex(rest, ")"); b >= 0 {
expr = strings.TrimSpace(rest[:b])
}
} else {
// Fallback: extract first (...) group if Dbg isn't explicit prefix
a := strings.Index(srcLine, "(")
b := strings.LastIndex(srcLine, ")")
if a >= 0 && b > a {
expr = strings.TrimSpace(srcLine[a+1 : b])
}
}
}
// Format output
var outBuilder strings.Builder
outBuilder.WriteString(fmt.Sprintf("[%s:%d] ", shortFile, line))
// Attempt to split expressions to map 1:1 with values
var parts []string
if expr != "" {
parts = splitExpressions(expr)
}
// If the number of extracted expressions matches the number of values,
// print them as "expr = value". Otherwise, fall back to "expr = val1, val2".
if len(parts) == len(values) {
for i, v := range values {
if i > 0 {
outBuilder.WriteString(", ")
}
outBuilder.WriteString(fmt.Sprintf("%s = %+v", parts[i], v))
}
} else {
if expr != "" {
outBuilder.WriteString(expr)
outBuilder.WriteString(" = ")
}
for i, v := range values {
if i > 0 {
outBuilder.WriteString(", ")
}
outBuilder.WriteString(fmt.Sprintf("%+v", v))
}
}
l.log(lx.LevelInfo, lx.ClassDbg, outBuilder.String(), nil, false)
}
// splitExpressions splits a comma-separated string of expressions,
// respecting nested parentheses, brackets, braces, and quotes.
// Example: "a, fn(b, c), d" -> ["a", "fn(b, c)", "d"]
func splitExpressions(s string) []string {
var parts []string
var current strings.Builder
depth := 0 // Tracks nested (), [], {}
inQuote := false // Tracks string literals
var quoteChar rune
for _, r := range s {
switch {
case inQuote:
current.WriteRune(r)
if r == quoteChar {
// We rely on the fact that valid Go source won't have unescaped quotes easily
// accessible here without complex parsing, but for simple Dbg calls this suffices.
// A robust parser handles `\"`, but simple state toggling covers 99% of debug cases.
inQuote = false
}
case r == '"' || r == '\'':
inQuote = true
quoteChar = r
current.WriteRune(r)
case r == '(' || r == '{' || r == '[':
depth++
current.WriteRune(r)
case r == ')' || r == '}' || r == ']':
depth--
current.WriteRune(r)
case r == ',' && depth == 0:
// Split point
parts = append(parts, strings.TrimSpace(current.String()))
current.Reset()
default:
current.WriteRune(r)
}
}
if current.Len() > 0 {
parts = append(parts, strings.TrimSpace(current.String()))
}
return parts
}
// -----------------------------------------------------------------------------
// Caller Resolution
// -----------------------------------------------------------------------------
// callerFrame walks stack frames until it finds the first frame
// outside the ll package.
func callerFrame(skip int) (file string, line int, ok bool) {
// +2 to skip callerFrame + dbg itself.
pcs := make([]uintptr, 32)
n := runtime.Callers(skip+2, pcs)
if n == 0 {
return "", 0, false
}
frames := runtime.CallersFrames(pcs[:n])
for {
fr, more := frames.Next()
// fr.Function looks like: "github.com/you/mod/ll.(*Logger).Dbg"
// We want the first frame that is NOT inside package ll.
if fr.Function == "" || !strings.Contains(fr.Function, "/ll.") && !strings.Contains(fr.Function, ".ll.") {
return fr.File, fr.Line, true
}
if !more {
// Fallback: return the last frame we saw
return fr.File, fr.Line, fr.File != ""
}
}
}
+388
View File
@@ -0,0 +1,388 @@
// field.go
package ll
import (
"fmt"
"os"
"strings"
"github.com/olekukonko/cat"
"github.com/olekukonko/ll/lx"
)
// FieldBuilder enables fluent addition of fields before logging.
// It acts as a builder pattern to attach key-value pairs (fields) to log entries,
// supporting structured logging with metadata. The builder allows chaining to add fields
// and log messages at various levels (Info, Debug, Warn, Error, etc.) in a single expression.
type FieldBuilder struct {
logger *Logger // Associated logger instance for logging operations
fields lx.Fields // Fields to include in the log entry as ordered key-value pairs
}
// Logger creates a new logger with the builder's fields embedded in its context.
// It clones the parent logger and copies the builder's fields into the new logger's context,
// enabling persistent field inclusion in subsequent logs. This method supports fluent chaining
// after Fields or Field calls.
// Example:
//
// logger := New("app").Enable()
// newLogger := logger.Fields("user", "alice").Logger()
// newLogger.Info("Action") // Output: [app] INFO: Action [user=alice]
func (fb *FieldBuilder) Logger() *Logger {
// Clone the parent logger to preserve its configuration
newLogger := fb.logger.Clone()
// Copy builder's fields into the new logger's context
newLogger.context = make(lx.Fields, len(fb.fields))
copy(newLogger.context, fb.fields)
return newLogger
}
// Info logs a message at Info level with the builder's fields.
// It concatenates the arguments with spaces and delegates to the logger's log method,
// returning early if fields are nil. This method is used for informational messages.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("user", "alice").Info("Action", "started") // Output: [app] INFO: Action started [user=alice]
func (fb *FieldBuilder) Info(args ...any) {
// Skip logging if fields are nil
if fb.fields == nil {
return
}
// Log at Info level with the builder's fields, no stack trace
fb.logger.log(lx.LevelInfo, lx.ClassText, cat.Space(args...), fb.fields, false)
}
// Infof logs a message at Info level with the builder's fields.
// It formats the message using the provided format string and arguments, then delegates
// to the logger's internal log method. If fields are nil, it returns early to avoid logging.
// This method is part of the fluent API, typically called after adding fields.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("user", "alice").Infof("Action %s", "started") // Output: [app] INFO: Action started [user=alice]
func (fb *FieldBuilder) Infof(format string, args ...any) {
// Skip logging if fields are nil to prevent invalid log entries
if fb.fields == nil {
return
}
// Format the message using the provided arguments
msg := fmt.Sprintf(format, args...)
// Log at Info level with the builder's fields, no stack trace
fb.logger.log(lx.LevelInfo, lx.ClassText, msg, fb.fields, false)
}
// Debug logs a message at Debug level with the builder's fields.
// It concatenates the arguments with spaces and delegates to the logger's log method,
// returning early if fields are nil. This method is used for debugging information.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("user", "alice").Debug("Debugging", "mode") // Output: [app] DEBUG: Debugging mode [user=alice]
func (fb *FieldBuilder) Debug(args ...any) {
// Skip logging if fields are nil
if fb.fields == nil {
return
}
// Log at Debug level with the builder's fields, no stack trace
fb.logger.log(lx.LevelDebug, lx.ClassText, cat.Space(args...), fb.fields, false)
}
// Debugf logs a message at Debug level with the builder's fields.
// It formats the message and delegates to the logger's log method, returning early if
// fields are nil. This method is used for debugging information that may be disabled in
// production environments.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("user", "alice").Debugf("Debug %s", "mode") // Output: [app] DEBUG: Debug mode [user=alice]
func (fb *FieldBuilder) Debugf(format string, args ...any) {
// Skip logging if fields are nil
if fb.fields == nil {
return
}
// Format the message
msg := fmt.Sprintf(format, args...)
// Log at Debug level with the builder's fields, no stack trace
fb.logger.log(lx.LevelDebug, lx.ClassText, msg, fb.fields, false)
}
// Warn logs a message at Warn level with the builder's fields.
// It concatenates the arguments with spaces and delegates to the logger's log method,
// returning early if fields are nil. This method is used for warning conditions.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("user", "alice").Warn("Warning", "issued") // Output: [app] WARN: Warning issued [user=alice]
func (fb *FieldBuilder) Warn(args ...any) {
// Skip logging if fields are nil
if fb.fields == nil {
return
}
// Log at Warn level with the builder's fields, no stack trace
fb.logger.log(lx.LevelWarn, lx.ClassText, cat.Space(args...), fb.fields, false)
}
// Warnf logs a message at Warn level with the builder's fields.
// It formats the message and delegates to the logger's log method, returning early if
// fields are nil. This method is used for warning conditions that do not halt execution.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("user", "alice").Warnf("Warning %s", "issued") // Output: [app] WARN: Warning issued [user=alice]
func (fb *FieldBuilder) Warnf(format string, args ...any) {
// Skip logging if fields are nil
if fb.fields == nil {
return
}
// Format the message
msg := fmt.Sprintf(format, args...)
// Log at Warn level with the builder's fields, no stack trace
fb.logger.log(lx.LevelWarn, lx.ClassText, msg, fb.fields, false)
}
// Error logs a message at Error level with the builder's fields.
// It concatenates the arguments with spaces and delegates to the logger's log method,
// returning early if fields are nil. This method is used for error conditions.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("user", "alice").Error("Error", "occurred") // Output: [app] ERROR: Error occurred [user=alice]
func (fb *FieldBuilder) Error(args ...any) {
// Skip logging if fields are nil
if fb.fields == nil {
return
}
// Log at Error level with the builder's fields, no stack trace
fb.logger.log(lx.LevelError, lx.ClassText, cat.Space(args...), fb.fields, false)
}
// Errorf logs a message at Error level with the builder's fields.
// It formats the message and delegates to the logger's log method, returning early if
// fields are nil. This method is used for error conditions that may require attention.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("user", "alice").Errorf("Error %s", "occurred") // Output: [app] ERROR: Error occurred [user=alice]
func (fb *FieldBuilder) Errorf(format string, args ...any) {
// Skip logging if fields are nil
if fb.fields == nil {
return
}
// Format the message
msg := fmt.Sprintf(format, args...)
// Log at Error level with the builder's fields, no stack trace
fb.logger.log(lx.LevelError, lx.ClassText, msg, fb.fields, false)
}
// Stack logs a message at Error level with a stack trace and the builder's fields.
// It concatenates the arguments with spaces and delegates to the logger's log method,
// returning early if fields are nil. This method is useful for debugging critical errors.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("user", "alice").Stack("Critical", "error") // Output: [app] ERROR: Critical error [user=alice stack=...]
func (fb *FieldBuilder) Stack(args ...any) {
// Skip logging if fields are nil
if fb.fields == nil {
return
}
// Log at Error level with the builder's fields and a stack trace
fb.logger.log(lx.LevelError, lx.ClassText, cat.Space(args...), fb.fields, true)
}
// Stackf logs a message at Error level with a stack trace and the builder's fields.
// It formats the message and delegates to the logger's log method, returning early if
// fields are nil. This method is useful for debugging critical errors.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("user", "alice").Stackf("Critical %s", "error") // Output: [app] ERROR: Critical error [user=alice stack=...]
func (fb *FieldBuilder) Stackf(format string, args ...any) {
// Skip logging if fields are nil
if fb.fields == nil {
return
}
// Format the message
msg := fmt.Sprintf(format, args...)
// Log at Error level with the builder's fields and a stack trace
fb.logger.log(lx.LevelError, lx.ClassText, msg, fb.fields, true)
}
// Fatal logs a message at Error level with a stack trace and the builder's fields, then exits.
// It constructs the message from variadic arguments, logs it with a stack trace, and terminates
// the program with exit code 1. Returns early if fields are nil. This method is used for
// unrecoverable errors.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("user", "alice").Fatal("Fatal", "error") // Output: [app] ERROR: Fatal error [user=alice stack=...], then exits
func (fb *FieldBuilder) Fatal(args ...any) {
// Skip logging if fields are nil
if fb.fields == nil {
return
}
// Build the message by concatenating arguments with spaces
var builder strings.Builder
for i, arg := range args {
if i > 0 {
builder.WriteString(lx.Space)
}
builder.WriteString(fmt.Sprint(arg))
}
// Log at Error level with the builder's fields and a stack trace
fb.logger.log(lx.LevelFatal, lx.ClassText, builder.String(), fb.fields, fb.logger.fatalStack)
// Exit the program with status code 1
if fb.logger.fatalExits {
os.Exit(1)
}
}
// Fatalf logs a formatted message at Error level with a stack trace and the builder's fields,
// then exits. It delegates to Fatal and returns early if fields are nil. This method is used
// for unrecoverable errors.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("user", "alice").Fatalf("Fatal %s", "error") // Output: [app] ERROR: Fatal error [user=alice stack=...], then exits
func (fb *FieldBuilder) Fatalf(format string, args ...any) {
// Skip logging if fields are nil
if fb.fields == nil {
return
}
// Format the message and pass to Fatal
fb.Fatal(fmt.Sprintf(format, args...))
}
// Panic logs a message at Error level with a stack trace and the builder's fields, then panics.
// It constructs the message from variadic arguments, logs it with a stack trace, and triggers
// a panic with the message. Returns early if fields are nil. This method is used for critical
// errors that require immediate program termination with a panic.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("user", "alice").Panic("Panic", "error") // Output: [app] ERROR: Panic error [user=alice stack=...], then panics
func (fb *FieldBuilder) Panic(args ...any) {
// Skip logging if fields are nil
if fb.fields == nil {
return
}
// Build the message by concatenating arguments with spaces
var builder strings.Builder
for i, arg := range args {
if i > 0 {
builder.WriteString(lx.Space)
}
builder.WriteString(fmt.Sprint(arg))
}
msg := builder.String()
// Log at Error level with the builder's fields and a stack trace
fb.logger.log(lx.LevelError, lx.ClassText, msg, fb.fields, true)
// Trigger a panic with the formatted message
panic(msg)
}
// Panicf logs a formatted message at Error level with a stack trace and the builder's fields,
// then panics. It delegates to Panic and returns early if fields are nil. This method is used
// for critical errors that require immediate program termination with a panic.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("user", "alice").Panicf("Panic %s", "error") // Output: [app] ERROR: Panic error [user=alice stack=...], then panics
func (fb *FieldBuilder) Panicf(format string, args ...any) {
// Skip logging if fields are nil
if fb.fields == nil {
return
}
// Format the message and pass to Panic
fb.Panic(fmt.Sprintf(format, args...))
}
// Err adds one or more errors to the FieldBuilder as a field and logs them.
// It stores non-nil errors in the "error" field: a single error if only one is non-nil,
// or a slice of errors if multiple are non-nil. It logs the concatenated string representations
// of non-nil errors (e.g., "failed 1; failed 2") at the Error level. Returns the FieldBuilder
// for chaining, allowing further field additions or logging. Thread-safe via the logger's mutex.
// Example:
//
// logger := New("app").Enable()
// err1 := errors.New("failed 1")
// err2 := errors.New("failed 2")
// logger.Fields("k", "v").Err(err1, err2).Info("Error occurred")
// // Output: [app] ERROR: failed 1; failed 2
// // [app] INFO: Error occurred [error=[failed 1 failed 2] k=v]
func (fb *FieldBuilder) Err(errs ...error) *FieldBuilder {
// Initialize fields slice if nil
if fb.fields == nil {
fb.fields = make(lx.Fields, 0, 4)
}
// Collect non-nil errors and build log message
var nonNilErrors []error
var builder strings.Builder
count := 0
for i, err := range errs {
if err != nil {
if i > 0 && count > 0 {
builder.WriteString("; ")
}
builder.WriteString(err.Error())
nonNilErrors = append(nonNilErrors, err)
count++
}
}
// Set error field and log if there are non-nil errors
if count > 0 {
if count == 1 {
// Store single error directly
fb.fields = append(fb.fields, lx.Field{Key: "error", Value: nonNilErrors[0]})
} else {
// Store slice of errors
fb.fields = append(fb.fields, lx.Field{Key: "error", Value: nonNilErrors})
}
// Log concatenated error messages at Error level
fb.logger.log(lx.LevelError, lx.ClassText, builder.String(), nil, false)
}
// Return FieldBuilder for chaining
return fb
}
// Merge adds additional key-value pairs to the FieldBuilder.
// It processes variadic arguments as key-value pairs, expecting string keys. Non-string keys
// or uneven pairs generate an "error" field with a descriptive message. Returns the FieldBuilder
// for chaining to allow further field additions or logging.
// Example:
//
// logger := New("app").Enable()
// logger.Fields("k1", "v1").Merge("k2", "v2").Info("Action") // Output: [app] INFO: Action [k1=v1 k2=v2]
func (fb *FieldBuilder) Merge(pairs ...any) *FieldBuilder {
// Initialize fields slice if nil
if fb.fields == nil {
fb.fields = make(lx.Fields, 0, len(pairs)/2)
}
// Process pairs as key-value, advancing by 2
for i := 0; i < len(pairs)-1; i += 2 {
// Ensure the key is a string
if key, ok := pairs[i].(string); ok {
fb.fields = append(fb.fields, lx.Field{Key: key, Value: pairs[i+1]})
} else {
// Log an error field for non-string keys
fb.fields = append(fb.fields, lx.Field{
Key: "error",
Value: fmt.Errorf("non-string key in Merge: %v", pairs[i]),
})
}
}
// Check for uneven pairs (missing value)
if len(pairs)%2 != 0 {
fb.fields = append(fb.fields, lx.Field{
Key: "error",
Value: fmt.Errorf("uneven key-value pairs in Merge: [%v]", pairs[len(pairs)-1]),
})
}
return fb
}
+707
View File
@@ -0,0 +1,707 @@
package ll
import (
"sync/atomic"
"time"
"github.com/olekukonko/ll/lx"
)
// defaultLogger is the global logger instance for package-level logging functions.
// It provides a shared logger for convenience, allowing logging without explicitly creating
// a logger instance. The logger is initialized with default settings: enabled, Debug level,
// flat namespace style, and a text handler to os.Stdout. It is thread-safe due to the Logger
// structs mutex.
var defaultLogger = New("")
// Handler sets the handler for the default logger.
// It configures the output destination and format (e.g., text, JSON) for logs emitted by
// defaultLogger. Returns the default logger for method chaining, enabling fluent configuration.
// Example:
//
// ll.Handler(lh.NewJSONHandler(os.Stdout)).Enable()
// ll.Info("Started") // Output: {"level":"INFO","message":"Started"}
func Handler(handler lx.Handler) *Logger {
return defaultLogger.Handler(handler)
}
// Level sets the minimum log level for the default logger.
// It determines which log messages (Debug, Info, Warn, Error) are emitted. Messages below
// the specified level are ignored. Returns the default logger for method chaining.
// Example:
//
// ll.Level(lx.LevelWarn)
// ll.Info("Ignored") // No output
// ll.Warn("Logged") // Output: [] WARN: Logged
func Level(level lx.LevelType) *Logger {
return defaultLogger.Level(level)
}
// Style sets the namespace style for the default logger.
// It controls how namespace paths are formatted in logs (FlatPath: [parent/child],
// NestedPath: [parent]→[child]). Returns the default logger for method chaining.
// Example:
//
// ll.Style(lx.NestedPath)
// ll.Info("Test") // Output: []: INFO: Test
func Style(style lx.StyleType) *Logger {
return defaultLogger.Style(style)
}
// NamespaceEnable enables logging for a namespace and its children using the default logger.
// It activates logging for the specified namespace path (e.g., "app/db") and all its
// descendants. Returns the default logger for method chaining. Thread-safe via the Loggers mutex.
// Example:
//
// ll.NamespaceEnable("app/db")
// ll.Clone().Namespace("db").Info("Query") // Output: [app/db] INFO: Query
func NamespaceEnable(path string) *Logger {
return defaultLogger.NamespaceEnable(path)
}
// NamespaceDisable disables logging for a namespace and its children using the default logger.
// It suppresses logging for the specified namespace path and all its descendants. Returns
// the default logger for method chaining. Thread-safe via the Loggers mutex.
// Example:
//
// ll.NamespaceDisable("app/db")
// ll.Clone().Namespace("db").Info("Query") // No output
func NamespaceDisable(path string) *Logger {
return defaultLogger.NamespaceDisable(path)
}
// Namespace creates a child logger with a sub-namespace appended to the current path.
// The child inherits the default loggers configuration but has an independent context.
// Thread-safe with read lock. Returns the new logger for further configuration or logging.
// Example:
//
// logger := ll.Namespace("app")
// logger.Info("Started") // Output: [app] INFO: Started
func Namespace(name string) *Logger {
return defaultLogger.Namespace(name)
}
// Info logs a message at Info level with variadic arguments using the default logger.
// It concatenates the arguments with spaces and delegates to defaultLoggers Info method.
// Thread-safe via the Loggers log method.
// Example:
//
// ll.Info("Service", "started") // Output: [] INFO: Service started
func Info(args ...any) {
defaultLogger.Info(args...)
}
// Infof logs a message at Info level with a format string using the default logger.
// It formats the message using the provided format string and arguments, then delegates to
// defaultLoggers Infof method. Thread-safe via the Loggers log method.
// Example:
//
// ll.Infof("Service %s", "started") // Output: [] INFO: Service started
func Infof(format string, args ...any) {
defaultLogger.Infof(format, args...)
}
// Debug logs a message at Debug level with variadic arguments using the default logger.
// It concatenates the arguments with spaces and delegates to defaultLoggers Debug method.
// Used for debugging information, typically disabled in production. Thread-safe.
// Example:
//
// ll.Level(lx.LevelDebug)
// ll.Debug("Debugging", "mode") // Output: [] DEBUG: Debugging mode
func Debug(args ...any) {
defaultLogger.Debug(args...)
}
// Debugf logs a message at Debug level with a format string using the default logger.
// It formats the message and delegates to defaultLoggers Debugf method. Used for debugging
// information, typically disabled in production. Thread-safe.
// Example:
//
// ll.Level(lx.LevelDebug)
// ll.Debugf("Debug %s", "mode") // Output: [] DEBUG: Debug mode
func Debugf(format string, args ...any) {
defaultLogger.Debugf(format, args...)
}
// Warn logs a message at Warn level with variadic arguments using the default logger.
// It concatenates the arguments with spaces and delegates to defaultLoggers Warn method.
// Used for warning conditions that do not halt execution. Thread-safe.
// Example:
//
// ll.Warn("Low", "memory") // Output: [] WARN: Low memory
func Warn(args ...any) {
defaultLogger.Warn(args...)
}
// Warnf logs a message at Warn level with a format string using the default logger.
// It formats the message and delegates to defaultLoggers Warnf method. Used for warning
// conditions that do not halt execution. Thread-safe.
// Example:
//
// ll.Warnf("Low %s", "memory") // Output: [] WARN: Low memory
func Warnf(format string, args ...any) {
defaultLogger.Warnf(format, args...)
}
// Error logs a message at Error level with variadic arguments using the default logger.
// It concatenates the arguments with spaces and delegates to defaultLoggers Error method.
// Used for error conditions requiring attention. Thread-safe.
// Example:
//
// ll.Error("Database", "failure") // Output: [] ERROR: Database failure
func Error(args ...any) {
defaultLogger.Error(args...)
}
// Errorf logs a message at Error level with a format string using the default logger.
// It formats the message and delegates to defaultLoggers Errorf method. Used for error
// conditions requiring attention. Thread-safe.
// Example:
//
// ll.Errorf("Database %s", "failure") // Output: [] ERROR: Database failure
func Errorf(format string, args ...any) {
defaultLogger.Errorf(format, args...)
}
// Stack logs a message at Error level with a stack trace and variadic arguments using the default logger.
// It concatenates the arguments with spaces and delegates to defaultLoggers Stack method.
// Thread-safe.
// Example:
//
// ll.Stack("Critical", "error") // Output: [] ERROR: Critical error [stack=...]
func Stack(args ...any) {
defaultLogger.Stack(args...)
}
// Stackf logs a message at Error level with a stack trace and a format string using the default logger.
// It formats the message and delegates to defaultLoggers Stackf method. Thread-safe.
// Example:
//
// ll.Stackf("Critical %s", "error") // Output: [] ERROR: Critical error [stack=...]
func Stackf(format string, args ...any) {
defaultLogger.Stackf(format, args...)
}
// Fatal logs a message at Error level with a stack trace and variadic arguments using the default logger,
// then exits. It concatenates the arguments with spaces, logs with a stack trace, and terminates
// with exit code 1. Thread-safe.
// Example:
//
// ll.Fatal("Fatal", "error") // Output: [] ERROR: Fatal error [stack=...], then exits
func Fatal(args ...any) {
defaultLogger.Fatal(args...)
}
// Fatalf logs a formatted message at Error level with a stack trace using the default logger,
// then exits. It formats the message, logs with a stack trace, and terminates with exit code 1.
// Thread-safe.
// Example:
//
// ll.Fatalf("Fatal %s", "error") // Output: [] ERROR: Fatal error [stack=...], then exits
func Fatalf(format string, args ...any) {
defaultLogger.Fatalf(format, args...)
}
// Panic logs a message at Error level with a stack trace and variadic arguments using the default logger,
// then panics. It concatenates the arguments with spaces, logs with a stack trace, and triggers a panic.
// Thread-safe.
// Example:
//
// ll.Panic("Panic", "error") // Output: [] ERROR: Panic error [stack=...], then panics
func Panic(args ...any) {
defaultLogger.Panic(args...)
}
// Panicf logs a formatted message at Error level with a stack trace using the default logger,
// then panics. It formats the message, logs with a stack trace, and triggers a panic. Thread-safe.
// Example:
//
// ll.Panicf("Panic %s", "error") // Output: [] ERROR: Panic error [stack=...], then panics
func Panicf(format string, args ...any) {
defaultLogger.Panicf(format, args...)
}
// If creates a conditional logger that logs only if the condition is true using the default logger.
func If(condition bool) *Conditional {
return defaultLogger.If(condition)
}
// IfErr creates a conditional logger that logs only if the error is non-nil using the default logger.
func IfErr(err error) *Conditional {
return defaultLogger.IfErr(err)
}
// IfErrAny creates a conditional logger that logs only if AT LEAST ONE error is non-nil using the default logger.
func IfErrAny(errs ...error) *Conditional {
return defaultLogger.IfErrAny(errs...)
}
// IfErrOne creates a conditional logger that logs only if ALL errors are non-nil using the default logger.
func IfErrOne(errs ...error) *Conditional {
return defaultLogger.IfErrOne(errs...)
}
// Context creates a new logger with additional contextual fields using the default logger.
// It preserves existing context fields and adds new ones, returning a new logger instance
// to avoid mutating the default logger. Thread-safe with write lock.
// Example:
//
// logger := ll.Context(map[string]interface{}{"user": "alice"})
// logger.Info("Action") // Output: [] INFO: Action [user=alice]
func Context(fields map[string]interface{}) *Logger {
return defaultLogger.Context(fields)
}
// AddContext adds a key-value pair to the default loggers context, modifying it directly.
// It mutates the default loggers context and is thread-safe using a write lock.
// Example:
//
// ll.AddContext("user", "alice")
// ll.Info("Action") // Output: [] INFO: Action [user=alice]
func AddContext(pairs ...any) *Logger {
return defaultLogger.AddContext(pairs...)
}
// GetContext returns the default loggers context map of persistent key-value fields.
// It provides thread-safe read access to the context using a read lock.
// Example:
//
// ll.AddContext("user", "alice")
// ctx := ll.GetContext() // Returns map[string]interface{}{"user": "alice"}k
func GetContext() map[string]interface{} {
return defaultLogger.GetContext()
}
// GetLevel returns the minimum log level for the default logger.
// It provides thread-safe read access to the level field using a read lock.
// Example:
//
// ll.Level(lx.LevelWarn)
// if ll.GetLevel() == lx.LevelWarn {
// ll.Warn("Warning level set") // Output: [] WARN: Warning level set
// }
func GetLevel() lx.LevelType {
return defaultLogger.GetLevel()
}
// GetPath returns the default loggers current namespace path.
// It provides thread-safe read access to the currentPath field using a read lock.
// Example:
//
// logger := ll.Namespace("app")
// path := logger.GetPath() // Returns "app"
func GetPath() string {
return defaultLogger.GetPath()
}
// GetSeparator returns the default loggers namespace separator (e.g., "/").
// It provides thread-safe read access to the separator field using a read lock.
// Example:
//
// ll.Separator(".")
// sep := ll.GetSeparator() // Returns "."
func GetSeparator() string {
return defaultLogger.GetSeparator()
}
// GetStyle returns the default loggers namespace formatting style (FlatPath or NestedPath).
// It provides thread-safe read access to the style field using a read lock.
// Example:
//
// ll.Style(lx.NestedPath)
// if ll.GetStyle() == lx.NestedPath {
// ll.Info("Nested style") // Output: []: INFO: Nested style
// }
func GetStyle() lx.StyleType {
return defaultLogger.GetStyle()
}
// GetHandler returns the default loggers current handler for customization or inspection.
// The returned handler should not be modified concurrently with logger operations.
// Example:
//
// handler := ll.GetHandler() // Returns the current handler (e.g., TextHandler)
func GetHandler() lx.Handler {
return defaultLogger.GetHandler()
}
// Separator sets the namespace separator for the default logger (e.g., "/" or ".").
// It updates the separator used in namespace paths. Thread-safe with write lock.
// Returns the default logger for method chaining.
// Example:
//
// ll.Separator(".")
// ll.Namespace("app").Info("Log") // Output: [app] INFO: Log
func Separator(separator string) *Logger {
return defaultLogger.Separator(separator)
}
// Prefix sets a prefix to be prepended to all log messages of the default logger.
// The prefix is applied before the message in the log output. Thread-safe with write lock.
// Returns the default logger for method chaining.
// Example:
//
// ll.Prefix("APP: ")
// ll.Info("Started") // Output: [] INFO: APP: Started
func Prefix(prefix string) *Logger {
return defaultLogger.Prefix(prefix)
}
// StackSize sets the buffer size for stack trace capture in the default logger.
// It configures the maximum size for stack traces in Stack, Fatal, and Panic methods.
// Thread-safe with write lock. Returns the default logger for chaining.
// Example:
//
// ll.StackSize(65536)
// ll.Stack("Error") // Captures up to 64KB stack trace
func StackSize(size int) *Logger {
return defaultLogger.StackSize(size)
}
// Use adds a middleware function to process log entries before they are handled by the default logger.
// It registers the middleware and returns a Middleware handle for removal. Middleware returning
// a non-nil error stops the log. Thread-safe with write lock.
// Example:
//
// mw := ll.Use(ll.FuncMiddleware(func(e *lx.Entry) error {
// if e.Level < lx.LevelWarn {
// return fmt.Errorf("level too low")
// }
// return nil
// }))
// ll.Info("Ignored") // No output
// mw.Remove()
// ll.Info("Logged") // Output: [] INFO: Logged
func Use(fn lx.Handler) *Middleware {
return defaultLogger.Use(fn)
}
// Remove removes middleware by the reference returned from Use for the default logger.
// It delegates to the Middlewares Remove method for thread-safe removal.
// Example:
//
// mw := ll.Use(someMiddleware)
// ll.Remove(mw) // Removes middleware
func Remove(m *Middleware) {
defaultLogger.Remove(m)
}
// Clear removes all middleware functions from the default logger.
// It resets the middleware chain to empty, ensuring no middleware is applied.
// Thread-safe with write lock. Returns the default logger for chaining.
// Example:
//
// ll.Use(someMiddleware)
// ll.Clear()
// ll.Info("No middleware") // Output: [] INFO: No middleware
func Clear() *Logger {
return defaultLogger.Clear()
}
// CanLog checks if a log at the given level would be emitted by the default logger.
// It considers enablement, log level, namespaces, sampling, and rate limits.
// Thread-safe via the Loggers shouldLog method.
// Example:
//
// ll.Level(lx.LevelWarn)
// canLog := ll.CanLog(lx.LevelInfo) // false
func CanLog(level lx.LevelType) bool {
return defaultLogger.CanLog(level)
}
// NamespaceEnabled checks if a namespace is enabled in the default logger.
// It evaluates the namespace hierarchy, considering parent namespaces, and caches the result
// for performance. Thread-safe with read lock.
// Example:
//
// ll.NamespaceDisable("app/db")
// enabled := ll.NamespaceEnabled("app/db") // false
func NamespaceEnabled(path string) bool {
return defaultLogger.NamespaceEnabled(path)
}
// Print logs a message at Info level without format specifiers using the default logger.
// It concatenates variadic arguments with spaces, minimizing allocations, and delegates
// to defaultLoggers Print method. Thread-safe via the Loggers log method.
// Example:
//
// ll.Print("message", "value") // Output: [] INFO: message value
func Print(args ...any) {
defaultLogger.Print(args...)
}
// Println logs a message at Info level without format specifiers, minimizing allocations
// by concatenating arguments with spaces. It is thread-safe via the log method.
// Example:
//
// ll.Println("message", "value") // Output: [] INFO: message value [New Line]
func Println(args ...any) {
defaultLogger.Println(args...)
}
// Printf logs a message at Info level with a format string using the default logger.
// It formats the message and delegates to defaultLoggers Printf method. Thread-safe via
// the Loggers log method.
// Example:
//
// ll.Printf("Message %s", "value") // Output: [] INFO: Message value
func Printf(format string, args ...any) {
defaultLogger.Printf(format, args...)
}
// Len returns the total number of log entries sent to the handler by the default logger.
// It provides thread-safe access to the entries counter using atomic operations.
// Example:
//
// ll.Info("Test")
// count := ll.Len() // Returns 1
func Len() int64 {
return defaultLogger.Len()
}
// Measure is a benchmarking helper that measures and returns the duration of a functions execution.
// It logs the duration at Info level with a "duration" field using defaultLogger. The function
// is executed once, and the elapsed time is returned. Thread-safe via the Loggers mutex.
// Example:
//
// duration := ll.Measure(func() { time.Sleep(time.Millisecond) })
// // Output: [] INFO: function executed [duration=~1ms]
func Measure(fns ...func()) time.Duration {
return defaultLogger.Measure(fns...)
}
// Labels temporarily attaches one or more label names to the logger for the next log entry.
// Labels are typically used for metrics, benchmarking, tracing, or categorizing logs in a structured way.
//
// The labels are stored atomically and intended to be short-lived, applying only to the next
// log operation (or until overwritten by a subsequent call to Labels). Multiple labels can
// be provided as separate string arguments.
//
// Example usage:
//
// logger := New("app").Enable()
//
// // Add labels for a specific operation
// logger.Labels("load_users", "process_orders").Measure(func() {
// // ... perform work ...
// }, func() {
// // ... optional callback ...
// })
func Labels(names ...string) *Logger {
return defaultLogger.Labels(names...)
}
// Since creates a timer that will log the duration when completed
// If startTime is provided, uses that as the start time; otherwise uses time.Now()
//
// defer logger.Since().Info("request") // Auto-start
// logger.Since(start).Info("request") // Manual timing
// logger.Since().If(debug).Debug("timing") // Conditional
func Since(start ...time.Time) *SinceBuilder {
return defaultLogger.Since(start...)
}
// Benchmark logs the duration since a start time at Info level using the default logger.
// It calculates the time elapsed since the provided start time and logs it with "start",
// "end", and "duration" fields. Thread-safe via the Loggers mutex.
// Example:
//
// start := time.Now()
// time.Sleep(time.Millisecond)
// ll.Benchmark(start) // Output: [] INFO: benchmark [start=... end=... duration=...]
func Benchmark(start time.Time) {
defaultLogger.Benchmark(start)
}
// Clone returns a new logger with the same configuration as the default logger.
// It creates a copy of defaultLoggers settings (level, style, namespaces, etc.) but with
// an independent context, allowing customization without affecting the global logger.
// Thread-safe via the Loggers Clone method.
// Example:
//
// logger := ll.Clone().Namespace("sub")
// logger.Info("Sub-logger") // Output: [sub] INFO: Sub-logger
func Clone() *Logger {
return defaultLogger.Clone()
}
// Err adds one or more errors to the default loggers context and logs them.
// It stores non-nil errors in the "error" context field and logs their concatenated string
// representations (e.g., "failed 1; failed 2") at the Error level. Thread-safe via the Loggers mutex.
// Example:
//
// err1 := errors.New("failed 1")
// ll.Err(err1)
// ll.Info("Error occurred") // Output: [] ERROR: failed 1
// // [] INFO: Error occurred [error=failed 1]
func Err(errs ...error) {
defaultLogger.Err(errs...)
}
// Start activates the global logging system.
// If the system was shut down, this re-enables all logging operations,
// subject to individual logger and namespace configurations.
// Thread-safe via atomic operations.
// Example:
//
// ll.Shutdown()
// ll.Info("Ignored") // No output
// ll.Start()
// ll.Info("Logged") // Output: [] INFO: Logged
func Start() {
atomic.StoreInt32(&systemActive, 1)
}
// Shutdown deactivates the global logging system.
// All logging operations are skipped, regardless of individual logger or namespace configurations,
// until Start() is called again. Thread-safe via atomic operations.
// Example:
//
// ll.Shutdown()
// ll.Info("Ignored") // No output
func Shutdown() {
atomic.StoreInt32(&systemActive, 0)
}
// Active returns true if the global logging system is currently active.
// Thread-safe via atomic operations.
// Example:
//
// if ll.Active() {
// ll.Info("System active") // Output: [] INFO: System active
// }
func Active() bool {
return atomic.LoadInt32(&systemActive) == 1
}
// Enable activates logging for the default logger.
// It allows logs to be emitted if other conditions (level, namespace) are met.
// Thread-safe with write lock. Returns the default logger for method chaining.
// Example:
//
// ll.Disable()
// ll.Info("Ignored") // No output
// ll.Enable()
// ll.Info("Logged") // Output: [] INFO: Logged
func Enable() *Logger {
return defaultLogger.Enable()
}
// Disable deactivates logging for the default logger.
// It suppresses all logs, regardless of level or namespace. Thread-safe with write lock.
// Returns the default logger for method chaining.
// Example:
//
// ll.Disable()
// ll.Info("Ignored") // No output
func Disable() *Logger {
return defaultLogger.Disable()
}
// Dbg logs debug information including the source file, line number, and expression value
// using the default logger. It captures the calling line of code and displays both the
// expression and its value. Useful for debugging without temporary print statements.
// Example:
//
// x := 42
// ll.Dbg(x) // Output: [file.go:123] x = 42
func Dbg(any ...interface{}) {
defaultLogger.dbg(2, any...)
}
// Dump displays a hex and ASCII representation of a values binary form using the default logger.
// It serializes the value using gob encoding or direct conversion and shows a hex/ASCII dump.
// Useful for inspecting binary data structures.
// Example:
//
// ll.Dump([]byte{0x41, 0x42}) // Outputs hex/ASCII dump
func Dump(values ...interface{}) {
defaultLogger.Dump(values...)
}
// Enabled returns whether the default logger is enabled for logging.
// It provides thread-safe read access to the enabled field using a read lock.
// Example:
//
// ll.Enable()
// if ll.Enabled() {
// ll.Info("Logging enabled") // Output: [] INFO: Logging enabled
// }
func Enabled() bool {
return defaultLogger.Enabled()
}
// Fields starts a fluent chain for adding fields using variadic key-value pairs with the default logger.
// It creates a FieldBuilder to attach fields, handling non-string keys or uneven pairs by
// adding an error field. Thread-safe via the FieldBuilders logger.
// Example:
//
// ll.Fields("user", "alice").Info("Action") // Output: [] INFO: Action [user=alice]
func Fields(pairs ...any) *FieldBuilder {
return defaultLogger.Fields(pairs...)
}
// Field starts a fluent chain for adding fields from a map with the default logger.
// It creates a FieldBuilder to attach fields from a map, supporting type-safe field addition.
// Thread-safe via the FieldBuilders logger.
// Example:
//
// ll.Field(map[string]interface{}{"user": "alice"}).Info("Action") // Output: [] INFO: Action [user=alice]
func Field(fields map[string]interface{}) *FieldBuilder {
return defaultLogger.Field(fields)
}
// Line adds vertical spacing (newlines) to the log output using the default logger.
// If no arguments are provided, it defaults to 1 newline. Multiple values are summed to
// determine the total lines. Useful for visually separating log sections. Thread-safe.
// Example:
//
// ll.Line(2).Info("After two newlines") // Adds 2 blank lines before: [] INFO: After two newlines
func Line(lines ...int) *Logger {
return defaultLogger.Line(lines...)
}
// Indent sets the indentation level for all log messages of the default logger.
// Each level adds two spaces to the log message, useful for hierarchical output.
// Thread-safe with write lock. Returns the default logger for method chaining.
// Example:
//
// ll.Indent(2)
// ll.Info("Indented") // Output: [] INFO: Indented
func Indent(depth int) *Logger {
return defaultLogger.Indent(depth)
}
// Mark logs the current file and line number where it's called, without any additional debug information.
// It's useful for tracing execution flow without the verbosity of Dbg.
// Example:
//
// logger.Mark() // *MARK*: [file.go:123]
func Mark(names ...string) {
defaultLogger.mark(2, names...)
}
// Output logs data in a human-readable JSON format at Info level, including caller file and line information.
// It is similar to Dbg but formats the output as JSON for better readability. It is thread-safe and respects
// the loggers configuration (e.g., enabled, level, suspend, handler, middleware).
func Output(values ...interface{}) {
defaultLogger.output(2, values...)
}
// Inspect logs one or more values in a **developer-friendly, deeply introspective format** at Info level.
// It includes the caller file and line number, and reveals **all fields** — including:
func Inspect(values ...interface{}) {
o := NewInspector(defaultLogger)
o.Log(2, values...)
}
func Apply(opts ...Option) *Logger {
return defaultLogger.Apply(opts...)
}
func Toggle(v bool) *Logger {
return defaultLogger.Toggle(v)
}
+239
View File
@@ -0,0 +1,239 @@
package ll
import (
"encoding/json"
"fmt"
"reflect"
"runtime"
"strings"
"unsafe"
"github.com/olekukonko/ll/lx"
)
// Inspector is a utility for Logger that provides advanced inspection and logging of data
// in human-readable JSON format. It uses reflection to access and represent unexported fields,
// nested structs, embedded structs, and pointers, making it useful for debugging complex data structures.
type Inspector struct {
logger *Logger
}
// NewInspector returns a new Inspector instance associated with the provided logger.
func NewInspector(logger *Logger) *Inspector {
return &Inspector{logger: logger}
}
// Log outputs the given values as indented JSON at the Info level, prefixed with the caller's
// file name and line number. It handles structs (including unexported fields, nested, and embedded),
// pointers, errors, and other types. The skip parameter determines how many stack frames to skip
// when identifying the caller; typically set to 2 to account for the call to Log and its wrapper.
//
// Example usage within a Logger method:
//
// o := NewInspector(l)
// o.Log(2, someStruct)
func (o *Inspector) Log(skip int, values ...interface{}) {
// Skip if logger is suspended or Info level is disabled
if o.logger.suspend.Load() || !o.logger.shouldLog(lx.LevelInfo) {
return
}
// Retrieve caller information for logging context
_, file, line, ok := runtime.Caller(skip)
if !ok {
o.logger.log(lx.LevelError, lx.ClassText, "Inspector: Unable to parse runtime caller", nil, false)
return
}
// Extract short filename for concise output
shortFile := file
if idx := strings.LastIndex(file, "/"); idx >= 0 {
shortFile = file[idx+1:]
}
// Process each value individually
for _, value := range values {
var jsonData []byte
var err error
// Use reflection for struct types to handle unexported and nested fields
val := reflect.ValueOf(value)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
if val.Kind() == reflect.Struct {
valueMap := o.structToMap(val)
jsonData, err = json.MarshalIndent(valueMap, "", " ")
} else if errVal, ok := value.(error); ok {
// Special handling for errors to represent them as a simple map
value = map[string]string{"error": errVal.Error()}
jsonData, err = json.MarshalIndent(value, "", " ")
} else {
// Fall back to standard JSON marshaling for non-struct types
jsonData, err = json.MarshalIndent(value, "", " ")
}
if err != nil {
o.logger.log(lx.LevelError, lx.ClassInspect, fmt.Sprintf("Inspector: JSON encoding error: %v", err), nil, false)
continue
}
// Construct log message with file, line, and JSON data
msg := fmt.Sprintf("[%s:%d] %s", shortFile, line, string(jsonData))
o.logger.log(lx.LevelInfo, lx.ClassInspect, msg, nil, false)
}
}
// structToMap recursively converts a struct's reflect.Value to a map[string]interface{}.
// It includes unexported fields (named with parentheses), prefixes pointers with '*',
// flattens anonymous embedded structs without json tags, and uses unsafe pointers to access
// unexported primitive fields when reflect.CanInterface() returns false.
func (o *Inspector) structToMap(val reflect.Value) map[string]interface{} {
result := make(map[string]interface{})
if !val.IsValid() {
return result
}
typ := val.Type()
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
fieldType := typ.Field(i)
// Determine field name: prefer json tag if present and not "-", else use struct field name
baseName := fieldType.Name
jsonTag := fieldType.Tag.Get("json")
hasJsonTag := false
if jsonTag != "" {
if idx := strings.Index(jsonTag, ","); idx != -1 {
jsonTag = jsonTag[:idx]
}
if jsonTag != "-" {
baseName = jsonTag
hasJsonTag = true
}
}
// Enclose unexported field names in parentheses
fieldName := baseName
if !fieldType.IsExported() {
fieldName = "(" + baseName + ")"
}
// Handle pointer fields
isPtr := fieldType.Type.Kind() == reflect.Ptr
if isPtr {
fieldName = "*" + fieldName
if field.IsNil() {
result[fieldName] = nil
continue
}
field = field.Elem()
}
// Recurse for struct fields
if field.Kind() == reflect.Struct {
subMap := o.structToMap(field)
isNested := !fieldType.Anonymous || hasJsonTag
if isNested {
result[fieldName] = subMap
} else {
// Flatten embedded struct fields into the parent map, avoiding overwrites
for k, v := range subMap {
if _, exists := result[k]; !exists {
result[k] = v
}
}
}
} else {
// Handle primitive fields
if field.CanInterface() {
result[fieldName] = field.Interface()
} else {
// Use unsafe access for unexported primitives
ptr := getDataPtr(field)
switch field.Kind() {
case reflect.String:
result[fieldName] = *(*string)(ptr)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
result[fieldName] = o.getIntFromUnexportedField(field)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
result[fieldName] = o.getUintFromUnexportedField(field)
case reflect.Float32, reflect.Float64:
result[fieldName] = o.getFloatFromUnexportedField(field)
case reflect.Bool:
result[fieldName] = *(*bool)(ptr)
default:
result[fieldName] = fmt.Sprintf("*unexported %s*", field.Type().String())
}
}
}
}
return result
}
// emptyInterface represents the internal structure of an empty interface{}.
// This is used for unsafe pointer manipulation to access unexported field data.
type emptyInterface struct {
typ unsafe.Pointer
word unsafe.Pointer
}
// getDataPtr returns an unsafe.Pointer to the underlying data of a reflect.Value.
// This enables direct access to unexported fields via unsafe operations.
func getDataPtr(v reflect.Value) unsafe.Pointer {
return (*emptyInterface)(unsafe.Pointer(&v)).word
}
// getIntFromUnexportedField extracts a signed integer value from an unexported field
// using unsafe pointer access. It supports int, int8, int16, int32, and int64 kinds,
// returning the value as int64. Returns 0 for unsupported kinds.
func (o *Inspector) getIntFromUnexportedField(field reflect.Value) int64 {
ptr := getDataPtr(field)
switch field.Kind() {
case reflect.Int:
return int64(*(*int)(ptr))
case reflect.Int8:
return int64(*(*int8)(ptr))
case reflect.Int16:
return int64(*(*int16)(ptr))
case reflect.Int32:
return int64(*(*int32)(ptr))
case reflect.Int64:
return *(*int64)(ptr)
}
return 0
}
// getUintFromUnexportedField extracts an unsigned integer value from an unexported field
// using unsafe pointer access. It supports uint, uint8, uint16, uint32, and uint64 kinds,
// returning the value as uint64. Returns 0 for unsupported kinds.
func (o *Inspector) getUintFromUnexportedField(field reflect.Value) uint64 {
ptr := getDataPtr(field)
switch field.Kind() {
case reflect.Uint:
return uint64(*(*uint)(ptr))
case reflect.Uint8:
return uint64(*(*uint8)(ptr))
case reflect.Uint16:
return uint64(*(*uint16)(ptr))
case reflect.Uint32:
return uint64(*(*uint32)(ptr))
case reflect.Uint64:
return *(*uint64)(ptr)
}
return 0
}
// getFloatFromUnexportedField extracts a floating-point value from an unexported field
// using unsafe pointer access. It supports float32 and float64 kinds, returning the value
// as float64. Returns 0 for unsupported kinds.
func (o *Inspector) getFloatFromUnexportedField(field reflect.Value) float64 {
ptr := getDataPtr(field)
switch field.Kind() {
case reflect.Float32:
return float64(*(*float32)(ptr))
case reflect.Float64:
return *(*float64)(ptr)
}
return 0
}
+48
View File
@@ -0,0 +1,48 @@
package ll
import "github.com/olekukonko/ll/lx"
// defaultStore is the global namespace store for enable/disable states.
// It is shared across all Logger instances to manage namespace hierarchy consistently.
// Thread-safe via the lx.Namespace structs sync.Map.
var defaultStore = &lx.Namespace{}
// systemActive indicates if the global logging system is active.
// Defaults to true, meaning logging is active unless explicitly shut down.
// Or, default to false and require an explicit ll.Start(). Let's default to true for less surprise.
var systemActive int32 = 1 // 1 for true, 0 for false (for atomic operations)
// Option defines a functional option for configuring a Logger.
type Option func(*Logger)
// reverseString reverses the input string by swapping characters from both ends.
// It converts the string to a rune slice to handle Unicode characters correctly,
// ensuring proper reversal for multi-byte characters.
// Used internally for string manipulation, such as in debugging or log formatting.
func reverseString(s string) string {
// Convert string to rune slice to handle Unicode characters
r := []rune(s)
// Iterate over half the slice, swapping characters from start and end
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
// Convert rune slice back to string and return
return string(r)
}
// viewString converts a byte slice to a printable string, replacing non-printable
// characters (ASCII < 32 or > 126) with a dot ('.').
// It ensures safe display of binary data in logs, such as in the Dump method.
// Used for formatting binary data in a human-readable hex/ASCII dump.
func viewString(b []byte) string {
// Convert byte slice to rune slice via string for processing
r := []rune(string(b))
// Replace non-printable characters with '.'
for i := range r {
if r[i] < 32 || r[i] > 126 {
r[i] = '.'
}
}
// Return the resulting printable string
return string(r)
}
+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")
}
+1457
View File
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
package lx
import (
"fmt"
"strings"
)
// Field represents a key-value pair where the key is a string and the value is of any type.
type Field struct {
Key string
Value interface{}
}
// Fields represents a slice of key-value pairs.
type Fields []Field
// Map converts the Fields slice to a map[string]interface{}.
// This is useful for backward compatibility or when map operations are needed.
// Example:
//
// fields := lx.Fields{{"user", "alice"}, {"age", 30}}
// m := fields.Map() // Returns map[string]interface{}{"user": "alice", "age": 30}
func (f Fields) Map() map[string]interface{} {
m := make(map[string]interface{}, len(f))
for _, pair := range f {
m[pair.Key] = pair.Value
}
return m
}
// Get returns the value for a given key and a boolean indicating if the key was found.
// This provides O(n) lookup, which is fine for small numbers of fields.
// Example:
//
// fields := lx.Fields{{"user", "alice"}, {"age", 30}}
// value, found := fields.Get("user") // Returns "alice", true
func (f Fields) Get(key string) (interface{}, bool) {
for _, pair := range f {
if pair.Key == key {
return pair.Value, true
}
}
return nil, false
}
// Filter returns a new Fields slice containing only pairs where the predicate returns true.
// Example:
//
// fields := lx.Fields{{"user", "alice"}, {"password", "secret"}, {"age", 30}}
// filtered := fields.Filter(func(key string, value interface{}) bool {
// return key != "password" // Remove sensitive fields
// })
func (f Fields) Filter(predicate func(key string, value interface{}) bool) Fields {
result := make(Fields, 0, len(f))
for _, pair := range f {
if predicate(pair.Key, pair.Value) {
result = append(result, pair)
}
}
return result
}
// Translate returns a new Fields slice with keys translated according to the provided mapping.
// Keys not in the mapping are passed through unchanged. This is useful for adapters like Victoria.
// Example:
//
// fields := lx.Fields{{"user", "alice"}, {"timestamp", time.Now()}}
// translated := fields.Translate(map[string]string{
// "user": "username",
// "timestamp": "ts",
// })
// // Returns: {{"username", "alice"}, {"ts", time.Now()}}
func (f Fields) Translate(mapping map[string]string) Fields {
result := make(Fields, len(f))
for i, pair := range f {
if newKey, ok := mapping[pair.Key]; ok {
result[i] = Field{Key: newKey, Value: pair.Value}
} else {
result[i] = pair
}
}
return result
}
// Merge merges another Fields slice into this one, with the other slice's fields taking precedence
// for duplicate keys (overwrites existing keys).
// Example:
//
// base := lx.Fields{{"user", "alice"}, {"age", 30}}
// additional := lx.Fields{{"age", 31}, {"city", "NYC"}}
// merged := base.Merge(additional)
// // Returns: {{"user", "alice"}, {"age", 31}, {"city", "NYC"}}
func (f Fields) Merge(other Fields) Fields {
result := make(Fields, 0, len(f)+len(other))
// Create a map to track which keys from 'other' we've seen
seen := make(map[string]bool, len(other))
// First add all fields from 'f'
result = append(result, f...)
// Then add fields from 'other', overwriting duplicates
for _, pair := range other {
// Check if this key already exists in result
found := false
for i, existing := range result {
if existing.Key == pair.Key {
result[i] = pair // Overwrite
found = true
break
}
}
if !found {
result = append(result, pair)
}
seen[pair.Key] = true
}
return result
}
// String returns a human-readable string representation of the fields.
// Example:
//
// fields := lx.Fields{{"user", "alice"}, {"age", 30}}
// str := fields.String() // Returns: "[user=alice age=30]"
func (f Fields) String() string {
var builder strings.Builder
builder.WriteString(LeftBracket)
for i, pair := range f {
if i > 0 {
builder.WriteString(Space)
}
builder.WriteString(pair.Key)
builder.WriteString("=")
builder.WriteString(fmt.Sprint(pair.Value))
}
builder.WriteString(RightBracket)
return builder.String()
}
+67
View File
@@ -0,0 +1,67 @@
package lx
import "io"
// Handler defines the interface for processing log entries.
// Implementations (e.g., TextHandler, JSONHandler) format and output log entries to various
// destinations (e.g., stdout, files). The Handle method returns an error if processing fails,
// allowing the logger to handle output failures gracefully.
// Example (simplified handler implementation):
//
// type MyHandler struct{}
// func (h *MyHandler) Handle(e *Entry) error {
// fmt.Printf("[%s] %s: %s\n", e.Namespace, e.Level.String(), e.Message)
// return nil
// }
type Handler interface {
Handle(e *Entry) error // Processes a log entry, returning any error
}
// Outputter defines the interface for handlers that support dynamic output
// destination changes. Implementations can switch their output writer at runtime.
//
// Example usage:
//
// h := &JSONHandler{}
// h.Output(os.Stderr) // Switch to stderr
// h.Output(file) // Switch to file
type Outputter interface {
Output(w io.Writer)
}
// HandlerOutputter combines the Handler and Outputter interfaces.
// Types implementing this interface can both process log entries and
// dynamically change their output destination at runtime.
//
// This is useful for creating flexible logging handlers that support
// features like log rotation, output redirection, or runtime configuration.
//
// Example usage:
//
// var ho HandlerOutputter = &TextHandler{}
// // Handle log entries
// ho.Handle(&Entry{...})
// // Switch output destination
// ho.Output(os.Stderr)
//
// Common implementations include TextHandler and JSONHandler when they
// support output destination changes.
type HandlerOutputter interface {
Handler // Can process log entries
Outputter // Can change output destination (has Output(w io.Writer) method)
}
// Timestamper defines an interface for handlers that support timestamp configuration.
// It includes a method to enable or disable timestamp logging and optionally set the timestamp format.
type Timestamper interface {
// Timestamped enables or disables timestamp logging and allows specifying an optional format.
// Parameters:
// enable: Boolean to enable or disable timestamp logging
// format: Optional string(s) to specify the timestamp format
Timestamped(enable bool, format ...string)
}
// Wrap is a handler decorator function that transforms a log handler.
// It takes an existing handler as input and returns a new, wrapped handler
// that adds functionality (like filtering, transformation, or routing).
type Wrap func(next Handler) Handler
+81
View File
@@ -0,0 +1,81 @@
package lx
// Formatting constants for log output.
// These constants define the characters used to format log messages, ensuring consistency
// across handlers (e.g., text, JSON, colorized). They are used to construct namespace paths,
// level indicators, and field separators in log entries.
const (
Space = " " // Single space for separating elements (e.g., between level and message)
DoubleSpace = " " // Double space for indentation (e.g., for hierarchical output)
Slash = "/" // Separator for namespace paths (e.g., "parent/child")
Arrow = "→" // Arrow for NestedPath style namespaces (e.g., [parent]→[child])
LeftBracket = "[" // Opening bracket for namespaces and fields (e.g., [app])
RightBracket = "]" // Closing bracket for namespaces and fields (e.g., [app])
Colon = ":" // Separator after namespace or level (e.g., [app]: INFO:) can also be "|"
Dot = "." // Separator for namespace paths (e.g., "parent.child")
Newline = "\n" // Newline for separating log entries or stack trace lines
)
// DefaultEnabled defines the default logging state (disabled).
// It specifies whether logging is enabled by default for new Logger instances in the ll package.
// Set to false to prevent logging until explicitly enabled.
const (
DefaultEnabled = true // Default state for new loggers (disabled)
)
// Log level constants, ordered by increasing severity.
// These constants define the severity levels for log messages, used to filter logs based
// on the loggers minimum level. They are ordered to allow comparison (e.g., LevelDebug < LevelWarn).
const (
LevelNone LevelType = iota // Debug level for detailed diagnostic information
LevelInfo // Info level for general operational messages
LevelWarn // Warn level for warning conditions
LevelError // Error level for error conditions requiring attention
LevelFatal // Fatal level for critical error conditions
LevelDebug // None level for logs without a specific severity (e.g., raw output)
LevelUnknown // None level for logs without a specific severity (e.g., raw output)
)
// String constants for each level
const (
DebugString = "DEBUG"
InfoString = "INFO"
WarnString = "WARN"
WarningString = "WARNING"
ErrorString = "ERROR"
FatalString = "FATAL"
NoneString = "NONE"
UnknownString = "UNKNOWN"
TextString = "TEXT"
JSONString = "JSON"
DumpString = "DUMP"
SpecialString = "SPECIAL"
RawString = "RAW"
InspectString = "INSPECT"
DbgString = "DBG"
TimedString = "TIMED"
)
// Log class constants, defining the type of log entry.
// These constants categorize log entries by their content or purpose, influencing how
// handlers process them (e.g., text, JSON, hex dump).
const (
ClassText ClassType = iota // Text entries for standard log messages
ClassJSON // JSON entries for structured output
ClassDump // Dump entries for hex/ASCII dumps
ClassSpecial // Special entries for custom or non-standard logs
ClassRaw // Raw entries for unformatted output
ClassInspect // Inspect entries for debugging
ClassDbg // Inspect entries for debugging
ClassTimed // Inspect entries for debugging
ClassUnknown // Unknown output
)
// Namespace style constants.
// These constants define how namespace paths are formatted in log output, affecting the
// visual representation of hierarchical namespaces.
const (
FlatPath StyleType = iota // Formats namespaces as [parent/child]
NestedPath // Formats namespaces as [parent]→[child]
)
+102
View File
@@ -0,0 +1,102 @@
package lx
import (
"strings"
"sync"
)
// namespaceRule stores the cached result of Enabled.
type namespaceRule struct {
isEnabledByRule bool
isDisabledByRule bool
}
// Namespace manages thread-safe namespace enable/disable states with caching.
// The store holds explicit user-defined rules (path -> bool).
// The cache holds computed effective states for paths (path -> namespaceRule)
// based on hierarchical rules to optimize lookups.
type Namespace struct {
store sync.Map // path (string) -> rule (bool: true=enable, false=disable)
cache sync.Map // path (string) -> namespaceRule
}
// Set defines an explicit enable/disable rule for a namespace path.
// It clears the cache to ensure subsequent lookups reflect the change.
func (ns *Namespace) Set(path string, enabled bool) {
ns.store.Store(path, enabled)
ns.clearCache()
}
// Load retrieves an explicit rule from the store for a path.
// Returns the rule (true=enable, false=disable) and whether it exists.
// Does not consider hierarchy or caching.
func (ns *Namespace) Load(path string) (rule interface{}, found bool) {
return ns.store.Load(path)
}
// Store directly sets a rule in the store, bypassing cache invalidation.
// Intended for internal use or sync.Map parity; prefer Set for standard use.
func (ns *Namespace) Store(path string, rule bool) {
ns.store.Store(path, rule)
}
// clearCache clears the cache of Enabled results.
// Called by Set to ensure consistency after rule changes.
func (ns *Namespace) clearCache() {
ns.cache.Range(func(key, _ interface{}) bool {
ns.cache.Delete(key)
return true
})
}
// Enabled checks if a path is enabled by namespace rules, considering the most
// specific rule (path or closest prefix) in the store. Results are cached.
// Args:
// - path: Absolute namespace path to check.
// - separator: Character delimiting path segments (e.g., "/", ".").
//
// Returns:
// - isEnabledByRule: True if an explicit rule enables the path.
// - isDisabledByRule: True if an explicit rule disables the path.
//
// If both are false, no explicit rule applies to the path or its prefixes.
func (ns *Namespace) Enabled(path string, separator string) (isEnabledByRule bool, isDisabledByRule bool) {
if path == "" { // Root path has no explicit rule
return false, false
}
// Check cache
if cachedValue, found := ns.cache.Load(path); found {
if state, ok := cachedValue.(namespaceRule); ok {
return state.isEnabledByRule, state.isDisabledByRule
}
ns.cache.Delete(path) // Remove invalid cache entry
}
// Compute: Most specific rule wins
parts := strings.Split(path, separator)
computedIsEnabled := false
computedIsDisabled := false
for i := len(parts); i >= 1; i-- {
currentPrefix := strings.Join(parts[:i], separator)
if val, ok := ns.store.Load(currentPrefix); ok {
if rule := val.(bool); rule {
computedIsEnabled = true
computedIsDisabled = false
} else {
computedIsEnabled = false
computedIsDisabled = true
}
break
}
}
// Cache result, including (false, false) for no rule
ns.cache.Store(path, namespaceRule{
isEnabledByRule: computedIsEnabled,
isDisabledByRule: computedIsDisabled,
})
return computedIsEnabled, computedIsDisabled
}
+144
View File
@@ -0,0 +1,144 @@
package lx
import (
"strings"
"time"
)
// LevelType represents the severity of a log message.
// It is an integer type used to define log levels (Debug, Info, Warn, Error, None), with associated
// string representations for display in log output.
type LevelType int
// String converts a LevelType to its string representation.
// It maps each level constant to a human-readable string, returning "UNKNOWN" for invalid levels.
// Used by handlers to display the log level in output.
// Example:
//
// var level lx.LevelType = lx.LevelInfo
// fmt.Println(level.String()) // Output: INFO
func (l LevelType) String() string {
switch l {
case LevelDebug:
return DebugString
case LevelInfo:
return InfoString
case LevelWarn:
return WarnString
case LevelError:
return ErrorString
case LevelFatal:
return FatalString
case LevelNone:
return NoneString
default:
return UnknownString
}
}
func (l LevelType) Name(class ClassType) string {
if class == ClassRaw || class == ClassDump || class == ClassInspect || class == ClassDbg || class == ClassTimed {
return class.String()
}
return l.String()
}
// LevelParse converts a string to its corresponding LevelType.
// It parses a string (case-insensitive) and returns the corresponding LevelType, defaulting to
// LevelUnknown for unrecognized strings. Supports "WARNING" as an alias for "WARN".
func LevelParse(s string) LevelType {
switch strings.ToUpper(s) {
case DebugString:
return LevelDebug
case InfoString:
return LevelInfo
case WarnString, WarningString: // Allow both "WARN" and "WARNING"
return LevelWarn
case ErrorString:
return LevelError
case NoneString:
return LevelNone
default:
return LevelUnknown
}
}
// Entry represents a single log entry passed to handlers.
// It encapsulates all information about a log message, including its timestamp, severity,
// content, namespace, metadata, and formatting style. Handlers process Entry instances
// to produce formatted output (e.g., text, JSON). The struct is immutable once created,
// ensuring thread-safety in handler processing.
type Entry struct {
Timestamp time.Time // Time the log was created
Level LevelType // Severity level of the log (Debug, Info, Warn, Error, None)
Message string // Log message content
Namespace string // Namespace path (e.g., "parent/child")
Fields Fields // Additional key-value metadata (e.g., {"user": "alice"})
Style StyleType // Namespace formatting style (FlatPath or NestedPath)
Error error // Associated error, if any (e.g., for error logs)
Class ClassType // Type of log entry (Text, JSON, Dump, Special, Raw)
Stack []byte // Stack trace data (if present)
Id int `json:"-"` // Unique ID for the entry, ignored in JSON output
}
// StyleType defines how namespace paths are formatted in log output.
// It is an integer type used to select between FlatPath ([parent/child]) and NestedPath
// ([parent]→[child]) styles, affecting how handlers render namespace hierarchies.
type StyleType int
// ClassType represents the type of a log entry.
// It is an integer type used to categorize log entries (Text, JSON, Dump, Special, Raw),
// influencing how handlers process and format them.
type ClassType int
// String converts a ClassType to its string representation.
// It maps each class constant to a human-readable string, returning "UNKNOWN" for invalid classes.
// Used by handlers to indicate the entry type in output (e.g., JSON fields).
// Example:
//
// var class lx.ClassType = lx.ClassText
// fmt.Println(class.String()) // Output: TEST
func (t ClassType) String() string {
switch t {
case ClassText:
return TextString
case ClassJSON:
return JSONString
case ClassDump:
return DumpString
case ClassSpecial:
return SpecialString
case ClassInspect:
return InspectString
case ClassDbg:
return DbgString
case ClassRaw:
return RawString
case ClassTimed:
return TimedString
default:
return UnknownString
}
}
// ParseClass converts a string to its corresponding ClassType.
// It parses a string (case-insensitive) and returns the corresponding ClassType, defaulting to
// ClassUnknown for unrecognized strings.
func ParseClass(s string) ClassType {
switch strings.ToUpper(s) {
case TextString:
return ClassText
case JSONString:
return ClassJSON
case DumpString:
return ClassDump
case SpecialString:
return ClassSpecial
case RawString:
return ClassRaw
default:
return ClassUnknown
}
}
+124
View File
@@ -0,0 +1,124 @@
package ll
import (
"github.com/olekukonko/ll/lx"
)
// Middleware represents a registered middleware and its operations in the logging pipeline.
// It holds an ID for identification, a reference to the parent logger, and the handler function
// that processes log entries. Middleware is used to transform or filter log entries before they
// are passed to the logger's output handler.
type Middleware struct {
id int // Unique identifier for the middleware
logger *Logger // Parent logger instance for context and logging operations
fn lx.Handler // Handler function that processes log entries
}
// Remove unregisters the middleware from the loggers middleware chain.
// It safely removes the middleware by its ID, ensuring thread-safety with a mutex lock.
// If the middleware or logger is nil, it returns early to prevent panics.
// Example usage:
//
// // Using a named middleware function
// mw := logger.Use(authMiddleware)
// defer mw.Remove()
//
// // Using an inline middleware
// mw = logger.Use(ll.Middle(func(e *lx.Entry) error {
// if e.Level < lx.LevelWarn {
// return fmt.Errorf("level too low")
// }
// return nil
// }))
// defer mw.Remove()
func (m *Middleware) Remove() {
// Check for nil middleware or logger to avoid panics
if m == nil || m.logger == nil {
return
}
// Acquire write lock to modify middleware slice
m.logger.mu.Lock()
defer m.logger.mu.Unlock()
// Iterate through middleware slice to find and remove matching ID
for i, entry := range m.logger.middleware {
if entry.id == m.id {
// Remove middleware by slicing out the matching entry
m.logger.middleware = append(m.logger.middleware[:i], m.logger.middleware[i+1:]...)
return
}
}
}
// Logger returns the parent logger for optional chaining.
// This allows middleware to access the logger for additional operations, such as logging errors
// or creating derived loggers. It is useful for fluent API patterns.
// Example:
//
// mw := logger.Use(authMiddleware)
// mw.Logger().Info("Middleware registered")
func (m *Middleware) Logger() *Logger {
return m.logger
}
// Error logs an error message at the Error level if the middleware blocks a log entry.
// It uses the parent logger to emit the error and returns the middleware for chaining.
// This is useful for debugging or auditing when middleware rejects a log.
// Example:
//
// mw := logger.Use(ll.Middle(func(e *lx.Entry) error {
// if e.Level < lx.LevelWarn {
// return fmt.Errorf("level too low")
// }
// return nil
// }))
// mw.Error("Rejected low-level log")
func (m *Middleware) Error(args ...any) *Middleware {
m.logger.Error(args...)
return m
}
// Errorf logs an error message at the Error level if the middleware blocks a log entry.
// It uses the parent logger to emit the error and returns the middleware for chaining.
// This is useful for debugging or auditing when middleware rejects a log.
// Example:
//
// mw := logger.Use(ll.Middle(func(e *lx.Entry) error {
// if e.Level < lx.LevelWarn {
// return fmt.Errorf("level too low")
// }
// return nil
// }))
// mw.Errorf("Rejected low-level log")
func (m *Middleware) Errorf(format string, args ...any) *Middleware {
m.logger.Errorf(format, args...)
return m
}
// middlewareFunc is a function adapter that implements the lx.Handler interface.
// It allows plain functions with the signature `func(*lx.Entry) error` to be used as middleware.
// The function should return nil to allow the log to proceed or a non-nil error to reject it,
// stopping the log from being emitted by the logger.
type middlewareFunc func(*lx.Entry) error
// Handle implements the lx.Handler interface for middlewareFunc.
// It calls the underlying function with the log entry and returns its result.
// This enables seamless integration of function-based middleware into the logging pipeline.
func (mf middlewareFunc) Handle(e *lx.Entry) error {
return mf(e)
}
// Middle creates a middleware handler from a function.
// It wraps a function with the signature `func(*lx.Entry) error` into a middlewareFunc,
// allowing it to be used in the loggers middleware pipeline. A non-nil error returned by
// the function will stop the log from being emitted, ensuring precise control over logging.
// Example:
//
// logger.Use(ll.Middle(func(e *lx.Entry) error {
// if e.Level == lx.LevelDebug {
// return fmt.Errorf("debug logs disabled")
// }
// return nil
// }))
func Middle(fn func(*lx.Entry) error) lx.Handler {
return middlewareFunc(fn)
}
+67
View File
@@ -0,0 +1,67 @@
package ll
import "github.com/olekukonko/ll/lx"
// WithHandler sets the handler for the logger as a functional option for configuring
// a new logger instance.
// Example:
//
// logger := New("app", WithHandler(lh.NewJSONHandler(os.Stdout)))
func WithHandler(handler lx.Handler) Option {
return func(l *Logger) {
l.handler = handler
}
}
// WithTimestamped returns an Option that configures timestamp settings for the logger's existing handler.
// It enables or disables timestamp logging and optionally sets the timestamp format if the handler
// supports the lx.Timestamper interface. If no handler is set, the function has no effect.
// Parameters:
//
// enable: Boolean to enable or disable timestamp logging
// format: Optional string(s) to specify the timestamp format
func WithTimestamped(enable bool, format ...string) Option {
return func(l *Logger) {
if l.handler != nil { // Check if a handler is set
// Verify if the handler supports the lx.Timestamper interface
if h, ok := l.handler.(lx.Timestamper); ok {
h.Timestamped(enable, format...) // Apply timestamp settings to the handler
}
}
}
}
// WithLevel sets the minimum log level for the logger as a functional option for
// configuring a new logger instance.
// Example:
//
// logger := New("app", WithLevel(lx.LevelWarn))
func WithLevel(level lx.LevelType) Option {
return func(l *Logger) {
l.level = level
}
}
// WithStyle sets the namespace formatting style for the logger as a functional option
// for configuring a new logger instance.
// Example:
//
// logger := New("app", WithStyle(lx.NestedPath))
func WithStyle(style lx.StyleType) Option {
return func(l *Logger) {
l.style = style
}
}
// Functional options (can be passed to New() or applied later)
func WithFatalExits(enabled bool) Option {
return func(l *Logger) {
l.fatalExits = enabled
}
}
func WithFatalStack(enabled bool) Option {
return func(l *Logger) {
l.fatalStack = enabled
}
}
+388
View File
@@ -0,0 +1,388 @@
package ll
import (
"fmt"
"strings"
"time"
"github.com/olekukonko/ll/lx"
)
// Measure executes one or more functions and logs the duration of each.
// It returns the total cumulative duration across all functions.
//
// Each function in `fns` is run sequentially. If a function is `nil`, it is skipped.
//
// Optional labels previously set via `Labels(...)` are applied to the corresponding function
// by position. If there are fewer labels than functions, missing labels are replaced with
// default names like "fn_0", "fn_1", etc. Labels are cleared after the call to prevent reuse.
//
// Example usage:
//
// logger := New("app").Enable()
//
// // Optional: add labels for functions
// logger.Labels("load_users", "process_orders")
//
// total := logger.Measure(
// func() {
// // simulate work 1
// time.Sleep(100 * time.Millisecond)
// },
// func() {
// // simulate work 2
// time.Sleep(200 * time.Millisecond)
// },
// func() {
// // simulate work 3
// time.Sleep(50 * time.Millisecond)
// },
// )
//
// // Logs something like:
// // [load_users] completed duration=100ms
// // [process_orders] completed duration=200ms
// // [fn_2] completed duration=50ms
//
// Returns the sum of durations of all executed functions.
func (l *Logger) Measure(fns ...func()) time.Duration {
if len(fns) == 0 {
return 0
}
var total time.Duration
lblPtr := l.labels.Swap(nil)
var lbls []string
if lblPtr != nil {
lbls = *lblPtr
}
for i, fn := range fns {
if fn == nil {
continue
}
// Use SinceBuilder instead of manual timing
sb := l.Since() // starts timer internally
fn()
duration := sb.Fields(
"index", i,
).Info(fmt.Sprintf("[%s] completed", func() string {
if i < len(lbls) && lbls[i] != "" {
return lbls[i]
}
return fmt.Sprintf("fn_%d", i)
}()))
total += duration
}
return total
}
// Since creates a timer that will log the duration when completed
// If startTime is provided, uses that as the start time; otherwise uses time.Now()
//
// defer logger.Since().Info("request") // Auto-start
// logger.Since(start).Info("request") // Manual timing
// logger.Since().If(debug).Debug("timing") // Conditional
func (l *Logger) Since(startTime ...time.Time) *SinceBuilder {
start := time.Now()
if len(startTime) > 0 && !startTime[0].IsZero() {
start = startTime[0]
}
return &SinceBuilder{
logger: l,
start: start,
condition: true,
fields: nil, // Lazily initialized
}
}
// SinceBuilder provides a fluent API for logging timed operations
// It mirrors FieldBuilder exactly for field operations
type SinceBuilder struct {
logger *Logger
start time.Time
condition bool
fields lx.Fields
}
// ---------------------------------------------------------------------
// Conditional Methods (match conditional.go pattern)
// ---------------------------------------------------------------------
// If adds a condition to this timer - only logs if condition is true
func (sb *SinceBuilder) If(condition bool) *SinceBuilder {
sb.condition = sb.condition && condition
return sb
}
// IfErr adds an error condition - only logs if err != nil
func (sb *SinceBuilder) IfErr(err error) *SinceBuilder {
sb.condition = sb.condition && (err != nil)
return sb
}
// IfAny logs if ANY condition is true
func (sb *SinceBuilder) IfAny(conditions ...bool) *SinceBuilder {
if !sb.condition {
return sb
}
for _, cond := range conditions {
if cond {
return sb
}
}
sb.condition = false
return sb
}
// IfOne logs if ALL conditions are true
func (sb *SinceBuilder) IfOne(conditions ...bool) *SinceBuilder {
if !sb.condition {
return sb
}
for _, cond := range conditions {
if !cond {
sb.condition = false
return sb
}
}
return sb
}
// ---------------------------------------------------------------------
// Field Methods - EXACT MATCH with FieldBuilder API
// ---------------------------------------------------------------------
// Fields adds key-value pairs as fields (variadic)
// EXACT match to FieldBuilder.Fields()
func (sb *SinceBuilder) Fields(pairs ...any) *SinceBuilder {
if sb.logger.suspend.Load() || !sb.condition {
return sb
}
// Lazy initialization
if sb.fields == nil {
sb.fields = make(lx.Fields, 0, len(pairs)/2)
}
// Process key-value pairs
for i := 0; i < len(pairs)-1; i += 2 {
if key, ok := pairs[i].(string); ok {
sb.fields = append(sb.fields, lx.Field{Key: key, Value: pairs[i+1]})
} else {
// Log error for non-string keys (matches Fields behavior)
sb.fields = append(sb.fields, lx.Field{
Key: "error",
Value: fmt.Errorf("missing key '%v'", pairs[i]),
})
}
}
// Handle uneven pairs (matches Fields behavior)
if len(pairs)%2 != 0 {
sb.fields = append(sb.fields, lx.Field{
Key: "error",
Value: fmt.Errorf("missing key '%v'", pairs[len(pairs)-1]),
})
}
return sb
}
// Field adds fields from a map
// EXACT match to FieldBuilder.Field()
func (sb *SinceBuilder) Field(fields map[string]interface{}) *SinceBuilder {
if sb.logger.suspend.Load() || !sb.condition || len(fields) == 0 {
return sb
}
// Lazy initialization
if sb.fields == nil {
sb.fields = make(lx.Fields, 0, len(fields))
}
// Copy fields from input map (preserves iteration order)
for k, v := range fields {
sb.fields = append(sb.fields, lx.Field{Key: k, Value: v})
}
return sb
}
// Err adds one or more errors as a field
// EXACT match to FieldBuilder.Err()
func (sb *SinceBuilder) Err(errs ...error) *SinceBuilder {
if sb.logger.suspend.Load() || !sb.condition {
return sb
}
// Lazy initialization
if sb.fields == nil {
sb.fields = make(lx.Fields, 0, 2)
}
// Collect non-nil errors
var nonNilErrors []error
var builder strings.Builder
count := 0
for i, err := range errs {
if err != nil {
if i > 0 && count > 0 {
builder.WriteString("; ")
}
builder.WriteString(err.Error())
nonNilErrors = append(nonNilErrors, err)
count++
}
}
if count > 0 {
if count == 1 {
sb.fields = append(sb.fields, lx.Field{Key: "error", Value: nonNilErrors[0]})
} else {
sb.fields = append(sb.fields, lx.Field{Key: "error", Value: nonNilErrors})
}
// Note: Unlike FieldBuilder.Err(), we DON'T log immediately
// The error will be included in the timing log
}
return sb
}
// Merge adds additional key-value pairs to the fields
// EXACT match to FieldBuilder.Merge()
func (sb *SinceBuilder) Merge(pairs ...any) *SinceBuilder {
if sb.logger.suspend.Load() || !sb.condition {
return sb
}
// Lazy initialization
if sb.fields == nil {
sb.fields = make(lx.Fields, 0, len(pairs)/2)
}
// Process pairs as key-value
for i := 0; i < len(pairs)-1; i += 2 {
if key, ok := pairs[i].(string); ok {
sb.fields = append(sb.fields, lx.Field{Key: key, Value: pairs[i+1]})
} else {
sb.fields = append(sb.fields, lx.Field{
Key: "error",
Value: fmt.Errorf("non-string key in Merge: %v", pairs[i]),
})
}
}
if len(pairs)%2 != 0 {
sb.fields = append(sb.fields, lx.Field{
Key: "error",
Value: fmt.Errorf("uneven key-value pairs in Merge: [%v]", pairs[len(pairs)-1]),
})
}
return sb
}
// ---------------------------------------------------------------------
// Logging Methods (match logger pattern)
// ---------------------------------------------------------------------
// Debug logs the duration at Debug level with message
func (sb *SinceBuilder) Debug(msg string) time.Duration {
return sb.logAtLevel(lx.LevelDebug, msg)
}
// Info logs the duration at Info level with message
func (sb *SinceBuilder) Info(msg string) time.Duration {
return sb.logAtLevel(lx.LevelInfo, msg)
}
// Warn logs the duration at Warn level with message
func (sb *SinceBuilder) Warn(msg string) time.Duration {
return sb.logAtLevel(lx.LevelWarn, msg)
}
// Error logs the duration at Error level with message
func (sb *SinceBuilder) Error(msg string) time.Duration {
return sb.logAtLevel(lx.LevelError, msg)
}
// Log is an alias for Info (for backward compatibility)
func (sb *SinceBuilder) Log(msg string) time.Duration {
return sb.Info(msg)
}
// logAtLevel internal method that handles the actual logging
func (sb *SinceBuilder) logAtLevel(level lx.LevelType, msg string) time.Duration {
// Fast path - don't even compute duration if we're not logging
if !sb.condition || sb.logger.suspend.Load() || !sb.logger.shouldLog(level) {
return time.Since(sb.start)
}
duration := time.Since(sb.start)
// Build final fields in this order:
// 1. Logger context fields (from logger.context)
// 2. Builder fields (from sb.fields)
// 3. Duration fields (always last)
// Pre-allocate with exact capacity
totalFields := 0
if sb.logger.context != nil {
totalFields += len(sb.logger.context)
}
if sb.fields != nil {
totalFields += len(sb.fields)
}
totalFields += 2 // duration_ms, duration
fields := make(lx.Fields, 0, totalFields)
// Add logger context fields first (preserves order)
if sb.logger.context != nil {
fields = append(fields, sb.logger.context...)
}
// Add builder fields
if sb.fields != nil {
fields = append(fields, sb.fields...)
}
// Add duration fields last (so they're visible at the end)
fields = append(fields,
lx.Field{Key: "duration_ms", Value: duration.Milliseconds()},
lx.Field{Key: "duration", Value: duration.String()},
)
sb.logger.log(level, lx.ClassTimed, msg, fields, false)
return duration
}
// ---------------------------------------------------------------------
// Utility Methods
// ---------------------------------------------------------------------
// Reset allows reusing the builder with a new start time
// Zero-allocation - keeps fields slice capacity
func (sb *SinceBuilder) Reset(startTime ...time.Time) *SinceBuilder {
sb.start = time.Now()
if len(startTime) > 0 && !startTime[0].IsZero() {
sb.start = startTime[0]
}
sb.condition = true
if sb.fields != nil {
sb.fields = sb.fields[:0] // Keep capacity, zero length
}
return sb
}
// Elapsed returns the current duration without logging
func (sb *SinceBuilder) Elapsed() time.Duration {
return time.Since(sb.start)
}