mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
## What Adds in-memory log buffering for CLI diagnostics so the debug detail leading up to a failure is available without logging debug during normal operation. Below-level entries are kept in a slog flight recorder (`v3.2.0`) and emitted only when a command returns an error. This implements CLI-4 (connection-log RFC req 13). Depends on the `cdr.dev/slog/v3` `v3.2.0` bump (already on `main`). ## How - **Root command** (`cli/root.go`): - New global `--log-buffer-size` / `CODER_LOG_BUFFER_SIZE` (default `10`, `0` disables, clamped to `10000`), shared by all commands instead of duplicated per command. - `bufferedLoggerMiddleware` runs the invocation logger at `Info` (or `Debug` with `-v`) with a flight recorder, and on a returned error calls `Flush` to replay the buffered history to stderr. `Flush` does not add a duplicate error line; the returned error is still rendered by the normal formatter. - Opt-in via an `annotationBufferedLogger` annotation so it does not clobber the ~17 commands that build their own logger on `inv.Logger` (which would otherwise double-log). Future commands opt in with one line. - `start` / `stop` / `update`: opt into the middleware and just `client.SetLogger(inv.Logger)` so codersdk request/response debug is captured and flushed to stderr on failure. They **no longer write session log files** and dropped their per-command `--log-dir` / `--log-buffer-size`. - `ssh`: keeps its own file logger (writing to stderr would corrupt its stdio stream), now using the global buffer size and the shared `bufferedLogger` helper. It retains `--log-dir` / `CODER_SSH_LOG_DIR`, stdio/PID filename handling, and file pruning (50 most recent). - `restart` is excluded, matching the `client_session_id` opt-in set (DEVEX-664). ## Default behavior change `start` / `stop` / `update` now emit buffered diagnostics (recent below-level entries, e.g. sdk request/response) to **stderr** when they fail; on success they stay quiet. `coder ssh` continues to write a per-connection diagnostic file by default under the XDG state dir (`~/.local/state/coder/logs`), pruned to the 50 most recent. Opt out of buffering with `--log-buffer-size 0`. ## Review changes (Asher) - Shared logger construction: the level is computed once and the flight recorder is always attached, in a single `bufferedLogger` helper used by ssh and the workspace commands. - Workspace commands print to normal output instead of a log file, and the default buffer is `10` to avoid flooding the terminal on failure. - The buffer lives on the root command / logger (global option + middleware) rather than being wired into each command. ## Alignment with #29671 (agent) Same core model: `Info` default with a flight recorder, `-v` for full debug, and the shared `--log-buffer-size` semantics (`0` disables). Differences: the agent writes to a file and defaults the buffer to `1000`, while the CLI prints to stderr and defaults to `10`; both now rely on slog's auto-flush-on-error (the CLI additionally flushes via the middleware on a returned error). > [!NOTE] > The agent PR (#29671) adds an agent-scoped `--log-buffer-size` / `CODER_AGENT_LOG_BUFFER_SIZE` on the hidden `coder agent` command. Once both land, the global `--log-buffer-size` here and the agent's own flag will need reconciling on `coder agent`. Flagging for whichever merges second. ## Testing `cli.TestClampLogBufferSize`, `TestPruneSessionLogs`, `TestDefaultSessionLogDir`, and `TestBufferedLogger_*` cover clamping, ssh log retention, the default state dir, buffer-until-error, the middleware's flush path, and the verbose escape hatch. `start`/`stop`/`update`/`ssh` command tests pass. Golden `--help` output and `docs/reference/cli` regenerated. (`TestSSH/Env` is a pre-existing interactive-PTY test that does not run in all sandboxes; it fails identically on `main` here.) <details> <summary>Rollout context</summary> Cross-repo sequence: coder/slog flight recorder (released as v3.2.0) -> go.mod bump in coder/coder (#29600, merged) -> CLIENT-672 (agent, #29671) and this PR (CLI-4), which are independent consumers and can merge in any order. </details> --- <sub>Opened by Coder Agents on behalf of @aqandrew.</sub>
98 lines
3.2 KiB
Go
98 lines
3.2 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/adrg/xdg"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
)
|
|
|
|
const (
|
|
// defaultCLIFlightRecorderSize is the default number of log entries below the
|
|
// current log level that a command keeps in memory and emits to stderr when
|
|
// it fails. It is intentionally small so a failure prints a few lines of
|
|
// context rather than flooding the terminal.
|
|
defaultCLIFlightRecorderSize = 10
|
|
// maxCLIFlightRecorderSize caps the configurable flight recorder size to bound
|
|
// memory use.
|
|
maxCLIFlightRecorderSize = 10000
|
|
// keepSessionLogFiles is the number of ssh session log files retained per log
|
|
// directory; older files are pruned when a new session starts.
|
|
keepSessionLogFiles = 50
|
|
)
|
|
|
|
// flightRecorder returns logger writing to sink at the current display level
|
|
// (Info by default, Debug under --verbose) with a flight recorder attached. The
|
|
// recorder keeps entries below the current level in memory and emits them when
|
|
// an error is logged, so the detail leading up to a failure is captured without
|
|
// logging it during normal operation. The recorder is always attached so it
|
|
// keeps working if the level changes; at Debug it simply has nothing to record.
|
|
func (r *RootCmd) flightRecorder(logger slog.Logger, sink slog.Sink, size int64) slog.Logger {
|
|
level := slog.LevelInfo
|
|
if r.verbose {
|
|
level = slog.LevelDebug
|
|
}
|
|
return logger.AppendSinks(sink).Leveled(level).FlightRecorder(int(clampFlightRecorderSize(size)))
|
|
}
|
|
|
|
// defaultSessionLogDir returns the default directory for ssh session logs.
|
|
// Following the XDG Base Directory spec, logs are state data, so they live under
|
|
// the state directory rather than the cache or data directory.
|
|
func defaultSessionLogDir() string {
|
|
return filepath.Join(xdg.StateHome, "coder", "logs")
|
|
}
|
|
|
|
// clampFlightRecorderSize bounds a requested size to [0, maxCLIFlightRecorderSize].
|
|
func clampFlightRecorderSize(size int64) int64 {
|
|
if size < 0 {
|
|
return 0
|
|
}
|
|
if size > maxCLIFlightRecorderSize {
|
|
return maxCLIFlightRecorderSize
|
|
}
|
|
return size
|
|
}
|
|
|
|
// pruneSessionLogs removes the oldest coder-*.log files in dir, keeping at most
|
|
// keep of the most recent. It is best effort: it continues past individual
|
|
// failures and returns them joined so the caller can surface them.
|
|
func pruneSessionLogs(dir string, keep int) error {
|
|
matches, err := filepath.Glob(filepath.Join(dir, "coder-*.log"))
|
|
if err != nil {
|
|
return xerrors.Errorf("glob session logs in %q: %w", dir, err)
|
|
}
|
|
type logFile struct {
|
|
path string
|
|
modTime time.Time
|
|
}
|
|
var errs []error
|
|
files := make([]logFile, 0, len(matches))
|
|
for _, path := range matches {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
errs = append(errs, xerrors.Errorf("stat %q: %w", path, err))
|
|
continue
|
|
}
|
|
files = append(files, logFile{path: path, modTime: info.ModTime()})
|
|
}
|
|
if len(files) <= keep {
|
|
return errors.Join(errs...)
|
|
}
|
|
// Newest first, then remove everything past the keep threshold.
|
|
sort.Slice(files, func(i, j int) bool {
|
|
return files[i].modTime.After(files[j].modTime)
|
|
})
|
|
for _, f := range files[keep:] {
|
|
if err := os.Remove(f.path); err != nil {
|
|
errs = append(errs, xerrors.Errorf("remove %q: %w", f.path, err))
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|