Files
multica/server/internal/logger/logger.go
Bohan Jiang 6c3b79db19 feat(daemon): bound daemon.log size with rotation (MUL-4330) (#5170)
* feat(daemon): bound daemon.log size with rotation (MUL-4330)

The background daemon redirected its stdout/stderr into daemon.log opened
O_APPEND and never rotated it, so the file grew without limit until it was
too large to open. Every structured log line already flows through slog
(including agent subprocess stderr, forwarded via newLogWriter), so the
daemon's logger is effectively the sole author of the file's volume.

Route the foreground daemon's slog output — both the injected component
logger and the package-global slog default — through a size-based rotating
writer (lumberjack) that keeps the active daemon.log small (20MB default,
5 gzip-compressed backups, 30d), all env-overridable. Raw crash output
(Go runtime panics, pre-logger errors) now goes to a separate daemon.err.log
so the child's inherited fds never hold daemon.log open, which would block
rotation's rename on Windows.

The Desktop app spawns the daemon via this same launcher and its log tail
already handles size-shrink, so both CLI and Desktop are covered.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): address log-rotation review — foreground output, Windows handles, bounded err log (MUL-4330)

Resolves the blocking review items on the daemon.log rotation change:

1. Windows first-upgrade rotation: a foreground managed daemon now re-points
   its own stdout/stderr to daemon.err.log at startup (SetStdHandle) before
   building the rotator, releasing any daemon.log handle an older self-update
   launcher inherited (Go opens files without FILE_SHARE_DELETE, which would
   otherwise block rename-on-rotate). No-op on Unix, where an open fd never
   blocks rename.

2. `daemon logs -f` vs rotation: Unix uses `tail -F` (reopen by name); Windows
   opens the reader with FILE_SHARE_DELETE so it can't block the rotator's
   rename, and reopens the file on size-shrink to follow across rotation.

3. Self-update handoff no longer briefly runs two rotators on one file: the old
   process closes its rotator and moves remaining handoff logs (incl. the slog
   default) to the crash sink before the successor starts.

4. daemon.err.log is now bounded: it rolls to a single ".1" backup once past
   5MB at open time, so a crash loop can't move the growth problem to it. It is
   also surfaced in the troubleshooting docs.

5. Explicit `--foreground` in a terminal keeps live stdout/stderr logging (a
   documented debugging path); only detached/background children rotate into
   daemon.log. Decided by whether stderr is a terminal.

Also: rotation env knobs now reject 0/negative (0 means 100MB / keep-all in
lumberjack), preventing an accidental unbounded config. Adds unit tests for
the err-log rolling and positive-int parsing; Windows/Linux(arm64) cross-builds
and `GOOS=windows go vet` pass.

Co-authored-by: multica-agent <github@multica.ai>

* docs: sync zh/ja/ko troubleshooting with daemon.log rotation + daemon.err.log (MUL-4330)

Co-authored-by: multica-agent <github@multica.ai>

* test(handler): bump the agent's own runtime version in quick-create parent test (MUL-4330)

TestQuickCreateIssueParentTrustBoundary bumped an arbitrary `LIMIT 1`
agent_runtime, but the handler version-checks agent.RuntimeID — the runtime
bound to the request's agent. In the shared handler test workspace, other
tests register additional runtimes, so the two diverge and the agent's real
runtime keeps the seed's empty cli_version, tripping the daemon-version gate
(422 daemon_version_unsupported) before the parent_issue_id assertions run.
Bump the runtime tied to the agent instead, making the setup deterministic.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 15:55:06 +08:00

118 lines
3.7 KiB
Go

package logger
import (
"io"
"log/slog"
"net/http"
"os"
"strings"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/lmittmann/tint"
"github.com/multica-ai/multica/server/internal/middleware"
)
// isTerminal reports whether the given file descriptor is connected to a
// terminal. Used to suppress ANSI color escapes when stderr is redirected
// to a file (e.g. daemon.log), so log files stay clean.
func isTerminal(f *os.File) bool {
fi, err := f.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
// Init initializes the global slog logger. Colors are enabled when stderr
// is a terminal and disabled otherwise. Reads LOG_LEVEL env var (debug,
// info, warn, error). Default: debug.
func Init() {
level := parseLevel(os.Getenv("LOG_LEVEL"))
handler := tint.NewHandler(os.Stderr, &tint.Options{
Level: level,
TimeFormat: "15:04:05.000",
NoColor: !isTerminal(os.Stderr),
})
slog.SetDefault(slog.New(handler))
}
// NewLogger creates a named slog logger. Colors follow the same
// TTY-detection rule as Init. Useful for standalone processes (daemon,
// migrate) that want a component prefix.
func NewLogger(component string) *slog.Logger {
level := parseLevel(os.Getenv("LOG_LEVEL"))
handler := tint.NewHandler(os.Stderr, &tint.Options{
Level: level,
TimeFormat: "15:04:05.000",
NoColor: !isTerminal(os.Stderr),
})
return slog.New(handler).With("component", component)
}
// StderrIsTerminal reports whether this process's stderr is attached to a
// terminal. The daemon uses it to distinguish a user running
// `daemon start --foreground` in a shell (log live to the terminal) from a
// detached/background child whose stderr is redirected to a file (rotate into
// daemon.log instead).
func StderrIsTerminal() bool {
return isTerminal(os.Stderr)
}
// NewWriterLoggerDefault builds a named slog logger that writes structured,
// color-free output to w, and installs the same handler as the global slog
// default so bare slog.Info/Warn/... calls (e.g. from LoadConfig) land in the
// same sink. Intended for standalone processes that log to a file or rotating
// writer instead of a terminal (the daemon), where ANSI color is never wanted
// and every log line — injected-logger and package-global alike — must end up
// in the one managed file. Reads LOG_LEVEL like NewLogger/Init.
func NewWriterLoggerDefault(component string, w io.Writer) *slog.Logger {
level := parseLevel(os.Getenv("LOG_LEVEL"))
handler := tint.NewHandler(w, &tint.Options{
Level: level,
TimeFormat: "15:04:05.000",
NoColor: true,
})
base := slog.New(handler)
slog.SetDefault(base)
return base.With("component", component)
}
// RequestAttrs extracts request_id, user_id, and X-Client-* metadata from
// an HTTP request for use in handler-level structured logging. Mirrors the
// global request logger so handler logs end up with the same observability
// dimensions as the access log.
func RequestAttrs(r *http.Request) []any {
attrs := make([]any, 0, 10)
if rid := chimw.GetReqID(r.Context()); rid != "" {
attrs = append(attrs, "request_id", rid)
}
if uid := r.Header.Get("X-User-ID"); uid != "" {
attrs = append(attrs, "user_id", uid)
}
platform, version, os := middleware.ClientMetadataFromContext(r.Context())
if platform != "" {
attrs = append(attrs, "client_platform", platform)
}
if version != "" {
attrs = append(attrs, "client_version", version)
}
if os != "" {
attrs = append(attrs, "client_os", os)
}
return attrs
}
func parseLevel(s string) slog.Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "info":
return slog.LevelInfo
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelDebug
}
}