Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,22 @@ stopped by `Manager.Close()`.
- `Manager` β€” entry point; created with `New(cfg, opts...)`.
- `Registry` β€” interface for the Consul backend; can be replaced by an in-memory stub in tests.
- `Service` / `HealthCheck` / `Event` β€” domain types.
- `Logger` β€” minimal, version-agnostic, `slog`-compatible logging interface. Pass any
`*slog.Logger` (or equivalent) directly; no `lib-observability` dependency required.

**Functional options:**

```go
libsd.WithLogger(logger) // inject a lib-commons log.Logger
libsd.WithLogger(logger) // inject any slog-compatible libsd.Logger (e.g. *slog.Logger)
```

> **Breaking change (logger decoupling):** `Config.Logger` and `WithLogger()` now take
> the stdlib-only `libsd.Logger` interface (`InfoContext`/`WarnContext`/`ErrorContext`/`DebugContext`)
> instead of `lib-observability`'s `log.Logger`. Pass a `*slog.Logger` directly. A
> `lib-observability` `log.Logger` no longer satisfies the API (it uses `Log(ctx, level, msg, ...Field)`),
> so callers relying on it must switch to an `slog`-compatible logger. This removes the
> version-pinning coupling on `lib-observability`.

## Usage

```go
Expand Down
2 changes: 1 addition & 1 deletion close_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ func TestManagerClose_RegistryWithoutCloser(t *testing.T) {
func TestManagerClose_DisabledManagerSafe(t *testing.T) {
t.Parallel()

m, err := New(Config{Enabled: false, Logger: log.NewNop()})
m, err := New(Config{Enabled: false, Logger: nopLogger()})
require.NoError(t, err)

assert.NotPanics(t, func() {
Expand Down
11 changes: 3 additions & 8 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import (
"strconv"
"strings"
"time"

"github.com/LerianStudio/lib-observability/log"
)

const (
Expand Down Expand Up @@ -171,8 +169,9 @@ type Config struct {
AllowStale *bool

// Logger receives structured log output from the Manager and registry.
// Defaults to log.NewNop() when nil.
Logger log.Logger
// It is a version-agnostic, slog-compatible interface (see libsd.Logger):
// pass any *slog.Logger or equivalent directly. A nil Logger silences output.
Logger Logger
}

// ConfigFromEnv returns a Config populated from environment variables.
Expand Down Expand Up @@ -250,10 +249,6 @@ func (c Config) Validate() error {
}

func (c Config) withDefaults() Config {
if c.Logger == nil {
c.Logger = log.NewNop()
}

if c.ConsulAddr == "" {
c.ConsulAddr = "localhost:8500"
}
Expand Down
5 changes: 2 additions & 3 deletions consul_dual_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"testing"
"time"

"github.com/LerianStudio/lib-observability/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -42,7 +41,7 @@ func integrationManagerDual(t *testing.T, internalPort int, preferView EndpointV
AdvertiseInternalAddr: advertiseAddr, // internal view β€” real/reachable (host.docker.internal)
AdvertiseInternalPort: internalPort,
PreferView: preferView,
Logger: log.NewNop(),
Logger: nopLogger(),
})
require.NoError(t, err)

Expand Down Expand Up @@ -271,7 +270,7 @@ func integrationManagerInternalOnly(t *testing.T, internalPort int) *Manager {
AdvertiseInternalAddr: advertiseAddr, // internal view β€” real/reachable
AdvertiseInternalPort: internalPort,
// AdvertiseAddr intentionally empty: no external endpoint is advertised.
Logger: log.NewNop(),
Logger: nopLogger(),
})
require.NoError(t, err)

Expand Down
5 changes: 2 additions & 3 deletions consul_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"testing"
"time"

"github.com/LerianStudio/lib-observability/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand All @@ -30,7 +29,7 @@ func integrationManager(t *testing.T) *Manager {
Enabled: true,
ConsulAddr: consulAddr,
AdvertiseAddr: advertiseAddr,
Logger: log.NewNop(),
Logger: nopLogger(),
})
require.NoError(t, err)

Expand Down Expand Up @@ -269,7 +268,7 @@ func TestIntegration_ResolveHonorsDialTimeout(t *testing.T) {
ConsulAddr: "10.255.255.1:8500", // blackhole: SYNs are dropped, dial hangs until deadline
AdvertiseAddr: advertiseAddr,
DialTimeout: dialTimeout,
Logger: log.NewNop(),
Logger: nopLogger(),
})
require.NoError(t, err)
t.Cleanup(func() { _ = m.Close() })
Expand Down
107 changes: 107 additions & 0 deletions logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package libsd

import (
"context"
"strings"

"github.com/LerianStudio/lib-observability/log"
)

// Logger is the minimal, version-agnostic structured-logging contract that
// lib-service-discovery accepts from consumers. It uses only stdlib types and
// mirrors the context-aware methods of *slog.Logger, so any slog-compatible
// logger (including the stdlib *slog.Logger) satisfies it directly β€” with no
// dependency on lib-observability and no adapter wrapper on the caller side.
//
// The variadic args follow slog semantics: alternating key/value pairs
// (e.g. "consul", addr) or slog.Attr values.
type Logger interface {
InfoContext(ctx context.Context, msg string, args ...any)
WarnContext(ctx context.Context, msg string, args ...any)
ErrorContext(ctx context.Context, msg string, args ...any)
DebugContext(ctx context.Context, msg string, args ...any)
}

// obsLoggerAdapter bridges a consumer-supplied libsd.Logger to the
// lib-observability log.Logger contract used internally by the registry and
// resolvers, so no internal call site changes. lib-observability remains a
// private implementation detail and never appears in the public API.
type obsLoggerAdapter struct {
l Logger
attrs []any
groups []string
}

// toObsLogger wraps a public libsd.Logger as an internal log.Logger. A nil
// logger yields the lib-observability no-op logger, preserving the prior
// "nil logger is silent" behaviour.
func toObsLogger(l Logger) log.Logger {
if l == nil {
return log.NewNop()
}

return &obsLoggerAdapter{l: l}
Comment on lines +38 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟑 Minor | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== logger.go ==\n'
sed -n '1,180p' logger.go

printf '\n== logger_test.go ==\n'
sed -n '1,140p' logger_test.go

printf '\n== search for Logger interface/type defs ==\n'
rg -n "type Logger|toObsLogger|obsLoggerAdapter|slog.Logger|NewNop" -S .

Repository: LerianStudio/lib-service-discovery

Length of output: 8461


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== logger.go ==\n'
sed -n '1,220p' logger.go

printf '\n== logger_test.go ==\n'
sed -n '1,220p' logger_test.go

printf '\n== logger-related declarations ==\n'
rg -n "type Logger|obsLoggerAdapter|toObsLogger|NewNop|slog.Logger" -S --glob '!**/vendor/**' .

Repository: LerianStudio/lib-service-discovery

Length of output: 8451


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "type Logger|toObsLogger|obsLoggerAdapter|NewNop|slog.Logger" -S .

Repository: LerianStudio/lib-service-discovery

Length of output: 2473


🌐 Web query:

Go standard library slog.Logger nil receiver panic InfoContext nil pointer method behavior

πŸ’‘ Result:

Calling methods on a nil *slog.Logger will cause a runtime panic [1]. In Go, calling a method on a nil pointer is technically permitted by the language specification [2][3], but it only succeeds if the method does not dereference the receiver [4][3]. The methods of *slog.Logger (such as Info, InfoContext, Log, etc.) dereference the receiver to access its underlying handler [5][6][7]. Because these methods internally invoke l.Handler or otherwise access the Logger's fields, calling them on a nil *slog.Logger results in a nil pointer dereference panic [1]. Regarding nil contexts passed to methods like InfoContext: - Passing a nil context.Context to slog methods is discouraged [8][9], even though it is technically permitted by the function signatures [10]. - Internally, slog handles nil contexts by converting them into context.Background before performing any operations [6][7][9]. This behavior is designed for resilience [8], but developers are still advised to provide a valid context whenever one is available to ensure proper tracing and cancellation propagation [10][11]. In summary, while you should not pass a nil context, the slog package handles that gracefully [6][7][9]; however, a nil *slog.Logger itself is not usable and will cause a crash if any logging method is called on it [1].

Citations:


Handle typed-nil public loggers before wrapping them. A nil *slog.Logger can still satisfy Logger as a non-nil interface value, so toObsLogger will wrap it and the first Log call will panic. Add a typed-nil regression test alongside the existing nil case.

πŸ“ Affects 2 files
  • logger.go#L38-L43 (this comment)
  • logger_test.go#L39-L48
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@logger.go` around lines 38 - 43, The toObsLogger function must detect
typed-nil Logger implementations before creating obsLoggerAdapter; add the
appropriate typed-nil check while preserving the existing plain-nil behavior. In
logger_test.go, extend the existing nil logger coverage with a regression test
using a typed-nil *slog.Logger and verify it produces the no-op logger without
panicking.

}

// fieldsToArgs converts lib-observability Fields into slog-style key/value
// args, qualifying keys with the active group prefix.
func fieldsToArgs(prefix string, fields []log.Field) []any {
out := make([]any, 0, len(fields)*2)

for _, f := range fields {
key := f.Key
if prefix != "" {
key = prefix + "." + f.Key
}

out = append(out, key, f.Value)
}

return out
}

func (a *obsLoggerAdapter) Log(ctx context.Context, level log.Level, msg string, fields ...log.Field) {
prefix := strings.Join(a.groups, ".")
args := append(append([]any{}, a.attrs...), fieldsToArgs(prefix, fields)...)

switch level {
case log.LevelError:
a.l.ErrorContext(ctx, msg, args...)
case log.LevelWarn:
a.l.WarnContext(ctx, msg, args...)
case log.LevelDebug:
a.l.DebugContext(ctx, msg, args...)
default: // LevelInfo and any unknown level default to info.
a.l.InfoContext(ctx, msg, args...)
}
}

func (a *obsLoggerAdapter) With(fields ...log.Field) log.Logger {
prefix := strings.Join(a.groups, ".")

return &obsLoggerAdapter{
l: a.l,
attrs: append(append([]any{}, a.attrs...), fieldsToArgs(prefix, fields)...),
groups: a.groups,
}
}

func (a *obsLoggerAdapter) WithGroup(name string) log.Logger {
if name == "" {
return a
}

return &obsLoggerAdapter{
l: a.l,
attrs: append([]any{}, a.attrs...),
groups: append(append([]string{}, a.groups...), name),
}
}

// Enabled always reports true: level filtering is delegated to the underlying
// slog-compatible logger supplied by the consumer.
func (a *obsLoggerAdapter) Enabled(_ log.Level) bool { return true }

// Sync is a no-op: the underlying slog-compatible logger manages its own
// flushing, and the libsd.Logger contract does not expose a sync hook.
func (a *obsLoggerAdapter) Sync(_ context.Context) error { return nil }
96 changes: 96 additions & 0 deletions logger_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//go:build unit

package libsd

import (
"context"
"testing"

"github.com/LerianStudio/lib-observability/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// recordingLogger is a libsd.Logger that captures the level, message and args
// of each call, so the adapter's level mapping and field translation can be
// asserted.
type recordingLogger struct {
level string
msg string
args []any
}

func (r *recordingLogger) InfoContext(_ context.Context, msg string, args ...any) {
r.level, r.msg, r.args = "info", msg, args
}

func (r *recordingLogger) WarnContext(_ context.Context, msg string, args ...any) {
r.level, r.msg, r.args = "warn", msg, args
}

func (r *recordingLogger) ErrorContext(_ context.Context, msg string, args ...any) {
r.level, r.msg, r.args = "error", msg, args
}

func (r *recordingLogger) DebugContext(_ context.Context, msg string, args ...any) {
r.level, r.msg, r.args = "debug", msg, args
}

func TestToObsLogger_NilYieldsNop(t *testing.T) {
t.Parallel()

// A nil public logger must produce a usable, silent internal logger.
got := toObsLogger(nil)
require.NotNil(t, got)
assert.NotPanics(t, func() {
got.Log(context.Background(), log.LevelInfo, "silent")
})
}

func TestObsLoggerAdapter_LevelMapping(t *testing.T) {
t.Parallel()

cases := []struct {
level log.Level
want string
}{
{log.LevelError, "error"},
{log.LevelWarn, "warn"},
{log.LevelInfo, "info"},
{log.LevelDebug, "debug"},
{log.LevelUnknown, "info"}, // unknown levels default to info
}

for _, tc := range cases {
rec := &recordingLogger{}
toObsLogger(rec).Log(context.Background(), tc.level, "msg")
assert.Equal(t, tc.want, rec.level, "level %v", tc.level)
}
}

func TestObsLoggerAdapter_FieldsBecomeSlogArgs(t *testing.T) {
t.Parallel()

rec := &recordingLogger{}
toObsLogger(rec).Log(context.Background(), log.LevelInfo, "resolved",
log.String("service", "svc-a"), log.Int("count", 3))

assert.Equal(t, "resolved", rec.msg)
assert.Equal(t, []any{"service", "svc-a", "count", 3}, rec.args)
}

func TestObsLoggerAdapter_WithAndWithGroup(t *testing.T) {
t.Parallel()

rec := &recordingLogger{}

l := toObsLogger(rec).
With(log.String("base", "b")).
WithGroup("grp")
l.Log(context.Background(), log.LevelWarn, "m", log.String("k", "v"))

// Accumulated With attrs come first; grouped keys are dotted.
assert.Equal(t, []any{"base", "b", "grp.k", "v"}, rec.args)
assert.True(t, l.Enabled(log.LevelDebug))
assert.NoError(t, l.Sync(context.Background()))
}
9 changes: 9 additions & 0 deletions logger_testhelpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build unit || integration

package libsd

import "log/slog"

// nopLogger returns a silent, slog-compatible libsd.Logger for tests that do
// not assert on emitted messages. Shared by the unit and integration suites.
func nopLogger() Logger { return slog.New(slog.DiscardHandler) }
7 changes: 3 additions & 4 deletions managed_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"testing"
"time"

"github.com/LerianStudio/lib-observability/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -160,7 +159,7 @@ func TestManagedResolver_CloseDuringInFlightSeedDoesNotLeak(t *testing.T) {
Enabled: true,
ConsulAddr: "localhost:8500",
AdvertiseAddr: "127.0.0.1",
Logger: log.NewNop(),
Logger: nopLogger(),
}, WithRegistry(reg))
require.NoError(t, err)

Expand Down Expand Up @@ -239,7 +238,7 @@ func TestManagedResolver_ResolveAfterCloseDoesNotLeakWatcher(t *testing.T) {
Enabled: true,
ConsulAddr: "localhost:8500",
AdvertiseAddr: "127.0.0.1",
Logger: log.NewNop(),
Logger: nopLogger(),
}, WithRegistry(reg))
require.NoError(t, err)

Expand Down Expand Up @@ -558,7 +557,7 @@ func TestManagedResolver_CloseStopsWatchers(t *testing.T) {
Enabled: true,
ConsulAddr: "localhost:8500",
AdvertiseAddr: "127.0.0.1",
Logger: log.NewNop(),
Logger: nopLogger(),
}, WithRegistry(reg))
require.NoError(t, err)

Expand Down
9 changes: 5 additions & 4 deletions manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,15 @@ type Manager struct {
type Option func(*Manager)

// WithLogger sets the structured logger used by the Manager and its registry.
// A nil logger is silently ignored; the Config.Logger (or log.NewNop()) is used instead.
func WithLogger(l log.Logger) Option {
// It accepts any slog-compatible libsd.Logger (e.g. *slog.Logger). A nil logger
// is silently ignored; the Config.Logger (or a silent no-op) is used instead.
func WithLogger(l Logger) Option {
return func(m *Manager) {
if m == nil || l == nil {
return
}

m.logger = l
m.logger = toObsLogger(l)
}
}

Expand Down Expand Up @@ -125,7 +126,7 @@ func New(cfg Config, opts ...Option) (*Manager, error) {

m := &Manager{
config: cfg,
logger: cfg.Logger,
logger: toObsLogger(cfg.Logger),
workload: cfg.Workload,
preferView: cfg.PreferView,
seedTimeout: cfg.SeedTimeout,
Expand Down
Loading
Loading