Skip to content

Conversation

@banjoh
Copy link
Contributor

@banjoh banjoh commented Oct 21, 2025

What this PR does / why we need it:

Add ability to override logger when creating new actions

Here is an example of how to use it

package main

import (
	"log/slog"
	"os"

	"helm.sh/helm/v4/pkg/action"
	"helm.sh/helm/v4/pkg/cli"
)

func main() {
	settings := cli.New()

	mylogger := slog.New(slog.NewTextHandler(os.Stdout, nil))

	actionConfig := action.NewConfiguration()
	actionConfig.SetLogger(mylogger)              // <------ New method to set logger

	// You can pass an empty string instead of settings.Namespace() to list
	// all namespaces
	if err := actionConfig.Init(settings.RESTClientGetter(), settings.Namespace(), os.Getenv("HELM_DRIVER")); err != nil {
		mylogger.Error("failed to init action config", "error", err)
		os.Exit(1)
	}
}

Fixes: #31399

Special notes for your reviewer:

  • As opposed to v3, the logger is set using a SetLogger() method instead of passing the logger in action.Configuration.Init() function, or setting it via action.Configuration.Log
  • The logger is an slog.Logger instead of the older log logger library in v3

If applicable:

  • this PR contains user facing changes (the docs needed label should be applied if so)
  • this PR contains unit tests
  • this PR has been tested for backwards compatibility

Fixes: helm#31399

Signed-off-by: Evans Mungai <mbuevans@gmail.com>
@pull-request-size pull-request-size bot added the size/L Denotes a PR that changes 100-499 lines, ignoring generated files. label Oct 21, 2025
Signed-off-by: Evans Mungai <mbuevans@gmail.com>
Signed-off-by: Evans Mungai <mbuevans@gmail.com>
Copy link
Contributor

@benoittgt benoittgt left a comment

Choose a reason for hiding this comment

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

Thanks a lot @banjoh for the work on this! I have few questions

d.SetLogger(cfg.Logger())
d.SetNamespace(namespace)
store = storage.Init(d)
case "sql":
Copy link
Contributor

Choose a reason for hiding this comment

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

We should also set the logger for SQL?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added


// logger returns the logger for the Client. If nil, returns slog.Default().
func (c *Client) Logger() *slog.Logger {
if lg := c.logger.Load(); lg != nil {
Copy link
Contributor

@benoittgt benoittgt Oct 22, 2025

Choose a reason for hiding this comment

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

To reduce duplication what about having something like ?

// internal/logging/logger.go
package logging

import "log/slog"

func GetLogger(logger *slog.Logger) *slog.Logger {
[...]
}

func SetLogger(newLogger *slog.Logger) *slog.Logger {
[...]
}

or maybe go even further if we accept this (also with the nil as slog.Default()).

type Holder struct {
    logger *slog.Logger
}

func (h *Holder) Logger() *slog.Logger {
    if h.logger != nil {
        return h.logger
    }
    return slog.Default()
}

func (h *Holder) SetLogger(newLogger *slog.Logger) {
    if newLogger == nil {
        h.logger = slog.Default()
        return
    }
    h.logger = newLogger
}

Copy link
Contributor Author

@banjoh banjoh Oct 22, 2025

Choose a reason for hiding this comment

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

Good suggestion. I assume you are suggesting using Holder as a struct embedding, right? Like so

type MyThing struct {
    Holder
}

func (m *MyThing) Do() {
    m.Logger().Info("Doing something")
}


func (c *Client) SetLogger(newLogger *slog.Logger) {
if newLogger == nil {
c.logger.Store(slog.New(slog.DiscardHandler)) // Assume nil as discarding logs
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment as before. What about using slog.Default() when the logger is nil, and requiring users to explicitly pass something like slog.New(slog.NewJSONHandler(io.Discard, nil)) if they want to discard logs?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I had that in the begin then I changed my mind. When consuming an API like this, I find passing nil i.e the default value of a pointer is synonymous to "unset my value". The effect of "unsetting" a logger is not to log anything.

HookOutputFunc func(namespace, pod, container string) io.Writer

// logger is an slog.Logger pointer to use with the Configuration instance
logger atomic.Pointer[slog.Logger]
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm curious, why are we not using a *slog.Logger directly here? If I use the SDK example and adapt it with your code, I don't see the need to use and atomic pointer.

func runUpgrade(....) error {
    upgradeLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil)).With(
        slog.String("request_id", "abc-123"), 
    )
    actionConfig := action.NewConfiguration()
    actionConfig.SetLogger(upgradeLogger)
    // [...]
    upgradeClient := action.NewUpgrade(actionConfig) // then I have my logger per action 🤔 

Maybe I’m missing some scope detail or other considerations you had in mind?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is to make sure SetLogger and Logger are thread safe

Copy link
Contributor

Choose a reason for hiding this comment

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

I’m wondering in which scenario someone would actually modify the logger during execution. 🤔
If we typically set it once in Configuration (for example, at action initialization), I don’t see a case where concurrent reads and writes would happen:

cfg := action.NewConfiguration()

// Goroutine 1: Constantly changing logger
go func() {
    for {
        cfg.SetLogger(logger1)  // Writing
    }
}()

// Goroutine 2: Reading logger
go func() {
    for {
        cfg.Logger().Info("test")  // Reading
    }
}()

Wouldn’t this kind of pattern be unusual in practice? It feels like we’d only set the logger once and reuse it across actions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wouldn’t this kind of pattern be unusual in practice? It feels like we’d only set the logger once and reuse it across actions.

Its unusual to do this for sure, but given that the cost of implementing thread safety is low, why not ensure its there? If performance is a concern, using atomic.Pointer ensure we have lock-free thread safety so the cost is negligible.

Signed-off-by: Evans Mungai <mbuevans@gmail.com>
Signed-off-by: Evans Mungai <mbuevans@gmail.com>
Signed-off-by: Evans Mungai <mbuevans@gmail.com>
Signed-off-by: Evans Mungai <mbuevans@gmail.com>
Signed-off-by: Evans Mungai <mbuevans@gmail.com>
mutex sync.Mutex
}

func NewConfiguration() *Configuration {
Copy link
Member

@gjenkins8 gjenkins8 Oct 22, 2025

Choose a reason for hiding this comment

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

Suggested change
func NewConfiguration() *Configuration {
func NewConfiguration(options...ConfigurationOption) *Configuration {

Option parameters might be better than this SetLogger() pattern?

Copy link
Member

Choose a reason for hiding this comment

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

ie.

type ConfigurationOption func(c *Configuration)

// Override the default logging handler
// If unspecified, the default logger will be used
func ConfigurationSetLogger(h *slog.Handler) ConfigurationOption {
    return func(c *Configuration) {
       c.logger = slog.New(h)
    }
}
func NewConfiguration(options...ConfigurationOption) {
    c := &Configuration{}
    for _, o := options {
       o(c)
    }
    
     if o.logger == nil {
        o.logger = slog.Default()
     }

Copy link
Member

Choose a reason for hiding this comment

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

this would assume that the user can set the logger when creating the action Configuration

(and doesn't want to change it later; but would be a somewhat "more idiomatic" or "cleaner" approach, perhaps)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. It allows us to add more options in future

banjoh and others added 2 commits October 22, 2025 19:08
Co-authored-by: George Jenkins <gvjenkins@gmail.com>
Signed-off-by: Evans Mungai <mbuevans@gmail.com>
Signed-off-by: Evans Mungai <mbuevans@gmail.com>
Copy link
Member

@robertsirc robertsirc left a comment

Choose a reason for hiding this comment

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

Not to be difficult, but shouldn't we put some test around this?

@banjoh
Copy link
Contributor Author

banjoh commented Oct 22, 2025

Not to be difficult, but shouldn't we put some test around this?

We could. They should be trivial to add

Signed-off-by: Evans Mungai <mbuevans@gmail.com>
Signed-off-by: Evans Mungai <mbuevans@gmail.com>
@pull-request-size pull-request-size bot added size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. and removed size/L Denotes a PR that changes 100-499 lines, ignoring generated files. labels Oct 23, 2025
Signed-off-by: Evans Mungai <mbuevans@gmail.com>
@banjoh
Copy link
Contributor Author

banjoh commented Oct 23, 2025

Not to be difficult, but shouldn't we put some test around this?

@robertsirc I've added some tests. Please take a look. Thanks

for _, o := range options {
o(c)
}

Copy link
Member

Choose a reason for hiding this comment

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

I think we need the if c.logger == nil { c.SetLogger(slog.Default()) } here?

Otherwise nothing will se tthe logger, and Logger() will default to the discard handler.

Copy link
Contributor Author

@banjoh banjoh Oct 23, 2025

Choose a reason for hiding this comment

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

Oh, yes. Good catch.

Copy link
Contributor Author

@banjoh banjoh Oct 23, 2025

Choose a reason for hiding this comment

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

Hmm. logger would never be nil since it's not a pointer type

type LogHolder struct {
	logger atomic.Pointer[slog.Logger]
}

Copy link
Member

Choose a reason for hiding this comment

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

The atomic value/pointer is nil:

if c.logger.Load() == nil { c.SetLogger(slog.Default()) }

Signed-off-by: Evans Mungai <mbuevans@gmail.com>
@benoittgt
Copy link
Contributor

Hello

TL;DR. Looks good on SDK and cli.


I did a full test. First in SDK scenario.

// Inspired by https://github.com/helm/helm-www/blob/main/sdkexamples/upgrade.go
// main.go
package main

import (
	"context"
	"log/slog"
	"os"

	"helm.sh/helm/v4/pkg/action"
	"helm.sh/helm/v4/pkg/chart/loader"
	"helm.sh/helm/v4/pkg/cli"
	"helm.sh/helm/v4/pkg/kube"
	releasev1 "helm.sh/helm/v4/pkg/release/v1"
)

func main() {
	baseLogger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
		Level: slog.LevelDebug,
	}))
	logger := baseLogger.With(
		slog.String("deployment-id", "12345"),
	)
	logger.Info("Starting Helm upgrade test with new slog integration")

	settings := cli.New()
	settings.Debug = true

	actionConfig := action.NewConfiguration()
	actionConfig.SetLogger(logger.Handler())

	if err := actionConfig.Init(
		settings.RESTClientGetter(),
		settings.Namespace(),
		os.Getenv("HELM_DRIVER"),
	); err != nil {
		logger.Error("Failed to initialize action config", "error", err)
		os.Exit(1)
	}

	upgradeClient := action.NewUpgrade(actionConfig)
	// release type will be *releasev1.Release"
	upgradeClient.WaitStrategy = kube.HookOnlyStrategy

	releaseName := "simple-test"
	chartPath := "../simple-test"

	logger.Info("Upgrading release", "release", releaseName, "chart", chartPath)

	chart, err := loader.Load(chartPath)
	if err != nil {
		logger.Error("Failed to load chart", "error", err)
		os.Exit(1)
	}

	rel, err := upgradeClient.RunWithContext(context.Background(), releaseName, chart, nil)
	if err != nil {
		logger.Error("Upgrade failed", "error", err)
		os.Exit(1)
	}

	r := rel.(*releasev1.Release)
	logger.Info("Upgrade successful", "release", r.Name, "version", r.Version, "status", r.Info.Status.String())
}
go run main.go

[...]
time=2025-10-24T18:05:23.280+02:00 level=DEBUG msg="Patched resource" deployment-id=12345 namespace=default name=simple-test gvk="apps/v1, Kind=Deployment"
time=2025-10-24T18:05:23.297+02:00 level=DEBUG msg="updating status for upgraded release" deployment-id=12345 name=simple-test
time=2025-10-24T18:05:23.300+02:00 level=INFO msg="Upgrade successful" deployment-id=12345 release=simple-test version=21 status=deployed

WIth cli

./bin/helm upgrade simple-test simple-test --debug --wait
level=DEBUG msg="number of dependencies in the chart" dependencies=0
level=DEBUG msg="waiting for resources" count=3 timeout=5m0s
level=DEBUG msg="waiting for resource" name=simple-test kind=ServiceAccount expectedStatus=Current actualStatus=Unknown
level=DEBUG msg="waiting for resource" name=simple-test kind=Deployment expectedStatus=Current actualStatus=Unknown
Release "simple-test" has been upgraded. Happy Helming!

It looks good.

benoittgt added a commit to benoittgt/helm that referenced this pull request Oct 24, 2025
While looking at SDK feature for v4. I was surprise with the error:

> "reporter failed to start: event funnel closed: context deadline
  exceeded"

It is related when you forgot to set a minimal timeout:
```go
upgradeClient := action.NewUpgrade(actionConfig)
upgradeClient.WaitStrategy = kube.StatusWatcherStrategy
// When Timeout is zero, the status wait uses a context with zero timeout which
// immediately expires causing "context deadline exceeded" errors.
upgradeClient.Timeout = 2 * time.Minute
```

Also maybe it should be even more documented. Initial [PR](helm#13604) say:
> I have not written any docs, I assume that can be done when we are closer to Helm 4, a lot of it is covered by linking to - https://github.com/kubernetes-sigs/cli-utils/blob/master/pkg/kstatus/README.md

Related:
  - helm#31411 (comment)
benoittgt added a commit to benoittgt/helm that referenced this pull request Oct 24, 2025
> "reporter failed to start: event funnel closed: context deadline  exceeded"

It happens when you forget to set a minimal timeout:
```go
upgradeClient := action.NewUpgrade(actionConfig)
upgradeClient.WaitStrategy = kube.StatusWatcherStrategy
// When Timeout is zero, the status wait uses a context with zero timeout which
// immediately expires causing "context deadline exceeded" errors.
upgradeClient.Timeout = 2 * time.Minute
```

Also maybe it might be worth documenting this more clearly. Initial [PR](helm#13604) say:
> I have not written any docs, I assume that can be done when we are closer to Helm 4, a lot of it is covered by linking to - https://github.com/kubernetes-sigs/cli-utils/blob/master/pkg/kstatus/README.md

Related:
  - helm#31411 (comment)
Signed-off-by: Benoit Tigeot <benoit.tigeot@lifen.fr>
benoittgt added a commit to benoittgt/helm that referenced this pull request Oct 24, 2025
> "reporter failed to start: event funnel closed: context deadline  exceeded"

It happens when you forget to set a minimal timeout:
```go
upgradeClient := action.NewUpgrade(actionConfig)
upgradeClient.WaitStrategy = kube.StatusWatcherStrategy
// When Timeout is zero, the status wait uses a context with zero timeout which
// immediately expires causing "context deadline exceeded" errors.
upgradeClient.Timeout = 2 * time.Minute
```

Also maybe it might be worth documenting this more clearly. Initial [PR](helm#13604) say:
> I have not written any docs, I assume that can be done when we are closer to Helm 4, a lot of it is covered by linking to - https://github.com/kubernetes-sigs/cli-utils/blob/master/pkg/kstatus/README.md

Related:
  - helm#31411 (comment)
Signed-off-by: Benoit Tigeot <benoit.tigeot@lifen.fr>
Copy link
Collaborator

@mattfarina mattfarina left a comment

Choose a reason for hiding this comment

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

lgtm

I know there is another PR to expand on test, too.

@mattfarina mattfarina added the Has One Approval This PR has one approval. It still needs a second approval to be merged. label Oct 27, 2025
Copy link
Member

@robertsirc robertsirc left a comment

Choose a reason for hiding this comment

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

LGTM

@matheuscscp
Copy link
Contributor

Hi, I'm gonna do a review on behalf of Flux later today

@matheuscscp
Copy link
Contributor

LGTM!

Copy link
Member

@scottrigby scottrigby left a comment

Choose a reason for hiding this comment

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

lgtm! Was also wanting to hear from Flux review - thanks @matheuscscp 👍

Copy link
Contributor

@TerryHowe TerryHowe left a comment

Choose a reason for hiding this comment

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

/lgtm

Thanks for working on this, nice work!


// Logger returns the logger for the LogHolder. If nil, returns slog.Default().
func (l *LogHolder) Logger() *slog.Logger {
if lg := l.logger.Load(); lg != nil {
Copy link
Contributor

@TerryHowe TerryHowe Oct 29, 2025

Choose a reason for hiding this comment

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

It would be nice if this would default to slog.Default().Handler() so this doesn't have to call SetLogger all over the place. There could be a follow up to do this if anyone else thinks this is a good idea.

Copy link
Member

Choose a reason for hiding this comment

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

this does sound useful as a follow-up IMO

@scottrigby scottrigby merged commit f4c5220 into helm:main Oct 29, 2025
5 checks passed
@gjenkins8 gjenkins8 removed the Has One Approval This PR has one approval. It still needs a second approval to be merged. label Nov 2, 2025
jnoordsij added a commit to jnoordsij/helm-diff-plugin that referenced this pull request Nov 4, 2025
@scottrigby scottrigby added this to the 4.0.0 milestone Nov 4, 2025
@scottrigby scottrigby added the bug Categorizes issue or PR as related to a bug. label Nov 4, 2025
@scottrigby scottrigby changed the title feat: reinstate logger parameter to actions package fix: reinstate logger parameter to actions package Nov 4, 2025
benoittgt added a commit to benoittgt/helm that referenced this pull request Nov 10, 2025
> "reporter failed to start: event funnel closed: context deadline  exceeded"

It happens when you forget to set a minimal timeout:
```go
upgradeClient := action.NewUpgrade(actionConfig)
upgradeClient.WaitStrategy = kube.StatusWatcherStrategy
// When Timeout is zero, the status wait uses a context with zero timeout which
// immediately expires causing "context deadline exceeded" errors.
upgradeClient.Timeout = 2 * time.Minute
```

Also maybe it might be worth documenting this more clearly. Initial [PR](helm#13604) say:
> I have not written any docs, I assume that can be done when we are closer to Helm 4, a lot of it is covered by linking to - https://github.com/kubernetes-sigs/cli-utils/blob/master/pkg/kstatus/README.md

Related:
  - helm#31411 (comment)
Signed-off-by: Benoit Tigeot <benoit.tigeot@lifen.fr>
@banjoh banjoh deleted the em/reinstate-logger-param branch November 13, 2025 17:18
}

// LoggerSetterGetter is an interface that can set and get a logger
type LoggerSetterGetter interface {
Copy link
Contributor

Choose a reason for hiding this comment

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

@banjoh There also used to be a method for passing a logger to the pkg/storage.Storage. Now the way you can do it is by passing a driver.Driver to pkg/storage.Init() that implements this LoggerSetterGetter interface. Can this interface be exposed, please? 🙏

Comment on lines +342 to +348
// Get logger from driver if it implements the LoggerSetterGetter interface
if ls, ok := d.(logging.LoggerSetterGetter); ok {
ls.SetLogger(s.Logger().Handler())
} else {
// If the driver does not implement the LoggerSetterGetter interface, set the default logger
s.SetLogger(slog.Default().Handler())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry, a bit too late, but there's a bug here. Line 344 should be s.SetLogger()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Categorizes issue or PR as related to a bug. size/XL Denotes a PR that changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Helm v4 SDK no longer offers the ability to specify a logger per action

8 participants