Files
notifier/internal/logging/logger.go
T
igodwin 72f154ab07 feat(observability): slog-backed logging, Prometheus metrics, grpc health
- internal/logging now wraps log/slog; logging.format json/text finally
  works (json is the documented default). Same exported API.
- New internal/metrics: /metrics on the configured metrics port with
  notification gauges by status/type, queue depth, and HTTP request
  count/duration labeled by mux route pattern; sampled from service
  stats so the service layer stays metrics-agnostic.
- Standard grpc.health.v1 health service registered (k8s gRPC probes);
  gRPC MaxRecvMsgSize bounded to match the REST 1 MB body limit.
- Dedicated health listener on health_check.port serving /health and
  /readyz (probes now work in grpc-only mode); metrics, health, and
  REST servers all shut down gracefully.
- main wires retry backoff, CORS, readiness checks, and TLS from config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 09:16:12 -07:00

190 lines
4.5 KiB
Go

package logging
import (
"fmt"
"io"
"log/slog"
"os"
"time"
)
// Logger provides structured logging backed by log/slog, with UTC RFC3339
// timestamps and a level-gated API compatible with the previous
// *log.Logger-based implementation.
type Logger struct {
slogger *slog.Logger
level LogLevel
}
// LogLevel represents the logging level
type LogLevel int
const (
DebugLevel LogLevel = iota
InfoLevel
WarnLevel
ErrorLevel
)
// toSlogLevel maps our LogLevel to the equivalent slog.Level.
func (l LogLevel) toSlogLevel() slog.Level {
switch l {
case DebugLevel:
return slog.LevelDebug
case InfoLevel:
return slog.LevelInfo
case WarnLevel:
return slog.LevelWarn
case ErrorLevel:
return slog.LevelError
default:
return slog.LevelInfo
}
}
// replaceAttr normalizes the slog time attribute to a UTC RFC3339 timestamp
// so log output has a stable, predictable format regardless of handler.
func replaceAttr(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
if t, ok := a.Value.Any().(time.Time); ok {
a.Value = slog.StringValue(t.UTC().Format(time.RFC3339))
}
}
return a
}
// newHandler builds a slog.Handler for the given format ("json" or "text",
// with "json" as the default), level, and output writer.
func newHandler(format string, level LogLevel, output io.Writer) slog.Handler {
opts := &slog.HandlerOptions{
Level: level.toSlogLevel(),
ReplaceAttr: replaceAttr,
}
switch format {
case "text":
return slog.NewTextHandler(output, opts)
default:
return slog.NewJSONHandler(output, opts)
}
}
// New creates a new logger using the text format with the given level and
// output writer.
func New(level LogLevel, output io.Writer) *Logger {
if output == nil {
output = os.Stdout
}
return &Logger{
slogger: slog.New(newHandler("text", level, output)),
level: level,
}
}
// NewFromConfig creates a logger from configuration using the json format,
// matching the documented default for logging.format.
func NewFromConfig(levelStr string, outputPath string) (*Logger, error) {
return NewFromOptions(levelStr, "json", outputPath)
}
// NewFromOptions creates a logger from configuration with an explicit
// format ("json", "text", or "" which defaults to "json"). outputPath may be
// "stdout", "stderr", "" (defaults to stdout), or a file path, which is
// opened for append (creating it if necessary).
func NewFromOptions(levelStr string, format string, outputPath string) (*Logger, error) {
level := parseLevel(levelStr)
var output io.Writer
switch outputPath {
case "stdout", "":
output = os.Stdout
case "stderr":
output = os.Stderr
default:
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, err
}
output = file
}
return &Logger{
slogger: slog.New(newHandler(format, level, output)),
level: level,
}, nil
}
// Slog exposes the underlying *slog.Logger for structured call sites.
func (l *Logger) Slog() *slog.Logger {
return l.slogger
}
// Debug logs a debug message
func (l *Logger) Debug(msg string) {
l.slogger.Debug(msg)
}
// Debugf logs a formatted debug message
func (l *Logger) Debugf(format string, args ...interface{}) {
l.slogger.Debug(fmt.Sprintf(format, args...))
}
// Info logs an info message
func (l *Logger) Info(msg string) {
l.slogger.Info(msg)
}
// Infof logs a formatted info message
func (l *Logger) Infof(format string, args ...interface{}) {
l.slogger.Info(fmt.Sprintf(format, args...))
}
// Warn logs a warning message
func (l *Logger) Warn(msg string) {
l.slogger.Warn(msg)
}
// Warnf logs a formatted warning message
func (l *Logger) Warnf(format string, args ...interface{}) {
l.slogger.Warn(fmt.Sprintf(format, args...))
}
// Error logs an error message
func (l *Logger) Error(msg string) {
l.slogger.Error(msg)
}
// Errorf logs a formatted error message
func (l *Logger) Errorf(format string, args ...interface{}) {
l.slogger.Error(fmt.Sprintf(format, args...))
}
// Fatal logs a fatal message at error level and exits
func (l *Logger) Fatal(msg string) {
l.slogger.Error(msg)
os.Exit(1)
}
// Fatalf logs a formatted fatal message at error level and exits
func (l *Logger) Fatalf(format string, args ...interface{}) {
l.slogger.Error(fmt.Sprintf(format, args...))
os.Exit(1)
}
// parseLevel parses a log level string
func parseLevel(levelStr string) LogLevel {
switch levelStr {
case "debug":
return DebugLevel
case "info":
return InfoLevel
case "warn", "warning":
return WarnLevel
case "error":
return ErrorLevel
default:
return InfoLevel
}
}