Route GORM's logger through slog and bound it (closes #178)
All checks were successful
check / check (push) Successful in 2m54s
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.
This commit is contained in:
140
internal/logfield/logfield.go
Normal file
140
internal/logfield/logfield.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Package logfield bounds a client-supplied value against what the log
|
||||
// handler will actually emit for it, so a line's size is set by this
|
||||
// service rather than by the client that provoked it.
|
||||
//
|
||||
// It lives outside internal/middleware because more than one writer
|
||||
// needs it: the access log, and the GORM adapter in internal/gormlog,
|
||||
// which logs SQL with the client-chosen parameters interpolated into
|
||||
// it. One budget, one implementation.
|
||||
package logfield
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxBytes is the default budget for a field whose value the
|
||||
// client supplies outright. It is spent in ENCODED bytes (see
|
||||
// Truncate), so 512 still holds a real browser's User-Agent whole
|
||||
// — those are plain ASCII, which encodes one byte for one — while
|
||||
// a value built from characters the encoder escapes keeps a
|
||||
// shorter prefix. That is the intended trade: 500 quotation marks
|
||||
// are not a debugging asset.
|
||||
MaxBytes = 512
|
||||
|
||||
// truncationMarker is appended to any value Truncate cut, so a
|
||||
// short value and a truncated one cannot be confused. It is
|
||||
// charged on top of the budget, not inside it.
|
||||
truncationMarker = "[truncated]"
|
||||
)
|
||||
|
||||
// EncodedBytes is what r costs on the line once the log handler has
|
||||
// escaped it, taking the worse of the two handlers internal/logger
|
||||
// configures.
|
||||
//
|
||||
// slog's JSON handler escapes quote, backslash, newline, carriage
|
||||
// return and tab to two bytes each, and every other C0 control plus
|
||||
// LINE SEPARATOR and PARAGRAPH SEPARATOR to a six-byte \u escape; it
|
||||
// passes every other rune through as its own UTF-8. Its text handler
|
||||
// quotes with strconv.Quote, which spells a non-printable rune below
|
||||
// U+10000 as \uXXXX but one at or above U+10000 as \UXXXXXXXX — ten
|
||||
// bytes, not six. The text handler is therefore the worse of the two
|
||||
// for every non-printable rune, and by four bytes apiece for the
|
||||
// 955,086 unassigned, private-use and format code points on planes 1
|
||||
// to 16.
|
||||
//
|
||||
// Charging ten there is what makes the stated per-line ceiling hold
|
||||
// for the tty handler as well: U+1000C encodes as F0 90 80 8C, every
|
||||
// byte >= 0x80, which httpguts.ValidHeaderFieldValue accepts and
|
||||
// net/textproto does not strip, so a header can be filled with them.
|
||||
//
|
||||
// Both handlers pass printable runes through as their own UTF-8, so
|
||||
// unicode.IsPrint separates the escaped cases from the plain ones for
|
||||
// either handler.
|
||||
func EncodedBytes(r rune) int {
|
||||
const (
|
||||
// A backslash and the character itself.
|
||||
shortEscapeBytes = 2
|
||||
// \uXXXX, which is also the width of \u00XX.
|
||||
escapedRuneBytes = 6
|
||||
// \UXXXXXXXX, strconv.Quote's spelling of a non-printable
|
||||
// rune outside the basic multilingual plane.
|
||||
escapedAstralRuneBytes = 10
|
||||
// The first code point strconv.Quote spells with \U.
|
||||
firstAstralRune = 0x10000
|
||||
)
|
||||
|
||||
switch {
|
||||
case r == '"' || r == '\\' || r == '\n' || r == '\r' || r == '\t':
|
||||
return shortEscapeBytes
|
||||
case !unicode.IsPrint(r) && r >= firstAstralRune:
|
||||
return escapedAstralRuneBytes
|
||||
case !unicode.IsPrint(r):
|
||||
return escapedRuneBytes
|
||||
default:
|
||||
return utf8.RuneLen(r)
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate caps s at maxBytes of ENCODED output, marking the value
|
||||
// when it cuts.
|
||||
//
|
||||
// Budgeting raw bytes would not bound the line. Escaping only ever
|
||||
// grows a value, so a raw budget spent on characters the encoder
|
||||
// escapes buys a field several times its nominal size — and the line
|
||||
// is the thing an operator is told to multiply by their request rate.
|
||||
// Charging each rune what it will actually cost is what makes the
|
||||
// stated ceiling true rather than merely larger. The visible
|
||||
// consequence is that an escape-heavy value keeps a shorter prefix
|
||||
// than a plain one, which is the correct trade.
|
||||
//
|
||||
// The result is always valid UTF-8. A cut on a byte boundary can split
|
||||
// a multi-byte rune, and a header — or a SQL literal — can carry bytes
|
||||
// that were never valid UTF-8 to begin with; both are dropped rather
|
||||
// than kept, since an encoder would otherwise spend six bytes
|
||||
// replacing each one.
|
||||
func Truncate(s string, maxBytes int) string {
|
||||
// No rune encodes to fewer bytes than it occupies, so nothing past
|
||||
// maxBytes raw can fit the budget. Slicing first bounds the scan
|
||||
// below to the budget rather than to the size of the value the
|
||||
// client sent.
|
||||
window, cut := s, false
|
||||
if len(window) > maxBytes {
|
||||
window, cut = window[:maxBytes], true
|
||||
}
|
||||
|
||||
var (
|
||||
kept strings.Builder
|
||||
spent int
|
||||
)
|
||||
|
||||
for i := 0; i < len(window); {
|
||||
r, size := utf8.DecodeRuneInString(window[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
i += size
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
cost := EncodedBytes(r)
|
||||
if spent+cost > maxBytes {
|
||||
cut = true
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
spent += cost
|
||||
|
||||
kept.WriteString(window[i : i+size])
|
||||
|
||||
i += size
|
||||
}
|
||||
|
||||
if !cut {
|
||||
return kept.String()
|
||||
}
|
||||
|
||||
return kept.String() + truncationMarker
|
||||
}
|
||||
202
internal/logfield/logfield_test.go
Normal file
202
internal/logfield/logfield_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package logfield_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/logfield"
|
||||
)
|
||||
|
||||
// encodedCost is what a whole string costs on a line, by the same
|
||||
// accounting Truncate spends its budget with.
|
||||
func encodedCost(s string) int {
|
||||
total := 0
|
||||
for _, r := range s {
|
||||
total += logfield.EncodedBytes(r)
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// TestTruncate_SpendsEncodedBytesNotRawBytes is the zero-headroom
|
||||
// version of the line-length assertions elsewhere.
|
||||
//
|
||||
// A line ceiling has slack in it by construction, so a line-level
|
||||
// assertion only catches a raw-byte budget for the fills with the
|
||||
// widest multiplier. Here the budget is checked against exactly what
|
||||
// it bought: a value built from a single rune must keep exactly
|
||||
// MaxBytes/EncodedBytes(r) of them, for every rune, with nothing
|
||||
// spare.
|
||||
func TestTruncate_SpendsEncodedBytesNotRawBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, r := range map[string]rune{
|
||||
"plain": 'x',
|
||||
"quote": '"',
|
||||
"backslash": '\\',
|
||||
"tab": '\t',
|
||||
"newline": '\n',
|
||||
"carriage_return": '\r',
|
||||
"c0_control": '',
|
||||
"del": '',
|
||||
"line_separator": '
',
|
||||
"astral_nonprintable": '\U0001000C',
|
||||
"multibyte_printable": 'é',
|
||||
"three_byte_printable": '€',
|
||||
"emoji_printable": '\U0001F600',
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cost := logfield.EncodedBytes(r)
|
||||
want := logfield.MaxBytes / cost
|
||||
|
||||
// Far past the budget under either accounting.
|
||||
in := strings.Repeat(string(r), logfield.MaxBytes*2)
|
||||
|
||||
got := logfield.Truncate(in, logfield.MaxBytes)
|
||||
|
||||
assert.True(
|
||||
t, strings.HasSuffix(got, "[truncated]"),
|
||||
"a value past the budget must be marked",
|
||||
)
|
||||
|
||||
kept := strings.TrimSuffix(got, "[truncated]")
|
||||
|
||||
assert.Equal(
|
||||
t, want, utf8.RuneCountInString(kept),
|
||||
"budget bought the wrong number of runes at "+
|
||||
"%d encoded bytes each", cost,
|
||||
)
|
||||
assert.LessOrEqual(
|
||||
t, encodedCost(kept), logfield.MaxBytes,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodedBytes_CoversWhatTheHandlersActuallyEmit measures the
|
||||
// charge against what slog really writes rather than against a
|
||||
// reading of its source, over both handlers internal/logger can
|
||||
// install. An undercharged rune fails here rather than quietly
|
||||
// falsifying every stated line ceiling.
|
||||
func TestEncodedBytes_CoversWhatTheHandlersActuallyEmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
emitted := func(h func(*bytes.Buffer) slog.Handler, r rune) int {
|
||||
var withValue, withoutValue bytes.Buffer
|
||||
|
||||
slog.New(h(&withValue)).Info("m", "v", string(r))
|
||||
slog.New(h(&withoutValue)).Info("m", "v", "")
|
||||
|
||||
return withValue.Len() - withoutValue.Len()
|
||||
}
|
||||
|
||||
jsonHandler := func(b *bytes.Buffer) slog.Handler {
|
||||
return slog.NewJSONHandler(b, &slog.HandlerOptions{
|
||||
ReplaceAttr: dropTime,
|
||||
})
|
||||
}
|
||||
textHandler := func(b *bytes.Buffer) slog.Handler {
|
||||
return slog.NewTextHandler(b, &slog.HandlerOptions{
|
||||
ReplaceAttr: dropTime,
|
||||
})
|
||||
}
|
||||
|
||||
// Every code point below U+0800 densely — which covers all of C0,
|
||||
// DEL, C1 and the two-byte range — plus the separators only the
|
||||
// JSON handler escapes, plus a stratified walk across the rest of
|
||||
// the assigned space and into the astral planes.
|
||||
var runes []rune
|
||||
|
||||
for r := rune(1); r < 0x800; r++ {
|
||||
runes = append(runes, r)
|
||||
}
|
||||
|
||||
runes = append(
|
||||
runes,
|
||||
'
', // LINE SEPARATOR, escaped only by the JSON handler
|
||||
'
', // PARAGRAPH SEPARATOR, likewise
|
||||
rune(0xFEFF), // ZERO WIDTH NO-BREAK SPACE
|
||||
rune(0xFFFD), // REPLACEMENT CHARACTER
|
||||
)
|
||||
|
||||
for r := rune(0x800); r <= 0x10FFFF; r += 0x1D1 {
|
||||
runes = append(runes, r)
|
||||
}
|
||||
|
||||
for _, r := range runes {
|
||||
if !utf8.ValidRune(r) {
|
||||
continue
|
||||
}
|
||||
|
||||
charged := logfield.EncodedBytes(r)
|
||||
|
||||
require.LessOrEqual(
|
||||
t, emitted(jsonHandler, r), charged,
|
||||
"json handler spends more than U+%04X is charged", r,
|
||||
)
|
||||
require.LessOrEqual(
|
||||
t, emitted(textHandler, r), charged,
|
||||
"text handler spends more than U+%04X is charged", r,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func dropTime(_ []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.TimeKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// TestTruncate_LeavesShortValuesAlone keeps the marker meaningful: a
|
||||
// value that fits is returned untouched, so a reader can tell a short
|
||||
// value from a cut one.
|
||||
func TestTruncate_LeavesShortValuesAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const in = "Mozilla/5.0 (X11; Linux x86_64)"
|
||||
|
||||
assert.Equal(t, in, logfield.Truncate(in, logfield.MaxBytes))
|
||||
}
|
||||
|
||||
// TestTruncate_DropsInvalidUTF8 covers a value that was never valid
|
||||
// UTF-8 — a SQL parameter or a header can carry one. Replacing each
|
||||
// bad byte would cost six encoded bytes apiece, so they are dropped.
|
||||
func TestTruncate_DropsInvalidUTF8(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := logfield.Truncate("a\xffb\xfec", logfield.MaxBytes)
|
||||
|
||||
assert.Equal(t, "abc", got)
|
||||
assert.True(t, utf8.ValidString(got))
|
||||
}
|
||||
|
||||
// TestTruncate_NeverSplitsARune covers a cut landing inside a
|
||||
// multi-byte encoding.
|
||||
func TestTruncate_NeverSplitsARune(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A three-byte printable rune, so a small budget lands inside the
|
||||
// encoding rather than between two of them.
|
||||
in := strings.Repeat("€", logfield.MaxBytes)
|
||||
|
||||
for budget := 1; budget <= 16; budget++ {
|
||||
got := strings.TrimSuffix(
|
||||
logfield.Truncate(in, budget), "[truncated]",
|
||||
)
|
||||
|
||||
assert.True(
|
||||
t, utf8.ValidString(got),
|
||||
"budget %d produced invalid UTF-8", budget,
|
||||
)
|
||||
assert.LessOrEqual(t, encodedCost(got), budget)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user