Route GORM's logger through slog and bound it (closes #178)
All checks were successful
check / check (push) Successful in 2m57s
All checks were successful
check / check (push) Successful in 2m57s
This commit was merged in pull request #182.
This commit is contained in:
168
internal/gormlog/gormlog.go
Normal file
168
internal/gormlog/gormlog.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// Package gormlog adapts GORM's logger onto the service's slog
|
||||
// logger.
|
||||
//
|
||||
// GORM's own default logger is not usable here. It is built at package
|
||||
// init with log.New(os.Stdout, ...) at LogLevel Warn with
|
||||
// IgnoreRecordNotFoundError false, so it writes the fully interpolated
|
||||
// SQL — parameters and all — for every statement that returns an
|
||||
// error, including gorm.ErrRecordNotFound. Two of this service's
|
||||
// lookups miss by design on unauthenticated routes: the entrypoint
|
||||
// lookup on /webhook/{uuid}, whose path segment the client picks
|
||||
// outright, and the user lookup behind the login form, whose username
|
||||
// the client picks outright. Under the default logger each of those
|
||||
// misses printed an unbounded, attacker-chosen string, at no level the
|
||||
// operator can turn down, past every handler internal/logger installs.
|
||||
//
|
||||
// This adapter fixes all three properties at once: the lines get a
|
||||
// level the operator controls, they are shaped by whichever handler
|
||||
// internal/logger selected, and every value a client can influence is
|
||||
// spent through logfield.Truncate.
|
||||
package gormlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
"sneak.berlin/go/webhooker/internal/logfield"
|
||||
)
|
||||
|
||||
// DefaultSlowThreshold is the duration at or above which a statement
|
||||
// is logged as slow. It is GORM's own default, kept deliberately: slow
|
||||
// SQL is the one thing GORM's logger reports that nothing else in this
|
||||
// service does, so silencing the logger outright would have cost real
|
||||
// observability to fix a log-volume defect.
|
||||
const DefaultSlowThreshold = 200 * time.Millisecond
|
||||
|
||||
// Logger implements gormlogger.Interface on top of an *slog.Logger.
|
||||
//
|
||||
// It is safe for concurrent use: every field is set at construction
|
||||
// and never written again.
|
||||
type Logger struct {
|
||||
log *slog.Logger
|
||||
slowThreshold time.Duration
|
||||
}
|
||||
|
||||
// Interface compliance is asserted here rather than discovered at the
|
||||
// gorm.Open call sites.
|
||||
var _ gormlogger.Interface = (*Logger)(nil)
|
||||
|
||||
// New returns a GORM logger that writes through log.
|
||||
func New(log *slog.Logger) *Logger {
|
||||
return &Logger{
|
||||
log: log,
|
||||
slowThreshold: DefaultSlowThreshold,
|
||||
}
|
||||
}
|
||||
|
||||
// LogMode returns the logger unchanged.
|
||||
//
|
||||
// GORM's LogLevel is deliberately not honoured. Level is the operator's
|
||||
// decision and it is expressed once, through LOG_LEVEL and the
|
||||
// slog.LevelVar internal/logger holds; a second level knob inside the
|
||||
// database layer could only disagree with it. The mapping from GORM's
|
||||
// four categories onto slog levels is fixed in Trace below.
|
||||
//
|
||||
//nolint:ireturn // The interface return is GORM's signature, not a choice.
|
||||
func (l *Logger) LogMode(gormlogger.LogLevel) gormlogger.Interface {
|
||||
return l
|
||||
}
|
||||
|
||||
// Info logs one of GORM's own informational messages.
|
||||
func (l *Logger) Info(
|
||||
ctx context.Context, msg string, data ...any,
|
||||
) {
|
||||
l.log.InfoContext(ctx, "gorm", "message", format(msg, data...))
|
||||
}
|
||||
|
||||
// Warn logs one of GORM's own warnings.
|
||||
func (l *Logger) Warn(
|
||||
ctx context.Context, msg string, data ...any,
|
||||
) {
|
||||
l.log.WarnContext(ctx, "gorm", "message", format(msg, data...))
|
||||
}
|
||||
|
||||
// Error logs one of GORM's own errors.
|
||||
func (l *Logger) Error(
|
||||
ctx context.Context, msg string, data ...any,
|
||||
) {
|
||||
l.log.ErrorContext(ctx, "gorm", "message", format(msg, data...))
|
||||
}
|
||||
|
||||
// Trace reports the outcome of a single statement. GORM calls it for
|
||||
// every statement it runs, so the cheap paths stay cheap: fc()
|
||||
// renders the interpolated SQL and is called only on a branch that
|
||||
// will actually emit.
|
||||
//
|
||||
// The arms are ordered exactly as GORM's own Trace orders them —
|
||||
// non-record-not-found error, then slow, then the routine case — so
|
||||
// that a statement which both misses and runs slow is still reported
|
||||
// as slow. A miss is the likeliest statement to be slow, since it is
|
||||
// the one that scans without finding a row, and ordering the drop
|
||||
// ahead of the slow arm would have made this adapter less observant
|
||||
// than the IgnoreRecordNotFoundError option it was chosen over.
|
||||
func (l *Logger) Trace(
|
||||
ctx context.Context,
|
||||
begin time.Time,
|
||||
fc func() (string, int64),
|
||||
err error,
|
||||
) {
|
||||
elapsed := time.Since(begin)
|
||||
|
||||
switch {
|
||||
case err != nil && !errors.Is(err, gormlogger.ErrRecordNotFound):
|
||||
sql, rows := fc()
|
||||
l.log.ErrorContext(ctx, "sql statement failed",
|
||||
"error", logfield.Truncate(err.Error(), logfield.MaxBytes),
|
||||
"sql", logfield.Truncate(sql, logfield.MaxBytes),
|
||||
"rows", rows,
|
||||
"elapsed_ms", elapsed.Milliseconds(),
|
||||
)
|
||||
|
||||
case l.slowThreshold > 0 && elapsed >= l.slowThreshold:
|
||||
sql, rows := fc()
|
||||
l.log.WarnContext(ctx, "slow sql statement",
|
||||
"sql", logfield.Truncate(sql, logfield.MaxBytes),
|
||||
"rows", rows,
|
||||
"elapsed_ms", elapsed.Milliseconds(),
|
||||
"threshold_ms", l.slowThreshold.Milliseconds(),
|
||||
)
|
||||
|
||||
case err != nil:
|
||||
// gorm.ErrRecordNotFound is not an error on the paths that
|
||||
// produce it here: an invented entrypoint UUID and an unknown
|
||||
// username are the expected outcome of an unauthenticated
|
||||
// request, not a fault. This is the IgnoreRecordNotFoundError
|
||||
// behaviour, and it is unconditional rather than configurable
|
||||
// because no caller in this service wants the other one — the
|
||||
// two handlers that care already record the miss themselves,
|
||||
// at DEBUG, without the SQL. A miss that ran slow has already
|
||||
// been reported by the arm above.
|
||||
return
|
||||
|
||||
case l.log.Enabled(ctx, slog.LevelDebug):
|
||||
sql, rows := fc()
|
||||
l.log.DebugContext(ctx, "sql statement",
|
||||
"sql", logfield.Truncate(sql, logfield.MaxBytes),
|
||||
"rows", rows,
|
||||
"elapsed_ms", elapsed.Milliseconds(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// format renders one of GORM's printf-style internal messages and
|
||||
// bounds it. GORM builds these itself, but they can quote a value the
|
||||
// statement carried, so they are spent through the same budget as
|
||||
// everything else rather than trusted.
|
||||
func format(msg string, data ...any) string {
|
||||
if len(data) == 0 {
|
||||
return logfield.Truncate(msg, logfield.MaxBytes)
|
||||
}
|
||||
|
||||
return logfield.Truncate(
|
||||
fmt.Sprintf(msg, data...), logfield.MaxBytes,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user