diff --git a/log/log.go b/log/log.go index 3a82819..896b368 100644 --- a/log/log.go +++ b/log/log.go @@ -103,7 +103,7 @@ func createLog(e Entry) { namespacesMux.Lock() namespaces[e.Namespace] = true namespacesMux.Unlock() - + sliceTex.Lock() for _, c := range clients { func(c *Client, e Entry) { @@ -133,7 +133,7 @@ func createLog(e Entry) { func GetNamespaces() []string { namespacesMux.RLock() defer namespacesMux.RUnlock() - + result := make([]string, 0, len(namespaces)) for ns := range namespaces { result = append(result, ns) @@ -556,3 +556,37 @@ func fileInfo(skip int) string { } return fmt.Sprintf("%s:%d", file, line) } + +// Broadcast sends an [Entry] to all registered clients. This is the public +// entry point used by adapter packages (such as the slog handler) that +// construct entries themselves. The unexported level field is inferred from +// [Entry.Level] when not already set. +func Broadcast(e Entry) { + if e.level == 0 && e.Level != "" && e.Level != "TRACE" { + e.level = parseLevelString(e.Level) + } + createLog(e) +} + +func parseLevelString(s string) Level { + switch s { + case "TRACE": + return LTrace + case "DEBUG": + return LDebug + case "INFO": + return LInfo + case "NOTICE": + return LNotice + case "WARN": + return LWarn + case "ERROR": + return LError + case "PANIC": + return LPanic + case "FATAL": + return LFatal + default: + return LInfo + } +} diff --git a/slog/handler.go b/slog/handler.go new file mode 100644 index 0000000..0eb219b --- /dev/null +++ b/slog/handler.go @@ -0,0 +1,164 @@ +// Package slog provides a [log/slog.Handler] that routes structured log +// records into the log-socket broadcasting system, giving every slog-based +// caller free WebSocket streaming and the browser viewer UI. +package slog + +import ( + "context" + "fmt" + "log/slog" + "runtime" + "strings" + + "github.com/taigrr/log-socket/v2/log" +) + +// Handler implements [slog.Handler] by converting each [slog.Record] into a +// log-socket [log.Entry] and feeding it through the normal broadcast path. +// +// Attributes accumulated via [Handler.WithAttrs] are prepended to the log +// message as key=value pairs. Groups set via [Handler.WithGroup] prefix +// attribute keys with "group.". +type Handler struct { + namespace string + level slog.Level + attrs []slog.Attr + groups []string +} + +// ensure interface compliance at compile time. +var _ slog.Handler = (*Handler)(nil) + +// Option configures a [Handler]. +type Option func(*Handler) + +// WithNamespace sets the log-socket namespace for entries produced by this +// handler. If empty, [log.DefaultNamespace] is used. +func WithNamespace(ns string) Option { + return func(h *Handler) { + h.namespace = ns + } +} + +// WithLevel sets the minimum slog level the handler will accept. +func WithLevel(l slog.Level) Option { + return func(h *Handler) { + h.level = l + } +} + +// NewHandler returns a new [Handler] that writes to the log-socket broadcast +// system. Options may be used to set the namespace and minimum level. +func NewHandler(opts ...Option) *Handler { + h := &Handler{ + namespace: log.DefaultNamespace, + level: slog.LevelDebug, + } + for _, o := range opts { + o(h) + } + return h +} + +// Enabled reports whether the handler is configured to process records at +// the given level. +func (h *Handler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level +} + +// Handle converts r into a log-socket Entry and broadcasts it. +func (h *Handler) Handle(_ context.Context, r slog.Record) error { + var b strings.Builder + b.WriteString(r.Message) + + // Append pre-collected attrs. + for _, a := range h.attrs { + writeAttr(&b, h.groups, a) + } + + // Append record-level attrs. + r.Attrs(func(a slog.Attr) bool { + writeAttr(&b, h.groups, a) + return true + }) + + file := "???" + if r.PC != 0 { + fs := runtime.CallersFrames([]uintptr{r.PC}) + f, _ := fs.Next() + if f.File != "" { + short := f.File + if idx := strings.LastIndex(short, "/"); idx >= 0 { + short = short[idx+1:] + } + file = fmt.Sprintf("%s:%d", short, f.Line) + } + } + + e := log.Entry{ + Timestamp: r.Time, + Output: b.String(), + File: file, + Level: slogLevelToString(r.Level), + Namespace: h.namespace, + } + log.Broadcast(e) + return nil +} + +// WithAttrs returns a new Handler with the given attributes appended. +func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { + h2 := h.clone() + h2.attrs = append(h2.attrs, attrs...) + return h2 +} + +// WithGroup returns a new Handler where subsequent attributes are nested +// under the given group name. +func (h *Handler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + h2 := h.clone() + h2.groups = append(h2.groups, name) + return h2 +} + +func (h *Handler) clone() *Handler { + h2 := &Handler{ + namespace: h.namespace, + level: h.level, + attrs: make([]slog.Attr, len(h.attrs)), + groups: make([]string, len(h.groups)), + } + copy(h2.attrs, h.attrs) + copy(h2.groups, h.groups) + return h2 +} + +func writeAttr(b *strings.Builder, groups []string, a slog.Attr) { + if a.Equal(slog.Attr{}) { + return + } + b.WriteByte(' ') + for _, g := range groups { + b.WriteString(g) + b.WriteByte('.') + } + b.WriteString(a.Key) + b.WriteByte('=') + b.WriteString(a.Value.String()) +} + +func slogLevelToString(l slog.Level) string { + switch { + case l >= slog.LevelError: + return "ERROR" + case l >= slog.LevelWarn: + return "WARN" + case l >= slog.LevelInfo: + return "INFO" + default: + return "DEBUG" + } +} diff --git a/slog/handler_test.go b/slog/handler_test.go new file mode 100644 index 0000000..0f4465b --- /dev/null +++ b/slog/handler_test.go @@ -0,0 +1,118 @@ +package slog + +import ( + "context" + "log/slog" + "testing" + "time" + + "github.com/taigrr/log-socket/v2/log" +) + +// getWithTimeout reads from a log client with a timeout to avoid hanging tests. +func getWithTimeout(c *log.Client, timeout time.Duration) (log.Entry, bool) { + ch := make(chan log.Entry, 1) + go func() { ch <- c.Get() }() + select { + case e := <-ch: + return e, true + case <-time.After(timeout): + return log.Entry{}, false + } +} + +func TestHandler_Enabled(t *testing.T) { + h := NewHandler(WithLevel(slog.LevelWarn)) + if h.Enabled(context.Background(), slog.LevelInfo) { + t.Error("expected Info to be disabled when min level is Warn") + } + if !h.Enabled(context.Background(), slog.LevelWarn) { + t.Error("expected Warn to be enabled") + } + if !h.Enabled(context.Background(), slog.LevelError) { + t.Error("expected Error to be enabled") + } +} + +func TestHandler_Handle(t *testing.T) { + c := log.CreateClient() + defer c.Destroy() + c.SetLogLevel(log.LTrace) + + h := NewHandler(WithNamespace("test-ns")) + logger := slog.New(h) + + logger.Info("hello world", "key", "value") + + e, ok := getWithTimeout(c, time.Second) + if !ok { + t.Fatal("timed out waiting for log entry") + } + if e.Namespace != "test-ns" { + t.Errorf("namespace = %q, want %q", e.Namespace, "test-ns") + } + if e.Level != "INFO" { + t.Errorf("level = %q, want INFO", e.Level) + } + if e.Output == "" { + t.Error("output should not be empty") + } +} + +func TestHandler_WithAttrs(t *testing.T) { + c := log.CreateClient() + defer c.Destroy() + c.SetLogLevel(log.LTrace) + + h := NewHandler() + h2 := h.WithAttrs([]slog.Attr{slog.String("service", "api")}) + logger := slog.New(h2) + + logger.Info("request") + + e, ok := getWithTimeout(c, time.Second) + if !ok { + t.Fatal("timed out") + } + if e.Output != "request service=api" { + t.Errorf("output = %q, want %q", e.Output, "request service=api") + } +} + +func TestHandler_WithGroup(t *testing.T) { + c := log.CreateClient() + defer c.Destroy() + c.SetLogLevel(log.LTrace) + + h := NewHandler() + h2 := h.WithGroup("http").WithAttrs([]slog.Attr{slog.Int("status", 200)}) + logger := slog.New(h2) + + logger.Info("done") + + e, ok := getWithTimeout(c, time.Second) + if !ok { + t.Fatal("timed out") + } + if e.Output != "done http.status=200" { + t.Errorf("output = %q, want %q", e.Output, "done http.status=200") + } +} + +func TestSlogLevelMapping(t *testing.T) { + tests := []struct { + level slog.Level + want string + }{ + {slog.LevelDebug, "DEBUG"}, + {slog.LevelInfo, "INFO"}, + {slog.LevelWarn, "WARN"}, + {slog.LevelError, "ERROR"}, + } + for _, tt := range tests { + got := slogLevelToString(tt.level) + if got != tt.want { + t.Errorf("slogLevelToString(%v) = %q, want %q", tt.level, got, tt.want) + } + } +}