diff --git a/README.md b/README.md index 588c5ad..089c75c 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,16 @@ 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 structured-logging interface the library needs. It mirrors + the context-aware methods of the stdlib `*slog.Logger` + (`InfoContext`/`WarnContext`/`ErrorContext`/`DebugContext`), so any structured + logger can be injected without coupling the consumer to a specific + lib-observability version. Use `libsd.NewNopLogger()` to disable logging. **Functional options:** ```go -libsd.WithLogger(logger) // inject a lib-commons log.Logger +libsd.WithLogger(logger) // inject any logger implementing libsd.Logger (e.g. *slog.Logger) ``` ## Usage diff --git a/close_test.go b/close_test.go index 4f4218d..e0bdd88 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: NewNopLogger()}) require.NoError(t, err) assert.NotPanics(t, func() { diff --git a/config.go b/config.go index 2530e61..949b44c 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 + // Any structured logger satisfying the minimal Logger interface is accepted + // (for example *slog.Logger). Defaults to a no-op logger when nil. + Logger Logger } // ConfigFromEnv returns a Config populated from environment variables. @@ -251,7 +250,7 @@ func (c Config) Validate() error { func (c Config) withDefaults() Config { if c.Logger == nil { - c.Logger = log.NewNop() + c.Logger = NewNopLogger() } if c.ConsulAddr == "" { diff --git a/consul_dual_integration_test.go b/consul_dual_integration_test.go index 2b0709c..05ec6b3 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: NewNopLogger(), }) 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: NewNopLogger(), }) require.NoError(t, err) diff --git a/consul_integration_test.go b/consul_integration_test.go index 70de77d..68430d3 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: NewNopLogger(), }) 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: NewNopLogger(), }) require.NoError(t, err) t.Cleanup(func() { _ = m.Close() }) diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..ae9afb4 --- /dev/null +++ b/logger.go @@ -0,0 +1,107 @@ +package libsd + +import ( + "context" + + "github.com/LerianStudio/lib-observability/log" +) + +// Logger is the minimal structured-logging interface required by +// lib-service-discovery. +// +// It intentionally mirrors the context-aware methods of the standard library's +// *slog.Logger, so a consumer can supply any structured logger — including +// *slog.Logger — without being forced to import and version-match a specific +// logging library. This keeps the lib-observability logging types out of the +// public API and avoids the type incompatibilities that arise when a consumer +// pins a different lib-observability version than this library does. +// +// Any value implementing these four methods satisfies the interface via Go's +// structural typing, so no concrete adapter type is required on the caller side. +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) +} + +// nopLogger is a Logger that discards everything. +type nopLogger struct{} + +func (nopLogger) InfoContext(context.Context, string, ...any) {} +func (nopLogger) WarnContext(context.Context, string, ...any) {} +func (nopLogger) ErrorContext(context.Context, string, ...any) {} +func (nopLogger) DebugContext(context.Context, string, ...any) {} + +// NewNopLogger returns a Logger that discards all output. It is the default used +// when no logger is supplied via Config.Logger or WithLogger. +func NewNopLogger() Logger { return nopLogger{} } + +// observabilityAdapter adapts a public Logger to the internal lib-observability +// log.Logger consumed by the registry and resolvers. It lets the internal +// logging call sites keep using the log.Log(ctx, level, msg, fields...) style +// while the public API only ever exposes the version-agnostic Logger interface. +type observabilityAdapter struct { + inner Logger + fields []log.Field +} + +// toInternalLogger wraps a public Logger in the internal log.Logger contract. +// A nil Logger yields a lib-observability no-op logger. +func toInternalLogger(l Logger) log.Logger { + if l == nil { + return log.NewNop() + } + + return &observabilityAdapter{inner: l} +} + +// Log routes an internal log event to the appropriate context-aware method of +// the wrapped Logger, flattening the typed fields into slog-style key/value +// arguments. +func (a *observabilityAdapter) Log(ctx context.Context, level log.Level, msg string, fields ...log.Field) { + all := fields + if len(a.fields) > 0 { + all = make([]log.Field, 0, len(a.fields)+len(fields)) + all = append(all, a.fields...) + all = append(all, fields...) + } + + args := make([]any, 0, len(all)*2) + for _, f := range all { + args = append(args, f.Key, f.Value) + } + + switch level { + case log.LevelError: + a.inner.ErrorContext(ctx, msg, args...) + case log.LevelWarn: + a.inner.WarnContext(ctx, msg, args...) + case log.LevelDebug: + a.inner.DebugContext(ctx, msg, args...) + default: // LevelInfo and any unknown level map to Info. + a.inner.InfoContext(ctx, msg, args...) + } +} + +// With returns a child adapter carrying additional persistent fields. +// +//nolint:ireturn // must satisfy the lib-observability log.Logger contract. +func (a *observabilityAdapter) With(fields ...log.Field) log.Logger { + merged := make([]log.Field, 0, len(a.fields)+len(fields)) + merged = append(merged, a.fields...) + merged = append(merged, fields...) + + return &observabilityAdapter{inner: a.inner, fields: merged} +} + +// WithGroup is a no-op: the public Logger interface has no grouping concept. +// +//nolint:ireturn // must satisfy the lib-observability log.Logger contract. +func (a *observabilityAdapter) WithGroup(_ string) log.Logger { return a } + +// Enabled always reports true and defers level filtering to the wrapped Logger. +func (a *observabilityAdapter) Enabled(_ log.Level) bool { return true } + +// Sync is a no-op: flushing is the responsibility of the wrapped Logger. +func (a *observabilityAdapter) Sync(_ context.Context) error { return nil } diff --git a/managed_resolver_test.go b/managed_resolver_test.go index 7c322d7..9f8aedf 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: NewNopLogger(), }, 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: NewNopLogger(), }, 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: NewNopLogger(), }, WithRegistry(reg)) require.NoError(t, err) diff --git a/manager.go b/manager.go index cdf8aa3..778e73a 100644 --- a/manager.go +++ b/manager.go @@ -72,14 +72,14 @@ 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 { +// A nil logger is silently ignored; the Config.Logger (or a no-op logger) is used instead. +func WithLogger(l Logger) Option { return func(m *Manager) { if m == nil || l == nil { return } - m.logger = l + m.logger = toInternalLogger(l) } } @@ -125,7 +125,7 @@ func New(cfg Config, opts ...Option) (*Manager, error) { m := &Manager{ config: cfg, - logger: cfg.Logger, + logger: toInternalLogger(cfg.Logger), workload: cfg.Workload, preferView: cfg.PreferView, seedTimeout: cfg.SeedTimeout, diff --git a/manager_test.go b/manager_test.go index 815d52e..6f4981f 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 Logger that records every message logged, 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: NewNopLogger(), }, 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: NewNopLogger(), }, 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: NewNopLogger(), }, 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: NewNopLogger(), }, 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: NewNopLogger(), }, WithRegistry(stub)) require.NoError(t, err) assert.Equal(t, stub, m.registry) @@ -201,11 +201,16 @@ func TestNew_WithRegistryNilIsIgnored(t *testing.T) { func TestNew_WithLoggerOption(t *testing.T) { t.Parallel() - nop := log.NewNop() + nop := NewNopLogger() m, err := New(Config{Enabled: false}, WithLogger(nop)) require.NoError(t, err) - assert.Equal(t, nop, m.logger) + + // WithLogger wraps the public Logger in the internal adapter, so the Manager's + // internal logger is the adapter carrying the supplied Logger. + adapter, ok := m.logger.(*observabilityAdapter) + require.True(t, ok, "expected internal logger to be an *observabilityAdapter") + assert.Equal(t, nop, adapter.inner) } func TestNew_NilOptionIsIgnored(t *testing.T) { @@ -952,7 +957,7 @@ func TestResolvePreferredEndpoint(t *testing.T) { ConsulAddr: "localhost:8500", AdvertiseAddr: "127.0.0.1", PreferView: tt.preferView, - Logger: log.NewNop(), + Logger: NewNopLogger(), }, WithRegistry(&stubRegistry{resolveResult: tt.resolveRes})) require.NoError(t, err) @@ -984,7 +989,7 @@ func internalOnlyManager(t *testing.T, onRegister func(Service)) *Manager { AdvertiseInternalAddr: "svc.ns.svc.cluster.local", AdvertiseInternalPort: 9090, AdvertiseInternalScheme: "http", - Logger: log.NewNop(), + Logger: NewNopLogger(), }, WithRegistry(&captureRegistry{onRegister: onRegister})) require.NoError(t, err) @@ -1214,7 +1219,7 @@ func TestResolvePreferredURL(t *testing.T) { ConsulAddr: "localhost:8500", AdvertiseAddr: "127.0.0.1", PreferView: view, - Logger: log.NewNop(), + Logger: NewNopLogger(), }, 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..19c84eb 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,17 @@ 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} + // lib-service-discovery accepts any structured logger implementing the + // minimal libsd.Logger interface, so the stdlib *slog.Logger works directly + // with no lib-observability dependency or adapter required. + logger := slog.New(slog.NewTextHandler(os.Stderr, &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 +77,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 +132,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)