1
0
mirror of https://github.com/taigrr/log-socket synced 2026-03-20 16:02:28 -07:00

6 Commits

Author SHA1 Message Date
9395e417c6 feat(log): add Logger.Debugln, expand test coverage, update Go/CI
- Add missing Logger.Debugln method (package-level Debugln existed but
  Logger type lacked it)
- Replace empty test stubs with real tests for Debug, Debugf, Info,
  Infof, Print, Printf, Notice, Warn, Warnf, Error, Errorf, Panic,
  Panicf, Panicln
- Add tests for namespace filtering, multi-namespace clients,
  namespace registry, level storage, colorize, parseLevelString,
  Broadcast, matchesNamespace, fileInfo, Logger.Debugln,
  empty namespace default
- Update go.mod to Go 1.26.1
- Update CI to actions/checkout@v4, actions/setup-go@v5, Go 1.26,
  add -race flag, trigger on pull_request
- Fix stale CRUSH.md references (Go version, CI config, stderr bug)
2026-03-06 11:03:47 +00:00
5cb1329155 feat(slog): add slog.Handler adapter (#20)
* docs: add example programs for common usage patterns

Adds four focused examples in examples/ directory:
- basic: drop-in logger with web UI
- namespaces: namespace-based logging by component
- client: programmatic log client with filtering
- log-levels: all log levels and filtering

Fixes #7

* feat(slog): add slog.Handler adapter for log-socket

Implements log/slog.Handler that routes structured log records into the
log-socket broadcasting system. Supports namespaces, WithAttrs,
WithGroup, and configurable minimum level.

Also adds Broadcast() as a public entry point for adapter packages
that construct log.Entry values directly.

* chore: update to Go 1.26, resolve slog LogValuer values
2026-03-01 22:48:12 -05:00
e725622696 docs: add example programs for common usage patterns (#19)
Adds four focused examples in examples/ directory:
- basic: drop-in logger with web UI
- namespaces: namespace-based logging by component
- client: programmatic log client with filtering
- log-levels: all log levels and filtering

Fixes #7
2026-02-22 21:21:31 -05:00
47bfb5ed98 fix: stderr namespace filter, fileInfo depth, panic guard, race safety
- fix(stderr): CreateClient() with no args so stderr sees all namespaces,
  not just 'default'. Previously namespaced logs were invisible on stderr.
- fix(logger): fileInfo depth offset to 2+FileInfoDepth so Logger methods
  report the actual caller, not the log package itself. Default depth (0)
  now correctly shows caller file:line.
- fix(panic): guard args[0] access with len(args) > 0 instead of >= 0.
  Panic/Panicf/Panicln would index out of range when called with no args.
- fix(createLog): nil/initialized check before channel send to prevent
  race with concurrent Destroy calls.
- chore: bump go.mod to 1.25.6
2026-02-17 08:31:21 +00:00
6a709d3963 feat(log): add colored terminal output without external packages (#16)
Adds ANSI color-coded log levels for terminal output:
- TRACE: Gray
- DEBUG: Cyan
- INFO: Green
- NOTICE: Blue
- WARN: Yellow
- ERROR/PANIC/FATAL: Red (bold for PANIC/FATAL)

Colors are auto-detected based on terminal capability and can be
controlled via SetColorEnabled()/ColorEnabled().

Fixes #9
2026-02-17 03:27:53 -05:00
0a78d1ce90 test: add comprehensive benchmark suite (#18)
Adds benchmarks for all log levels (Trace, Debug, Info, Warn, Error),
formatted logging, multiple client fan-out, parallel writes, namespace
filtering, and component-level benchmarks (fileInfo, entry creation).

Uses isolated namespaces to avoid interference with the stderr client.

Fixes #6
2026-02-17 03:25:12 -05:00
18 changed files with 1396 additions and 498 deletions

View File

@@ -1,23 +1,20 @@
name: Go package
on: [push]
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Install dependencies
run: go get .
go-version: "1.26"
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...
run: go test -race -v ./...

View File

@@ -239,7 +239,7 @@ Still embeds viewer.html, but HTML now includes:
## Go Version & Dependencies
- **Go version**: 1.24.4 (specified in go.mod)
- **Go version**: 1.26.1 (specified in go.mod)
- **Only external dependency**: `github.com/gorilla/websocket v1.5.3`
## Naming Conventions & Style
@@ -314,27 +314,17 @@ UI provides "Reconnect" button for this purpose.
### 6. Stderr Client Uses All Namespaces
The built-in stderr client (created in `init()`) listens to all namespaces:
```go
stderrClient = CreateClient(DefaultNamespace)
stderrClient = CreateClient() // No args = all namespaces
```
But only prints logs matching its own namespace in `logStdErr()`:
It prints logs matching its level and namespace filter in `logStdErr()`:
```go
if e.level >= c.LogLevel && c.matchesNamespace(e.Namespace) {
fmt.Fprintf(os.Stderr, "%s\t%s\t[%s]\t%s\t%s\n", ...)
}
```
**Wait, that's a bug!** The stderr client is created with `DefaultNamespace` but should be created with no namespaces to see all logs. Let me check this.
Actually looking at the code:
```go
stderrClient = CreateClient(DefaultNamespace)
```
This means stderr client only sees "default" namespace logs. This might be intentional, but seems like a bug. Should probably be:
```go
stderrClient = CreateClient() // No args = all namespaces
```
Since `CreateClient()` is called with no arguments, the Namespaces slice is empty, which means it matches all namespaces.
### 7. Grid Layout Updated
The log viewer grid changed from 4 to 5 columns:
@@ -374,8 +364,9 @@ All existing tests pass with namespace support added.
## CI/CD
GitHub Actions workflow (`.github/workflows/ci.yaml`):
- Still uses Go 1.21 (should update to 1.24.4 to match go.mod)
- No changes needed for v2 functionality
- Uses Go 1.26, actions/checkout@v4, actions/setup-go@v5
- Runs tests with `-race` flag
- Triggers on push and pull_request
## Common Tasks

View File

@@ -15,6 +15,7 @@ A real-time log viewer with WebSocket support and namespace filtering, written i
- **Namespace filtering**: Subscribe to specific namespaces via WebSocket
- **Frontend namespace selector**: Filter logs by namespace in the web UI
- **Namespace API**: GET `/api/namespaces` to list all active namespaces
- **Colored terminal output**: Log levels are color-coded in terminal (no external packages)
## Features
@@ -188,6 +189,40 @@ ws://localhost:8080/ws?namespaces=api,database # Multiple namespaces
- **Color Coding**: Different log levels are color-coded
- **Reconnect**: Reconnect WebSocket with new namespace filter
## Terminal Colors
Log output to stderr is automatically colorized when writing to a terminal. Colors are disabled when output is piped or redirected to a file.
### Color Scheme
| Level | Color |
|--------|--------------|
| TRACE | Gray |
| DEBUG | Cyan |
| INFO | Green |
| NOTICE | Blue |
| WARN | Yellow |
| ERROR | Red |
| PANIC | Bold Red |
| FATAL | Bold Red |
### Controlling Colors
```go
// Disable colors (e.g., for CI/CD or file output)
logger.SetColorEnabled(false)
// Enable colors explicitly
logger.SetColorEnabled(true)
// Check current state
if logger.ColorEnabled() {
// colors are on
}
```
Colors are implemented using standard ANSI escape codes with no external dependencies.
## Migration from v1
### Import Path

36
examples/basic/main.go Normal file
View File

@@ -0,0 +1,36 @@
// Example: basic usage of log-socket as a drop-in logger.
//
// This demonstrates using the package-level logging functions,
// which work similarly to the standard library's log package.
package main
import (
"fmt"
"net/http"
"github.com/taigrr/log-socket/v2/browser"
logger "github.com/taigrr/log-socket/v2/log"
"github.com/taigrr/log-socket/v2/ws"
)
func main() {
defer logger.Flush()
// Set the minimum log level (default is LTrace, showing everything)
logger.SetLogLevel(logger.LDebug)
// Package-level functions log to the "default" namespace
logger.Info("Application starting up")
logger.Debug("Debug mode enabled")
logger.Warnf("Config file not found at %s, using defaults", "/etc/app/config.yaml")
logger.Errorf("Failed to connect to database: %s", "connection refused")
// Print/Printf/Println are aliases for Info
logger.Println("This is equivalent to Infoln")
// Start the web UI so you can view logs at http://localhost:8080
http.HandleFunc("/ws", ws.LogSocketHandler)
http.HandleFunc("/", browser.LogSocketViewHandler)
fmt.Println("Log viewer available at http://localhost:8080")
logger.Fatal(http.ListenAndServe("0.0.0.0:8080", nil))
}

63
examples/client/main.go Normal file
View File

@@ -0,0 +1,63 @@
// Example: programmatic log client with namespace filtering.
//
// This shows how to create a Client that receives log entries
// programmatically, optionally filtered to specific namespaces.
// Useful for building custom log processors, alerting, or forwarding.
package main
import (
"fmt"
"time"
logger "github.com/taigrr/log-socket/v2/log"
)
func main() {
defer logger.Flush()
// Create a client that receives ALL log entries
allLogs := logger.CreateClient()
allLogs.SetLogLevel(logger.LInfo)
// Create a client that only receives "database" and "auth" logs
securityLogs := logger.CreateClient("database", "auth")
securityLogs.SetLogLevel(logger.LWarn) // Only warnings and above
dbLog := logger.NewLogger("database")
authLog := logger.NewLogger("auth")
apiLog := logger.NewLogger("api")
// Process all logs
go func() {
for {
entry := allLogs.Get()
fmt.Printf("[ALL] %s [%s] %s: %s\n",
entry.Timestamp.Format(time.TimeOnly),
entry.Namespace, entry.Level, entry.Output)
}
}()
// Process only security-relevant warnings/errors
go func() {
for {
entry := securityLogs.Get()
if entry.Level == "ERROR" || entry.Level == "WARN" {
fmt.Printf("🚨 SECURITY ALERT [%s] %s: %s\n",
entry.Namespace, entry.Level, entry.Output)
}
}
}()
// Generate some logs
for i := 0; i < 5; i++ {
apiLog.Info("API request processed")
dbLog.Info("Query executed successfully")
dbLog.Warn("Connection pool running low")
authLog.Error("Brute force attempt detected")
time.Sleep(1 * time.Second)
}
// Clean up clients when done
allLogs.Destroy()
securityLogs.Destroy()
}

View File

@@ -0,0 +1,48 @@
// Example: log level filtering and all available levels.
//
// log-socket supports 8 log levels from TRACE (most verbose)
// to FATAL (least verbose). Setting a log level filters out
// everything below it.
package main
import (
"fmt"
logger "github.com/taigrr/log-socket/v2/log"
)
func main() {
defer logger.Flush()
fmt.Println("=== All log levels (TRACE and above) ===")
logger.SetLogLevel(logger.LTrace)
logger.Trace("Detailed execution trace — variable x = 42")
logger.Debug("Processing request for user_id=123")
logger.Info("Server started on :8080")
logger.Notice("Configuration reloaded")
logger.Warn("Disk usage at 85%")
logger.Error("Failed to send email: SMTP timeout")
// logger.Panic("...") — would panic
// logger.Fatal("...") — would os.Exit(1)
fmt.Println("\n=== Formatted variants ===")
logger.Infof("Request took %dms", 42)
logger.Warnf("Retrying in %d seconds (attempt %d/%d)", 5, 2, 3)
logger.Errorf("HTTP %d: %s", 503, "Service Unavailable")
fmt.Println("\n=== Only WARN and above ===")
logger.SetLogLevel(logger.LWarn)
logger.Debug("This will NOT appear")
logger.Info("This will NOT appear either")
logger.Warn("This WILL appear")
logger.Error("This WILL appear too")
fmt.Println("\n=== Per-logger levels via namespaced loggers ===")
logger.SetLogLevel(logger.LTrace) // Reset global
appLog := logger.NewLogger("app")
appLog.Info("Namespaced loggers inherit the global output but tag entries")
appLog.Warnf("Something needs attention in the %s subsystem", "app")
}

View File

@@ -0,0 +1,73 @@
// Example: namespace-based logging for organizing logs by component.
//
// Namespaces let you tag log entries by subsystem (api, database, auth, etc.)
// and filter them in the web UI or via programmatic clients.
package main
import (
"fmt"
"math/rand"
"net/http"
"time"
"github.com/taigrr/log-socket/v2/browser"
logger "github.com/taigrr/log-socket/v2/log"
"github.com/taigrr/log-socket/v2/ws"
)
func main() {
defer logger.Flush()
// Create loggers for different subsystems
apiLog := logger.NewLogger("api")
dbLog := logger.NewLogger("database")
authLog := logger.NewLogger("auth")
cacheLog := logger.NewLogger("cache")
// Simulate application activity
go func() {
for {
apiLog.Infof("GET /api/users — 200 OK (%dms)", rand.Intn(200))
apiLog.Debugf("Request headers: Accept=application/json")
time.Sleep(1 * time.Second)
}
}()
go func() {
for {
dbLog.Infof("SELECT * FROM users — %d rows", rand.Intn(100))
if rand.Float64() < 0.3 {
dbLog.Warn("Slow query detected (>500ms)")
}
time.Sleep(2 * time.Second)
}
}()
go func() {
for {
if rand.Float64() < 0.7 {
authLog.Info("User login successful")
} else {
authLog.Error("Failed login attempt from 192.168.1.42")
}
time.Sleep(3 * time.Second)
}
}()
go func() {
for {
cacheLog.Tracef("Cache hit ratio: %.1f%%", rand.Float64()*100)
if rand.Float64() < 0.1 {
cacheLog.Warn("Cache eviction triggered")
}
time.Sleep(5 * time.Second)
}
}()
// The /api/namespaces endpoint lists all active namespaces
http.HandleFunc("/ws", ws.LogSocketHandler)
http.HandleFunc("/api/namespaces", ws.NamespacesHandler)
http.HandleFunc("/", browser.LogSocketViewHandler)
fmt.Println("Log viewer with namespace filtering at http://localhost:8080")
logger.Fatal(http.ListenAndServe("0.0.0.0:8080", nil))
}

2
go.mod
View File

@@ -1,5 +1,5 @@
module github.com/taigrr/log-socket/v2
go 1.25.4
go 1.26.1
require github.com/gorilla/websocket v1.5.3

171
log/bench_test.go Normal file
View File

@@ -0,0 +1,171 @@
package log
import (
"fmt"
"testing"
)
const benchNS = "bench-isolated-ns"
// benchClient creates a client with a continuous drain goroutine.
// Returns the client and a stop function to call after b.StopTimer().
func benchClient(ns string) (*Client, func()) {
c := CreateClient(ns)
c.SetLogLevel(LTrace)
done := make(chan struct{})
go func() {
for {
select {
case <-done:
return
case <-c.writer:
}
}
}()
return c, func() {
close(done)
c.Destroy()
}
}
// BenchmarkCreateClient measures client creation overhead.
func BenchmarkCreateClient(b *testing.B) {
for i := 0; i < b.N; i++ {
c := CreateClient("bench")
c.Destroy()
}
}
// BenchmarkTrace benchmarks Logger.Trace.
func BenchmarkTrace(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Trace("benchmark trace message")
}
}
// BenchmarkTracef benchmarks formatted Logger.Tracef.
func BenchmarkTracef(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Tracef("benchmark trace message %d", i)
}
}
// BenchmarkDebug benchmarks Logger.Debug.
func BenchmarkDebug(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Debug("benchmark debug message")
}
}
// BenchmarkInfo benchmarks Logger.Info.
func BenchmarkInfo(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Info("benchmark info message")
}
}
// BenchmarkInfof benchmarks Logger.Infof with formatting.
func BenchmarkInfof(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Infof("user %s performed action %d", "testuser", i)
}
}
// BenchmarkWarn benchmarks Logger.Warn.
func BenchmarkWarn(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Warn("benchmark warn message")
}
}
// BenchmarkError benchmarks Logger.Error.
func BenchmarkError(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Error("benchmark error message")
}
}
// BenchmarkMultipleClients measures fan-out to multiple consumers.
func BenchmarkMultipleClients(b *testing.B) {
const numClients = 5
l := NewLogger(benchNS)
stops := make([]func(), numClients)
for i := range stops {
_, stops[i] = benchClient(benchNS)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Info("benchmark multi-client")
}
b.StopTimer()
for _, stop := range stops {
stop()
}
}
// BenchmarkParallelInfo benchmarks concurrent Info calls from multiple goroutines.
func BenchmarkParallelInfo(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient(benchNS)
defer stop()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
l.Info("parallel benchmark info")
}
})
}
// BenchmarkNamespaceFiltering benchmarks logging when the consumer
// filters by a different namespace (messages are not delivered).
func BenchmarkNamespaceFiltering(b *testing.B) {
l := NewLogger(benchNS)
_, stop := benchClient("completely-different-ns")
defer stop()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Info("filtered out message")
}
}
// BenchmarkFileInfo measures the cost of runtime.Caller for file info.
func BenchmarkFileInfo(b *testing.B) {
for i := 0; i < b.N; i++ {
fileInfo(1)
}
}
// BenchmarkEntryCreation measures raw fmt.Sprint overhead (baseline).
func BenchmarkEntryCreation(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = fmt.Sprint("benchmark message ", i)
}
}

View File

@@ -1,389 +0,0 @@
package log
import (
"testing"
)
// drainClient continuously reads from a client to prevent blocking.
// Returns a function to stop draining and wait for completion.
func drainClient(c *Client) func() {
done := make(chan struct{})
go func() {
for {
select {
case <-done:
return
case <-c.writer:
// Drain entries
}
}
}()
return func() { close(done) }
}
// -----------------------------------------------------------------------------
// Serial Benchmarks - Single Log Levels
// -----------------------------------------------------------------------------
func BenchmarkTrace(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LTrace)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Trace("benchmark message")
}
}
func BenchmarkDebug(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LDebug)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Debug("benchmark message")
}
}
func BenchmarkInfo(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LInfo)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Info("benchmark message")
}
}
func BenchmarkNotice(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LNotice)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Notice("benchmark message")
}
}
func BenchmarkWarn(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LWarn)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Warn("benchmark message")
}
}
func BenchmarkError(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LError)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Error("benchmark message")
}
}
// -----------------------------------------------------------------------------
// Formatted Logging Benchmarks
// -----------------------------------------------------------------------------
func BenchmarkDebugf(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LDebug)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Debugf("benchmark message %d with %s", i, "formatting")
}
}
func BenchmarkInfof(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LInfo)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Infof("benchmark message %d with %s", i, "formatting")
}
}
func BenchmarkErrorf(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LError)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Errorf("benchmark message %d with %s", i, "formatting")
}
}
// -----------------------------------------------------------------------------
// Parallel Benchmarks
// -----------------------------------------------------------------------------
func BenchmarkDebugParallel(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LDebug)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Debug("parallel benchmark message")
}
})
}
func BenchmarkInfoParallel(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LInfo)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Info("parallel benchmark message")
}
})
}
func BenchmarkInfofParallel(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LInfo)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
counter := 0
for pb.Next() {
Infof("parallel benchmark message %d", counter)
counter++
}
})
}
// -----------------------------------------------------------------------------
// Logger Instance Benchmarks (Namespaced Logging)
// -----------------------------------------------------------------------------
func BenchmarkLoggerInfo(b *testing.B) {
logger := NewLogger("benchmark")
c := CreateClient("benchmark")
c.SetLogLevel(LInfo)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
for i := 0; i < b.N; i++ {
logger.Info("benchmark message")
}
}
func BenchmarkLoggerInfof(b *testing.B) {
logger := NewLogger("benchmark")
c := CreateClient("benchmark")
c.SetLogLevel(LInfo)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
for i := 0; i < b.N; i++ {
logger.Infof("benchmark message %d", i)
}
}
func BenchmarkLoggerDebugParallel(b *testing.B) {
logger := NewLogger("benchmark")
c := CreateClient("benchmark")
c.SetLogLevel(LDebug)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
logger.Debug("parallel benchmark message")
}
})
}
// -----------------------------------------------------------------------------
// Multiple Client Benchmarks
// -----------------------------------------------------------------------------
func BenchmarkMultipleClients(b *testing.B) {
const numClients = 5
var clients []*Client
var stopFuncs []func()
for i := 0; i < numClients; i++ {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LInfo)
stop := drainClient(c)
clients = append(clients, c)
stopFuncs = append(stopFuncs, stop)
}
defer func() {
for i, c := range clients {
stopFuncs[i]()
c.Destroy()
}
}()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Info("benchmark message to multiple clients")
}
}
func BenchmarkMultipleNamespaces(b *testing.B) {
namespaces := []string{"auth", "db", "api", "cache", "queue"}
var loggers []*Logger
var clients []*Client
var stopFuncs []func()
for _, ns := range namespaces {
loggers = append(loggers, NewLogger(ns))
c := CreateClient(ns)
c.SetLogLevel(LInfo)
stop := drainClient(c)
clients = append(clients, c)
stopFuncs = append(stopFuncs, stop)
}
defer func() {
for i, c := range clients {
stopFuncs[i]()
c.Destroy()
}
}()
b.ResetTimer()
for i := 0; i < b.N; i++ {
loggers[i%len(loggers)].Info("benchmark message")
}
}
// -----------------------------------------------------------------------------
// With Synchronous Client Consumption (Legacy Pattern)
// -----------------------------------------------------------------------------
// BenchmarkDebugWithSyncConsumer measures the overhead of synchronous consumption
// using the Get() method, processing one message at a time.
func BenchmarkDebugWithSyncConsumer(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LDebug)
stop := drainClient(c)
defer stop()
defer c.Destroy()
// This benchmark measures just the logging side with async draining.
// For sync consumption patterns, see the TestOrder test.
b.ResetTimer()
for i := 0; i < b.N; i++ {
Debug("benchmark message")
}
}
// -----------------------------------------------------------------------------
// Comparison Benchmarks (Different Message Sizes)
// -----------------------------------------------------------------------------
func BenchmarkInfoShortMessage(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LInfo)
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Info("ok")
}
}
func BenchmarkInfoMediumMessage(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LInfo)
stop := drainClient(c)
defer stop()
defer c.Destroy()
msg := "This is a medium-length log message for benchmarking purposes"
b.ResetTimer()
for i := 0; i < b.N; i++ {
Info(msg)
}
}
func BenchmarkInfoLongMessage(b *testing.B) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LInfo)
stop := drainClient(c)
defer stop()
defer c.Destroy()
msg := "This is a much longer log message that simulates real-world logging scenarios where developers tend to include more context about what the application is doing, including variable values, request IDs, and other debugging information that can be quite verbose"
b.ResetTimer()
for i := 0; i < b.N; i++ {
Info(msg)
}
}
// -----------------------------------------------------------------------------
// Overhead Benchmarks (Level Filtering)
// -----------------------------------------------------------------------------
func BenchmarkDebugFilteredByLevel(b *testing.B) {
// No client with Debug level, so Debug logs won't be consumed
// This measures the overhead of creating log entries that get filtered
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LError) // Only Error and above
stop := drainClient(c)
defer stop()
defer c.Destroy()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Debug("this message will be filtered")
}
}

99
log/color.go Normal file
View File

@@ -0,0 +1,99 @@
package log
import (
"os"
"sync"
)
// ANSI color codes for terminal output
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorBlue = "\033[34m"
colorPurple = "\033[35m"
colorCyan = "\033[36m"
colorWhite = "\033[37m"
colorGray = "\033[90m"
// Bold variants
colorBoldRed = "\033[1;31m"
colorBoldYellow = "\033[1;33m"
colorBoldWhite = "\033[1;37m"
)
var (
colorEnabled = true
colorEnabledOnce sync.Once
colorMux sync.RWMutex
)
// SetColorEnabled enables or disables colored output for stderr logging.
// By default, color is enabled when stderr is a terminal.
func SetColorEnabled(enabled bool) {
colorMux.Lock()
colorEnabled = enabled
colorMux.Unlock()
}
// ColorEnabled returns whether colored output is currently enabled.
func ColorEnabled() bool {
colorMux.RLock()
defer colorMux.RUnlock()
return colorEnabled
}
// isTerminal checks if the given file descriptor is a terminal.
// This is a simple heuristic that works on Unix-like systems.
func isTerminal(f *os.File) bool {
stat, err := f.Stat()
if err != nil {
return false
}
return (stat.Mode() & os.ModeCharDevice) != 0
}
// initColorEnabled sets the default color state based on whether stderr is a terminal.
func initColorEnabled() {
colorEnabledOnce.Do(func() {
colorEnabled = isTerminal(os.Stderr)
})
}
// levelColor returns the ANSI color code for a given log level.
func levelColor(level Level) string {
switch level {
case LTrace:
return colorGray
case LDebug:
return colorCyan
case LInfo:
return colorGreen
case LNotice:
return colorBlue
case LWarn:
return colorYellow
case LError:
return colorRed
case LPanic:
return colorBoldRed
case LFatal:
return colorBoldRed
default:
return colorReset
}
}
// colorize wraps text with ANSI color codes if color is enabled.
func colorize(text string, color string) string {
if !ColorEnabled() {
return text
}
return color + text + colorReset
}
// colorizeLevelText returns the level string with appropriate color.
func colorizeLevelText(level string, lvl Level) string {
return colorize(level, levelColor(lvl))
}

View File

@@ -22,7 +22,8 @@ var (
func init() {
namespaces = make(map[string]bool)
stderrClient = CreateClient(DefaultNamespace)
initColorEnabled()
stderrClient = CreateClient()
stderrClient.SetLogLevel(LTrace)
stderrFinished = make(chan bool, 1)
go stderrClient.logStdErr()
@@ -31,7 +32,10 @@ func init() {
func (c *Client) logStdErr() {
for e := range c.writer {
if e.level >= c.LogLevel && c.matchesNamespace(e.Namespace) {
fmt.Fprintf(os.Stderr, "%s\t%s\t[%s]\t%s\t%s\n", e.Timestamp.String(), e.Level, e.Namespace, e.Output, e.File)
levelStr := colorizeLevelText(e.Level, e.level)
nsStr := colorize("["+e.Namespace+"]", colorPurple)
fileStr := colorize(e.File, colorGray)
fmt.Fprintf(os.Stderr, "%s\t%s\t%s\t%s\t%s\n", e.Timestamp.String(), levelStr, nsStr, e.Output, fileStr)
}
}
stderrFinished <- true
@@ -70,19 +74,19 @@ func Flush() {
}
func (c *Client) Destroy() error {
var otherClients []*Client
if !c.initialized {
panic(errors.New("cannot delete uninitialized client, did you use CreateClient?"))
}
sliceTex.Lock()
c.writer = nil
c.initialized = false
var otherClients []*Client
for _, x := range clients {
if x.initialized {
if x != c && x.initialized {
otherClients = append(otherClients, x)
}
}
clients = otherClients
c.writer = nil
sliceTex.Unlock()
return nil
}
@@ -103,6 +107,9 @@ func createLog(e Entry) {
sliceTex.Lock()
for _, c := range clients {
func(c *Client, e Entry) {
if c.writer == nil || !c.initialized {
return
}
// Filter by namespace if client has filters specified
if !c.matchesNamespace(e.Namespace) {
return
@@ -417,7 +424,7 @@ func Panic(args ...any) {
Namespace: DefaultNamespace,
}
createLog(e)
if len(args) >= 0 {
if len(args) > 0 {
switch args[0].(type) {
case error:
panic(args[0])
@@ -441,7 +448,7 @@ func Panicf(format string, args ...any) {
Namespace: DefaultNamespace,
}
createLog(e)
if len(args) >= 0 {
if len(args) > 0 {
switch args[0].(type) {
case error:
panic(args[0])
@@ -464,7 +471,7 @@ func Panicln(args ...any) {
Namespace: DefaultNamespace,
}
createLog(e)
if len(args) >= 0 {
if len(args) > 0 {
switch args[0].(type) {
case error:
panic(args[0])
@@ -549,3 +556,37 @@ func fileInfo(skip int) string {
}
return fmt.Sprintf("%s:%d", file, line)
}
// Broadcast sends an [Entry] to all registered clients. This is the public
// entry point used by adapter packages (such as the slog handler) that
// construct entries themselves. The unexported level field is inferred from
// [Entry.Level] when not already set.
func Broadcast(e Entry) {
if e.level == 0 && e.Level != "" && e.Level != "TRACE" {
e.level = parseLevelString(e.Level)
}
createLog(e)
}
func parseLevelString(s string) Level {
switch s {
case "TRACE":
return LTrace
case "DEBUG":
return LDebug
case "INFO":
return LInfo
case "NOTICE":
return LNotice
case "WARN":
return LWarn
case "ERROR":
return LError
case "PANIC":
return LPanic
case "FATAL":
return LFatal
default:
return LInfo
}
}

View File

@@ -2,9 +2,23 @@ package log
import (
"strconv"
"sync"
"testing"
"time"
)
// getEntry reads from a client with a timeout to avoid hanging tests.
func getEntry(c *Client, timeout time.Duration) (Entry, bool) {
ch := make(chan Entry, 1)
go func() { ch <- c.Get() }()
select {
case e := <-ch:
return e, true
case <-time.After(timeout):
return Entry{}, false
}
}
// Test CreateClient() and Client.Destroy()
func TestCreateDestroy(t *testing.T) {
// Ensure only stderr exists at the beginning
@@ -36,6 +50,21 @@ func TestSetLogLevel(t *testing.T) {
c.Destroy()
}
func BenchmarkDebugSerial(b *testing.B) {
c := CreateClient("test")
var x sync.WaitGroup
x.Add(b.N)
for i := 0; i < b.N; i++ {
Debug(i)
go func() {
c.Get()
x.Done()
}()
}
x.Wait()
c.Destroy()
}
// Trace ensure logs come out in the right order
func TestOrder(t *testing.T) {
testString := "Testing trace: "
@@ -48,70 +77,477 @@ func TestOrder(t *testing.T) {
t.Error("Trace input doesn't match output")
}
}
c.Destroy()
}
// Debug prints out logs on debug level
func TestDebug(t *testing.T) {
Debug("Test of Debug")
// if logLevel >= LDebug {
// entry := logger.WithFields(logrus.Fields{})
// entry.Data["file"] = fileInfo(2)
// entry.Debug(args...)
// }
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LDebug)
Debug("debug message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out waiting for debug entry")
}
if e.Level != "DEBUG" {
t.Errorf("level = %q, want DEBUG", e.Level)
}
if e.Output != "debug message" {
t.Errorf("output = %q, want %q", e.Output, "debug message")
}
if e.Namespace != DefaultNamespace {
t.Errorf("namespace = %q, want %q", e.Namespace, DefaultNamespace)
}
c.Destroy()
}
func TestDebugf(t *testing.T) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LDebug)
Debugf("hello %s %d", "world", 42)
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "hello world 42" {
t.Errorf("output = %q, want %q", e.Output, "hello world 42")
}
c.Destroy()
}
// Info prints out logs on info level
func TestInfo(t *testing.T) {
// if logLevel >= LInfo {
// entry := logger.WithFields(logrus.Fields{})
// entry.Data["file"] = fileInfo(2)
// entry.Info(args...)
// }
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LInfo)
Info("info message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out waiting for info entry")
}
if e.Level != "INFO" {
t.Errorf("level = %q, want INFO", e.Level)
}
if e.Output != "info message" {
t.Errorf("output = %q, want %q", e.Output, "info message")
}
c.Destroy()
}
func TestInfof(t *testing.T) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LInfo)
Infof("count: %d", 99)
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "count: 99" {
t.Errorf("output = %q, want %q", e.Output, "count: 99")
}
c.Destroy()
}
// Print prints out logs on info level
func TestPrint(t *testing.T) {
// if logLevel >= LInfo {
// entry := logger.WithFields(logrus.Fields{})
// entry.Data["file"] = fileInfo(2)
// entry.Info(args...)
// }
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LInfo)
Print("print message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
// Print is an alias for Info
if e.Level != "INFO" {
t.Errorf("level = %q, want INFO", e.Level)
}
if e.Output != "print message" {
t.Errorf("output = %q, want %q", e.Output, "print message")
}
c.Destroy()
}
func TestPrintf(t *testing.T) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LInfo)
Printf("formatted %s", "print")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "formatted print" {
t.Errorf("output = %q, want %q", e.Output, "formatted print")
}
c.Destroy()
}
func TestNotice(t *testing.T) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LNotice)
Notice("notice message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Level != "NOTICE" {
t.Errorf("level = %q, want NOTICE", e.Level)
}
c.Destroy()
}
// Warn prints out logs on warn level
func TestWarn(t *testing.T) {
// if logLevel >= LWarn {
// entry := logger.WithFields(logrus.Fields{})
// entry.Data["file"] = fileInfo(2)
// entry.Warn(args...)
// }
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LWarn)
Warn("warning message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out waiting for warn entry")
}
if e.Level != "WARN" {
t.Errorf("level = %q, want WARN", e.Level)
}
if e.Output != "warning message" {
t.Errorf("output = %q, want %q", e.Output, "warning message")
}
c.Destroy()
}
func TestWarnf(t *testing.T) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LWarn)
Warnf("warn %d", 1)
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "warn 1" {
t.Errorf("output = %q, want %q", e.Output, "warn 1")
}
c.Destroy()
}
// Error prints out logs on error level
func TestError(t *testing.T) {
// if logLevel >= LError {
// entry := logger.WithFields(logrus.Fields{})
// entry.Data["file"] = fileInfo(2)
// entry.Error(args...)
// }
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LError)
Error("error message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out waiting for error entry")
}
if e.Level != "ERROR" {
t.Errorf("level = %q, want ERROR", e.Level)
}
if e.Output != "error message" {
t.Errorf("output = %q, want %q", e.Output, "error message")
}
c.Destroy()
}
// Fatal prints out logs on fatal level
func TestFatal(t *testing.T) {
// if logLevel >= LFatal {
// entry := logger.WithFields(logrus.Fields{})
// entry.Data["file"] = fileInfo(2)
// entry.Fatal(args...)
// }
func TestErrorf(t *testing.T) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LError)
Errorf("err: %s", "something broke")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "err: something broke" {
t.Errorf("output = %q, want %q", e.Output, "err: something broke")
}
c.Destroy()
}
// Panic prints out logs on panic level
func TestPanic(t *testing.T) {
// if logLevel >= LPanic {
// entry := logger.WithFields(logrus.Fields{})
// entry.Data["file"] = fileInfo(2)
// entry.Panic(args...)
// }
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LPanic)
defer func() {
r := recover()
if r == nil {
t.Error("expected panic, got nil")
}
c.Destroy()
}()
Panic("panic message")
}
func TestPanicf(t *testing.T) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LPanic)
defer func() {
r := recover()
if r == nil {
t.Error("expected panic, got nil")
}
c.Destroy()
}()
Panicf("panic %d", 42)
}
func TestPanicln(t *testing.T) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LPanic)
defer func() {
r := recover()
if r == nil {
t.Error("expected panic, got nil")
}
c.Destroy()
}()
Panicln("panic line")
}
// TestLogLevelFiltering verifies that the client's log level is stored correctly.
// Note: level filtering only applies to stderr output, not to client channels.
// All entries matching the namespace are delivered to the client channel regardless of level.
func TestLogLevelFiltering(t *testing.T) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LWarn)
if c.GetLogLevel() != LWarn {
t.Errorf("expected log level LWarn, got %d", c.GetLogLevel())
}
// Both entries arrive at the client channel (level filtering is stderr-only)
Info("info message")
Warn("warn message")
e1, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out waiting for first entry")
}
if e1.Output != "info message" {
t.Errorf("expected 'info message', got %q", e1.Output)
}
e2, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out waiting for second entry")
}
if e2.Output != "warn message" {
t.Errorf("expected 'warn message', got %q", e2.Output)
}
c.Destroy()
}
// TestNamespaceFiltering verifies clients only receive matching namespaces.
func TestNamespaceFiltering(t *testing.T) {
c := CreateClient("api")
c.SetLogLevel(LTrace)
apiLogger := NewLogger("api")
dbLogger := NewLogger("database")
// Log to database namespace — should not arrive at "api" client
dbLogger.Info("db message")
// Log to api namespace — should arrive
apiLogger.Info("api message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out waiting for api entry")
}
if e.Output != "api message" {
t.Errorf("expected 'api message', got %q", e.Output)
}
if e.Namespace != "api" {
t.Errorf("namespace = %q, want 'api'", e.Namespace)
}
c.Destroy()
}
// TestMultiNamespaceClient verifies a client subscribed to multiple namespaces.
func TestMultiNamespaceClient(t *testing.T) {
c := CreateClient("api", "auth")
c.SetLogLevel(LTrace)
apiLogger := NewLogger("api")
authLogger := NewLogger("auth")
dbLogger := NewLogger("database")
dbLogger.Info("db message") // filtered out
apiLogger.Info("api message") // should arrive
authLogger.Info("auth message") // should arrive
e1, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out waiting for first entry")
}
if e1.Output != "api message" {
t.Errorf("first entry = %q, want 'api message'", e1.Output)
}
e2, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out waiting for second entry")
}
if e2.Output != "auth message" {
t.Errorf("second entry = %q, want 'auth message'", e2.Output)
}
c.Destroy()
}
// TestGetNamespaces verifies the namespace registry.
func TestGetNamespaces(t *testing.T) {
l := NewLogger("test-ns-registry")
l.Info("register this namespace")
nss := GetNamespaces()
found := false
for _, ns := range nss {
if ns == "test-ns-registry" {
found = true
break
}
}
if !found {
t.Errorf("expected 'test-ns-registry' in GetNamespaces(), got %v", nss)
}
}
// TestLoggerDebugln verifies the Debugln method on Logger.
func TestLoggerDebugln(t *testing.T) {
c := CreateClient("debugln-test")
c.SetLogLevel(LDebug)
l := NewLogger("debugln-test")
l.Debugln("debugln message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Level != "DEBUG" {
t.Errorf("level = %q, want DEBUG", e.Level)
}
// Sprintln appends a newline
if e.Output != "debugln message\n" {
t.Errorf("output = %q, want %q", e.Output, "debugln message\n")
}
c.Destroy()
}
// TestNewLoggerEmptyNamespace verifies empty namespace defaults to DefaultNamespace.
func TestNewLoggerEmptyNamespace(t *testing.T) {
l := NewLogger("")
if l.Namespace != DefaultNamespace {
t.Errorf("namespace = %q, want %q", l.Namespace, DefaultNamespace)
}
}
// TestFileInfo verifies fileInfo returns a non-empty file:line string.
func TestFileInfo(t *testing.T) {
fi := fileInfo(1)
if fi == "" || fi == "<???>:1" {
t.Errorf("fileInfo returned unexpected value: %q", fi)
}
}
// TestColorize verifies color wrapping.
func TestColorize(t *testing.T) {
SetColorEnabled(true)
result := colorize("hello", colorRed)
expected := colorRed + "hello" + colorReset
if result != expected {
t.Errorf("colorize with color enabled: got %q, want %q", result, expected)
}
SetColorEnabled(false)
result = colorize("hello", colorRed)
if result != "hello" {
t.Errorf("colorize with color disabled: got %q, want %q", result, "hello")
}
// Restore default
SetColorEnabled(true)
}
// TestParseLevelString verifies level string parsing.
func TestParseLevelString(t *testing.T) {
tests := []struct {
input string
want Level
}{
{"TRACE", LTrace},
{"DEBUG", LDebug},
{"INFO", LInfo},
{"NOTICE", LNotice},
{"WARN", LWarn},
{"ERROR", LError},
{"PANIC", LPanic},
{"FATAL", LFatal},
{"UNKNOWN", LInfo}, // default
{"", LInfo}, // default
}
for _, tt := range tests {
got := parseLevelString(tt.input)
if got != tt.want {
t.Errorf("parseLevelString(%q) = %d, want %d", tt.input, got, tt.want)
}
}
}
// TestBroadcast verifies the public Broadcast function.
func TestBroadcast(t *testing.T) {
c := CreateClient("broadcast-ns")
c.SetLogLevel(LTrace)
e := Entry{
Timestamp: time.Now(),
Output: "broadcast test",
File: "test.go:1",
Level: "WARN",
Namespace: "broadcast-ns",
}
Broadcast(e)
got, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if got.Output != "broadcast test" {
t.Errorf("output = %q, want %q", got.Output, "broadcast test")
}
if got.Level != "WARN" {
t.Errorf("level = %q, want WARN", got.Level)
}
c.Destroy()
}
// TestMatchesNamespace verifies the namespace matching helper.
func TestMatchesNamespace(t *testing.T) {
// Client with no namespace filter matches everything
c := CreateClient()
if !c.matchesNamespace("anything") {
t.Error("empty Namespaces should match all")
}
c.Destroy()
// Client with specific namespaces
c2 := CreateClient("api", "auth")
if !c2.matchesNamespace("api") {
t.Error("should match 'api'")
}
if !c2.matchesNamespace("auth") {
t.Error("should match 'auth'")
}
if c2.matchesNamespace("database") {
t.Error("should not match 'database'")
}
c2.Destroy()
}
func TestFlush(t *testing.T) {

View File

@@ -28,7 +28,7 @@ func (l Logger) Trace(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "TRACE",
level: LTrace,
Namespace: l.Namespace,
@@ -42,7 +42,7 @@ func (l Logger) Tracef(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "TRACE",
level: LTrace,
Namespace: l.Namespace,
@@ -56,7 +56,7 @@ func (l Logger) Traceln(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "TRACE",
level: LTrace,
Namespace: l.Namespace,
@@ -70,7 +70,7 @@ func (l Logger) Debug(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "DEBUG",
level: LDebug,
Namespace: l.Namespace,
@@ -84,7 +84,21 @@ func (l Logger) Debugf(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "DEBUG",
level: LDebug,
Namespace: l.Namespace,
}
createLog(e)
}
// Debugln prints out logs on debug level with a newline
func (l Logger) Debugln(args ...any) {
output := fmt.Sprintln(args...)
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(2 + l.FileInfoDepth),
Level: "DEBUG",
level: LDebug,
Namespace: l.Namespace,
@@ -98,7 +112,7 @@ func (l Logger) Info(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "INFO",
level: LInfo,
Namespace: l.Namespace,
@@ -112,7 +126,7 @@ func (l Logger) Infof(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "INFO",
level: LInfo,
Namespace: l.Namespace,
@@ -126,7 +140,7 @@ func (l Logger) Infoln(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "INFO",
level: LInfo,
Namespace: l.Namespace,
@@ -140,7 +154,7 @@ func (l Logger) Notice(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "NOTICE",
level: LNotice,
Namespace: l.Namespace,
@@ -154,7 +168,7 @@ func (l Logger) Noticef(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "NOTICE",
level: LNotice,
Namespace: l.Namespace,
@@ -168,7 +182,7 @@ func (l Logger) Noticeln(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "NOTICE",
level: LNotice,
Namespace: l.Namespace,
@@ -182,7 +196,7 @@ func (l Logger) Warn(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "WARN",
level: LWarn,
Namespace: l.Namespace,
@@ -196,7 +210,7 @@ func (l Logger) Warnf(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "WARN",
level: LWarn,
Namespace: l.Namespace,
@@ -210,7 +224,7 @@ func (l Logger) Warnln(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "WARN",
level: LWarn,
Namespace: l.Namespace,
@@ -224,7 +238,7 @@ func (l Logger) Error(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "ERROR",
level: LError,
Namespace: l.Namespace,
@@ -238,7 +252,7 @@ func (l Logger) Errorf(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "ERROR",
level: LError,
Namespace: l.Namespace,
@@ -252,7 +266,7 @@ func (l Logger) Errorln(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "ERROR",
level: LError,
Namespace: l.Namespace,
@@ -266,13 +280,13 @@ func (l Logger) Panic(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "PANIC",
level: LPanic,
Namespace: l.Namespace,
}
createLog(e)
if len(args) >= 0 {
if len(args) > 0 {
switch args[0].(type) {
case error:
panic(args[0])
@@ -290,13 +304,13 @@ func (l Logger) Panicf(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "PANIC",
level: LPanic,
Namespace: l.Namespace,
}
createLog(e)
if len(args) >= 0 {
if len(args) > 0 {
switch args[0].(type) {
case error:
panic(args[0])
@@ -314,13 +328,13 @@ func (l Logger) Panicln(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "PANIC",
level: LPanic,
Namespace: l.Namespace,
}
createLog(e)
if len(args) >= 0 {
if len(args) > 0 {
switch args[0].(type) {
case error:
panic(args[0])
@@ -338,7 +352,7 @@ func (l Logger) Fatal(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "FATAL",
level: LFatal,
Namespace: l.Namespace,
@@ -354,7 +368,7 @@ func (l Logger) Fatalf(format string, args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "FATAL",
level: LFatal,
Namespace: l.Namespace,
@@ -370,7 +384,7 @@ func (l Logger) Fatalln(args ...any) {
e := Entry{
Timestamp: time.Now(),
Output: output,
File: fileInfo(l.FileInfoDepth),
File: fileInfo(2 + l.FileInfoDepth),
Level: "FATAL",
level: LFatal,
Namespace: l.Namespace,

165
slog/handler.go Normal file
View File

@@ -0,0 +1,165 @@
// Package slog provides a [log/slog.Handler] that routes structured log
// records into the log-socket broadcasting system, giving every slog-based
// caller free WebSocket streaming and the browser viewer UI.
package slog
import (
"context"
"fmt"
"log/slog"
"runtime"
"strings"
"github.com/taigrr/log-socket/v2/log"
)
// Handler implements [slog.Handler] by converting each [slog.Record] into a
// log-socket [log.Entry] and feeding it through the normal broadcast path.
//
// Attributes accumulated via [Handler.WithAttrs] are prepended to the log
// message as key=value pairs. Groups set via [Handler.WithGroup] prefix
// attribute keys with "group.".
type Handler struct {
namespace string
level slog.Level
attrs []slog.Attr
groups []string
}
// ensure interface compliance at compile time.
var _ slog.Handler = (*Handler)(nil)
// Option configures a [Handler].
type Option func(*Handler)
// WithNamespace sets the log-socket namespace for entries produced by this
// handler. If empty, [log.DefaultNamespace] is used.
func WithNamespace(ns string) Option {
return func(h *Handler) {
h.namespace = ns
}
}
// WithLevel sets the minimum slog level the handler will accept.
func WithLevel(l slog.Level) Option {
return func(h *Handler) {
h.level = l
}
}
// NewHandler returns a new [Handler] that writes to the log-socket broadcast
// system. Options may be used to set the namespace and minimum level.
func NewHandler(opts ...Option) *Handler {
h := &Handler{
namespace: log.DefaultNamespace,
level: slog.LevelDebug,
}
for _, o := range opts {
o(h)
}
return h
}
// Enabled reports whether the handler is configured to process records at
// the given level.
func (h *Handler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level
}
// Handle converts r into a log-socket Entry and broadcasts it.
func (h *Handler) Handle(_ context.Context, r slog.Record) error {
var b strings.Builder
b.WriteString(r.Message)
// Append pre-collected attrs.
for _, a := range h.attrs {
writeAttr(&b, h.groups, a)
}
// Append record-level attrs.
r.Attrs(func(a slog.Attr) bool {
writeAttr(&b, h.groups, a)
return true
})
file := "???"
if r.PC != 0 {
fs := runtime.CallersFrames([]uintptr{r.PC})
f, _ := fs.Next()
if f.File != "" {
short := f.File
if idx := strings.LastIndex(short, "/"); idx >= 0 {
short = short[idx+1:]
}
file = fmt.Sprintf("%s:%d", short, f.Line)
}
}
e := log.Entry{
Timestamp: r.Time,
Output: b.String(),
File: file,
Level: slogLevelToString(r.Level),
Namespace: h.namespace,
}
log.Broadcast(e)
return nil
}
// WithAttrs returns a new Handler with the given attributes appended.
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
h2 := h.clone()
h2.attrs = append(h2.attrs, attrs...)
return h2
}
// WithGroup returns a new Handler where subsequent attributes are nested
// under the given group name.
func (h *Handler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
h2 := h.clone()
h2.groups = append(h2.groups, name)
return h2
}
func (h *Handler) clone() *Handler {
h2 := &Handler{
namespace: h.namespace,
level: h.level,
attrs: make([]slog.Attr, len(h.attrs)),
groups: make([]string, len(h.groups)),
}
copy(h2.attrs, h.attrs)
copy(h2.groups, h.groups)
return h2
}
func writeAttr(b *strings.Builder, groups []string, a slog.Attr) {
a.Value = a.Value.Resolve()
if a.Equal(slog.Attr{}) {
return
}
b.WriteByte(' ')
for _, g := range groups {
b.WriteString(g)
b.WriteByte('.')
}
b.WriteString(a.Key)
b.WriteByte('=')
b.WriteString(a.Value.String())
}
func slogLevelToString(l slog.Level) string {
switch {
case l >= slog.LevelError:
return "ERROR"
case l >= slog.LevelWarn:
return "WARN"
case l >= slog.LevelInfo:
return "INFO"
default:
return "DEBUG"
}
}

118
slog/handler_test.go Normal file
View File

@@ -0,0 +1,118 @@
package slog
import (
"context"
"log/slog"
"testing"
"time"
"github.com/taigrr/log-socket/v2/log"
)
// getWithTimeout reads from a log client with a timeout to avoid hanging tests.
func getWithTimeout(c *log.Client, timeout time.Duration) (log.Entry, bool) {
ch := make(chan log.Entry, 1)
go func() { ch <- c.Get() }()
select {
case e := <-ch:
return e, true
case <-time.After(timeout):
return log.Entry{}, false
}
}
func TestHandler_Enabled(t *testing.T) {
h := NewHandler(WithLevel(slog.LevelWarn))
if h.Enabled(context.Background(), slog.LevelInfo) {
t.Error("expected Info to be disabled when min level is Warn")
}
if !h.Enabled(context.Background(), slog.LevelWarn) {
t.Error("expected Warn to be enabled")
}
if !h.Enabled(context.Background(), slog.LevelError) {
t.Error("expected Error to be enabled")
}
}
func TestHandler_Handle(t *testing.T) {
c := log.CreateClient()
defer c.Destroy()
c.SetLogLevel(log.LTrace)
h := NewHandler(WithNamespace("test-ns"))
logger := slog.New(h)
logger.Info("hello world", "key", "value")
e, ok := getWithTimeout(c, time.Second)
if !ok {
t.Fatal("timed out waiting for log entry")
}
if e.Namespace != "test-ns" {
t.Errorf("namespace = %q, want %q", e.Namespace, "test-ns")
}
if e.Level != "INFO" {
t.Errorf("level = %q, want INFO", e.Level)
}
if e.Output == "" {
t.Error("output should not be empty")
}
}
func TestHandler_WithAttrs(t *testing.T) {
c := log.CreateClient()
defer c.Destroy()
c.SetLogLevel(log.LTrace)
h := NewHandler()
h2 := h.WithAttrs([]slog.Attr{slog.String("service", "api")})
logger := slog.New(h2)
logger.Info("request")
e, ok := getWithTimeout(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "request service=api" {
t.Errorf("output = %q, want %q", e.Output, "request service=api")
}
}
func TestHandler_WithGroup(t *testing.T) {
c := log.CreateClient()
defer c.Destroy()
c.SetLogLevel(log.LTrace)
h := NewHandler()
h2 := h.WithGroup("http").WithAttrs([]slog.Attr{slog.Int("status", 200)})
logger := slog.New(h2)
logger.Info("done")
e, ok := getWithTimeout(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "done http.status=200" {
t.Errorf("output = %q, want %q", e.Output, "done http.status=200")
}
}
func TestSlogLevelMapping(t *testing.T) {
tests := []struct {
level slog.Level
want string
}{
{slog.LevelDebug, "DEBUG"},
{slog.LevelInfo, "INFO"},
{slog.LevelWarn, "WARN"},
{slog.LevelError, "ERROR"},
}
for _, tt := range tests {
got := slogLevelToString(tt.level)
if got != tt.want {
t.Errorf("slogLevelToString(%v) = %q, want %q", tt.level, got, tt.want)
}
}
}