1
0
mirror of https://github.com/taigrr/log-socket synced 2026-03-20 14:52:27 -07:00

1 Commits

Author SHA1 Message Date
0453539d29 feat(log): add colored terminal output without external packages
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-08 08:11:26 +00:00
7 changed files with 163 additions and 398 deletions

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

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,6 +22,7 @@ var (
func init() {
namespaces = make(map[string]bool)
initColorEnabled()
stderrClient = CreateClient(DefaultNamespace)
stderrClient.SetLogLevel(LTrace)
stderrFinished = make(chan bool, 1)
@@ -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
@@ -99,7 +103,7 @@ func createLog(e Entry) {
namespacesMux.Lock()
namespaces[e.Namespace] = true
namespacesMux.Unlock()
sliceTex.Lock()
for _, c := range clients {
func(c *Client, e Entry) {
@@ -126,7 +130,7 @@ func createLog(e Entry) {
func GetNamespaces() []string {
namespacesMux.RLock()
defer namespacesMux.RUnlock()
result := make([]string, 0, len(namespaces))
for ns := range namespaces {
result = append(result, ns)

View File

@@ -2,6 +2,7 @@ package log
import (
"strconv"
"sync"
"testing"
)
@@ -36,6 +37,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: "

10
main.go
View File

@@ -17,21 +17,21 @@ func generateLogs() {
apiLogger := logger.NewLogger("api")
dbLogger := logger.NewLogger("database")
authLogger := logger.NewLogger("auth")
for {
logger.Info("This is a default namespace log!")
apiLogger.Info("API request received")
apiLogger.Debug("Processing API call")
dbLogger.Info("Database query executed")
dbLogger.Warn("Slow query detected")
authLogger.Info("User authentication successful")
authLogger.Error("Failed login attempt detected")
logger.Trace("This is a trace log in default namespace!")
logger.Warn("This is a warning in default namespace!")
time.Sleep(2 * time.Second)
}
}

View File

@@ -23,7 +23,7 @@ func LogSocketHandler(w http.ResponseWriter, r *http.Request) {
if namespacesParam != "" {
namespaces = strings.Split(namespacesParam, ",")
}
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
logger.Error("upgrade:", err)