All checks were successful
check / check (push) Successful in 2m50s
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 as an error, 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.
Trace orders its cases exactly as GORM's own Trace orders them --
error-that-is-not-a-miss, then slow, then routine -- so a statement
that both missed and ran slow is still reported as slow. Ordering the
drop first would have made this adapter strictly less observant than
the IgnoreRecordNotFoundError option it was chosen over, on the two
lookups the issue is about, and a miss is the statement most likely to
be slow.
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. All three sites are pinned independently:
internal/handlers covers the main and per-webhook databases,
internal/delivery covers the archive writer, whose type is unexported.
Reverting any one of the three to a bare &gorm.Config{} fails the
suite.
The flood test's per-line and volume assertions were vacuous, because
the replaced default logger wrote only to a buffer while everything
else went to the captured stdout. It now tees to stdout as GORM's real
default does, so a reverted call site lands in the same capture and
those assertions measure the whole writer set.
README: the ceiling now covers GORM, and the writers it does not cover
are re-derived by measuring rather than by reading. fx's console
logger and the Go runtime write to standard error. net/http's nil
ErrorLog is not a separate writer at all -- slog.SetDefault redirects
the log package's default logger into internal/logger's handler, so
those lines arrive on standard output at INFO. A handler panic reaches
that same path because chi's Recoverer crashes before writing, which
is filed as #187 and is also the widest line the service can write, at
2,772 bytes against the stated 2,560.
203 lines
5.5 KiB
Go
203 lines
5.5 KiB
Go
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)
|
||
}
|
||
}
|