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"}}