Files
simplelog/simplelog.go
T
clawbot 151dd42b4b
check / check (push) Successful in 35s
Return sink write errors from every handler (closes #22)
The console and JSON handlers threw away the error from their write to
stdout, so a lost log line looked delivered. They now return it,
wrapped. The webhook handler also returns an error for a status outside
2xx, and no longer follows redirects, which could resend the request
without the record. The multiplex handler passes the record to every
handler and returns their errors joined with errors.Join instead of
stopping at the first.

Both stdout handlers gain an unexported writer, nil meaning os.Stdout at
write time, so tests can supply a sink that fails. The README says what
each handler returns and that the slog.Logger methods discard a
handler's error.

Model: opus-5-5
2026-10-06 08:45:17 +02:00

175 lines
4.2 KiB
Go

// Package simplelog installs a multiplexing slog handler as the process
// default on import. It logs human-readable colored output when stdout is
// a terminal, JSON otherwise, and can additionally POST each record to a
// webhook configured via the LOGGER_WEBHOOK_URL environment variable.
package simplelog
import (
"context"
"encoding/json"
"errors"
"log"
"log/slog"
"os"
"time"
"github.com/google/uuid"
"github.com/mattn/go-isatty"
)
//nolint:gochecknoglobals // webhook destination is read once from the environment
var webhookURL = os.Getenv("LOGGER_WEBHOOK_URL")
//nolint:gochecknoglobals // package-level default logger state is the package's design
var (
ourCustomLogger *slog.Logger
ourCustomHandler slog.Handler
)
//nolint:gochecknoinits // installs itself as slog default on import by design
func init() {
ourCustomHandler = NewMultiplexHandler()
ourCustomLogger = slog.New(ourCustomHandler)
slog.SetDefault(ourCustomLogger)
}
// MultiplexHandler fans each log record out to a set of underlying
// handlers.
type MultiplexHandler struct {
handlers []ExtendedHandler
}
// NewMultiplexHandler returns a handler that writes colored console
// output when stdout is a terminal and JSON otherwise, plus an optional
// webhook handler when LOGGER_WEBHOOK_URL is set.
func NewMultiplexHandler() slog.Handler {
cl := &MultiplexHandler{}
if isatty.IsTerminal(os.Stdout.Fd()) {
cl.handlers = append(cl.handlers, NewConsoleHandler())
} else {
cl.handlers = append(cl.handlers, NewJSONHandler())
}
if webhookURL != "" {
handler, err := NewWebhookHandler(webhookURL)
if err != nil {
log.Fatalf("Failed to initialize Webhook handler: %v", err)
}
cl.handlers = append(cl.handlers, handler)
}
return cl
}
// Handle forwards the record to every underlying handler, including the
// ones after a handler that fails. It returns the failures joined with
// errors.Join, or nil when every handler succeeded.
func (cl *MultiplexHandler) Handle(
ctx context.Context,
record slog.Record,
) error {
var errs []error
for _, handler := range cl.handlers {
err := handler.Handle(ctx, record)
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// Enabled reports whether the handler processes records at the given
// level; it always returns true.
func (cl *MultiplexHandler) Enabled(
_ context.Context,
_ slog.Level,
) bool {
// send us all events
return true
}
// WithAttrs returns a new MultiplexHandler whose underlying handlers
// each carry the given attributes.
func (cl *MultiplexHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
newHandlers := make([]ExtendedHandler, len(cl.handlers))
for i, handler := range cl.handlers {
newHandlers[i] = handler.WithAttrs(attrs)
}
return &MultiplexHandler{handlers: newHandlers}
}
// WithGroup returns a new MultiplexHandler whose underlying handlers
// each use the given group name.
func (cl *MultiplexHandler) WithGroup(name string) slog.Handler {
newHandlers := make([]ExtendedHandler, len(cl.handlers))
for i, handler := range cl.handlers {
newHandlers[i] = handler.WithGroup(name)
}
return &MultiplexHandler{handlers: newHandlers}
}
// ExtendedEvent describes an Event augmented with caller file and line
// information.
type ExtendedEvent interface {
GetID() uuid.UUID
GetTimestamp() time.Time
GetLevel() string
GetMessage() string
GetData() json.RawMessage
GetFile() string
GetLine() int
}
type extendedEvent struct {
Event
File string `json:"file"`
Line int `json:"line"`
}
func (e extendedEvent) GetID() uuid.UUID {
return e.ID
}
func (e extendedEvent) GetTimestamp() time.Time {
return e.Timestamp
}
func (e extendedEvent) GetLevel() string {
return e.Level
}
func (e extendedEvent) GetMessage() string {
return e.Message
}
func (e extendedEvent) GetData() json.RawMessage {
return e.Data
}
func (e extendedEvent) GetFile() string {
return e.File
}
func (e extendedEvent) GetLine() int {
return e.Line
}
// NewExtendedEvent wraps baseEvent with the caller file and line it was
// logged from.
//
//nolint:ireturn // returning the interface is this constructor's public API
func NewExtendedEvent(baseEvent Event, file string, line int) ExtendedEvent {
return extendedEvent{
Event: baseEvent,
File: file,
Line: line,
}
}