From aa8918bce7e5f0b147f4b06f730ee7607120e6cf Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 29 Apr 2026 23:30:45 +0800 Subject: [PATCH 1/4] Migrate to using Microsoft ILogger --- .../Datastream/DatastreamCacheTests.cs | 3 +- .../DatastreamClientAdapterTests.cs | 5 +- .../Datastream/DatastreamClientTests.cs | 6 +- .../Datastream/DatastreamConcurrencyTests.cs | 6 +- .../DatastreamConnectionMonitoringTests.cs | 5 +- .../Datastream/DatastreamConnectionTests.cs | 4 +- .../Mocks/DatastreamClientTestFactory.cs | 5 +- .../Datastream/Mocks/FakeLoggerExtensions.cs | 20 ++ .../Datastream/Mocks/MockSchematicLogger.cs | 53 ---- .../ReplicatorHealthServiceTests.cs | 9 +- .../SchematicHQ.Client.Test.Custom.props | 1 + src/SchematicHQ.Client.Test/TestClient.cs | 17 +- .../TestCompaniesClient.cs | 7 +- .../TestEventBuffer.cs | 23 +- .../TestEventCaptureClient.cs | 20 +- src/SchematicHQ.Client.Test/TestLogger.cs | 241 ------------------ .../Core/Public/ClientOptionsCustom.cs | 28 +- src/SchematicHQ.Client/Datastream/Client.cs | 134 +++++----- .../Datastream/DatastreamClientAdapter.cs | 86 ++----- .../Datastream/ReplicatorHealthService.cs | 37 +-- src/SchematicHQ.Client/EventBuffer.cs | 44 ++-- src/SchematicHQ.Client/EventCaptureClient.cs | 7 +- src/SchematicHQ.Client/Logger.cs | 66 ----- .../OpenFeature/SchematicProvider.cs | 11 +- src/SchematicHQ.Client/Schematic.cs | 49 ++-- .../SchematicHQ.Client.Custom.props | 1 + 26 files changed, 253 insertions(+), 635 deletions(-) create mode 100644 src/SchematicHQ.Client.Test/Datastream/Mocks/FakeLoggerExtensions.cs delete mode 100644 src/SchematicHQ.Client.Test/Datastream/Mocks/MockSchematicLogger.cs delete mode 100644 src/SchematicHQ.Client.Test/TestLogger.cs delete mode 100644 src/SchematicHQ.Client/Logger.cs diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs index b4dec908..e7493637 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamCacheTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Microsoft.Extensions.Logging.Testing; using System.Text.Json.Serialization; using NUnit.Framework; using SchematicHQ.Client.Datastream; @@ -10,7 +11,7 @@ namespace SchematicHQ.Client.Test.Datastream public class DatastreamCacheTests { private MockWebSocket _mockWebSocket; - private MockSchematicLogger _mockLogger; + private FakeLogger _mockLogger; private DatastreamClient _client; [SetUp] diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs index b3e19b96..6e9e0715 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientAdapterTests.cs @@ -1,4 +1,5 @@ using System; +using Microsoft.Extensions.Logging.Testing; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; @@ -13,7 +14,7 @@ public class DatastreamClientAdapterTests { private DatastreamClientAdapter _adapter; private MockWebSocket _mockWebSocket; - private MockSchematicLogger _mockLogger; + private FakeLogger _mockLogger; private Action _connectionCallback; [SetUp] @@ -21,7 +22,7 @@ public void Setup() { // We need to capture the connection callback to trigger connection state changes in tests _connectionCallback = null; - _mockLogger = new MockSchematicLogger(); + _mockLogger = new FakeLogger(); _mockWebSocket = new MockWebSocket(); _mockWebSocket.SetState(WebSocketState.Open); diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs index 21bc17d1..a6edd4cc 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamClientTests.cs @@ -1,6 +1,8 @@ using System.Text; +using Microsoft.Extensions.Logging.Testing; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; using NUnit.Framework; using SchematicHQ.Client.RulesEngine.Utils; using SchematicHQ.Client.Datastream; @@ -12,7 +14,7 @@ namespace SchematicHQ.Client.Test.Datastream public class DatastreamClientTests { private MockWebSocket _mockWebSocket; - private MockSchematicLogger _mockLogger; + private FakeLogger _mockLogger; private DatastreamClient _client; private JsonSerializerOptions _jsonOptions; @@ -228,7 +230,7 @@ public void Dispose_ClosesWebSocketConnection() _client.Dispose(); // Assert - Assert.That(_mockLogger.HasLogEntry(LogLevel.Info, "Connected to Schematic WebSocket"), Is.False); + Assert.That(_mockLogger.HasLogEntry(LogLevel.Information, "Connected to Schematic WebSocket"), Is.False); } private void SetupFlagsResponse() diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamConcurrencyTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamConcurrencyTests.cs index b40d6527..956368d6 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamConcurrencyTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamConcurrencyTests.cs @@ -1,5 +1,7 @@ using System.Text; +using Microsoft.Extensions.Logging.Testing; using System.Text.Json; +using Microsoft.Extensions.Logging; using NUnit.Framework; using SchematicHQ.Client.RulesEngine; using SchematicHQ.Client.RulesEngine.Utils; @@ -12,7 +14,7 @@ namespace SchematicHQ.Client.Test.Datastream public class DatastreamConcurrencyTests { private MockWebSocket _mockWebSocket; - private MockSchematicLogger _mockLogger; + private FakeLogger _mockLogger; private DatastreamClient _client; [SetUp] @@ -112,7 +114,7 @@ public async Task EmptyResponse_LogsWarning() await Task.Delay(100); // Assert - Assert.That(_mockLogger.HasLogEntry(LogLevel.Warn, "Received empty company data"), Is.True); + Assert.That(_mockLogger.HasLogEntry(LogLevel.Warning, "Received empty company data"), Is.True); } [Test] diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionMonitoringTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionMonitoringTests.cs index 6c11ece9..9501becc 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionMonitoringTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionMonitoringTests.cs @@ -1,4 +1,5 @@ using System; +using Microsoft.Extensions.Logging.Testing; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; @@ -11,12 +12,12 @@ namespace SchematicHQ.Client.Test.Datastream [TestFixture] public class DatastreamConnectionMonitoringTests { - private MockSchematicLogger _logger; + private FakeLogger _logger; [SetUp] public void Setup() { - _logger = new MockSchematicLogger(); + _logger = new FakeLogger(); } [Test] diff --git a/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionTests.cs b/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionTests.cs index 44c3eaa8..1a304bd1 100644 --- a/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/DatastreamConnectionTests.cs @@ -1,6 +1,8 @@ using System.Net.WebSockets; +using Microsoft.Extensions.Logging.Testing; using System.Text; using System.Text.Json; +using Microsoft.Extensions.Logging; using NUnit.Framework; using SchematicHQ.Client.Datastream; using SchematicHQ.Client.Test.Datastream.Mocks; @@ -11,7 +13,7 @@ namespace SchematicHQ.Client.Test.Datastream public class DatastreamConnectionTests { private MockWebSocket _mockWebSocket; - private MockSchematicLogger _mockLogger; + private FakeLogger _mockLogger; private DatastreamClient _client; private Action _connectionCallback; diff --git a/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestFactory.cs b/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestFactory.cs index 63f72687..b67cb974 100644 --- a/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestFactory.cs +++ b/src/SchematicHQ.Client.Test/Datastream/Mocks/DatastreamClientTestFactory.cs @@ -1,4 +1,5 @@ using System.Net.WebSockets; +using Microsoft.Extensions.Logging.Testing; using System.Reflection; using SchematicHQ.Client.Datastream; @@ -9,10 +10,10 @@ namespace SchematicHQ.Client.Test.Datastream.Mocks /// public class DatastreamClientTestFactory { - public static (DatastreamClient Client, MockWebSocket WebSocket, MockSchematicLogger Logger, Action ConnectionCallback) + public static (DatastreamClient Client, MockWebSocket WebSocket, FakeLogger Logger, Action ConnectionCallback) CreateClientWithMocks(string apiKey = "test-api-key", TimeSpan? cacheTtl = null, Action? connectionCallback = null) { - var logger = new MockSchematicLogger(); + var logger = new FakeLogger(); var mockWebSocket = new MockWebSocket(); mockWebSocket.SetState(WebSocketState.Open); diff --git a/src/SchematicHQ.Client.Test/Datastream/Mocks/FakeLoggerExtensions.cs b/src/SchematicHQ.Client.Test/Datastream/Mocks/FakeLoggerExtensions.cs new file mode 100644 index 00000000..c7e0aaa6 --- /dev/null +++ b/src/SchematicHQ.Client.Test/Datastream/Mocks/FakeLoggerExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; + +namespace SchematicHQ.Client.Test.Datastream.Mocks +{ + public static class FakeLoggerExtensions + { + public static bool HasLogEntry(this FakeLogger logger, LogLevel level, string messageContains) + { + return logger.Collector.GetSnapshot().Any(r => r.Level == level && r.Message.Contains(messageContains)); + } + + public static ILoggerFactory ToLoggerFactory(this FakeLogger logger) + { + return LoggerFactory.Create(b => b + .SetMinimumLevel(LogLevel.Trace) + .AddProvider(new FakeLoggerProvider(logger.Collector))); + } + } +} diff --git a/src/SchematicHQ.Client.Test/Datastream/Mocks/MockSchematicLogger.cs b/src/SchematicHQ.Client.Test/Datastream/Mocks/MockSchematicLogger.cs deleted file mode 100644 index 4609e68d..00000000 --- a/src/SchematicHQ.Client.Test/Datastream/Mocks/MockSchematicLogger.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace SchematicHQ.Client.Test.Datastream.Mocks -{ - /// - /// Mock implementation of ISchematicLogger for testing. - /// Records every call regardless of level (no filtering) so tests can - /// assert that a specific message was attempted at a specific severity. - /// - public class MockSchematicLogger : ISchematicLogger - { - public List LogEntries { get; } = new(); - - public void Debug(string message, params object[] args) - { - LogEntries.Add(new LogEntry(LogLevel.Debug, message, args)); - } - - public void Error(string message, params object[] args) - { - LogEntries.Add(new LogEntry(LogLevel.Error, message, args)); - } - - public void Info(string message, params object[] args) - { - LogEntries.Add(new LogEntry(LogLevel.Info, message, args)); - } - - public void Warn(string message, params object[] args) - { - LogEntries.Add(new LogEntry(LogLevel.Warn, message, args)); - } - - public bool HasLogEntry(LogLevel level, string messageContains) - { - return LogEntries.Any(e => e.Level == level && e.Message.Contains(messageContains)); - } - } - - public class LogEntry - { - public LogEntry(LogLevel level, string message, object[] args) - { - Level = level; - Message = message; - Args = args; - } - - public LogLevel Level { get; } - public string Message { get; } - public object[] Args { get; } - - public string FormattedMessage => string.Format(Message, Args); - } -} diff --git a/src/SchematicHQ.Client.Test/Datastream/ReplicatorHealthServiceTests.cs b/src/SchematicHQ.Client.Test/Datastream/ReplicatorHealthServiceTests.cs index ba6a06e8..aae58357 100644 --- a/src/SchematicHQ.Client.Test/Datastream/ReplicatorHealthServiceTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/ReplicatorHealthServiceTests.cs @@ -1,4 +1,5 @@ using NUnit.Framework; +using Microsoft.Extensions.Logging.Testing; using Moq; using Moq.Protected; using System; @@ -20,7 +21,7 @@ public async Task PerformHealthCheck_WhenReplicatorReady_SetsHealthyToTrue() // Arrange var mockHttpMessageHandler = new Mock(); var httpClient = new HttpClient(mockHttpMessageHandler.Object); - var logger = new MockSchematicLogger(); + var logger = new FakeLogger(); var healthResponse = """ { @@ -71,7 +72,7 @@ public async Task PerformHealthCheck_WhenReplicatorNotReady_SetsHealthyToFalse() // Arrange var mockHttpMessageHandler = new Mock(); var httpClient = new HttpClient(mockHttpMessageHandler.Object); - var logger = new MockSchematicLogger(); + var logger = new FakeLogger(); var healthResponse = """ { @@ -122,7 +123,7 @@ public async Task CacheVersionChanged_WhenVersionChanges_FiresEvent() // Arrange var mockHttpMessageHandler = new Mock(); var httpClient = new HttpClient(mockHttpMessageHandler.Object); - var logger = new MockSchematicLogger(); + var logger = new FakeLogger(); var eventCallCount = 0; string? firstEventOldVersion = null; @@ -234,7 +235,7 @@ public void Dispose_CleansUpResources() { // Arrange var httpClient = new HttpClient(); - var logger = new MockSchematicLogger(); + var logger = new FakeLogger(); var service = new ReplicatorHealthService(httpClient, "http://test/ready", logger); // Act & Assert - should not throw diff --git a/src/SchematicHQ.Client.Test/SchematicHQ.Client.Test.Custom.props b/src/SchematicHQ.Client.Test/SchematicHQ.Client.Test.Custom.props index 7f6d6963..9f59d19a 100644 --- a/src/SchematicHQ.Client.Test/SchematicHQ.Client.Test.Custom.props +++ b/src/SchematicHQ.Client.Test/SchematicHQ.Client.Test.Custom.props @@ -9,6 +9,7 @@ Configure additional MSBuild properties for your project in this file: false + diff --git a/src/SchematicHQ.Client.Test/TestClient.cs b/src/SchematicHQ.Client.Test/TestClient.cs index b3ebd6ca..72be70e3 100644 --- a/src/SchematicHQ.Client.Test/TestClient.cs +++ b/src/SchematicHQ.Client.Test/TestClient.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using Moq; using NUnit.Framework; using System.Net; @@ -8,6 +10,7 @@ using SchematicHQ.Client.Core; using SchematicHQ.Client.Cache; using SchematicHQ.Client.RulesEngine; +using SchematicHQ.Client.Test.Datastream.Mocks; namespace SchematicHQ.Client.Test { @@ -16,7 +19,7 @@ public class SchematicTests { private Schematic _schematic; private ClientOptions _options; - private Mock _logger; + private FakeLogger _logger; private int _defaultEventBufferPeriod = 3; // seconds private HttpResponseMessage CreateCheckFlagResponse(HttpStatusCode code, bool flagValue) @@ -101,7 +104,7 @@ private void SetupSchematicTestClient(bool isOffline, HttpResponseMessage respon HttpClient testClient = new HttpClient(new OfflineHttpMessageHandler()); _options = new ClientOptions { - Logger = _logger.Object, + LoggerFactory = _logger.ToLoggerFactory(), Offline = isOffline, FlagDefaults = flagDefaults ?? new Dictionary(), DefaultEventBufferPeriod = TimeSpan.FromSeconds(_defaultEventBufferPeriod), @@ -122,7 +125,7 @@ private void SetupSchematicTestClient(bool isOffline, HttpResponseMessage respon [SetUp] public void Setup() { - _logger = new Mock(); + _logger = new FakeLogger(); } [TearDown] @@ -207,7 +210,7 @@ public async Task CheckFlag_LogsErrorAndReturnsDefaultOnException() // Assert Assert.That(result, Is.True); // default is true for the test flag - _logger.Verify(logger => logger.Error("Error checking flag via API: {0}", It.IsAny()), Times.Once); + Assert.That(_logger.HasLogEntry(LogLevel.Error, "Error checking flag via API"), Is.True); } [Test] @@ -450,7 +453,7 @@ public async Task CheckFlag_WithCacheDisabled_AlwaysCallsAPI() var testClient = handler.CreateClient(); _options = new ClientOptions { - Logger = _logger.Object, + LoggerFactory = _logger.ToLoggerFactory(), Offline = false, FlagDefaults = new Dictionary(), DefaultEventBufferPeriod = TimeSpan.FromSeconds(_defaultEventBufferPeriod), @@ -486,7 +489,7 @@ public async Task CheckFlag_ReturnsFalseOnErrorWithNoDefault() // Assert Assert.That(result, Is.False); - _logger.Verify(logger => logger.Error("Error checking flag via API: {0}", It.IsAny()), Times.Once); + Assert.That(_logger.HasLogEntry(LogLevel.Error, "Error checking flag via API"), Is.True); } [Test] @@ -500,7 +503,7 @@ public async Task CheckFlag_DifferentContextsProduceDifferentCacheKeys() var testClient = handler.CreateClient(); _options = new ClientOptions { - Logger = _logger.Object, + LoggerFactory = _logger.ToLoggerFactory(), Offline = false, FlagDefaults = new Dictionary(), DefaultEventBufferPeriod = TimeSpan.FromSeconds(_defaultEventBufferPeriod), diff --git a/src/SchematicHQ.Client.Test/TestCompaniesClient.cs b/src/SchematicHQ.Client.Test/TestCompaniesClient.cs index f19b037a..3ff2e8b1 100644 --- a/src/SchematicHQ.Client.Test/TestCompaniesClient.cs +++ b/src/SchematicHQ.Client.Test/TestCompaniesClient.cs @@ -1,3 +1,6 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; using Moq; using NUnit.Framework; using System.Net; @@ -13,7 +16,6 @@ public class CompaniesClientTests { private Schematic _schematic; private ClientOptions _options; - private Mock _logger; private HttpResponseMessage CreateUpsertCompanyResponse(HttpStatusCode code) { @@ -58,10 +60,9 @@ private HttpResponseMessage CreateUpsertUserResponse(HttpStatusCode code) private void SetupSchematicTestClient(HttpResponseMessage response) { - _logger = new Mock(); _options = new ClientOptions { - Logger = _logger.Object + LoggerFactory = NullLoggerFactory.Instance }; var handler = new Mock(MockBehavior.Strict); diff --git a/src/SchematicHQ.Client.Test/TestEventBuffer.cs b/src/SchematicHQ.Client.Test/TestEventBuffer.cs index 6bd2a20b..b8a48edc 100644 --- a/src/SchematicHQ.Client.Test/TestEventBuffer.cs +++ b/src/SchematicHQ.Client.Test/TestEventBuffer.cs @@ -1,4 +1,5 @@ -using Moq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; namespace SchematicHQ.Client.Tests @@ -6,14 +7,14 @@ namespace SchematicHQ.Client.Tests [TestFixture] public class EventBufferTests { - private Mock _mockLogger; + private ILogger _mockLogger; private EventBuffer _buffer; private List _processedItems; [SetUp] public void SetUp() { - _mockLogger = new Mock(); + _mockLogger = NullLogger.Instance; _processedItems = new List(); _buffer = new EventBuffer(async items => { @@ -22,7 +23,7 @@ public void SetUp() _processedItems.AddRange(items); } await Task.CompletedTask; // Simulate async operation - }, _mockLogger.Object); + }, _mockLogger); } [TearDown] @@ -83,7 +84,7 @@ public void PeriodicFlush_FlushesAtInterval() _processedItems.AddRange(items); autoResetEvent.Set(); await Task.CompletedTask; // Simulate async operation - }, _mockLogger.Object, 100, TimeSpan.FromMilliseconds(500)); + }, _mockLogger, 100, TimeSpan.FromMilliseconds(500)); _buffer.Start(); _buffer.Push(1); @@ -119,7 +120,7 @@ public async Task Flush_DoesNotInterfereWithPeriodicFlush() manualFlushes++; } await Task.CompletedTask; - }, _mockLogger.Object, 100, TimeSpan.FromMilliseconds(500)); + }, _mockLogger, 100, TimeSpan.FromMilliseconds(500)); _buffer.Start(); @@ -153,7 +154,7 @@ public async Task StartAndStop_ConcurrencyTest() processedItems.AddRange(items); } await Task.CompletedTask; - }, _mockLogger.Object); + }, _mockLogger); buffer.Start(); await Task.Delay(10); @@ -218,7 +219,7 @@ public void SizeBasedFlush_TriggersWhenMaxSizeReached() } autoResetEvent.Set(); await Task.CompletedTask; - }, _mockLogger.Object, maxSize, TimeSpan.FromSeconds(60)); + }, _mockLogger, maxSize, TimeSpan.FromSeconds(60)); _buffer.Start(); @@ -251,7 +252,7 @@ public async Task Flush_RetriesOnTransientFailure() flushedItems.AddRange(items); } await Task.CompletedTask; - }, _mockLogger.Object); + }, _mockLogger); _buffer.Start(); _buffer.Push(1); @@ -274,7 +275,7 @@ public async Task Flush_DiscardsAfterMaxRetries() Interlocked.Increment(ref callCount); await Task.CompletedTask; throw new Exception("Persistent failure"); - }, _mockLogger.Object); + }, _mockLogger); _buffer.Start(); _buffer.Push(1); @@ -298,7 +299,7 @@ public async Task Stop_FlushesRemainingEvents() flushedItems.AddRange(items); } await Task.CompletedTask; - }, _mockLogger.Object, 100, TimeSpan.FromSeconds(60)); + }, _mockLogger, 100, TimeSpan.FromSeconds(60)); _buffer.Start(); _buffer.Push(1); diff --git a/src/SchematicHQ.Client.Test/TestEventCaptureClient.cs b/src/SchematicHQ.Client.Test/TestEventCaptureClient.cs index 132c7856..dd67f82c 100644 --- a/src/SchematicHQ.Client.Test/TestEventCaptureClient.cs +++ b/src/SchematicHQ.Client.Test/TestEventCaptureClient.cs @@ -1,5 +1,7 @@ using System.Net; using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using Moq.Protected; using NUnit.Framework; @@ -10,14 +12,14 @@ namespace SchematicHQ.Client.Tests [TestFixture] public class EventCaptureClientTests { - private Mock _mockLogger; + private ILogger _mockLogger; private Mock _mockHandler; private HttpClient _httpClient; [SetUp] public void SetUp() { - _mockLogger = new Mock(); + _mockLogger = NullLogger.Instance; _mockHandler = new Mock(); _httpClient = new HttpClient(_mockHandler.Object); } @@ -42,7 +44,7 @@ public async Task SendBatchAsync_SendsToCorrectEndpoint() .Callback((req, _) => capturedRequest = req) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); - var client = new EventCaptureClient(_httpClient, "test_api_key", _mockLogger.Object); + var client = new EventCaptureClient(_httpClient, "test_api_key", _mockLogger); var events = new List { new CreateEventRequestBody @@ -73,7 +75,7 @@ public async Task SendBatchAsync_UsesCustomBaseUrl() .Callback((req, _) => capturedRequest = req) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); - var client = new EventCaptureClient(_httpClient, "test_api_key", _mockLogger.Object, "https://custom.capture.com"); + var client = new EventCaptureClient(_httpClient, "test_api_key", _mockLogger, "https://custom.capture.com"); var events = new List { new CreateEventRequestBody @@ -105,7 +107,7 @@ public async Task SendBatchAsync_IncludesApiKeyInPayload() }) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); - var client = new EventCaptureClient(_httpClient, "test_api_key_123", _mockLogger.Object); + var client = new EventCaptureClient(_httpClient, "test_api_key_123", _mockLogger); var events = new List { new CreateEventRequestBody @@ -144,7 +146,7 @@ public async Task SendBatchAsync_TransformsMultipleEvents() }) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); - var client = new EventCaptureClient(_httpClient, "test_key", _mockLogger.Object); + var client = new EventCaptureClient(_httpClient, "test_key", _mockLogger); var events = new List { new CreateEventRequestBody @@ -192,7 +194,7 @@ public async Task SendBatchAsync_SkipsEmptyList() ItExpr.IsAny()) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); - var client = new EventCaptureClient(_httpClient, "test_key", _mockLogger.Object); + var client = new EventCaptureClient(_httpClient, "test_key", _mockLogger); await client.SendBatchAsync(new List()); _mockHandler @@ -218,7 +220,7 @@ public void SendBatchAsync_ThrowsOnHttpError() Content = new StringContent("Internal Server Error") }); - var client = new EventCaptureClient(_httpClient, "test_key", _mockLogger.Object); + var client = new EventCaptureClient(_httpClient, "test_key", _mockLogger); var events = new List { new CreateEventRequestBody @@ -249,7 +251,7 @@ public async Task SendBatchAsync_IncludesSentAtWhenPresent() }) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); - var client = new EventCaptureClient(_httpClient, "test_key", _mockLogger.Object); + var client = new EventCaptureClient(_httpClient, "test_key", _mockLogger); var events = new List { new CreateEventRequestBody diff --git a/src/SchematicHQ.Client.Test/TestLogger.cs b/src/SchematicHQ.Client.Test/TestLogger.cs deleted file mode 100644 index 8195b630..00000000 --- a/src/SchematicHQ.Client.Test/TestLogger.cs +++ /dev/null @@ -1,241 +0,0 @@ -using NUnit.Framework; - -namespace SchematicHQ.Client.Tests -{ - [TestFixture] - public class LoggerTests - { - private StringWriter _stringWriter; - private ConsoleLogger _logger; - - [SetUp] - public void SetUp() - { - //redirect console output - _stringWriter = new StringWriter(); - Console.SetOut(_stringWriter); - // The shared tests below exercise every level, so use Debug here. - // Level-filter behavior is covered by the dedicated tests further down. - _logger = new ConsoleLogger(LogLevel.Debug); - } - - [TearDown] - public void TearDown() - { - _stringWriter.Dispose(); - // reset console output writer - Console.SetOut(Console.Out); - } - - [Test] - public void Error_ShouldLogErrorMessage() - { - // Arrange - var message = "This is an error message"; - - // Act - _logger.Error(message); - - // Assert - var output = _stringWriter.ToString().Trim(); - Assert.That(output.Contains("[ERROR]"), Is.True); - Assert.That(output.Contains(message), Is.True); - } - - [Test] - public void Test_Warn_ShouldLogWarnMessage() - { - // Arrange - var message = "This is a warning message"; - - // Act - _logger.Warn(message); - - // Assert - var output = _stringWriter.ToString().Trim(); - Assert.That(output.Contains("[WARN]"), Is.True); - Assert.That(output.Contains(message), Is.True); - } - - [Test] - public void Test_Info_ShouldLogInfoMessage() - { - // Arrange - var message = "This is an info message"; - - // Act - _logger.Info(message); - - // Assert - var output = _stringWriter.ToString().Trim(); - Assert.That(output.Contains("[INFO]"), Is.True); - Assert.That(output.Contains(message), Is.True); - } - - [Test] - public void Test_Debug_ShouldLogDebugMessage() - { - // Arrange - var message = "This is a debug message"; - - // Act - _logger.Debug(message); - - // Assert - var output = _stringWriter.ToString().Trim(); - Assert.That(output.Contains("[DEBUG]"), Is.True); - Assert.That(output.Contains(message), Is.True); - } - - [Test] - public void Test_Error_ShouldFormatMessageWithArgs() - { - // Arrange - var message = "Error {0}"; - var arg = "123"; - - // Act - _logger.Error(message, arg); - - // Assert - var output = _stringWriter.ToString().Trim(); - Assert.That(output.Contains("[ERROR]"), Is.True); - Assert.That(output.Contains("Error 123"), Is.True); - } - - [Test] - public void Test_Warn_ShouldFormatMessageWithArgs() - { - // Arrange - var message = "Warning {0}"; - var arg = "123"; - - // Act - _logger.Warn(message, arg); - - // Assert - var output = _stringWriter.ToString().Trim(); - Assert.That(output.Contains("[WARN]"), Is.True); - Assert.That(output.Contains("Warning 123"), Is.True); - } - - [Test] - public void Test_Info_ShouldFormatMessageWithArgs() - { - // Arrange - var message = "Info {0}"; - var arg = "123"; - - // Act - _logger.Info(message, arg); - - // Assert - var output = _stringWriter.ToString().Trim(); - Assert.That(output.Contains("[INFO]"), Is.True); - Assert.That(output.Contains("Info 123"), Is.True); - } - - [Test] - public void Test_Debug_ShouldFormatMessageWithArgs() - { - // Arrange - var message = "Debug {0}"; - var arg = "123"; - - // Act - _logger.Debug(message, arg); - - // Assert - var output = _stringWriter.ToString().Trim(); - Assert.That(output.Contains("[DEBUG]"), Is.True); - Assert.That(output.Contains("Debug 123"), Is.True); - } - - [Test] - public void DefaultConstructor_DefaultsToWarnLevel() - { - // Per the SDK spec: a ConsoleLogger with no level argument must - // suppress Debug and Info but emit Warn and Error. - var logger = new ConsoleLogger(); - - logger.Debug("debug-msg"); - logger.Info("info-msg"); - logger.Warn("warn-msg"); - logger.Error("error-msg"); - - var output = _stringWriter.ToString(); - Assert.That(output.Contains("debug-msg"), Is.False); - Assert.That(output.Contains("info-msg"), Is.False); - Assert.That(output.Contains("warn-msg"), Is.True); - Assert.That(output.Contains("error-msg"), Is.True); - } - - [Test] - public void WarnLevel_FiltersDebugAndInfo() - { - var logger = new ConsoleLogger(LogLevel.Warn); - - logger.Debug("debug-msg"); - logger.Info("info-msg"); - logger.Warn("warn-msg"); - logger.Error("error-msg"); - - var output = _stringWriter.ToString(); - Assert.That(output.Contains("debug-msg"), Is.False); - Assert.That(output.Contains("info-msg"), Is.False); - Assert.That(output.Contains("warn-msg"), Is.True); - Assert.That(output.Contains("error-msg"), Is.True); - } - - [Test] - public void InfoLevel_FiltersDebugOnly() - { - var logger = new ConsoleLogger(LogLevel.Info); - - logger.Debug("debug-msg"); - logger.Info("info-msg"); - logger.Warn("warn-msg"); - logger.Error("error-msg"); - - var output = _stringWriter.ToString(); - Assert.That(output.Contains("debug-msg"), Is.False); - Assert.That(output.Contains("info-msg"), Is.True); - Assert.That(output.Contains("warn-msg"), Is.True); - Assert.That(output.Contains("error-msg"), Is.True); - } - - [Test] - public void ErrorLevel_OnlyEmitsError() - { - var logger = new ConsoleLogger(LogLevel.Error); - - logger.Debug("debug-msg"); - logger.Info("info-msg"); - logger.Warn("warn-msg"); - logger.Error("error-msg"); - - var output = _stringWriter.ToString(); - Assert.That(output.Contains("debug-msg"), Is.False); - Assert.That(output.Contains("info-msg"), Is.False); - Assert.That(output.Contains("warn-msg"), Is.False); - Assert.That(output.Contains("error-msg"), Is.True); - } - - [Test] - public void DebugLevel_EmitsAllLevels() - { - var logger = new ConsoleLogger(LogLevel.Debug); - - logger.Debug("debug-msg"); - logger.Info("info-msg"); - logger.Warn("warn-msg"); - logger.Error("error-msg"); - - var output = _stringWriter.ToString(); - Assert.That(output.Contains("debug-msg"), Is.True); - Assert.That(output.Contains("info-msg"), Is.True); - Assert.That(output.Contains("warn-msg"), Is.True); - Assert.That(output.Contains("error-msg"), Is.True); - } - } -} \ No newline at end of file diff --git a/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs b/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs index 988d35f4..3b795608 100644 --- a/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs +++ b/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs @@ -1,4 +1,5 @@ -using System.Net.Http; + +using Microsoft.Extensions.Logging; using SchematicHQ.Client.Core; using SchematicHQ.Client.Cache; using SchematicHQ.Client.RulesEngine; @@ -9,25 +10,11 @@ namespace SchematicHQ.Client; public partial class ClientOptions { - public Dictionary FlagDefaults { get; set; } = new Dictionary(); - - /// - /// Custom logger implementation. When null (the default), the SDK uses - /// a configured with . - /// When a custom logger is provided, the SDK does not override or wrap - /// its level — the provided logger's own configuration is the source of - /// truth and is ignored. - /// - public ISchematicLogger? Logger { get; set; } - - /// - /// Level for the default . Defaults to - /// ; raise to - /// for verbose diagnostics. Ignored when a custom - /// is provided. - /// - public LogLevel LogLevel { get; set; } = LogLevel.Warn; + private static readonly ILoggerFactory DefaultLoggerFactory = + Microsoft.Extensions.Logging.LoggerFactory.Create(builder => builder.AddSimpleConsole()); + public Dictionary FlagDefaults { get; set; } = new Dictionary(); + public ILoggerFactory LoggerFactory { get; set; } = DefaultLoggerFactory; public List> CacheProviders { get; set; } = new List>(); public CacheConfiguration? CacheConfiguration { get; set; } public bool Offline { get; set; } @@ -69,8 +56,7 @@ public static ClientOptions WithHttpClient(this ClientOptions options, HttpClien FlagDefaults = options.FlagDefaults, Headers = new Headers(new Dictionary(options.Headers)), HttpClient = httpClient, - Logger = options.Logger, - LogLevel = options.LogLevel, + LoggerFactory = options.LoggerFactory, MaxRetries = options.MaxRetries, Offline = options.Offline, ReplicatorMode = options.ReplicatorMode, diff --git a/src/SchematicHQ.Client/Datastream/Client.cs b/src/SchematicHQ.Client/Datastream/Client.cs index 15628bc4..0b8021b9 100644 --- a/src/SchematicHQ.Client/Datastream/Client.cs +++ b/src/SchematicHQ.Client/Datastream/Client.cs @@ -16,7 +16,7 @@ namespace SchematicHQ.Client.Datastream { public class DatastreamClient : IDisposable { - private readonly ISchematicLogger _logger; + private readonly ILogger _logger; private readonly string _apiKey; private readonly Uri _baseUrl; private readonly TimeSpan _cacheTtl; @@ -80,7 +80,7 @@ public class DatastreamClient : IDisposable public DatastreamClient( string baseUrl, - ISchematicLogger logger, + ILogger logger, string apiKey, Action connectionStateCallback, TimeSpan? cacheTtl = null, @@ -119,7 +119,7 @@ public DatastreamClient( { try { - _logger.Info("Initializing Redis cache for Datastream company, user and flag data"); + _logger.LogInformation("Initializing Redis cache for Datastream company, user and flag data"); // We need to use the Cache namespace version, but cast it to the Client namespace interface _companyCache = new RedisCache(options.RedisConfig); _userCache = new RedisCache(options.RedisConfig); @@ -131,7 +131,7 @@ public DatastreamClient( } catch (Exception ex) { - _logger.Error("Failed to initialize Redis cache: {0}. Falling back to local cache.", ex.Message); + _logger.LogError(ex, "Failed to initialize Redis cache. Falling back to local cache."); _companyCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); _userCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); _companyLookupCache = new LocalCache(options.LocalCacheCapacity, _cacheTtl); @@ -224,7 +224,7 @@ private async Task ConnectAndReadAsync() { await _webSocket.ConnectAsync(_baseUrl, _cancellationTokenSource.Token); - _logger.Info("Connected to Schematic WebSocket"); + _logger.LogInformation("Connected to Schematic WebSocket"); attempts = 0; // Signal connection state @@ -264,7 +264,7 @@ private async Task ConnectAndReadAsync() catch (Exception connectEx) { // Handle connection errors specifically - _logger.Error("Failed to connect to WebSocket: {0}", connectEx.Message); + _logger.LogError(connectEx, "Failed to connect to WebSocket"); // Don't rethrow - allow the outer exception handler to handle retries throw; } @@ -283,7 +283,7 @@ await _webSocket.CloseAsync( } catch (Exception ex) { - _logger.Error("Error closing WebSocket: {0}", ex.Message); + _logger.LogError(ex, "Error closing WebSocket"); _webSocket.Abort(); } } @@ -292,14 +292,14 @@ await _webSocket.CloseAsync( catch (WebSocketAuthenticationException authEx) { _reconnectSemaphore.Release(); - _logger.Error(authEx.Message); + _logger.LogError(authEx, "WebSocket authentication failed (close code {CloseCode})", authEx.CloseCode); _connectionStateCallback(false); return; } catch (Exception connectionEx) { _reconnectSemaphore.Release(); - _logger.Error("WebSocket connection error: {0}", connectionEx.Message); + _logger.LogError(connectionEx, "WebSocket connection error"); attempts++; _connectionStateCallback(false); @@ -311,7 +311,7 @@ await _webSocket.CloseAsync( if (attempts >= MaxReconnectAttempts) { - _logger.Error("Unable to connect to server after {0} attempts", MaxReconnectAttempts); + _logger.LogError("Unable to connect to server after {MaxReconnectAttempts} attempts", MaxReconnectAttempts); return; } @@ -346,7 +346,7 @@ private async Task ReadMessagesAsync() { var buffer = new byte[4096]; var receiveBuffer = new List(); - _logger.Info("Starting to read messages from WebSocket"); + _logger.LogInformation("Starting to read messages from WebSocket"); try { while (_webSocket.State == WebSocketState.Open && !_cancellationTokenSource.Token.IsCancellationRequested) @@ -384,7 +384,7 @@ private async Task ReadMessagesAsync() if (string.IsNullOrEmpty(message)) { - _logger.Debug("Received empty message from WebSocket"); + _logger.LogDebug("Received empty message from WebSocket"); return; // Trigger reconnection } @@ -395,13 +395,13 @@ private async Task ReadMessagesAsync() } catch (Exception ex) { - _logger.Error("Failed to process WebSocket message: {0}", ex.Message); + _logger.LogError(ex, "Failed to process WebSocket message"); } } } catch (OperationCanceledException) { - _logger.Info("WebSocket read operation was cancelled"); + _logger.LogInformation("WebSocket read operation was cancelled"); return; } catch (WebSocketAuthenticationException) @@ -410,7 +410,7 @@ private async Task ReadMessagesAsync() } catch (Exception ex) { - _logger.Error("Error reading from WebSocket: {0}", ex.Message); + _logger.LogError(ex, "Error reading from WebSocket"); return; // Exit and trigger reconnection } } @@ -421,14 +421,14 @@ private async Task ReadMessagesAsync() } catch (Exception ex) { - _logger.Error("Fatal error in ReadMessagesAsync: {0}", ex.Message); + _logger.LogError(ex, "Fatal error in ReadMessagesAsync"); } finally { // Signal that reconnection should happen if (!_cancellationTokenSource.IsCancellationRequested) { - _logger.Info("Signaling for WebSocket reconnection"); + _logger.LogInformation("Signaling for WebSocket reconnection"); _readCancellationSource.Cancel(); } } @@ -462,7 +462,7 @@ private void HandleMessageResponse(DataStreamResponse? message) HandleUserMessage(message); break; default: - _logger.Error("Received unknown entity type: {0}", message.EntityType); + _logger.LogError("Received unknown entity type: {EntityType}", message.EntityType); break; } } @@ -473,7 +473,7 @@ private void HandleFlagsMessage(DataStreamResponse response) { if (response.Data == null) { - _logger.Warn("Received empty flags data"); + _logger.LogWarning("Received empty flags data"); return; } @@ -490,7 +490,7 @@ private void HandleFlagsMessage(DataStreamResponse response) if (flags == null || flags.Count == 0) { - _logger.Warn("Received empty or null flags list"); + _logger.LogWarning("Received empty or null flags list"); return; } @@ -498,7 +498,7 @@ private void HandleFlagsMessage(DataStreamResponse response) { if (string.IsNullOrEmpty(flag.Key)) { - _logger.Debug("Flag key is null, skipping flag: {0}", flag.Id); + _logger.LogDebug("Flag key is null, skipping flag: {FlagId}", flag.Id); continue; } var cacheKey = FlagCacheKey(flag.Key); @@ -518,7 +518,7 @@ private void HandleFlagsMessage(DataStreamResponse response) } catch (Exception ex) { - _logger.Error("Failed to handle flags message: {0}", ex.Message); + _logger.LogError(ex, "Failed to handle flags message"); } } @@ -528,7 +528,7 @@ private void HandleFlagMessage(DataStreamResponse response) { if (response.Data == null) { - _logger.Warn("Received empty flag data"); + _logger.LogWarning("Received empty flag data"); return; } @@ -568,11 +568,11 @@ private void HandleFlagMessage(DataStreamResponse response) { var deleteCacheKey = FlagCacheKey(flagKey); _flagsCache.Delete(deleteCacheKey); - _logger.Debug("Deleted single flag from cache: {0}", flagKey); + _logger.LogDebug("Deleted single flag from cache: {FlagKey}", flagKey); } else { - _logger.Warn("Could not extract flag key from delete message"); + _logger.LogWarning("Could not extract flag key from delete message"); } return; @@ -583,25 +583,25 @@ private void HandleFlagMessage(DataStreamResponse response) if (flag == null) { - _logger.Warn("Received null flag data"); + _logger.LogWarning("Received null flag data"); return; } if (string.IsNullOrEmpty(flag.Key)) { - _logger.Debug("Flag key is null, skipping flag: {0}", flag.Id); + _logger.LogDebug("Flag key is null, skipping flag: {FlagId}", flag.Id); return; } var cacheKey = FlagCacheKey(flag.Key); _flagsCache.Set(cacheKey, flag); - _logger.Debug("Cached single flag: {0}", flag.Key); + _logger.LogDebug("Cached single flag: {FlagKey}", flag.Key); // Note: Unlike bulk flags processing, we do NOT call DeleteMissing for single flag updates } catch (Exception ex) { - _logger.Error("Failed to handle single flag message: {0}", ex.Message); + _logger.LogError(ex, "Failed to handle single flag message"); } } @@ -639,7 +639,7 @@ private async void HandleCompanyMessage(DataStreamResponse response) { if (response.Data == null) { - _logger.Warn("Received empty company data"); + _logger.LogWarning("Received empty company data"); return; } @@ -660,7 +660,7 @@ private async void HandleCompanyMessage(DataStreamResponse response) { if (string.IsNullOrEmpty(response.EntityId)) { - _logger.Error("Partial company message missing entity_id"); + _logger.LogError("Partial company message missing entity_id"); return; } var id = response.EntityId; @@ -669,7 +669,7 @@ private async void HandleCompanyMessage(DataStreamResponse response) var existing = _companyCache.Get(existingIdKey); if (existing == null) { - _logger.Warn("Cache miss for partial company '{0}', skipping", id); + _logger.LogWarning("Cache miss for partial company '{CompanyId}', skipping", id); return; } @@ -679,7 +679,7 @@ private async void HandleCompanyMessage(DataStreamResponse response) } catch (Exception ex) { - _logger.Error("Failed to merge partial company: {0}", ex.Message); + _logger.LogError(ex, "Failed to merge partial company"); return; } } @@ -690,7 +690,7 @@ private async void HandleCompanyMessage(DataStreamResponse response) if (company == null) { - _logger.Warn("Received null company data"); + _logger.LogWarning("Received null company data"); return; } @@ -702,7 +702,7 @@ private async void HandleCompanyMessage(DataStreamResponse response) // Wait for the lock with a reasonable timeout if (!await companyLock.WaitAsync(TimeSpan.FromSeconds(5))) { - _logger.Warn("Timeout waiting for company lock during WebSocket update"); + _logger.LogWarning("Timeout waiting for company lock during WebSocket update"); return; } @@ -736,12 +736,12 @@ private async void HandleCompanyMessage(DataStreamResponse response) } catch (Exception lockEx) { - _logger.Error($"Error during locked company update: {lockEx.Message}"); + _logger.LogError(lockEx, "Error during locked company update"); } } catch (Exception ex) { - _logger.Error("Failed to handle company message: {0}", ex.Message); + _logger.LogError(ex, "Failed to handle company message"); } } @@ -751,7 +751,7 @@ private void HandleUserMessage(DataStreamResponse response) { if (response.Data == null) { - _logger.Warn("Received empty user data"); + _logger.LogWarning("Received empty user data"); return; } @@ -771,7 +771,7 @@ private void HandleUserMessage(DataStreamResponse response) { if (string.IsNullOrEmpty(response.EntityId)) { - _logger.Error("Partial user message missing entity_id"); + _logger.LogError("Partial user message missing entity_id"); return; } var id = response.EntityId; @@ -780,7 +780,7 @@ private void HandleUserMessage(DataStreamResponse response) var existing = _userCache.Get(existingIdKey); if (existing == null) { - _logger.Warn("Cache miss for partial user '{0}', skipping", id); + _logger.LogWarning("Cache miss for partial user '{UserId}', skipping", id); return; } @@ -790,7 +790,7 @@ private void HandleUserMessage(DataStreamResponse response) } catch (Exception ex) { - _logger.Error("Failed to merge partial user: {0}", ex.Message); + _logger.LogError(ex, "Failed to merge partial user"); return; } } @@ -801,7 +801,7 @@ private void HandleUserMessage(DataStreamResponse response) if (user == null) { - _logger.Warn("Received null user data"); + _logger.LogWarning("Received null user data"); return; } @@ -814,7 +814,7 @@ private void HandleUserMessage(DataStreamResponse response) { var resourceKey = ResourceKeyToCacheKey(CacheKeyPrefixUser, key.Key, key.Value); _userLookupCache.Delete(resourceKey); - _logger.Debug("Deleted user from cache with key: {0}", resourceKey); + _logger.LogDebug("Deleted user from cache with key: {ResourceKey}", resourceKey); } return; } @@ -827,7 +827,7 @@ private void HandleUserMessage(DataStreamResponse response) } catch (Exception ex) { - _logger.Error("Failed to handle user message: {0}", ex.Message); + _logger.LogError(ex, "Failed to handle user message"); } } @@ -837,7 +837,7 @@ private void HandleErrorMessage(DataStreamResponse response) { if (response.Data == null) { - _logger.Warn("Received empty error data"); + _logger.LogWarning("Received empty error data"); return; } @@ -848,7 +848,7 @@ private void HandleErrorMessage(DataStreamResponse response) { if (!string.IsNullOrEmpty(error.Error)) { - _logger.Error("Received error from server: {0}", error.Error); + _logger.LogError("Received error from server: {ServerError}", error.Error); } // Check if we have keys and entity type in the error response @@ -863,7 +863,7 @@ private void HandleErrorMessage(DataStreamResponse response) NotifyPendingRequests(null, error.Keys, CacheKeyPrefixUser, _pendingUserRequests); break; default: - _logger.Warn("Received error for unsupported entity type: {0}", error.EntityType.Value); + _logger.LogWarning("Received error for unsupported entity type: {EntityType}", error.EntityType.Value); break; } } @@ -871,7 +871,7 @@ private void HandleErrorMessage(DataStreamResponse response) } catch (Exception ex) { - _logger.Error("Failed to deserialize error message: {0}", ex.Message); + _logger.LogError(ex, "Failed to deserialize error message"); } } @@ -884,7 +884,7 @@ internal async Task CheckFlag(RulesengineCompany? company, Rule } catch (Exception ex) { - _logger.Error("Error checking flag {0}: {1}", flag.Key, ex.Message); + _logger.LogError(ex, "Error checking flag {FlagKey}", flag.Key); return new CheckFlagResult { Reason = "Error", @@ -1034,7 +1034,7 @@ private async Task GetAllFlagsAsync(CancellationToken cancellationToken) } else if (_webSocket.State != WebSocketState.Open) { - _logger.Warn("WebSocket is not open, cannot request flags data"); + _logger.LogWarning("WebSocket is not open, cannot request flags data"); return; } @@ -1130,13 +1130,13 @@ public async Task UpdateCompanyMetricsAsync(EventBodyTrack eventBody) { if (eventBody == null) { - _logger.Error("Event body cannot be null"); + _logger.LogError("Event body cannot be null"); return false; } if (eventBody.Company == null || eventBody.Company.Count == 0) { - _logger.Error("No keys provided for company lookup"); + _logger.LogError("No keys provided for company lookup"); return false; } @@ -1148,7 +1148,7 @@ public async Task UpdateCompanyMetricsAsync(EventBodyTrack eventBody) // Wait for the lock with a reasonable timeout to prevent deadlocks if (!await companyLock.WaitAsync(TimeSpan.FromSeconds(5))) { - _logger.Warn("Timeout waiting for company lock during metrics update"); + _logger.LogWarning("Timeout waiting for company lock during metrics update"); return false; } @@ -1165,7 +1165,7 @@ public async Task UpdateCompanyMetricsAsync(EventBodyTrack eventBody) var companyCopy = DeepCopyCompany(company); if (companyCopy == null) { - _logger.Error("Failed to create deep copy of company"); + _logger.LogError("Failed to create deep copy of company"); return false; } @@ -1183,7 +1183,7 @@ public async Task UpdateCompanyMetricsAsync(EventBodyTrack eventBody) if (!metricUpdated) { - _logger.Debug($"No matching metric found for event {eventBody.Event}"); + _logger.LogDebug("No matching metric found for event {EventName}", eventBody.Event); return false; } @@ -1194,7 +1194,7 @@ public async Task UpdateCompanyMetricsAsync(EventBodyTrack eventBody) } catch (Exception ex) { - _logger.Warn($"Failed to cache company metrics: {ex.Message}"); + _logger.LogWarning(ex, "Failed to cache company metrics"); return false; } @@ -1208,7 +1208,7 @@ public async Task UpdateCompanyMetricsAsync(EventBodyTrack eventBody) } catch (Exception ex) { - _logger.Error($"Error during company metrics update: {ex.Message}"); + _logger.LogError(ex, "Error during company metrics update"); return false; } } @@ -1227,7 +1227,7 @@ public bool UpdateCompanyMetrics(EventBodyTrack eventBody) } catch (Exception ex) { - _logger.Error($"Error in synchronous company metrics update: {ex.Message}"); + _logger.LogError(ex, "Error in synchronous company metrics update"); return false; } } /// @@ -1317,16 +1317,16 @@ await _webSocket.SendAsync( private string GetCacheVersion() { var replicatorVersion = _cacheVersionProvider?.Invoke(); - _logger.Debug("Cache version provider returned: {0}", replicatorVersion ?? "(null)"); + _logger.LogDebug("Cache version provider returned: {ReplicatorVersion}", replicatorVersion ?? "(null)"); if (!string.IsNullOrEmpty(replicatorVersion)) { - _logger.Info("Using replicator cache version: {0}", replicatorVersion); + _logger.LogInformation("Using replicator cache version: {ReplicatorVersion}", replicatorVersion); return replicatorVersion; } var sdkVersion = SchemaVersionGenerator.GetGlobalSchemaVersion(); - _logger.Info("Using SDK cache version: {0}", sdkVersion); + _logger.LogInformation("Using SDK cache version: {SdkVersion}", sdkVersion); return sdkVersion; } @@ -1477,7 +1477,7 @@ private void CleanupUnusedCompanyLocks() if (locksToRemove.Count > 0) { - _logger.Debug($"Cleaned up {locksToRemove.Count} unused company locks"); + _logger.LogDebug("Cleaned up {LockCount} unused company locks", locksToRemove.Count); } } } @@ -1505,29 +1505,29 @@ public void Dispose() // we need to eventually block here, but with a clean timeout if (!closeTask.Wait(TimeSpan.FromSeconds(5))) { - _logger.Warn("WebSocket close handshake timed out"); + _logger.LogWarning("WebSocket close handshake timed out"); } } catch (OperationCanceledException) { - _logger.Warn("WebSocket close handshake was cancelled"); + _logger.LogWarning("WebSocket close handshake was cancelled"); } catch (AggregateException ex) when (ex.InnerExceptions.Any(e => e is OperationCanceledException)) { - _logger.Warn("WebSocket close handshake was cancelled"); + _logger.LogWarning("WebSocket close handshake was cancelled"); } // If it didn't close gracefully, abort if (_webSocket.State != WebSocketState.Closed) { - _logger.Warn("WebSocket didn't close gracefully, aborting"); + _logger.LogWarning("WebSocket didn't close gracefully, aborting"); _webSocket.Abort(); } } } catch (Exception ex) { - _logger.Error("Error closing WebSocket connection: {0}", ex.Message); + _logger.LogError(ex, "Error closing WebSocket connection"); // Ensure we abort in case of errors try { _webSocket.Abort(); } catch { /* Ignore any errors during abort */ } diff --git a/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs b/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs index 28a6e75a..f27b2592 100644 --- a/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs +++ b/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs @@ -19,7 +19,7 @@ namespace SchematicHQ.Client.Datastream public class DatastreamClientAdapter { private readonly DatastreamClient _client; - private readonly ISchematicLogger _logger; + private readonly ILogger _logger; private readonly bool _replicatorMode; private readonly IReplicatorHealthService? _replicatorHealthService; @@ -29,27 +29,27 @@ public class DatastreamClientAdapter /// /// Creates a new datastream client adapter /// - public DatastreamClientAdapter(string baseUrl, ISchematicLogger logger, string apiKey, DatastreamOptions options, bool replicatorMode = false, string? replicatorHealthUrl = null) + public DatastreamClientAdapter(string baseUrl, ILogger logger, string apiKey, DatastreamOptions options, bool replicatorMode = false, string? replicatorHealthUrl = null) { _logger = logger; _replicatorMode = replicatorMode; - + // Initialize replicator health service if in replicator mode if (_replicatorMode && !string.IsNullOrWhiteSpace(replicatorHealthUrl)) { // Create a simple HTTP client for health checks var httpClient = new System.Net.Http.HttpClient(); _replicatorHealthService = new ReplicatorHealthService(httpClient, replicatorHealthUrl, logger); - + // Subscribe to cache version changes for logging and potential cache invalidation _replicatorHealthService.CacheVersionChanged += OnCacheVersionChanged; - + _replicatorHealthService.Start(); } - + _client = new DatastreamClient( baseUrl, - _logger, + logger, apiKey, _connectionTracker.UpdateConnectionState, // callback to update connection state options.CacheTTL, @@ -131,13 +131,13 @@ public bool IsReplicatorMode() } else { - _logger.Debug("Timed out waiting for replicator cache version"); + _logger.LogDebug("Timed out waiting for replicator cache version"); return null; } } catch (Exception ex) { - _logger.Debug("Failed to get replicator cache version: {0}", ex.Message); + _logger.LogDebug(ex, "Failed to get replicator cache version"); return null; } } @@ -147,7 +147,7 @@ public bool IsReplicatorMode() /// private void OnCacheVersionChanged(string? oldVersion, string? newVersion) { - _logger.Info("Cache version changed from {0} to {1} - new cache keys will use updated version", + _logger.LogInformation("Cache version changed from {OldVersion} to {NewVersion} - new cache keys will use updated version", oldVersion ?? "(null)", newVersion ?? "(null)"); // Note: Cache invalidation is automatic because cache keys include the version. @@ -183,7 +183,7 @@ public async Task IsConnectedAsync(TimeSpan? timeout = null) } catch (Exception ex) { - _logger.Error($"Error checking datastream connection: {ex.Message}"); + _logger.LogError(ex, "Error checking datastream connection"); return false; } } @@ -232,13 +232,13 @@ public async Task CheckFlag(CheckFlagRequestBody request, strin if (allRequiredResourcesInCache) { // All required resources in cache - evaluate flag - _logger.Debug("Replicator mode: All required resources in cache, evaluating flag '{0}'", flagKey); + _logger.LogDebug("Replicator mode: All required resources in cache, evaluating flag '{FlagKey}'", flagKey); return await _client.CheckFlag(cachedCompany, cachedUser, cachedFlag!); } else if (!flagInCache) { // Flag missing from cache - replicator should have populated it, so flag doesn't exist - _logger.Debug("Replicator mode: Flag '{0}' missing from cache, replicator is healthy so flag doesn't exist", flagKey); + _logger.LogDebug("Replicator mode: Flag '{FlagKey}' missing from cache, replicator is healthy so flag doesn't exist", flagKey); return new CheckFlagResult { Reason = "FlagNotFound", @@ -250,7 +250,7 @@ public async Task CheckFlag(CheckFlagRequestBody request, strin else { // Some company/user resources missing - evaluate with available data - _logger.Warn("Replicator mode: Some required resources missing from cache for flag '{0}', evaluating with available data", flagKey); + _logger.LogWarning("Replicator mode: Some required resources missing from cache for flag '{FlagKey}', evaluating with available data", flagKey); return await _client.CheckFlag(cachedCompany, cachedUser, cachedFlag!); } } @@ -260,13 +260,13 @@ public async Task CheckFlag(CheckFlagRequestBody request, strin if (allRequiredResourcesInCache) { // All required resources in cache - evaluate flag even though replicator is unhealthy - _logger.Warn("Replicator mode: Replicator unhealthy but all required resources in cache, evaluating flag '{0}'", flagKey); + _logger.LogWarning("Replicator mode: Replicator unhealthy but all required resources in cache, evaluating flag '{FlagKey}'", flagKey); return await _client.CheckFlag(cachedCompany, cachedUser, cachedFlag!); } else { // Not all resources in cache and replicator unhealthy - fallback to API - _logger.Warn("Replicator mode: Replicator unhealthy and missing required resources for flag '{0}', falling back to API", flagKey); + _logger.LogWarning("Replicator mode: Replicator unhealthy and missing required resources for flag '{FlagKey}', falling back to API", flagKey); throw new InvalidOperationException($"Replicator unhealthy and required resources missing for flag '{flagKey}' - API fallback required"); } } @@ -318,7 +318,7 @@ public async Task CheckFlag(CheckFlagRequestBody request, strin } catch (Exception ex) { - _logger.Error("Error fetching missing resources for flag {0}: {1}", flagKey, ex.Message); + _logger.LogError(ex, "Error fetching missing resources for flag {FlagKey}", flagKey); return new CheckFlagResult { Reason = "Error", @@ -426,57 +426,5 @@ public async Task WaitForConnectionAsync(TimeSpan timeout) } } - // Adapter to convert ISchematicLogger to ILogger for the DatastreamClient - private class LoggerAdapter : ILogger - { - private readonly ISchematicLogger _logger; - - public LoggerAdapter(ConsoleLogger logger) - { - _logger = logger; - } - - public IDisposable BeginScope(TState state) - { - return new NoopDisposable(); - } - - public bool IsEnabled(MsLogLevel logLevel) - { - return true; - } - - public void Log(MsLogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - var message = formatter(state, exception); - - switch (logLevel) - { - case MsLogLevel.Critical: - case MsLogLevel.Error: - _logger.Error(message); - break; - case MsLogLevel.Warning: - _logger.Warn(message); - break; - case MsLogLevel.Information: - _logger.Info(message); - break; - case MsLogLevel.Debug: - case MsLogLevel.Trace: - _logger.Debug(message); - break; - default: - _logger.Info(message); - break; - } - } - - // A no-operation disposable class - private class NoopDisposable : IDisposable - { - public void Dispose() { } - } - } } } diff --git a/src/SchematicHQ.Client/Datastream/ReplicatorHealthService.cs b/src/SchematicHQ.Client/Datastream/ReplicatorHealthService.cs index 46f3de5e..edfed68c 100644 --- a/src/SchematicHQ.Client/Datastream/ReplicatorHealthService.cs +++ b/src/SchematicHQ.Client/Datastream/ReplicatorHealthService.cs @@ -2,6 +2,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using SchematicHQ.Client.Core; namespace SchematicHQ.Client.Datastream @@ -50,7 +51,7 @@ internal class ReplicatorHealthService : IReplicatorHealthService { private readonly HttpClient _httpClient; private readonly string _healthUrl; - private readonly ISchematicLogger _logger; + private readonly ILogger _logger; private readonly TimeSpan _checkInterval; private readonly CancellationTokenSource _cancellationTokenSource; private Task? _healthCheckTask; @@ -71,7 +72,7 @@ internal class ReplicatorHealthService : IReplicatorHealthService public ReplicatorHealthService( HttpClient httpClient, string healthUrl, - ISchematicLogger logger, + ILogger logger, TimeSpan? checkInterval = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); @@ -107,7 +108,7 @@ public ReplicatorHealthService( } catch (Exception ex) { - _logger.Debug("Failed to get cache version immediately: {0}", ex.Message); + _logger.LogDebug(ex, "Failed to get cache version immediately"); return null; } } @@ -121,7 +122,7 @@ public void Start() if (_healthCheckTask != null) return; // Already started - _logger.Info("Starting replicator health check service for URL: {0}", _healthUrl); + _logger.LogInformation("Starting replicator health check service for URL: {HealthUrl}", _healthUrl); // Perform an immediate health check to get the cache version right away _ = Task.Run(async () => @@ -129,11 +130,11 @@ public void Start() try { await PerformHealthCheck(); - _logger.Debug("Initial health check completed"); + _logger.LogDebug("Initial health check completed"); } catch (Exception ex) { - _logger.Debug("Initial health check failed: {0}", ex.Message); + _logger.LogDebug(ex, "Initial health check failed"); } }); @@ -146,7 +147,7 @@ public void Stop() if (_disposed || _healthCheckTask == null) return; - _logger.Info("Stopping replicator health check service"); + _logger.LogInformation("Stopping replicator health check service"); _cancellationTokenSource.Cancel(); try @@ -159,7 +160,7 @@ public void Stop() } catch (Exception ex) { - _logger.Error("Error stopping health check service: {0}", ex.Message); + _logger.LogError(ex, "Error stopping health check service"); } _healthCheckTask = null; @@ -176,7 +177,7 @@ private async Task HealthCheckLoop() } catch (Exception ex) { - _logger.Error("Error during health check: {0}", ex.Message); + _logger.LogError(ex, "Error during health check"); _isHealthy = false; } @@ -199,14 +200,14 @@ private async Task PerformHealthCheck() // Read and parse the JSON response first var responseBody = await response.Content.ReadAsStringAsync(_cancellationTokenSource.Token); - _logger.Debug("Raw health response: {0}", responseBody); + _logger.LogDebug("Raw health response: {ResponseBody}", responseBody); var healthResponse = System.Text.Json.JsonSerializer.Deserialize(responseBody, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - _logger.Debug("Deserialized health response - Ready: {0}, CacheVersion: '{1}'", + _logger.LogDebug("Deserialized health response - Ready: {Ready}, CacheVersion: '{CacheVersion}'", healthResponse?.Ready ?? false, healthResponse?.CacheVersion ?? "NULL"); bool wasHealthy = _isHealthy; @@ -219,29 +220,29 @@ private async Task PerformHealthCheck() _isHealthy = response.IsSuccessStatusCode && healthResponse?.Ready == true; _cacheVersion = healthResponse?.CacheVersion; - _logger.Debug("Updated cache version from '{0}' to '{1}'", + _logger.LogDebug("Updated cache version from '{PreviousCacheVersion}' to '{CacheVersion}'", previousCacheVersion ?? "NULL", _cacheVersion ?? "NULL"); // Log state changes - matching Go client exactly if (_isHealthy && !wasHealthy) { - _logger.Info("External replicator is now ready"); + _logger.LogInformation("External replicator is now ready"); } else if (!_isHealthy && wasHealthy) { - _logger.Info("External replicator is no longer ready"); + _logger.LogInformation("External replicator is no longer ready"); } // Log readiness status for debugging if (!response.IsSuccessStatusCode) { - _logger.Debug("Replicator readiness check: HTTP {0}, Ready={1}", response.StatusCode, healthResponse?.Ready ?? false); + _logger.LogDebug("Replicator readiness check: HTTP {StatusCode}, Ready={Ready}", response.StatusCode, healthResponse?.Ready ?? false); } // Log cache version changes and notify subscribers if (_cacheVersion != previousCacheVersion) { - _logger.Info("Replicator cache version updated: {0} -> {1}", previousCacheVersion ?? "(null)", _cacheVersion ?? "(null)"); + _logger.LogInformation("Replicator cache version updated: {PreviousCacheVersion} -> {CacheVersion}", previousCacheVersion ?? "(null)", _cacheVersion ?? "(null)"); // Notify subscribers of the cache version change CacheVersionChanged?.Invoke(previousCacheVersion, _cacheVersion); @@ -259,7 +260,7 @@ private async Task PerformHealthCheck() if (wasHealthyBefore) { - _logger.Warn("Failed to parse replicator health response: {0}", ex.Message); + _logger.LogWarning(ex, "Failed to parse replicator health response"); } } catch (Exception ex) @@ -269,7 +270,7 @@ private async Task PerformHealthCheck() if (wasHealthyBefore) { - _logger.Debug("Replicator health check failed: {0}", ex.Message); + _logger.LogDebug(ex, "Replicator health check failed"); } } } diff --git a/src/SchematicHQ.Client/EventBuffer.cs b/src/SchematicHQ.Client/EventBuffer.cs index e3c79b02..dd392d50 100644 --- a/src/SchematicHQ.Client/EventBuffer.cs +++ b/src/SchematicHQ.Client/EventBuffer.cs @@ -1,4 +1,5 @@ using System.Threading.Channels; +using Microsoft.Extensions.Logging; #nullable enable @@ -27,7 +28,7 @@ public class EventBuffer : IEventBuffer where T : notnull private readonly int _maxSize; private readonly TimeSpan _flushPeriod; private readonly Func, Task> _action; - private readonly ISchematicLogger _logger; + private readonly ILogger _logger; private readonly Channel _channel; private readonly SemaphoreSlim _semaphore; private CancellationTokenSource _cts; @@ -35,7 +36,7 @@ public class EventBuffer : IEventBuffer where T : notnull private Task _periodicFlushTask = Task.CompletedTask; private Task _processBufferTask = Task.CompletedTask; - public EventBuffer(Func, Task> action, ISchematicLogger logger, int maxSize = DefaultMaxSize, TimeSpan? flushPeriod = null) + public EventBuffer(Func, Task> action, ILogger logger, int maxSize = DefaultMaxSize, TimeSpan? flushPeriod = null) { _maxSize = maxSize; _flushPeriod = flushPeriod ?? DefaultFlushPeriod; @@ -50,7 +51,7 @@ public EventBuffer(Func, Task> action, ISchematicLogger logger, int maxS _state = StateStopped; _semaphore = new SemaphoreSlim(0); - _logger.Debug("EventBuffer initialized with maxSize: {0}, flushPeriod: {1}", _maxSize, _flushPeriod); + _logger.LogDebug("EventBuffer initialized with maxSize: {MaxSize}, flushPeriod: {FlushPeriod}", _maxSize, _flushPeriod); AppDomain.CurrentDomain.ProcessExit += async (s, e) => { await EmergencyFlush(); @@ -66,7 +67,7 @@ private async Task EmergencyFlush() { try { - _logger.Debug("Emergency flush triggered by program termination"); + _logger.LogDebug("Emergency flush triggered by program termination"); var items = new List(_channel.Reader.Count); while (_channel.Reader.TryRead(out var item)) { @@ -75,14 +76,14 @@ private async Task EmergencyFlush() if (items.Count > 0) { - _logger.Info("Emergency flushing {0} items", items.Count); + _logger.LogInformation("Emergency flushing {ItemCount} items", items.Count); await _action(items); } - _logger.Info("Emergency flush completed"); + _logger.LogInformation("Emergency flush completed"); } catch (Exception ex) { - _logger.Error("Error during emergency flush: {0}", ex.Message); + _logger.LogError(ex, "Error during emergency flush"); } } @@ -94,7 +95,7 @@ public void Push(T item) if (!_channel.Writer.TryWrite(item)) throw new InvalidOperationException("Failed to write item to buffer channel."); - _logger.Debug("Item added to buffer. Current size: {0}", _channel.Reader.Count); + _logger.LogDebug("Item added to buffer. Current size: {QueueSize}", _queue.Count); if (_channel.Reader.Count >= _maxSize) { @@ -111,7 +112,7 @@ public void Start() _periodicFlushTask = Task.Run(() => PeriodicFlushAsync(_cts.Token)); _processBufferTask = Task.Run(() => ProcessBufferAsync(_cts.Token)); - _logger.Info("EventBuffer started."); + _logger.LogInformation("EventBuffer started."); } public async Task Flush() @@ -143,7 +144,7 @@ private async Task ProcessBufferAsync(CancellationToken token) } catch (OperationCanceledException) { - _logger.Warn("Process buffer task was canceled."); + _logger.LogWarning("Process buffer task was canceled."); } } } @@ -163,11 +164,11 @@ private async Task PeriodicFlushAsync(CancellationToken token) } catch (OperationCanceledException) { - _logger.Warn("Periodic flush task was canceled."); + _logger.LogWarning("Periodic flush task was canceled."); } catch (Exception ex) { - _logger.Error("An error occurred during periodic flush: {0}", ex.Message); + _logger.LogError(ex, "An error occurred during periodic flush"); } } } @@ -182,7 +183,7 @@ private async Task FlushBufferAsync() if (items.Count > 0) { - _logger.Info("Flushing buffer with {0} items.", items.Count); + _logger.LogInformation("Flushing buffer with {ItemCount} items.", items.Count); // Initialize retry counter and success flag int retryCount = 0; @@ -197,7 +198,7 @@ private async Task FlushBufferAsync() if (retryCount > 0) { // Log retry attempt - _logger.Info("Retrying event batch submission (attempt {0} of {1})", retryCount, MaxRetries); + _logger.LogInformation("Retrying event batch submission (attempt {Attempt} of {MaxRetries})", retryCount, MaxRetries); } // Attempt to send events @@ -217,10 +218,9 @@ private async Task FlushBufferAsync() double jitter = Random.Shared.NextDouble() * 0.1 * delay; // 10% jitter TimeSpan waitTime = TimeSpan.FromSeconds(delay + jitter); - _logger.Warn( - string.Format("Event batch submission failed: {0}. Retrying in {1:0.##} seconds...", - ex.Message, waitTime.TotalSeconds) - ); + _logger.LogWarning(ex, + "Event batch submission failed. Retrying in {WaitSeconds:0.##} seconds...", + waitTime.TotalSeconds); // Wait before retry await Task.Delay(waitTime); @@ -231,11 +231,11 @@ private async Task FlushBufferAsync() // After all retries, if still not successful, log the error if (!success) { - _logger.Error("Event batch submission failed after {0} retries: {1}", MaxRetries, lastException?.Message ?? "Unknown error"); + _logger.LogError(lastException, "Event batch submission failed after {MaxRetries} retries", MaxRetries); } else if (retryCount > 0) { - _logger.Info("Event batch submission succeeded after {0} retries", retryCount); + _logger.LogInformation("Event batch submission succeeded after {RetryCount} retries", retryCount); } } } @@ -264,11 +264,11 @@ public async Task Stop() _cts.Dispose(); _semaphore.Dispose(); - _logger.Info("EventBuffer shut down cleanly."); + _logger.LogInformation("EventBuffer shut down cleanly."); } catch (Exception ex) { - _logger.Error("Error during shutdown: {0}", ex.Message); + _logger.LogError(ex, "Error during shutdown"); throw; } } diff --git a/src/SchematicHQ.Client/EventCaptureClient.cs b/src/SchematicHQ.Client/EventCaptureClient.cs index c5a7e60d..a1dd3479 100644 --- a/src/SchematicHQ.Client/EventCaptureClient.cs +++ b/src/SchematicHQ.Client/EventCaptureClient.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; using OneOf; using SchematicHQ.Client.Core; @@ -59,9 +60,9 @@ internal class EventCaptureClient private readonly HttpClient _httpClient; private readonly string _apiKey; private readonly string _baseUrl; - private readonly ISchematicLogger _logger; + private readonly ILogger _logger; - public EventCaptureClient(HttpClient httpClient, string apiKey, ISchematicLogger logger, string? baseUrl = null) + public EventCaptureClient(HttpClient httpClient, string apiKey, ILogger logger, string? baseUrl = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey)); @@ -103,7 +104,7 @@ public async Task SendBatchAsync(List events) var json = JsonSerializer.Serialize(batchPayload, JsonOptions.JsonSerializerOptions); var content = new StringContent(json, Encoding.UTF8, "application/json"); - _logger.Debug("Sending {0} events to capture service at {1}", events.Count, endpoint); + _logger.LogDebug("Sending {EventCount} events to capture service at {Endpoint}", events.Count, endpoint); using var response = await _httpClient.PostAsync(endpoint, content); diff --git a/src/SchematicHQ.Client/Logger.cs b/src/SchematicHQ.Client/Logger.cs deleted file mode 100644 index dab8888d..00000000 --- a/src/SchematicHQ.Client/Logger.cs +++ /dev/null @@ -1,66 +0,0 @@ -#nullable enable - -namespace SchematicHQ.Client; - -/// -/// Severity levels for . Lower numeric values -/// are more verbose; e.g. a logger configured at drops -/// and calls. -/// -public enum LogLevel -{ - Debug = 0, - Info = 1, - Warn = 2, - Error = 3, -} - -public interface ISchematicLogger -{ - void Error(string message, params object[] args); - void Warn(string message, params object[] args); - void Info(string message, params object[] args); - void Debug(string message, params object[] args); -} - -/// -/// Default console-based logger. Filters messages by the configured -/// ; defaults to so debug -/// and info diagnostics are suppressed unless the consumer explicitly opts -/// in via ClientOptions.LogLevel. -/// -public class ConsoleLogger : ISchematicLogger -{ - private readonly LogLevel _level; - - public ConsoleLogger() : this(LogLevel.Warn) { } - - public ConsoleLogger(LogLevel level) - { - _level = level; - } - - public void Error(string message, params object[] args) - { - if (_level > LogLevel.Error) return; - Console.WriteLine($"[ERROR] {string.Format(message, args)}"); - } - - public void Warn(string message, params object[] args) - { - if (_level > LogLevel.Warn) return; - Console.WriteLine($"[WARN] {string.Format(message, args)}"); - } - - public void Info(string message, params object[] args) - { - if (_level > LogLevel.Info) return; - Console.WriteLine($"[INFO] {string.Format(message, args)}"); - } - - public void Debug(string message, params object[] args) - { - if (_level > LogLevel.Debug) return; - Console.WriteLine($"[DEBUG] {string.Format(message, args)}"); - } -} diff --git a/src/SchematicHQ.Client/OpenFeature/SchematicProvider.cs b/src/SchematicHQ.Client/OpenFeature/SchematicProvider.cs index c11c984f..db133a60 100644 --- a/src/SchematicHQ.Client/OpenFeature/SchematicProvider.cs +++ b/src/SchematicHQ.Client/OpenFeature/SchematicProvider.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using OpenFeature; using OpenFeature.Constant; using OpenFeature.Error; @@ -18,7 +19,7 @@ public class SchematicProvider : FeatureProvider private static readonly Metadata _metadata = new Metadata("schematic-provider"); private readonly Schematic _schematic; private readonly ClientOptions _options; - private readonly ISchematicLogger _logger; + private readonly ILogger _logger; /// /// Creates a new instance of the SchematicProvider. @@ -33,7 +34,7 @@ public SchematicProvider(string apiKey, ClientOptions? options = null) } _options = options ?? new ClientOptions(); - _logger = _options.Logger ?? new ConsoleLogger(_options.LogLevel); + _logger = _options.LoggerFactory.CreateLogger(); _schematic = new Schematic(apiKey, _options); } @@ -46,7 +47,7 @@ public SchematicProvider(Schematic schematic, ClientOptions? options = null) { _schematic = schematic ?? throw new ArgumentNullException(nameof(schematic)); _options = options ?? new ClientOptions(); - _logger = _options.Logger ?? new ConsoleLogger(_options.LogLevel); + _logger = _options.LoggerFactory.CreateLogger(); } /// @@ -162,14 +163,14 @@ public override Task> ResolveStructureValueAsync( /// public override Task InitializeAsync(EvaluationContext? context, CancellationToken cancellationToken = default) { - _logger.Info("Schematic provider initialized"); + _logger.LogInformation("Schematic provider initialized"); return Task.CompletedTask; } /// public override async Task ShutdownAsync(CancellationToken cancellationToken = default) { - _logger.Info("Schematic provider shutting down"); + _logger.LogInformation("Schematic provider shutting down"); await _schematic.Shutdown().ConfigureAwait(false); } diff --git a/src/SchematicHQ.Client/Schematic.cs b/src/SchematicHQ.Client/Schematic.cs index 05f682dd..45df47e7 100644 --- a/src/SchematicHQ.Client/Schematic.cs +++ b/src/SchematicHQ.Client/Schematic.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using SchematicHQ.Client.Datastream; using SchematicHQ.Client.Cache; using SchematicHQ.Client.Core; @@ -17,7 +18,7 @@ public partial class Schematic { private readonly ClientOptions _options; private readonly IEventBuffer _eventBuffer; - private readonly ISchematicLogger _logger; + private readonly ILogger _logger; private readonly List> _flagCheckCacheProviders; private readonly bool _offline; private readonly DatastreamClientAdapter? _datastreamClient; @@ -50,7 +51,7 @@ public Schematic(string apiKey, ClientOptions? options = null) _options = options ?? new ClientOptions(); _offline = _options.Offline; _replicatorMode = _options.ReplicatorMode; - _logger = _options.Logger ?? new ConsoleLogger(_options.LogLevel); + _logger = _options.LoggerFactory.CreateLogger("SchematicHQ.Client"); // Validate replicator mode configuration if (_replicatorMode && string.IsNullOrWhiteSpace(_options.ReplicatorHealthUrl)) @@ -147,7 +148,7 @@ public Schematic(string apiKey, ClientOptions? options = null) case CacheProviderType.Redis: if (_options.CacheConfiguration.RedisConfig == null) { - _logger.Warn("Redis configuration not provided, falling back to local cache"); + _logger.LogWarning("Redis configuration not provided, falling back to local cache"); _flagCheckCacheProviders.Add(new LocalCache()); } else @@ -229,7 +230,7 @@ public Schematic(string apiKey, ClientOptions? options = null) else { _datastreamConnected = false; - _logger.Info("Replicator mode enabled - datastream client created for cache access only"); + _logger.LogInformation("Replicator mode enabled - datastream client created for cache access only"); } } } @@ -255,17 +256,17 @@ private void StartConnectionMonitoring() _datastreamConnected = isConnected; if (isConnected) { - _logger.Info("Datastream connection established"); + _logger.LogInformation("Datastream connection established"); } else { - _logger.Warn("Datastream connection lost, falling back to API"); + _logger.LogWarning("Datastream connection lost, falling back to API"); } } } catch (Exception ex) { - _logger.Error($"Error monitoring datastream connection: {ex.Message}"); + _logger.LogError(ex, "Error monitoring datastream connection"); } await Task.Delay(TimeSpan.FromSeconds(5)); @@ -273,7 +274,7 @@ private void StartConnectionMonitoring() } catch (Exception ex) { - _logger.Error($"Connection monitoring stopped: {ex.Message}"); + _logger.LogError(ex, "Connection monitoring stopped"); } }); } @@ -345,7 +346,7 @@ public async Task CheckFlagWithEntitlement(str catch (Exception ex) { // Fall back to API if datastream fails - _logger.Debug("Datastream flag check failed ({0}), falling back to API", ex.Message); + _logger.LogDebug(ex, "Datastream flag check failed, falling back to API"); return await CheckFlagWithEntitlementApi(flagKey, company, user); } } @@ -404,7 +405,7 @@ private async Task CheckFlagWithEntitlementApi } catch (Exception cacheEx) { - _logger.Error("Error caching flag result: {0}", cacheEx.Message); + _logger.LogError(cacheEx, "Error caching flag result"); } } @@ -412,7 +413,7 @@ private async Task CheckFlagWithEntitlementApi } catch (Exception ex) { - _logger.Error("Error checking flag via API: {0}", ex.Message); + _logger.LogError(ex, "Error checking flag via API"); return new CheckFlagWithEntitlementResponse { FlagKey = flagKey, @@ -431,7 +432,7 @@ public async Task> CheckFlags( if (_offline) { - _logger.Debug("Offline mode enabled, returning default flag values"); + _logger.LogDebug("Offline mode enabled, returning default flag values"); if (keyList == null || keyList.Count == 0) { return new List(); @@ -463,7 +464,7 @@ public async Task> CheckFlags( if (keyList == null || keyList.Count == 0) { - _logger.Debug("No specific flag keys provided, calling CheckFlags API"); + _logger.LogDebug("No specific flag keys provided, calling CheckFlags API"); var apiResp = await API.Features.CheckFlagsAsync(requestBody); return apiResp.Data.Flags.ToList(); } @@ -496,11 +497,11 @@ public async Task> CheckFlags( if (allCached) { - _logger.Debug("All {0} flags found in cache", keyList.Count); + _logger.LogDebug("All {KeyCount} flags found in cache", keyList.Count); return keyList.Select(k => cachedResults[k]).ToList(); } - _logger.Debug("Cache miss for some flags, calling API for all {0} keys", keyList.Count); + _logger.LogDebug("Cache miss for some flags, calling API for all {KeyCount} keys", keyList.Count); var freshResp = await API.Features.CheckFlagsAsync(requestBody); var apiResults = freshResp.Data.Flags.ToDictionary(f => f.Flag); @@ -516,7 +517,7 @@ public async Task> CheckFlags( } catch (Exception cacheEx) { - _logger.Error("Error caching flag result: {0}", cacheEx.Message); + _logger.LogError(cacheEx, "Error caching flag result"); } } } @@ -537,7 +538,7 @@ public async Task> CheckFlags( } catch (Exception ex) { - _logger.Error("Error checking flags: {0}", ex.Message); + _logger.LogError(ex, "Error checking flags"); return (keyList ?? new List()).Select(k => new CheckFlagResponseData { Flag = k, @@ -569,12 +570,12 @@ public async Task> CheckFlags( UserId = result.UserId }); } - _logger.Debug("All {0} flags evaluated via Datastream", keys.Count); + _logger.LogDebug("All {KeyCount} flags evaluated via Datastream", keys.Count); return results; } catch (Exception ex) { - _logger.Debug("Datastream CheckFlags failed ({0}), falling back to API", ex.Message); + _logger.LogDebug(ex, "Datastream CheckFlags failed, falling back to API"); return null; } } @@ -638,12 +639,12 @@ public void Track(string eventName, Dictionary? company = null, var success = _datastreamClient.UpdateCompanyMetrics(eventBody); if (!success) { - _logger.Error("Failed to update company metrics: datastream update failed"); + _logger.LogError("Failed to update company metrics: datastream update failed"); } } catch (Exception ex) { - _logger.Error("Failed to update company metrics: {0}", ex.Message); + _logger.LogError(ex, "Failed to update company metrics"); } } } @@ -675,7 +676,7 @@ private void EnqueueEvent( } catch (Exception ex) { - _logger.Error("Error enqueueing event: {0}", ex.Message); + _logger.LogError(ex, "Error enqueueing event"); } } @@ -706,13 +707,13 @@ private void SubmitFlagCheckEvent( ReqUser = user }; - _logger.Debug("Submitting flag check event: {0}", flagKey); + _logger.LogDebug("Submitting flag check event: {FlagKey}", flagKey); EnqueueEvent(EventType.FlagCheck, eventBody); } catch (Exception ex) { - _logger.Error("Error submitting flag check event: {0}", ex.Message); + _logger.LogError(ex, "Error submitting flag check event"); } } diff --git a/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props b/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props index a46f9164..095bfec3 100644 --- a/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props +++ b/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props @@ -9,6 +9,7 @@ Configure additional MSBuild properties for your project in this file: MIT + From fc3d734160885c469a2f0815cdbc5fbc57455440 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 29 Apr 2026 23:39:53 +0800 Subject: [PATCH 2/4] Use conditional targeting --- .../SchematicHQ.Client.Test.Custom.props | 2 +- .../SchematicHQ.Client.Custom.props | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/SchematicHQ.Client.Test/SchematicHQ.Client.Test.Custom.props b/src/SchematicHQ.Client.Test/SchematicHQ.Client.Test.Custom.props index 9f59d19a..a7029f21 100644 --- a/src/SchematicHQ.Client.Test/SchematicHQ.Client.Test.Custom.props +++ b/src/SchematicHQ.Client.Test/SchematicHQ.Client.Test.Custom.props @@ -9,7 +9,7 @@ Configure additional MSBuild properties for your project in this file: false - + diff --git a/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props b/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props index 095bfec3..c8b28412 100644 --- a/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props +++ b/src/SchematicHQ.Client/SchematicHQ.Client.Custom.props @@ -9,11 +9,20 @@ Configure additional MSBuild properties for your project in this file: MIT - + + + + + + + + + + $(MSBuildThisFileDirectory)RulesEngine/Utils/GeneratedModelHash.cs From d8dad9bf0ec1bbbd135631473c30c8480aad4429 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 17 Jun 2026 09:21:45 +0800 Subject: [PATCH 3/4] Rebase fixes --- .../OpenFeature/SchematicProviderOfflineTests.cs | 14 ++++++++------ .../Core/Public/ClientOptionsCustom.cs | 2 +- src/SchematicHQ.Client/EventBuffer.cs | 6 +++--- .../OpenFeature/SchematicProvider.cs | 2 +- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/SchematicHQ.Client.Test/OpenFeature/SchematicProviderOfflineTests.cs b/src/SchematicHQ.Client.Test/OpenFeature/SchematicProviderOfflineTests.cs index 2b32d920..9e4cbe78 100644 --- a/src/SchematicHQ.Client.Test/OpenFeature/SchematicProviderOfflineTests.cs +++ b/src/SchematicHQ.Client.Test/OpenFeature/SchematicProviderOfflineTests.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; using NUnit.Framework; using OpenFeature; using OpenFeature.Constant; @@ -267,30 +269,30 @@ public void Constructor_WithNullSchematic_ThrowsArgumentNullException() public async Task Initialize_WithCustomLogger_RoutesThroughLogger() { // Arrange - var mockLogger = new MockSchematicLogger(); - var options = new ClientOptions { Offline = true, Logger = mockLogger }; + var mockLogger = new FakeLogger(); + var options = new ClientOptions { Offline = true, LoggerFactory = mockLogger.ToLoggerFactory() }; var provider = new SchematicProvider("test-key", options); // Act await provider.InitializeAsync(EvaluationContext.Empty); // Assert - Assert.That(mockLogger.HasLogEntry(LogLevel.Info, "initialized"), Is.True); + Assert.That(mockLogger.HasLogEntry(LogLevel.Information, "initialized"), Is.True); } [Test] public async Task Shutdown_WithCustomLogger_RoutesThroughLogger() { // Arrange - var mockLogger = new MockSchematicLogger(); - var options = new ClientOptions { Offline = true, Logger = mockLogger }; + var mockLogger = new FakeLogger(); + var options = new ClientOptions { Offline = true, LoggerFactory = mockLogger.ToLoggerFactory() }; var provider = new SchematicProvider("test-key", options); // Act await provider.ShutdownAsync(); // Assert - Assert.That(mockLogger.HasLogEntry(LogLevel.Info, "shutting down"), Is.True); + Assert.That(mockLogger.HasLogEntry(LogLevel.Information, "shutting down"), Is.True); } // Regression guard: when no custom Logger is supplied, ClientOptions.Logger diff --git a/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs b/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs index 3b795608..28f7aab9 100644 --- a/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs +++ b/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs @@ -11,7 +11,7 @@ namespace SchematicHQ.Client; public partial class ClientOptions { private static readonly ILoggerFactory DefaultLoggerFactory = - Microsoft.Extensions.Logging.LoggerFactory.Create(builder => builder.AddSimpleConsole()); + Microsoft.Extensions.Logging.LoggerFactory.Create(builder => builder.AddSimpleConsole().SetMinimumLevel(LogLevel.Warning)); public Dictionary FlagDefaults { get; set; } = new Dictionary(); public ILoggerFactory LoggerFactory { get; set; } = DefaultLoggerFactory; diff --git a/src/SchematicHQ.Client/EventBuffer.cs b/src/SchematicHQ.Client/EventBuffer.cs index dd392d50..31cac867 100644 --- a/src/SchematicHQ.Client/EventBuffer.cs +++ b/src/SchematicHQ.Client/EventBuffer.cs @@ -95,7 +95,7 @@ public void Push(T item) if (!_channel.Writer.TryWrite(item)) throw new InvalidOperationException("Failed to write item to buffer channel."); - _logger.LogDebug("Item added to buffer. Current size: {QueueSize}", _queue.Count); + _logger.LogDebug("Item added to buffer. Current size: {QueueSize}", _channel.Reader.Count); if (_channel.Reader.Count >= _maxSize) { @@ -121,7 +121,7 @@ public async Task Flush() throw new InvalidOperationException("Buffer is not running."); await DrainAsync(); - _logger.Info("Buffer flushed manually."); + _logger.LogInformation("Buffer flushed manually."); } private async Task DrainAsync() @@ -256,7 +256,7 @@ public async Task Stop() } catch (Exception ex) { - _logger.Warn("Error draining buffer on stop: {0}", ex.Message); + _logger.LogWarning("Error draining buffer on stop: {Message}", ex.Message); } var timeout = TimeSpan.FromSeconds(MaxWaitForBuffer); diff --git a/src/SchematicHQ.Client/OpenFeature/SchematicProvider.cs b/src/SchematicHQ.Client/OpenFeature/SchematicProvider.cs index db133a60..ed2f171b 100644 --- a/src/SchematicHQ.Client/OpenFeature/SchematicProvider.cs +++ b/src/SchematicHQ.Client/OpenFeature/SchematicProvider.cs @@ -82,7 +82,7 @@ public override async Task> ResolveBooleanValueAsync( } catch (Exception ex) { - _logger.Error("Error evaluating boolean flag '{0}': {1}", flagKey, ex.Message); + _logger.LogError("Error evaluating boolean flag '{FlagKey}': {Message}", flagKey, ex.Message); var errorType = MapExceptionToErrorType(ex); return new ResolutionDetails( From 4c46e43149caa1c63b4fde5e43005151560bdec3 Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 23 Jun 2026 19:44:04 +0800 Subject: [PATCH 4/4] Readd log level override --- .../SchematicProviderOfflineTests.cs | 2 +- .../Core/Public/ClientOptionsCustom.cs | 21 ++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/SchematicHQ.Client.Test/OpenFeature/SchematicProviderOfflineTests.cs b/src/SchematicHQ.Client.Test/OpenFeature/SchematicProviderOfflineTests.cs index 9e4cbe78..c4f01867 100644 --- a/src/SchematicHQ.Client.Test/OpenFeature/SchematicProviderOfflineTests.cs +++ b/src/SchematicHQ.Client.Test/OpenFeature/SchematicProviderOfflineTests.cs @@ -309,7 +309,7 @@ public async Task Initialize_WithNoCustomLogger_StillLogsViaDefaultConsoleLogger try { // No Logger set; raise LogLevel so the Info init message is emitted. - var options = new ClientOptions { Offline = true, LogLevel = LogLevel.Info }; + var options = new ClientOptions { Offline = true, LogLevel = LogLevel.Information }; var provider = new SchematicProvider("test-key", options); // Act diff --git a/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs b/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs index 28f7aab9..2b7be7d6 100644 --- a/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs +++ b/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs @@ -10,11 +10,25 @@ namespace SchematicHQ.Client; public partial class ClientOptions { - private static readonly ILoggerFactory DefaultLoggerFactory = - Microsoft.Extensions.Logging.LoggerFactory.Create(builder => builder.AddSimpleConsole().SetMinimumLevel(LogLevel.Warning)); + private ILoggerFactory? _loggerFactory; + private ILoggerFactory CreateDefaultLogger() => Microsoft.Extensions.Logging.LoggerFactory.Create(builder => builder.AddSimpleConsole().SetMinimumLevel(LogLevel)); public Dictionary FlagDefaults { get; set; } = new Dictionary(); - public ILoggerFactory LoggerFactory { get; set; } = DefaultLoggerFactory; + + + /// + /// Override the logger factory for the client. Defaults to a simple console logger. + /// + public ILoggerFactory LoggerFactory + { + get => _loggerFactory ??= CreateDefaultLogger(); + set => _loggerFactory = value; + } + + /// + /// Sets the log level for the client. Defaults to LogLevel.Warning. No-op if a custom is provided. + /// + public LogLevel LogLevel { get; set; } = LogLevel.Warning; public List> CacheProviders { get; set; } = new List>(); public CacheConfiguration? CacheConfiguration { get; set; } public bool Offline { get; set; } @@ -57,6 +71,7 @@ public static ClientOptions WithHttpClient(this ClientOptions options, HttpClien Headers = new Headers(new Dictionary(options.Headers)), HttpClient = httpClient, LoggerFactory = options.LoggerFactory, + LogLevel = options.LogLevel, MaxRetries = options.MaxRetries, Offline = options.Offline, ReplicatorMode = options.ReplicatorMode,