Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,18 @@ Improvements:
but no endpoint (external or internal) is configured.

### Changed
- **BREAKING β€” public logger decoupled from lib-observability (#24)**:
`Config.Logger` and `WithLogger()` now take the new stdlib-only
`libsd.Logger` interface (`InfoContext`/`WarnContext`/`ErrorContext`/
`DebugContext(ctx, msg, ...any)`) instead of lib-observability's
`log.Logger`. Any `slog`-compatible logger β€” including the stdlib
`*slog.Logger` (e.g. `slog.Default()`) β€” satisfies it directly.
**Migration**: replace the `log.Logger` you pass to `WithLogger()` /
`Config.Logger` with a `*slog.Logger`. Consumers no longer need
lib-observability as a direct dependency, and no adapter wrapper is
required on the caller side. (lib-observability remains an internal
implementation detail of this lib via a private adapter and is not part
of the public API.)
- **BREAKING β€” `Service.EndpointFor` signature**: now returns `(Endpoint, error)`
instead of a single `Endpoint`. Callers must handle the error
(`ErrEndpointViewUnavailable`).
Expand Down
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
Comment on lines +172 to +174

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 | 🟠 Major | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Files ==\n'
git ls-files | rg '(^|/)(config\.go|logger\.go|.*test.*\.go)$'

printf '\n== AST outline: config.go ==\n'
ast-grep outline config.go --view expanded || true

printf '\n== AST outline: logger.go ==\n'
ast-grep outline logger.go --view expanded || true

printf '\n== Relevant symbols/text ==\n'
rg -n "toObsLogger|withDefaults|type Logger|Logger interface|InfoContext|slog.Logger|nil Logger|silences output|New\\(" config.go logger.go . -g '!**/vendor/**'

printf '\n== config.go excerpt ==\n'
sed -n '1,260p' config.go

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

Repository: LerianStudio/lib-service-discovery

Length of output: 32276


🏁 Script executed:

#!/bin/bash
set -euo pipefail
printf '\n== Files ==\n'
git ls-files | rg '(^|/)(config\.go|logger\.go|.*test.*\.go)$'
printf '\n== config.go / logger.go symbols ==\n'
rg -n "toObsLogger|withDefaults|type Logger|InfoContext|slog.Logger|nil Logger|silences output|New\\(" config.go logger.go . -g '!**/vendor/**'
printf '\n== config.go excerpt ==\n'
sed -n '1,260p' config.go
printf '\n== logger.go excerpt ==\n'
sed -n '1,260p' logger.go

Repository: LerianStudio/lib-service-discovery

Length of output: 29783


Handle typed-nil loggers as nil. toObsLogger only treats a nil interface as silent; a var l *slog.Logger = nil still wraps and can panic on the first log call because slog.Logger methods dereference the receiver. Handle the underlying nil case here and add a regression test.

πŸ€– 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 `@config.go` around lines 172 - 174, Update toObsLogger to detect both a nil
Logger interface and an interface containing a typed-nil logger, returning the
silent logger behavior in either case before wrapping or invoking methods. Add a
regression test using a nil *slog.Logger assigned to Logger and verify logging
does not panic or emit output.

}

// 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 | 🟠 Major | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant files and inspect the surrounding code.
git ls-files | rg '(^|/)(logger\.go|manager\.go|.*logger.*\.go)$'

echo '--- logger.go ---'
wc -l logger.go
cat -n logger.go | sed -n '1,220p'

echo '--- manager.go ---'
wc -l manager.go
cat -n manager.go | sed -n '1,220p'

Repository: LerianStudio/lib-service-discovery

Length of output: 14102


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the Logger interface and any typed-nil-related helper usages.
rg -n --hidden --glob '!**/vendor/**' 'type Logger interface|toObsLogger|WithLogger|NewNop|obsLoggerAdapter|slog\.Logger' .

Repository: LerianStudio/lib-service-discovery

Length of output: 3467


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# If the interface is defined elsewhere, inspect just that file and any adapter implementation.
files=$(rg -l --hidden --glob '!**/vendor/**' 'type Logger interface|obsLoggerAdapter|toObsLogger' . | sort -u)
printf '%s\n' "$files"

for f in $files; do
  echo "--- $f ---"
  wc -l "$f"
  # Print only the relevant slices with line numbers.
  cat -n "$f" | sed -n '1,220p'
done

Repository: LerianStudio/lib-service-discovery

Length of output: 25136


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Behavioral probe: determine whether a typed-nil implementation stored in the Logger interface
# compares equal to nil and whether the adapter receives a non-nil interface.
python3 - <<'PY'
print("pending repository inspection")
PY

Repository: LerianStudio/lib-service-discovery

Length of output: 202


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat >/tmp/typed_nil_probe.go <<'EOF'
package main

import "fmt"

type Logger interface {
	InfoContext()
}

type impl struct{}

func (*impl) InfoContext() {
	fmt.Println("called")
}

func main() {
	var p *impl = nil
	var l Logger = p

	fmt.Printf("interface == nil: %v\n", l == nil)
	fmt.Printf("dynamic type: %T\n", l)

	defer func() {
		if r := recover(); r != nil {
			fmt.Printf("panic: %v\n", r)
		}
	}()

	l.InfoContext()
}
EOF

go run /tmp/typed_nil_probe.go

Repository: LerianStudio/lib-service-discovery

Length of output: 228


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the Config.Logger contract and any nil-related documentation.
rg -n --hidden --glob '!**/vendor/**' 'Logger\s+Logger|Config\.Logger|nil Logger|WithLogger\(' config.go README.md CHANGELOG.md logger.go manager.go

Repository: LerianStudio/lib-service-discovery

Length of output: 1002


Handle typed-nil Logger values before wrapping them.

l == nil only catches an untyped nil; a typed-nil Logger can still be wrapped and will panic on the first log call.

  • logger.go#L38-L43: add a nil-aware guard in toObsLogger.
  • manager.go#L77-L83 and manager.go#L127-L130: route both WithLogger and New through the same guard.
πŸ“ Affects 2 files
  • logger.go#L38-L43 (this comment)
  • manager.go#L75-L83
πŸ€– 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 nil check does not handle
typed-nil Logger values, and manager construction paths must consistently use
that guard. Update toObsLogger to perform a nil-aware check before creating
obsLoggerAdapter, then route both WithLogger and New in manager.go through
toObsLogger; apply the changes at logger.go lines 38-43, manager.go lines 75-83,
and manager.go lines 127-130.

}

// 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
Loading
Loading