Skip to content
Open
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
2 changes: 2 additions & 0 deletions backend/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions backend/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
18 changes: 18 additions & 0 deletions backend/pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
},
{
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions backend/pkg/logger/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package logger

import (
"runtime"
"strings"

"github.com/rs/zerolog"
zlog "github.com/rs/zerolog/log"
Expand All @@ -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.
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected spelling of 'HEADLAMP_CONFIG_LOG_LEVEL' to 'HEADLAMP_LOG_LEVEL'.

Copilot uses AI. Check for mistakes.
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")
Comment on lines +60 to +61
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected spelling of 'HEADLAMP_CONFIG_LOG_LEVEL' to 'HEADLAMP_LOG_LEVEL'.

Copilot uses AI. Check for mistakes.
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")
Comment on lines +70 to +71
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected spelling of 'HEADLAMP_CONFIG_LOG_LEVEL' to 'HEADLAMP_LOG_LEVEL'.

Copilot uses AI. Check for mistakes.
}

// 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)
Expand Down
85 changes: 85 additions & 0 deletions backend/pkg/logger/logger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
})
}
24 changes: 24 additions & 0 deletions docs/development/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected spelling of 'HEADLAMP_CONFIG_LOG_LEVEL' to 'HEADLAMP_LOG_LEVEL'.

Suggested change
- the log level: `--log-level` or env var `HEADLAMP_CONFIG_LOG_LEVEL`
- the log level: `--log-level` or env var `HEADLAMP_LOG_LEVEL`

Copilot uses AI. Check for mistakes.

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.
Expand Down
13 changes: 13 additions & 0 deletions docs/development/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected spelling of 'HEADLAMP_CONFIG_LOG_LEVEL' to 'HEADLAMP_LOG_LEVEL'.

Suggested change
- `HEADLAMP_CONFIG_LOG_LEVEL`
- `HEADLAMP_LOG_LEVEL`

Copilot uses AI. Check for mistakes.

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:
Expand Down
Loading