169 lines
5.9 KiB
Go
169 lines
5.9 KiB
Go
// 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,
|
|
)
|
|
}
|