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
175 lines
4.2 KiB
Go
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,
|
|
}
|
|
}
|