From 8c08b73ca76742becb19f11652bbf4bd0737f543 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Bronisz Date: Thu, 30 Jul 2026 15:16:36 +0200 Subject: [PATCH] feat(go): abstract logging behind an interface to avoid forcing slog on callers Replace ad-hoc logging with a consistent Logger interface (Debug/Info/Warn/Error) at the top-level package, and narrower subsets in internal/flag_logger and internal/local_resolver. A no-op default is used when nil is provided. --- .../go/confidence/flag_logger_e2e_test.go | 28 +---- .../go/confidence/flag_logs_test.go | 9 +- .../inmemory_materialization_store.go | 9 +- .../go/confidence/integration_test.go | 53 +++++---- .../confidence/internal/flag_logger/grpc.go | 8 +- .../internal/flag_logger/grpc_test.go | 36 +++--- .../confidence/internal/flag_logger/logger.go | 14 +++ .../internal/flag_logger/logger_test.go | 80 +++++++++++++ .../local_resolver/default_resolver_test.go | 6 +- .../internal/local_resolver/local_resolver.go | 12 +- .../internal/local_resolver/logger.go | 18 +++ .../internal/local_resolver/logger_test.go | 47 ++++++++ .../internal/local_resolver/recover.go | 15 ++- .../internal/local_resolver/recover_test.go | 2 +- .../internal/local_resolver/wasm.go | 21 ++-- .../local_resolver/wasm_memory_test.go | 2 +- openfeature-provider/go/confidence/logger.go | 28 +++++ .../go/confidence/logger_test.go | 110 ++++++++++++++++++ .../go/confidence/provider.go | 13 +-- .../go/confidence/provider_builder.go | 23 ++-- .../go/confidence/provider_resolve_test.go | 31 +++-- .../go/confidence/retry_test.go | 34 +++--- .../go/confidence/state_fetcher.go | 9 +- .../go/confidence/state_fetcher_test.go | 23 ++-- 24 files changed, 446 insertions(+), 185 deletions(-) create mode 100644 openfeature-provider/go/confidence/internal/flag_logger/logger.go create mode 100644 openfeature-provider/go/confidence/internal/flag_logger/logger_test.go create mode 100644 openfeature-provider/go/confidence/internal/local_resolver/logger.go create mode 100644 openfeature-provider/go/confidence/internal/local_resolver/logger_test.go create mode 100644 openfeature-provider/go/confidence/logger.go create mode 100644 openfeature-provider/go/confidence/logger_test.go diff --git a/openfeature-provider/go/confidence/flag_logger_e2e_test.go b/openfeature-provider/go/confidence/flag_logger_e2e_test.go index 765b79a4..b0819177 100644 --- a/openfeature-provider/go/confidence/flag_logger_e2e_test.go +++ b/openfeature-provider/go/confidence/flag_logger_e2e_test.go @@ -2,10 +2,8 @@ package confidence import ( "context" - "log/slog" "os" "strings" - "sync" "testing" "time" @@ -24,10 +22,8 @@ const flagLogsTargetingKey = "test-a" func TestFlagLogs_ShouldSuccessfullySendToRealBackend(t *testing.T) { ctx := context.Background() - // Create a custom logger that captures log messages (Debug level to capture all logs) - var logBuffer logCaptureBuffer - captureHandler := slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}) - logger := slog.New(captureHandler) + // Create a custom logger that captures log messages + logger := newRecorderLogger() // Create a real provider with real gRPC connection provider, err := NewProvider(ctx, ProviderConfig{ @@ -64,7 +60,7 @@ func TestFlagLogs_ShouldSuccessfullySendToRealBackend(t *testing.T) { time.Sleep(200 * time.Millisecond) // Verify the logs contain success message and no errors - logs := logBuffer.String() + logs := logger.String() if strings.Contains(logs, "Failed to write flag logs") { t.Errorf("Backend returned error - found 'Failed to write flag logs' in logs:\n%s", logs) } @@ -74,21 +70,3 @@ func TestFlagLogs_ShouldSuccessfullySendToRealBackend(t *testing.T) { t.Log("Successfully sent WriteFlagLogs to real Confidence backend") } - -// logCaptureBuffer is a thread-safe buffer for capturing log output -type logCaptureBuffer struct { - mu sync.Mutex - buf strings.Builder -} - -func (b *logCaptureBuffer) Write(p []byte) (n int, err error) { - b.mu.Lock() - defer b.mu.Unlock() - return b.buf.Write(p) -} - -func (b *logCaptureBuffer) String() string { - b.mu.Lock() - defer b.mu.Unlock() - return b.buf.String() -} diff --git a/openfeature-provider/go/confidence/flag_logs_test.go b/openfeature-provider/go/confidence/flag_logs_test.go index 10119ae5..9d6c6f9d 100644 --- a/openfeature-provider/go/confidence/flag_logs_test.go +++ b/openfeature-provider/go/confidence/flag_logs_test.go @@ -2,17 +2,16 @@ package confidence import ( "context" - "log/slog" "os" "strings" "testing" "time" + "github.com/open-feature/go-sdk/openfeature" + fl "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/flag_logger" lr "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/local_resolver" resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" - - "github.com/open-feature/go-sdk/openfeature" ) // Unit tests that verify WriteFlagLogs contains correct flag assignment data. @@ -37,7 +36,7 @@ func setupFlagLogsUnitTest(t *testing.T) (*fl.CapturingFlagLogger, openfeature.I capturingLogger := fl.NewCapturingFlagLogger() // Create state provider that fetches from real Confidence service - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + logger := newLoggerForTest(t) stateProvider := NewFlagsAdminStateFetcher(unitTestClientSecret, logger) // Fetch initial state @@ -49,7 +48,7 @@ func setupFlagLogsUnitTest(t *testing.T) (*fl.CapturingFlagLogger, openfeature.I unsupportedMatStore := newUnsupportedMaterializationStore() resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) + return lr.NewLocalResolverWithPoolSize(ctx, logSink, logger, 2) }, unsupportedMatStore) provider := NewLocalResolverProvider(resolverSupplier, stateProvider, capturingLogger, unitTestClientSecret, logger) diff --git a/openfeature-provider/go/confidence/inmemory_materialization_store.go b/openfeature-provider/go/confidence/inmemory_materialization_store.go index bf2a1abc..72fa90be 100644 --- a/openfeature-provider/go/confidence/inmemory_materialization_store.go +++ b/openfeature-provider/go/confidence/inmemory_materialization_store.go @@ -2,7 +2,6 @@ package confidence import ( "context" - "log/slog" "sync" ) @@ -30,7 +29,7 @@ type inMemoryMaterializationStore struct { // storage: unit -> materialization -> data storage map[string]map[string]*materializationData mu sync.RWMutex - logger *slog.Logger + logger Logger // call tracking for tests readCalls [][]ReadOp writeCalls [][]WriteOp @@ -42,9 +41,9 @@ type materializationData struct { } // newInMemoryMaterializationStore creates a new in-memory materialization store. -func newInMemoryMaterializationStore(logger *slog.Logger) *inMemoryMaterializationStore { +func newInMemoryMaterializationStore(logger Logger) *inMemoryMaterializationStore { if logger == nil { - logger = slog.Default() + logger = &noopLogger{} } return &inMemoryMaterializationStore{ storage: make(map[string]map[string]*materializationData), @@ -55,7 +54,7 @@ func newInMemoryMaterializationStore(logger *slog.Logger) *inMemoryMaterializati // newInMemoryMaterializationStoreWithInclusions creates a store pre-populated with inclusion data. // This is useful for testing materialized segment criterion evaluation. // The initialInclusions map structure is: unit -> materialization -> included -func newInMemoryMaterializationStoreWithInclusions(logger *slog.Logger, initialInclusions map[string]map[string]bool) *inMemoryMaterializationStore { +func newInMemoryMaterializationStoreWithInclusions(logger Logger, initialInclusions map[string]map[string]bool) *inMemoryMaterializationStore { store := newInMemoryMaterializationStore(logger) store.mu.Lock() diff --git a/openfeature-provider/go/confidence/integration_test.go b/openfeature-provider/go/confidence/integration_test.go index 93c46ef5..9f3c9604 100644 --- a/openfeature-provider/go/confidence/integration_test.go +++ b/openfeature-provider/go/confidence/integration_test.go @@ -2,8 +2,6 @@ package confidence import ( "context" - "log/slog" - "os" "strings" "sync" "sync/atomic" @@ -13,11 +11,12 @@ import ( "github.com/open-feature/go-sdk/openfeature" "github.com/prometheus/common/expfmt" "github.com/prometheus/common/model" + "google.golang.org/grpc" + fl "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/flag_logger" lr "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/local_resolver" resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" tu "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/testutil" - "google.golang.org/grpc" ) // mockStateProvider provides test state for integration testing @@ -100,7 +99,6 @@ func (m *mockGrpcStubForIntegration) GetCallsReceived() int32 { func TestIntegration_OpenFeatureShutdownFlushesLogs(t *testing.T) { // Load test state testState := tu.LoadTestResolverState(t) - accountID := tu.LoadTestAccountID(t) ctx := context.Background() @@ -113,7 +111,7 @@ func TestIntegration_OpenFeatureShutdownFlushesLogs(t *testing.T) { mockStub := &mockGrpcStubForIntegration{ onCallReceived: make(chan struct{}, 100), // Buffer to prevent blocking } - actualGrpcLogger := fl.NewGrpcWasmFlagLogger(mockStub, "test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + actualGrpcLogger := fl.NewGrpcWasmFlagLogger(mockStub, "test-client-secret", newLoggerForTest(t)) trackingLogger := &trackingFlagLogger{ actualLogger: actualGrpcLogger, @@ -121,7 +119,7 @@ func TestIntegration_OpenFeatureShutdownFlushesLogs(t *testing.T) { } // Create provider with test state - provider, err := createProviderWithTestState(ctx, stateProvider, accountID, trackingLogger, newUnsupportedMaterializationStore()) + provider, err := createProviderWithTestState(t, stateProvider, trackingLogger, newUnsupportedMaterializationStore()) if err != nil { t.Fatalf("Failed to create provider: %v", err) } @@ -197,7 +195,7 @@ func TestIntegration_OpenFeatureResolveStickyFlagMatStoreReadAndWrite(t *testing mockStub := &mockGrpcStubForIntegration{ onCallReceived: make(chan struct{}, 100), // Buffer to prevent blocking } - actualGrpcLogger := fl.NewGrpcWasmFlagLogger(mockStub, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + actualGrpcLogger := fl.NewGrpcWasmFlagLogger(mockStub, "test-secret", newLoggerForTest(t)) trackingLogger := &trackingFlagLogger{ actualLogger: actualGrpcLogger, @@ -205,11 +203,11 @@ func TestIntegration_OpenFeatureResolveStickyFlagMatStoreReadAndWrite(t *testing } matStore := newInMemoryMaterializationStore(nil) resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) + return lr.NewLocalResolverWithPoolSize(ctx, logSink, newLoggerForTest(t), 2) }, matStore) // Create provider with test state - provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, "test-secret", newLoggerForTest(t)) client := openfeature.NewClient("integration-test") @@ -320,7 +318,7 @@ func TestIntegration_OpenFeatureMaterializedSegmentCriterion(t *testing.T) { mockStub := &mockGrpcStubForIntegration{ onCallReceived: make(chan struct{}, 100), } - actualGrpcLogger := fl.NewGrpcWasmFlagLogger(mockStub, SECRET, slog.New(slog.NewTextHandler(os.Stderr, nil))) + actualGrpcLogger := fl.NewGrpcWasmFlagLogger(mockStub, SECRET, newLoggerForTest(t)) trackingLogger := &trackingFlagLogger{ actualLogger: actualGrpcLogger, @@ -336,11 +334,11 @@ func TestIntegration_OpenFeatureMaterializedSegmentCriterion(t *testing.T) { } matStore := newInMemoryMaterializationStoreWithInclusions(nil, initialInclusions) resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) + return lr.NewLocalResolverWithPoolSize(ctx, logSink, newLoggerForTest(t), 2) }, matStore) // Create provider with test state - provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, SECRET, slog.New(slog.NewTextHandler(os.Stderr, nil))) + provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, SECRET, newLoggerForTest(t)) client := openfeature.NewClient("integration-test-mat-seg") @@ -398,11 +396,11 @@ func TestIntegration_OpenFeatureMaterializedSegmentCriterion(t *testing.T) { } matStore := newInMemoryMaterializationStoreWithInclusions(nil, initialInclusions) resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) + return lr.NewLocalResolverWithPoolSize(ctx, logSink, newLoggerForTest(t), 2) }, matStore) // Create provider with test state - provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, SECRET, slog.New(slog.NewTextHandler(os.Stderr, nil))) + provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, SECRET, newLoggerForTest(t)) client := openfeature.NewClient("integration-test-mat-seg-not-in") @@ -439,11 +437,11 @@ func TestIntegration_OpenFeatureMaterializedSegmentCriterion(t *testing.T) { // Create empty materialization store (no context for tutorial_visitor) matStore := newInMemoryMaterializationStore(nil) resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) + return lr.NewLocalResolverWithPoolSize(ctx, logSink, newLoggerForTest(t), 2) }, matStore) // Create provider with test state - provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, SECRET, slog.New(slog.NewTextHandler(os.Stderr, nil))) + provider := NewLocalResolverProvider(resolverSupplier, stateProvider, trackingLogger, SECRET, newLoggerForTest(t)) client := openfeature.NewClient("integration-test-mat-seg-no-ctx") @@ -483,7 +481,6 @@ func TestIntegration_OpenFeatureMaterializedSegmentCriterion(t *testing.T) { func TestIntegration_OpenFeatureObjectWithStructDefault(t *testing.T) { // Load test state testState := tu.LoadTestResolverState(t) - accountID := tu.LoadTestAccountID(t) ctx := context.Background() @@ -496,7 +493,7 @@ func TestIntegration_OpenFeatureObjectWithStructDefault(t *testing.T) { mockStub := &mockGrpcStubForIntegration{ onCallReceived: make(chan struct{}, 100), } - actualGrpcLogger := fl.NewGrpcWasmFlagLogger(mockStub, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil))) + actualGrpcLogger := fl.NewGrpcWasmFlagLogger(mockStub, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", newLoggerForTest(t)) trackingLogger := &trackingFlagLogger{ actualLogger: actualGrpcLogger, @@ -504,7 +501,7 @@ func TestIntegration_OpenFeatureObjectWithStructDefault(t *testing.T) { } // Create provider with test state - provider, err := createProviderWithTestState(ctx, stateProvider, accountID, trackingLogger, newUnsupportedMaterializationStore()) + provider, err := createProviderWithTestState(t, stateProvider, trackingLogger, newUnsupportedMaterializationStore()) if err != nil { t.Fatalf("Failed to create provider: %v", err) } @@ -691,7 +688,6 @@ func TestIntegration_OpenFeatureObjectWithStructDefault(t *testing.T) { func TestIntegration_GetPrometheusMetrics(t *testing.T) { // Load test state testState := tu.LoadTestResolverState(t) - accountID := tu.LoadTestAccountID(t) ctx := context.Background() @@ -704,7 +700,7 @@ func TestIntegration_GetPrometheusMetrics(t *testing.T) { mockStub := &mockGrpcStubForIntegration{ onCallReceived: make(chan struct{}, 100), } - actualGrpcLogger := fl.NewGrpcWasmFlagLogger(mockStub, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil))) + actualGrpcLogger := fl.NewGrpcWasmFlagLogger(mockStub, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", newLoggerForTest(t)) trackingLogger := &trackingFlagLogger{ actualLogger: actualGrpcLogger, @@ -712,7 +708,7 @@ func TestIntegration_GetPrometheusMetrics(t *testing.T) { } // Create provider with test state - provider, err := createProviderWithTestState(ctx, stateProvider, accountID, trackingLogger, newUnsupportedMaterializationStore()) + provider, err := createProviderWithTestState(t, stateProvider, trackingLogger, newUnsupportedMaterializationStore()) if err != nil { t.Fatalf("Failed to create provider: %v", err) } @@ -808,17 +804,20 @@ func TestIntegration_GetPrometheusMetrics(t *testing.T) { // createProviderWithTestState creates a provider with mock state provider and tracking logger func createProviderWithTestState( - ctx context.Context, + tb testing.TB, stateProvider StateProvider, - accountID string, - logger FlagLogger, + fl FlagLogger, matStore MaterializationStore, ) (*LocalResolverProvider, error) { + logger := newLoggerForTest(tb) + resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) + return lr.NewLocalResolverWithPoolSize(ctx, logSink, logger, 2) }, matStore) + // Create provider with the client secret from test state // The test state includes client secret: mkjJruAATQWjeY7foFIWfVAcBWnci2YF - provider := NewLocalResolverProvider(resolverSupplier, stateProvider, logger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil))) + provider := NewLocalResolverProvider(resolverSupplier, stateProvider, fl, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", logger) + return provider, nil } diff --git a/openfeature-provider/go/confidence/internal/flag_logger/grpc.go b/openfeature-provider/go/confidence/internal/flag_logger/grpc.go index 672ba70a..48a39fb9 100644 --- a/openfeature-provider/go/confidence/internal/flag_logger/grpc.go +++ b/openfeature-provider/go/confidence/internal/flag_logger/grpc.go @@ -3,25 +3,25 @@ package flag_logger import ( "context" "fmt" - "log/slog" "sync" "sync/atomic" "time" - resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" "google.golang.org/grpc/metadata" + + resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" ) type GrpcFlagLogger struct { stub resolverv1.InternalFlagLoggerServiceClient clientSecret string - logger *slog.Logger + logger Logger wg sync.WaitGroup attempts atomic.Int64 failures atomic.Int64 } -func NewGrpcWasmFlagLogger(stub resolverv1.InternalFlagLoggerServiceClient, clientSecret string, logger *slog.Logger) *GrpcFlagLogger { +func NewGrpcWasmFlagLogger(stub resolverv1.InternalFlagLoggerServiceClient, clientSecret string, logger Logger) *GrpcFlagLogger { return &GrpcFlagLogger{ stub: stub, clientSecret: clientSecret, diff --git a/openfeature-provider/go/confidence/internal/flag_logger/grpc_test.go b/openfeature-provider/go/confidence/internal/flag_logger/grpc_test.go index 9e70a267..e6d6c4fe 100644 --- a/openfeature-provider/go/confidence/internal/flag_logger/grpc_test.go +++ b/openfeature-provider/go/confidence/internal/flag_logger/grpc_test.go @@ -1,18 +1,16 @@ package flag_logger import ( - "bytes" "context" "errors" - "log/slog" - "os" "strings" "sync/atomic" "testing" "time" - resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" "google.golang.org/grpc" + + resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" ) // mockInternalFlagLoggerServiceClient is a mock implementation for testing @@ -37,7 +35,7 @@ func (m *mockInternalFlagLoggerServiceClient) ClientWriteFlagLogs(ctx context.Co func TestNewGrpcWasmFlagLogger(t *testing.T) { mockStub := &mockInternalFlagLoggerServiceClient{} - logger := NewGrpcWasmFlagLogger(mockStub, "test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + logger := NewGrpcWasmFlagLogger(mockStub, "test-client-secret", newLoggerForTest(t)) if logger == nil { t.Fatal("Expected logger to be created, got nil") @@ -56,7 +54,7 @@ func TestGrpcWasmFlagLogger_Write_Empty(t *testing.T) { }, } - logger := NewGrpcWasmFlagLogger(mockStub, "test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + logger := NewGrpcWasmFlagLogger(mockStub, "test-client-secret", newLoggerForTest(t)) // Empty request should be skipped request := &resolverv1.WriteFlagLogsRequest{} @@ -82,7 +80,7 @@ func TestGrpcWasmFlagLogger_Write_SmallRequest(t *testing.T) { }, } - logger := NewGrpcWasmFlagLogger(mockStub, "test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + logger := NewGrpcWasmFlagLogger(mockStub, "test-client-secret", newLoggerForTest(t)) // Create a small request (below chunk threshold) request := &resolverv1.WriteFlagLogsRequest{ @@ -116,7 +114,7 @@ func TestGrpcWasmFlagLogger_ErrorHandling(t *testing.T) { }, } - logger := NewGrpcWasmFlagLogger(mockStub, "test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + logger := NewGrpcWasmFlagLogger(mockStub, "test-client-secret", newLoggerForTest(t)) request := &resolverv1.WriteFlagLogsRequest{ FlagAssigned: make([]*resolverv1.FlagAssigned, 10), @@ -144,7 +142,7 @@ func TestGrpcWasmFlagLogger_Shutdown(t *testing.T) { }, } - logger := NewGrpcWasmFlagLogger(mockStub, "test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + logger := NewGrpcWasmFlagLogger(mockStub, "test-client-secret", newLoggerForTest(t)) // Send multiple requests for i := 0; i < 5; i++ { @@ -163,8 +161,7 @@ func TestGrpcWasmFlagLogger_Shutdown(t *testing.T) { } func TestGrpcWasmFlagLogger_FailureStats_NoLogOnSuccess(t *testing.T) { - var buf bytes.Buffer - testLogger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})) + testLogger := newWarningRecorderLogger() mockStub := &mockInternalFlagLoggerServiceClient{ writeFlagLogsFunc: func(ctx context.Context, req *resolverv1.WriteFlagLogsRequest) (*resolverv1.WriteFlagLogsResponse, error) { @@ -181,14 +178,13 @@ func TestGrpcWasmFlagLogger_FailureStats_NoLogOnSuccess(t *testing.T) { } logger.Shutdown() - if strings.Contains(buf.String(), "Flag log write failures") { + if strings.Contains(testLogger.String(), "Flag log write failures") { t.Error("Expected no failure log when all writes succeed") } } func TestGrpcWasmFlagLogger_FailureStats_LogOnFailures(t *testing.T) { - var buf bytes.Buffer - testLogger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})) + testLogger := newWarningRecorderLogger() var callCount atomic.Int32 mockStub := &mockInternalFlagLoggerServiceClient{ @@ -210,15 +206,14 @@ func TestGrpcWasmFlagLogger_FailureStats_LogOnFailures(t *testing.T) { } logger.Shutdown() - output := buf.String() + output := testLogger.String() if !strings.Contains(output, "Flag log write failures") { t.Error("Expected failure log after window with errors") } } func TestGrpcWasmFlagLogger_FailureStats_NoLogBeforeWindow(t *testing.T) { - var buf bytes.Buffer - testLogger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})) + testLogger := newWarningRecorderLogger() mockStub := &mockInternalFlagLoggerServiceClient{ writeFlagLogsFunc: func(ctx context.Context, req *resolverv1.WriteFlagLogsRequest) (*resolverv1.WriteFlagLogsResponse, error) { @@ -236,14 +231,13 @@ func TestGrpcWasmFlagLogger_FailureStats_NoLogBeforeWindow(t *testing.T) { } logger.Shutdown() - if strings.Contains(buf.String(), "Flag log write failures") { + if strings.Contains(testLogger.String(), "Flag log write failures") { t.Error("Expected no failure log before window boundary") } } func TestGrpcWasmFlagLogger_FailureStats_AllFail(t *testing.T) { - var buf bytes.Buffer - testLogger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})) + testLogger := newWarningRecorderLogger() mockStub := &mockInternalFlagLoggerServiceClient{ writeFlagLogsFunc: func(ctx context.Context, req *resolverv1.WriteFlagLogsRequest) (*resolverv1.WriteFlagLogsResponse, error) { @@ -260,7 +254,7 @@ func TestGrpcWasmFlagLogger_FailureStats_AllFail(t *testing.T) { } logger.Shutdown() - output := buf.String() + output := testLogger.String() if !strings.Contains(output, "Flag log write failures") { t.Error("Expected failure log after window with all failures") } diff --git a/openfeature-provider/go/confidence/internal/flag_logger/logger.go b/openfeature-provider/go/confidence/internal/flag_logger/logger.go new file mode 100644 index 00000000..228a9636 --- /dev/null +++ b/openfeature-provider/go/confidence/internal/flag_logger/logger.go @@ -0,0 +1,14 @@ +package flag_logger + +// Logger is the subset of the top-level confidence.Logger interface required by +// this package. +// +// Only Debug and Warn are used: Debug for successful send traces and Warn for +// periodic failure summaries reported every 10 write attempts. +// +// The variadic args follow the slog convention: alternating string key / arbitrary +// value pairs. +type Logger interface { + Debug(msg string, args ...any) + Warn(msg string, args ...any) +} diff --git a/openfeature-provider/go/confidence/internal/flag_logger/logger_test.go b/openfeature-provider/go/confidence/internal/flag_logger/logger_test.go new file mode 100644 index 00000000..5a2e7c3c --- /dev/null +++ b/openfeature-provider/go/confidence/internal/flag_logger/logger_test.go @@ -0,0 +1,80 @@ +package flag_logger + +import ( + "bytes" + "fmt" + "strings" + "testing" +) + +// testingLogger routes log messages to [testing.TB.Log] so they appear in test +// output only on failure (or with -v). +type testingLogger struct { + log func(msgs ...any) +} + +// newLoggerForTest returns a [Logger] backed by tb.Log. +func newLoggerForTest(tb testing.TB) *testingLogger { + return &testingLogger{ + log: tb.Log, + } +} + +func (l *testingLogger) Debug(msg string, args ...any) { + l.logMessage("debug", msg, args...) +} + +func (l *testingLogger) Warn(msg string, args ...any) { + l.logMessage("warning", msg, args...) +} + +func (l *testingLogger) logMessage(lvl string, msg string, args ...any) { + var fields string + + if len(args) > 0 { + var sb strings.Builder + + sb.WriteRune('{') + for len(args) > 0 { + k, v := args[0].(string), args[1] + if sb.Len() > 1 { + sb.WriteString(", ") + } + sb.WriteString(fmt.Sprintf("%s: %v", k, v)) + args = args[2:] + } + sb.WriteRune('}') + + fields = sb.String() + } + + l.log(fmt.Sprintf("[%s] %s", lvl, msg), fields) +} + +// warningRecorderLogger captures only Warn calls so tests can assert on warning +// output without noise from Debug messages. +type warningRecorderLogger struct { + buf bytes.Buffer +} + +// newWarningRecorderLogger returns an empty [warningRecorderLogger]. +func newWarningRecorderLogger() *warningRecorderLogger { + return &warningRecorderLogger{} +} + +func (l *warningRecorderLogger) Debug(msg string, args ...any) {} + +func (l *warningRecorderLogger) Warn(msg string, args ...any) { + l.buf.WriteString(msg) + defer l.buf.WriteRune('\n') + + for len(args) > 0 { + k, v := args[0].(string), args[1] + l.buf.WriteString(fmt.Sprintf(" %s=%v", k, v)) + args = args[2:] + } +} + +func (l *warningRecorderLogger) String() string { + return l.buf.String() +} diff --git a/openfeature-provider/go/confidence/internal/local_resolver/default_resolver_test.go b/openfeature-provider/go/confidence/internal/local_resolver/default_resolver_test.go index 5b40d59b..5fd0a81b 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/default_resolver_test.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/default_resolver_test.go @@ -5,17 +5,17 @@ import ( "os" "testing" + "google.golang.org/protobuf/types/known/structpb" + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver" "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm" tu "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/testutil" - - "google.golang.org/protobuf/types/known/structpb" ) var resolverFactory LocalResolverFactory func TestMain(m *testing.M) { - resolverFactory = DefaultResolverFactory(NoOpLogSink) + resolverFactory = DefaultResolverFactory(NoOpLogSink, &noopLogger{}) defer resolverFactory.Close(context.Background()) os.Exit(m.Run()) } diff --git a/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go b/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go index 1e7675db..0f3d7541 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go @@ -32,9 +32,9 @@ type LocalResolver interface { } // DefaultResolverFactory composes the default stack: Wasm -> Recovering -> Pooled(DefaultPoolSize) -func DefaultResolverFactory(logSink LogSink) LocalResolverFactory { - base := NewWasmResolverFactory(logSink) - rcv := NewRecoveringResolverFactory(base) +func DefaultResolverFactory(logSink LogSink, logger Logger) LocalResolverFactory { + base := NewWasmResolverFactory(logSink, logger) + rcv := NewRecoveringResolverFactory(base, logger) return NewPooledResolverFactory(rcv, DefaultPoolSize) } @@ -43,9 +43,9 @@ type localResolverImpl struct { factory LocalResolverFactory } -func NewLocalResolverWithPoolSize(ctx context.Context, logSink LogSink, poolSize int) LocalResolver { - factory := NewWasmResolverFactory(logSink) - factory = NewRecoveringResolverFactory(factory) +func NewLocalResolverWithPoolSize(ctx context.Context, logSink LogSink, logger Logger, poolSize int) LocalResolver { + factory := NewWasmResolverFactory(logSink, logger) + factory = NewRecoveringResolverFactory(factory, logger) if poolSize <= 0 { poolSize = DefaultPoolSize } diff --git a/openfeature-provider/go/confidence/internal/local_resolver/logger.go b/openfeature-provider/go/confidence/internal/local_resolver/logger.go new file mode 100644 index 00000000..7c33db8d --- /dev/null +++ b/openfeature-provider/go/confidence/internal/local_resolver/logger.go @@ -0,0 +1,18 @@ +package local_resolver + +// Logger is the subset of the top-level confidence.Logger interface required by +// this package. +// +// Only Warn is used: to report WASM instance crashes caught by [RecoveringResolver] +// and to surface pool saturation events. +// +// The variadic args follow the slog convention: alternating string key / arbitrary +// value pairs. +type Logger interface { + Warn(msg string, args ...any) +} + +// noopLogger is the default Logger used when none is provided by the caller. +type noopLogger struct{} + +func (l *noopLogger) Warn(_ string, _ ...any) {} diff --git a/openfeature-provider/go/confidence/internal/local_resolver/logger_test.go b/openfeature-provider/go/confidence/internal/local_resolver/logger_test.go new file mode 100644 index 00000000..6d690d34 --- /dev/null +++ b/openfeature-provider/go/confidence/internal/local_resolver/logger_test.go @@ -0,0 +1,47 @@ +package local_resolver + +import ( + "fmt" + "strings" + "testing" +) + +// testingLogger routes log messages to [testing.TB.Log] so they appear in test +// output only on failure (or with -v). +type testingLogger struct { + log func(msgs ...any) +} + +// newLoggerForTest returns a [Logger] backed by tb.Log. +func newLoggerForTest(tb testing.TB) *testingLogger { + return &testingLogger{ + log: tb.Log, + } +} + +func (l *testingLogger) Warn(msg string, args ...any) { + l.logMessage("warning", msg, args...) +} + +func (l *testingLogger) logMessage(lvl string, msg string, args ...any) { + var fields string + + if len(args) > 0 { + var sb strings.Builder + + sb.WriteRune('{') + for len(args) > 0 { + k, v := args[0].(string), args[1] + if sb.Len() > 1 { + sb.WriteString(", ") + } + sb.WriteString(fmt.Sprintf("%s: %v", k, v)) + args = args[2:] + } + sb.WriteRune('}') + + fields = sb.String() + } + + l.log(fmt.Sprintf("[%s] %s", lvl, msg), fields) +} diff --git a/openfeature-provider/go/confidence/internal/local_resolver/recover.go b/openfeature-provider/go/confidence/internal/local_resolver/recover.go index 97513205..132d08e5 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/recover.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/recover.go @@ -3,7 +3,6 @@ package local_resolver import ( "context" "fmt" - "log/slog" "sync/atomic" "time" @@ -15,15 +14,20 @@ import ( // LocalResolver instances that auto-recover (recreate) on low-level panics. type RecoveringResolverFactory struct { LocalResolverFactory + logger Logger } -func NewRecoveringResolverFactory(inner LocalResolverFactory) *RecoveringResolverFactory { - return &RecoveringResolverFactory{inner} +func NewRecoveringResolverFactory(inner LocalResolverFactory, logger Logger) *RecoveringResolverFactory { + return &RecoveringResolverFactory{ + LocalResolverFactory: inner, + logger: logger, + } } func (f *RecoveringResolverFactory) New() LocalResolver { rr := &RecoveringResolver{ factory: f.LocalResolverFactory, + logger: f.logger, } lr := f.LocalResolverFactory.New() rr.current.Store(lr) @@ -35,6 +39,7 @@ func (f *RecoveringResolverFactory) New() LocalResolver { // resolver can be reinitialized before use. type RecoveringResolver struct { factory LocalResolverFactory + logger Logger current atomic.Value // holds LocalResolver broken atomic.Bool // indicates an instance has panicked @@ -103,7 +108,7 @@ func (r *RecoveringResolver) SetResolverState(request *wasm.SetResolverStateRequ func (r *RecoveringResolver) RegisterResolve(request *wasm.RegisterResolveRequest) { defer func() { if rec := recover(); rec != nil { - slog.Warn("RegisterResolve panicked, ignoring", "error", rec) + r.logger.Warn("RegisterResolve panicked, ignoring", "error", rec) } }() if r.broken.Load() { @@ -144,7 +149,7 @@ func (r *RecoveringResolver) FlushAssignLogs() (err error) { func (r *RecoveringResolver) PrometheusSnapshot(bucketsPerDecade uint32, openmetrics bool) string { defer func() { if rec := recover(); rec != nil { - slog.Warn("PrometheusSnapshot panicked, ignoring", "error", rec) + r.logger.Warn("PrometheusSnapshot panicked, ignoring", "error", rec) } }() if r.broken.Load() { diff --git a/openfeature-provider/go/confidence/internal/local_resolver/recover_test.go b/openfeature-provider/go/confidence/internal/local_resolver/recover_test.go index 02dcde7a..31185fea 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/recover_test.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/recover_test.go @@ -50,7 +50,7 @@ func (f *mockFactory) Close(context.Context) error { return nil } // subsequent calls succeed. func TestRecoveringResolver_RecreatesAfterPanic(t *testing.T) { inner := &mockFactory{} - factory := NewRecoveringResolverFactory(inner) + factory := NewRecoveringResolverFactory(inner, newLoggerForTest(t)) rr := factory.New() // First call: the mockResolver (shouldPanic=true) panics, withRecover diff --git a/openfeature-provider/go/confidence/internal/local_resolver/wasm.go b/openfeature-provider/go/confidence/internal/local_resolver/wasm.go index 533faa86..f7c135ec 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/wasm.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/wasm.go @@ -6,18 +6,17 @@ import ( "encoding/binary" "errors" "fmt" - "log/slog" "sync" "sync/atomic" - "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm" - - "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver" - resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver" + resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm" ) // instanceCounter is a package-level counter for auto-assigning unique instance IDs. @@ -39,6 +38,7 @@ func NoOpLogSink(logs *resolverv1.WriteFlagLogsRequest) {} type WasmResolver struct { instance api.Module logSink LogSink + logger Logger mu *sync.Mutex instanceID string fnCache sync.Map @@ -70,14 +70,14 @@ func (r *WasmResolver) ResolveProcess(request *wasm.ResolveProcessRequest) (*was func (r *WasmResolver) RegisterResolve(request *wasm.RegisterResolveRequest) { if err := r.call("wasm_msg_guest_register_resolve", request, nil); err != nil { - slog.Warn("Failed to register resolve telemetry", "error", err) + r.logger.Warn("Failed to register resolve telemetry", "error", err) } } func (r *WasmResolver) ApplyFlags(request *resolver.ApplyFlagsRequest) error { if err := r.call("wasm_msg_guest_apply_flags", request, nil); err != nil { // Apply is best-effort logging — surface the failure but don't propagate. - slog.Warn("Failed to apply flags", "error", err) + r.logger.Warn("Failed to apply flags", "error", err) } return nil } @@ -108,7 +108,7 @@ func (r *WasmResolver) PrometheusSnapshot(bucketsPerDecade uint32, openmetrics b } resp := &wasm.PrometheusSnapshotResponse{} if err := r.call("wasm_msg_guest_prometheus_snapshot", req, resp); err != nil { - slog.Warn("prometheus snapshot failed", "error", err) + r.logger.Warn("prometheus snapshot failed", "error", err) return "" } return resp.GetText() @@ -157,6 +157,7 @@ type WasmResolverFactory struct { runtime wazero.Runtime module wazero.CompiledModule logSink LogSink + logger Logger } var _ LocalResolverFactory = (*WasmResolverFactory)(nil) @@ -181,7 +182,7 @@ func transferResponseError(inst api.Module, errMsg string) uint32 { return transfer(inst, mustMarshal(resp)) } -func NewWasmResolverFactory(logSink LogSink) LocalResolverFactory { +func NewWasmResolverFactory(logSink LogSink, logger Logger) LocalResolverFactory { ctx := context.Background() runtime := wazero.NewRuntime(ctx) _, err := runtime.NewHostModuleBuilder("wasm_msg"). @@ -205,6 +206,7 @@ func NewWasmResolverFactory(logSink LogSink) LocalResolverFactory { runtime: runtime, module: module, logSink: logSink, + logger: logger, } } @@ -219,6 +221,7 @@ func (wrf *WasmResolverFactory) New() LocalResolver { return &WasmResolver{ instance: instance, logSink: wrf.logSink, + logger: wrf.logger, mu: &sync.Mutex{}, instanceID: fmt.Sprintf("%d", id), } diff --git a/openfeature-provider/go/confidence/internal/local_resolver/wasm_memory_test.go b/openfeature-provider/go/confidence/internal/local_resolver/wasm_memory_test.go index fa567c7c..068c0595 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/wasm_memory_test.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/wasm_memory_test.go @@ -14,7 +14,7 @@ import ( // does not free the guest's request allocation, memory leaks accumulate and // eventually force WASM memory.grow. func TestWasmMemoryStableOnRepeatedResolveCalls(t *testing.T) { - factory := NewWasmResolverFactory(NoOpLogSink) + factory := NewWasmResolverFactory(NoOpLogSink, newLoggerForTest(t)) defer factory.Close(context.Background()) resolver := factory.New() diff --git a/openfeature-provider/go/confidence/logger.go b/openfeature-provider/go/confidence/logger.go new file mode 100644 index 00000000..aefe10f8 --- /dev/null +++ b/openfeature-provider/go/confidence/logger.go @@ -0,0 +1,28 @@ +package confidence + +// Logger is the logging interface accepted by [ProviderConfig] and [ProviderTestConfig]. +// +// Implementations must be safe for concurrent use, as the provider calls Logger +// from multiple goroutines (state polling, log flushing, WASM crash recovery). +// +// The variadic args follow the slog convention: alternating string key / arbitrary +// value pairs, e.g.: +// +// logger.Warn("retry limit reached", "attempt", 3, "flag", "my-flag") +// +// A nil Logger is accepted by [NewProvider] and [NewProviderForTest]; it is +// silently replaced with a no-op implementation. +type Logger interface { + Debug(msg string, args ...any) + Info(msg string, args ...any) + Warn(msg string, args ...any) + Error(msg string, args ...any) +} + +// noopLogger is the default Logger used when none is provided by the caller. +type noopLogger struct{} + +func (l *noopLogger) Debug(_ string, _ ...any) {} +func (l *noopLogger) Info(_ string, _ ...any) {} +func (l *noopLogger) Warn(_ string, _ ...any) {} +func (l *noopLogger) Error(_ string, _ ...any) {} diff --git a/openfeature-provider/go/confidence/logger_test.go b/openfeature-provider/go/confidence/logger_test.go new file mode 100644 index 00000000..88ca7e3e --- /dev/null +++ b/openfeature-provider/go/confidence/logger_test.go @@ -0,0 +1,110 @@ +package confidence + +import ( + "bytes" + "fmt" + "strings" + "sync" + "testing" +) + +// testingLogger routes log messages to [testing.TB.Log] so they appear in test +// output only on failure (or with -v). +type testingLogger struct { + log func(msgs ...any) +} + +// newLoggerForTest returns a [Logger] backed by tb.Log. +func newLoggerForTest(tb testing.TB) *testingLogger { + return &testingLogger{ + log: tb.Log, + } +} + +func (l *testingLogger) Debug(msg string, args ...any) { + l.logMessage("debug", msg, args...) +} + +func (l *testingLogger) Info(msg string, args ...any) { + l.logMessage("info", msg, args...) +} + +func (l *testingLogger) Warn(msg string, args ...any) { + l.logMessage("warning", msg, args...) +} + +func (l *testingLogger) Error(msg string, args ...any) { + l.logMessage("error", msg, args...) +} + +func (l *testingLogger) logMessage(lvl string, msg string, args ...any) { + var fields string + + if len(args) > 0 { + var sb strings.Builder + + sb.WriteRune('{') + for len(args) > 0 { + k, v := args[0].(string), args[1] + if sb.Len() > 1 { + sb.WriteString(", ") + } + sb.WriteString(fmt.Sprintf("%s: %v", k, v)) + args = args[2:] + } + sb.WriteRune('}') + + fields = sb.String() + } + + l.log(fmt.Sprintf("[%s] %s", lvl, msg), fields) +} + +// recorderLogger accumulates all log lines in a buffer so tests can assert on +// logged output. It is safe for concurrent use. +type recorderLogger struct { + l sync.Mutex + buf bytes.Buffer +} + +// newRecorderLogger returns an empty [recorderLogger]. +func newRecorderLogger() *recorderLogger { + return &recorderLogger{} +} + +func (l *recorderLogger) Debug(msg string, args ...any) { + l.log(msg, args...) +} + +func (l *recorderLogger) Info(msg string, args ...any) { + l.log(msg, args...) +} + +func (l *recorderLogger) Warn(msg string, args ...any) { + l.log(msg, args...) +} + +func (l *recorderLogger) Error(msg string, args ...any) { + l.log(msg, args...) +} + +func (l *recorderLogger) log(msg string, args ...any) { + l.l.Lock() + defer l.l.Unlock() + + l.buf.WriteString(msg) + defer l.buf.WriteRune('\n') + + for len(args) > 0 { + k, v := args[0].(string), args[1] + l.buf.WriteString(fmt.Sprintf(" %s=%v", k, v)) + args = args[2:] + } +} + +func (l *recorderLogger) String() string { + l.l.Lock() + defer l.l.Unlock() + + return l.buf.String() +} diff --git a/openfeature-provider/go/confidence/provider.go b/openfeature-provider/go/confidence/provider.go index c3157508..3c4a624e 100644 --- a/openfeature-provider/go/confidence/provider.go +++ b/openfeature-provider/go/confidence/provider.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log/slog" "os" "strconv" "strings" @@ -56,7 +55,7 @@ type LocalResolverProvider struct { stateProvider StateProvider flagLogger FlagLogger clientSecret string - logger *slog.Logger + logger Logger cancelFunc context.CancelFunc wg sync.WaitGroup mu sync.Mutex @@ -76,14 +75,12 @@ func NewLocalResolverProvider( stateProvider StateProvider, flagLogger FlagLogger, clientSecret string, - logger *slog.Logger, + logger Logger, opts ...Option, ) *LocalResolverProvider { // Create a default logger if none provided if logger == nil { - logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelInfo, - })) + logger = &noopLogger{} } // Apply options @@ -630,7 +627,7 @@ func (p *LocalResolverProvider) startScheduledTasks(parentCtx context.Context) { // getStatePollInterval gets the state poll interval from environment or returns default // Deprecated: Use ProviderConfig.StatePollInterval instead. Environment variable support will be removed in a future version. -func getStatePollInterval(logger *slog.Logger) time.Duration { +func getStatePollInterval(logger Logger) time.Duration { if envVal := os.Getenv("CONFIDENCE_STATE_POLL_INTERVAL_SECONDS"); envVal != "" { if seconds, err := strconv.ParseInt(envVal, 10, 64); err == nil { if logger != nil { @@ -644,7 +641,7 @@ func getStatePollInterval(logger *slog.Logger) time.Duration { // getLogPollInterval gets the log poll interval from environment or returns default // Deprecated: Use ProviderConfig.LogPollInterval instead. Environment variable support will be removed in a future version. -func getLogPollInterval(logger *slog.Logger) time.Duration { +func getLogPollInterval(logger Logger) time.Duration { if envVal := os.Getenv("CONFIDENCE_LOG_POLL_INTERVAL_SECONDS"); envVal != "" { if seconds, err := strconv.ParseInt(envVal, 10, 64); err == nil { if logger != nil { diff --git a/openfeature-provider/go/confidence/provider_builder.go b/openfeature-provider/go/confidence/provider_builder.go index 8337e42a..2e7148df 100644 --- a/openfeature-provider/go/confidence/provider_builder.go +++ b/openfeature-provider/go/confidence/provider_builder.go @@ -3,16 +3,15 @@ package confidence import ( "context" "fmt" - "log/slog" "net/http" - "os" "time" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + fl "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/flag_logger" lr "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/local_resolver" resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" ) const confidenceDomain = "edge-grpc.spotify.com" @@ -20,7 +19,7 @@ const confidenceDomain = "edge-grpc.spotify.com" type ProviderConfig struct { ClientSecret string EncryptionKey string // Optional: hex-encoded AES-256 key for decrypting CDN state - Logger *slog.Logger + Logger Logger TransportHooks TransportHooks // Optional: defaults to DefaultTransportHooks MaterializationStore MaterializationStore // Optional UseRemoteMaterializationStore bool // set to true to use a Remote lookup for materializations. Requires that MaterializationStore is nil. @@ -33,7 +32,7 @@ type ProviderTestConfig struct { StateProvider StateProvider FlagLogger FlagLogger ClientSecret string - Logger *slog.Logger + Logger Logger MaterializationStore MaterializationStore // Optional StatePollInterval time.Duration // Optional: interval for state polling, defaults to 10 seconds LogPollInterval time.Duration // Optional: interval for log flushing, defaults to 60 seconds @@ -47,9 +46,7 @@ func NewProvider(ctx context.Context, config ProviderConfig) (*LocalResolverProv logger := config.Logger if logger == nil { - logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelInfo, - })) + logger = &noopLogger{} } if config.EncryptionKey == "" { @@ -88,7 +85,7 @@ func NewProvider(ctx context.Context, config ProviderConfig) (*LocalResolverProv } resolverSupplier := func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithPoolSize(ctx, logSink, config.ResolverPoolSize) + return lr.NewLocalResolverWithPoolSize(ctx, logSink, logger, config.ResolverPoolSize) } resolverSupplierWithMaterialization := wrapResolverSupplierWithMaterializations(resolverSupplier, materializationStore) providerOpts := buildProviderOptions(config.StatePollInterval, config.LogPollInterval) @@ -107,9 +104,7 @@ func NewProviderForTest(ctx context.Context, config ProviderTestConfig) (*LocalR logger := config.Logger if logger == nil { - logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelInfo, - })) + logger = &noopLogger{} } materializationStore := config.MaterializationStore @@ -117,7 +112,7 @@ func NewProviderForTest(ctx context.Context, config ProviderTestConfig) (*LocalR materializationStore = newUnsupportedMaterializationStore() } resolverSupplier := func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithPoolSize(ctx, logSink, config.ResolverPoolSize) + return lr.NewLocalResolverWithPoolSize(ctx, logSink, logger, config.ResolverPoolSize) } resolverSupplierWithMaterialization := wrapResolverSupplierWithMaterializations(resolverSupplier, materializationStore) providerOpts := buildProviderOptions(config.StatePollInterval, config.LogPollInterval) diff --git a/openfeature-provider/go/confidence/provider_resolve_test.go b/openfeature-provider/go/confidence/provider_resolve_test.go index 70525d0a..b9926eea 100644 --- a/openfeature-provider/go/confidence/provider_resolve_test.go +++ b/openfeature-provider/go/confidence/provider_resolve_test.go @@ -2,16 +2,15 @@ package confidence import ( "context" - "log/slog" - "os" "testing" "github.com/open-feature/go-sdk/openfeature" + "google.golang.org/protobuf/proto" + lr "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/local_resolver" adminv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/admin" iamv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/admin" tu "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/testutil" - "google.golang.org/protobuf/proto" ) func TestLocalResolverProvider_ReturnsDefaultOnError(t *testing.T) { @@ -40,10 +39,10 @@ func TestLocalResolverProvider_ReturnsDefaultOnError(t *testing.T) { unsupportedMatStore := newUnsupportedMaterializationStore() resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) + return lr.NewLocalResolverWithPoolSize(ctx, logSink, newLoggerForTest(t), 2) }, unsupportedMatStore) // Use different client secret that won't match - openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil)))) + openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "test-secret", newLoggerForTest(t))) client := openfeature.NewClient("test-client") evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{ @@ -88,10 +87,10 @@ func TestLocalResolverProvider_ReturnsCorrectValue(t *testing.T) { unsupportedMatStore := newUnsupportedMaterializationStore() resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) + return lr.NewLocalResolverWithPoolSize(ctx, logSink, newLoggerForTest(t), 2) }, unsupportedMatStore) // Use the correct client secret from test data - openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil)))) + openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", newLoggerForTest(t))) client := openfeature.NewClient("test-client") evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{ @@ -167,9 +166,9 @@ func TestLocalResolverProvider_SkipApplyContextKey(t *testing.T) { unsupportedMatStore := newUnsupportedMaterializationStore() resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) + return lr.NewLocalResolverWithPoolSize(ctx, logSink, newLoggerForTest(t), 2) }, unsupportedMatStore) - openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil)))) + openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", newLoggerForTest(t))) client := openfeature.NewClient("test-client") evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{ @@ -194,7 +193,7 @@ func TestLocalResolverProvider_SkipApplyContextKey(t *testing.T) { func TestLocalResolverProvider_PathNotFound(t *testing.T) { ctx := context.Background() - runtime := lr.DefaultResolverFactory(lr.NoOpLogSink) + runtime := lr.DefaultResolverFactory(lr.NoOpLogSink, newLoggerForTest(t)) defer runtime.Close(ctx) // Load real test state @@ -210,10 +209,10 @@ func TestLocalResolverProvider_PathNotFound(t *testing.T) { unsupportedMatStore := newUnsupportedMaterializationStore() resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) + return lr.NewLocalResolverWithPoolSize(ctx, logSink, newLoggerForTest(t), 2) }, unsupportedMatStore) // Use the correct client secret from test data - openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil)))) + openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", newLoggerForTest(t))) client := openfeature.NewClient("test-client") evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{ @@ -278,9 +277,9 @@ func TestLocalResolverProvider_MissingMaterializations(t *testing.T) { unsupportedMatStore := newUnsupportedMaterializationStore() resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) + return lr.NewLocalResolverWithPoolSize(ctx, logSink, newLoggerForTest(t), 2) }, unsupportedMatStore) - openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", slog.New(slog.NewTextHandler(os.Stderr, nil)))) + openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "mkjJruAATQWjeY7foFIWfVAcBWnci2YF", newLoggerForTest(t))) client := openfeature.NewClient("test-client") evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{ @@ -319,9 +318,9 @@ func TestLocalResolverProvider_MissingMaterializations(t *testing.T) { unsupportedMatStore := newUnsupportedMaterializationStore() resolverSupplier := wrapResolverSupplierWithMaterializations(func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithPoolSize(ctx, logSink, 2) + return lr.NewLocalResolverWithPoolSize(ctx, logSink, newLoggerForTest(t), 2) }, unsupportedMatStore) - openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil)))) + openfeature.SetProviderAndWait(NewLocalResolverProvider(resolverSupplier, stateProvider, mockFlagLogger, "test-secret", newLoggerForTest(t))) client := openfeature.NewClient("test-client") evalCtx := openfeature.NewTargetlessEvaluationContext(map[string]interface{}{ diff --git a/openfeature-provider/go/confidence/retry_test.go b/openfeature-provider/go/confidence/retry_test.go index 87e6a5a3..d2e9f764 100644 --- a/openfeature-provider/go/confidence/retry_test.go +++ b/openfeature-provider/go/confidence/retry_test.go @@ -1,22 +1,20 @@ -package confidence_test +package confidence import ( "context" - "log/slog" "net" - "os" "sync/atomic" "testing" "time" - "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence" - fl "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/flag_logger" - resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" + + fl "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/flag_logger" + resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" ) func TestRetryOnUnavailable(t *testing.T) { @@ -36,7 +34,7 @@ func TestRetryOnUnavailable(t *testing.T) { return lis.DialContext(ctx) }), grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithDefaultServiceConfig(confidence.RetryServiceConfig), + grpc.WithDefaultServiceConfig(RetryServiceConfig), ) if err != nil { t.Fatalf("Failed to dial: %v", err) @@ -44,7 +42,7 @@ func TestRetryOnUnavailable(t *testing.T) { t.Cleanup(func() { conn.Close() }) stub := resolverv1.NewInternalFlagLoggerServiceClient(conn) - logger := fl.NewGrpcWasmFlagLogger(stub, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + logger := fl.NewGrpcWasmFlagLogger(stub, "test-secret", newLoggerForTest(t)) logger.Write(&resolverv1.WriteFlagLogsRequest{ FlagAssigned: []*resolverv1.FlagAssigned{{ResolveId: "r1"}}, @@ -72,7 +70,7 @@ func TestNoRetryOnPermissionDenied(t *testing.T) { return lis.DialContext(ctx) }), grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithDefaultServiceConfig(confidence.RetryServiceConfig), + grpc.WithDefaultServiceConfig(RetryServiceConfig), ) if err != nil { t.Fatalf("Failed to dial: %v", err) @@ -80,7 +78,7 @@ func TestNoRetryOnPermissionDenied(t *testing.T) { t.Cleanup(func() { conn.Close() }) stub := resolverv1.NewInternalFlagLoggerServiceClient(conn) - logger := fl.NewGrpcWasmFlagLogger(stub, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + logger := fl.NewGrpcWasmFlagLogger(stub, "test-secret", newLoggerForTest(t)) logger.Write(&resolverv1.WriteFlagLogsRequest{ FlagAssigned: []*resolverv1.FlagAssigned{{ResolveId: "r1"}}, @@ -109,7 +107,7 @@ func TestAllRetriesExhausted(t *testing.T) { return lis.DialContext(ctx) }), grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithDefaultServiceConfig(confidence.RetryServiceConfig), + grpc.WithDefaultServiceConfig(RetryServiceConfig), ) if err != nil { t.Fatalf("Failed to dial: %v", err) @@ -117,7 +115,7 @@ func TestAllRetriesExhausted(t *testing.T) { t.Cleanup(func() { conn.Close() }) stub := resolverv1.NewInternalFlagLoggerServiceClient(conn) - logger := fl.NewGrpcWasmFlagLogger(stub, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + logger := fl.NewGrpcWasmFlagLogger(stub, "test-secret", newLoggerForTest(t)) logger.Write(&resolverv1.WriteFlagLogsRequest{ FlagAssigned: []*resolverv1.FlagAssigned{{ResolveId: "r1"}}, @@ -146,7 +144,7 @@ func TestShutdownDuringRetries(t *testing.T) { return lis.DialContext(ctx) }), grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithDefaultServiceConfig(confidence.RetryServiceConfig), + grpc.WithDefaultServiceConfig(RetryServiceConfig), ) if err != nil { t.Fatalf("Failed to dial: %v", err) @@ -154,7 +152,7 @@ func TestShutdownDuringRetries(t *testing.T) { t.Cleanup(func() { conn.Close() }) stub := resolverv1.NewInternalFlagLoggerServiceClient(conn) - logger := fl.NewGrpcWasmFlagLogger(stub, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + logger := fl.NewGrpcWasmFlagLogger(stub, "test-secret", newLoggerForTest(t)) logger.Write(&resolverv1.WriteFlagLogsRequest{ FlagAssigned: []*resolverv1.FlagAssigned{{ResolveId: "r1"}}, @@ -186,7 +184,7 @@ func TestTransportHooksPreserveRetry(t *testing.T) { t.Cleanup(srv.Stop) baseOpts := []grpc.DialOption{ - grpc.WithDefaultServiceConfig(confidence.RetryServiceConfig), + grpc.WithDefaultServiceConfig(RetryServiceConfig), } opts := append([]grpc.DialOption{}, baseOpts...) opts = append(opts, @@ -203,7 +201,7 @@ func TestTransportHooksPreserveRetry(t *testing.T) { t.Cleanup(func() { conn.Close() }) stub := resolverv1.NewInternalFlagLoggerServiceClient(conn) - logger := fl.NewGrpcWasmFlagLogger(stub, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + logger := fl.NewGrpcWasmFlagLogger(stub, "test-secret", newLoggerForTest(t)) logger.Write(&resolverv1.WriteFlagLogsRequest{ FlagAssigned: []*resolverv1.FlagAssigned{{ResolveId: "r1"}}, @@ -232,7 +230,7 @@ func TestMultipleWritesWithRetryConfig(t *testing.T) { return lis.DialContext(ctx) }), grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithDefaultServiceConfig(confidence.RetryServiceConfig), + grpc.WithDefaultServiceConfig(RetryServiceConfig), ) if err != nil { t.Fatalf("Failed to dial: %v", err) @@ -240,7 +238,7 @@ func TestMultipleWritesWithRetryConfig(t *testing.T) { t.Cleanup(func() { conn.Close() }) stub := resolverv1.NewInternalFlagLoggerServiceClient(conn) - logger := fl.NewGrpcWasmFlagLogger(stub, "test-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + logger := fl.NewGrpcWasmFlagLogger(stub, "test-secret", newLoggerForTest(t)) for i := 0; i < 5; i++ { logger.Write(&resolverv1.WriteFlagLogsRequest{ diff --git a/openfeature-provider/go/confidence/state_fetcher.go b/openfeature-provider/go/confidence/state_fetcher.go index 87e2f66e..c5eb7afb 100644 --- a/openfeature-provider/go/confidence/state_fetcher.go +++ b/openfeature-provider/go/confidence/state_fetcher.go @@ -8,7 +8,6 @@ import ( "encoding/hex" "fmt" "io" - "log/slog" "net/http" "sync/atomic" "time" @@ -30,7 +29,7 @@ type FlagsAdminStateFetcher struct { rawResolverState atomic.Value // stores []byte accountID atomic.Value // stores string HTTPClient *http.Client // Exported for testing - logger *slog.Logger + logger Logger } // Compile-time interface conformance check @@ -39,7 +38,7 @@ var _ StateProvider = (*FlagsAdminStateFetcher)(nil) // NewFlagsAdminStateFetcher creates a new FlagsAdminStateFetcher func NewFlagsAdminStateFetcher( clientSecret string, - logger *slog.Logger, + logger Logger, ) *FlagsAdminStateFetcher { return NewFlagsAdminStateFetcherWithTransport(clientSecret, logger, http.DefaultTransport) } @@ -47,7 +46,7 @@ func NewFlagsAdminStateFetcher( // NewFlagsAdminStateFetcherWithTransport creates a new FlagsAdminStateFetcher with a custom HTTP transport. func NewFlagsAdminStateFetcherWithTransport( clientSecret string, - logger *slog.Logger, + logger Logger, transport http.RoundTripper, ) *FlagsAdminStateFetcher { return NewFlagsAdminStateFetcherWithEncryption(clientSecret, "", logger, transport) @@ -57,7 +56,7 @@ func NewFlagsAdminStateFetcherWithTransport( func NewFlagsAdminStateFetcherWithEncryption( clientSecret string, encryptionKey string, - logger *slog.Logger, + logger Logger, transport http.RoundTripper, ) *FlagsAdminStateFetcher { f := &FlagsAdminStateFetcher{ diff --git a/openfeature-provider/go/confidence/state_fetcher_test.go b/openfeature-provider/go/confidence/state_fetcher_test.go index d98c88af..f402dfae 100644 --- a/openfeature-provider/go/confidence/state_fetcher_test.go +++ b/openfeature-provider/go/confidence/state_fetcher_test.go @@ -2,16 +2,15 @@ package confidence import ( "context" - "log/slog" "net/http" "net/http/httptest" "net/url" - "os" "testing" "time" - adminv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/admin" "google.golang.org/protobuf/proto" + + adminv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/admin" ) // testTransport is a custom RoundTripper that redirects all requests to a test server @@ -31,7 +30,7 @@ func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) { } func TestNewFlagsAdminStateFetcher(t *testing.T) { - fetcher := NewFlagsAdminStateFetcher("test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + fetcher := NewFlagsAdminStateFetcher("test-client-secret", newLoggerForTest(t)) if fetcher == nil { t.Fatal("Expected fetcher to be created, got nil") @@ -51,7 +50,7 @@ func TestNewFlagsAdminStateFetcher(t *testing.T) { } func TestFlagsAdminStateFetcher_GetRawState(t *testing.T) { - fetcher := NewFlagsAdminStateFetcher("test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + fetcher := NewFlagsAdminStateFetcher("test-client-secret", newLoggerForTest(t)) // Initial state should be empty but not nil state := fetcher.GetRawState() @@ -61,7 +60,7 @@ func TestFlagsAdminStateFetcher_GetRawState(t *testing.T) { } func TestFlagsAdminStateFetcher_GetAccountID(t *testing.T) { - fetcher := NewFlagsAdminStateFetcher("test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + fetcher := NewFlagsAdminStateFetcher("test-client-secret", newLoggerForTest(t)) // Initially empty if fetcher.GetAccountID() != "" { @@ -97,7 +96,7 @@ func TestFlagsAdminStateFetcher_Reload_Success(t *testing.T) { })) defer server.Close() - fetcher := NewFlagsAdminStateFetcher("test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + fetcher := NewFlagsAdminStateFetcher("test-client-secret", newLoggerForTest(t)) // Use custom transport to redirect to test server fetcher.HTTPClient = &http.Client{ Timeout: 30 * time.Second, @@ -162,7 +161,7 @@ func TestFlagsAdminStateFetcher_Reload_NotModified(t *testing.T) { })) defer server.Close() - fetcher := NewFlagsAdminStateFetcher("test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + fetcher := NewFlagsAdminStateFetcher("test-client-secret", newLoggerForTest(t)) fetcher.HTTPClient = &http.Client{ Timeout: 30 * time.Second, Transport: &testTransport{testServerURL: server.URL}, @@ -201,7 +200,7 @@ func TestFlagsAdminStateFetcher_Reload_Error(t *testing.T) { })) defer server.Close() - fetcher := NewFlagsAdminStateFetcher("test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + fetcher := NewFlagsAdminStateFetcher("test-client-secret", newLoggerForTest(t)) fetcher.HTTPClient = &http.Client{ Timeout: 30 * time.Second, Transport: &testTransport{testServerURL: server.URL}, @@ -232,7 +231,7 @@ func TestFlagsAdminStateFetcher_Provide(t *testing.T) { })) defer server.Close() - fetcher := NewFlagsAdminStateFetcher("test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + fetcher := NewFlagsAdminStateFetcher("test-client-secret", newLoggerForTest(t)) fetcher.HTTPClient = &http.Client{ Timeout: 30 * time.Second, Transport: &testTransport{testServerURL: server.URL}, @@ -279,7 +278,7 @@ func TestFlagsAdminStateFetcher_Provide_ReturnsStateOnError(t *testing.T) { })) defer server.Close() - fetcher := NewFlagsAdminStateFetcher("test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + fetcher := NewFlagsAdminStateFetcher("test-client-secret", newLoggerForTest(t)) fetcher.HTTPClient = &http.Client{ Timeout: 30 * time.Second, Transport: &testTransport{testServerURL: server.URL}, @@ -320,7 +319,7 @@ func TestFlagsAdminStateFetcher_HTTPTimeout(t *testing.T) { })) defer server.Close() - fetcher := NewFlagsAdminStateFetcher("test-client-secret", slog.New(slog.NewTextHandler(os.Stderr, nil))) + fetcher := NewFlagsAdminStateFetcher("test-client-secret", newLoggerForTest(t)) // Set short timeout for test fetcher.HTTPClient = &http.Client{ Timeout: 100 * time.Millisecond,