diff --git a/.env.example b/.env.example index 79cce1d..996fbd4 100644 --- a/.env.example +++ b/.env.example @@ -129,8 +129,15 @@ ADMIN_TOKEN= # export AUTO_DISCOVER_TOKEN=false # ── Observability ──────────────────────────────────────────────────────── -# Log level: debug | info | warn | error +# Log level: debug | info | warn | error | trace (trace = wire-level bodies) LOG_LEVEL=info +# Log format: text (key=value, colored) or json (one JSON object per line) +#LOG_FORMAT=text +# One `access` line per HTTP request; false disables (healthz/metrics/OPTIONS +# are rate-limited to 1/min regardless) +#LOG_ACCESS=true +# In-memory log ring for /admin/logs (50-5000) +#LOG_RING_SIZE=500 # ── Optional extras (see .env.full-example for every key) ──────────────── # Upstream base URL (default https://www.codebuff.com) diff --git a/README.md b/README.md index 6bfa640..0543f64 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,10 @@ All keys can be set via environment variables or the JSON config file passed to | `TLS_FINGERPRINT` | `auto` | `auto`, `chrome120`, `chrome126`, `safari17`, `safari18`, `firefox120`, `firefox128`, `edge126`, `random` | | `DEBUG_DUMP` | `false` | Persist redacted traffic dumps to `./dump/` (mode 0600) | | `LOG_FILE` | `""` | Append log lines to a file (e.g. `./logs/proxy.log`) | -| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | +| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error`, `trace` (trace = wire-level bodies) | +| `LOG_FORMAT` | `text` | `text` (key=value, colored) or `json` (one JSON object per line) | +| `LOG_ACCESS` | `true` | Log one `access` line per HTTP request (`false` disables; `/healthz`, `/metrics`, OPTIONS are rate-limited to 1/min regardless) | +| `LOG_RING_SIZE` | `500` | In-memory log ring for `/admin/logs` (50–5000) | | `MAX_MESSAGES_PER_DAY` | `0` | Per-token daily cap on successful chats (`0` = unlimited, default; the upstream `429` lock is the real enforcement) | | `IDLE_ROTATION_TIMEOUT` | `0` | Finish runs after this idle period (`0` = disabled; `SAFE_MODE` sets 30m when unset) | | `SAFE_MODE` | `true` | Apply anti-ban presets (see below; set `false` to disable) | diff --git a/cmd/freebuff-proxy/main.go b/cmd/freebuff-proxy/main.go index a3ebfc0..3707eb0 100644 --- a/cmd/freebuff-proxy/main.go +++ b/cmd/freebuff-proxy/main.go @@ -99,10 +99,10 @@ func main() { // Effective log level: LOG_LEVEL config wins, else -v → debug, else info. level := resolveLogLevel(cfg.LogLevel, *verbose) - logger := telemetry.New(level, cfg.LogFile) + logger := telemetry.New(level, cfg.LogFile, cfg.LogFormat) // The dashboard log viewer reads from an in-memory ring that mirrors // every record the process logger emits (no log file or docker needed). - logringHandler := logring.NewHandler(logger.Handler(), 500) + logringHandler := logring.NewHandler(logger.Handler(), cfg.LogRingSize) logger = slog.New(logringHandler) // The pool/upstream/session/runs log through slog.Default(); route it // through our logger so the configured level and log file cover them too. @@ -281,7 +281,7 @@ func main() { "registry_refresh", cfg.RegistryRefresh.String(), "registry_agents", len(reg.AgentIDs()), "registry_models", reg.ModelCount(), - "log_level", level.String(), + "log_level", logLevelDisplay(level), "verbose", *verbose, ) if cfg.ActingUserID != "" { @@ -432,6 +432,16 @@ func resolveLogLevel(cfgLogLevel string, verbose bool) slog.Level { return slog.LevelInfo } +// logLevelDisplay renders the configured level for the startup summary. +// LevelTrace prints as TRACE instead of slog's "DEBUG-4" (the level sits +// below DEBUG, so slog's String() appends the negative offset). +func logLevelDisplay(level slog.Level) string { + if level == telemetry.LevelTrace { + return "TRACE" + } + return level.String() +} + // ignoredExeAdjacentEnv returns the path of a .env that sits next to the // executable while the process reads ./.env from the working directory — // the usual reason config "seems to vanish" under a non-interactive @@ -471,9 +481,9 @@ func refreshLoop(ctx context.Context, logger *slog.Logger, reg *registry.Registr } func logRegistryRefresh(ctx context.Context, logger *slog.Logger, reg *registry.Registry) { + // Success is logged inside Registry.Refresh (agents/models/ms); only the + // failure path lives here so refresh failures stay visible at the caller. if err := reg.Refresh(ctx); err != nil { logger.Warn("registry refresh failed; keeping previous state", "err", err) - return } - logger.Info("registry refreshed", "agents", len(reg.AgentIDs()), "models", reg.ModelCount()) } diff --git a/cmd/freebuff-proxy/main_test.go b/cmd/freebuff-proxy/main_test.go index c03b6a4..c2c85e9 100644 --- a/cmd/freebuff-proxy/main_test.go +++ b/cmd/freebuff-proxy/main_test.go @@ -12,6 +12,7 @@ import ( "time" "freebuff-proxy/internal/egress" + "freebuff-proxy/internal/telemetry" ) // TestHoldForExitIfConsolePipedStderrNoHang guards the console hold: with @@ -234,6 +235,8 @@ func TestResolveLogLevel(t *testing.T) { {"config wins", "warn", false, slog.LevelWarn}, {"config beats verbose", "error", true, slog.LevelError}, {"config case-insensitive", "DEBUG", false, slog.LevelDebug}, + {"trace level", "trace", false, telemetry.LevelTrace}, + {"trace case-insensitive", "TRACE", true, telemetry.LevelTrace}, {"unparseable falls back to info", "bogus", true, slog.LevelInfo}, } for _, tc := range cases { @@ -245,6 +248,26 @@ func TestResolveLogLevel(t *testing.T) { } } +// TestLogLevelDisplay pins the startup-summary level rendering: trace shows +// as TRACE (not slog's "DEBUG-4"), every other level keeps slog's name. +func TestLogLevelDisplay(t *testing.T) { + cases := []struct { + level slog.Level + want string + }{ + {telemetry.LevelTrace, "TRACE"}, + {slog.LevelDebug, "DEBUG"}, + {slog.LevelInfo, "INFO"}, + {slog.LevelWarn, "WARN"}, + {slog.LevelError, "ERROR"}, + } + for _, tc := range cases { + if got := logLevelDisplay(tc.level); got != tc.want { + t.Errorf("logLevelDisplay(%v) = %q, want %q", tc.level, got, tc.want) + } + } +} + // TestIgnoredExeAdjacentEnv pins the exe-adjacent .env warning branch: a // .env next to the executable is flagged ONLY when the working directory // differs from the executable's directory — the usual reason config "seems diff --git a/internal/config/config.go b/internal/config/config.go index 906b62a..f5a7b52 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,8 @@ import ( "strconv" "strings" "time" + + "freebuff-proxy/internal/telemetry" ) // Config is the fully-resolved, validated runtime configuration. @@ -44,12 +46,17 @@ type Config struct { // The only safe value is the token's own account id. (True CLI parity — // auto-deriving each token's own id once via GET /api/v1/me — is // deferred; see the gap analysis item 24.) - ActingUserID string - TLSFingerprint string // "" (plain Go transport) | chrome120 | chrome126 | safari17 | safari18 | firefox120 | firefox128 | edge126 | random | auto - RegistryRefresh time.Duration - DebugDump bool - LogFile string - LogLevel string // "" (use -v/default) or debug|info|warn|error + ActingUserID string + TLSFingerprint string // "" (plain Go transport) | chrome120 | chrome126 | safari17 | safari18 | firefox120 | firefox128 | edge126 | random | auto + RegistryRefresh time.Duration + DebugDump bool + LogFile string + LogLevel string // "" (use -v/default) or debug|info|warn|error|trace + LogFormat string // "text" (default) or "json" + LogAccess bool // true = per-request access log lines (LOG_ACCESS; default true, an empty .env line keeps it enabled) + // LogRingSize is the bounded in-memory log ring capacity behind the + // dashboard log viewer (LOG_RING_SIZE; default 500, validated 50..5000). + LogRingSize int MaxMessagesPerDay int // 0 = unlimited: per-token cap on successful chats per 24h MaxSpendPerDay int64 // 0 = unlimited: ADVISORY per-token Pacific-day spend ceiling in ledger units (tokens from upstream usage blocks; issue #122). Never blocks — the upstream $ ceilings ($15 full / $5 limited / $0.50 restricted, compose by minimum, server-enforced) are the real gate. Surfaced as SpendLimit/SpendPct on /healthz so operator comparisons align with the Pacific-midnight reset. IdleRotationTimeout time.Duration // 0 = disabled: pause rotation/refresh after this idle period @@ -184,6 +191,9 @@ type rawConfig struct { DebugDump bool `json:"DEBUG_DUMP"` LogFile string `json:"LOG_FILE"` LogLevel string `json:"LOG_LEVEL"` + LogFormat string `json:"LOG_FORMAT"` + LogAccess bool `json:"LOG_ACCESS"` + LogRingSize *int `json:"LOG_RING_SIZE"` MaxMessagesPerDay *int `json:"MAX_MESSAGES_PER_DAY"` MaxSpendPerDay *int `json:"MAX_SPEND_PER_DAY"` IdleRotationTimeout string `json:"IDLE_ROTATION_TIMEOUT"` @@ -223,12 +233,14 @@ func defaultRawConfig() rawConfig { RegistryRefresh: "6h", CostMode: "free", // free-tier mode; omission routes requests as PAID and fresh free accounts get 402 "Out of credits" (upstream check: cost_mode !== 'free' → billing) MaxMessagesPerDay: nil, - MaxSpendPerDay: nil, // 0 = unlimited advisory spend ceiling (never enforced) - IdleRotationTimeout: "", // "" = disabled (unset → SAFE_MODE preset may fill) - SafeMode: true, // anti-ban presets on by default; set SAFE_MODE=false to disable - HybridMode: false, // relay client tokens AND serve the pool (off by default) - CORSAllowedOrigin: "*", // browser clients reach /v1/* cross-origin by default - RequestJitter: "", // "" = disabled (unset → SAFE_MODE preset may fill) + MaxSpendPerDay: nil, // 0 = unlimited advisory spend ceiling (never enforced) + IdleRotationTimeout: "", // "" = disabled (unset → SAFE_MODE preset may fill) + SafeMode: true, // anti-ban presets on by default; set SAFE_MODE=false to disable + LogAccess: true, // per-request access lines on by default; LOG_ACCESS=false disables them + LogRingSize: ptrInt(500), // dashboard log viewer ring capacity (T19) + HybridMode: false, // relay client tokens AND serve the pool (off by default) + CORSAllowedOrigin: "*", // browser clients reach /v1/* cross-origin by default + RequestJitter: "", // "" = disabled (unset → SAFE_MODE preset may fill) CLIVersion: "0.10.7", TransientRetries: nil, // nil = 1 (one retry after a transient transport failure; 0 disables) SessionPersist: false, // opt-in: persist session state across restarts @@ -377,6 +389,9 @@ func Load(configPath string) (Config, error) { overrideBool(&raw.DebugDump, "DEBUG_DUMP") overrideString(&raw.LogFile, "LOG_FILE") overrideString(&raw.LogLevel, "LOG_LEVEL") + overrideString(&raw.LogFormat, "LOG_FORMAT") + overrideBool(&raw.LogAccess, "LOG_ACCESS") + overrideInt(&raw.LogRingSize, "LOG_RING_SIZE") overrideInt(&raw.MaxMessagesPerDay, "MAX_MESSAGES_PER_DAY") overrideInt(&raw.MaxSpendPerDay, "MAX_SPEND_PER_DAY") overrideString(&raw.IdleRotationTimeout, "IDLE_ROTATION_TIMEOUT") @@ -540,6 +555,13 @@ func Load(configPath string) (Config, error) { transientRetries = *raw.TransientRetries } + // LOG_RING_SIZE: nil (unset/empty) defaults to 500; an explicit value + // must stay within 50..5000 (validated in Validate). + logRingSize := 500 + if raw.LogRingSize != nil { + logRingSize = *raw.LogRingSize + } + // FALLBACK_AFTER_MS (issue #100): milliseconds, ""/0 = disabled. Any // parse failure fails the load — a typo silently disabling model // fallback would be worse than surfacing it. @@ -585,6 +607,12 @@ func Load(configPath string) (Config, error) { raw.ActingUserID = raw.LegacyActingUserID } + // LOG_FORMAT default: empty means the text format (the historic output). + logFormat := strings.TrimSpace(raw.LogFormat) + if logFormat == "" { + logFormat = "text" + } + cfg := Config{ ListenAddr: strings.TrimSpace(raw.ListenAddr), UpstreamBaseURL: upstreamBaseURL, @@ -602,6 +630,9 @@ func Load(configPath string) (Config, error) { DebugDump: raw.DebugDump, LogFile: strings.TrimSpace(raw.LogFile), LogLevel: strings.TrimSpace(raw.LogLevel), + LogFormat: logFormat, + LogAccess: raw.LogAccess, + LogRingSize: logRingSize, MaxMessagesPerDay: maxMessagesPerDay, MaxSpendPerDay: maxSpendPerDay, IdleRotationTimeout: idleRotationTimeout, @@ -756,6 +787,8 @@ func (c Config) Validate() error { return errors.New("MAX_MESSAGES_PER_DAY cannot be negative") case c.MaxSpendPerDay < 0: return errors.New("MAX_SPEND_PER_DAY cannot be negative") + case c.LogRingSize != 0 && (c.LogRingSize < 50 || c.LogRingSize > 5000): + return errors.New("LOG_RING_SIZE must be between 50 and 5000 (default 500)") } if c.WebhookURL != "" { @@ -804,11 +837,17 @@ func (c Config) Validate() error { } if c.LogLevel != "" { - var level slog.Level - if err := level.UnmarshalText([]byte(c.LogLevel)); err != nil { - return fmt.Errorf("LOG_LEVEL %q must be one of: debug, info, warn, error", c.LogLevel) + if _, ok := telemetry.ParseLevel(c.LogLevel); !ok { + return fmt.Errorf("LOG_LEVEL %q must be one of: debug, info, warn, error, trace", c.LogLevel) } } + switch c.LogFormat { + case "", "text", "json": + // "" never survives From (it defaults to "text"), accepted for + // direct Config construction. + default: + return fmt.Errorf("LOG_FORMAT %q must be one of: text, json", c.LogFormat) + } u, err := url.Parse(c.UpstreamBaseURL) if err != nil { @@ -918,6 +957,9 @@ func applyDotenv(raw *rawConfig, path string) error { overrideBoolFrom(&raw.DebugDump, get, "DEBUG_DUMP") overrideStringFrom(&raw.LogFile, get, "LOG_FILE") overrideStringFrom(&raw.LogLevel, get, "LOG_LEVEL") + overrideStringFrom(&raw.LogFormat, get, "LOG_FORMAT") + overrideBoolFrom(&raw.LogAccess, get, "LOG_ACCESS") + overrideIntFrom(&raw.LogRingSize, get, "LOG_RING_SIZE") overrideIntFrom(&raw.MaxMessagesPerDay, get, "MAX_MESSAGES_PER_DAY") overrideIntFrom(&raw.MaxSpendPerDay, get, "MAX_SPEND_PER_DAY") overrideStringFrom(&raw.IdleRotationTimeout, get, "IDLE_ROTATION_TIMEOUT") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0d2d82e..1cb8cf6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -5,6 +5,7 @@ import ( "log/slog" "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -17,7 +18,7 @@ import ( var envKeys = []string{ "LISTEN_ADDR", "UPSTREAM_BASE_URL", "AUTH_TOKENS", "ROTATION_INTERVAL", "REQUEST_TIMEOUT", "SESSION_CALL_TIMEOUT", "API_KEYS", "COST_MODE", "ACTING_USER_ID", "USER_ID", - "TLS_FINGERPRINT", "REGISTRY_REFRESH", "DEBUG_DUMP", "LOG_FILE", "LOG_LEVEL", + "TLS_FINGERPRINT", "REGISTRY_REFRESH", "DEBUG_DUMP", "LOG_FILE", "LOG_LEVEL", "LOG_FORMAT", "LOG_ACCESS", "LOG_RING_SIZE", "MAX_MESSAGES_PER_DAY", "IDLE_ROTATION_TIMEOUT", "SAFE_MODE", "HYBRID_MODE", "MODELS_HIDE_UNAVAILABLE", "CORS_ALLOWED_ORIGIN", "REQUEST_JITTER", "CLI_VERSION", "MODEL_ALIASES", "AUTO_DISCOVER_TOKEN", "TRANSIENT_RETRIES", "ADMIN_TOKEN", @@ -221,6 +222,83 @@ func TestTransientRetries(t *testing.T) { t.Setenv("TRANSIENT_RETRIES", "") } +// TestLogRingSize pins the T19 LOG_RING_SIZE knob: default 500 when unset, +// an empty value keeps the default, explicit values must stay within +// 50..5000 (below the floor / above the cap fail validation), and the JSON +// and .env sources both apply. +func TestLogRingSize(t *testing.T) { + clearEnv(t) + t.Setenv("AUTH_TOKENS", "tok") + + // default: 500 when unset + if cfg, err := Load(""); err != nil { + t.Fatalf("Load (default): %v", err) + } else if cfg.LogRingSize != 500 { + t.Errorf("LogRingSize default = %d, want 500", cfg.LogRingSize) + } + + // explicit empty value keeps the default + t.Setenv("LOG_RING_SIZE", "") + if cfg, err := Load(""); err != nil { + t.Fatalf("Load (empty): %v", err) + } else if cfg.LogRingSize != 500 { + t.Errorf("LogRingSize (empty) = %d, want 500", cfg.LogRingSize) + } + + // env source: a valid value loads + t.Setenv("LOG_RING_SIZE", "2000") + if cfg, err := Load(""); err != nil { + t.Fatalf("Load (env 2000): %v", err) + } else if cfg.LogRingSize != 2000 { + t.Errorf("LogRingSize (env) = %d, want 2000", cfg.LogRingSize) + } + + // boundary values are accepted + for _, v := range []string{"50", "5000"} { + t.Setenv("LOG_RING_SIZE", v) + n, _ := strconv.Atoi(v) + if cfg, err := Load(""); err != nil { + t.Fatalf("Load (LOG_RING_SIZE=%s): %v", v, err) + } else if cfg.LogRingSize != n { + t.Errorf("LogRingSize (LOG_RING_SIZE=%s) = %d, want %d", v, cfg.LogRingSize, n) + } + } + + // below the floor fails validation + t.Setenv("LOG_RING_SIZE", "49") + if _, err := Load(""); err == nil || !strings.Contains(err.Error(), "LOG_RING_SIZE") { + t.Fatalf("Load (49): err = %v, want validation error mentioning LOG_RING_SIZE", err) + } + + // above the cap fails validation + t.Setenv("LOG_RING_SIZE", "5001") + if _, err := Load(""); err == nil || !strings.Contains(err.Error(), "LOG_RING_SIZE") { + t.Fatalf("Load (5001): err = %v, want validation error mentioning LOG_RING_SIZE", err) + } + t.Setenv("LOG_RING_SIZE", "") + + // JSON file source + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"LOG_RING_SIZE": 750}`), 0o644); err != nil { + t.Fatal(err) + } + if cfg, err := Load(path); err != nil { + t.Fatalf("Load (file): %v", err) + } else if cfg.LogRingSize != 750 { + t.Errorf("LogRingSize (file) = %d, want 750", cfg.LogRingSize) + } + + // .env source (applyDotenv) + if err := os.WriteFile(".env", []byte("AUTH_TOKENS=tok\nLOG_RING_SIZE=900\n"), 0o644); err != nil { + t.Fatal(err) + } + if cfg, err := Load(""); err != nil { + t.Fatalf("Load (.env): %v", err) + } else if cfg.LogRingSize != 900 { + t.Errorf("LogRingSize (.env) = %d, want 900", cfg.LogRingSize) + } +} + func TestSafeMode(t *testing.T) { t.Run("default SafeMode values", func(t *testing.T) { clearEnv(t) @@ -1011,11 +1089,22 @@ func TestLogLevel(t *testing.T) { t.Errorf("LogLevel = %q, want debug", cfg.LogLevel) } + // trace is accepted (case-insensitive), matching telemetry.ParseLevel + t.Setenv("LOG_LEVEL", "trace") + if cfg, err := Load(""); err != nil { + t.Fatalf("Load (env trace): %v", err) + } else if cfg.LogLevel != "trace" { + t.Errorf("LogLevel = %q, want trace", cfg.LogLevel) + } + // invalid level fails validation t.Setenv("LOG_LEVEL", "bogus") if _, err := Load(""); err == nil || !strings.Contains(err.Error(), "LOG_LEVEL") { t.Fatalf("Load (invalid level): err = %v, want error mentioning LOG_LEVEL", err) } + if _, err := Load(""); err == nil || !strings.Contains(err.Error(), "debug, info, warn, error, trace") { + t.Fatalf("Load (invalid level): err = %v, want error listing trace", err) + } // .env source t.Setenv("LOG_LEVEL", "") @@ -1029,6 +1118,112 @@ func TestLogLevel(t *testing.T) { } } +func TestLogFormat(t *testing.T) { + clearEnv(t) + t.Setenv("AUTH_TOKENS", "tok") + + // default: "text" when unset (the historic output shape) + if cfg, err := Load(""); err != nil { + t.Fatalf("Load (default): %v", err) + } else if cfg.LogFormat != "text" { + t.Errorf("LogFormat = %q, want text by default", cfg.LogFormat) + } + + // env source + t.Setenv("LOG_FORMAT", "json") + if cfg, err := Load(""); err != nil { + t.Fatalf("Load (env json): %v", err) + } else if cfg.LogFormat != "json" { + t.Errorf("LogFormat = %q, want json", cfg.LogFormat) + } + + // explicit empty resets to the default + t.Setenv("LOG_FORMAT", "") + if cfg, err := Load(""); err != nil { + t.Fatalf("Load (empty format): %v", err) + } else if cfg.LogFormat != "text" { + t.Errorf("LogFormat = %q, want text for empty value", cfg.LogFormat) + } + + // invalid format fails validation + t.Setenv("LOG_FORMAT", "xml") + if _, err := Load(""); err == nil || !strings.Contains(err.Error(), "LOG_FORMAT") { + t.Fatalf("Load (invalid format): err = %v, want error mentioning LOG_FORMAT", err) + } + + // JSON file source (weakest): env wins over it + t.Setenv("LOG_FORMAT", "text") + json := `{"AUTH_TOKENS":["tok"],"LOG_FORMAT":"json"}` + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(json), 0o644); err != nil { + t.Fatal(err) + } + if cfg, err := Load(path); err != nil { + t.Fatalf("Load (json file): %v", err) + } else if cfg.LogFormat != "text" { + t.Errorf("LogFormat = %q, want text (env beats JSON file)", cfg.LogFormat) + } + t.Setenv("LOG_FORMAT", "") + if cfg, err := Load(path); err != nil { + t.Fatalf("Load (json file, no env): %v", err) + } else if cfg.LogFormat != "json" { + t.Errorf("LogFormat = %q, want json from JSON file", cfg.LogFormat) + } +} + +// TestLogAccess pins T17: LOG_ACCESS defaults to true, an empty .env line +// keeps it enabled (the access gate must never flip off from an unset or +// blank value), and only an explicit false disables the access lines. +func TestLogAccess(t *testing.T) { + clearEnv(t) + t.Setenv("AUTH_TOKENS", "tok") + + if cfg, err := Load(""); err != nil { + t.Fatalf("Load (default): %v", err) + } else if !cfg.LogAccess { + t.Error("LogAccess = false by default, want true") + } + + // env source: explicit false disables. + t.Setenv("LOG_ACCESS", "false") + if cfg, err := Load(""); err != nil { + t.Fatalf("Load (env false): %v", err) + } else if cfg.LogAccess { + t.Error("LogAccess = true for LOG_ACCESS=false, want false") + } + + // Explicit true re-enables. + t.Setenv("LOG_ACCESS", "true") + if cfg, err := Load(""); err != nil { + t.Fatalf("Load (env true): %v", err) + } else if !cfg.LogAccess { + t.Error("LogAccess = false for LOG_ACCESS=true, want true") + } + + // An empty .env line must not disable access logging: the empty value + // leaves the default (true) untouched. + t.Setenv("LOG_ACCESS", "") + if err := os.WriteFile(".env", []byte("AUTH_TOKENS=tok\nLOG_ACCESS=\n"), 0o644); err != nil { + t.Fatal(err) + } + if cfg, err := Load(""); err != nil { + t.Fatalf("Load (empty .env line): %v", err) + } else if !cfg.LogAccess { + t.Error("LogAccess = false for an empty LOG_ACCESS=.env line, want true") + } + + // The .env source: LOG_ACCESS=false in .env disables (env wins). + t.Setenv("LOG_ACCESS", "") + if err := os.WriteFile(".env", []byte("AUTH_TOKENS=tok\nLOG_ACCESS=false\n"), 0o644); err != nil { + t.Fatal(err) + } + if cfg, err := Load(""); err != nil { + t.Fatalf("Load (.env false): %v", err) + } else if cfg.LogAccess { + t.Error("LogAccess = true for .env LOG_ACCESS=false, want false") + } +} + func TestTLSFingerprint(t *testing.T) { clearEnv(t) t.Setenv("AUTH_TOKENS", "tok") diff --git a/internal/dashboard/assets/app.css b/internal/dashboard/assets/app.css index c4edfcb..508c25c 100644 --- a/internal/dashboard/assets/app.css +++ b/internal/dashboard/assets/app.css @@ -515,6 +515,26 @@ textarea[name="content"] { margin-bottom: 0.6rem; } +.logs-filter { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; + margin: 0 0 0.8rem; +} + +.logs-filter select, +.logs-filter input { + margin: 0; + width: auto; + flex: 0 1 auto; +} + +.logs-filter input[type="search"] { + flex: 1 1 18rem; + min-width: 10rem; +} + .step-card { margin-bottom: 1rem; } diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go index 717d064..6763f16 100644 --- a/internal/dashboard/dashboard.go +++ b/internal/dashboard/dashboard.go @@ -185,7 +185,7 @@ func (d *Dashboard) render(w http.ResponseWriter, r *http.Request, content strin // Page returns a handler for the named content template, wired to its data. func (d *Dashboard) Page(name string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - d.render(w, r, name, d.dataFor(name)) + d.render(w, r, name, d.dataFor(name, r)) } } @@ -215,8 +215,9 @@ func (d *Dashboard) RenderRestricted(w http.ResponseWriter, r *http.Request, msg } } -// dataFor resolves the page data for a named content template. -func (d *Dashboard) dataFor(name string) any { +// dataFor resolves the page data for a named content template. r carries the +// query params consumed by the filtered pages (logs). +func (d *Dashboard) dataFor(name string, r *http.Request) any { switch name { case "overview": return d.overviewData() @@ -227,7 +228,7 @@ func (d *Dashboard) dataFor(name string) any { case "models": return d.modelsData() case "logs": - return d.logsData() + return d.logsData(r) case "traces": return d.tracesData() case "setup": @@ -266,7 +267,15 @@ func (d *Dashboard) playgroundData() playgroundData { type logsData struct { Enabled bool - Entries []logEntry + // Level/Msg echo the active filters so the template keeps the controls + // in sync when a filtered fragment re-renders (hx-get targets the same + // #logs-root region). + Level string + Msg string + // HasFilter reports whether a filter is active (drives the empty-state + // copy: "no matching records" vs "no records yet"). + HasFilter bool + Entries []logEntry } type logEntry struct { @@ -276,12 +285,28 @@ type logEntry struct { Fields string } -func (d *Dashboard) logsData() logsData { +func (d *Dashboard) logsData(r *http.Request) logsData { ld := logsData{Enabled: d.logs != nil} if d.logs == nil { return ld } + level := strings.TrimSpace(r.URL.Query().Get("level")) + msg := strings.TrimSpace(r.URL.Query().Get("msg")) + // Echo the level lowercased so the select's option comparison (exact + // match) stays in sync even when the client passes "WARN" or "Info". + ld.Level = strings.ToLower(level) + ld.Msg = msg + ld.HasFilter = level != "" || msg != "" + msgLower := strings.ToLower(msg) for _, e := range d.logs.Recent(200) { + // level matches exactly (INFO/WARN/... case-insensitive); msg is a + // case-insensitive substring of the message. + if level != "" && !strings.EqualFold(e.Level, level) { + continue + } + if msg != "" && !strings.Contains(strings.ToLower(e.Message), msgLower) { + continue + } ld.Entries = append(ld.Entries, logEntry{ Time: e.Time, Level: e.Level, diff --git a/internal/dashboard/dashboard_test.go b/internal/dashboard/dashboard_test.go index e8be337..73cc31c 100644 --- a/internal/dashboard/dashboard_test.go +++ b/internal/dashboard/dashboard_test.go @@ -193,6 +193,74 @@ func TestLogsPageWithoutRing(t *testing.T) { } } +// TestLogsPageFilters pins the T19 filter row: ?level and ?msg (substring, +// case-insensitive) render only matching rows, the empty state switches to +// the filtered copy when a filter matches nothing, and the filter controls +// are present for the hx-get wiring. +func TestLogsPageFilters(t *testing.T) { + ts := newDashboardForPages(t, true) // seeds one INFO "hello ring" record + + get := func(path string) string { + t.Helper() + resp, err := http.Get(ts.URL + path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + return string(mustReadAll(t, resp)) + } + + // The filter row renders: level select + msg input. + page := get("/logs") + for _, want := range []string{`name="level"`, `id="logs-msg"`, "all levels", "hx-get=\"/admin/logs\""} { + if !strings.Contains(page, want) { + t.Errorf("logs page missing filter control %q", want) + } + } + + // level=warn excludes the INFO record and shows the filtered empty state. + page = get("/logs?level=warn") + if strings.Contains(page, "hello ring") { + t.Error("level=warn filter rendered an info record") + } + if !strings.Contains(page, "No matching log records") { + t.Error("level=warn filter should show the filtered empty state") + } + + // level=info keeps the INFO record. + page = get("/logs?level=info") + if !strings.Contains(page, "hello ring") { + t.Error("level=info filter dropped the info record") + } + + // msg is a case-insensitive substring. + for _, q := range []string{"?msg=ring", "?msg=RING", "?msg=hello"} { + page = get("/logs" + q) + if !strings.Contains(page, "hello ring") { + t.Errorf("msg filter %q dropped the matching record", q) + } + } + + // A msg matching nothing flips to the filtered empty state. + page = get("/logs?msg=zzz-none") + if strings.Contains(page, "hello ring") { + t.Error("msg=zzz-none filter rendered a non-matching record") + } + if !strings.Contains(page, "No matching log records") { + t.Error("msg=zzz-none filter should show the filtered empty state") + } + + // Combined level+msg filter. + page = get("/logs?level=info&msg=ring") + if !strings.Contains(page, "hello ring") { + t.Error("combined info+ring filter dropped the matching record") + } + page = get("/logs?level=warn&msg=ring") + if strings.Contains(page, "hello ring") { + t.Error("combined warn+ring filter rendered a non-matching record") + } +} + func TestMetricsPageRendersSparklines(t *testing.T) { cfg := &config.Config{ UpstreamBaseURL: "https://www.codebuff.com", diff --git a/internal/dashboard/templates/layout.tmpl b/internal/dashboard/templates/layout.tmpl index 3fb91b8..d1432dc 100644 --- a/internal/dashboard/templates/layout.tmpl +++ b/internal/dashboard/templates/layout.tmpl @@ -43,7 +43,7 @@ {{if eq .Page "overview"}}{{end}} {{if eq .Page "tokens"}}{{end}} -{{if eq .Page "logs"}}{{end}} +{{if eq .Page "logs"}}{{end}} {{if eq .Page "traces"}}{{end}} {{if eq .Page "metrics"}}{{end}}