From 20a4a330d6b180587bb5b244610838b2b04bb355 Mon Sep 17 00:00:00 2001 From: gandalf-at-lerian Date: Wed, 22 Jul 2026 11:37:41 -0300 Subject: [PATCH 1/2] feat!: decouple public logger from lib-observability Requested by: Guilherme Rodrigues (fixes #24 - logger abstraction leak) The public API leaked lib-observability's log.Logger through Config.Logger and WithLogger(), forcing consumers to pin the exact lib-observability version or hand-roll an adapter. - Add a minimal, stdlib-only, slog-compatible libsd.Logger interface (InfoContext/WarnContext/ErrorContext/DebugContext). - Switch Config.Logger and WithLogger() to libsd.Logger. - Add an internal obsLoggerAdapter bridging libsd.Logger -> lib-observability log.Logger, so no internal call site changes; lib-observability stays an implementation detail. - Remove lib-observability from the public API surface. - Update the demo, docs and tests to use *slog.Logger. BREAKING CHANGE: Config.Logger and WithLogger() now take libsd.Logger instead of lib-observability's log.Logger. A log.Logger no longer satisfies the API (it uses Log(ctx, level, msg, ...Field)); pass any slog-compatible logger (e.g. *slog.Logger) directly, with no lib-observability dependency. --- README.md | 11 +++- close_test.go | 2 +- config.go | 11 +--- consul_dual_integration_test.go | 5 +- consul_integration_test.go | 5 +- logger.go | 107 ++++++++++++++++++++++++++++++++ logger_test.go | 96 ++++++++++++++++++++++++++++ logger_testhelpers_test.go | 9 +++ managed_resolver_test.go | 7 +-- manager.go | 9 +-- manager_test.go | 40 ++++++------ services/main.go | 22 ++++--- 12 files changed, 272 insertions(+), 52 deletions(-) create mode 100644 logger.go create mode 100644 logger_test.go create mode 100644 logger_testhelpers_test.go diff --git a/README.md b/README.md index 588c5ad..bb5c610 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/close_test.go b/close_test.go index 4f4218d..21e9348 100644 --- a/close_test.go +++ b/close_test.go @@ -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() { diff --git a/config.go b/config.go index 2530e61..a920fae 100644 --- a/config.go +++ b/config.go @@ -7,8 +7,6 @@ import ( "strconv" "strings" "time" - - "github.com/LerianStudio/lib-observability/log" ) const ( @@ -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. @@ -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" } diff --git a/consul_dual_integration_test.go b/consul_dual_integration_test.go index 2b0709c..81f1806 100644 --- a/consul_dual_integration_test.go +++ b/consul_dual_integration_test.go @@ -10,7 +10,6 @@ import ( "testing" "time" - "github.com/LerianStudio/lib-observability/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -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) @@ -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) diff --git a/consul_integration_test.go b/consul_integration_test.go index 70de77d..f43b2af 100644 --- a/consul_integration_test.go +++ b/consul_integration_test.go @@ -11,7 +11,6 @@ import ( "testing" "time" - "github.com/LerianStudio/lib-observability/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -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) @@ -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() }) diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..22dd672 --- /dev/null +++ b/logger.go @@ -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} +} + +// 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 } diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..ca95bce --- /dev/null +++ b/logger_test.go @@ -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())) +} diff --git a/logger_testhelpers_test.go b/logger_testhelpers_test.go new file mode 100644 index 0000000..ab98ad5 --- /dev/null +++ b/logger_testhelpers_test.go @@ -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) } diff --git a/managed_resolver_test.go b/managed_resolver_test.go index 7c322d7..db577f4 100644 --- a/managed_resolver_test.go +++ b/managed_resolver_test.go @@ -12,7 +12,6 @@ import ( "testing" "time" - "github.com/LerianStudio/lib-observability/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -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) @@ -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) @@ -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) diff --git a/manager.go b/manager.go index cdf8aa3..294e722 100644 --- a/manager.go +++ b/manager.go @@ -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) } } @@ -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, diff --git a/manager_test.go b/manager_test.go index 815d52e..300dd43 100644 --- a/manager_test.go +++ b/manager_test.go @@ -8,29 +8,29 @@ import ( "sync" "testing" - "github.com/LerianStudio/lib-observability/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// captureLogger is a log.Logger that records every Log call, so tests can assert -// that a specific message (e.g. the internal-view degrade warning) was emitted. +// captureLogger is a libsd.Logger that records every logged message, so tests +// can assert that a specific message (e.g. the internal-view degrade warning) +// was emitted. type captureLogger struct { mu sync.Mutex msgs []string } -func (c *captureLogger) Log(_ context.Context, _ log.Level, msg string, _ ...log.Field) { +func (c *captureLogger) record(msg string) { c.mu.Lock() defer c.mu.Unlock() c.msgs = append(c.msgs, msg) } -func (c *captureLogger) With(_ ...log.Field) log.Logger { return c } -func (c *captureLogger) WithGroup(_ string) log.Logger { return c } -func (c *captureLogger) Enabled(_ log.Level) bool { return true } -func (c *captureLogger) Sync(_ context.Context) error { return nil } +func (c *captureLogger) InfoContext(_ context.Context, msg string, _ ...any) { c.record(msg) } +func (c *captureLogger) WarnContext(_ context.Context, msg string, _ ...any) { c.record(msg) } +func (c *captureLogger) ErrorContext(_ context.Context, msg string, _ ...any) { c.record(msg) } +func (c *captureLogger) DebugContext(_ context.Context, msg string, _ ...any) { c.record(msg) } func (c *captureLogger) has(msg string) bool { c.mu.Lock() @@ -80,7 +80,7 @@ func enabledManager(t *testing.T, reg Registry) *Manager { Enabled: true, ConsulAddr: "localhost:8500", AdvertiseAddr: "127.0.0.1", - Logger: log.NewNop(), + Logger: nopLogger(), }, WithRegistry(reg)) require.NoError(t, err) @@ -111,7 +111,7 @@ func TestNew_EnabledConsumerOnlySucceeds(t *testing.T) { m, err := New(Config{ Enabled: true, ConsulAddr: "localhost:8500", - Logger: log.NewNop(), + Logger: nopLogger(), }, WithRegistry(stub)) require.NoError(t, err) require.NotNil(t, m) @@ -135,7 +135,7 @@ func TestRegister_NoEndpointErrors(t *testing.T) { m, err := New(Config{ Enabled: true, ConsulAddr: "localhost:8500", - Logger: log.NewNop(), + Logger: nopLogger(), }, WithRegistry(&captureRegistry{onRegister: func(Service) { registered = true }})) require.NoError(t, err) @@ -155,7 +155,7 @@ func TestRegister_WithServicePortStillNeedsAdvertise(t *testing.T) { m, err := New(Config{ Enabled: true, ConsulAddr: "localhost:8500", - Logger: log.NewNop(), + Logger: nopLogger(), }, WithRegistry(&captureRegistry{})) require.NoError(t, err) @@ -184,7 +184,7 @@ func TestNew_WithRegistryOption(t *testing.T) { Enabled: true, ConsulAddr: "localhost:8500", AdvertiseAddr: "127.0.0.1", - Logger: log.NewNop(), + Logger: nopLogger(), }, WithRegistry(stub)) require.NoError(t, err) assert.Equal(t, stub, m.registry) @@ -201,11 +201,15 @@ func TestNew_WithRegistryNilIsIgnored(t *testing.T) { func TestNew_WithLoggerOption(t *testing.T) { t.Parallel() - nop := log.NewNop() + nop := nopLogger() m, err := New(Config{Enabled: false}, WithLogger(nop)) require.NoError(t, err) - assert.Equal(t, nop, m.logger) + // m.logger is the internal lib-observability adapter that bridges the + // public libsd.Logger; assert it wraps exactly the logger we passed. + adapter, ok := m.logger.(*obsLoggerAdapter) + require.True(t, ok) + assert.Equal(t, nop, adapter.l) } func TestNew_NilOptionIsIgnored(t *testing.T) { @@ -952,7 +956,7 @@ func TestResolvePreferredEndpoint(t *testing.T) { ConsulAddr: "localhost:8500", AdvertiseAddr: "127.0.0.1", PreferView: tt.preferView, - Logger: log.NewNop(), + Logger: nopLogger(), }, WithRegistry(&stubRegistry{resolveResult: tt.resolveRes})) require.NoError(t, err) @@ -984,7 +988,7 @@ func internalOnlyManager(t *testing.T, onRegister func(Service)) *Manager { AdvertiseInternalAddr: "svc.ns.svc.cluster.local", AdvertiseInternalPort: 9090, AdvertiseInternalScheme: "http", - Logger: log.NewNop(), + Logger: nopLogger(), }, WithRegistry(&captureRegistry{onRegister: onRegister})) require.NoError(t, err) @@ -1214,7 +1218,7 @@ func TestResolvePreferredURL(t *testing.T) { ConsulAddr: "localhost:8500", AdvertiseAddr: "127.0.0.1", PreferView: view, - Logger: log.NewNop(), + Logger: nopLogger(), }, WithRegistry(&stubRegistry{resolveResult: intSvc})) require.NoError(t, err) t.Cleanup(func() { _ = m.Close() }) diff --git a/services/main.go b/services/main.go index 721312f..dda785d 100644 --- a/services/main.go +++ b/services/main.go @@ -19,13 +19,13 @@ import ( "context" "fmt" "io" + "log/slog" "net/http" "os" "os/signal" "syscall" "time" - "github.com/LerianStudio/lib-observability/log" libsd "github.com/LerianStudio/lib-service-discovery" ) @@ -38,14 +38,16 @@ func main() { // and callers resolving its internal endpoint could not reach it. listen := ":" + getenv("SD_ADVERTISE_PORT", getenv("SD_INTERNAL_PORT", "8080")) - logger := &log.GoLogger{Level: log.LevelDebug} + // Any slog-compatible logger satisfies libsd.Logger — no lib-observability + // dependency required on the consumer side. + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) ctx := context.Background() cfg := libsd.ConfigFromEnv() sd, err := libsd.New(cfg, libsd.WithLogger(logger)) if err != nil { - logger.Log(ctx, log.LevelError, "init service discovery", log.Err(err)) + logger.ErrorContext(ctx, "init service discovery", "error", err) os.Exit(1) } @@ -74,8 +76,8 @@ func main() { if next != "" { resolver, err = sd.WatchResolve(ctx, next, "" /* fallback */) if err != nil { - logger.Log(ctx, log.LevelError, "start dynamic resolver", - log.String("next", next), log.Err(err)) + logger.ErrorContext(ctx, "start dynamic resolver", + "next", next, "error", err) } } @@ -129,14 +131,14 @@ func main() { go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Log(ctx, log.LevelError, "http server", log.Err(err)) + logger.ErrorContext(ctx, "http server", "error", err) } }() - logger.Log(ctx, log.LevelInfo, "demo service up", - log.String("name", name), - log.String("listen", listen), - log.String("next", next)) + logger.InfoContext(ctx, "demo service up", + "name", name, + "listen", listen, + "next", next) // Graceful shutdown: deregister so the instance leaves the catalog cleanly. stop := make(chan os.Signal, 1) From 31b097e04d2a68a9a3d53f595d134edc1fda8b43 Mon Sep 17 00:00:00 2001 From: gandalf-at-lerian Date: Wed, 22 Jul 2026 14:15:10 -0300 Subject: [PATCH 2/2] docs(changelog): note breaking logger decoupling from lib-observability (#24) --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c33dd04..3335e0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`).