aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMarc Pervaz Boocha <mboocha@sudomsg.com>2025-08-07 22:55:19 +0530
committerMarc Pervaz Boocha <mboocha@sudomsg.com>2025-08-07 22:55:19 +0530
commit2cb8a25e0c0e3ff93a0b96c63fe54a64f212f939 (patch)
tree3537af98281be071f384c24bc24e4c70a814ba38
parentRemoved the extra logger arguement from http (diff)
downloadkit-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.go172
-rw-r--r--logging/log_test.go130
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)
+ }
+ })
+}