Files
coder/cli/flightrecorder.go
T
Andrew Aquino 4a67ac3306 feat: buffer CLI session logs and flush on connection failure (#29752)
## 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>
2026-09-29 22:27:30 +00:00

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...)
}