// 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" "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, stopping at // the first error. func (cl *MultiplexHandler) Handle( ctx context.Context, record slog.Record, ) error { for _, handler := range cl.handlers { err := handler.Handle(ctx, record) if err != nil { return err } } return nil } // 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, } }