v0.7.0 major configuration and sub-command restructuring, not tested, docs and default config outdated
This commit is contained in:
@ -3,6 +3,7 @@ package format
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"logwisp/src/internal/config"
|
||||
|
||||
"logwisp/src/internal/core"
|
||||
|
||||
@ -19,20 +20,15 @@ type Formatter interface {
|
||||
}
|
||||
|
||||
// Creates a new Formatter based on the provided configuration.
|
||||
func NewFormatter(name string, options map[string]any, logger *log.Logger) (Formatter, error) {
|
||||
// Default to raw if no format specified
|
||||
if name == "" {
|
||||
name = "raw"
|
||||
}
|
||||
|
||||
switch name {
|
||||
func NewFormatter(cfg *config.FormatConfig, logger *log.Logger) (Formatter, error) {
|
||||
switch cfg.Type {
|
||||
case "json":
|
||||
return NewJSONFormatter(options, logger)
|
||||
return NewJSONFormatter(cfg.JSONFormatOptions, logger)
|
||||
case "txt":
|
||||
return NewTextFormatter(options, logger)
|
||||
case "raw":
|
||||
return NewRawFormatter(options, logger)
|
||||
return NewTextFormatter(cfg.TextFormatOptions, logger)
|
||||
case "raw", "":
|
||||
return NewRawFormatter(cfg.RawFormatOptions, logger)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown formatter type: %s", name)
|
||||
return nil, fmt.Errorf("unknown formatter type: %s", cfg.Type)
|
||||
}
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
// FILE: logwisp/src/internal/format/format_test.go
|
||||
package format
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lixenwraith/log"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestLogger() *log.Logger {
|
||||
return log.NewLogger()
|
||||
}
|
||||
|
||||
func TestNewFormatter(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
formatName string
|
||||
expected string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "JSONFormatter",
|
||||
formatName: "json",
|
||||
expected: "json",
|
||||
},
|
||||
{
|
||||
name: "TextFormatter",
|
||||
formatName: "txt",
|
||||
expected: "txt",
|
||||
},
|
||||
{
|
||||
name: "RawFormatter",
|
||||
formatName: "raw",
|
||||
expected: "raw",
|
||||
},
|
||||
{
|
||||
name: "DefaultToRaw",
|
||||
formatName: "",
|
||||
expected: "raw",
|
||||
},
|
||||
{
|
||||
name: "UnknownFormatter",
|
||||
formatName: "xml",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
formatter, err := NewFormatter(tc.formatName, nil, logger)
|
||||
if tc.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, formatter)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, formatter)
|
||||
assert.Equal(t, tc.expected, formatter.Name())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"logwisp/src/internal/config"
|
||||
"logwisp/src/internal/core"
|
||||
|
||||
"github.com/lixenwraith/log"
|
||||
@ -13,39 +14,15 @@ import (
|
||||
|
||||
// Produces structured JSON logs
|
||||
type JSONFormatter struct {
|
||||
pretty bool
|
||||
timestampField string
|
||||
levelField string
|
||||
messageField string
|
||||
sourceField string
|
||||
logger *log.Logger
|
||||
config *config.JSONFormatterOptions
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// Creates a new JSON formatter
|
||||
func NewJSONFormatter(options map[string]any, logger *log.Logger) (*JSONFormatter, error) {
|
||||
func NewJSONFormatter(opts *config.JSONFormatterOptions, logger *log.Logger) (*JSONFormatter, error) {
|
||||
f := &JSONFormatter{
|
||||
timestampField: "timestamp",
|
||||
levelField: "level",
|
||||
messageField: "message",
|
||||
sourceField: "source",
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
// Extract options
|
||||
if pretty, ok := options["pretty"].(bool); ok {
|
||||
f.pretty = pretty
|
||||
}
|
||||
if field, ok := options["timestamp_field"].(string); ok && field != "" {
|
||||
f.timestampField = field
|
||||
}
|
||||
if field, ok := options["level_field"].(string); ok && field != "" {
|
||||
f.levelField = field
|
||||
}
|
||||
if field, ok := options["message_field"].(string); ok && field != "" {
|
||||
f.messageField = field
|
||||
}
|
||||
if field, ok := options["source_field"].(string); ok && field != "" {
|
||||
f.sourceField = field
|
||||
config: opts,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
return f, nil
|
||||
@ -57,9 +34,9 @@ func (f *JSONFormatter) Format(entry core.LogEntry) ([]byte, error) {
|
||||
output := make(map[string]any)
|
||||
|
||||
// First, populate with LogWisp metadata
|
||||
output[f.timestampField] = entry.Time.Format(time.RFC3339Nano)
|
||||
output[f.levelField] = entry.Level
|
||||
output[f.sourceField] = entry.Source
|
||||
output[f.config.TimestampField] = entry.Time.Format(time.RFC3339Nano)
|
||||
output[f.config.LevelField] = entry.Level
|
||||
output[f.config.SourceField] = entry.Source
|
||||
|
||||
// Try to parse the message as JSON
|
||||
var msgData map[string]any
|
||||
@ -68,21 +45,21 @@ func (f *JSONFormatter) Format(entry core.LogEntry) ([]byte, error) {
|
||||
// LogWisp metadata takes precedence
|
||||
for k, v := range msgData {
|
||||
// Don't overwrite our standard fields
|
||||
if k != f.timestampField && k != f.levelField && k != f.sourceField {
|
||||
if k != f.config.TimestampField && k != f.config.LevelField && k != f.config.SourceField {
|
||||
output[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// If the original JSON had these fields, log that we're overriding
|
||||
if _, hasTime := msgData[f.timestampField]; hasTime {
|
||||
if _, hasTime := msgData[f.config.TimestampField]; hasTime {
|
||||
f.logger.Debug("msg", "Overriding timestamp from JSON message",
|
||||
"component", "json_formatter",
|
||||
"original", msgData[f.timestampField],
|
||||
"logwisp", output[f.timestampField])
|
||||
"original", msgData[f.config.TimestampField],
|
||||
"logwisp", output[f.config.TimestampField])
|
||||
}
|
||||
} else {
|
||||
// Message is not valid JSON - add as message field
|
||||
output[f.messageField] = entry.Message
|
||||
output[f.config.MessageField] = entry.Message
|
||||
}
|
||||
|
||||
// Add any additional fields from LogEntry.Fields
|
||||
@ -101,7 +78,7 @@ func (f *JSONFormatter) Format(entry core.LogEntry) ([]byte, error) {
|
||||
// Marshal to JSON
|
||||
var result []byte
|
||||
var err error
|
||||
if f.pretty {
|
||||
if f.config.Pretty {
|
||||
result, err = json.MarshalIndent(output, "", " ")
|
||||
} else {
|
||||
result, err = json.Marshal(output)
|
||||
@ -147,7 +124,7 @@ func (f *JSONFormatter) FormatBatch(entries []core.LogEntry) ([]byte, error) {
|
||||
// Marshal the entire batch as an array
|
||||
var result []byte
|
||||
var err error
|
||||
if f.pretty {
|
||||
if f.config.Pretty {
|
||||
result, err = json.MarshalIndent(batch, "", " ")
|
||||
} else {
|
||||
result, err = json.Marshal(batch)
|
||||
|
||||
@ -1,129 +0,0 @@
|
||||
// FILE: logwisp/src/internal/format/json_test.go
|
||||
package format
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"logwisp/src/internal/core"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJSONFormatter_Format(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
testTime := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
entry := core.LogEntry{
|
||||
Time: testTime,
|
||||
Source: "test-app",
|
||||
Level: "INFO",
|
||||
Message: "this is a test",
|
||||
}
|
||||
|
||||
t.Run("BasicFormatting", func(t *testing.T) {
|
||||
formatter, err := NewJSONFormatter(nil, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := formatter.Format(entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(output, &result)
|
||||
require.NoError(t, err, "Output should be valid JSON")
|
||||
|
||||
assert.Equal(t, testTime.Format(time.RFC3339Nano), result["timestamp"])
|
||||
assert.Equal(t, "INFO", result["level"])
|
||||
assert.Equal(t, "test-app", result["source"])
|
||||
assert.Equal(t, "this is a test", result["message"])
|
||||
assert.True(t, strings.HasSuffix(string(output), "\n"), "Output should end with a newline")
|
||||
})
|
||||
|
||||
t.Run("PrettyFormatting", func(t *testing.T) {
|
||||
formatter, err := NewJSONFormatter(map[string]any{"pretty": true}, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := formatter.Format(entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, string(output), ` "level": "INFO"`)
|
||||
assert.True(t, strings.HasSuffix(string(output), "\n"))
|
||||
})
|
||||
|
||||
t.Run("MessageIsJSON", func(t *testing.T) {
|
||||
jsonMessageEntry := entry
|
||||
jsonMessageEntry.Message = `{"user":"test","request_id":"abc-123"}`
|
||||
formatter, err := NewJSONFormatter(nil, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := formatter.Format(jsonMessageEntry)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(output, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "test", result["user"])
|
||||
assert.Equal(t, "abc-123", result["request_id"])
|
||||
_, messageExists := result["message"]
|
||||
assert.False(t, messageExists, "message field should not exist when message is merged JSON")
|
||||
})
|
||||
|
||||
t.Run("MessageIsJSONWithConflicts", func(t *testing.T) {
|
||||
jsonMessageEntry := entry
|
||||
jsonMessageEntry.Level = "INFO" // top-level
|
||||
jsonMessageEntry.Message = `{"level":"DEBUG","msg":"hello"}`
|
||||
formatter, err := NewJSONFormatter(nil, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := formatter.Format(jsonMessageEntry)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(output, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "INFO", result["level"], "Top-level LogEntry field should take precedence")
|
||||
})
|
||||
|
||||
t.Run("CustomFieldNames", func(t *testing.T) {
|
||||
options := map[string]any{"timestamp_field": "@timestamp"}
|
||||
formatter, err := NewJSONFormatter(options, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := formatter.Format(entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(output, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, defaultExists := result["timestamp"]
|
||||
assert.False(t, defaultExists)
|
||||
assert.Equal(t, testTime.Format(time.RFC3339Nano), result["@timestamp"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestJSONFormatter_FormatBatch(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
formatter, err := NewJSONFormatter(nil, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
entries := []core.LogEntry{
|
||||
{Time: time.Now(), Level: "INFO", Message: "First message"},
|
||||
{Time: time.Now(), Level: "WARN", Message: "Second message"},
|
||||
}
|
||||
|
||||
output, err := formatter.FormatBatch(entries)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result []map[string]interface{}
|
||||
err = json.Unmarshal(output, &result)
|
||||
require.NoError(t, err, "Batch output should be a valid JSON array")
|
||||
require.Len(t, result, 2)
|
||||
|
||||
assert.Equal(t, "First message", result[0]["message"])
|
||||
assert.Equal(t, "WARN", result[1]["level"])
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
package format
|
||||
|
||||
import (
|
||||
"logwisp/src/internal/config"
|
||||
"logwisp/src/internal/core"
|
||||
|
||||
"github.com/lixenwraith/log"
|
||||
@ -9,20 +10,26 @@ import (
|
||||
|
||||
// Outputs the log message as-is with a newline
|
||||
type RawFormatter struct {
|
||||
config *config.RawFormatterOptions
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// Creates a new raw formatter
|
||||
func NewRawFormatter(options map[string]any, logger *log.Logger) (*RawFormatter, error) {
|
||||
func NewRawFormatter(cfg *config.RawFormatterOptions, logger *log.Logger) (*RawFormatter, error) {
|
||||
return &RawFormatter{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Returns the message with a newline appended
|
||||
func (f *RawFormatter) Format(entry core.LogEntry) ([]byte, error) {
|
||||
// Simply return the message with newline
|
||||
return append([]byte(entry.Message), '\n'), nil
|
||||
// TODO: Standardize not to add "\n" when processing raw, check lixenwraith/log for consistency
|
||||
if f.config.AddNewLine {
|
||||
return append([]byte(entry.Message), '\n'), nil
|
||||
} else {
|
||||
return []byte(entry.Message), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the formatter name
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
// FILE: logwisp/src/internal/format/raw_test.go
|
||||
package format
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"logwisp/src/internal/core"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRawFormatter_Format(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
formatter, err := NewRawFormatter(nil, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
entry := core.LogEntry{
|
||||
Time: time.Now(),
|
||||
Message: "This is a raw log line.",
|
||||
}
|
||||
|
||||
output, err := formatter.Format(entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := "This is a raw log line.\n"
|
||||
assert.Equal(t, expected, string(output))
|
||||
}
|
||||
@ -4,6 +4,7 @@ package format
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"logwisp/src/internal/config"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
@ -15,41 +16,29 @@ import (
|
||||
|
||||
// Produces human-readable text logs using templates
|
||||
type TextFormatter struct {
|
||||
template *template.Template
|
||||
timestampFormat string
|
||||
logger *log.Logger
|
||||
config *config.TextFormatterOptions
|
||||
template *template.Template
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// Creates a new text formatter
|
||||
func NewTextFormatter(options map[string]any, logger *log.Logger) (*TextFormatter, error) {
|
||||
// Default template
|
||||
templateStr := "[{{.Timestamp | FmtTime}}] [{{.Level | ToUpper}}] {{.Source}} - {{.Message}}{{ if .Fields }} {{.Fields}}{{ end }}"
|
||||
if tmpl, ok := options["template"].(string); ok && tmpl != "" {
|
||||
templateStr = tmpl
|
||||
}
|
||||
|
||||
// Default timestamp format
|
||||
timestampFormat := time.RFC3339
|
||||
if tsFormat, ok := options["timestamp_format"].(string); ok && tsFormat != "" {
|
||||
timestampFormat = tsFormat
|
||||
}
|
||||
|
||||
func NewTextFormatter(opts *config.TextFormatterOptions, logger *log.Logger) (*TextFormatter, error) {
|
||||
f := &TextFormatter{
|
||||
timestampFormat: timestampFormat,
|
||||
logger: logger,
|
||||
config: opts,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
// Create template with helper functions
|
||||
funcMap := template.FuncMap{
|
||||
"FmtTime": func(t time.Time) string {
|
||||
return t.Format(f.timestampFormat)
|
||||
return t.Format(f.config.TimestampFormat)
|
||||
},
|
||||
"ToUpper": strings.ToUpper,
|
||||
"ToLower": strings.ToLower,
|
||||
"TrimSpace": strings.TrimSpace,
|
||||
}
|
||||
|
||||
tmpl, err := template.New("log").Funcs(funcMap).Parse(templateStr)
|
||||
tmpl, err := template.New("log").Funcs(funcMap).Parse(f.config.Template)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid template: %w", err)
|
||||
}
|
||||
@ -86,7 +75,7 @@ func (f *TextFormatter) Format(entry core.LogEntry) ([]byte, error) {
|
||||
"error", err)
|
||||
|
||||
fallback := fmt.Sprintf("[%s] [%s] %s - %s\n",
|
||||
entry.Time.Format(f.timestampFormat),
|
||||
entry.Time.Format(f.config.TimestampFormat),
|
||||
strings.ToUpper(entry.Level),
|
||||
entry.Source,
|
||||
entry.Message)
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
// FILE: logwisp/src/internal/format/text_test.go
|
||||
package format
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"logwisp/src/internal/core"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewTextFormatter(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
t.Run("InvalidTemplate", func(t *testing.T) {
|
||||
options := map[string]any{"template": "{{ .Timestamp | InvalidFunc }}"}
|
||||
_, err := NewTextFormatter(options, logger)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid template")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTextFormatter_Format(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
testTime := time.Date(2023, 10, 27, 10, 30, 0, 0, time.UTC)
|
||||
entry := core.LogEntry{
|
||||
Time: testTime,
|
||||
Source: "api",
|
||||
Level: "WARN",
|
||||
Message: "rate limit exceeded",
|
||||
}
|
||||
|
||||
t.Run("DefaultTemplate", func(t *testing.T) {
|
||||
formatter, err := NewTextFormatter(nil, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := formatter.Format(entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := fmt.Sprintf("[%s] [WARN] api - rate limit exceeded\n", testTime.Format(time.RFC3339))
|
||||
assert.Equal(t, expected, string(output))
|
||||
})
|
||||
|
||||
t.Run("CustomTemplate", func(t *testing.T) {
|
||||
options := map[string]any{"template": "{{.Level}}:{{.Source}}:{{.Message}}"}
|
||||
formatter, err := NewTextFormatter(options, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := formatter.Format(entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := "WARN:api:rate limit exceeded\n"
|
||||
assert.Equal(t, expected, string(output))
|
||||
})
|
||||
|
||||
t.Run("CustomTimestampFormat", func(t *testing.T) {
|
||||
options := map[string]any{"timestamp_format": "2006-01-02"}
|
||||
formatter, err := NewTextFormatter(options, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := formatter.Format(entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, strings.HasPrefix(string(output), "[2023-10-27]"))
|
||||
})
|
||||
|
||||
t.Run("EmptyLevelDefaultsToInfo", func(t *testing.T) {
|
||||
emptyLevelEntry := entry
|
||||
emptyLevelEntry.Level = ""
|
||||
formatter, err := NewTextFormatter(nil, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := formatter.Format(emptyLevelEntry)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, string(output), "[INFO]")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user