mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-12 07:41:42 +00:00
@@ -0,0 +1,70 @@
|
||||
package logger_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
)
|
||||
|
||||
// This test demonstrates the logger output formats
|
||||
func TestLoggerDemo(t *testing.T) {
|
||||
t.Skip("Demo only - run manually with: go test -v -run TestLoggerDemo")
|
||||
|
||||
fmt.Println("\n=== TEXT FORMAT (DEFAULT) ===")
|
||||
textBuf := &bytes.Buffer{}
|
||||
textLogger := logger.New(logger.LevelInfo, logger.FormatText, textBuf)
|
||||
|
||||
textLogger.Info("Port forward started", map[string]interface{}{
|
||||
"forward_id": "prod/default/pod/app:8080",
|
||||
"local_port": 8080,
|
||||
"pod": "app-xyz123",
|
||||
})
|
||||
|
||||
textLogger.Warn("Connection failed, retrying", map[string]interface{}{
|
||||
"forward_id": "prod/default/pod/app:8080",
|
||||
"error": "connection refused",
|
||||
"retry": 3,
|
||||
})
|
||||
|
||||
textLogger.Error("Failed to resolve resource", map[string]interface{}{
|
||||
"forward_id": "prod/default/pod/app:8080",
|
||||
"error": "pod not found",
|
||||
})
|
||||
|
||||
fmt.Print(textBuf.String())
|
||||
|
||||
fmt.Println("\n=== JSON FORMAT ===")
|
||||
jsonBuf := &bytes.Buffer{}
|
||||
jsonLogger := logger.New(logger.LevelInfo, logger.FormatJSON, jsonBuf)
|
||||
|
||||
jsonLogger.Info("Port forward started", map[string]interface{}{
|
||||
"forward_id": "prod/default/pod/app:8080",
|
||||
"local_port": 8080,
|
||||
"pod": "app-xyz123",
|
||||
})
|
||||
|
||||
jsonLogger.Warn("Connection failed, retrying", map[string]interface{}{
|
||||
"forward_id": "prod/default/pod/app:8080",
|
||||
"error": "connection refused",
|
||||
"retry": 3,
|
||||
})
|
||||
|
||||
jsonLogger.Error("Failed to resolve resource", map[string]interface{}{
|
||||
"forward_id": "prod/default/pod/app:8080",
|
||||
"error": "pod not found",
|
||||
})
|
||||
|
||||
fmt.Print(jsonBuf.String())
|
||||
|
||||
fmt.Println("\n=== LOG LEVEL FILTERING (Debug level disabled) ===")
|
||||
filteredBuf := &bytes.Buffer{}
|
||||
filteredLogger := logger.New(logger.LevelInfo, logger.FormatText, filteredBuf)
|
||||
|
||||
filteredLogger.Debug("This will not appear", nil)
|
||||
filteredLogger.Info("This will appear", nil)
|
||||
filteredLogger.Warn("This will also appear", nil)
|
||||
|
||||
fmt.Print(filteredBuf.String())
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// KlogWriter is an io.Writer that routes klog output through our structured logger.
|
||||
// It parses klog messages and routes them to appropriate log levels.
|
||||
// It is thread-safe for concurrent writes.
|
||||
type KlogWriter struct {
|
||||
logger *Logger
|
||||
buffer *bytes.Buffer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewKlogWriter creates a new KlogWriter that routes k8s client-go logs
|
||||
// through our structured logger.
|
||||
func NewKlogWriter(logger *Logger) *KlogWriter {
|
||||
return &KlogWriter{
|
||||
logger: logger,
|
||||
buffer: &bytes.Buffer{},
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements io.Writer.
|
||||
// It parses klog output and routes it through our structured logger.
|
||||
// This method is thread-safe.
|
||||
func (w *KlogWriter) Write(p []byte) (n int, err error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
// Write to buffer first
|
||||
w.buffer.Write(p)
|
||||
|
||||
// Process complete lines
|
||||
for {
|
||||
line, err := w.buffer.ReadString('\n')
|
||||
if err != nil {
|
||||
// No complete line yet, write back what we read and wait for more
|
||||
if err == io.EOF && line != "" {
|
||||
w.buffer.WriteString(line)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Process the complete line
|
||||
w.processLine(strings.TrimSpace(line))
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// processLine parses a klog line and routes it to the appropriate log level.
|
||||
func (w *KlogWriter) processLine(line string) {
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse klog format: "I1124 12:34:56.789012 12345 file.go:123] message"
|
||||
// First character indicates level: I=Info, W=Warning, E=Error, F=Fatal
|
||||
if len(line) < 1 {
|
||||
return
|
||||
}
|
||||
|
||||
level := line[0]
|
||||
message := line
|
||||
|
||||
// Try to extract just the message part after "]"
|
||||
if idx := strings.Index(line, "] "); idx != -1 {
|
||||
message = line[idx+2:]
|
||||
}
|
||||
|
||||
// Determine log level and route accordingly
|
||||
switch level {
|
||||
case 'I': // Info
|
||||
w.logger.Debug(message, map[string]interface{}{
|
||||
"source": "k8s-client",
|
||||
})
|
||||
case 'W': // Warning
|
||||
w.logger.Warn(message, map[string]interface{}{
|
||||
"source": "k8s-client",
|
||||
})
|
||||
case 'E', 'F': // Error or Fatal
|
||||
w.logger.Error(message, map[string]interface{}{
|
||||
"source": "k8s-client",
|
||||
})
|
||||
default:
|
||||
// Unknown format, log as debug
|
||||
w.logger.Debug(message, map[string]interface{}{
|
||||
"source": "k8s-client",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKlogWriter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectedLevel string
|
||||
expectedMsg string
|
||||
loggerLevel Level
|
||||
loggerFormat Format
|
||||
shouldLog bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "info level log",
|
||||
input: "I1124 12:34:56.789012 12345 portforward.go:123] Starting port forward\n",
|
||||
expectedLevel: "DEBUG",
|
||||
expectedMsg: "Starting port forward",
|
||||
loggerLevel: LevelDebug,
|
||||
loggerFormat: FormatText,
|
||||
shouldLog: true,
|
||||
description: "Info logs from k8s should be routed as DEBUG",
|
||||
},
|
||||
{
|
||||
name: "warning level log",
|
||||
input: "W1124 12:34:56.789012 12345 portforward.go:456] Connection unstable\n",
|
||||
expectedLevel: "WARN",
|
||||
expectedMsg: "Connection unstable",
|
||||
loggerLevel: LevelDebug,
|
||||
loggerFormat: FormatText,
|
||||
shouldLog: true,
|
||||
description: "Warning logs should be routed as WARN",
|
||||
},
|
||||
{
|
||||
name: "error level log",
|
||||
input: "E1124 12:34:56.789012 12345 portforward.go:789] Connection failed\n",
|
||||
expectedLevel: "ERROR",
|
||||
expectedMsg: "Connection failed",
|
||||
loggerLevel: LevelDebug,
|
||||
loggerFormat: FormatText,
|
||||
shouldLog: true,
|
||||
description: "Error logs should be routed as ERROR",
|
||||
},
|
||||
{
|
||||
name: "fatal level log",
|
||||
input: "F1124 12:34:56.789012 12345 portforward.go:999] Fatal error\n",
|
||||
expectedLevel: "ERROR",
|
||||
expectedMsg: "Fatal error",
|
||||
loggerLevel: LevelDebug,
|
||||
loggerFormat: FormatText,
|
||||
shouldLog: true,
|
||||
description: "Fatal logs should be routed as ERROR",
|
||||
},
|
||||
{
|
||||
name: "multiline input",
|
||||
input: "I1124 12:34:56.789012 12345 portforward.go:123] First message\nI1124 12:34:57.123456 12345 portforward.go:124] Second message\n",
|
||||
expectedLevel: "DEBUG",
|
||||
expectedMsg: "First message",
|
||||
loggerLevel: LevelDebug,
|
||||
loggerFormat: FormatText,
|
||||
shouldLog: true,
|
||||
description: "Should handle multiple log lines",
|
||||
},
|
||||
{
|
||||
name: "log filtered by level",
|
||||
input: "I1124 12:34:56.789012 12345 portforward.go:123] Debug message\n",
|
||||
expectedLevel: "DEBUG",
|
||||
expectedMsg: "Debug message",
|
||||
loggerLevel: LevelInfo, // Logger set to INFO, DEBUG should be filtered
|
||||
loggerFormat: FormatText,
|
||||
shouldLog: false,
|
||||
description: "DEBUG logs should be filtered when logger level is INFO",
|
||||
},
|
||||
{
|
||||
name: "unknown log format",
|
||||
input: "X1124 12:34:56.789012 12345 portforward.go:123] Unknown format\n",
|
||||
expectedLevel: "DEBUG",
|
||||
expectedMsg: "Unknown format",
|
||||
loggerLevel: LevelDebug,
|
||||
loggerFormat: FormatText,
|
||||
shouldLog: true,
|
||||
description: "Unknown format should default to DEBUG",
|
||||
},
|
||||
{
|
||||
name: "empty line",
|
||||
input: "\n",
|
||||
expectedLevel: "",
|
||||
expectedMsg: "",
|
||||
loggerLevel: LevelDebug,
|
||||
loggerFormat: FormatText,
|
||||
shouldLog: false,
|
||||
description: "Empty lines should be ignored",
|
||||
},
|
||||
{
|
||||
name: "partial line no newline",
|
||||
input: "I1124 12:34:56.789012 12345 portforward.go:123] Partial",
|
||||
expectedLevel: "",
|
||||
expectedMsg: "",
|
||||
loggerLevel: LevelDebug,
|
||||
loggerFormat: FormatText,
|
||||
shouldLog: false,
|
||||
description: "Partial lines without newline should be buffered",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create output buffer
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Create logger with specified level and format
|
||||
logger := New(tt.loggerLevel, tt.loggerFormat, &buf)
|
||||
|
||||
// Create klog writer
|
||||
klogWriter := NewKlogWriter(logger)
|
||||
|
||||
// Write input
|
||||
n, err := klogWriter.Write([]byte(tt.input))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(tt.input), n)
|
||||
|
||||
// Check output
|
||||
output := buf.String()
|
||||
|
||||
if !tt.shouldLog {
|
||||
assert.Empty(t, output, "Expected no log output")
|
||||
return
|
||||
}
|
||||
|
||||
if tt.loggerFormat == FormatText {
|
||||
// Text format: [LEVEL] message
|
||||
assert.Contains(t, output, fmt.Sprintf("[%s]", tt.expectedLevel))
|
||||
assert.Contains(t, output, tt.expectedMsg)
|
||||
assert.Contains(t, output, "k8s-client") // Should include source field
|
||||
} else {
|
||||
// JSON format
|
||||
var entry logEntry
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
if len(lines) > 0 {
|
||||
err := json.Unmarshal([]byte(lines[0]), &entry)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedLevel, entry.Level)
|
||||
assert.Equal(t, tt.expectedMsg, entry.Message)
|
||||
assert.Equal(t, "k8s-client", entry.Fields["source"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKlogWriterBuffering(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
writes []string
|
||||
expectCount int
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "single complete line",
|
||||
writes: []string{
|
||||
"I1124 12:34:56.789012 12345 portforward.go:123] Complete line\n",
|
||||
},
|
||||
expectCount: 1,
|
||||
description: "Single complete line should produce one log entry",
|
||||
},
|
||||
{
|
||||
name: "partial then complete",
|
||||
writes: []string{
|
||||
"I1124 12:34:56.789012 12345 portforward.go:123] Partial ",
|
||||
"line\n",
|
||||
},
|
||||
expectCount: 1,
|
||||
description: "Partial writes should be buffered and combined",
|
||||
},
|
||||
{
|
||||
name: "multiple complete lines in chunks",
|
||||
writes: []string{
|
||||
"I1124 12:34:56.789012 12345 portforward.go:123] First\n",
|
||||
"I1124 12:34:57.123456 12345 portforward.go:124] Second\n",
|
||||
"I1124 12:34:58.456789 12345 portforward.go:125] Third\n",
|
||||
},
|
||||
expectCount: 3,
|
||||
description: "Multiple complete lines should produce multiple log entries",
|
||||
},
|
||||
{
|
||||
name: "mixed partial and complete",
|
||||
writes: []string{
|
||||
"I1124 12:34:56.789012 12345 portforward.go:123] First\nI1124 12:34:57.123456 12345 port",
|
||||
"forward.go:124] Second\n",
|
||||
},
|
||||
expectCount: 2,
|
||||
description: "Mixed partial and complete lines should be handled correctly",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := New(LevelDebug, FormatText, &buf)
|
||||
klogWriter := NewKlogWriter(logger)
|
||||
|
||||
// Write all chunks
|
||||
for _, write := range tt.writes {
|
||||
_, err := klogWriter.Write([]byte(write))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Count log entries (each line starts with [LEVEL])
|
||||
output := buf.String()
|
||||
count := strings.Count(output, "[DEBUG]") +
|
||||
strings.Count(output, "[INFO]") +
|
||||
strings.Count(output, "[WARN]") +
|
||||
strings.Count(output, "[ERROR]")
|
||||
|
||||
assert.Equal(t, tt.expectCount, count, "Expected %d log entries, got %d", tt.expectCount, count)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKlogWriterJSONFormat(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := New(LevelDebug, FormatJSON, &buf)
|
||||
klogWriter := NewKlogWriter(logger)
|
||||
|
||||
// Write a k8s log line
|
||||
input := "I1124 12:34:56.789012 12345 portforward.go:123] Starting port forward\n"
|
||||
_, err := klogWriter.Write([]byte(input))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse JSON output
|
||||
var entry logEntry
|
||||
err = json.Unmarshal(buf.Bytes(), &entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify JSON structure
|
||||
assert.Equal(t, "DEBUG", entry.Level)
|
||||
assert.Equal(t, "Starting port forward", entry.Message)
|
||||
assert.NotEmpty(t, entry.Time)
|
||||
assert.Equal(t, "k8s-client", entry.Fields["source"])
|
||||
}
|
||||
|
||||
func TestKlogWriterConcurrency(t *testing.T) {
|
||||
// Test that concurrent writes don't cause data races
|
||||
var buf bytes.Buffer
|
||||
logger := New(LevelDebug, FormatText, &buf)
|
||||
klogWriter := NewKlogWriter(logger)
|
||||
|
||||
done := make(chan bool)
|
||||
numGoroutines := 10
|
||||
numWrites := 100
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(id int) {
|
||||
for j := 0; j < numWrites; j++ {
|
||||
msg := fmt.Sprintf("I1124 12:34:56.789012 12345 test.go:123] Message from goroutine %d iteration %d\n", id, j)
|
||||
klogWriter.Write([]byte(msg))
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Just verify we didn't panic (data race detector would catch issues)
|
||||
assert.NotEmpty(t, buf.String())
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Level int
|
||||
|
||||
const (
|
||||
LevelDebug Level = iota
|
||||
LevelInfo
|
||||
LevelWarn
|
||||
LevelError
|
||||
)
|
||||
|
||||
type Format int
|
||||
|
||||
const (
|
||||
FormatText Format = iota
|
||||
FormatJSON
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
level Level
|
||||
format Format
|
||||
output io.Writer
|
||||
}
|
||||
|
||||
type logEntry struct {
|
||||
Time string `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Fields map[string]interface{} `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
func New(level Level, format Format, output io.Writer) *Logger {
|
||||
if output == nil {
|
||||
output = os.Stderr
|
||||
}
|
||||
return &Logger{
|
||||
level: level,
|
||||
format: format,
|
||||
output: output,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) log(level Level, msg string, fields map[string]interface{}) {
|
||||
if level < l.level {
|
||||
return
|
||||
}
|
||||
|
||||
levelStr := levelToString(level)
|
||||
|
||||
if l.format == FormatJSON {
|
||||
entry := logEntry{
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
Level: levelStr,
|
||||
Message: msg,
|
||||
Fields: fields,
|
||||
}
|
||||
data, _ := json.Marshal(entry)
|
||||
fmt.Fprintln(l.output, string(data))
|
||||
} else {
|
||||
// Text format
|
||||
if len(fields) > 0 {
|
||||
fmt.Fprintf(l.output, "[%s] %s %v\n", levelStr, msg, fields)
|
||||
} else {
|
||||
fmt.Fprintf(l.output, "[%s] %s\n", levelStr, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Debug(msg string, fields ...map[string]interface{}) {
|
||||
f := make(map[string]interface{})
|
||||
if len(fields) > 0 {
|
||||
f = fields[0]
|
||||
}
|
||||
l.log(LevelDebug, msg, f)
|
||||
}
|
||||
|
||||
func (l *Logger) Info(msg string, fields ...map[string]interface{}) {
|
||||
f := make(map[string]interface{})
|
||||
if len(fields) > 0 {
|
||||
f = fields[0]
|
||||
}
|
||||
l.log(LevelInfo, msg, f)
|
||||
}
|
||||
|
||||
func (l *Logger) Warn(msg string, fields ...map[string]interface{}) {
|
||||
f := make(map[string]interface{})
|
||||
if len(fields) > 0 {
|
||||
f = fields[0]
|
||||
}
|
||||
l.log(LevelWarn, msg, f)
|
||||
}
|
||||
|
||||
func (l *Logger) Error(msg string, fields ...map[string]interface{}) {
|
||||
f := make(map[string]interface{})
|
||||
if len(fields) > 0 {
|
||||
f = fields[0]
|
||||
}
|
||||
l.log(LevelError, msg, f)
|
||||
}
|
||||
|
||||
func levelToString(level Level) string {
|
||||
switch level {
|
||||
case LevelDebug:
|
||||
return "DEBUG"
|
||||
case LevelInfo:
|
||||
return "INFO"
|
||||
case LevelWarn:
|
||||
return "WARN"
|
||||
case LevelError:
|
||||
return "ERROR"
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
// Global logger for backward compatibility
|
||||
var globalLogger *Logger
|
||||
|
||||
func Init(level Level, format Format, output ...io.Writer) {
|
||||
var out io.Writer
|
||||
if len(output) > 0 && output[0] != nil {
|
||||
out = output[0]
|
||||
} else {
|
||||
out = os.Stderr
|
||||
}
|
||||
globalLogger = New(level, format, out)
|
||||
}
|
||||
|
||||
func Debug(msg string, fields ...map[string]interface{}) {
|
||||
if globalLogger != nil {
|
||||
globalLogger.Debug(msg, fields...)
|
||||
}
|
||||
}
|
||||
|
||||
func Info(msg string, fields ...map[string]interface{}) {
|
||||
if globalLogger != nil {
|
||||
globalLogger.Info(msg, fields...)
|
||||
}
|
||||
}
|
||||
|
||||
func Warn(msg string, fields ...map[string]interface{}) {
|
||||
if globalLogger != nil {
|
||||
globalLogger.Warn(msg, fields...)
|
||||
}
|
||||
}
|
||||
|
||||
func Error(msg string, fields ...map[string]interface{}) {
|
||||
if globalLogger != nil {
|
||||
globalLogger.Error(msg, fields...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoggerTextFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
level Level
|
||||
logLevel Level
|
||||
message string
|
||||
fields map[string]interface{}
|
||||
expectOutput bool
|
||||
expectContains []string
|
||||
}{
|
||||
{
|
||||
name: "info logged at info level",
|
||||
level: LevelInfo,
|
||||
logLevel: LevelInfo,
|
||||
message: "test message",
|
||||
fields: nil,
|
||||
expectOutput: true,
|
||||
expectContains: []string{"[INFO]", "test message"},
|
||||
},
|
||||
{
|
||||
name: "debug filtered at info level",
|
||||
level: LevelInfo,
|
||||
logLevel: LevelDebug,
|
||||
message: "debug message",
|
||||
fields: nil,
|
||||
expectOutput: false,
|
||||
expectContains: []string{},
|
||||
},
|
||||
{
|
||||
name: "error logged at info level",
|
||||
level: LevelInfo,
|
||||
logLevel: LevelError,
|
||||
message: "error message",
|
||||
fields: nil,
|
||||
expectOutput: true,
|
||||
expectContains: []string{"[ERROR]", "error message"},
|
||||
},
|
||||
{
|
||||
name: "info with fields",
|
||||
level: LevelInfo,
|
||||
logLevel: LevelInfo,
|
||||
message: "test message",
|
||||
fields: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": 123,
|
||||
},
|
||||
expectOutput: true,
|
||||
expectContains: []string{"[INFO]", "test message", "key1", "value1"},
|
||||
},
|
||||
{
|
||||
name: "warn logged at warn level",
|
||||
level: LevelWarn,
|
||||
logLevel: LevelWarn,
|
||||
message: "warning message",
|
||||
fields: nil,
|
||||
expectOutput: true,
|
||||
expectContains: []string{"[WARN]", "warning message"},
|
||||
},
|
||||
{
|
||||
name: "info filtered at warn level",
|
||||
level: LevelWarn,
|
||||
logLevel: LevelInfo,
|
||||
message: "info message",
|
||||
fields: nil,
|
||||
expectOutput: false,
|
||||
expectContains: []string{},
|
||||
},
|
||||
{
|
||||
name: "debug logged at debug level",
|
||||
level: LevelDebug,
|
||||
logLevel: LevelDebug,
|
||||
message: "debug message",
|
||||
fields: nil,
|
||||
expectOutput: true,
|
||||
expectContains: []string{"[DEBUG]", "debug message"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(tt.level, FormatText, buf)
|
||||
|
||||
// Log at the specified level
|
||||
switch tt.logLevel {
|
||||
case LevelDebug:
|
||||
if tt.fields != nil {
|
||||
logger.Debug(tt.message, tt.fields)
|
||||
} else {
|
||||
logger.Debug(tt.message)
|
||||
}
|
||||
case LevelInfo:
|
||||
if tt.fields != nil {
|
||||
logger.Info(tt.message, tt.fields)
|
||||
} else {
|
||||
logger.Info(tt.message)
|
||||
}
|
||||
case LevelWarn:
|
||||
if tt.fields != nil {
|
||||
logger.Warn(tt.message, tt.fields)
|
||||
} else {
|
||||
logger.Warn(tt.message)
|
||||
}
|
||||
case LevelError:
|
||||
if tt.fields != nil {
|
||||
logger.Error(tt.message, tt.fields)
|
||||
} else {
|
||||
logger.Error(tt.message)
|
||||
}
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
if tt.expectOutput {
|
||||
assert.NotEmpty(t, output, "Expected log output but got none")
|
||||
for _, expected := range tt.expectContains {
|
||||
assert.Contains(t, output, expected, "Expected output to contain: %s", expected)
|
||||
}
|
||||
} else {
|
||||
assert.Empty(t, output, "Expected no log output but got: %s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerJSONFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
level Level
|
||||
logLevel Level
|
||||
message string
|
||||
fields map[string]interface{}
|
||||
expectOutput bool
|
||||
expectLevel string
|
||||
}{
|
||||
{
|
||||
name: "info logged at info level",
|
||||
level: LevelInfo,
|
||||
logLevel: LevelInfo,
|
||||
message: "test message",
|
||||
fields: nil,
|
||||
expectOutput: true,
|
||||
expectLevel: "INFO",
|
||||
},
|
||||
{
|
||||
name: "debug filtered at info level",
|
||||
level: LevelInfo,
|
||||
logLevel: LevelDebug,
|
||||
message: "debug message",
|
||||
fields: nil,
|
||||
expectOutput: false,
|
||||
expectLevel: "",
|
||||
},
|
||||
{
|
||||
name: "error logged at debug level",
|
||||
level: LevelDebug,
|
||||
logLevel: LevelError,
|
||||
message: "error message",
|
||||
fields: nil,
|
||||
expectOutput: true,
|
||||
expectLevel: "ERROR",
|
||||
},
|
||||
{
|
||||
name: "info with fields",
|
||||
level: LevelInfo,
|
||||
logLevel: LevelInfo,
|
||||
message: "test message",
|
||||
fields: map[string]interface{}{
|
||||
"context": "production",
|
||||
"port": 8080,
|
||||
"retry": 3,
|
||||
},
|
||||
expectOutput: true,
|
||||
expectLevel: "INFO",
|
||||
},
|
||||
{
|
||||
name: "warn at warn level",
|
||||
level: LevelWarn,
|
||||
logLevel: LevelWarn,
|
||||
message: "warning message",
|
||||
fields: nil,
|
||||
expectOutput: true,
|
||||
expectLevel: "WARN",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(tt.level, FormatJSON, buf)
|
||||
|
||||
// Log at the specified level
|
||||
switch tt.logLevel {
|
||||
case LevelDebug:
|
||||
if tt.fields != nil {
|
||||
logger.Debug(tt.message, tt.fields)
|
||||
} else {
|
||||
logger.Debug(tt.message)
|
||||
}
|
||||
case LevelInfo:
|
||||
if tt.fields != nil {
|
||||
logger.Info(tt.message, tt.fields)
|
||||
} else {
|
||||
logger.Info(tt.message)
|
||||
}
|
||||
case LevelWarn:
|
||||
if tt.fields != nil {
|
||||
logger.Warn(tt.message, tt.fields)
|
||||
} else {
|
||||
logger.Warn(tt.message)
|
||||
}
|
||||
case LevelError:
|
||||
if tt.fields != nil {
|
||||
logger.Error(tt.message, tt.fields)
|
||||
} else {
|
||||
logger.Error(tt.message)
|
||||
}
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
if tt.expectOutput {
|
||||
assert.NotEmpty(t, output, "Expected log output but got none")
|
||||
|
||||
// Parse JSON
|
||||
var entry logEntry
|
||||
err := json.Unmarshal([]byte(strings.TrimSpace(output)), &entry)
|
||||
require.NoError(t, err, "Failed to parse JSON output: %s", output)
|
||||
|
||||
// Validate fields
|
||||
assert.Equal(t, tt.expectLevel, entry.Level)
|
||||
assert.Equal(t, tt.message, entry.Message)
|
||||
assert.NotEmpty(t, entry.Time, "Time field should not be empty")
|
||||
|
||||
// Validate custom fields if provided
|
||||
if tt.fields != nil {
|
||||
require.NotNil(t, entry.Fields, "Expected fields in JSON output")
|
||||
for key, expectedValue := range tt.fields {
|
||||
actualValue, exists := entry.Fields[key]
|
||||
assert.True(t, exists, "Expected field %s not found in output", key)
|
||||
// JSON unmarshaling converts numbers to float64
|
||||
if floatVal, ok := expectedValue.(int); ok {
|
||||
assert.Equal(t, float64(floatVal), actualValue)
|
||||
} else {
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert.Empty(t, output, "Expected no log output but got: %s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalLogger(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
initLevel Level
|
||||
initFormat Format
|
||||
logFunc func(string, ...map[string]interface{})
|
||||
message string
|
||||
expectContains string
|
||||
}{
|
||||
{
|
||||
name: "global info logger text",
|
||||
initLevel: LevelInfo,
|
||||
initFormat: FormatText,
|
||||
logFunc: Info,
|
||||
message: "global info message",
|
||||
expectContains: "[INFO]",
|
||||
},
|
||||
{
|
||||
name: "global error logger text",
|
||||
initLevel: LevelInfo,
|
||||
initFormat: FormatText,
|
||||
logFunc: Error,
|
||||
message: "global error message",
|
||||
expectContains: "[ERROR]",
|
||||
},
|
||||
{
|
||||
name: "global warn logger json",
|
||||
initLevel: LevelWarn,
|
||||
initFormat: FormatJSON,
|
||||
logFunc: Warn,
|
||||
message: "global warn message",
|
||||
expectContains: `"level":"WARN"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Capture stderr by replacing globalLogger's output
|
||||
buf := &bytes.Buffer{}
|
||||
Init(tt.initLevel, tt.initFormat)
|
||||
globalLogger.output = buf
|
||||
|
||||
// Call the global log function
|
||||
tt.logFunc(tt.message)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, tt.expectContains)
|
||||
assert.Contains(t, output, tt.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogLevelsFiltering(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loggerLevel Level
|
||||
logAtLevels []Level
|
||||
expectOutputs []bool
|
||||
}{
|
||||
{
|
||||
name: "debug level logs everything",
|
||||
loggerLevel: LevelDebug,
|
||||
logAtLevels: []Level{LevelDebug, LevelInfo, LevelWarn, LevelError},
|
||||
expectOutputs: []bool{true, true, true, true},
|
||||
},
|
||||
{
|
||||
name: "info level filters debug",
|
||||
loggerLevel: LevelInfo,
|
||||
logAtLevels: []Level{LevelDebug, LevelInfo, LevelWarn, LevelError},
|
||||
expectOutputs: []bool{false, true, true, true},
|
||||
},
|
||||
{
|
||||
name: "warn level filters debug and info",
|
||||
loggerLevel: LevelWarn,
|
||||
logAtLevels: []Level{LevelDebug, LevelInfo, LevelWarn, LevelError},
|
||||
expectOutputs: []bool{false, false, true, true},
|
||||
},
|
||||
{
|
||||
name: "error level only logs errors",
|
||||
loggerLevel: LevelError,
|
||||
logAtLevels: []Level{LevelDebug, LevelInfo, LevelWarn, LevelError},
|
||||
expectOutputs: []bool{false, false, false, true},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(tt.loggerLevel, FormatText, buf)
|
||||
|
||||
for i, logLevel := range tt.logAtLevels {
|
||||
buf.Reset()
|
||||
|
||||
switch logLevel {
|
||||
case LevelDebug:
|
||||
logger.Debug("test")
|
||||
case LevelInfo:
|
||||
logger.Info("test")
|
||||
case LevelWarn:
|
||||
logger.Warn("test")
|
||||
case LevelError:
|
||||
logger.Error("test")
|
||||
}
|
||||
|
||||
hasOutput := buf.Len() > 0
|
||||
assert.Equal(t, tt.expectOutputs[i], hasOutput,
|
||||
"Level %v at logger level %v: expected output=%v, got=%v",
|
||||
logLevel, tt.loggerLevel, tt.expectOutputs[i], hasOutput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerNilOutput(t *testing.T) {
|
||||
// Test that logger defaults to os.Stderr when output is nil
|
||||
logger := New(LevelInfo, FormatText, nil)
|
||||
assert.NotNil(t, logger.output, "Logger output should not be nil")
|
||||
}
|
||||
|
||||
func TestLevelToString(t *testing.T) {
|
||||
tests := []struct {
|
||||
level Level
|
||||
expected string
|
||||
}{
|
||||
{LevelDebug, "DEBUG"},
|
||||
{LevelInfo, "INFO"},
|
||||
{LevelWarn, "WARN"},
|
||||
{LevelError, "ERROR"},
|
||||
{Level(999), "UNKNOWN"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.expected, func(t *testing.T) {
|
||||
result := levelToString(tt.level)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFieldTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fields map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "string fields",
|
||||
fields: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "numeric fields",
|
||||
fields: map[string]interface{}{
|
||||
"port": 8080,
|
||||
"timeout": 30,
|
||||
"retry": 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "boolean fields",
|
||||
fields: map[string]interface{}{
|
||||
"enabled": true,
|
||||
"running": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed types",
|
||||
fields: map[string]interface{}{
|
||||
"context": "production",
|
||||
"port": 8080,
|
||||
"enabled": true,
|
||||
"namespace": "default",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(LevelInfo, FormatJSON, buf)
|
||||
|
||||
logger.Info("test message", tt.fields)
|
||||
|
||||
var entry logEntry
|
||||
err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, len(tt.fields), len(entry.Fields),
|
||||
"Field count mismatch")
|
||||
|
||||
for key := range tt.fields {
|
||||
_, exists := entry.Fields[key]
|
||||
assert.True(t, exists, "Field %s not found in JSON output", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWithCustomOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
output io.Writer
|
||||
expectDiscard bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "init with custom buffer",
|
||||
output: &bytes.Buffer{},
|
||||
expectDiscard: false,
|
||||
description: "Should use provided buffer",
|
||||
},
|
||||
{
|
||||
name: "init with io.Discard",
|
||||
output: io.Discard,
|
||||
expectDiscard: true,
|
||||
description: "Should use io.Discard to silence output",
|
||||
},
|
||||
{
|
||||
name: "init without output defaults to stderr",
|
||||
output: nil,
|
||||
expectDiscard: false,
|
||||
description: "Should default to stderr when no output provided",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.output != nil {
|
||||
Init(LevelInfo, FormatText, tt.output)
|
||||
} else {
|
||||
Init(LevelInfo, FormatText)
|
||||
}
|
||||
|
||||
// Verify global logger was initialized
|
||||
assert.NotNil(t, globalLogger, "Global logger should be initialized")
|
||||
|
||||
if tt.output != nil && !tt.expectDiscard {
|
||||
// For buffer, verify output works
|
||||
if buf, ok := tt.output.(*bytes.Buffer); ok {
|
||||
Info("test message")
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "test message")
|
||||
assert.Contains(t, output, "[INFO]")
|
||||
}
|
||||
} else if tt.expectDiscard {
|
||||
// For io.Discard, verify no output appears (we can't really test this directly,
|
||||
// but we can verify the logger was set with the right output)
|
||||
assert.Equal(t, io.Discard, globalLogger.output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user