diff --git a/AGENTS.md b/AGENTS.md index 2432ea28..fc19cb7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -447,6 +447,37 @@ All test runs save comprehensive debugging artifacts to `control_logs/TIMESTAMP- ## Testing Guidelines +### Test Log Output + +By default, `go test ./...` runs with zerolog set to `ErrorLevel`. This is configured centrally by an `init()` in `hscontrol/types/testlog.go`, which uses `testing.Testing()` to detect test mode and only applies to test binaries — production binaries are unaffected. Because `hscontrol/types` is transitively imported by every package that emits zerolog output, this single insertion point silences all `go test` runs without any per-package boilerplate. + +To re-enable verbose logs while debugging a specific test, set the `HEADSCALE_TEST_LOG_LEVEL` environment variable to any zerolog level (`trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled`): + +```bash +# Show all zerolog output for one package +HEADSCALE_TEST_LOG_LEVEL=trace go test -v ./hscontrol/mapper/... + +# See debug and above for a single test +HEADSCALE_TEST_LOG_LEVEL=debug go test -v ./hscontrol/db/... -run TestNodeRegistration + +# Make CI fully silent (suppress even errors) +HEADSCALE_TEST_LOG_LEVEL=disabled go test ./... +``` + +**Do not add per-test `zerolog.SetGlobalLevel` calls.** The central mechanism handles the default. If a specific test genuinely needs a different level (e.g., a benchmark that wants to assert on log output, or a test that needs to capture errors), use the save/restore idiom so subsequent tests in the same binary are not affected: + +```go +originalLevel := zerolog.GlobalLevel() +defer zerolog.SetGlobalLevel(originalLevel) +zerolog.SetGlobalLevel(zerolog.DebugLevel) +``` + +**Pitfalls**: + +- `log.Fatal()` still calls `os.Exit(1)` and `log.Panic()` still panics regardless of level — only the rendered message is suppressed. +- Local buffer loggers created via `zerolog.New(&buf)` are also gated by the global level. Tests that assert on log output (currently only `hscontrol/util/zlog`) re-enable trace level via their own `init_test.go`. +- Avoid the broken `defer zerolog.SetGlobalLevel(zerolog.DebugLevel)` pattern. It restores to `DebugLevel` instead of the prior level, polluting global state for subsequent tests in the same binary. + ### Integration Test Patterns #### **CRITICAL: EventuallyWithT Pattern for External Calls** diff --git a/hscontrol/types/testlog.go b/hscontrol/types/testlog.go new file mode 100644 index 00000000..af7f5b42 --- /dev/null +++ b/hscontrol/types/testlog.go @@ -0,0 +1,48 @@ +package types + +import ( + "os" + "testing" + + "github.com/rs/zerolog" +) + +// EnvTestLogLevel overrides the default test log level. Accepts any zerolog +// level string: trace, debug, info, warn, error, fatal, panic, disabled. +const EnvTestLogLevel = "HEADSCALE_TEST_LOG_LEVEL" + +// init quiets zerolog when this package is loaded inside a test binary. +// +// hscontrol/types is transitively imported by every test in the repo that +// emits zerolog output, so this init() runs once per test binary and is +// the only place that needs to know about test logging configuration. +// +// Default: ErrorLevel (silent in green-path runs, real errors still surface). +// Override: HEADSCALE_TEST_LOG_LEVEL=debug (or trace, info, warn, disabled). +// +// Production binaries are unaffected because testing.Testing() returns false +// outside of test execution. The same testing.Testing() pattern is already +// used in hscontrol/db/users.go and hscontrol/db/node.go, so importing the +// testing package here is consistent with existing project conventions. +// +// Pitfalls: +// - log.Fatal still calls os.Exit and log.Panic still panics regardless of +// level — only the rendered message is suppressed. +// - Local buffer loggers (zerolog.New(&buf)) are also gated by the global +// level. Tests that assert on log output (currently only +// hscontrol/util/zlog) re-enable trace level via their own init_test.go. +func init() { + if !testing.Testing() { + return + } + + if raw := os.Getenv(EnvTestLogLevel); raw != "" { + lvl, err := zerolog.ParseLevel(raw) + if err == nil { + zerolog.SetGlobalLevel(lvl) + return + } + } + + zerolog.SetGlobalLevel(zerolog.ErrorLevel) +} diff --git a/hscontrol/util/zlog/init_test.go b/hscontrol/util/zlog/init_test.go new file mode 100644 index 00000000..ce001334 --- /dev/null +++ b/hscontrol/util/zlog/init_test.go @@ -0,0 +1,18 @@ +package zlog + +import "github.com/rs/zerolog" + +// init pins zerolog to TraceLevel for the zlog test binary. +// +// zlog's tests use zerolog.New(&buf) and assert on Info-level output. zerolog's +// (*Logger).should() gates emission on the global level, so any global level +// above Info would silently break the assertions. +// +// Today zlog does not transitively import hscontrol/types, so the test +// silencing init() in hscontrol/types/testlog.go does not run in this binary. +// This init defends against that changing in the future: if a future import +// chain pulls in hscontrol/types, this file will still ensure trace-level +// output is available for zlog's assertions. +func init() { + zerolog.SetGlobalLevel(zerolog.TraceLevel) +}