feat(go): implement golang sdk logger#3379
Conversation
|
Thanks for the PR. It is labeled Slash commands (own line, regular comment) move it around the queue:
See CONTRIBUTING.md for details. |
|
I was doing code review but realized that you are still working on it since github tell me to refresh the PR. However, I do found some problem. I'm sorry when I wrote the misleading proposal since it's just a small silly example. I did some small research on it and realized that there is something new about go log system and it would probably help: https://go.dev/blog/slog. I think it can be used directly to replace the |
Thanks for the clarification. I introduced the interface based on the original proposal, but I agree that using |
|
By the way, test is missing. So if you don't mind please provide unit test for this PR. |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #3379 +/- ##
============================================
- Coverage 74.65% 74.64% -0.01%
Complexity 943 943
============================================
Files 1228 1229 +1
Lines 120529 120577 +48
Branches 97263 97263
============================================
+ Hits 89975 90004 +29
- Misses 27614 27636 +22
+ Partials 2940 2937 -3
🚀 New features to boost your workflow:
|
| } | ||
|
|
||
| func NopLogger() *slog.Logger { | ||
| return slog.New(slog.NewTextHandler(io.Discard, nil)) |
There was a problem hiding this comment.
NopLogger() isn't actually silent. slog.NewTextHandler(io.Discard, nil) passes nil options, so the handler's min level defaults to info - info/warn/error still get fully formatted and then written to io.Discard, only debug is skipped. measured on go 1.25 that's ~430-530 ns/op (and 1 alloc once an error value is passed, which is exactly the heartbeat warn) vs ~3ns for a true noop. the WithLogger doc says output is "silently discarded" and the PR claims zero overhead, which only holds for debug.
fix is one line: return slog.New(slog.DiscardHandler). it reports Enabled() == false at every level so nothing is formatted, 0 alloc across the board, and it's already in the stdlib (go.mod and toolchain are on 1.25). all the current call sites are cold (heartbeat, login, redirect) so there's no runtime regression today, but the claim and the contract should match the code.
| return slog.New(handler) | ||
| } | ||
|
|
||
| func NewStderrLogger(level slog.Level) *slog.Logger { |
There was a problem hiding this comment.
NewStderrLogger has no non-test callers - the only thing that calls it is its own test (TestNewStderrLogger_RespectsLevel), and NewLogger(level, os.Stderr) already covers what it does. golangci's unused won't flag it since it's exported, so it just sits as dead public surface. worth dropping it (and its test).
| "os" | ||
| ) | ||
|
|
||
| func NewLogger(level slog.Level, writer io.Writer) *slog.Logger { |
There was a problem hiding this comment.
two small things on the exported helpers: NewLogger(level, writer) flips the usual slog arg order (slog.NewTextHandler(writer, opts) is writer-first), so NewLogger(writer, level) would read more naturally. also none of NewLogger/NewStderrLogger/NopLogger have doc comments - not a CI failure with the current lint config, but they're exported.
| func CheckAndRedirectToLeader(ctx context.Context, c iggcon.Client, currentAddress string, transport iggcon.Protocol) (string, error) { | ||
| log.Println("Checking cluster metadata for leader detection") | ||
| func CheckAndRedirectToLeader(ctx context.Context, c iggcon.Client, currentAddress string, transport iggcon.Protocol, logger *slog.Logger) (string, error) { | ||
| logger.Debug("Checking cluster metadata for leader detection") |
There was a problem hiding this comment.
logger is dereferenced here unconditionally as the first statement, and again in processClusterMetadata. adding the param turned this exported helper into one with an undocumented non-nil precondition. it's safe through the real path today since both NewIggyClient and NewIggyTcpClient default a nil logger to NopLogger(), but a struct-literal IggyTcpClient (e.g. newTestClient in the tcp tests) leaves logger nil and would panic the moment the leader path runs. cheapest guard is to default nil to NopLogger() at the top of CheckAndRedirectToLeader, or document the precondition.
| } | ||
|
|
||
| // Prepend the logger option so transport inherits it. | ||
| tcpOpts := append([]tcp.Option{tcp.WithLogger(logger)}, opts.tcpOptions...) |
There was a problem hiding this comment.
this prepends tcp.WithLogger(logger) then appends the user's opts.tcpOptions, so if someone passes both WithLogger(a) and WithTcp(tcp.WithLogger(b)), the transport ends up logging through b (last write wins) while ic.logger at line 102 keeps a - and the heartbeat at line 132 logs through a. so the client and its transport can silently use two different loggers.
it's structural, not a precedence thing: the transport's logger field is unexported so the client can't read it back, and reordering prepend/append won't fix the split. either route the heartbeat through one governing logger so a single logger covers everything, or document that WithTcp(tcp.WithLogger) doesn't govern the client-level heartbeat and WithLogger is the unified entry. don't just swap the append order - that would clobber an explicit transport logger.
There was a problem hiding this comment.
I went with the documentation approach for now and clarified the behavior of WithLogger(...) vs tcp.WithLogger(...). WithLogger(...) remains the client-level logger used by heartbeat logging and is forwarded to the transport as a default, while an explicit tcp.WithLogger(...) only overrides the transport logger.
There was a problem hiding this comment.
Another approach would be remove tcp.WithLogger, and change the signature of NewIggyTcpClient to
func NewIggyTcpClient(logger *slog.Logger, options ...Option) *IggyTcpClient. logger actually is not a optional parameter, we always use NewIggyClient to create TCP client, and we always provide a logger to it. This is actually better than document it. (in my opinion)
| pingCtx, pingCancel := context.WithTimeout(lifetimeCtx, ic.heartbeatInterval/2) | ||
| if err := ic.Ping(pingCtx); err != nil { | ||
| log.Printf("[WARN] heartbeat failed: %v", err) | ||
| ic.logger.Warn("heartbeat failed", "err", err) |
There was a problem hiding this comment.
key here is "err" but leader_aware.go uses "error" for the same kind of field. pick one (slog idiom is "error") so the structured keys stay consistent across the SDK.
There was a problem hiding this comment.
Pushed the requested changes.
NopLoggerusesslog.DiscardHandler(zero formatting, zero alloc)- dropped
NewStderrLoggerand its test - flipped
NewLoggerto(writer, level)and added doc comments - added a nil guard on the logger at the top of
CheckAndRedirectToLeader - documented the
WithLogger/tcp.WithLoggersplit onWithTcp,WithLogger, andtcp.WithLogger - changed the heartbeat key to
"error"for consistency withleader_aware.go
There was a problem hiding this comment.
I think both logger.go and logger_test.go can be removed. But it doesn't mean we don't need test. Actually, I think it would be great if we can test wither the WithLogger works.
NopLogger() can be replaced with slog.New(slog.DiscardHandler) directly, very straight forward and easy to understand.
| logger := opts.logger | ||
| if logger == nil { | ||
| logger = iggcon.NopLogger() | ||
| } |
There was a problem hiding this comment.
This if statement can be removed. We can just move the default value into the GetDefaultOptions() at R46.
|
|
||
| func GetDefaultOptions() Options { | ||
| return Options{ | ||
| config: defaultTcpClientConfig(), |
There was a problem hiding this comment.
| config: defaultTcpClientConfig(), | |
| logger: iggcon.NopLogger() // Or just slog.New(slog.DiscardHandler) should be better in my opinion |
| logger := opts.logger | ||
| if logger == nil { | ||
| logger = iggcon.NopLogger() | ||
| } |
There was a problem hiding this comment.
same here, move it to GetDefaultOptions(). We set NopLogger() in default options, and let the
for _, opt := range options {
opt(&opts)
}to overwrite it.
| // NopLogger returns a logger that silently discards all output. | ||
| func NopLogger() *slog.Logger { | ||
| return slog.New(slog.DiscardHandler) | ||
| } |
There was a problem hiding this comment.
Maybe we can delete logger.go and logger_test.go. If we use *slog.Logger, we can just inline slog.New(slog.DiscardHandler) for default logger. And for user who want to see the logs, they can do
client.NewIggyClient(client.WithLogger(slog.New(slog.NewTextHandler(
os.Stdout,
&slog.HandlerOptions{
Level: slog.LevelDebug,
}))))So the NewLogger won't be actually used.
The NopLogger makes sense, but I think it's also fine to just inline
slog.New(slog.DiscardHandler)So we can remove both of them, and the corresponding tests.
| func CheckAndRedirectToLeader(ctx context.Context, c iggcon.Client, currentAddress string, transport iggcon.Protocol, logger *slog.Logger) (string, error) { | ||
| if logger == nil { | ||
| logger = iggcon.NopLogger() | ||
| } |
There was a problem hiding this comment.
Regarding here, if you change the GetDefaultOptions(), it should be safe to remove the nil check.
|
|
||
| return &IggyTcpClient{ | ||
| config: opts.config, | ||
| logger: logger, |
There was a problem hiding this comment.
| logger: logger, | |
| logger: opts.logger, |
Which issue does this PR address?
Closes #3364
Rationale
The SDK currently logs to stdout using
log.Printfand users have no way to controlit. They can't turn it off, change the log level, or redirect it. This PR adds a
configurable logger so users can decide whether to enable logging, what level of
detail they want, and where the logs should go.
What changed?
The SDK was printing log messages directly to stdout with no user control.
This PR adds a Logger interface that users can pass to the client via
WithLogger().By default, logging is silent (zero overhead). When enabled, users can choose their
log level (Debug, Info, Warn, Error) and output destination (stderr, file, custom).
All internal
log.Printfcalls have been replaced with logger method calls.Local Execution
Passed
go vet ./...- no issuesgofmt- all files formatted correctlygolangci-lint- no errors or warningsgo build ./...- builds successfullygo test ./contracts ./client/tcp ./internal/util- all tests passAI Usage
Tool: Claude Sonnet 4.6
Scope: Generated the logger interface, DefaultLogger and NoopLogger implementations. All generated code was reviewed, integrated and tested manually.
Verification: All code compiles and tests pass locally.