Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ loader [options] COMMAND [ARGS...]
| `--no-tui` | | off | Force plain-text output instead of the fullscreen TUI |
| `--log-dir` | | `.loader` | Directory to write per-run log files into (gets its own timestamped subfolder); also settable via `LOADER_LOG_DIR` |
| `--no-log` | | off | Disable writing per-run log files |
| `--log-env` | | none | Env var name to record in `environment.log` (can be specified multiple times); also settable via `LOADER_LOG_ENV_VARS` or a config file |

At least one of `--max-count` or `--duration` must be set, otherwise the tool runs until interrupted.

Expand Down Expand Up @@ -85,7 +86,8 @@ Unless `--no-log` is set, each run writes its log files to a fresh
timestamped subfolder (e.g. `2026-09-24T15-04-21Z`, UTC) under `--log-dir`
(default `.loader`), so repeated runs never clobber each other:

- `environment.log` — the environment loader saw at startup, one `KEY=value` per line
- `environment.log` — only the env vars you've chosen to record (see below), one `KEY=value`
per line; omitted entirely if none are configured
- `proc-N.log` — combined stdout/stderr for launched process `N` (matches `LOADER_RUN_ATTEMPT`)
- `run.log` — one timestamped line per start/stop event, mirroring the TUI's recent-activity feed:

Expand All @@ -96,6 +98,25 @@ timestamped subfolder (e.g. `2026-09-24T15-04-21Z`, UTC) under `--log-dir`

- `summary.log` — the run's config followed by its final summary stats, written once the run finishes

### Choosing which env vars to record

Which env vars end up in `environment.log` is resolved in this order (first match wins):

1. `--log-env NAME` (can be specified multiple times)
2. `LOADER_LOG_ENV_VARS` — a comma-separated list, e.g. `LOADER_LOG_ENV_VARS=FOO,BAR`
3. `logging.env_vars` in `~/.config/loader/config.yaml`:

```yaml
logging:
env_vars:
- FOO
- BAR
```

4. none — `environment.log` is omitted

Named vars that aren't actually set are silently skipped.

## Interactive mode

In a terminal, `loader` runs as a fullscreen dashboard with:
Expand Down
6 changes: 5 additions & 1 deletion engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ type Config struct {
// LogDir is the resolved directory to write per-run log files into.
// Empty means logging is disabled.
LogDir string

// LogEnvVars is the resolved list of environment variable names to
// record in environment.log. Empty means none are logged.
LogEnvVars []string
}

// LogLine is a single line of output emitted by a process, or a system
Expand Down Expand Up @@ -412,7 +416,7 @@ type Engine struct {
// up eagerly so StopLaunching/KillRunning are safe to call as soon as
// NewEngine returns, even before Run's goroutine has started.
func NewEngine(cfg Config) (*Engine, error) {
logger, err := newRunLogger(cfg.LogDir)
logger, err := newRunLogger(cfg.LogDir, cfg.LogEnvVars)
if err != nil {
return nil, err
}
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/charmbracelet/lipgloss v1.1.0
github.com/urfave/cli/v3 v3.13.0
golang.org/x/term v0.46.0
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,7 @@ golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
29 changes: 21 additions & 8 deletions logdir.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ type RunLogger struct {
runLog *os.File
}

// newRunLogger creates dir and writes environment.log into it. An empty dir
// means logging is disabled, and returns (nil, nil).
func newRunLogger(dir string) (*RunLogger, error) {
// newRunLogger creates dir and writes environment.log into it, containing
// only the names listed in envVars that are actually set. An empty dir means
// logging is disabled, and returns (nil, nil).
func newRunLogger(dir string, envVars []string) (*RunLogger, error) {
if dir == "" {
return nil, nil
}
Expand All @@ -41,7 +42,7 @@ func newRunLogger(dir string) (*RunLogger, error) {
return nil, fmt.Errorf("create run log: %w", err)
}
l := &RunLogger{dir: dir, runLog: f}
if err := l.writeEnvironment(); err != nil {
if err := l.writeEnvironment(envVars); err != nil {
return nil, err
}
return l, nil
Expand All @@ -55,10 +56,22 @@ func (l *RunLogger) Dir() string {
return l.dir
}

func (l *RunLogger) writeEnvironment() error {
env := os.Environ()
sort.Strings(env)
return os.WriteFile(filepath.Join(l.dir, "environment.log"), []byte(strings.Join(env, "\n")+"\n"), 0o644)
// writeEnvironment writes environment.log containing "NAME=value" lines for
// each name in envVars that is set in the current process environment. Names
// that aren't set are silently omitted. If no lines result, the file is
// skipped entirely: no file means nothing was configured to be logged.
func (l *RunLogger) writeEnvironment(envVars []string) error {
var lines []string
for _, name := range envVars {
if v, ok := os.LookupEnv(name); ok {
lines = append(lines, name+"="+v)
}
}
if len(lines) == 0 {
return nil
}
sort.Strings(lines)
return os.WriteFile(filepath.Join(l.dir, "environment.log"), []byte(strings.Join(lines, "\n")+"\n"), 0o644)
}

// processLogPath returns the path to the combined stdout/stderr file for
Expand Down
52 changes: 52 additions & 0 deletions logdir_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package main

import (
"os"
"path/filepath"
"testing"
)

func TestWriteEnvironmentFiltersToNamedVars(t *testing.T) {
t.Setenv("LOADER_TEST_A", "1")
t.Setenv("LOADER_TEST_B", "2")

dir := t.TempDir()
l := &RunLogger{dir: dir}
if err := l.writeEnvironment([]string{"LOADER_TEST_B", "LOADER_TEST_A", "LOADER_TEST_UNSET"}); err != nil {
t.Fatalf("writeEnvironment: %v", err)
}

got, err := os.ReadFile(filepath.Join(dir, "environment.log"))
if err != nil {
t.Fatalf("read environment.log: %v", err)
}
want := "LOADER_TEST_A=1\nLOADER_TEST_B=2\n"
if string(got) != want {
t.Fatalf("got %q, want %q", got, want)
}
}

func TestWriteEnvironmentSkipsFileWhenNoNames(t *testing.T) {
dir := t.TempDir()
l := &RunLogger{dir: dir}
if err := l.writeEnvironment(nil); err != nil {
t.Fatalf("writeEnvironment: %v", err)
}
assertNoEnvironmentLog(t, dir)
}

func TestWriteEnvironmentSkipsFileWhenNamesAllUnset(t *testing.T) {
dir := t.TempDir()
l := &RunLogger{dir: dir}
if err := l.writeEnvironment([]string{"LOADER_TEST_DEFINITELY_UNSET"}); err != nil {
t.Fatalf("writeEnvironment: %v", err)
}
assertNoEnvironmentLog(t, dir)
}

func assertNoEnvironmentLog(t *testing.T, dir string) {
t.Helper()
if _, err := os.Stat(filepath.Join(dir, "environment.log")); !os.IsNotExist(err) {
t.Fatalf("expected environment.log to be absent, stat error: %v", err)
}
}
60 changes: 60 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"

"github.com/urfave/cli/v3"
"golang.org/x/term"
"gopkg.in/yaml.v3"
)

// outputMode decides how subprocess stdout/stderr should be handled: the TUI
Expand Down Expand Up @@ -43,6 +45,52 @@ func resolveLogDir(override string) string {
return filepath.Join(base, time.Now().UTC().Format(logDirTimeFormat))
}

// logEnvVarsConfig mirrors the "logging.env_vars" section of
// ~/.config/loader/config.yaml.
type logEnvVarsConfig struct {
Logging struct {
EnvVars []string `yaml:"env_vars"`
} `yaml:"logging"`
}

// resolveLogEnvVars resolves the environment variable names to record in
// environment.log, in order of precedence: the --log-env flag (if given at
// all, it wins outright), else the LOADER_LOG_ENV_VARS CSV env var, else
// ~/.config/loader/config.yaml's logging.env_vars, else none.
func resolveLogEnvVars(flagValues []string) ([]string, error) {
if len(flagValues) > 0 {
return flagValues, nil
}

if csv, ok := os.LookupEnv("LOADER_LOG_ENV_VARS"); ok {
var names []string
for _, name := range strings.Split(csv, ",") {
name = strings.TrimSpace(name)
if name != "" {
names = append(names, name)
}
}
return names, nil
}

configDir, err := os.UserConfigDir()
if err != nil {
return nil, fmt.Errorf("locate user config dir: %w", err)
}
data, err := os.ReadFile(filepath.Join(configDir, "loader", "config.yaml"))
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read config.yaml: %w", err)
}
var cfg logEnvVarsConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config.yaml: %w", err)
}
return cfg.Logging.EnvVars, nil
}

func buildConfig(cmd *cli.Command, interactive bool) (Config, error) {
args := cmd.Args().Slice()
if len(args) == 0 {
Expand All @@ -55,8 +103,15 @@ func buildConfig(cmd *cli.Command, interactive bool) (Config, error) {
}

var logDir string
var logEnvVars []string
if !cmd.Bool("no-log") {
logDir = resolveLogDir(cmd.String("log-dir"))

var err error
logEnvVars, err = resolveLogEnvVars(cmd.StringSlice("log-env"))
if err != nil {
return Config{}, err
}
}

return Config{
Expand All @@ -67,6 +122,7 @@ func buildConfig(cmd *cli.Command, interactive bool) (Config, error) {
TestDuration: cmd.Duration("duration"),
OutputMode: outputMode(interactive, cmd.Bool("verbose")),
LogDir: logDir,
LogEnvVars: logEnvVars,
}, nil
}

Expand Down Expand Up @@ -140,6 +196,10 @@ func main() {
Name: "no-log",
Usage: "disable writing per-run log files",
},
&cli.StringSliceFlag{
Name: "log-env",
Usage: "environment variable name to record in environment.log (repeatable); overrides LOADER_LOG_ENV_VARS and config.yaml",
},
},
Action: run,
}
Expand Down
112 changes: 112 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package main

import (
"os"
"path/filepath"
"runtime"
"testing"
)

// setUserConfigDir points os.UserConfigDir() at a subdirectory of dir and
// returns the resulting config dir, accounting for the env var UserConfigDir
// consults varying by OS.
func setUserConfigDir(t *testing.T, dir string) string {
t.Helper()
switch runtime.GOOS {
case "darwin":
t.Setenv("HOME", dir)
return filepath.Join(dir, "Library", "Application Support")
case "windows":
t.Setenv("AppData", dir)
return dir
default:
t.Setenv("XDG_CONFIG_HOME", dir)
return dir
}
}

func TestResolveLogEnvVarsFlagWinsOverAll(t *testing.T) {
t.Setenv("LOADER_LOG_ENV_VARS", "FROM_ENV")
writeConfigYAML(t, "FROM_YAML")

got, err := resolveLogEnvVars([]string{"FROM_FLAG"})
if err != nil {
t.Fatalf("resolveLogEnvVars: %v", err)
}
if len(got) != 1 || got[0] != "FROM_FLAG" {
t.Fatalf("got %v, want [FROM_FLAG]", got)
}
}

func TestResolveLogEnvVarsCSVEnvVarOverYAML(t *testing.T) {
t.Setenv("LOADER_LOG_ENV_VARS", " FOO , BAR ,,BAZ")
writeConfigYAML(t, "FROM_YAML")

got, err := resolveLogEnvVars(nil)
if err != nil {
t.Fatalf("resolveLogEnvVars: %v", err)
}
want := []string{"FOO", "BAR", "BAZ"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v, want %v", got, want)
}
}
}

func TestResolveLogEnvVarsFallsBackToYAML(t *testing.T) {
writeConfigYAML(t, "FROM_YAML")

got, err := resolveLogEnvVars(nil)
if err != nil {
t.Fatalf("resolveLogEnvVars: %v", err)
}
if len(got) != 1 || got[0] != "FROM_YAML" {
t.Fatalf("got %v, want [FROM_YAML]", got)
}
}

func TestResolveLogEnvVarsNoSourceReturnsEmpty(t *testing.T) {
setUserConfigDir(t, t.TempDir())

got, err := resolveLogEnvVars(nil)
if err != nil {
t.Fatalf("resolveLogEnvVars: %v", err)
}
if len(got) != 0 {
t.Fatalf("got %v, want empty", got)
}
}

func TestResolveLogEnvVarsMalformedYAMLErrors(t *testing.T) {
configDir := setUserConfigDir(t, t.TempDir())
configPath := filepath.Join(configDir, "loader")
if err := os.MkdirAll(configPath, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configPath, "config.yaml"), []byte("logging: [this is not a map"), 0o644); err != nil {
t.Fatal(err)
}

if _, err := resolveLogEnvVars(nil); err == nil {
t.Fatal("expected an error for malformed config.yaml, got nil")
}
}

// writeConfigYAML points os.UserConfigDir() at a fresh temp dir and writes a
// loader/config.yaml into it with the given env var name.
func writeConfigYAML(t *testing.T, envVar string) {
t.Helper()
configDir := setUserConfigDir(t, t.TempDir())
configPath := filepath.Join(configDir, "loader")
if err := os.MkdirAll(configPath, 0o755); err != nil {
t.Fatal(err)
}
content := "logging:\n env_vars:\n - " + envVar + "\n"
if err := os.WriteFile(filepath.Join(configPath, "config.yaml"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
Loading