fix: preserve newest backend diagnostics (#2929) (#2934)

This commit is contained in:
Carlos Florêncio
2026-08-23 20:49:47 +00:00
committed by GitHub
parent 2b2719b2a1
commit 81da6af99f
26 changed files with 2360 additions and 376 deletions
@@ -0,0 +1,647 @@
package logger
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"time"
)
const (
activeBackendLogName = "backend-logs.log"
backendDayMarkerName = ".backend-logs-day"
rolloverJournalName = ".backend-logs-rollover.json"
conversionJournalName = ".backend-logs-conversion.json"
conversionSourceName = ".backend-logs-conversion-source.log"
backendLogSegmentBytes int64 = 16 * 1024 * 1024
backendLogTotalBytes int64 = 256 * 1024 * 1024
)
var (
legacyBackendLogPattern = regexp.MustCompile(`^backend-logs-(\d{4}-\d{2}-\d{2})\.log$`)
segmentBackendLogPattern = regexp.MustCompile(`^backend-logs-(\d{4}-\d{2}-\d{2})-(\d{6})\.log$`)
)
// BackendLogFile describes the stable filename grammar shared by the logger
// and diagnostic bundle collector.
type BackendLogFile struct {
Day string
Sequence int
Active bool
Legacy bool
}
type backendLogFileInfo struct {
BackendLogFile
Name string
Path string
Size int64
ModTime time.Time
}
// ParseBackendLogFilename parses active, numbered, and legacy backend log names.
func ParseBackendLogFilename(name string) (BackendLogFile, bool) {
if name == activeBackendLogName {
return BackendLogFile{Active: true}, true
}
if match := segmentBackendLogPattern.FindStringSubmatch(name); len(match) == 3 {
sequence, err := strconv.Atoi(match[2])
if err == nil && validUTCDay(match[1]) {
return BackendLogFile{Day: match[1], Sequence: sequence}, true
}
}
if match := legacyBackendLogPattern.FindStringSubmatch(name); len(match) == 2 && validUTCDay(match[1]) {
return BackendLogFile{Day: match[1], Legacy: true}, true
}
return BackendLogFile{}, false
}
func backendLogSegmentName(day string, sequence int) string {
return fmt.Sprintf("backend-logs-%s-%06d.log", day, sequence)
}
func scanBackendLogFiles(logDir string) ([]backendLogFileInfo, error) {
entries, err := os.ReadDir(logDir)
if err != nil {
return nil, err
}
files := make([]backendLogFileInfo, 0, len(entries))
for _, entry := range entries {
parsed, ok := ParseBackendLogFilename(entry.Name())
if !ok {
continue
}
path := filepath.Join(logDir, entry.Name())
info, err := os.Lstat(path)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
if !info.Mode().IsRegular() {
continue
}
files = append(files, backendLogFileInfo{
BackendLogFile: parsed, Name: entry.Name(), Path: path,
Size: info.Size(), ModTime: info.ModTime(),
})
}
return files, nil
}
func backendLogBytes(files []backendLogFileInfo) int64 {
var total int64
for _, file := range files {
total += file.Size
}
return total
}
func closedBackendLogFiles(files []backendLogFileInfo) []backendLogFileInfo {
closed := make([]backendLogFileInfo, 0, len(files))
for _, file := range files {
if !file.Active {
closed = append(closed, file)
}
}
return closed
}
func sortBackendLogFiles(files []backendLogFileInfo) {
sort.Slice(files, func(i, j int) bool {
left, right := files[i], files[j]
if left.Day != right.Day {
return left.Day < right.Day
}
if left.Active != right.Active {
return !left.Active
}
if left.Legacy != right.Legacy {
return left.Legacy
}
return left.Sequence < right.Sequence
})
}
func nextBackendLogSequence(files []backendLogFileInfo, day string) (int, error) {
next := 1
for _, file := range files {
if file.Day == day && !file.Active && !file.Legacy && file.Sequence >= next {
next = file.Sequence + 1
}
}
if next > 999999 {
return 0, fmt.Errorf("backend log segment sequence exhausted")
}
return next, nil
}
func validUTCDay(value string) bool {
parsed, err := time.Parse(time.DateOnly, value)
return err == nil && parsed.Format(time.DateOnly) == value
}
// BackendLogDayRetained reports whether a closed backend log is within the
// three-UTC-day maximum age relative to today.
func BackendLogDayRetained(day string, today time.Time) bool {
parsed, err := time.Parse(time.DateOnly, day)
if err != nil {
return false
}
todayUTC := today.UTC()
cutoff := time.Date(
todayUTC.Year(), todayUTC.Month(), todayUTC.Day(), 0, 0, 0, 0, time.UTC,
).AddDate(0, 0, -2)
todayDay := time.Date(
todayUTC.Year(), todayUTC.Month(), todayUTC.Day(), 0, 0, 0, 0, time.UTC,
)
return !parsed.Before(cutoff) && !parsed.After(todayDay)
}
type rolloverJournal struct {
SourceDay string `json:"source_day"`
SourceSize int64 `json:"source_size"`
SourceModUnixNano int64 `json:"source_mod_unix_nano"`
Destination string `json:"destination"`
DestinationStart int64 `json:"destination_start"`
CopiedOffset int64 `json:"copied_offset"`
}
func (w *dailyWriter) recoverRollover() error {
path := filepath.Join(w.logDir, rolloverJournalName)
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var journal rolloverJournal
if err := json.Unmarshal(data, &journal); err != nil {
return err
}
activePath := filepath.Join(w.logDir, activeBackendLogName)
if _, err := os.Stat(activePath); os.IsNotExist(err) {
return removeIfExists(path)
}
if err := w.copyRollover(activePath, &journal); err != nil {
return err
}
if err := os.Remove(activePath); err != nil && !os.IsNotExist(err) {
return err
}
return removeIfExists(path)
}
func (w *dailyWriter) copyRollover(activePath string, journal *rolloverJournal) error {
source, err := os.Open(activePath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer func() { _ = source.Close() }()
sourceInfo, err := source.Stat()
if err != nil {
return err
}
if sourceInfo.Size() != journal.SourceSize || sourceInfo.ModTime().UnixNano() != journal.SourceModUnixNano {
journal.SourceSize = sourceInfo.Size()
journal.SourceModUnixNano = sourceInfo.ModTime().UnixNano()
if err := writeJSONOwnerFile(filepath.Join(w.logDir, rolloverJournalName), journal); err != nil {
return err
}
}
destinationPath := filepath.Join(w.logDir, filepath.Base(journal.Destination))
destination, err := openOwnerAppend(destinationPath)
if err != nil {
return err
}
defer func() { _ = destination.Close() }()
destinationInfo, err := destination.Stat()
if err != nil {
return err
}
copied := destinationInfo.Size() - journal.DestinationStart
if copied < 0 || copied > journal.SourceSize {
return fmt.Errorf("invalid rollover destination size")
}
journal.CopiedOffset = copied
if _, err := source.Seek(copied, io.SeekStart); err != nil {
return err
}
if err := copyJournaled(source, destination, journal, filepath.Join(w.logDir, rolloverJournalName)); err != nil {
return err
}
return destination.Sync()
}
func copyJournaled(source io.Reader, destination io.Writer, journal *rolloverJournal, path string) error {
buf := make([]byte, 1024*1024)
for {
n, readErr := source.Read(buf)
if n > 0 {
written, writeErr := destination.Write(buf[:n])
journal.CopiedOffset += int64(written)
if writeErr != nil {
return writeErr
}
if written != n {
return io.ErrShortWrite
}
if err := writeJSONOwnerFile(path, journal); err != nil {
return err
}
}
if errors.Is(readErr, io.EOF) {
return nil
}
if readErr != nil {
return readErr
}
}
}
type conversionJournal struct {
SourceDay string `json:"source_day"`
SourceSize int64 `json:"source_size"`
SourceModUnixNano int64 `json:"source_mod_unix_nano"`
TailOffset int64 `json:"tail_offset"`
BackupName string `json:"backup_name"`
NextOutput int `json:"next_output"`
Outputs []conversionOutput `json:"outputs"`
}
type conversionOutput struct {
Name string `json:"name"`
Offset int64 `json:"offset"`
Length int64 `json:"length"`
}
func (w *dailyWriter) convertActive(day string, sourceSize int64) error {
if sourceSize <= w.maxBytes {
return nil
}
files, err := scanBackendLogFiles(w.logDir)
if err != nil {
return err
}
closed := closedBackendLogFiles(files)
closedBytes := backendLogBytes(closed)
sortBackendLogFiles(closed)
desiredKeep := sourceSize
if desiredKeep > w.maxTotalBytes {
desiredKeep = w.maxTotalBytes
}
for closedBytes+desiredKeep > w.maxTotalBytes && len(closed) > 0 {
oldest := closed[0]
if err := os.Remove(oldest.Path); err != nil {
return fmt.Errorf("remove old log during conversion: %w", err)
}
closedBytes -= oldest.Size
closed = closed[1:]
}
available := w.maxTotalBytes - closedBytes
if available <= 0 {
return fmt.Errorf("no budget remains for active log conversion")
}
keep := desiredKeep
if keep > available {
keep = available
}
firstSequence, err := nextBackendLogSequence(files, day)
if err != nil {
return err
}
outputs, err := conversionOutputs(day, keep, w.maxBytes, firstSequence)
if err != nil {
return err
}
journal := &conversionJournal{
SourceDay: day, SourceSize: sourceSize, TailOffset: sourceSize - keep,
BackupName: conversionSourceName, NextOutput: len(outputs) - 1, Outputs: outputs,
}
path := filepath.Join(w.logDir, conversionJournalName)
if err := writeJSONOwnerFile(path, journal); err != nil {
return err
}
if err := renameExclusive(filepath.Join(w.logDir, activeBackendLogName), filepath.Join(w.logDir, conversionSourceName)); err != nil {
return err
}
return w.finishConversion(journal)
}
func conversionOutputs(day string, keep, segmentBytes int64, firstSequence int) ([]conversionOutput, error) {
if keep <= 0 || segmentBytes <= 0 || firstSequence <= 0 {
return nil, fmt.Errorf("invalid active log conversion limits")
}
outputs := make([]conversionOutput, 0, (keep+segmentBytes-1)/segmentBytes)
for offset, sequence := int64(0), firstSequence; offset < keep; sequence++ {
if sequence > 999999 {
return nil, fmt.Errorf("backend log segment sequence exhausted")
}
length := keep - offset
if length > segmentBytes {
length = segmentBytes
}
outputs = append(outputs, conversionOutput{
Name: backendLogSegmentName(day, sequence), Offset: offset, Length: length,
})
offset += length
}
return outputs, nil
}
func (w *dailyWriter) recoverConversion() error {
path := filepath.Join(w.logDir, conversionJournalName)
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var journal conversionJournal
if err := json.Unmarshal(data, &journal); err != nil {
return err
}
if err := validateConversionJournal(&journal); err != nil {
return err
}
activePath := filepath.Join(w.logDir, activeBackendLogName)
backupPath := filepath.Join(w.logDir, journal.BackupName)
complete, err := w.ensureConversionSource(path, activePath, backupPath, &journal)
if err != nil {
return err
}
if complete {
return nil
}
if err := w.finishConversion(&journal); err != nil {
return err
}
return nil
}
func (w *dailyWriter) ensureConversionSource(
journalPath, activePath, backupPath string, journal *conversionJournal,
) (bool, error) {
_, err := os.Lstat(backupPath)
if err == nil {
return false, nil
}
if !os.IsNotExist(err) {
return false, err
}
if allConversionOutputsPresent(w.logDir, journal.Outputs) {
return true, removeIfExists(journalPath)
}
_, activeErr := os.Lstat(activePath)
if activeErr == nil {
if err := renameExclusive(activePath, backupPath); err != nil {
return false, err
}
return false, nil
}
if !os.IsNotExist(activeErr) {
return false, activeErr
}
return false, fmt.Errorf("conversion source is missing")
}
func (w *dailyWriter) finishConversion(journal *conversionJournal) error {
if err := validateConversionJournal(journal); err != nil {
return err
}
backupPath := filepath.Join(w.logDir, journal.BackupName)
backupInfo, err := os.Lstat(backupPath)
if err != nil {
return err
}
nextOutput, err := nextConversionOutput(journal, backupInfo.Size())
if err != nil {
return err
}
for index := nextOutput; index >= 0; index-- {
if err := w.finishConversionOutput(backupPath, journal, index); err != nil {
return err
}
}
if !allConversionOutputsPresent(w.logDir, journal.Outputs) {
return fmt.Errorf("conversion outputs are incomplete")
}
if err := removeIfExists(backupPath); err != nil {
return err
}
return removeIfExists(filepath.Join(w.logDir, conversionJournalName))
}
func (w *dailyWriter) finishConversionOutput(
backupPath string, journal *conversionJournal, index int,
) error {
output := journal.Outputs[index]
outputPath := filepath.Join(w.logDir, output.Name)
if err := removeIfExists(outputPath + ".tmp"); err != nil {
return err
}
backupInfo, err := os.Lstat(backupPath)
if err != nil {
return err
}
if !backupInfo.Mode().IsRegular() {
return fmt.Errorf("conversion source is not a regular file")
}
sourceStart := journal.TailOffset + output.Offset
sourceEnd := sourceStart + output.Length
if sourceEnd > journal.SourceSize || sourceStart < 0 {
return fmt.Errorf("invalid conversion source range")
}
if backupInfo.Size() < sourceStart {
return fmt.Errorf("invalid conversion source size")
}
if backupInfo.Size() < sourceEnd {
if err := validateConversionOutputFile(outputPath, output); err != nil {
return err
}
return recordConversionProgress(journal, index, filepath.Join(filepath.Dir(backupPath), conversionJournalName))
}
if backupInfo.Size() != sourceEnd {
return fmt.Errorf("invalid conversion source size")
}
if err := ensureConversionOutput(backupPath, outputPath, journal, output); err != nil {
return err
}
if err := truncateConversionSource(backupPath, sourceStart); err != nil {
return err
}
return recordConversionProgress(journal, index, filepath.Join(filepath.Dir(backupPath), conversionJournalName))
}
func ensureConversionOutput(
backupPath, outputPath string, journal *conversionJournal, output conversionOutput,
) error {
if err := validateConversionOutputFile(outputPath, output); err == nil {
return nil
} else if !os.IsNotExist(err) {
return err
}
offset := journal.TailOffset + output.Offset
if err := copyFileRange(backupPath, outputPath+".tmp", offset, output.Length); err != nil {
return err
}
if err := renameExclusive(outputPath+".tmp", outputPath); err != nil {
if !os.IsExist(err) {
return err
}
return removeIfExists(outputPath + ".tmp")
}
return nil
}
func validateConversionOutputFile(path string, output conversionOutput) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() || info.Size() != output.Length {
return fmt.Errorf("conversion output %s is invalid", output.Name)
}
return nil
}
func nextConversionOutput(journal *conversionJournal, sourceSize int64) (int, error) {
if sourceSize == journal.TailOffset {
return -1, nil
}
for index := len(journal.Outputs) - 1; index >= 0; index-- {
output := journal.Outputs[index]
if journal.TailOffset+output.Offset+output.Length == sourceSize {
return index, nil
}
}
return 0, fmt.Errorf("invalid conversion source progress")
}
func truncateConversionSource(path string, size int64) error {
file, err := os.OpenFile(path, os.O_RDWR, 0o600)
if err != nil {
return err
}
if err := file.Truncate(size); err != nil {
_ = file.Close()
return err
}
if err := file.Sync(); err != nil {
_ = file.Close()
return err
}
return file.Close()
}
func recordConversionProgress(journal *conversionJournal, index int, journalPath string) error {
journal.NextOutput = index - 1
return writeJSONOwnerFile(journalPath, journal)
}
func allConversionOutputsPresent(logDir string, outputs []conversionOutput) bool {
for _, output := range outputs {
info, err := os.Lstat(filepath.Join(logDir, output.Name))
if err != nil || !info.Mode().IsRegular() || info.Size() != output.Length {
return false
}
}
return true
}
func validateConversionJournal(journal *conversionJournal) error {
if journal.BackupName != conversionSourceName || !validUTCDay(journal.SourceDay) ||
journal.SourceSize < 0 || journal.TailOffset < 0 || journal.TailOffset > journal.SourceSize {
return fmt.Errorf("invalid conversion journal")
}
if len(journal.Outputs) == 0 {
return fmt.Errorf("conversion output is empty")
}
if journal.NextOutput < -1 || journal.NextOutput >= len(journal.Outputs) {
return fmt.Errorf("invalid conversion output progress")
}
var previousSequence int
var total int64
for index, output := range journal.Outputs {
var err error
total, previousSequence, err = validateConversionOutput(
journal, output, index, previousSequence, total,
)
if err != nil {
return err
}
}
if journal.TailOffset+total != journal.SourceSize {
return fmt.Errorf("conversion output exceeds source")
}
return nil
}
func validateConversionOutput(
journal *conversionJournal,
output conversionOutput,
index, previousSequence int,
total int64,
) (int64, int, error) {
parsed, ok := ParseBackendLogFilename(output.Name)
if !ok || parsed.Active || parsed.Legacy || parsed.Day != journal.SourceDay || output.Length <= 0 || output.Offset < 0 {
return 0, 0, fmt.Errorf("invalid conversion output")
}
if index > 0 && parsed.Sequence <= previousSequence {
return 0, 0, fmt.Errorf("conversion outputs are not ordered")
}
if output.Offset+output.Length > journal.SourceSize-journal.TailOffset || output.Offset != total {
return 0, 0, fmt.Errorf("conversion output exceeds source range")
}
return total + output.Length, parsed.Sequence, nil
}
func copyFileRange(sourcePath, destinationPath string, offset, length int64) error {
source, err := os.Open(sourcePath)
if err != nil {
return err
}
defer func() { _ = source.Close() }()
if _, err := source.Seek(offset, io.SeekStart); err != nil {
return err
}
destination, err := os.OpenFile(destinationPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return err
}
if _, err := io.CopyN(destination, source, length); err != nil {
_ = destination.Close()
_ = os.Remove(destinationPath)
return err
}
if err := destination.Sync(); err != nil {
_ = destination.Close()
_ = os.Remove(destinationPath)
return err
}
if err := destination.Close(); err != nil {
_ = os.Remove(destinationPath)
return err
}
return nil
}
func writeJSONOwnerFile(path string, value any) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return writeOwnerFile(path, data)
}
@@ -0,0 +1,387 @@
package logger
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestDailyWriterRotatesFullActiveSegment(t *testing.T) {
logDir := t.TempDir()
now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
writer, err := newDailyWriter(logDir, func() time.Time { return now })
if err != nil {
t.Fatalf("newDailyWriter: %v", err)
}
defer func() { _ = writer.Close() }()
writer.maxBytes = int64(len("first\n"))
if _, err := writer.Write([]byte("first\n")); err != nil {
t.Fatalf("write first segment: %v", err)
}
if _, err := writer.Write([]byte("next\n")); err != nil {
t.Fatalf("write second segment: %v", err)
}
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000001.log")); got != "first\n" {
t.Fatalf("closed segment = %q, want first entry", got)
}
if got := readLogFile(t, filepath.Join(logDir, activeBackendLogName)); got != "next\n" {
t.Fatalf("active segment = %q, want next entry", got)
}
}
func TestDailyWriterEvictsOldestClosedSegment(t *testing.T) {
logDir := t.TempDir()
now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
writer := newTestDailyWriter(t, logDir, now, 4, 8)
for _, entry := range []string{"one\n", "two\n", "tri\n"} {
if _, err := writer.Write([]byte(entry)); err != nil {
t.Fatalf("write %q: %v", entry, err)
}
}
if _, err := os.Stat(filepath.Join(logDir, "backend-logs-2026-08-22-000001.log")); !os.IsNotExist(err) {
t.Fatalf("oldest segment still exists, stat error = %v", err)
}
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000002.log")); got != "two\n" {
t.Fatalf("retained closed segment = %q, want two", got)
}
if got := readLogFile(t, filepath.Join(logDir, activeBackendLogName)); got != "tri\n" {
t.Fatalf("active segment = %q, want tri", got)
}
}
func TestDailyWriterEvictsOldestSegmentAcrossUTCDays(t *testing.T) {
logDir := t.TempDir()
now := time.Date(2026, 8, 21, 23, 59, 0, 0, time.UTC)
current := now
writer := newTestDailyWriterWithClock(t, logDir, func() time.Time { return current }, 4, 8)
if _, err := writer.Write([]byte("one\n")); err != nil {
t.Fatalf("write one: %v", err)
}
if _, err := writer.Write([]byte("two\n")); err != nil {
t.Fatalf("write two: %v", err)
}
current = current.Add(2 * time.Minute)
if _, err := writer.Write([]byte("tri\n")); err != nil {
t.Fatalf("write tri: %v", err)
}
if _, err := os.Stat(filepath.Join(logDir, "backend-logs-2026-08-21-000001.log")); !os.IsNotExist(err) {
t.Fatalf("oldest cross-day segment remains: %v", err)
}
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-21-000002.log")); got != "two\n" {
t.Fatalf("retained prior-day segment = %q, want two", got)
}
if got := readLogFile(t, filepath.Join(logDir, activeBackendLogName)); got != "tri\n" {
t.Fatalf("active next-day segment = %q, want tri", got)
}
}
func TestDailyWriterResumesSequenceAfterRestart(t *testing.T) {
logDir := t.TempDir()
now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
writer := newTestDailyWriter(t, logDir, now, 4, 20)
if _, err := writer.Write([]byte("one\n")); err != nil {
t.Fatalf("write one: %v", err)
}
if _, err := writer.Write([]byte("two\n")); err != nil {
t.Fatalf("write two: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close first writer: %v", err)
}
writer = newTestDailyWriter(t, logDir, now, 4, 20)
if _, err := writer.Write([]byte("tri\n")); err != nil {
t.Fatalf("write tri: %v", err)
}
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000001.log")); got != "one\n" {
t.Fatalf("first sequence = %q, want one", got)
}
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000002.log")); got != "two\n" {
t.Fatalf("second sequence = %q, want two", got)
}
if got := readLogFile(t, filepath.Join(logDir, activeBackendLogName)); got != "tri\n" {
t.Fatalf("active after restart = %q, want tri", got)
}
}
func TestDailyWriterConvertsOversizedActiveLog(t *testing.T) {
logDir := t.TempDir()
day := "2026-08-22"
source := []byte("one-two-three\n")
if err := os.WriteFile(filepath.Join(logDir, activeBackendLogName), source, 0o600); err != nil {
t.Fatalf("seed active log: %v", err)
}
if err := os.WriteFile(filepath.Join(logDir, backendDayMarkerName), []byte(day), 0o600); err != nil {
t.Fatalf("seed day marker: %v", err)
}
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 5, 10)
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000001.log")); got != "two-t" {
t.Fatalf("converted first segment = %q, want two-t", got)
}
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000002.log")); got != "hree\n" {
t.Fatalf("converted second segment = %q, want hree", got)
}
if _, err := os.Stat(filepath.Join(logDir, "backend-logs-2026-08-22-000003.log")); !os.IsNotExist(err) {
t.Fatalf("discarded third segment remains: %v", err)
}
if got := readLogFile(t, filepath.Join(logDir, activeBackendLogName)); got != "" {
t.Fatalf("converted active log = %q, want empty", got)
}
if _, err := os.Stat(filepath.Join(logDir, conversionJournalName)); !os.IsNotExist(err) {
t.Fatalf("conversion journal remains: %v", err)
}
}
func TestDailyWriterRecoversOversizedConversionWithTailOffset(t *testing.T) {
logDir := t.TempDir()
source := []byte("one-two-three\n")
journal := &conversionJournal{
SourceDay: "2026-08-22", SourceSize: int64(len(source)), TailOffset: 4,
BackupName: conversionSourceName,
Outputs: []conversionOutput{
{Name: "backend-logs-2026-08-22-000001.log", Offset: 0, Length: 5},
{Name: "backend-logs-2026-08-22-000002.log", Offset: 5, Length: 5},
},
}
if err := os.WriteFile(filepath.Join(logDir, conversionSourceName), source, 0o600); err != nil {
t.Fatalf("seed conversion source: %v", err)
}
if err := writeJSONOwnerFile(filepath.Join(logDir, conversionJournalName), journal); err != nil {
t.Fatalf("seed conversion journal: %v", err)
}
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 5, 10)
for name, want := range map[string]string{
"backend-logs-2026-08-22-000001.log": "two-t",
"backend-logs-2026-08-22-000002.log": "hree\n",
} {
if got := readLogFile(t, filepath.Join(logDir, name)); got != want {
t.Fatalf("recovered %s = %q, want %q", name, got, want)
}
}
if _, err := os.Stat(filepath.Join(logDir, conversionSourceName)); !os.IsNotExist(err) {
t.Fatalf("conversion source remains: %v", err)
}
}
func TestBackendLogDayRetainedRejectsFutureDays(t *testing.T) {
today := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
for day, want := range map[string]bool{
"2026-08-20": true,
"2026-08-21": true,
"2026-08-22": true,
"2026-08-23": false,
"not-a-day": false,
} {
if got := BackendLogDayRetained(day, today); got != want {
t.Fatalf("BackendLogDayRetained(%q) = %v, want %v", day, got, want)
}
}
}
func TestDailyWriterConversionPrefersActiveTailOverClosedLogs(t *testing.T) {
logDir := t.TempDir()
source := []byte("one-two-three\n")
if err := os.WriteFile(filepath.Join(logDir, "backend-logs-2026-08-21.log"), []byte("old-data"), 0o600); err != nil {
t.Fatalf("seed old log: %v", err)
}
if err := os.WriteFile(filepath.Join(logDir, activeBackendLogName), source, 0o600); err != nil {
t.Fatalf("seed active log: %v", err)
}
if err := os.WriteFile(filepath.Join(logDir, backendDayMarkerName), []byte("2026-08-22"), 0o600); err != nil {
t.Fatalf("seed day marker: %v", err)
}
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 5, 15)
if _, err := os.Stat(filepath.Join(logDir, "backend-logs-2026-08-21.log")); !os.IsNotExist(err) {
t.Fatalf("older log was retained while converting active log: %v", err)
}
for name, want := range map[string]string{
"backend-logs-2026-08-22-000001.log": "one-t",
"backend-logs-2026-08-22-000002.log": "wo-th",
"backend-logs-2026-08-22-000003.log": "ree\n",
} {
if got := readLogFile(t, filepath.Join(logDir, name)); got != want {
t.Fatalf("converted %s = %q, want %q", name, got, want)
}
}
}
func TestDailyWriterCleansExpiredSegmentsAndLegacyFiles(t *testing.T) {
logDir := t.TempDir()
for name := range map[string]string{
"backend-logs-2026-08-19.log": "expired-legacy",
"backend-logs-2026-08-20.log": "oldest-retained",
"backend-logs-2026-08-21-000001.log": "yesterday",
} {
if err := os.WriteFile(filepath.Join(logDir, name), []byte(name), 0o600); err != nil {
t.Fatalf("seed %s: %v", name, err)
}
}
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 8, 100)
if _, err := os.Stat(filepath.Join(logDir, "backend-logs-2026-08-19.log")); !os.IsNotExist(err) {
t.Fatalf("expired legacy file remains: %v", err)
}
for _, name := range []string{"backend-logs-2026-08-20.log", "backend-logs-2026-08-21-000001.log"} {
if _, err := os.Stat(filepath.Join(logDir, name)); err != nil {
t.Fatalf("retained file %s missing: %v", name, err)
}
}
}
func TestDailyWriterRecoversInterruptedConversion(t *testing.T) {
logDir := t.TempDir()
source := []byte("one-two-three\n")
backup := filepath.Join(logDir, conversionSourceName)
if err := os.WriteFile(backup, source, 0o600); err != nil {
t.Fatalf("seed conversion source: %v", err)
}
journal := &conversionJournal{
SourceDay: "2026-08-22", SourceSize: int64(len(source)), TailOffset: 0,
BackupName: conversionSourceName,
Outputs: []conversionOutput{
{Name: "backend-logs-2026-08-22-000001.log", Offset: 0, Length: 5},
{Name: "backend-logs-2026-08-22-000002.log", Offset: 5, Length: 5},
{Name: "backend-logs-2026-08-22-000003.log", Offset: 10, Length: 4},
},
}
if err := writeJSONOwnerFile(filepath.Join(logDir, conversionJournalName), journal); err != nil {
t.Fatalf("seed conversion journal: %v", err)
}
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 5, 20)
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000001.log")); got != "one-t" {
t.Fatalf("recovered first segment = %q", got)
}
if got := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000003.log")); got != "ree\n" {
t.Fatalf("recovered last segment = %q", got)
}
if _, err := os.Stat(backup); !os.IsNotExist(err) {
t.Fatalf("conversion source remains: %v", err)
}
}
func TestDailyWriterRecoversStaleConversionTemp(t *testing.T) {
logDir := t.TempDir()
source := []byte("one-two-three\n")
if err := os.WriteFile(filepath.Join(logDir, conversionSourceName), source, 0o600); err != nil {
t.Fatalf("seed conversion source: %v", err)
}
journal := &conversionJournal{
SourceDay: "2026-08-22", SourceSize: int64(len(source)), TailOffset: 0,
BackupName: conversionSourceName,
Outputs: []conversionOutput{
{Name: "backend-logs-2026-08-22-000001.log", Offset: 0, Length: 5},
{Name: "backend-logs-2026-08-22-000002.log", Offset: 5, Length: 5},
{Name: "backend-logs-2026-08-22-000003.log", Offset: 10, Length: 4},
},
}
if err := writeJSONOwnerFile(filepath.Join(logDir, conversionJournalName), journal); err != nil {
t.Fatalf("seed conversion journal: %v", err)
}
if err := os.WriteFile(filepath.Join(logDir, journal.Outputs[0].Name+".tmp"), []byte("stale"), 0o600); err != nil {
t.Fatalf("seed stale temporary output: %v", err)
}
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 5, 20)
if got := readLogFile(t, filepath.Join(logDir, journal.Outputs[0].Name)); got != "one-t" {
t.Fatalf("recovered first segment = %q", got)
}
if _, err := os.Stat(filepath.Join(logDir, journal.Outputs[0].Name+".tmp")); !os.IsNotExist(err) {
t.Fatalf("stale temporary output remains: %v", err)
}
}
func TestDailyWriterResumesInterruptedConversionTruncation(t *testing.T) {
logDir := t.TempDir()
journal := &conversionJournal{
SourceDay: "2026-08-22", SourceSize: 14, TailOffset: 0,
BackupName: conversionSourceName,
Outputs: []conversionOutput{
{Name: "backend-logs-2026-08-22-000001.log", Offset: 0, Length: 5},
{Name: "backend-logs-2026-08-22-000002.log", Offset: 5, Length: 5},
{Name: "backend-logs-2026-08-22-000003.log", Offset: 10, Length: 4},
},
}
if err := os.WriteFile(filepath.Join(logDir, conversionSourceName), []byte("one-two-th"), 0o600); err != nil {
t.Fatalf("seed partially truncated source: %v", err)
}
if err := os.WriteFile(filepath.Join(logDir, journal.Outputs[2].Name), []byte("ree\n"), 0o600); err != nil {
t.Fatalf("seed newest output: %v", err)
}
if err := writeJSONOwnerFile(filepath.Join(logDir, conversionJournalName), journal); err != nil {
t.Fatalf("seed conversion journal: %v", err)
}
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 5, 20)
for name, want := range map[string]string{
"backend-logs-2026-08-22-000001.log": "one-t",
"backend-logs-2026-08-22-000002.log": "wo-th",
"backend-logs-2026-08-22-000003.log": "ree\n",
} {
if got := readLogFile(t, filepath.Join(logDir, name)); got != want {
t.Fatalf("recovered %s = %q, want %q", name, got, want)
}
}
}
func TestDailyWriterPreservesFreshActiveLogAfterCompletedConversion(t *testing.T) {
logDir := t.TempDir()
if err := os.WriteFile(filepath.Join(logDir, activeBackendLogName), []byte("new\n"), 0o600); err != nil {
t.Fatalf("seed fresh active log: %v", err)
}
if err := os.WriteFile(filepath.Join(logDir, backendDayMarkerName), []byte("2026-08-22"), 0o600); err != nil {
t.Fatalf("seed active day marker: %v", err)
}
journal := &conversionJournal{
SourceDay: "2026-08-22", SourceSize: 14, TailOffset: 0,
BackupName: conversionSourceName,
Outputs: []conversionOutput{
{Name: "backend-logs-2026-08-22-000001.log", Offset: 0, Length: 5},
{Name: "backend-logs-2026-08-22-000002.log", Offset: 5, Length: 5},
{Name: "backend-logs-2026-08-22-000003.log", Offset: 10, Length: 4},
},
}
for _, output := range journal.Outputs {
contents := map[int64]string{0: "one-t", 5: "wo-th", 10: "ree\n"}[output.Offset]
if err := os.WriteFile(filepath.Join(logDir, output.Name), []byte(contents), 0o600); err != nil {
t.Fatalf("seed %s: %v", output.Name, err)
}
}
if err := writeJSONOwnerFile(filepath.Join(logDir, conversionJournalName), journal); err != nil {
t.Fatalf("seed conversion journal: %v", err)
}
_ = newTestDailyWriter(t, logDir, time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC), 5, 20)
if got := readLogFile(t, filepath.Join(logDir, activeBackendLogName)); got != "new\n" {
t.Fatalf("fresh active log = %q, want new entry", got)
}
}
func newTestDailyWriter(t *testing.T, logDir string, now time.Time, segmentBytes, totalBytes int64) *dailyWriter {
return newTestDailyWriterWithClock(t, logDir, func() time.Time { return now }, segmentBytes, totalBytes)
}
func newTestDailyWriterWithClock(
t *testing.T, logDir string, now func() time.Time, segmentBytes, totalBytes int64,
) *dailyWriter {
t.Helper()
if err := os.MkdirAll(logDir, 0o700); err != nil {
t.Fatalf("create log directory: %v", err)
}
writer := &dailyWriter{
logDir: logDir, now: now,
maxBytes: segmentBytes, maxTotalBytes: totalBytes,
}
t.Cleanup(func() { _ = writer.Close() })
if err := writer.prepare(); err != nil {
t.Fatalf("prepare writer: %v", err)
}
return writer
}
@@ -241,9 +241,6 @@ func (w *retryDailyWriter) Write(data []byte) (int, error) {
}
written, err := w.writer.Write(data)
if err != nil {
if errors.Is(err, errDailyBackendLogLimit) {
return written, err
}
_ = w.writer.Close()
w.writer = nil
w.nextRetry = w.now().Add(30 * time.Second)
@@ -1,36 +1,14 @@
package logger
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sync"
"time"
)
const (
activeBackendLogName = "backend-logs.log"
backendDayMarkerName = ".backend-logs-day"
rolloverJournalName = ".backend-logs-rollover.json"
dailyBackendLogBytes = 256 * 1024 * 1024
)
var datedBackendLogPattern = regexp.MustCompile(`^backend-logs-(\d{4}-\d{2}-\d{2})\.log$`)
var errDailyBackendLogLimit = errors.New("daily backend log byte limit reached")
type rolloverJournal struct {
SourceDay string `json:"source_day"`
SourceSize int64 `json:"source_size"`
SourceModUnixNano int64 `json:"source_mod_unix_nano"`
Destination string `json:"destination"`
DestinationStart int64 `json:"destination_start"`
CopiedOffset int64 `json:"copied_offset"`
}
type dailyWriter struct {
mu sync.Mutex
logDir string
@@ -38,7 +16,9 @@ type dailyWriter struct {
day string
file *os.File
size int64
retainedBytes int64
maxBytes int64
maxTotalBytes int64
closed bool
maintenanceErr error
}
@@ -53,7 +33,10 @@ func newDailyWriter(logDir string, now func() time.Time) (*dailyWriter, error) {
if err := os.Chmod(logDir, 0o700); err != nil {
return nil, fmt.Errorf("secure log directory: %w", err)
}
writer := &dailyWriter{logDir: logDir, now: now, maxBytes: dailyBackendLogBytes}
writer := &dailyWriter{
logDir: logDir, now: now, maxBytes: backendLogSegmentBytes,
maxTotalBytes: backendLogTotalBytes,
}
if err := writer.prepare(); err != nil {
return nil, err
}
@@ -65,19 +48,36 @@ func (w *dailyWriter) prepare() error {
if err := w.recoverRollover(); err != nil {
return fmt.Errorf("recover rollover: %w", err)
}
if err := w.recoverConversion(); err != nil {
return fmt.Errorf("recover log conversion: %w", err)
}
w.recordMaintenance(w.cleanup(today))
day, err := w.readActiveDay(today)
if err != nil {
return err
}
if day != today {
if err := w.rollover(day); err != nil {
active, exists, err := w.activeInfo()
if err != nil {
return err
}
if exists && active.Size > w.maxBytes {
if err := w.convertActive(day, active.Size); err != nil {
return fmt.Errorf("convert oversized active log: %w", err)
}
exists = false
}
if exists && day != today {
if err := w.rotateActive(day); err != nil {
return fmt.Errorf("roll over %s: %w", day, err)
}
}
if err := w.openActive(today); err != nil {
return err
}
w.maintenanceErr = w.cleanup(today)
w.recordMaintenance(w.cleanup(today))
w.recordMaintenance(w.refreshRetainedBytes())
w.recordMaintenance(w.enforceBudget(0))
return nil
}
@@ -87,56 +87,203 @@ func (w *dailyWriter) Write(data []byte) (int, error) {
if w.closed {
return 0, os.ErrClosed
}
if int64(len(data)) > w.maxBytes {
return 0, fmt.Errorf("backend log entry exceeds segment limit")
}
today := utcDay(w.now())
if today != w.day {
if err := w.switchDay(today); err != nil {
// Preserve the triggering entry in the active file. The next write
// retries rollover while the marker still names the prior day.
if w.file == nil {
return 0, err
}
return w.file.Write(data)
return 0, err
}
}
if w.size+int64(len(data)) > w.maxBytes {
return 0, errDailyBackendLogLimit
if err := w.rotateActive(w.day); err != nil {
return 0, err
}
if err := w.openActive(w.day); err != nil {
return 0, err
}
}
if err := w.enforceBudget(int64(len(data))); err != nil {
return 0, err
}
written, err := w.file.Write(data)
w.size += int64(written)
w.retainedBytes += int64(written)
return written, err
}
func (w *dailyWriter) switchDay(today string) error {
previous := w.day
if err := w.file.Close(); err != nil {
if err := w.rotateActive(w.day); err != nil {
return err
}
w.file = nil
if err := w.rollover(previous); err != nil {
return w.reopenPrevious(previous, err)
}
if err := w.openActive(today); err != nil {
return err
}
w.maintenanceErr = w.cleanup(today)
w.recordMaintenance(w.cleanup(today))
w.recordMaintenance(w.refreshRetainedBytes())
w.recordMaintenance(w.enforceBudget(0))
return nil
}
func (w *dailyWriter) reopenPrevious(day string, rolloverErr error) error {
file, openErr := openOwnerAppend(filepath.Join(w.logDir, activeBackendLogName))
if openErr != nil {
return errors.Join(rolloverErr, openErr)
}
w.file = file
w.day = day
info, statErr := file.Stat()
if statErr != nil {
_ = file.Close()
func (w *dailyWriter) rotateActive(day string) error {
if w.file != nil {
if err := w.file.Close(); err != nil {
return err
}
w.file = nil
return errors.Join(rolloverErr, statErr)
}
w.size = info.Size()
return rolloverErr
active, exists, err := w.activeInfo()
if err != nil {
return err
}
if !exists {
return removeIfExists(filepath.Join(w.logDir, backendDayMarkerName))
}
if active.Size == 0 {
if err := os.Remove(active.Path); err != nil && !os.IsNotExist(err) {
return err
}
return removeIfExists(filepath.Join(w.logDir, backendDayMarkerName))
}
sequence, err := w.nextSequence(day)
if err != nil {
return err
}
destination := filepath.Join(w.logDir, backendLogSegmentName(day, sequence))
if err := renameExclusive(active.Path, destination); err != nil {
return fmt.Errorf("rename active log: %w", err)
}
return removeIfExists(filepath.Join(w.logDir, backendDayMarkerName))
}
func (w *dailyWriter) openActive(day string) error {
file, err := openOwnerAppend(filepath.Join(w.logDir, activeBackendLogName))
if err != nil {
return fmt.Errorf("open active log: %w", err)
}
if err := writeOwnerFile(filepath.Join(w.logDir, backendDayMarkerName), []byte(day)); err != nil {
_ = file.Close()
return fmt.Errorf("write active day marker: %w", err)
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return fmt.Errorf("stat active log: %w", err)
}
if _, err := w.nextSequence(day); err != nil {
_ = file.Close()
return err
}
w.file, w.day, w.size = file, day, info.Size()
return nil
}
func (w *dailyWriter) activeInfo() (backendLogFileInfo, bool, error) {
path := filepath.Join(w.logDir, activeBackendLogName)
info, err := os.Lstat(path)
if os.IsNotExist(err) {
return backendLogFileInfo{}, false, nil
}
if err != nil {
return backendLogFileInfo{}, false, fmt.Errorf("stat active log: %w", err)
}
if !info.Mode().IsRegular() {
return backendLogFileInfo{}, false, fmt.Errorf("active log is not a regular file")
}
return backendLogFileInfo{
Name: activeBackendLogName, Path: path,
BackendLogFile: BackendLogFile{Active: true}, Size: info.Size(), ModTime: info.ModTime(),
}, true, nil
}
func (w *dailyWriter) readActiveDay(today string) (string, error) {
active, exists, err := w.activeInfo()
if err != nil {
return "", err
}
if !exists {
return today, nil
}
data, err := os.ReadFile(filepath.Join(w.logDir, backendDayMarkerName))
if err == nil && validUTCDay(string(data)) {
return string(data), nil
}
return active.ModTime.UTC().Format(time.DateOnly), nil
}
func (w *dailyWriter) nextSequence(day string) (int, error) {
files, err := scanBackendLogFiles(w.logDir)
if err != nil {
return 0, err
}
return nextBackendLogSequence(files, day)
}
func (w *dailyWriter) enforceBudget(additional int64) error {
if additional < 0 || w.maxTotalBytes <= 0 {
return fmt.Errorf("invalid backend log budget")
}
if w.retainedBytes+additional <= w.maxTotalBytes {
return nil
}
files, err := scanBackendLogFiles(w.logDir)
if err != nil {
return err
}
total := backendLogBytes(files)
closed := closedBackendLogFiles(files)
sortBackendLogFiles(closed)
for total+additional > w.maxTotalBytes && len(closed) > 0 {
oldest := closed[0]
if err := os.Remove(oldest.Path); err != nil {
return fmt.Errorf("remove oldest backend log %s: %w", oldest.Name, err)
}
total -= oldest.Size
closed = closed[1:]
}
w.retainedBytes = total
if total+additional > w.maxTotalBytes {
return fmt.Errorf("backend log budget exceeded")
}
return nil
}
func (w *dailyWriter) refreshRetainedBytes() error {
files, err := scanBackendLogFiles(w.logDir)
if err != nil {
return err
}
w.retainedBytes = backendLogBytes(files)
return nil
}
func (w *dailyWriter) cleanup(today string) error {
files, err := scanBackendLogFiles(w.logDir)
if err != nil {
return err
}
todayTime, err := time.Parse(time.DateOnly, today)
if err != nil {
return err
}
for _, file := range files {
if file.Active || file.Day == "" {
continue
}
if !BackendLogDayRetained(file.Day, todayTime) {
if err := os.Remove(file.Path); err != nil {
return fmt.Errorf("remove expired backend log %s: %w", file.Name, err)
}
}
}
return nil
}
func (w *dailyWriter) recordMaintenance(err error) {
if err != nil {
w.maintenanceErr = errors.Join(w.maintenanceErr, err)
}
}
func (w *dailyWriter) Close() error {
@@ -158,222 +305,10 @@ func (w *dailyWriter) MaintenanceError() error {
return w.maintenanceErr
}
func (w *dailyWriter) readActiveDay(today string) (string, error) {
activePath := filepath.Join(w.logDir, activeBackendLogName)
if _, err := os.Stat(activePath); os.IsNotExist(err) {
return today, nil
} else if err != nil {
return "", fmt.Errorf("stat active log: %w", err)
}
data, err := os.ReadFile(filepath.Join(w.logDir, backendDayMarkerName))
if err == nil {
if day := string(data); validUTCDay(day) {
return day, nil
}
}
info, err := os.Stat(activePath)
if err != nil {
return "", fmt.Errorf("stat unmarked active log: %w", err)
}
return info.ModTime().UTC().Format(time.DateOnly), nil
}
func (w *dailyWriter) openActive(day string) error {
file, err := openOwnerAppend(filepath.Join(w.logDir, activeBackendLogName))
if err != nil {
return fmt.Errorf("open active log: %w", err)
}
if err := writeOwnerFile(filepath.Join(w.logDir, backendDayMarkerName), []byte(day)); err != nil {
_ = file.Close()
return fmt.Errorf("write active day marker: %w", err)
}
w.file = file
w.day = day
info, err := file.Stat()
if err != nil {
_ = file.Close()
w.file = nil
return fmt.Errorf("stat active log: %w", err)
}
w.size = info.Size()
return nil
}
func (w *dailyWriter) rollover(day string) error {
activePath := filepath.Join(w.logDir, activeBackendLogName)
info, err := os.Stat(activePath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
journal, err := w.loadOrCreateJournal(day, info)
if err != nil {
return err
}
if err := w.copyRollover(activePath, journal); err != nil {
return err
}
if err := os.Remove(activePath); err != nil && !os.IsNotExist(err) {
return err
}
_ = os.Remove(filepath.Join(w.logDir, backendDayMarkerName))
return removeIfExists(filepath.Join(w.logDir, rolloverJournalName))
}
func (w *dailyWriter) loadOrCreateJournal(day string, info os.FileInfo) (*rolloverJournal, error) {
path := filepath.Join(w.logDir, rolloverJournalName)
if data, err := os.ReadFile(path); err == nil {
var journal rolloverJournal
if err := json.Unmarshal(data, &journal); err != nil {
return nil, fmt.Errorf("decode rollover journal: %w", err)
}
return &journal, nil
}
destination := fmt.Sprintf("backend-logs-%s.log", day)
destinationInfo, err := os.Stat(filepath.Join(w.logDir, destination))
var destinationStart int64
if err == nil {
destinationStart = destinationInfo.Size()
} else if !os.IsNotExist(err) {
return nil, err
}
journal := &rolloverJournal{
SourceDay: day, SourceSize: info.Size(), SourceModUnixNano: info.ModTime().UnixNano(),
Destination: destination, DestinationStart: destinationStart,
}
return journal, writeJSONOwnerFile(path, journal)
}
func (w *dailyWriter) copyRollover(activePath string, journal *rolloverJournal) error {
source, err := os.Open(activePath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer func() { _ = source.Close() }()
sourceInfo, err := source.Stat()
if err != nil {
return err
}
if sourceInfo.Size() != journal.SourceSize ||
sourceInfo.ModTime().UnixNano() != journal.SourceModUnixNano {
journal.SourceSize = sourceInfo.Size()
journal.SourceModUnixNano = sourceInfo.ModTime().UnixNano()
if err := writeJSONOwnerFile(filepath.Join(w.logDir, rolloverJournalName), journal); err != nil {
return err
}
}
destinationPath := filepath.Join(w.logDir, filepath.Base(journal.Destination))
destination, err := openOwnerAppend(destinationPath)
if err != nil {
return err
}
defer func() { _ = destination.Close() }()
destinationInfo, err := destination.Stat()
if err != nil {
return err
}
copied := destinationInfo.Size() - journal.DestinationStart
if copied < 0 || copied > journal.SourceSize {
return fmt.Errorf("invalid rollover destination size")
}
journal.CopiedOffset = copied
if _, err := source.Seek(copied, io.SeekStart); err != nil {
return err
}
if err := copyJournaled(source, destination, journal, filepath.Join(w.logDir, rolloverJournalName)); err != nil {
return err
}
return destination.Sync()
}
func copyJournaled(source io.Reader, destination io.Writer, journal *rolloverJournal, path string) error {
buf := make([]byte, 1024*1024)
for {
n, readErr := source.Read(buf)
if n > 0 {
written, writeErr := destination.Write(buf[:n])
journal.CopiedOffset += int64(written)
if writeErr != nil {
return writeErr
}
if written != n {
return io.ErrShortWrite
}
if err := writeJSONOwnerFile(path, journal); err != nil {
return err
}
}
if errors.Is(readErr, io.EOF) {
return nil
}
if readErr != nil {
return readErr
}
}
}
func (w *dailyWriter) recoverRollover() error {
path := filepath.Join(w.logDir, rolloverJournalName)
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var journal rolloverJournal
if err := json.Unmarshal(data, &journal); err != nil {
return err
}
activePath := filepath.Join(w.logDir, activeBackendLogName)
if _, err := os.Stat(activePath); os.IsNotExist(err) {
return removeIfExists(path)
}
if err := w.copyRollover(activePath, &journal); err != nil {
return err
}
if err := os.Remove(activePath); err != nil && !os.IsNotExist(err) {
return err
}
return removeIfExists(path)
}
func (w *dailyWriter) cleanup(today string) error {
entries, err := os.ReadDir(w.logDir)
if err != nil {
return err
}
cutoff, _ := time.Parse(time.DateOnly, today)
cutoff = cutoff.AddDate(0, 0, -2)
for _, entry := range entries {
match := datedBackendLogPattern.FindStringSubmatch(entry.Name())
if len(match) != 2 {
continue
}
day, err := time.Parse(time.DateOnly, match[1])
if err == nil && day.Before(cutoff) {
if err := os.Remove(filepath.Join(w.logDir, entry.Name())); err != nil {
return err
}
}
}
return nil
}
func utcDay(now time.Time) string {
return now.UTC().Format(time.DateOnly)
}
func validUTCDay(value string) bool {
parsed, err := time.Parse(time.DateOnly, value)
return err == nil && parsed.Format(time.DateOnly) == value
}
func openOwnerAppend(path string) (*os.File, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err == nil {
@@ -393,17 +328,18 @@ func writeOwnerFile(path string, data []byte) error {
return os.Rename(temp, path)
}
func writeJSONOwnerFile(path string, value any) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return writeOwnerFile(path, data)
}
func removeIfExists(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func renameExclusive(source, destination string) error {
if _, err := os.Lstat(destination); err == nil {
return os.ErrExist
} else if !os.IsNotExist(err) {
return err
}
return os.Rename(source, destination)
}
@@ -37,7 +37,7 @@ func TestDailyWriterAppendsAndRollsByUTCDay(t *testing.T) {
t.Fatalf("close day two: %v", err)
}
archived := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-07-29.log"))
archived := readLogFile(t, filepath.Join(logDir, "backend-logs-2026-07-29-000001.log"))
if archived != "day-one\nsame-day\n" {
t.Fatalf("archived log = %q", archived)
}
@@ -131,7 +131,7 @@ func TestDailyWriterResumesJournalWithoutDuplicatingDestination(t *testing.T) {
}
}
func TestDailyWriterRejectsEntriesBeyondDailyByteLimit(t *testing.T) {
func TestDailyWriterRejectsEntryLargerThanSegment(t *testing.T) {
logDir := t.TempDir()
activePath := filepath.Join(logDir, activeBackendLogName)
const dailyLimit = int64(len("full\n"))
@@ -148,8 +148,8 @@ func TestDailyWriterRejectsEntriesBeyondDailyByteLimit(t *testing.T) {
t.Fatalf("newDailyWriter: %v", err)
}
defer func() { _ = writer.Close() }()
if writer.maxBytes != dailyBackendLogBytes {
t.Fatalf("default daily limit = %d, want %d", writer.maxBytes, dailyBackendLogBytes)
if writer.maxBytes != backendLogSegmentBytes {
t.Fatalf("default segment limit = %d, want %d", writer.maxBytes, backendLogSegmentBytes)
}
writer.maxBytes = dailyLimit
@@ -137,10 +137,11 @@ func (c *Client) ReadPump(_ context.Context) {
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
// CloseNormalClosure (1000): Normal browser close
// CloseGoingAway (1001): Client navigating away
// CloseNoStatusReceived (1005): Client closed without status (normal browser close)
// CloseAbnormalClosure (1006): Abnormal close (network drop)
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived, websocket.CloseAbnormalClosure) {
if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived, websocket.CloseAbnormalClosure) {
c.logger.Error("WebSocket read error", zap.Error(err))
}
break
@@ -0,0 +1,84 @@
package websocket
import (
"context"
"testing"
"time"
gorillaws "github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestReadPumpCloseLogging(t *testing.T) {
tests := []struct {
name string
code int
wantError bool
}{
{name: "normal", code: gorillaws.CloseNormalClosure},
{name: "unexpected", code: gorillaws.CloseInternalServerErr, wantError: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
core, recorded := observer.New(zap.DebugLevel)
log, err := logger.NewFromZap(zap.New(core, zap.AddStacktrace(zap.ErrorLevel)))
if err != nil {
t.Fatalf("create logger: %v", err)
}
hub := NewHub(nil, log)
hubCtx, cancelHub := context.WithCancel(context.Background())
hubDone := make(chan struct{})
go func() {
defer close(hubDone)
hub.Run(hubCtx)
}()
t.Cleanup(func() {
cancelHub()
joinWithin(t, hubDone, "websocket hub")
})
browser, server := newTerminalWSPair(t)
client := NewClient("close-test", authn.Identity{}, server, hub, log)
hub.Register(client)
waitForClientCount(t, hub, client.ID)
pumpDone := make(chan struct{})
go func() {
defer close(pumpDone)
client.ReadPump(context.Background())
}()
t.Cleanup(func() {
_ = browser.Close()
_ = server.Close()
joinWithin(t, pumpDone, "read pump")
})
if err := browser.WriteControl(
gorillaws.CloseMessage,
gorillaws.FormatCloseMessage(test.code, "test close"),
time.Now().Add(wsTestTimeout),
); err != nil {
t.Fatalf("write close frame: %v", err)
}
joinWithin(t, pumpDone, "read pump")
records := recorded.FilterMessage("WebSocket read error").All()
wantRecords := 0
if test.wantError {
wantRecords = 1
}
if len(records) != wantRecords {
t.Fatalf("read error records = %d, want %d; records = %+v", len(records), wantRecords, records)
}
if !test.wantError {
return
}
if records[0].Stack == "" {
t.Fatal("unexpected close error did not include a stack trace")
}
})
}
}
@@ -278,48 +278,29 @@ func (s *Service) addBackendFiles(
manifestFiles := make([]backendArchiveFile, 0, len(candidates))
included := false
for _, candidate := range candidates {
info, err := os.Lstat(candidate.Path)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, included, partial, warnings, err
}
if !info.Mode().IsRegular() {
continue
}
if remaining == 0 {
partial = true
warnings = appendUnique(warnings, "backend logs were truncated to the archive byte limit")
continue
}
length := min(info.Size(), remaining)
offset := info.Size() - length
if length < info.Size() {
file, ok, err := addBackendCandidate(writer, candidate, remaining, openBackendCandidate)
if err != nil {
if os.IsNotExist(err) {
partial = true
warnings = appendUnique(warnings, "backend logs changed during collection")
continue
}
return nil, included, partial, warnings, err
}
if !ok {
continue
}
if file.Length < file.Size {
partial = true
warnings = appendUnique(warnings, "backend logs were truncated to the archive byte limit")
}
source, err := os.Open(candidate.Path)
if err != nil {
return nil, included, partial, warnings, err
}
header := &zip.FileHeader{Name: "backend/" + candidate.Name, Method: zip.Store}
header.SetMode(0o600)
destination, err := writer.CreateHeader(header)
if err == nil {
err = copySection(destination, source, offset, length)
}
closeErr := source.Close()
if err != nil {
return nil, included, partial, warnings, err
}
if closeErr != nil {
return nil, included, partial, warnings, closeErr
}
manifestFiles = append(manifestFiles, backendArchiveFile{
Name: candidate.Name, Size: info.Size(), Offset: offset, Length: length,
})
remaining -= length
manifestFiles = append(manifestFiles, file)
remaining -= file.Length
included = true
}
if !included {
@@ -329,24 +310,40 @@ func (s *Service) addBackendFiles(
return manifestFiles, included, partial, warnings, nil
}
type backendCandidate struct {
Name string
Path string
}
func (s *Service) backendCandidates() []backendCandidate {
logDir := filepath.Join(s.config.HomeDir, "logs")
now := s.config.Now().UTC()
names := []string{"backend-logs.log"}
for daysAgo := 1; daysAgo <= 2; daysAgo++ {
date := now.AddDate(0, 0, -daysAgo).Format("2006-01-02")
names = append(names, "backend-logs-"+date+".log")
func addBackendCandidate(
writer *zip.Writer, candidate backendCandidate, budget int64,
open func(string) (*os.File, error),
) (backendArchiveFile, bool, error) {
source, err := open(candidate.Path)
if err != nil {
return backendArchiveFile{}, false, err
}
out := make([]backendCandidate, 0, len(names))
for _, name := range names {
out = append(out, backendCandidate{Name: name, Path: filepath.Join(logDir, name)})
info, err := source.Stat()
if err != nil {
_ = source.Close()
return backendArchiveFile{}, false, err
}
return out
if !info.Mode().IsRegular() {
return backendArchiveFile{}, false, source.Close()
}
length := min(info.Size(), budget)
offset := info.Size() - length
header := &zip.FileHeader{Name: "backend/" + candidate.Name, Method: zip.Store}
header.SetMode(0o600)
destination, err := writer.CreateHeader(header)
if err == nil {
err = copySection(destination, source, offset, length)
}
closeErr := source.Close()
if err != nil {
return backendArchiveFile{}, false, err
}
if closeErr != nil {
return backendArchiveFile{}, false, closeErr
}
return backendArchiveFile{
Name: candidate.Name, Size: info.Size(), Offset: offset, Length: length,
}, true, nil
}
func addFrontendFiles(writer *zip.Writer, item *job) ([]frontendArchiveFile, error) {
@@ -0,0 +1,87 @@
package logbundle
import (
"os"
"path/filepath"
"slices"
"time"
"github.com/kandev/kandev/internal/common/logger"
)
type backendCandidate struct {
Name string
Path string
Day string
Sequence int
Active bool
Legacy bool
}
func (s *Service) backendCandidates() []backendCandidate {
logDir := filepath.Join(s.config.HomeDir, "logs")
entries, err := os.ReadDir(logDir)
if err != nil {
return nil
}
today := s.config.Now().UTC()
todayDay := today.Format(time.DateOnly)
candidates := make([]backendCandidate, 0, len(entries))
for _, entry := range entries {
parsed, ok := logger.ParseBackendLogFilename(entry.Name())
if !ok {
continue
}
path := filepath.Join(logDir, entry.Name())
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() {
continue
}
day := parsed.Day
if parsed.Active {
day = todayDay
}
if !logger.BackendLogDayRetained(day, today) {
continue
}
candidates = append(candidates, backendCandidate{
Name: entry.Name(), Path: path, Day: day, Sequence: parsed.Sequence,
Active: parsed.Active, Legacy: parsed.Legacy,
})
}
slices.SortFunc(candidates, compareBackendCandidates)
return candidates
}
func compareBackendCandidates(left, right backendCandidate) int {
if left.Day != right.Day {
if left.Day > right.Day {
return -1
}
return 1
}
leftRank, rightRank := backendCandidateRank(left), backendCandidateRank(right)
if leftRank != rightRank {
if leftRank > rightRank {
return -1
}
return 1
}
if left.Name < right.Name {
return -1
}
if left.Name > right.Name {
return 1
}
return 0
}
func backendCandidateRank(candidate backendCandidate) int {
if candidate.Active {
return int(^uint(0) >> 1)
}
if candidate.Legacy {
return -1
}
return candidate.Sequence
}
@@ -0,0 +1,171 @@
package logbundle
import (
"archive/zip"
"bytes"
"io"
"os"
"path/filepath"
"testing"
"time"
)
func TestBackendCandidatesDiscoverNewestSegments(t *testing.T) {
home := t.TempDir()
logDir := filepath.Join(home, "logs")
if err := os.MkdirAll(logDir, 0o700); err != nil {
t.Fatalf("create log directory: %v", err)
}
for name, contents := range map[string]string{
"backend-logs.log": "active",
"backend-logs-2026-08-22-000001.log": "first",
"backend-logs-2026-08-22-000002.log": "latest",
"backend-logs-2026-08-23-000001.log": "future",
"backend-logs-2026-08-21.log": "legacy",
"backend-logs-2026-08-19-000001.log": "expired",
"backend-logs-not-a-segment.log": "malformed",
"backend-logs-2026-08-22-000003.log.tmp": "temporary",
} {
writeTestFile(t, filepath.Join(logDir, name), contents)
}
writeTestFile(t, filepath.Join(logDir, "notes.log"), "private")
if err := os.Symlink(filepath.Join(logDir, "notes.log"), filepath.Join(logDir, "backend-logs-2026-08-22-000004.log")); err != nil {
t.Fatalf("create symlink: %v", err)
}
service := newTestService(t, Config{
HomeDir: home,
Now: func() time.Time { return time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) },
})
candidates := service.backendCandidates()
if got, want := len(candidates), 4; got != want {
t.Fatalf("candidate count = %d, want %d", got, want)
}
want := []string{
"backend-logs.log",
"backend-logs-2026-08-22-000002.log",
"backend-logs-2026-08-22-000001.log",
"backend-logs-2026-08-21.log",
}
for index, candidate := range candidates {
if candidate.Name != want[index] {
t.Fatalf("candidate[%d] = %q, want %q", index, candidate.Name, want[index])
}
}
}
func TestBackendOnlyBundleKeepsNewestSegmentsWithinBudget(t *testing.T) {
home := t.TempDir()
logDir := filepath.Join(home, "logs")
if err := os.MkdirAll(logDir, 0o700); err != nil {
t.Fatalf("create log directory: %v", err)
}
writeTestFile(t, filepath.Join(logDir, "backend-logs.log"), "latest\n")
writeTestFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000002.log"), "second\n")
writeTestFile(t, filepath.Join(logDir, "backend-logs-2026-08-22-000001.log"), "first\n")
writeTestFile(t, filepath.Join(logDir, "backend-logs-2026-08-21.log"), "yesterday\n")
service := newTestService(t, Config{
HomeDir: home,
Now: func() time.Time { return time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) },
})
var archive bytes.Buffer
writer := zip.NewWriter(&archive)
files, included, partial, warnings, err := service.addBackendFiles(writer, false, nil, int64(len("latest\nsecond\n")))
if err != nil {
t.Fatalf("add backend files: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close archive: %v", err)
}
if !included || !partial || len(warnings) == 0 || len(files) != 2 {
t.Fatalf("files=%#v included=%v partial=%v warnings=%v", files, included, partial, warnings)
}
reader, err := zip.NewReader(bytes.NewReader(archive.Bytes()), int64(archive.Len()))
if err != nil {
t.Fatalf("read archive: %v", err)
}
contents := make(map[string]string)
for _, file := range reader.File {
if file.Name == "manifest.json" {
continue
}
opened, err := file.Open()
if err != nil {
t.Fatalf("open %s: %v", file.Name, err)
}
data, err := io.ReadAll(opened)
_ = opened.Close()
if err != nil {
t.Fatalf("read %s: %v", file.Name, err)
}
contents[file.Name] = string(data)
}
if contents["backend/backend-logs.log"] != "latest\n" || contents["backend/backend-logs-2026-08-22-000002.log"] != "second\n" {
t.Fatalf("newest archive contents = %#v", contents)
}
if _, ok := contents["backend/backend-logs-2026-08-22-000001.log"]; ok {
t.Fatal("old segment was included after the archive budget was full")
}
}
func TestBackendCandidateCollectionUsesOpenedHandleAcrossRotation(t *testing.T) {
home := t.TempDir()
logDir := filepath.Join(home, "logs")
if err := os.MkdirAll(logDir, 0o700); err != nil {
t.Fatalf("create log directory: %v", err)
}
path := filepath.Join(logDir, "backend-logs.log")
writeTestFile(t, path, "old-content")
service := newTestService(t, Config{
HomeDir: home,
Now: func() time.Time { return time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) },
})
candidates := service.backendCandidates()
if len(candidates) != 1 {
t.Fatalf("candidate count = %d, want 1", len(candidates))
}
var archive bytes.Buffer
writer := zip.NewWriter(&archive)
rotatedPath := path + ".rotated"
open := func(candidatePath string) (*os.File, error) {
source, err := openBackendCandidate(candidatePath)
if err != nil {
return nil, err
}
if err := os.Rename(candidatePath, rotatedPath); err != nil {
_ = source.Close()
return nil, err
}
writeTestFile(t, candidatePath, "new")
return source, nil
}
file, included, err := addBackendCandidate(writer, candidates[0], int64(len("old-content")), open)
if err != nil {
t.Fatalf("add backend candidate: %v", err)
}
if !included || file.Length != int64(len("old-content")) {
t.Fatalf("file=%#v included=%v", file, included)
}
if err := writer.Close(); err != nil {
t.Fatalf("close archive: %v", err)
}
reader, err := zip.NewReader(bytes.NewReader(archive.Bytes()), int64(archive.Len()))
if err != nil {
t.Fatalf("read archive: %v", err)
}
opened, err := reader.File[0].Open()
if err != nil {
t.Fatalf("open archived log: %v", err)
}
contents, err := io.ReadAll(opened)
_ = opened.Close()
if err != nil {
t.Fatalf("read archived log: %v", err)
}
if got := string(contents); got != "old-content" {
t.Fatalf("archived contents = %q, want old-content", got)
}
}
@@ -0,0 +1,21 @@
//go:build !windows
package logbundle
import (
"errors"
"os"
"golang.org/x/sys/unix"
)
func openBackendCandidate(path string) (*os.File, error) {
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
if err != nil {
if errors.Is(err, unix.ELOOP) {
return nil, os.ErrNotExist
}
return nil, err
}
return os.NewFile(uintptr(fd), path), nil
}
@@ -0,0 +1,21 @@
//go:build windows
package logbundle
import (
"os"
"golang.org/x/sys/windows"
)
func openBackendCandidate(path string) (*os.File, error) {
handle, err := windows.CreateFile(
windows.StringToUTF16Ptr(path), windows.GENERIC_READ,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT, 0,
)
if err != nil {
return nil, err
}
return os.NewFile(uintptr(handle), path), nil
}
@@ -1,9 +1,13 @@
# ADR-2026-07-30-file-backed-diagnostic-bundles: File-backed diagnostic bundles
**Status:** accepted
**Status:** accepted (backend file retention amended by ADR-2026-08-22-preserve-newest-bounded-backend-logs)
**Date:** 2026-07-30
**Area:** backend, frontend, infra, protocol, workflow
The 2026-08-22 amendment replaces the stop-at-256-MiB behavior with bounded
size segments and one 256 MiB budget across retained backend logs. The original
bundle, privacy, UTC-day, and maximum-age decisions remain accepted.
## Context
Frontend error toasts and console failures can be the only user-visible
@@ -126,10 +130,10 @@ does not mistake a best-effort bundle for complete evidence. Diagnostic bundle
requests can be temporarily busy or coalesced instead of competing with normal
product activity.
The fixed daily file bound limits worst-case disk growth but can omit later
same-day evidence after sustained pressure. Count and byte limits reduce how
quickly automatic browser reports consume that budget, while UTC rollover
restores capacity without operator action.
The aggregate backend-log budget limits worst-case disk growth while oldest
closed segments are evicted first, so later evidence remains available during
sustained pressure. Count and byte limits reduce how quickly automatic browser
reports consume that budget, while UTC age cleanup removes stale evidence.
Fixed byte/profile caps mean a very large retained backend file or a fifth
browser profile may be represented only partially. Newest backend bytes are
@@ -0,0 +1,95 @@
# ADR-2026-08-22-preserve-newest-bounded-backend-logs: Preserve Newest Bounded Backend Logs
**Status:** accepted
**Date:** 2026-08-22
**Area:** backend, infra, operations
## Context
The backend stops file logging after the active UTC-day file reaches 256 MiB.
This behavior bounds disk use, but it preserves the earliest entries and
removes later diagnostic evidence.
Issue #2929 exposed this problem through repeated normal WebSocket close
errors. A local debug instance reached the same limit through normal debug
traffic. In both cases, the useful evidence occurred after the file stopped.
## Decision
Kandev keeps at most 256 MiB across all retained backend log segments. The
three-day UTC window is a maximum age, not a guaranteed allocation for each
day. Quiet installations can retain three days. Busy installations retain a
shorter newest-first window within the same total budget.
The active file remains `backend-logs.log`. It accepts at most 16 MiB. Before
the next entry exceeds that limit, the logger closes the file as
`backend-logs-YYYY-MM-DD-NNNNNN.log` and opens a new active file.
Closed segment sequence numbers increase during one UTC day. The logger
removes the oldest closed segments across retained days when all recognized
backend logs need more than 256 MiB. The active file and newest closed segments
remain available.
At UTC midnight, the logger closes the active file as the final segment for
the prior day. It opens a new active file for the new day and removes segments
outside the current-plus-two-day window.
The segment size, total budget, UTC boundary, and maximum age are not
configurable. Files remain owner-only on supported Unix systems.
Restarts reconstruct the current day, next sequence, and total retained bytes
from recognized files and the day marker. Existing singleton files named
`backend-logs-YYYY-MM-DD.log` remain valid inputs for budget cleanup and
diagnostic bundles.
An upgrade can find an active file from the old format that is larger than one
segment. The logger converts its newest content into bounded segments before
it continues normal writes. This conversion and all rotations are
crash-recoverable.
Diagnostic bundles discover the active file, numbered segments, and legacy
singleton files. They select candidates newest-first and retain their existing
source budgets and manifest byte-range reporting.
This decision amends only the backend file-retention policy in
[ADR-2026-07-30-file-backed-diagnostic-bundles](2026-07-30-file-backed-diagnostic-bundles.md).
The bundle, privacy, and access decisions remain unchanged.
## Consequences
Backend logging continues during sustained traffic. A failure that occurs late
in a busy day remains available until newer traffic replaces its segment.
High volume can retain less than one day of evidence. Low volume can retain up
to three days. Retained backend log data uses at most 256 MiB in steady state,
which reduces the former three-day maximum by two thirds.
One day can contain multiple retained log files instead of one file. Diagnostic
collection and manual searches must recognize and order these segments.
Rotation adds filesystem rename, open, cleanup, and directory-scan operations.
These operations run on the asynchronous file-sink path and do not block
application producers. File-sink queue limits still apply during slow storage
operations.
The first upgrade from the single-file format can copy bounded tail data once.
The conversion needs temporary disk space of at most one segment and crash
recovery.
## Alternatives Considered
- **Keep the hard daily stop.** Rejected because it removes the newest evidence
after sustained traffic.
- **Create unlimited size-rotated files.** Rejected because one busy day can
consume unbounded disk space.
- **Use one 256 MiB rolling file.** Rejected because removing an old prefix
requires large repeated copies or a non-text circular format.
- **Keep 256 MiB for each retained day.** Rejected because rolling segments do
not need a reserved budget for each day. This option can use approximately
768 MiB.
- **Use a smaller global budget.** Rejected because 256 MiB matches the old
single-file ceiling and retains more busy-period evidence without preserving
the old three-file maximum.
- **Use a generic rotation library.** Rejected because the existing contract
requires UTC-day ownership, legacy discovery, owner-only files, and bundle
ordering.
+2 -1
View File
@@ -102,7 +102,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-07-29-interactive-accent-surface-semantics | [Separate Brand Accent from Interactive Surface Fills](2026-07-29-interactive-accent-surface-semantics.md) | accepted | frontend | 2026-07-29 |
| 2026-07-30-embedded-editor-executor-capabilities | [Derive Embedded Editor Availability from the Active Executor](2026-07-30-embedded-editor-executor-capabilities.md) | accepted | backend, frontend, protocol | 2026-07-30 |
| 2026-07-30-session-owned-mcp-observability | [Keep MCP Attachment Evidence Session Owned](2026-07-30-session-owned-mcp-observability.md) | accepted | backend, frontend, protocol, security | 2026-07-30 |
| 2026-07-30-file-backed-diagnostic-bundles | [File-backed diagnostic bundles](2026-07-30-file-backed-diagnostic-bundles.md) | accepted | backend, frontend, infra, protocol, workflow | 2026-07-30 |
| 2026-07-30-file-backed-diagnostic-bundles | [File-backed diagnostic bundles](2026-07-30-file-backed-diagnostic-bundles.md) | accepted (backend file retention amended by 2026-08-22) | backend, frontend, infra, protocol, workflow | 2026-07-30 |
| 2026-07-30-runtime-task-state-before-running-event | [Publish Task State Before Running Session State](2026-07-30-runtime-task-state-before-running-event.md) | accepted | backend, frontend, protocol, workflow | 2026-07-30 |
| 2026-07-31-authenticated-plugin-actions | [Authenticated Plugin Actions](2026-07-31-authenticated-plugin-actions.md) | accepted | backend, frontend, protocol, security | 2026-07-31 |
| 2026-07-31-plugin-repository-provider-extensions | [Plugin Repository Provider Extensions](2026-07-31-plugin-repository-provider-extensions.md) | accepted | frontend, backend, protocol | 2026-07-31 |
@@ -191,6 +191,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-20-acp-client-non-underscore-extension-methods | [Route Non-Underscore Inbound Client Methods to the Extension Handler](2026-08-20-acp-client-non-underscore-extension-methods.md) | accepted | backend, protocol | 2026-08-20 |
| 2026-08-22-persistent-fork-approval-labels | [Persist Fork Approval Labels Across Pushes](2026-08-22-persistent-fork-approval-labels.md) | accepted | infra, workflow, security | 2026-08-22 |
| 2026-08-22-plugin-version-retention | [Keep Exactly One Superseded Plugin Version](2026-08-22-plugin-version-retention.md) | accepted | backend | 2026-08-22 |
| 2026-08-22-preserve-newest-bounded-backend-logs | [Preserve Newest Bounded Backend Logs](2026-08-22-preserve-newest-bounded-backend-logs.md) | accepted | backend, infra, operations | 2026-08-22 |
| 2026-08-12-plugin-webhook-auth-gate | [Require Auth for Plugin Webhooks Unless the Manifest Declares Them Public](2026-08-12-plugin-webhook-auth-gate.md) | accepted | backend, frontend, security | 2026-08-12 |
| 2026-08-22-system-oriented-specifications | [Organize specifications by system](2026-08-22-system-oriented-specifications.md) | accepted | workflow, infra | 2026-08-22 |
| 2026-08-22-pr-walkthrough-r2-hosting | [Host PR walkthrough HTML in Cloudflare R2](2026-08-22-pr-walkthrough-r2-hosting.md) | accepted | infra, workflow, security | 2026-08-22 |
@@ -0,0 +1,167 @@
---
spec: docs/specs/platform/diagnostic-logging.md
created: 2026-08-22
status: done
---
# Implementation Plan: Bounded Rolling Backend Logs
## Overview
Issue [#2929](https://github.com/kdlbs/kandev/issues/2929) exposed two related
diagnostic failures. The gateway logs normal WebSocket close code `1000` as an
error with a stack trace. The backend then stops file logging after the current
UTC-day file reaches 256 MiB, so later failures are absent from diagnostics.
The repair classifies normal closes correctly and replaces the daily hard stop
with bounded size segments. All retained backend logs share one 256 MiB budget.
Three UTC days is a maximum age, not a reserved budget for each day. A 16 MiB
active segment and oldest-segment eviction preserve the newest evidence while
reducing the former maximum by two thirds.
## Confirmed Root Causes
`Client.ReadPump` calls `websocket.IsUnexpectedCloseError` in
`apps/backend/internal/gateway/websocket/client.go`. Its expected-code list
omits `websocket.CloseNormalClosure` (`1000`). Gorilla therefore classifies a
normal browser close as unexpected, and the error-level Zap entry includes a
stack trace.
`dailyWriter.Write` in
`apps/backend/internal/common/logger/daily_writer.go` returns
`errDailyBackendLogLimit` when the active file reaches 256 MiB. The retrying
sink does not rotate or reactivate for this error. File logging stays stopped
until the UTC day changes.
A focused real WebSocket reproduction confirmed one error entry and stack for
code `1000`. The existing writer regression confirmed the hard daily stop. The
local debug instance also reached 256 MiB without this specific error burst,
which shows that the retention failure is independent of the reported noise.
## Backend Design
### Gateway close classification
- Add `websocket.CloseNormalClosure` to the expected close-code list in
`Client.ReadPump`.
- Keep the current expected handling for codes `1001`, `1005`, and `1006`.
- Keep error-level stack traces for close codes outside the expected list.
### Segment storage
- Keep `backend-logs.log` as the active file and cap it at 16 MiB.
- Close full segments as `backend-logs-YYYY-MM-DD-NNNNNN.log` with an
increasing six-digit sequence for each UTC day.
- Keep at most 256 MiB across the active file and all retained closed segments.
- Remove the oldest closed segments across retained days before opening space
for newer entries.
- At UTC midnight, close the prior day's active segment and start a new active
file for the current day.
- Reconstruct the active day, retained bytes, and next sequence at startup.
- Keep legacy `backend-logs-YYYY-MM-DD.log` files eligible for retention and
bundles. Count them toward the global budget.
- Convert an oversized active file from the old format into bounded segments,
preserving its newest content before accepting new writes. Process outputs
newest-first, sync each output, truncate the source, and journal progress so
migration I/O stays linear.
- Preserve owner-only file permissions and crash recovery for size rotation,
day rotation, and legacy conversion.
- Keep segment metadata and filesystem operations in a focused companion file
so the writer remains within backend function and file-size limits.
Rotation errors use the existing retrying sink behavior. The writer preserves
the existing files, does not exceed the total budget, records sink loss, and
retries activation after 30 seconds. The old permanent
`errDailyBackendLogLimit` path is removed.
### Diagnostic bundles
- Discover the active file, numbered segments, and legacy singleton files.
- Restrict candidates to the current UTC day and the two preceding UTC days.
- Order candidates by day, sequence, and active-file recency from newest to
oldest before applying the existing archive source budget.
- Keep the current symlink, regular-file, byte-range, manifest, and privacy
rules. Open each candidate once without following the final path component,
use the opened handle for size and copying, and mark a disappeared candidate
as partial.
## Tests
Implementation follows red-green-refactor in each task.
- Gateway integration tests send real normal and unexpected close frames and
inspect an observed Zap core.
- Writer tests use small injected segment and total limits. They cover size
rotation, continued writes after a full budget, cross-day oldest eviction, a
later marker remaining readable, UTC rollover, restart sequencing, maximum
age, legacy input, oversized active-file conversion, and crash recovery.
- Failure tests verify that rename, removal, or open failures do not replace
existing files or exceed the configured test budget.
- Bundle tests cover numbered-segment discovery, newest-first ordering, legacy
compatibility, archive truncation, symlink exclusion, future dates, and a
rotation during collection.
No browser end-to-end test is required. The behavior changes only backend log
classification, storage, and collection.
## Public Documentation
The implementation updates the backend log-retention descriptions in:
- `docs/public/operations.md`
- `docs/public/configuration.md`
- `docs/public/docker.md`
- `docs/public/k8s.md`
The pages must explain that high-volume days keep the newest bounded window,
list the active and numbered filenames, and preserve the existing disk-budget
guidance. `docs/public/cli.md` needs no change because its current text only
identifies the log directory.
## Verification Results
- `cd apps/backend && go test ./internal/gateway/websocket -run
TestReadPumpCloseLogging -count=1` passed.
- `cd apps/backend && go test ./internal/common/logger -run
'TestDailyWriter|TestBackendLogSegment|TestRetryDailyWriter' -count=1` passed.
- `cd apps/backend && go test ./internal/system/logbundle -run
'TestBackendOnlyBundle|TestBackendCandidates' -count=1` passed.
- `node --test scripts/validate-public-docs.test.mjs` passed all 61 tests and
`node scripts/validate-public-docs.mjs` validated 41 pages.
- Affected package tests passed: logger 31, WebSocket 520, logbundle 22.
- `golangci-lint run ./... --new-from-rev="origin/main" --timeout=5m` passed
with no issues. Changed Go files are gofmt-clean.
## Implementation Waves And Parallel Candidates
Wave 1:
- [x] [Task 01: Classify normal WebSocket closes](task-01-classify-normal-websocket-closes.md)
- [x] [Task 02: Implement bounded log segments](task-02-implement-bounded-log-segments.md)
Tasks 01 and 02 touch separate packages and are parallel candidates. The
default execution order remains sequential unless the user authorizes
delegation.
Wave 2, after Task 02:
- [x] [Task 03: Extend diagnostic bundle discovery](task-03-extend-diagnostic-bundle-discovery.md)
Wave 3, after Tasks 02 and 03:
- [x] [Task 04: Update public logging documentation](task-04-update-public-logging-documentation.md)
## Risks And Out Of Scope
- The close-code change must not suppress unexpected protocol or application
closes.
- Upgrade conversion must survive interruption without duplicating, replacing,
or losing the newest bounded content.
- Segment discovery must ignore unrelated files, malformed names, directories,
and symlinks.
- Whole-segment eviction can retain slightly less than 256 MiB. It must never
retain more than the total budget in steady state.
- This repair does not change browser reconnection behavior.
- This repair does not increase the 256 MiB total budget or three-day maximum
age.
- This repair does not add a user-configurable rotation policy.
@@ -0,0 +1,74 @@
---
id: "01-classify-normal-websocket-closes"
title: "Classify normal WebSocket closes"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/platform/diagnostic-logging.md"
---
# Task 01: Classify Normal WebSocket Closes
## Intent
Stop error-level stack traces for WebSocket close code `1000`. Keep the
existing diagnostic signal for unexpected close codes.
## Acceptance
- `Client.ReadPump` treats `websocket.CloseNormalClosure` as an expected close.
- A real code-`1000` close produces no `WebSocket read error` entry.
- An unexpected close still produces one error entry with a stack trace.
## Files Likely Touched
- `apps/backend/internal/gateway/websocket/client.go`
- `apps/backend/internal/gateway/websocket/client_pump_close_test.go`
## Dependencies
None.
## Parallelism
`parallel-safe` with Task 02. This task owns the two gateway files. Task 02
owns only logger-package files. Execute sequentially unless the user authorizes
delegation.
## Inputs
- The WebSocket close requirements in the diagnostic-logging specification.
- The confirmed root cause in `plan.md`.
- The real WebSocket fixture helpers in the websocket test package.
- The observed-log pattern in `terminal_wsutil_test.go`.
## Implementation
1. Add a focused integration test for normal and unexpected close codes.
2. Run the test and confirm that the normal-close case fails.
3. Add `websocket.CloseNormalClosure` to the expected close-code list.
4. Run the focused test again and refactor only if clarity improves.
## Verification
```bash
cd apps/backend && go test ./internal/gateway/websocket -run TestReadPumpCloseLogging -count=1
```
## Output Contract
Report the changed files, the exact test result, blockers, and remaining risks.
Update this task and `plan.md` in the same conversation.
## Results
- Changed `apps/backend/internal/gateway/websocket/client.go` to treat close
code `1000` as an expected lifecycle close.
- Added `client_pump_close_test.go`, which drives real WebSocket close frames
and checks both normal and unexpected logging behavior.
- Verification: `cd apps/backend && go test ./internal/gateway/websocket
-run TestReadPumpCloseLogging -count=1` passed.
- Blockers: none.
- Remaining risk: the writer and bundle tasks still need to preserve later
diagnostics under sustained log volume.
@@ -0,0 +1,113 @@
---
id: "02-implement-bounded-log-segments"
title: "Implement bounded log segments"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/platform/diagnostic-logging.md"
---
# Task 02: Implement Bounded Log Segments
## Intent
Replace the stop-at-256-MiB writer with bounded segments that preserve the
newest backend evidence within one 256 MiB total budget.
## Acceptance
- The active file rotates before it exceeds 16 MiB.
- Writes continue after retained output exceeds 256 MiB. All recognized backend
logs remain within the total budget, and later entries replace the oldest
complete segments across days.
- UTC rollover, restart, cleanup, legacy input, and interrupted operations
preserve the specified data and filename contracts.
## Files Likely Touched
- `apps/backend/internal/common/logger/daily_writer.go`
- `apps/backend/internal/common/logger/daily_writer_test.go`
- `apps/backend/internal/common/logger/backend_log_segments.go`
- `apps/backend/internal/common/logger/backend_log_segments_test.go`
- `apps/backend/internal/common/logger/retry_daily_writer.go`
- `apps/backend/internal/common/logger/retry_daily_writer_test.go`
The companion segment files are new if they keep the existing writer within
the backend complexity limits.
## Dependencies
None.
## Parallelism
`parallel-safe` with Task 01. This task owns only logger-package files. Task 01
owns the gateway files. Task 03 depends on this task's filename and ordering
contract.
## Inputs
- The storage requirements and scenarios in the diagnostic-logging
specification.
- The accepted bounded-log ADR.
- The existing daily writer, retrying writer, rollover journal, and tests.
## Implementation
1. Parameterize limits in test fixtures and add failing cases for rotation,
eviction, later-entry retention, restart, UTC rollover, cleanup, legacy
conversion, and operation failure.
2. Add strict parsing and sorting for active, numbered, and legacy files.
3. Rotate the active file to an exclusive six-digit sequence name before its
next entry exceeds the segment limit.
4. Enforce the total budget by removing the oldest complete segments across
retained days before newer writes continue.
5. Reconstruct day, byte totals, and sequence state during startup without
following symlinks or replacing existing files.
6. Convert an oversized old-format active file by keeping its bounded tail and
recording recoverable progress.
7. Adapt the existing journal or add the smallest explicit transaction state
needed for crash-safe size, day, and conversion operations.
8. Remove the permanent daily-limit error branch from the retrying writer.
Route filesystem failures through the existing 30-second retry and loss
reporting behavior.
9. Run focused tests, then refactor helpers to satisfy Go complexity limits.
## Verification
```bash
cd apps/backend && go test ./internal/common/logger -run 'TestDailyWriter|TestBackendLogSegment|TestRetryDailyWriter' -count=1
```
## Output Contract
Report the migration algorithm, crash-recovery invariant, changed files, exact
test result, blockers, and remaining risks. Update this task and `plan.md` in
the same conversation.
## Results
- Changed `daily_writer.go` and `backend_log_segments.go` to use 16 MiB active
segments, a 256 MiB global budget, oldest-closed-segment eviction, UTC-day
rollover, strict filename parsing, legacy-file retention, and bounded
oversized-active-file conversion.
- Conversion renames the source to an owner-only backup and processes retained
outputs from newest to oldest. Each output is copied through a temporary
file, synced, and atomically renamed. The backup is then truncated and the
journal records the next output. This keeps migration I/O linear and lets
recovery resume after a stale temporary file, a truncated source, or an
interrupted journal write. Recovery checks completed outputs before it
adopts a fresh active file. Atomic rename is used for normal size and day
rotation, so completed segments are never replaced.
- Retention accepts only dates from two UTC days before today through today.
Future-dated segments are excluded from cleanup and bundle selection.
- Changed `backend_logger.go` so filesystem and rotation errors use the normal
30-second retry path instead of a permanent daily-limit stop.
- Added writer, migration, restart, UTC rollover, global-eviction, legacy,
and interrupted-conversion regression coverage.
- Verification: `cd apps/backend && go test ./internal/common/logger -run
'TestDailyWriter|TestBackendLogSegment|TestRetryDailyWriter' -count=1` passed.
- Blockers: none.
- Remaining risk: filesystem failures on platforms with unusual rename or
permission semantics need CI coverage in addition to local Unix coverage.
@@ -0,0 +1,92 @@
---
id: "03-extend-diagnostic-bundle-discovery"
title: "Extend diagnostic bundle discovery"
status: done
wave: 2
depends_on:
- "02-implement-bounded-log-segments"
plan: "plan.md"
spec: "../../specs/platform/diagnostic-logging.md"
---
# Task 03: Extend Diagnostic Bundle Discovery
## Intent
Include the active file, numbered segments, and legacy singleton files in
diagnostic bundles, with the newest evidence selected first.
## Acceptance
- Backend candidate discovery recognizes only valid retained log files.
- Candidates are ordered newest-first across active, numbered, and legacy
files before the existing archive budget is applied.
- Existing symlink exclusion, tail truncation, manifest byte ranges, and
source-budget behavior remain intact.
## Files Likely Touched
- `apps/backend/internal/system/logbundle/archive.go`
- `apps/backend/internal/system/logbundle/service_test.go`
## Dependencies
Task 02 defines the final segment parser, filename, sequence, and retention
contract. Reuse that parser through a narrow exported helper only if doing so
does not couple bundle policy to writer state. Otherwise, mirror the strict
filename grammar in the bundle package and cover it with table tests.
## Parallelism
`sequential` after Task 02. Task 04 depends on the final collection behavior.
## Inputs
- The bundle requirements in the diagnostic-logging specification.
- The accepted bounded-log ADR.
- The segment naming and ordering behavior delivered by Task 02.
- The current archive candidate and safe-file-copy implementation.
## Implementation
1. Add failing bundle tests with active, numbered, legacy, malformed, and
symlink candidates across retained and expired UTC days.
2. Replace the fixed three-file candidate list with strict directory discovery.
3. Sort valid candidates by UTC day and segment chronology from newest to
oldest. Treat the active file as the newest current-day candidate.
4. Feed the sorted candidates through the existing regular-file checks, tail
truncation, source budget, archive naming, and manifest reporting.
5. Verify that a late marker remains in a constrained bundle when older
segments exceed the archive budget.
## Verification
```bash
cd apps/backend && go test ./internal/system/logbundle -run 'TestBackendOnlyBundle|TestBackendCandidates' -count=1
```
## Output Contract
Report the ordering rule, changed files, exact test result, blockers, and
remaining risks. Update this task and `plan.md` in the same conversation.
## Results
- Changed `archive.go` and added `backend_candidates.go` plus focused candidate
tests to discover valid active, numbered, and legacy files from the log
directory.
- Candidates are restricted to the current UTC day and two preceding days,
sorted newest-first by day and segment sequence, and filtered with `Lstat`
so symlinks and unrelated names are excluded.
- Existing archive source budgets, tail copying, truncation warnings, and
manifest byte ranges remain in the archive path. Each candidate opens once
without following the final path component. The collector uses the opened
handle for both `Fstat` and copying. A candidate that disappears before
opening produces a partial bundle warning.
- Added discovery-order, future-date, newest-within-budget, and deterministic
rotation-during-collection bundle tests.
- Verification: `cd apps/backend && go test ./internal/system/logbundle -run
'TestBackendOnlyBundle|TestBackendCandidates' -count=1` passed.
- Blockers: none.
- Remaining risk: bundle collection still depends on the existing archive
budget, so a constrained archive can be partial by design.
@@ -0,0 +1,83 @@
---
id: "04-update-public-logging-documentation"
title: "Update public logging documentation"
status: done
wave: 3
depends_on:
- "02-implement-bounded-log-segments"
- "03-extend-diagnostic-bundle-discovery"
plan: "plan.md"
spec: "../../specs/platform/diagnostic-logging.md"
---
# Task 04: Update Public Logging Documentation
## Intent
Explain the bounded rolling behavior so operators know which evidence remains
available during high-volume days and how much disk space Kandev can use.
## Acceptance
- Operations and deployment pages describe the active file, numbered segment
names, 16 MiB segment bound, 256 MiB total budget, and three-day maximum age.
- The text states that high volume keeps the newest bounded evidence and can
shorten the available time window. Configuration text states that segment
and retention settings are not configurable.
- Public-document validation passes.
## Files Likely Touched
- `docs/public/operations.md`
- `docs/public/configuration.md`
- `docs/public/docker.md`
- `docs/public/k8s.md`
## Dependencies
Tasks 02 and 03 must confirm the final filenames, byte limits, upgrade behavior,
and bundle ordering before this task updates operator guidance.
## Parallelism
`sequential` after Tasks 02 and 03.
## Inputs
- The final writer and bundle behavior from Tasks 02 and 03.
- The diagnostic-logging specification.
- The accepted bounded-log ADR.
- The repository public-document style and validation rules.
## Implementation
1. Update each existing backend-log section in place. Keep its current page
type and operator focus.
2. Use the same filenames, byte limits, UTC-day terms, and newest-evidence
explanation on every page.
3. Preserve existing permissions, deployment path, and disk-sizing guidance.
4. Run the public-document validator and correct all reported issues.
## Verification
```bash
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
```
## Output Contract
Report the updated pages, exact validation results, blockers, and remaining
risks. Update this task and `plan.md` in the same conversation.
## Results
- Updated `docs/public/operations.md`, `configuration.md`, `docker.md`, and
`k8s.md` with the active and numbered filenames, 16 MiB segment bound,
256 MiB total budget, three-day maximum age, and newest-evidence behavior.
- Preserved deployment paths, persistence guidance, permissions, and existing
disk-budget context.
- Verification: `node --test scripts/validate-public-docs.test.mjs` passed all
61 tests; `node scripts/validate-public-docs.mjs` validated 41 pages.
- Blockers: none.
- Remaining risk: none known for the documented contract.
+3 -1
View File
@@ -255,7 +255,9 @@ launch and is not an environment configuration source.
| `logging.level` | `KANDEV_LOG_LEVEL` | `info` | File threshold: `debug`, `info`, `warn`, or `error`. `--debug` selects `debug`; normal and `--verbose` launches select `info`. |
| `logging.format` | `KANDEV_LOGGING_FORMAT` | `text`, or `json` in production/Kubernetes | `text` or `json`; `auto` is not accepted. |
Every backend launch writes to `<home>/logs/backend-logs.log` and prints that resolved path at startup. The active file appends across same-day restarts and accepts at most 256 MiB; later entries are dropped until the next UTC day rather than allowing diagnostics to fill the disk. At the next UTC day it rolls to `backend-logs-YYYY-MM-DD.log`; Kandev retains the current UTC day and the two preceding days. Files are owner-only (`0600`) on Unix.
Every backend launch writes to `<home>/logs/backend-logs.log` and prints that resolved path at startup. The active file appends across same-day restarts and accepts at most 16 MiB. Before a new entry would exceed that limit, Kandev closes the file as `backend-logs-YYYY-MM-DD-NNNNNN.log` and opens a new active file.
Active and closed backend files use at most 256 MiB in total. Kandev removes the oldest closed segments when needed, so high-volume periods keep the newest evidence instead of stopping file logging. Three UTC days is the maximum file age, not a reserved allocation for each day. The segment size, total budget, and maximum age are fixed and are not configurable. Legacy `backend-logs-YYYY-MM-DD.log` files remain readable during upgrades and count toward the total budget. Files are owner-only (`0600`) on Unix.
Normal launches write info and above to the file and warn and above to stdout. `--debug` writes debug and above to the file while stdout remains warn and above. `--verbose` writes info and above to both. The format default becomes JSON when `KUBERNETES_SERVICE_HOST` is non-empty or `KANDEV_ENV` is exactly `production`/`prod`; otherwise it is text.
+1 -1
View File
@@ -280,7 +280,7 @@ healthcheck:
start_period: 15s
```
The backend writes its daily files to `/data/logs/backend-logs.log` on the persistent volume and prints the path at startup. Each daily file accepts at most 256 MiB, and Kandev retains the current UTC day plus the two preceding days. `docker logs` shows the bounded stdout stream; use **Settings > System > Logs** for a frontend+backend diagnostic ZIP.
The backend writes its active file to `/data/logs/backend-logs.log` on the persistent volume and prints the path at startup. Each segment accepts at most 16 MiB and is named `backend-logs-YYYY-MM-DD-NNNNNN.log` when it rolls. Active and closed backend files use at most 256 MiB in total. Kandev removes the oldest closed segments when needed, so high-volume periods keep the newest evidence. Three UTC days is the maximum file age. `docker logs` shows the bounded stdout stream; use **Settings > System > Logs** for a frontend+backend diagnostic ZIP.
## Upgrade and remove
+1 -1
View File
@@ -140,7 +140,7 @@ See [Configuration](configuration.md) for exact YAML and `KANDEV_` names. Import
| `KANDEV_LOG_LEVEL` | `info` | Backend log threshold |
| `KANDEV_DATABASE_DRIVER` | `sqlite` by default | Set `postgres` for an external database |
Kubernetes detection makes the default log format JSON. Kandev writes daily files under `/data/logs/`, caps each day at 256 MiB, and emits warn-and-above to stdout by default; ensure the home path is persistent if the three-day file history must survive pod replacement.
Kubernetes detection makes the default log format JSON. Kandev writes the active file under `/data/logs/backend-logs.log`, closes it before an entry would exceed 16 MiB, and names the closed segment `backend-logs-YYYY-MM-DD-NNNNNN.log`. Active and closed backend files use at most 256 MiB in total. High-volume periods keep the newest evidence and can shorten the available history. Three UTC days is the maximum file age. Kandev emits warn-and-above to stdout by default; ensure the home path is persistent if the retained file history must survive pod replacement.
### PostgreSQL
+3 -1
View File
@@ -359,7 +359,9 @@ Standard bundles do not read stored chat transcripts, session messages, or agent
ACP evidence is a separate, debug-only opt-in. It is limited to one to ten sessions the caller is authorized to view and may contain prompts, responses, tool calls, file contents, MCP data, environment-derived values, and secrets. Collection is on demand from retained host files or reachable executors; unavailable, expired, invalid, or truncated sessions make the archive partial instead of broadening the request or continuously copying ACP frames.
Every launch writes info and above to `<home>/logs/backend-logs.log`; normal and debug stdout show warn and above. `kandev --verbose` also shows info on stdout. `kandev --debug` records debug events in the file and enables ACP message dumps. The backend appends on same-day restarts, caps each daily file at 256 MiB, rolls at UTC midnight, and retains the current day plus the two previous UTC days.
Every launch writes info and above to `<home>/logs/backend-logs.log`; normal and debug stdout show warn and above. `kandev --verbose` also shows info on stdout. `kandev --debug` records debug events in the file and enables ACP message dumps.
The active backend file accepts at most 16 MiB. Before a new entry would exceed that limit, Kandev closes it as `backend-logs-YYYY-MM-DD-NNNNNN.log` and opens a new active file. Active and closed backend files use at most 256 MiB in total. Kandev removes the oldest closed segments when needed, so logging continues and high-volume periods keep the newest evidence. Three UTC days is the maximum file age, not a reserved allocation for each day. Same-day restarts append to the active file, and UTC midnight starts a new active file.
The browser keeps a bounded three-day console history locally and sends it only when an authenticated bundle request asks for frontend evidence. Console calls are not continuously uploaded. Error toasts are reported immediately, but automatic reports remove URL query strings and fragments and use count and byte rate limits. A bundle can be partial if no browser responds, IndexedDB is unavailable, queues shed entries, or byte/profile limits truncate data. Ready bundles expire after 15 minutes; one active job per user and bounded global capacity can return `429` or `503`. Standard bundles cap backend/frontend/runtime sources at 160/80/2 MiB; ACP-inclusive bundles use 96/48/2 MiB plus 96 MiB ACP, with no budget transfer between sources.
+36 -34
View File
@@ -6,9 +6,8 @@ owner: tbd
# Diagnostic logging
Decision: [ADR-2026-07-30-file-backed-diagnostic-bundles](../../decisions/2026-07-30-file-backed-diagnostic-bundles.md)
Implementation plan: [diagnostic-logging](../../plans/diagnostic-logging/plan.md)
Decisions: [bundles](../../decisions/2026-07-30-file-backed-diagnostic-bundles.md),
[bounded logs](../../decisions/2026-08-22-preserve-newest-bounded-backend-logs.md).
## Why
@@ -34,28 +33,22 @@ history or returning an unbounded log export.
remains `warn` and higher.
- An explicitly verbose run writes `info` and higher entries to both the file
and stdout.
- Normal browser close code `1000` is lifecycle-only; unexpected codes remain
error entries with stacks.
- `logging.level` / `KANDEV_LOG_LEVEL` remains the supported override for the
file threshold, and `logging.format` remains supported.
`logging.outputPath`, `logging.maxSizeMb`, `logging.maxBackups`,
`logging.maxAgeDays`, and `logging.compress` are removed; the diagnostic
path and daily retention policy are not configurable.
- `backend-logs.log` represents the current UTC calendar day. At UTC midnight,
the backend atomically rolls it to `backend-logs-YYYY-MM-DD.log` and creates
a new owner-readable, owner-writable active file.
- A restart during the same UTC day appends to `backend-logs.log`. On the first
startup after a day boundary, the previous active file is rolled to the date
it represents before new entries are written.
- Cleanup retains a rolling three-day UTC window: the current active day and
at most the two preceding dated daily files. Older recognized daily files
are removed; unrelated files in the log directory are preserved.
- Each daily backend file accepts at most 256 MiB. Once the active file reaches
that bound, entries that would exceed it are dropped without blocking
application work and are reflected in file-sink loss statistics. UTC
rollover resumes normal writes into the next day's file.
- Rollover is crash-recoverable. A small owner-only rollover journal records
the source day, source identity/size, destination, and copied offset before
an existing dated destination is extended. Recovery resumes that transaction
exactly once and never replaces an existing dated file.
- `backend-logs.log` is the current-day active segment, capped at 16 MiB. Full
files become `backend-logs-YYYY-MM-DD-NNNNNN.log`.
- Active, closed, and legacy files share a 256 MiB budget; rotation evicts old
closed files so logging continues with newest evidence.
- At UTC midnight, the active file becomes the prior day's final segment and a
new file opens. Cleanup removes recognized files older than three UTC days;
this is a maximum age, not a per-day allocation.
- Legacy singleton files remain readable and count toward the budget. An
oversized legacy active file is converted to bounded newest segments.
- The structured backend in-memory ring buffer is removed. The System Logs
page, Improve Kandev, and agent diagnostics use the files and bundles
described below.
@@ -229,8 +222,7 @@ history or returning an unbounded log export.
```text
manifest.json
backend/backend-logs.log
backend/backend-logs-YYYY-MM-DD.log
backend/backend-logs*.log
frontend/browser-01.jsonl
frontend/browser-02.jsonl
runtime/sessions.json
@@ -238,8 +230,9 @@ acp/session-01/raw-acp.jsonl
acp/session-01/normalized-acp.jsonl
```
- `backend/` contains the recognized active and dated files still inside the
three-day retention window. File bytes are copied without reformatting.
- `backend/` contains recognized active, segmented, and legacy files inside the
three-day retention window. Candidates are selected newest-first, and file
bytes are copied without reformatting.
- `frontend/` contains one JSON Lines file per distinct responding browser
profile. Multiple tabs from one browser profile are deduplicated. Client
values never determine archive paths or filenames.
@@ -559,9 +552,9 @@ with the same task/session rules and excludes message bodies and user identity.
backend startup prints the intended path and a warning to stderr, continues
with stdout/stderr logging, and retries the file sink every 30 seconds.
Retention-cleanup failures are warnings and do not prevent startup.
- If midnight rollover fails after startup, the backend emits an error to
stderr and its existing sinks, continues writing to the active file, and
retries rollover on the next write without dropping the triggering entry.
- If size or day rotation fails, the backend preserves the existing files and
does not exceed the total retention budget. It reports the error to stderr
and retries the file sink after 30 seconds.
- If browser persistence fails, capture continues in the bounded memory
fallback and the bundle manifest marks the degraded storage mode.
- If no eligible frontend is connected, a frontend-only bundle becomes
@@ -597,12 +590,12 @@ with the same task/session rules and excludes message bodies and user identity.
## Persistence guarantees
- `backend-logs.log` preserves entries accepted by the file queue during the
current UTC day until the fixed 256 MiB daily bound. Pressure drops, startup
fallback gaps, bounded-shutdown loss, and archive truncation are explicit in
counters and bundle manifests.
- Dated archives preserve the two preceding UTC calendar days. Files outside
that rolling window are deleted at startup and after successful rollover.
- Backend segments preserve the newest accepted entries across retained UTC
days, up to the fixed 256 MiB total budget. High volume shortens the available
time window instead of removing later evidence.
- Three UTC days is the maximum backend-log age. It is not a guaranteed
allocation for each day. Files outside that window are removed at startup
and after successful rotation.
- Browser console history remains in the browser profile for three UTC days,
subject to its entry/byte caps. It is not server-persistent before capture.
- Raw ACP files retain their existing debug-only executor/host retention: at
@@ -632,12 +625,19 @@ with the same task/session rules and excludes message bodies and user identity.
error entries, **THEN** all are in the file while only warn/error are on
stdout.
- **GIVEN** a same-UTC-day restart, **WHEN** the backend opens its log,
**THEN** it appends without losing earlier entries.
**THEN** it appends to the active segment and does not replace closed
segments.
- **GIVEN** logging exceeds 256 MiB, restarts after rotation, or upgrades from
an oversized legacy file, **WHEN** the backend resumes, **THEN** it continues
with newest bounded content and the next sequence.
- **GIVEN** the configured log directory is temporarily unwritable, **WHEN**
Kandev starts, **THEN** product startup succeeds with a stderr warning and
the backend retries file activation every 30 seconds.
- **GIVEN** more than three UTC days of backend files, **WHEN** cleanup runs,
**THEN** only the current and two preceding days remain.
- **GIVEN** the gateway receives close code `1000` or an unexpected close code,
**WHEN** the read pump handles it, **THEN** only the unexpected close creates
a `WebSocket read error` entry with a stack trace.
- **GIVEN** an error toast on a recognized task route, **WHEN** its report is
accepted, **THEN** one backend error entry includes its visible text,
browser context, and `task_id`.
@@ -726,3 +726,5 @@ with the same task/session rules and excludes message bodies and user identity.
- Adding a setting for log path, retention, browser capture, or bundle bounds.
- Supporting configurable size-based, per-process, or local-time backend
rotation.
- Changing the browser reconnection policy or increasing the fixed 256 MiB
total backend-log budget.
+1 -1
View File
@@ -31,7 +31,7 @@
"docs/specs/office/scheduler.md": 50967,
"docs/specs/office/tasks.md": 42862,
"docs/specs/office-agent-tier-routing/spec.md": 58448,
"docs/specs/platform/diagnostic-logging.md": 40700,
"docs/specs/platform/diagnostic-logging.md": 40696,
"docs/specs/plugin-nav-sidebar-footer/spec.md": 63150,
"docs/specs/plugins/spec.md": 77806,
"docs/specs/subagent-context-persistence/spec.md": 160552,