From 3cec688c10a4f76376fdad0da43ec5320691843e Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 4 Oct 2025 02:07:17 -0700 Subject: [PATCH 1/4] fix: add RenderMessage() method for custom sinks (#64) Provides public API for rendering message templates in custom sinks. Handles destructuring operators (@), scalar hints ($), and format specifiers that were previously inaccessible to custom implementations. - Add RenderMessage() method to core.LogEvent - Refactor parser/token.go to break import cycle - Add tests and example --- CHANGELOG.md | 7 +++ README.md | 31 ++++++++-- core/event.go | 38 +++++++++++- core/event_test.go | 77 +++++++++++++++++++++++++ examples/custom-sink/main.go | 109 +++++++++++++++++++++++++++++++++++ internal/parser/token.go | 33 +++++------ 6 files changed, 272 insertions(+), 23 deletions(-) create mode 100644 core/event_test.go create mode 100644 examples/custom-sink/main.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d6bc9bc..a8ad30f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **RenderMessage() Method** - Public API for rendering message templates (#64) + - New `RenderMessage()` method on `core.LogEvent` for custom sinks + - Properly handles destructuring operators (`{@Property}`), scalar hints (`{$Property}`), and format specifiers (`{Property:format}`) + - Enables custom sinks to render message templates without accessing internal parser + - Returns the original template as fallback if parsing fails + - Example: `message := event.RenderMessage()` renders the template with all properties + - **Zed Extension** - Full editor support via Language Server Protocol (#56) - New `mtlog-lsp` command providing LSP wrapper for mtlog-analyzer - Native Zed extension using Rust/WASM (wasm32-wasip2 target) diff --git a/README.md b/README.md index fbe077c..f7e4956 100644 --- a/README.md +++ b/README.md @@ -1367,16 +1367,39 @@ The extension provides: Implement the `core.LogEventSink` interface for custom outputs: ```go -type CustomSink struct{} +type CustomSink struct { + output io.Writer +} + +func (s *CustomSink) Emit(event *core.LogEvent) { + // Use RenderMessage() to properly render the message template + // This handles format specifiers, destructuring operators, and scalar hints + message := event.RenderMessage() + + // Format and write the log entry + timestamp := event.Timestamp.Format("15:04:05") + fmt.Fprintf(s.output, "[%s] %s: %s\n", timestamp, event.Level, message) + + // Optionally include extra properties not in the template + for key, value := range event.Properties { + if !strings.Contains(event.MessageTemplate, "{"+key) { + fmt.Fprintf(s.output, " %s: %v\n", key, value) + } + } +} -func (s *CustomSink) Emit(event *core.LogEvent) error { - // Process the log event +func (s *CustomSink) Close() error { + // Cleanup if needed return nil } +// Use your custom sink log := mtlog.New( - mtlog.WithSink(&CustomSink{}), + mtlog.WithSink(&CustomSink{output: os.Stdout}), ) + +// This will properly render with RenderMessage(): +log.Info("User {@User} performed {Action} at {Time:HH:mm}", user, "login", time.Now()) ``` ### Custom Enrichers diff --git a/core/event.go b/core/event.go index baf4bce..90fd8e1 100644 --- a/core/event.go +++ b/core/event.go @@ -1,6 +1,11 @@ package core -import "time" +import ( + "time" + + "github.com/willibrandon/mtlog/internal/parser" + "github.com/willibrandon/mtlog/selflog" +) // LogEvent represents a single log event with all its properties. type LogEvent struct { @@ -31,3 +36,34 @@ func (e *LogEvent) AddPropertyIfAbsent(property *LogEventProperty) { func (e *LogEvent) AddProperty(name string, value any) { e.Properties[name] = value } + +// RenderMessage renders the message template with the event's properties. +// This method parses the MessageTemplate and replaces all placeholders with their +// corresponding property values, handling format specifiers, destructuring operators, +// and scalar hints. +// +// If parsing fails, the original MessageTemplate is returned as a fallback. +// +// Example: +// +// event := &LogEvent{ +// MessageTemplate: "User {UserId} logged in from {City}", +// Properties: map[string]any{ +// "UserId": 123, +// "City": "Seattle", +// }, +// } +// message := event.RenderMessage() // "User 123 logged in from Seattle" +func (e *LogEvent) RenderMessage() string { + tmpl, err := parser.Parse(e.MessageTemplate) + if err != nil { + // Log parsing error to selflog if enabled + if selflog.IsEnabled() { + selflog.Printf("[core] template parse error in RenderMessage: %v (template=%q)", err, e.MessageTemplate) + } + // Fallback to raw template on parse error + return e.MessageTemplate + } + + return tmpl.Render(e.Properties) +} diff --git a/core/event_test.go b/core/event_test.go new file mode 100644 index 0000000..6995d9c --- /dev/null +++ b/core/event_test.go @@ -0,0 +1,77 @@ +package core + +import ( + "testing" +) + +// TestRenderMessage demonstrates that RenderMessage() fixes issue #64. +// It shows that RenderMessage() correctly handles all template features. +func TestRenderMessage(t *testing.T) { + t.Run("destructuring operator", func(t *testing.T) { + event := &LogEvent{ + MessageTemplate: "Configuration: {@Config}", + Properties: map[string]any{ + "Config": map[string]any{ + "debug": true, + "port": 8080, + }, + }, + } + + got := event.RenderMessage() + expected := "Configuration: map[debug:true port:8080]" + + if got != expected { + t.Errorf("RenderMessage() = %q, want %q", got, expected) + } + }) + + t.Run("scalar hint", func(t *testing.T) { + event := &LogEvent{ + MessageTemplate: "Values: {$Values}", + Properties: map[string]any{ + "Values": []int{1, 2, 3}, + }, + } + + got := event.RenderMessage() + expected := "Values: [1 2 3]" + + if got != expected { + t.Errorf("RenderMessage() = %q, want %q", got, expected) + } + }) + + t.Run("format specifier", func(t *testing.T) { + event := &LogEvent{ + MessageTemplate: "Count: {Count:000}", + Properties: map[string]any{ + "Count": 42, + }, + } + + got := event.RenderMessage() + expected := "Count: 042" + + if got != expected { + t.Errorf("RenderMessage() = %q, want %q", got, expected) + } + }) + + t.Run("simple properties", func(t *testing.T) { + event := &LogEvent{ + MessageTemplate: "User {UserId} logged in from {City}", + Properties: map[string]any{ + "UserId": 123, + "City": "Seattle", + }, + } + + got := event.RenderMessage() + expected := "User 123 logged in from Seattle" + + if got != expected { + t.Errorf("RenderMessage() = %q, want %q", got, expected) + } + }) +} \ No newline at end of file diff --git a/examples/custom-sink/main.go b/examples/custom-sink/main.go new file mode 100644 index 0000000..f1d583e --- /dev/null +++ b/examples/custom-sink/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/willibrandon/mtlog" + "github.com/willibrandon/mtlog/core" +) + +// CustomSink demonstrates using RenderMessage() to properly render message templates +type CustomSink struct { + output *os.File +} + +func (s *CustomSink) Emit(event *core.LogEvent) { + // Use RenderMessage() to properly render the message template + // This handles format specifiers, destructuring operators, and scalar hints + message := event.RenderMessage() + + // Format and write the log entry + timestamp := event.Timestamp.Format("15:04:05") + levelStr := formatLevel(event.Level) + + fmt.Fprintf(s.output, "[%s] %s: %s\n", timestamp, levelStr, message) + + // Optionally include extra properties not in the template + for key, value := range event.Properties { + if !strings.Contains(event.MessageTemplate, "{"+key) { + fmt.Fprintf(s.output, " → %s: %v\n", key, value) + } + } +} + +func (s *CustomSink) Close() error { + return nil +} + +func formatLevel(level core.LogEventLevel) string { + switch level { + case core.VerboseLevel: + return "VRB" + case core.DebugLevel: + return "DBG" + case core.InformationLevel: + return "INF" + case core.WarningLevel: + return "WRN" + case core.ErrorLevel: + return "ERR" + case core.FatalLevel: + return "FTL" + default: + return "???" + } +} + +func main() { + // Create logger with custom sink + log := mtlog.New( + mtlog.WithSink(&CustomSink{output: os.Stdout}), + mtlog.WithMinimumLevel(core.DebugLevel), + ) + + // Simple message with properties + log.Info("Application started on port {Port}", 8080) + + // Destructuring operator - renders the entire struct + config := map[string]any{ + "debug": true, + "port": 8080, + "timeout": 30, + } + log.Debug("Configuration: {@Config}", config) + + // Scalar hint - renders as simple value + values := []int{10, 20, 30, 40, 50} + log.Info("Processing values: {$Values}", values) + + // Format specifier - applies padding + log.Info("Order {OrderId:00000} processed", 42) + + // Time formatting + log.Info("Event occurred at {Time:15:04:05}", time.Now()) + + // Multiple properties with format specifiers + log.Info("User {UserId:000} spent ${Amount:F2} on {ItemCount} items", + 123, 49.95, 3) + + // Properties not in template are shown separately + log.With("host", "api.example.com", "port", 443, "retry", 3). + Warning("Connection failed") + + // Complex nested structures + user := struct { + ID int + Name string + Tags []string + }{ + ID: 789, + Name: "Alice", + Tags: []string{"premium", "verified"}, + } + log.Info("User {@User} logged in", user) + + fmt.Println("\nāœ… Custom sink successfully using RenderMessage() to handle all template features!") +} \ No newline at end of file diff --git a/internal/parser/token.go b/internal/parser/token.go index a43b97b..2c11b01 100644 --- a/internal/parser/token.go +++ b/internal/parser/token.go @@ -7,8 +7,6 @@ import ( "strings" "time" "unicode/utf8" - - "github.com/willibrandon/mtlog/internal/capture" ) // MessageTemplateToken represents a single token in a message template. @@ -94,12 +92,12 @@ func (p *PropertyToken) formatValue(value any) string { // Handle different value types with format strings switch v := value.(type) { - case capture.Null: - // Null sentinel type renders as "nil" for strings - return v.String() - case *capture.CapturedStruct: - // Handle captured structs - render as struct notation - return formatStruct(v) + case time.Time: + // Handle time.Time first, before checking fmt.Stringer + if p.Format != "" { + return p.formatTime(v) + } + return formatValue(value) case int, int8, int16, int32, int64: if p.Format != "" { return p.formatNumber(v) @@ -115,11 +113,16 @@ func (p *PropertyToken) formatValue(value any) string { return p.formatFloat(v) } return formatValue(value) - case time.Time: - if p.Format != "" { - return p.formatTime(v) + case fmt.Stringer: + // Handle types that implement Stringer (includes Null and CapturedStruct) + // But check this AFTER concrete types like time.Time + str := v.String() + // Check if it's the special "nil" case (Null type) + if str == "nil" { + return str } - return formatValue(value) + // For other Stringers, use their string representation + return str case string: // Handle string formatting if p.Format == "l" { @@ -310,12 +313,6 @@ func (p *PropertyToken) applyAlignment(s string) string { } } -// formatStruct formats a CapturedStruct as Go struct notation. -func formatStruct(cs *capture.CapturedStruct) string { - // Simply delegate to the CapturedStruct's String() method - return cs.String() -} - func formatValue(value any) string { if value == nil { return "nil" // Go convention: nil without brackets From 93a1e89e2ae675c79288e8ba8265e347d60694e5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 4 Oct 2025 03:04:15 -0700 Subject: [PATCH 2/4] fix: address review feedback for RenderMessage() - Use reflection to identify capture.Null type instead of string comparison - Clarify comment about time.Time custom formatting vs String() method - Fix property detection in custom sink to check complete placeholder patterns (prevents false positives like "User" matching "{UserId}") --- examples/custom-sink/main.go | 14 +++++++++++++- internal/parser/token.go | 28 +++++++++++++++++----------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/examples/custom-sink/main.go b/examples/custom-sink/main.go index f1d583e..553f7a1 100644 --- a/examples/custom-sink/main.go +++ b/examples/custom-sink/main.go @@ -28,7 +28,19 @@ func (s *CustomSink) Emit(event *core.LogEvent) { // Optionally include extra properties not in the template for key, value := range event.Properties { - if !strings.Contains(event.MessageTemplate, "{"+key) { + // Check for complete placeholder patterns to avoid false positives + // (e.g., "User" should not match "{UserId}") + placeholder1 := "{" + key + "}" // Simple: {Key} + placeholder2 := "{" + key + ":" // With format: {Key:format} + placeholder3 := "{@" + key + "}" // Destructuring: {@Key} + placeholder4 := "{$" + key + "}" // Scalar: {$Key} + placeholder5 := "{" + key + "," // Alignment: {Key,10} + + if !strings.Contains(event.MessageTemplate, placeholder1) && + !strings.Contains(event.MessageTemplate, placeholder2) && + !strings.Contains(event.MessageTemplate, placeholder3) && + !strings.Contains(event.MessageTemplate, placeholder4) && + !strings.Contains(event.MessageTemplate, placeholder5) { fmt.Fprintf(s.output, " → %s: %v\n", key, value) } } diff --git a/internal/parser/token.go b/internal/parser/token.go index 2c11b01..157d7de 100644 --- a/internal/parser/token.go +++ b/internal/parser/token.go @@ -3,6 +3,7 @@ package parser import ( "encoding/json" "fmt" + "reflect" "strconv" "strings" "time" @@ -93,7 +94,8 @@ func (p *PropertyToken) formatValue(value any) string { // Handle different value types with format strings switch v := value.(type) { case time.Time: - // Handle time.Time first, before checking fmt.Stringer + // Handle time.Time explicitly to use custom formatting logic + // instead of its default String() method (time.Time implements fmt.Stringer) if p.Format != "" { return p.formatTime(v) } @@ -114,15 +116,19 @@ func (p *PropertyToken) formatValue(value any) string { } return formatValue(value) case fmt.Stringer: - // Handle types that implement Stringer (includes Null and CapturedStruct) - // But check this AFTER concrete types like time.Time - str := v.String() - // Check if it's the special "nil" case (Null type) - if str == "nil" { - return str + // Handle types that implement Stringer (includes Null and CapturedStruct from capture package) + // This case is checked AFTER concrete types like time.Time to ensure proper formatting + + // Use reflection to identify the capture.Null type without importing it + // This avoids import cycle issues while being more reliable than string comparison + typeName := reflect.TypeOf(v).String() + if typeName == "capture.Null" { + // This is the special Null sentinel type - preserve its "nil" representation + return v.String() } - // For other Stringers, use their string representation - return str + + // For other Stringers (like CapturedStruct), use their string representation + return v.String() case string: // Handle string formatting if p.Format == "l" { @@ -315,9 +321,9 @@ func (p *PropertyToken) applyAlignment(s string) string { func formatValue(value any) string { if value == nil { - return "nil" // Go convention: nil without brackets + return "nil" // Go convention: nil without brackets } - + switch v := value.(type) { case []byte: // Special handling for byte slices - render as string if valid UTF-8 From a0f63551a3a723216bff97c1373ebafda29b59dd Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 4 Oct 2025 03:26:29 -0700 Subject: [PATCH 3/4] docs: align terminology with capturing nomenclature --- README.md | 2 +- core/event.go | 2 +- core/event_test.go | 2 +- examples/custom-sink/main.go | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f7e4956..a636089 100644 --- a/README.md +++ b/README.md @@ -1373,7 +1373,7 @@ type CustomSink struct { func (s *CustomSink) Emit(event *core.LogEvent) { // Use RenderMessage() to properly render the message template - // This handles format specifiers, destructuring operators, and scalar hints + // This handles format specifiers, capturing operators, and scalar hints message := event.RenderMessage() // Format and write the log entry diff --git a/core/event.go b/core/event.go index 90fd8e1..838a2eb 100644 --- a/core/event.go +++ b/core/event.go @@ -39,7 +39,7 @@ func (e *LogEvent) AddProperty(name string, value any) { // RenderMessage renders the message template with the event's properties. // This method parses the MessageTemplate and replaces all placeholders with their -// corresponding property values, handling format specifiers, destructuring operators, +// corresponding property values, handling format specifiers, capturing operators, // and scalar hints. // // If parsing fails, the original MessageTemplate is returned as a fallback. diff --git a/core/event_test.go b/core/event_test.go index 6995d9c..40c4244 100644 --- a/core/event_test.go +++ b/core/event_test.go @@ -7,7 +7,7 @@ import ( // TestRenderMessage demonstrates that RenderMessage() fixes issue #64. // It shows that RenderMessage() correctly handles all template features. func TestRenderMessage(t *testing.T) { - t.Run("destructuring operator", func(t *testing.T) { + t.Run("capturing operator", func(t *testing.T) { event := &LogEvent{ MessageTemplate: "Configuration: {@Config}", Properties: map[string]any{ diff --git a/examples/custom-sink/main.go b/examples/custom-sink/main.go index 553f7a1..bb16be1 100644 --- a/examples/custom-sink/main.go +++ b/examples/custom-sink/main.go @@ -17,7 +17,7 @@ type CustomSink struct { func (s *CustomSink) Emit(event *core.LogEvent) { // Use RenderMessage() to properly render the message template - // This handles format specifiers, destructuring operators, and scalar hints + // This handles format specifiers, capturing operators, and scalar hints message := event.RenderMessage() // Format and write the log entry @@ -32,7 +32,7 @@ func (s *CustomSink) Emit(event *core.LogEvent) { // (e.g., "User" should not match "{UserId}") placeholder1 := "{" + key + "}" // Simple: {Key} placeholder2 := "{" + key + ":" // With format: {Key:format} - placeholder3 := "{@" + key + "}" // Destructuring: {@Key} + placeholder3 := "{@" + key + "}" // Capturing: {@Key} placeholder4 := "{$" + key + "}" // Scalar: {$Key} placeholder5 := "{" + key + "," // Alignment: {Key,10} @@ -79,7 +79,7 @@ func main() { // Simple message with properties log.Info("Application started on port {Port}", 8080) - // Destructuring operator - renders the entire struct + // Capturing operator - renders the entire struct config := map[string]any{ "debug": true, "port": 8080, From ba5acd4039bb2e83de3b9f7add2bc933776da61f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 4 Oct 2025 03:30:42 -0700 Subject: [PATCH 4/4] docs: update terminology for capturing operators in CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8ad30f..a71eb1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **RenderMessage() Method** - Public API for rendering message templates (#64) - New `RenderMessage()` method on `core.LogEvent` for custom sinks - - Properly handles destructuring operators (`{@Property}`), scalar hints (`{$Property}`), and format specifiers (`{Property:format}`) + - Properly handles capturing operators (`{@Property}`), scalar hints (`{$Property}`), and format specifiers (`{Property:format}`) - Enables custom sinks to render message templates without accessing internal parser - Returns the original template as fallback if parsing fails - Example: `message := event.RenderMessage()` renders the template with all properties