diff options
author | Marc Pervaz Boocha <mboocha@sudomsg.com> | 2025-08-07 22:55:19 +0530 |
---|---|---|
committer | Marc Pervaz Boocha <mboocha@sudomsg.com> | 2025-08-07 22:55:19 +0530 |
commit | 2cb8a25e0c0e3ff93a0b96c63fe54a64f212f939 (patch) | |
tree | 3537af98281be071f384c24bc24e4c70a814ba38 | |
parent | Removed the extra logger arguement from http (diff) | |
download | kit-2cb8a25e0c0e3ff93a0b96c63fe54a64f212f939.tar kit-2cb8a25e0c0e3ff93a0b96c63fe54a64f212f939.tar.gz kit-2cb8a25e0c0e3ff93a0b96c63fe54a64f212f939.tar.bz2 kit-2cb8a25e0c0e3ff93a0b96c63fe54a64f212f939.tar.lz kit-2cb8a25e0c0e3ff93a0b96c63fe54a64f212f939.tar.xz kit-2cb8a25e0c0e3ff93a0b96c63fe54a64f212f939.tar.zst kit-2cb8a25e0c0e3ff93a0b96c63fe54a64f212f939.zip |
Added Better stack traces on panic and auto setup a sensible defaultv0.2.0
-rw-r--r-- | logging/log.go | 172 | ||||
-rw-r--r-- | logging/log_test.go | 130 |
2 files changed, 282 insertions, 20 deletions
diff --git a/logging/log.go b/logging/log.go index 5f9af8d..944b817 100644 --- a/logging/log.go +++ b/logging/log.go @@ -13,6 +13,7 @@ import ( "os" "runtime" "strings" + "time" ) type ctxLoggerKeyType struct{} @@ -63,6 +64,12 @@ func WithGroup(ctx context.Context, name string) (*slog.Logger, context.Context) return logger, ctx } +type StackFrame struct { + Function string `json:"function"` + File string `json:"file"` + Line int `json:"line"` +} + // RecoverAndLog logs a recovered panic or error with a stack trace using the logger from ctx. // // Typically used in a deferred recover block: @@ -74,31 +81,142 @@ func WithGroup(ctx context.Context, name string) (*slog.Logger, context.Context) // } // }() func RecoverAndLog(ctx context.Context, msg string, err any) { - const size = 64 << 10 // 64 KB stack trace buffer - buf := make([]byte, size) - buf = buf[:runtime.Stack(buf, false)] logger := FromContext(ctx) - logger.ErrorContext(ctx, msg, "Error", err, "stack", string(buf)) + handler := logger.Handler() + + if !handler.Enabled(ctx, slog.LevelError) { + return + } + + // Capture stack + const depth = 32 + pcs := make([]uintptr, depth) + n := runtime.Callers(3, pcs) // Skip: Callers, RecoverAndLog, defer-fn + frames := runtime.CallersFrames(pcs[:n]) + + var stack []StackFrame + for { + frame, more := frames.Next() + stack = append(stack, StackFrame{ + Function: frame.Function, + File: frame.File, + Line: frame.Line, + }) + if !more { + break + } + } + var pc uintptr + if n > 0 { + pc = pcs[0] + } + r := slog.NewRecord(time.Now(), slog.LevelError, msg, pc) + r.Add("error", err, "stack", stack) + handler.Handle(ctx, r) } // LogSink defines where log output should be written. // // It is used to configure the log destination in Setup. -type LogSink string +type LogSink int const ( // SinkStdout directs logs to the standard output (os.Stdout). - SinkStdout LogSink = "stdout" + SinkStdout LogSink = iota // SinkStderr directs logs to the standard error (os.Stderr). - SinkStderr LogSink = "stderr" + SinkStderr // SinkFile directs logs to a file specified by the File field in LogConfig. // // If configured, the File field must be a valid path for writing logs. - SinkFile LogSink = "file" + SinkFile ) +func (s LogSink) String() string { + switch s { + case SinkStdout: + return "stdout" + case SinkStderr: + return "stderr" + case SinkFile: + return "file" + default: + return fmt.Sprintf("LogSink(%d)", s) + } +} + +func (s *LogSink) Set(str string) error { + switch strings.ToLower(str) { + case "stdout": + *s = SinkStdout + case "stderr": + *s = SinkStderr + case "file": + *s = SinkFile + default: + return fmt.Errorf("invalid LogSink %q", str) + } + return nil +} + +func (s LogSink) AppendText(b []byte) ([]byte, error) { + return append(b, s.String()...), nil +} + +func (s LogSink) MarshalText() ([]byte, error) { + return s.AppendText(nil) +} + +func (s *LogSink) UnmarshalText(b []byte) error { + return s.Set(string(b)) +} + +type LogFormat int + +const ( + // SinkStdout directs logs to the standard output (os.Stdout). + FormatText LogFormat = iota + + // SinkStderr directs logs to the standard error (os.Stderr). + FormatJSON +) + +func (s LogFormat) String() string { + switch s { + case FormatJSON: + return "json" + case FormatText: + return "text" + default: + return fmt.Sprintf("LogSink(%d)", s) + } +} + +func (s *LogFormat) Set(str string) error { + switch strings.ToLower(str) { + case "text": + *s = FormatText + case "json": + *s = FormatJSON + default: + return fmt.Errorf("invalid LogSink %q", str) + } + return nil +} + +func (s LogFormat) AppendText(b []byte) ([]byte, error) { + return append(b, s.String()...), nil +} + +func (s LogFormat) MarshalText() ([]byte, error) { + return s.AppendText(nil) +} + +func (s *LogFormat) UnmarshalText(b []byte) error { + return s.Set(string(b)) +} + // LogConfig holds configuration for the logging setup. // // Level specifies the minimum level for logs (e.g., slog.LevelInfo). @@ -113,10 +231,14 @@ const ( type LogConfig struct { Level slog.Level `toml:"level"` Sink LogSink `toml:"sink"` - Format string `toml:"format"` + Format LogFormat `toml:"format"` File string `toml:"file"` } +func init() { + Setup(LogConfig{}) // Setup Sensible Defaults +} + // Setup initializes the default slog.Logger based on the provided LogConfig. // // It configures the log output sink, log format, and log level. @@ -128,7 +250,7 @@ type LogConfig struct { // cfg := LogConfig{ // Level: slog.LevelInfo, // Sink: SinkFile, -// Format: "json", +// Format: FormatJSON, // File: "/var/log/myapp.log", // } // if err := Setup(cfg); err != nil { @@ -137,24 +259,34 @@ type LogConfig struct { // // After a successful call, the global slog logger will log accordingly. func Setup(cfg LogConfig) error { + logger, err := New(cfg) + if err != nil { + return nil + } + slog.SetDefault(logger) + return nil +} + +// Same as Setup but returns the logger instead of setting up the global logger +func New(cfg LogConfig) (*slog.Logger, error) { var w io.Writer switch cfg.Sink { - case SinkStdout, "": + case SinkStdout: w = os.Stdout case SinkStderr: w = os.Stderr case SinkFile: if cfg.File == "" { - return fmt.Errorf("log sink set to 'file' but no file path provided") + return nil, fmt.Errorf("log sink set to 'file' but no file path provided") } f, err := os.OpenFile(cfg.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { - return fmt.Errorf("failed to open log file: %w", err) + return nil, fmt.Errorf("failed to open log file: %w", err) } w = f default: - return fmt.Errorf("unsupported log sink: %s", cfg.Sink) + return nil, fmt.Errorf("invalid LogSink %v", cfg.Sink) } var handler slog.Handler @@ -162,15 +294,15 @@ func Setup(cfg LogConfig) error { Level: cfg.Level, } - switch strings.ToLower(cfg.Format) { - case "text": + switch cfg.Format { + case FormatText: handler = slog.NewTextHandler(w, opts) - case "json", "": + case FormatJSON: handler = slog.NewJSONHandler(w, opts) default: - return fmt.Errorf("unsupported log format: %s", cfg.Format) + return nil, fmt.Errorf("invalid LogFormat: %v", cfg.Format) } - slog.SetDefault(slog.New(handler)) - return nil + logger := slog.New(handler) + return logger, nil } diff --git a/logging/log_test.go b/logging/log_test.go index 93ceaf6..41ee398 100644 --- a/logging/log_test.go +++ b/logging/log_test.go @@ -3,6 +3,7 @@ package logging_test import ( "errors" "log/slog" + "strings" "testing" "go.sudomsg.com/kit/logging" @@ -139,3 +140,132 @@ func TestWithGroup(t *testing.T) { t.Errorf("expected 'user' and 'id' attributes in log record, %v", records) } } + +func TestLogSink(t *testing.T) { + t.Parallel() + + t.Run("RoundTrip", func(t *testing.T) { + t.Parallel() + + testCases := []logging.LogSink{ + logging.SinkStdout, + logging.SinkStderr, + logging.SinkFile, + } + + for _, original := range testCases { + t.Run(original.String(), func(t *testing.T) { + t.Run("case same", func(t *testing.T) { + t.Run(original.String(), func(t *testing.T) { + text, err := original.MarshalText() + if err != nil { + t.Fatalf("MarshalText failed: %v", err) + } + + var decoded logging.LogSink + if err := decoded.UnmarshalText(text); err != nil { + t.Fatalf("UnmarshalText failed: %v", err) + } + + if decoded != original { + t.Fatalf("Round-trip mismatch: got %v, want %v", decoded, original) + } + }) + }) + t.Run("case not same", func(t *testing.T) { + t.Run(original.String(), func(t *testing.T) { + text, err := original.MarshalText() + if err != nil { + t.Fatalf("MarshalText failed: %v", err) + } + + var decoded logging.LogSink + if err := decoded.UnmarshalText([]byte(strings.ToUpper(string(text)))); err != nil { + t.Fatalf("UnmarshalText failed: %v", err) + } + + if decoded != original { + t.Fatalf("Round-trip mismatch: got %v, want %v", decoded, original) + } + }) + }) + + }) + } + }) + + t.Run("Invalid", func(t *testing.T) { + t.Parallel() + + input := "invalid" + var decoded logging.LogSink + + if err := decoded.UnmarshalText([]byte(input)); err == nil { + t.Errorf("expected error for input %q, got none", input) + } + }) +} + +func TestLogFormat(t *testing.T) { + t.Parallel() + + t.Run("RoundTrip", func(t *testing.T) { + t.Parallel() + + testCases := []logging.LogFormat{ + logging.FormatText, + logging.FormatJSON, + } + + for _, original := range testCases { + t.Run(original.String(), func(t *testing.T) { + t.Run("case same", func(t *testing.T) { + t.Run(original.String(), func(t *testing.T) { + text, err := original.MarshalText() + if err != nil { + t.Fatalf("MarshalText failed: %v", err) + } + + var decoded logging.LogFormat + if err := decoded.UnmarshalText(text); err != nil { + t.Fatalf("UnmarshalText failed: %v", err) + } + + if decoded != original { + t.Fatalf("Round-trip mismatch: got %v, want %v", decoded, original) + } + }) + }) + t.Run("case not same", func(t *testing.T) { + t.Run(original.String(), func(t *testing.T) { + text, err := original.MarshalText() + if err != nil { + t.Fatalf("MarshalText failed: %v", err) + } + + var decoded logging.LogFormat + if err := decoded.UnmarshalText([]byte(strings.ToUpper(string(text)))); err != nil { + t.Fatalf("UnmarshalText failed: %v", err) + } + + if decoded != original { + t.Fatalf("Round-trip mismatch: got %v, want %v", decoded, original) + } + }) + }) + + }) + } + }) + + t.Run("Invalid", func(t *testing.T) { + t.Parallel() + + input := "invalid" + var decoded logging.LogFormat + + if err := decoded.UnmarshalText([]byte(input)); err == nil { + t.Errorf("expected error for input %q, got none", input) + } + }) +} |