diff --git a/tracelog/tracelog.go b/tracelog/tracelog.go index a68fc6a6a..1fe3db354 100644 --- a/tracelog/tracelog.go +++ b/tracelog/tracelog.go @@ -97,19 +97,41 @@ func logQueryArgs(args []any) []any { for _, a := range args { switch v := a.(type) { case []byte: - if len(v) < 64 { + if len(v) <= 64 { a = hex.EncodeToString(v) } else { - a = fmt.Sprintf("%x (truncated %d bytes)", v[:64], len(v)-64) + // Show first 60 bytes as hex (120 chars) + ellipsis. + // This ensures the result is always shorter than the + // full hex encoding: 120 + 3 < 130+ for any 65+ byte input. + a = hex.EncodeToString(v[:60]) + "…" } case string: if len(v) > 64 { + // Find the rune boundary nearest to 64 bytes, + // then back off so the ellipsis (3 bytes) still + // makes the result shorter than the original. l := 0 for w := 0; l < 64; l += w { _, w = utf8.DecodeRuneInString(v[l:]) } if len(v) > l { - a = fmt.Sprintf("%s (truncated %d bytes)", v[:l], len(v)-l) + // Ensure truncation + ellipsis is strictly shorter than original. + // Ellipsis is 3 bytes, so cut+3 must be < len(v), i.e. cut <= len(v)-4. + maxCut := len(v) - 4 + if maxCut < 1 { + maxCut = 1 + } + cut := 0 + var prevW int + for w := 0; cut < maxCut; cut += w { + prevW = w + _, w = utf8.DecodeRuneInString(v[cut:]) + } + // If cut overshot, back up to the previous rune boundary. + if cut > maxCut && prevW > 0 { + cut -= prevW + } + a = v[:cut] + "…" } } } diff --git a/tracelog/tracelog_test.go b/tracelog/tracelog_test.go index 856ccf669..024616be5 100644 --- a/tracelog/tracelog_test.go +++ b/tracelog/tracelog_test.go @@ -224,7 +224,7 @@ func TestLogQueryArgsHandlesUTF8(t *testing.T) { logs = logger.FilterByMsg("Query") require.Len(t, logs, 1) require.Equal(t, tracelog.LogLevelInfo, logs[0].lvl) - require.Equal(t, s+" (truncated 3 bytes)", logs[0].data["args"].([]any)[0]) + require.Equal(t, s+"…", logs[0].data["args"].([]any)[0]) }) } diff --git a/tracelog/truncation_test.go b/tracelog/truncation_test.go new file mode 100644 index 000000000..1f95ea628 --- /dev/null +++ b/tracelog/truncation_test.go @@ -0,0 +1,60 @@ +package tracelog + +import ( + "encoding/hex" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLogQueryArgsTruncation(t *testing.T) { + t.Parallel() + + t.Run("short string not truncated", func(t *testing.T) { + args := logQueryArgs([]any{"hello"}) + assert.Equal(t, "hello", args[0]) + }) + + t.Run("long string truncated with ellipsis", func(t *testing.T) { + s := strings.Repeat("a", 65) + args := logQueryArgs([]any{s}) + result := args[0].(string) + // maxCut = len(v)-4 = 61, so output = 61 a's + … (3 bytes) = 64 bytes + assert.Equal(t, strings.Repeat("a", 61)+"…", result) + // Result must be shorter than original + assert.Less(t, len(result), len(s)) + }) + + t.Run("short bytes not truncated", func(t *testing.T) { + b := []byte{0x01, 0x02, 0x03} + args := logQueryArgs([]any{b}) + assert.Equal(t, "010203", args[0]) + }) + + t.Run("long bytes truncated with ellipsis", func(t *testing.T) { + b := make([]byte, 65) + for i := range b { + b[i] = byte(i) + } + args := logQueryArgs([]any{b}) + result := args[0].(string) + // Should be 120 hex chars (60 bytes) + "…", always shorter than full hex + assert.True(t, strings.HasSuffix(result, "…"), "truncated bytes should end with …") + assert.Less(t, len(result), len(hex.EncodeToString(b)), "truncated result should be shorter than full hex") + }) + + t.Run("UTF-8 string truncated at rune boundary", func(t *testing.T) { + // 63 ASCII chars + 1 multi-byte rune (4 bytes) = 67 bytes, should not truncate + s := strings.Repeat("x", 63) + "😊" + args := logQueryArgs([]any{s}) + assert.Equal(t, s, args[0]) + + // 67 bytes + more = should truncate at rune boundary, result shorter than original + s2 := s + "aaa" // 70 bytes + args2 := logQueryArgs([]any{s2}) + result := args2[0].(string) + assert.True(t, strings.HasSuffix(result, "…"), "truncated string should end with …") + assert.Less(t, len(result), len(s2), "truncated result should be shorter than original") + }) +} \ No newline at end of file