All checks were successful
check / check (push) Successful in 2m54s
GORM's default logger printed the fully interpolated SQL to standard
output on every statement that returned an error, including a plain
record-not-found. On /webhook/{uuid} and on the login form the
interpolated parameter is client-chosen and unbounded, so an
unauthenticated client sized the operator's log, one line per request,
at no level the operator could turn down.
Every gorm.Open in the service now installs internal/gormlog, a
gormlogger.Interface over the service's *slog.Logger. Its lines take
the level the operator set and the handler internal/logger selected; a
record-not-found is not logged at all, since it is the expected
outcome on both of those paths and each handler already records its
own miss at DEBUG without the SQL; slow statements are kept at WARN
above the same 200ms threshold GORM used; and every value it emits is
spent through an encoded-byte budget.
That budget is internal/middleware's truncateLogField, moved to a new
internal/logfield package now that a second writer needs it. The move
is unchanged logic. MaxAccessLogLineBytes bounds a GORM line too, and
internal/gormlog asserts each line against the constant directly.
The third gorm.Open, in the archive writer, was not named in the issue
and had the same default.
README: the ceiling now covers GORM; the writers it does not cover are
named, including net/http's nil ErrorLog, fx's console logger and the
Go runtime, none of which carry a client-chosen value.
160 lines
5.3 KiB
Go
160 lines
5.3 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.
|
|
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 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.
|
|
return
|
|
|
|
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 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,
|
|
)
|
|
}
|