Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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

- **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)
Expand Down
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, capturing 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
Expand Down
38 changes: 37 additions & 1 deletion core/event.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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, capturing 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)
}
77 changes: 77 additions & 0 deletions core/event_test.go
Original file line number Diff line number Diff line change
@@ -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("capturing 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)
}
})
}
121 changes: 121 additions & 0 deletions examples/custom-sink/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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, capturing 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 {
// 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 + "}" // Capturing: {@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)
}
}
}

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)

// Capturing 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!")
}
43 changes: 23 additions & 20 deletions internal/parser/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@ package parser
import (
"encoding/json"
"fmt"
"reflect"
"strconv"
"strings"
"time"
"unicode/utf8"

"github.com/willibrandon/mtlog/internal/capture"
)

// MessageTemplateToken represents a single token in a message template.
Expand Down Expand Up @@ -94,12 +93,13 @@ 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 explicitly to use custom formatting logic
// instead of its default String() method (time.Time implements 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)
Expand All @@ -115,11 +115,20 @@ 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 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()
}
return formatValue(value)

// For other Stringers (like CapturedStruct), use their string representation
return v.String()
case string:
// Handle string formatting
if p.Format == "l" {
Expand Down Expand Up @@ -310,17 +319,11 @@ 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
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
Expand Down