diff --git a/backend/cmd/server.go b/backend/cmd/server.go index 23bb8ac5891..db1450dcbb3 100644 --- a/backend/cmd/server.go +++ b/backend/cmd/server.go @@ -55,6 +55,8 @@ func main() { os.Exit(1) } + logger.Init(conf.LogLevel) + if conf.Version { fmt.Printf("%s %s (%s/%s)\n", kubeconfig.AppName, kubeconfig.Version, runtime.GOOS, runtime.GOARCH) return diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index b951b60dd52..492339edbd3 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -37,6 +37,7 @@ type Config struct { InClusterContextName string `koanf:"in-cluster-context-name"` DevMode bool `koanf:"dev"` InsecureSsl bool `koanf:"insecure-ssl"` + LogLevel string `koanf:"log-level"` // NoBrowser disables automatically opening the default browser when running // a locally embedded Headlamp binary (non in-cluster with spa.UseEmbeddedFiles == true). // It has no effect in in-cluster mode or when running without embedded frontend. @@ -418,6 +419,7 @@ func addGeneralFlags(f *flag.FlagSet) { f.Bool("cache-enabled", false, "K8s cache in backend") f.Bool("no-browser", false, "Disable automatically opening the browser when using embedded frontend") f.Bool("insecure-ssl", false, "Accept/Ignore all server SSL certificates") + f.String("log-level", "info", "Backend log level (debug, info, warn, error)") f.Bool("enable-dynamic-clusters", false, "Enable dynamic clusters, which stores stateless clusters in the frontend.") // Note: When running in-cluster and if not explicitly set, this flag defaults to false. f.Bool("watch-plugins-changes", true, "Reloads plugins when there are changes to them or their directory") diff --git a/backend/pkg/config/config_test.go b/backend/pkg/config/config_test.go index 8a8874f5e7b..46ae2fed0ee 100644 --- a/backend/pkg/config/config_test.go +++ b/backend/pkg/config/config_test.go @@ -44,6 +44,7 @@ func TestParseBasic(t *testing.T) { assert.Equal(t, config.DefaultMeUsernamePath, conf.MeUsernamePath) assert.Equal(t, config.DefaultMeEmailPath, conf.MeEmailPath) assert.Equal(t, config.DefaultMeGroupsPath, conf.MeGroupsPath) + assert.Equal(t, "info", conf.LogLevel) }, }, { @@ -130,6 +131,16 @@ var ParseWithEnvTests = []struct { assert.Equal(t, "mycluster", conf.InClusterContextName) }, }, + { + name: "log_level_from_env", + args: []string{"go run ./cmd"}, + env: map[string]string{ + "HEADLAMP_CONFIG_LOG_LEVEL": "warn", + }, + verify: func(t *testing.T, conf *config.Config) { + assert.Equal(t, "warn", conf.LogLevel) + }, + }, } func TestParseWithEnv(t *testing.T) { @@ -232,6 +243,13 @@ func TestParseFlags(t *testing.T) { assert.Equal(t, "mycluster", conf.InClusterContextName) }, }, + { + name: "log_level_flag", + args: []string{"go run ./cmd", "--log-level=warn"}, + verify: func(t *testing.T, conf *config.Config) { + assert.Equal(t, "warn", conf.LogLevel) + }, + }, } for _, tt := range tests { diff --git a/backend/pkg/logger/logger.go b/backend/pkg/logger/logger.go index 20615ded267..940746357d6 100644 --- a/backend/pkg/logger/logger.go +++ b/backend/pkg/logger/logger.go @@ -18,6 +18,7 @@ package logger import ( "runtime" + "strings" "github.com/rs/zerolog" zlog "github.com/rs/zerolog/log" @@ -41,6 +42,35 @@ type LogFunc func(level uint, str map[string]string, err interface{}, msg string // logFunc holds the actual logging function. var logFunc LogFunc = log +// Init configures the global zerolog log level from environment variables. +// The HEADLAMP_CONFIG_LOG_LEVEL environment variable controls the global log level. +func Init(loglevel string) { + logLevel := strings.ToLower(strings.TrimSpace(loglevel)) + + // If no log level is provided, default to info. + if logLevel == "" { + zerolog.SetGlobalLevel(zerolog.InfoLevel) + return + } + + level, err := zerolog.ParseLevel(logLevel) + // If an invalid log level is provided, log a warning and default to info. + if err != nil { + zlog.Warn(). + Str("value", logLevel). + Msg("Invalid HEADLAMP_CONFIG_LOG_LEVEL, defaulting to info") + zerolog.SetGlobalLevel(zerolog.InfoLevel) + + return + } + + // Set the global log level. + zerolog.SetGlobalLevel(level) + zlog.Info(). + Str("level", level.String()). + Msg("Log level set from HEADLAMP_CONFIG_LOG_LEVEL") +} + // Log logs the message, source file, and line number at the specified level. func Log(level uint, str map[string]string, err interface{}, msg string) { logFunc(level, str, err, msg) diff --git a/backend/pkg/logger/logger_test.go b/backend/pkg/logger/logger_test.go index f6664508c86..c554c3345c9 100644 --- a/backend/pkg/logger/logger_test.go +++ b/backend/pkg/logger/logger_test.go @@ -18,9 +18,11 @@ package logger_test import ( "fmt" + "os" "testing" "github.com/kubernetes-sigs/headlamp/backend/pkg/logger" + "github.com/rs/zerolog" ) var capturedLogs []string @@ -83,3 +85,86 @@ func TestLog(t *testing.T) { capturedLogs = nil } } + +// Sets global log level from HEADLAMP_CONFIG_LOG_LEVEL. +func TestLogLevelsFromEnv(t *testing.T) { + orig := zerolog.GlobalLevel() + + t.Cleanup(func() { + zerolog.SetGlobalLevel(orig) + }) + + tests := []struct { + name string + envValue string + expected zerolog.Level + }{ + {"debug lowercase", "debug", zerolog.DebugLevel}, + {"info lowercase", "info", zerolog.InfoLevel}, + {"warn lowercase", "warn", zerolog.WarnLevel}, + {"error lowercase", "error", zerolog.ErrorLevel}, + + {"uppercase INFO", "INFO", zerolog.InfoLevel}, + {"mixed case Info", "Info", zerolog.InfoLevel}, + + {"leading whitespace", " warn", zerolog.WarnLevel}, + {"trailing whitespace", "error ", zerolog.ErrorLevel}, + {"both sides whitespace", " debug ", zerolog.DebugLevel}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("HEADLAMP_CONFIG_LOG_LEVEL", tt.envValue) + + logger.Init(os.Getenv("HEADLAMP_CONFIG_LOG_LEVEL")) + + if got := zerolog.GlobalLevel(); got != tt.expected { + t.Fatalf("expected %v, got %v", tt.expected, got) + } + }) + } +} + +// Falls back to info on invalid log level. +func TestInvalidLevelDefaultsToInfo(t *testing.T) { + orig := zerolog.GlobalLevel() + + t.Cleanup(func() { + zerolog.SetGlobalLevel(orig) + }) + + t.Setenv("HEADLAMP_CONFIG_LOG_LEVEL", "not-a-level") + + logger.Init(os.Getenv("HEADLAMP_CONFIG_LOG_LEVEL")) + + if got := zerolog.GlobalLevel(); got != zerolog.InfoLevel { + t.Fatalf("expected fallback to info, got %v", got) + } +} + +// Defaults to info when env is empty or missing. +func TestEmptyOrMissingEnvDefaultsToInfo(t *testing.T) { + orig := zerolog.GlobalLevel() + + t.Cleanup(func() { + zerolog.SetGlobalLevel(orig) + }) + + t.Run("empty", func(t *testing.T) { + t.Setenv("HEADLAMP_CONFIG_LOG_LEVEL", "") + logger.Init(os.Getenv("HEADLAMP_CONFIG_LOG_LEVEL")) + + if zerolog.GlobalLevel() != zerolog.InfoLevel { + t.Fatalf("expected info for empty env") + } + }) + + t.Run("missing", func(t *testing.T) { + os.Unsetenv("HEADLAMP_CONFIG_LOG_LEVEL") + logger.Init(os.Getenv("HEADLAMP_CONFIG_LOG_LEVEL")) + + if zerolog.GlobalLevel() != zerolog.InfoLevel { + t.Fatalf("expected info when env missing") + } + }) +} diff --git a/docs/development/backend.md b/docs/development/backend.md index fc83a6599f6..578af54fcd1 100644 --- a/docs/development/backend.md +++ b/docs/development/backend.md @@ -27,6 +27,30 @@ Once built, it can be run in development mode (insecure / don't use in productio npm run backend:start ``` +## Logging configuration + +Headlamp’s backend supports configurable log levels to control verbosity. + +Log level can be configured using either a flag or an environment variable: +- the log level: `--log-level` or env var `HEADLAMP_CONFIG_LOG_LEVEL` + +Supported Values: +- `debug` +- `info` (default) +- `warn` +- `error` + +> **Note:** Headlamp uses zerolog defaults. +> Zerolog’s default debug level is `info`, and Headlamp follows this behavior. + +### Examples + +Run with warning level: + +```bash +./headlamp-server --log-level warn +``` + ## Lint To lint the backend/ code. diff --git a/docs/development/index.md b/docs/development/index.md index ee060c93be1..a962f1badba 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -60,6 +60,19 @@ and in a different terminal instance: npm run frontend:start ``` +### Backend logging + +Backend log verbosity can be controlled using either a flag or an environment variable. +- `--log-level` +- `HEADLAMP_CONFIG_LOG_LEVEL` + +Supported Values: `debug`, `info`, `warn`, `error` (default: `info`) + +Example: +```bash +npm run backend:start -- --log-level warn +``` + ## Generate API documentation To generate the TypeScript API documentation: