// 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, ) }