1
0
mirror of https://github.com/taigrr/log-socket synced 2026-04-11 19:31:34 -07:00

6 Commits

Author SHA1 Message Date
9384170eb0 feat(ws): add read pump for disconnect detection, GetContext, Level.String
- Add WebSocket read pump in LogSocketHandler so client disconnects are
  detected promptly instead of only on the next WriteMessage call
- Add Client.GetContext(ctx) for context-aware blocking reads
- Implement fmt.Stringer on Level type (Level.String())
- Add tests for GetContext, GetContext cancellation, and Level.String()
2026-04-07 10:08:50 +00:00
70ade62c8c test(ws,browser): add test suites for ws and browser packages (#22)
- Add ws/server_test.go: WebSocket connection, namespace filtering, non-upgrade rejection, SetUpgrader
- Add ws/namespaces_test.go: NamespacesHandler response format and content
- Add browser/browser_test.go: template rendering, ws URL construction, trailing slash handling
- Fix goimports whitespace in ws/server.go
2026-03-08 14:29:47 -04:00
2375c6ca90 test(log): add comprehensive Logger type method tests (#23)
Tests all Logger methods: Trace/f/ln, Debug/f/ln, Info/f/ln,
Notice/f/ln, Warn/f/ln, Error/f/ln, Panic/f/ln, Print/f/ln,
Default(), SetInfoDepth, and panic-with-error behavior.

Previously these methods had near-zero direct test coverage.
2026-03-08 14:28:57 -04:00
99882832c0 feat(log): add Logger.Debugln, expand test coverage, update Go/CI (#21)
- 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:38:31 -05: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
19 changed files with 1936 additions and 86 deletions

View File

@@ -1,23 +1,20 @@
name: Go package name: Go package
on: [push] on: [push, pull_request]
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v4 uses: actions/setup-go@v5
with: with:
go-version: "1.25" go-version: "1.26"
- name: Install dependencies
run: go get .
- name: Build - name: Build
run: go build -v ./... run: go build -v ./...
- name: Test - 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 & 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` - **Only external dependency**: `github.com/gorilla/websocket v1.5.3`
## Naming Conventions & Style ## Naming Conventions & Style
@@ -314,27 +314,17 @@ UI provides "Reconnect" button for this purpose.
### 6. Stderr Client Uses All Namespaces ### 6. Stderr Client Uses All Namespaces
The built-in stderr client (created in `init()`) listens to all namespaces: The built-in stderr client (created in `init()`) listens to all namespaces:
```go ```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 ```go
if e.level >= c.LogLevel && c.matchesNamespace(e.Namespace) { if e.level >= c.LogLevel && c.matchesNamespace(e.Namespace) {
fmt.Fprintf(os.Stderr, "%s\t%s\t[%s]\t%s\t%s\n", ...) 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. Since `CreateClient()` is called with no arguments, the Namespaces slice is empty, which means it matches all namespaces.
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
```
### 7. Grid Layout Updated ### 7. Grid Layout Updated
The log viewer grid changed from 4 to 5 columns: The log viewer grid changed from 4 to 5 columns:
@@ -374,8 +364,9 @@ All existing tests pass with namespace support added.
## CI/CD ## CI/CD
GitHub Actions workflow (`.github/workflows/ci.yaml`): GitHub Actions workflow (`.github/workflows/ci.yaml`):
- Still uses Go 1.21 (should update to 1.24.4 to match go.mod) - Uses Go 1.26, actions/checkout@v4, actions/setup-go@v5
- No changes needed for v2 functionality - Runs tests with `-race` flag
- Triggers on push and pull_request
## Common Tasks ## Common Tasks

53
browser/browser_test.go Normal file
View File

@@ -0,0 +1,53 @@
package browser
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestLogSocketViewHandler_HTTP(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://localhost:8080/", nil)
w := httptest.NewRecorder()
LogSocketViewHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
// html/template escapes forward slashes in JS context
if !strings.Contains(body, `ws:\/\/localhost:8080\/ws`) {
t.Error("response should contain escaped ws://localhost:8080/ws URL")
}
if !strings.Contains(body, "<!DOCTYPE html>") {
t.Error("response should contain HTML doctype")
}
}
func TestLogSocketViewHandler_CustomPath(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://myhost:9090/dashboard/", nil)
w := httptest.NewRecorder()
LogSocketViewHandler(w, req)
body := w.Body.String()
if !strings.Contains(body, `ws:\/\/myhost:9090\/dashboard\/ws`) {
t.Error("expected escaped ws://myhost:9090/dashboard/ws in body")
}
}
func TestLogSocketViewHandler_TrailingSlashTrimmed(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
w := httptest.NewRecorder()
LogSocketViewHandler(w, req)
body := w.Body.String()
// Should NOT have double slash before ws
if strings.Contains(body, `\/\/ws`) {
t.Error("should not have double slash before /ws")
}
if !strings.Contains(body, `ws:\/\/example.com\/ws`) {
t.Error("expected escaped ws://example.com/ws in body")
}
}

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 module github.com/taigrr/log-socket/v2
go 1.25.6 go 1.26.1
require github.com/gorilla/websocket v1.5.3 require github.com/gorilla/websocket v1.5.3

View File

@@ -1,6 +1,7 @@
package log package log
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -103,7 +104,7 @@ func createLog(e Entry) {
namespacesMux.Lock() namespacesMux.Lock()
namespaces[e.Namespace] = true namespaces[e.Namespace] = true
namespacesMux.Unlock() namespacesMux.Unlock()
sliceTex.Lock() sliceTex.Lock()
for _, c := range clients { for _, c := range clients {
func(c *Client, e Entry) { func(c *Client, e Entry) {
@@ -133,7 +134,7 @@ func createLog(e Entry) {
func GetNamespaces() []string { func GetNamespaces() []string {
namespacesMux.RLock() namespacesMux.RLock()
defer namespacesMux.RUnlock() defer namespacesMux.RUnlock()
result := make([]string, 0, len(namespaces)) result := make([]string, 0, len(namespaces))
for ns := range namespaces { for ns := range namespaces {
result = append(result, ns) result = append(result, ns)
@@ -153,6 +154,7 @@ func (c *Client) SetLogLevel(level Level) {
c.LogLevel = level c.LogLevel = level
} }
// Get blocks until a log entry is available and returns it.
func (c *Client) Get() Entry { func (c *Client) Get() Entry {
if !c.initialized { if !c.initialized {
panic(errors.New("cannot get logs for uninitialized client, did you use CreateClient?")) panic(errors.New("cannot get logs for uninitialized client, did you use CreateClient?"))
@@ -160,6 +162,21 @@ func (c *Client) Get() Entry {
return <-c.writer return <-c.writer
} }
// GetContext blocks until a log entry is available or ctx is cancelled.
// The second return value is false when the context was cancelled before
// an entry arrived.
func (c *Client) GetContext(ctx context.Context) (Entry, bool) {
if !c.initialized {
panic(errors.New("cannot get logs for uninitialized client, did you use CreateClient?"))
}
select {
case e := <-c.writer:
return e, true
case <-ctx.Done():
return Entry{}, false
}
}
// Trace prints out logs on trace level // Trace prints out logs on trace level
func Trace(args ...any) { func Trace(args ...any) {
output := fmt.Sprint(args...) output := fmt.Sprint(args...)
@@ -556,3 +573,37 @@ func fileInfo(skip int) string {
} }
return fmt.Sprintf("%s:%d", file, line) 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

@@ -1,11 +1,25 @@
package log package log
import ( import (
"context"
"strconv" "strconv"
"sync" "sync"
"testing" "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() // Test CreateClient() and Client.Destroy()
func TestCreateDestroy(t *testing.T) { func TestCreateDestroy(t *testing.T) {
// Ensure only stderr exists at the beginning // Ensure only stderr exists at the beginning
@@ -64,70 +78,539 @@ func TestOrder(t *testing.T) {
t.Error("Trace input doesn't match output") t.Error("Trace input doesn't match output")
} }
} }
c.Destroy()
} }
// Debug prints out logs on debug level
func TestDebug(t *testing.T) { func TestDebug(t *testing.T) {
Debug("Test of Debug") c := CreateClient(DefaultNamespace)
// if logLevel >= LDebug { c.SetLogLevel(LDebug)
// entry := logger.WithFields(logrus.Fields{})
// entry.Data["file"] = fileInfo(2) Debug("debug message")
// entry.Debug(args...) 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) { func TestInfo(t *testing.T) {
// if logLevel >= LInfo { c := CreateClient(DefaultNamespace)
// entry := logger.WithFields(logrus.Fields{}) c.SetLogLevel(LInfo)
// entry.Data["file"] = fileInfo(2)
// entry.Info(args...) 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) { func TestPrint(t *testing.T) {
// if logLevel >= LInfo { c := CreateClient(DefaultNamespace)
// entry := logger.WithFields(logrus.Fields{}) c.SetLogLevel(LInfo)
// entry.Data["file"] = fileInfo(2)
// entry.Info(args...) 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) { func TestWarn(t *testing.T) {
// if logLevel >= LWarn { c := CreateClient(DefaultNamespace)
// entry := logger.WithFields(logrus.Fields{}) c.SetLogLevel(LWarn)
// entry.Data["file"] = fileInfo(2)
// entry.Warn(args...) 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) { func TestError(t *testing.T) {
// if logLevel >= LError { c := CreateClient(DefaultNamespace)
// entry := logger.WithFields(logrus.Fields{}) c.SetLogLevel(LError)
// entry.Data["file"] = fileInfo(2)
// entry.Error(args...) 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 TestErrorf(t *testing.T) {
func TestFatal(t *testing.T) { c := CreateClient(DefaultNamespace)
// if logLevel >= LFatal { c.SetLogLevel(LError)
// entry := logger.WithFields(logrus.Fields{})
// entry.Data["file"] = fileInfo(2) Errorf("err: %s", "something broke")
// entry.Fatal(args...) 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) { func TestPanic(t *testing.T) {
// if logLevel >= LPanic { c := CreateClient(DefaultNamespace)
// entry := logger.WithFields(logrus.Fields{}) c.SetLogLevel(LPanic)
// entry.Data["file"] = fileInfo(2)
// entry.Panic(args...) 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()
}
// TestGetContext verifies context cancellation stops blocking Get.
func TestGetContext(t *testing.T) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LTrace)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
_, ok := c.GetContext(ctx)
if ok {
t.Error("expected GetContext to return false on cancelled context")
}
c.Destroy()
}
// TestGetContextReceivesEntry verifies GetContext delivers entries normally.
func TestGetContextReceivesEntry(t *testing.T) {
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LTrace)
go func() {
time.Sleep(10 * time.Millisecond)
Info("context entry")
}()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
e, ok := c.GetContext(ctx)
if !ok {
t.Fatal("expected GetContext to return entry")
}
if e.Output != "context entry" {
t.Errorf("output = %q, want %q", e.Output, "context entry")
}
c.Destroy()
}
// TestLevelString verifies the Level.String() method.
func TestLevelString(t *testing.T) {
tests := []struct {
level Level
want string
}{
{LTrace, "TRACE"},
{LDebug, "DEBUG"},
{LInfo, "INFO"},
{LNotice, "NOTICE"},
{LWarn, "WARN"},
{LError, "ERROR"},
{LPanic, "PANIC"},
{LFatal, "FATAL"},
{Level(99), "UNKNOWN"},
}
for _, tt := range tests {
got := tt.level.String()
if got != tt.want {
t.Errorf("Level(%d).String() = %q, want %q", tt.level, got, tt.want)
}
}
} }
func TestFlush(t *testing.T) { func TestFlush(t *testing.T) {

View File

@@ -92,6 +92,20 @@ func (l Logger) Debugf(format string, args ...any) {
createLog(e) 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,
}
createLog(e)
}
// Info prints out logs on info level // Info prints out logs on info level
func (l Logger) Info(args ...any) { func (l Logger) Info(args ...any) {
output := fmt.Sprint(args...) output := fmt.Sprint(args...)

518
log/logger_test.go Normal file
View File

@@ -0,0 +1,518 @@
package log
import (
"testing"
"time"
)
func TestLoggerTrace(t *testing.T) {
c := CreateClient("logger-trace")
c.SetLogLevel(LTrace)
l := NewLogger("logger-trace")
l.Trace("trace message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Level != "TRACE" {
t.Errorf("level = %q, want TRACE", e.Level)
}
if e.Output != "trace message" {
t.Errorf("output = %q, want %q", e.Output, "trace message")
}
if e.Namespace != "logger-trace" {
t.Errorf("namespace = %q, want %q", e.Namespace, "logger-trace")
}
c.Destroy()
}
func TestLoggerTracef(t *testing.T) {
c := CreateClient("logger-tracef")
c.SetLogLevel(LTrace)
l := NewLogger("logger-tracef")
l.Tracef("trace %s %d", "msg", 1)
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Level != "TRACE" {
t.Errorf("level = %q, want TRACE", e.Level)
}
if e.Output != "trace msg 1" {
t.Errorf("output = %q, want %q", e.Output, "trace msg 1")
}
c.Destroy()
}
func TestLoggerTraceln(t *testing.T) {
c := CreateClient("logger-traceln")
c.SetLogLevel(LTrace)
l := NewLogger("logger-traceln")
l.Traceln("trace line")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Level != "TRACE" {
t.Errorf("level = %q, want TRACE", e.Level)
}
if e.Output != "trace line\n" {
t.Errorf("output = %q, want %q", e.Output, "trace line\n")
}
c.Destroy()
}
func TestLoggerDebug(t *testing.T) {
c := CreateClient("logger-debug")
c.SetLogLevel(LDebug)
l := NewLogger("logger-debug")
l.Debug("debug 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)
}
if e.Output != "debug message" {
t.Errorf("output = %q, want %q", e.Output, "debug message")
}
c.Destroy()
}
func TestLoggerDebugf(t *testing.T) {
c := CreateClient("logger-debugf")
c.SetLogLevel(LDebug)
l := NewLogger("logger-debugf")
l.Debugf("debug %d", 42)
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "debug 42" {
t.Errorf("output = %q, want %q", e.Output, "debug 42")
}
c.Destroy()
}
func TestLoggerInfo(t *testing.T) {
c := CreateClient("logger-info")
c.SetLogLevel(LInfo)
l := NewLogger("logger-info")
l.Info("info message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
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 TestLoggerInfof(t *testing.T) {
c := CreateClient("logger-infof")
c.SetLogLevel(LInfo)
l := NewLogger("logger-infof")
l.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()
}
func TestLoggerInfoln(t *testing.T) {
c := CreateClient("logger-infoln")
c.SetLogLevel(LInfo)
l := NewLogger("logger-infoln")
l.Infoln("info line")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "info line\n" {
t.Errorf("output = %q, want %q", e.Output, "info line\n")
}
c.Destroy()
}
func TestLoggerNotice(t *testing.T) {
c := CreateClient("logger-notice")
c.SetLogLevel(LNotice)
l := NewLogger("logger-notice")
l.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)
}
if e.Output != "notice message" {
t.Errorf("output = %q, want %q", e.Output, "notice message")
}
c.Destroy()
}
func TestLoggerNoticef(t *testing.T) {
c := CreateClient("logger-noticef")
c.SetLogLevel(LNotice)
l := NewLogger("logger-noticef")
l.Noticef("notice %s", "formatted")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "notice formatted" {
t.Errorf("output = %q, want %q", e.Output, "notice formatted")
}
c.Destroy()
}
func TestLoggerNoticeln(t *testing.T) {
c := CreateClient("logger-noticeln")
c.SetLogLevel(LNotice)
l := NewLogger("logger-noticeln")
l.Noticeln("notice line")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "notice line\n" {
t.Errorf("output = %q, want %q", e.Output, "notice line\n")
}
c.Destroy()
}
func TestLoggerWarn(t *testing.T) {
c := CreateClient("logger-warn")
c.SetLogLevel(LWarn)
l := NewLogger("logger-warn")
l.Warn("warn message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Level != "WARN" {
t.Errorf("level = %q, want WARN", e.Level)
}
if e.Output != "warn message" {
t.Errorf("output = %q, want %q", e.Output, "warn message")
}
c.Destroy()
}
func TestLoggerWarnf(t *testing.T) {
c := CreateClient("logger-warnf")
c.SetLogLevel(LWarn)
l := NewLogger("logger-warnf")
l.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()
}
func TestLoggerWarnln(t *testing.T) {
c := CreateClient("logger-warnln")
c.SetLogLevel(LWarn)
l := NewLogger("logger-warnln")
l.Warnln("warn line")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "warn line\n" {
t.Errorf("output = %q, want %q", e.Output, "warn line\n")
}
c.Destroy()
}
func TestLoggerError(t *testing.T) {
c := CreateClient("logger-error")
c.SetLogLevel(LError)
l := NewLogger("logger-error")
l.Error("error message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
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()
}
func TestLoggerErrorf(t *testing.T) {
c := CreateClient("logger-errorf")
c.SetLogLevel(LError)
l := NewLogger("logger-errorf")
l.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()
}
func TestLoggerErrorln(t *testing.T) {
c := CreateClient("logger-errorln")
c.SetLogLevel(LError)
l := NewLogger("logger-errorln")
l.Errorln("error line")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "error line\n" {
t.Errorf("output = %q, want %q", e.Output, "error line\n")
}
c.Destroy()
}
func TestLoggerPanic(t *testing.T) {
c := CreateClient("logger-panic")
c.SetLogLevel(LPanic)
l := NewLogger("logger-panic")
defer func() {
r := recover()
if r == nil {
t.Error("expected panic, got nil")
}
// Verify the entry was broadcast
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out waiting for panic entry")
}
if e.Level != "PANIC" {
t.Errorf("level = %q, want PANIC", e.Level)
}
if e.Namespace != "logger-panic" {
t.Errorf("namespace = %q, want %q", e.Namespace, "logger-panic")
}
c.Destroy()
}()
l.Panic("panic message")
}
func TestLoggerPanicf(t *testing.T) {
c := CreateClient("logger-panicf")
c.SetLogLevel(LPanic)
l := NewLogger("logger-panicf")
defer func() {
r := recover()
if r == nil {
t.Error("expected panic, got nil")
}
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "panic 42" {
t.Errorf("output = %q, want %q", e.Output, "panic 42")
}
c.Destroy()
}()
l.Panicf("panic %d", 42)
}
func TestLoggerPanicln(t *testing.T) {
c := CreateClient("logger-panicln")
c.SetLogLevel(LPanic)
l := NewLogger("logger-panicln")
defer func() {
r := recover()
if r == nil {
t.Error("expected panic, got nil")
}
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "panic line\n" {
t.Errorf("output = %q, want %q", e.Output, "panic line\n")
}
c.Destroy()
}()
l.Panicln("panic line")
}
func TestLoggerPrint(t *testing.T) {
c := CreateClient("logger-print")
c.SetLogLevel(LInfo)
l := NewLogger("logger-print")
l.Print("print message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
// Print delegates to 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 TestLoggerPrintf(t *testing.T) {
c := CreateClient("logger-printf")
c.SetLogLevel(LInfo)
l := NewLogger("logger-printf")
l.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 TestLoggerPrintln(t *testing.T) {
c := CreateClient("logger-println")
c.SetLogLevel(LInfo)
l := NewLogger("logger-println")
l.Println("println message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "println message\n" {
t.Errorf("output = %q, want %q", e.Output, "println message\n")
}
c.Destroy()
}
func TestLoggerSetInfoDepth(t *testing.T) {
l := NewLogger("depth-test")
l.SetInfoDepth(3)
if l.FileInfoDepth != 3 {
t.Errorf("FileInfoDepth = %d, want 3", l.FileInfoDepth)
}
}
func TestDefaultLogger(t *testing.T) {
l := Default()
if l.Namespace != DefaultNamespace {
t.Errorf("namespace = %q, want %q", l.Namespace, DefaultNamespace)
}
if l.FileInfoDepth != 0 {
t.Errorf("FileInfoDepth = %d, want 0", l.FileInfoDepth)
}
// Verify Default() logger can emit entries
c := CreateClient(DefaultNamespace)
c.SetLogLevel(LInfo)
l.Info("default logger message")
e, ok := getEntry(c, time.Second)
if !ok {
t.Fatal("timed out")
}
if e.Output != "default logger message" {
t.Errorf("output = %q, want %q", e.Output, "default logger message")
}
c.Destroy()
}
func TestLoggerPanicWithError(t *testing.T) {
// When the first arg is an error, Panic should re-panic with that error
c := CreateClient("logger-panic-err")
c.SetLogLevel(LPanic)
l := NewLogger("logger-panic-err")
testErr := errTest("test error")
defer func() {
r := recover()
if r == nil {
t.Error("expected panic, got nil")
}
if err, ok := r.(errTest); ok {
if string(err) != "test error" {
t.Errorf("panic value = %q, want %q", string(err), "test error")
}
} else {
// The first arg was an error, so Panic should re-panic with it
t.Logf("panic type = %T, value = %v (implementation re-panics with original error)", r, r)
}
c.Destroy()
}()
l.Panic(testErr)
}
// errTest is a simple error type for testing.
type errTest string
func (e errTest) Error() string { return string(e) }

View File

@@ -15,6 +15,31 @@ const (
const DefaultNamespace = "default" const DefaultNamespace = "default"
// String returns the human-readable name of the log level (e.g. "INFO").
// It implements [fmt.Stringer].
func (l Level) String() string {
switch l {
case LTrace:
return "TRACE"
case LDebug:
return "DEBUG"
case LInfo:
return "INFO"
case LNotice:
return "NOTICE"
case LWarn:
return "WARN"
case LError:
return "ERROR"
case LPanic:
return "PANIC"
case LFatal:
return "FATAL"
default:
return "UNKNOWN"
}
}
type ( type (
LogWriter chan Entry LogWriter chan Entry
Level int Level int

10
main.go
View File

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

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)
}
}
}

62
ws/namespaces_test.go Normal file
View File

@@ -0,0 +1,62 @@
package ws
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
logger "github.com/taigrr/log-socket/v2/log"
)
func TestNamespacesHandler(t *testing.T) {
// Log to a known namespace to ensure it appears
nsLogger := logger.NewLogger("ns-handler-test")
nsLogger.Info("register namespace")
req := httptest.NewRequest(http.MethodGet, "/api/namespaces", nil)
w := httptest.NewRecorder()
NamespacesHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
ct := w.Header().Get("Content-Type")
if ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var result struct {
Namespaces []string `json:"namespaces"`
}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
found := false
for _, ns := range result.Namespaces {
if ns == "ns-handler-test" {
found = true
break
}
}
if !found {
t.Errorf("namespace 'ns-handler-test' not found in %v", result.Namespaces)
}
}
func TestNamespacesHandler_ResponseFormat(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/namespaces", nil)
w := httptest.NewRecorder()
NamespacesHandler(w, req)
var result map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if _, ok := result["namespaces"]; !ok {
t.Error("response missing 'namespaces' key")
}
}

View File

@@ -1,6 +1,7 @@
package ws package ws
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
@@ -11,36 +12,61 @@ import (
var upgrader = websocket.Upgrader{} // use default options var upgrader = websocket.Upgrader{} // use default options
// SetUpgrader replaces the default [websocket.Upgrader] used by
// [LogSocketHandler].
func SetUpgrader(u websocket.Upgrader) { func SetUpgrader(u websocket.Upgrader) {
upgrader = u upgrader = u
} }
// LogSocketHandler upgrades the HTTP connection to a WebSocket and streams
// log entries to the client. An optional "namespaces" query parameter
// (comma-separated) filters which namespaces the client receives.
func LogSocketHandler(w http.ResponseWriter, r *http.Request) { func LogSocketHandler(w http.ResponseWriter, r *http.Request) {
// Get namespaces from query parameter, comma-separated // Get namespaces from query parameter, comma-separated.
// Empty or missing means all namespaces // Empty or missing means all namespaces.
namespacesParam := r.URL.Query().Get("namespaces") namespacesParam := r.URL.Query().Get("namespaces")
var namespaces []string var namespaces []string
if namespacesParam != "" { if namespacesParam != "" {
namespaces = strings.Split(namespacesParam, ",") namespaces = strings.Split(namespacesParam, ",")
} }
c, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
logger.Error("upgrade:", err) logger.Error("upgrade:", err)
return return
} }
defer c.Close() defer conn.Close()
lc := logger.CreateClient(namespaces...) lc := logger.CreateClient(namespaces...)
defer lc.Destroy() defer lc.Destroy()
lc.SetLogLevel(logger.LTrace) lc.SetLogLevel(logger.LTrace)
logger.Info("Websocket client attached.") logger.Info("Websocket client attached.")
// Start a read pump so the server detects client disconnects promptly.
// Without this, a disconnected client is only noticed when WriteMessage
// fails, which can be delayed indefinitely when no logs are produced.
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go func() {
defer cancel()
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
for { for {
logEvent := lc.Get() entry, ok := lc.GetContext(ctx)
logJSON, _ := json.Marshal(logEvent) if !ok {
err = c.WriteMessage(websocket.TextMessage, logJSON) // Context cancelled — client disconnected.
if err != nil { return
}
logJSON, _ := json.Marshal(entry)
if err := conn.WriteMessage(websocket.TextMessage, logJSON); err != nil {
logger.Warn("write:", err) logger.Warn("write:", err)
break return
} }
} }
} }

127
ws/server_test.go Normal file
View File

@@ -0,0 +1,127 @@
package ws
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
logger "github.com/taigrr/log-socket/v2/log"
)
func TestSetUpgrader(t *testing.T) {
custom := websocket.Upgrader{
ReadBufferSize: 2048,
WriteBufferSize: 2048,
}
SetUpgrader(custom)
if upgrader.ReadBufferSize != 2048 {
t.Errorf("ReadBufferSize = %d, want 2048", upgrader.ReadBufferSize)
}
if upgrader.WriteBufferSize != 2048 {
t.Errorf("WriteBufferSize = %d, want 2048", upgrader.WriteBufferSize)
}
// Reset to default
SetUpgrader(websocket.Upgrader{})
}
func TestLogSocketHandler_NonWebSocket(t *testing.T) {
// A non-upgrade request should fail gracefully (upgrader returns error)
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
w := httptest.NewRecorder()
LogSocketHandler(w, req)
// The upgrader should return a 400-level error for non-websocket requests
if w.Code == http.StatusOK || w.Code == http.StatusSwitchingProtocols {
t.Errorf("expected error status for non-websocket request, got %d", w.Code)
}
}
func TestLogSocketHandler_WebSocket(t *testing.T) {
// Set upgrader with permissive origin check for testing
SetUpgrader(websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
})
defer SetUpgrader(websocket.Upgrader{})
server := httptest.NewServer(http.HandlerFunc(LogSocketHandler))
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("failed to connect: %v", err)
}
defer conn.Close()
// Send a log entry and verify it arrives over the websocket
testLogger := logger.NewLogger("ws-test")
testLogger.Info("test message for websocket")
// Read messages until we find our test entry (the handler itself
// logs "Websocket client attached." which may arrive first)
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
var found bool
for i := 0; i < 10; i++ {
_, message, err := conn.ReadMessage()
if err != nil {
t.Fatalf("failed to read message: %v", err)
}
var entry logger.Entry
if err := json.Unmarshal(message, &entry); err != nil {
t.Fatalf("failed to unmarshal entry: %v", err)
}
if entry.Namespace == "ws-test" && entry.Level == "INFO" {
found = true
break
}
}
if !found {
t.Error("did not receive expected log entry with namespace ws-test")
}
}
func TestLogSocketHandler_NamespaceFilter(t *testing.T) {
SetUpgrader(websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
})
defer SetUpgrader(websocket.Upgrader{})
server := httptest.NewServer(http.HandlerFunc(LogSocketHandler))
defer server.Close()
// Connect with namespace filter
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws?namespaces=filtered-ns"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("failed to connect: %v", err)
}
defer conn.Close()
// Send a log to a different namespace — it should NOT be received
otherLogger := logger.NewLogger("other-ns")
otherLogger.Info("should not arrive")
// Send a log to the filtered namespace — it SHOULD be received
filteredLogger := logger.NewLogger("filtered-ns")
filteredLogger.Info("should arrive")
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, message, err := conn.ReadMessage()
if err != nil {
t.Fatalf("failed to read message: %v", err)
}
var entry logger.Entry
if err := json.Unmarshal(message, &entry); err != nil {
t.Fatalf("failed to unmarshal entry: %v", err)
}
if entry.Namespace != "filtered-ns" {
t.Errorf("namespace = %q, want filtered-ns", entry.Namespace)
}
if !strings.Contains(entry.Output, "should arrive") {
t.Errorf("output = %q, want to contain 'should arrive'", entry.Output)
}
}