// Package logging configures structured logging for the service. // // It wraps log/slog rather than a third-party logger so that the output is // plain JSON or plain text with no vendor in the dependency graph, and so that // the level and format can be chosen from the environment the Helm chart sets. package logging import ( "context" "io" "log/slog" "os" "strings" ) // Logger is the logging interface the application depends on. Declaring it here // keeps handlers and middleware testable with a recorder, and keeps them from // importing slog directly. type Logger interface { Debug(msg string, args ...any) Info(msg string, args ...any) Warn(msg string, args ...any) Error(msg string, args ...any) With(args ...any) Logger } // Options configures a logger. type Options struct { // Level is debug, info, warn or error. Level string // Format is json or text. Format string // Service is the value of the service.name attribute. Service string // Environment is the deployment name. Environment string // Version is the build version, reported in logs. Version string // Commit is the source revision, reported in logs. Commit string // BuildDate is the build timestamp, reported in logs. BuildDate string // Writer overrides the destination, for tests. Writer io.Writer } // New builds a logger. func New(opts Options) Logger { writer := opts.Writer if writer == nil { writer = os.Stdout } handlerOpts := &slog.HandlerOptions{Level: parseLevel(opts.Level)} var handler slog.Handler if strings.EqualFold(opts.Format, "text") { handler = slog.NewTextHandler(writer, handlerOpts) } else { handler = slog.NewJSONHandler(writer, handlerOpts) } logger := slog.New(handler).With( slog.String("service", orDefault(opts.Service, "Go Bananas!!")), slog.String("environment", opts.Environment), slog.String("version", opts.Version), slog.String("commit", orDefault(opts.Commit, "unknown")), slog.String("built", orDefault(opts.BuildDate, "unknown")), ) return &slogLogger{logger: logger} } // parseLevel maps a configuration string onto a slog level, defaulting to info // so that a typo never silences the service. func parseLevel(raw string) slog.Level { switch strings.ToLower(strings.TrimSpace(raw)) { case "debug": return slog.LevelDebug case "warn", "warning": return slog.LevelWarn case "error": return slog.LevelError default: return slog.LevelInfo } } // orDefault returns value when it is not empty, otherwise fallback. func orDefault(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value } // slogLogger adapts *slog.Logger to the Logger interface. type slogLogger struct { logger *slog.Logger } func (l *slogLogger) Debug(msg string, args ...any) { l.logger.Debug(msg, args...) } func (l *slogLogger) Info(msg string, args ...any) { l.logger.Info(msg, args...) } func (l *slogLogger) Warn(msg string, args ...any) { l.logger.Warn(msg, args...) } func (l *slogLogger) Error(msg string, args ...any) { l.logger.Error(msg, args...) } func (l *slogLogger) With(args ...any) Logger { return &slogLogger{logger: l.logger.With(args...)} } // Discard returns a logger that drops everything. It exists so that tests and // one-off commands do not have to thread a logger through just to satisfy the // dependency. func Discard() Logger { return &slogLogger{logger: slog.New(slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 8}))} } // FromContext returns the logger stored on a context, or a discarding logger. func FromContext(ctx context.Context) Logger { if logger, ok := ctx.Value(loggerKey{}).(Logger); ok { return logger } return Discard() } type loggerKey struct{} // WithContext stores a logger on a context. func WithContext(ctx context.Context, logger Logger) context.Context { return context.WithValue(ctx, loggerKey{}, logger) }