diff --git a/internal/observability/helpers.go b/internal/observability/helpers.go new file mode 100644 index 0000000..316e0e1 --- /dev/null +++ b/internal/observability/helpers.go @@ -0,0 +1,15 @@ +package observability + +// Small helpers to keep logging consistent and low-friction across the codebase. + +func Info(event string, component string, message string, fields map[string]any) { + LogJSON(LogLevelInfo, event, component, message, fields, nil) +} + +func Warn(event string, component string, message string, fields map[string]any, err error) { + LogJSON(LogLevelWarn, event, component, message, fields, err) +} + +func Error(event string, component string, message string, fields map[string]any, err error) { + LogJSON(LogLevelError, event, component, message, fields, err) +} diff --git a/internal/observability/log.go b/internal/observability/log.go index ab30209..d8696af 100644 --- a/internal/observability/log.go +++ b/internal/observability/log.go @@ -2,6 +2,7 @@ package observability import ( "encoding/json" + "fmt" "log" "time" ) @@ -43,7 +44,14 @@ func LogJSON(level LogLevel, event string, component string, message string, fie // Best-effort. If encoding fails, fall back to a minimal line. bytes, marshalErr := json.Marshal(entry) if marshalErr != nil { - log.Printf("level=%s event=%s component=%s error=%q", level, event, component, marshalErr.Error()) + // Keep output JSON-only even on failures by constructing a minimal entry. + // Marshal individual strings to ensure proper escaping. + tsBytes, _ := json.Marshal(time.Now().UTC().Format(time.RFC3339Nano)) + levelBytes, _ := json.Marshal(level) + eventBytes, _ := json.Marshal("log_marshal_failed") + componentBytes, _ := json.Marshal(component) + errBytes, _ := json.Marshal(marshalErr.Error()) + log.Print(fmt.Sprintf(`{"ts":%s,"level":%s,"event":%s,"component":%s,"error":%s}`, tsBytes, levelBytes, eventBytes, componentBytes, errBytes)) return }