Files
Timmy Welch 15a1510cbb Add a leveled logging package
Write through a diode queue so a slow writer never blocks the
caller
Aliases allow multiple loggers to have their level set at the same time
The "main" logger is a default normal logger "" is rewritten to "main"
The ALL pseudo-logger controls the default logger level
FormatInt formats human readable numbers
Supply a flag.Value for parsing a cli flag
logger names preserve case. Prefer functions over setting values directly
Tests are included
2026-11-05 16:27:52 -08:00

459 lines
11 KiB
Go

package log
import (
"context"
"fmt"
stdlog "log"
"path"
"runtime"
"slices"
"strconv"
"strings"
)
type Level int
func (l Level) String() string {
if str, ok := Levels[l]; ok {
return str
}
return strconv.Itoa(int(l))
}
func (l Level) GoString() string {
return l.String()
}
func FormatInt[T uint32 | int | int64](n T) string {
in := strconv.FormatInt(int64(n), 10)
numOfDigits := len(in)
if n < 0 {
numOfDigits-- // First character is the - sign (not a digit)
}
numOfCommas := (numOfDigits - 1) / 3
out := make([]byte, len(in)+numOfCommas)
if n < 0 {
in, out[0] = in[1:], '-'
}
for i, j, k := len(in)-1, len(out)-1, 0; ; i, j = i-1, j-1 {
out[j] = in[i]
if i == 0 {
return string(out)
}
if k++; k == 3 {
j, k = j-1, 0
out[j] = ','
}
}
}
// LogLevels needs Logger to be lowercase. Use SetLevel instead of assigning to this directly
var LogLevels = map[Logger]Level{}
func SetLevel(logger Logger, level Level) {
if logger == ALL {
for logger := range LogLevels {
LogLevels[logger] = level
}
return
}
logger = Logger(strings.ToLower(string(logger)))
loggers, isAlias := LoggerAliases[logger]
if isAlias {
for _, logger := range loggers {
LogLevels[logger] = level
}
return
}
LogLevels[logger] = level
LogLevels[Logger(strings.ToLower(string(logger)))] = level
}
var LoggerAliases = map[Logger][]Logger{}
func Aliases() []string {
aliases := make([]string, 0, len(LoggerAliases))
for alias := range LoggerAliases {
aliases = append(aliases, string(alias))
}
return aliases
}
func AvailableLoggers() []string {
_loggers := make([]string, len(LogLevels))
_loggers[0] = string(defaultLogger)
loggers := _loggers[1:1]
for logger := range LogLevels {
if logger == defaultLogger {
continue
}
loggers = append(loggers, string(logger))
}
slices.Sort(loggers)
return _loggers
}
func AvailableLevels() []string {
_levels := make([]Level, 0, len(Levels))
for level := range Levels {
_levels = append(_levels, level)
}
slices.Sort(_levels)
_strLevels := make([]string, 0, len(_levels))
for _, level := range _levels {
_strLevels = append(_strLevels, level.String())
}
return _strLevels
}
// Logger Use NewLogger
type Logger string
const (
Trace Level = -5 // Trace
Debug Level = -3 // Debug
MoreVerbose Level = -2 // More Verbose
Verbose Level = -1 // Verbose
Standard Level = 0 // Standard
Required Level = 1 // Required
Fatal Level = 9999 // Fatal
)
type (
// Record [4]any
Record struct {
logger Logger
level Level
pcs []uintptr
msg string
args []any
}
)
type Flag map[Logger]Level
func (f Flag) String() string {
var str strings.Builder
for key, value := range f {
str.WriteString(string(key))
str.WriteRune(':')
str.WriteString(value.String())
str.WriteString(",")
}
return strings.TrimRight(str.String(), ",")
}
func (f *Flag) Get() any {
delete(*f, "started")
return *f
}
func (f *Flag) Set(s string) error {
if (*f) == nil {
*f = Flag{"started": 0}
} else if _, started := (*f)["started"]; !started {
*f = Flag{"started": 0} // clear the map if this is the first time it's called
}
for item := range strings.SplitSeq(strings.ToLower(s), ",") {
logName, levelName := Partition(item, ":")
if logName == "" {
logName = "ALL"
}
if levelName == "" {
levelName = logName
logName = "ALL"
}
logger := Logger(logName)
_, isLogger := LogLevels[logger]
_, isAlias := LoggerAliases[logger]
if !(isLogger || isAlias || logger == "ALL") {
return fmt.Errorf("unknown log name: %v", logName)
}
var validLevel bool
(*f)[logger], validLevel = GetLevel(levelName)
if !validLevel {
return fmt.Errorf("unknown log level: %v", levelName)
}
SetLevel(logger, (*f)[logger])
}
return nil
}
var LevelNames = map[string]Level{
"trace": Trace,
"debug": Debug,
"moreverbose": MoreVerbose,
"verbose": Verbose,
"standard": Standard,
"required": Required,
"fatal": Fatal,
}
var Levels = map[Level]string{
Trace: "Trace",
Debug: "Debug",
MoreVerbose: "MoreVerbose",
Verbose: "Verbose",
Standard: "Standard",
Required: "Required",
Fatal: "Fatal",
}
var tokens = []string{ // TODO: Add a log format option??
"{level}",
"{message}",
"{file}",
"{longfile}",
"{line}",
"{function}",
"{logger}",
}
var funcNames = map[string]bool{
"Callers": false, // we exclude the runtime module so shouldn't be necessary. Technically true for all other functions listed here as well
"Fatal": true,
"Fatalf": true,
"Fatalln": true,
"Flags": true,
"Panic": true,
"Panicf": true,
"Panicln": true,
"Prefix": true,
"Print": true,
"Printf": true,
"Println": true,
"Debug": true,
"DebugContext": true,
"Error": true,
"ErrorContext": true,
"Info": true,
"InfoContext": true,
"Log": true,
"LogAttrs": true,
"Warn": true,
"WarnContext": true,
}
var modNames = map[string]bool{
"log": true,
"slog": true,
"slogr": true,
"logr": true,
"zap": true,
"runtime": true,
"zerolog": true,
}
var LogFormat = "{file}:{line}: {message}"
func FromContext(ctx context.Context) Logger {
logger, ok := ctx.Value(Logger("context")).(Logger)
if ok {
return logger
}
return defaultLogger
}
func NewContext(ctx context.Context, logger Logger) context.Context {
return context.WithValue(ctx, Logger("context"), logger)
}
func GetLevel(str string) (Level, bool) {
level, ok := LevelNames[strings.ToLower(str)]
if ok {
return level, true
}
num, err := strconv.Atoi(str)
if err == nil {
return Level(num), true
}
return Standard, false
}
func format(fmtString string, r Record) string {
workStr := fmtString
i := make([]any, 0, 5)
function, file, line := "", "", -1
start := strings.Index(workStr, "{")
for start >= 0 && start < len(workStr) {
workStr = workStr[start:]
end := strings.Index(workStr, "}")
if end < 0 {
break
}
switch key := workStr[:end+1]; key {
case "{level}":
i = append(i, r.level)
case "{message}":
msg := ""
if r.msg == "" {
msg = fmt.Sprintln(r.args...)
msg = msg[:len(msg)-1]
} else {
msg = fmt.Sprintf(r.msg, r.args...)
}
i = append(i, msg)
case "{file}":
if line == -1 {
function, file, line = GetRealFunc(r.pcs)
}
i = append(i, path.Base(file))
case "{longfile}":
if line == -1 {
function, file, line = GetRealFunc(r.pcs)
}
i = append(i, file)
case "{line}":
if line == -1 {
function, file, line = GetRealFunc(r.pcs)
}
i = append(i, line)
case "{function}":
if line == -1 {
function, file, line = GetRealFunc(r.pcs)
}
i = append(i, function)
case "{logger}":
i = append(i, r.logger)
}
workStr = workStr[1:]
start = strings.Index(workStr, "{")
}
sprintString := strings.NewReplacer(
"{level}", "%v",
"{message}", "%v",
"{file}", "%v",
"{longfile}", "%v",
"{line}", "%v",
"{function}", "%v",
"{logger}", "%v",
).Replace(fmtString)
return fmt.Sprintf(sprintString, i...)
}
// Partition splits the string at the first occurrence of sep, and return the part before the separator and the part after the separator. If the separator is not found, return the string itself, followed an empty string.
// (The same as python's str.partition)
func Partition(s string, sep string) (string, string) {
index := strings.Index(s, sep)
if index < 0 {
return s, ""
}
return s[0:index], s[index+1:]
}
// RPartition splits the string at the last occurrence of sep, and return the part before the separator and the part after the separator. If the separator is not found, return an empty string, followed by the string itself.
// (The same as python's str.rpartition)
func RPartition(s string, sep string) (string, string) {
index := strings.LastIndex(s, sep)
if index < 0 {
return "", s
}
return s[0:index], s[index+1:]
}
// IsRealFunc checks the given function as taken from runtime.Frame.Function and determines if it is the real calling function. EG not from an internal call in a logging package
func IsRealFunc(name string) bool {
_, funcname := RPartition(name, ".")
if funcNames[funcname] {
return false
}
subMod, modName := RPartition(name, "/")
_ = subMod
modName, _ = Partition(modName, ".")
return !modNames[modName]
}
// GetRealFunc checks each stack fram to find the real calling function to get a useful file and line number
func GetRealFunc(pcs []uintptr) (string, string, int) {
frames := runtime.CallersFrames(pcs)
next := true
var frame runtime.Frame
for next {
frame, next = frames.Next()
if !IsRealFunc(frame.Function) {
continue
}
return frame.Function, strings.ReplaceAll(frame.File, "gitea.narnian.us/lordwelch/comic-hasher/", "./"), frame.Line
}
return "", "", 0
}
func (l Logger) Log(level Level, msg string, args ...any) {
pcs := make([]uintptr, 16) // just to make sure we don't run into any idiocy about function call depth
n := runtime.Callers(2, pcs)
pcs = pcs[0:n]
// This should never actually happen
loggerLevel, ok := LogLevels[Logger(strings.ToLower(string(l)))]
if !ok {
loggerLevel = LogLevels[ALL]
}
if level < loggerLevel {
return
}
record := Record{
logger: l,
level: level,
pcs: pcs,
msg: msg,
args: args,
}
if Diode == nil {
stdlog.Println(format(LogFormat, record))
return
}
Diode.Set(record)
}
// Println is Logger.Log(level, "", args...)
func (l Logger) Println(level Level, args ...any) {
l.Log(level, "", args...)
}
// EnableNonBlocking logging please wait for 11 Milliseconds when exiting to ensure all logs are processed
func EnableNonBlocking(ctx context.Context, queueSize int) {
Diode = NewManyToOne(ctx, queueSize, nil)
go asyncLog(Diode)
}
// NewLogger creates a logger. Any aliases are additional names for the same logger, so two loggers that have the same alias can get their level set through a single name.
func NewLogger(name string, aliases ...string) Logger {
if name == "" {
name = "main"
}
lower := Logger(strings.ToLower(name))
l := Logger(name)
for _, alias := range aliases {
if alias == "" {
continue
}
a := Logger(strings.ToLower(alias))
LoggerAliases[a] = append(LoggerAliases[a], lower)
slices.Sort(LoggerAliases[a])
LoggerAliases[a] = slices.Compact(LoggerAliases[a])
}
if _, ok := LogLevels[lower]; ok {
return l
}
LogLevels[lower] = 0
return l
}
// ALL is for setting the default log level. Do not use it to log directly
const ALL Logger = "ALL"
var defaultLogger = NewLogger("main")
// Log is Logger.Log for the "main" logger
func Log(level Level, msg string, stuff ...any) {
defaultLogger.Log(level, msg, stuff...)
}
// Println is Logger.Println for the "main" logger
func Println(level Level, args ...any) {
defaultLogger.Log(level, "", args...)
}