From e0021c54b005aebe5cdf20b7d078fb0a8b240445 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Fri, 22 May 2026 08:38:06 -0600 Subject: [PATCH 1/3] merge entitlement data on partial events --- .../Datastream/MergeTests.cs | 254 ++++++++++++++++++ src/SchematicHQ.Client/Datastream/Merge.cs | 72 +++++ 2 files changed, 326 insertions(+) diff --git a/src/SchematicHQ.Client.Test/Datastream/MergeTests.cs b/src/SchematicHQ.Client.Test/Datastream/MergeTests.cs index 0598a944..c6cd3a15 100644 --- a/src/SchematicHQ.Client.Test/Datastream/MergeTests.cs +++ b/src/SchematicHQ.Client.Test/Datastream/MergeTests.cs @@ -653,5 +653,259 @@ public void DeepCopyUser_FullCopy() cpRules[0] = MakeRule("r2"); Assert.That(orig.Rules.First().Id, Is.EqualTo("r1")); } + + // --- Entitlement derived-field sync (sdk-spec.md → Message Types → Partial) --- + + private static RulesengineCompany CompanyWithEntitlements(List entitlements) + { + var c = BaseCompany(); + c.Entitlements = entitlements; + return c; + } + + private static RulesengineFeatureEntitlement CreditEntitlement(string featureId, string creditId, double? initialRemaining = null) + { + return new RulesengineFeatureEntitlement + { + FeatureId = featureId, + FeatureKey = featureId, + ValueType = RulesengineEntitlementValueType.Numeric, + CreditId = creditId, + CreditRemaining = initialRemaining, + CreditTotal = 500.0, + CreditUsed = 100.0, + }; + } + + private static RulesengineFeatureEntitlement EventEntitlement( + string featureId, + string eventName, + RulesengineMetricPeriod? period = null, + RulesengineMetricPeriodMonthReset? monthReset = null, + long? initialUsage = null) + { + return new RulesengineFeatureEntitlement + { + FeatureId = featureId, + FeatureKey = featureId, + ValueType = RulesengineEntitlementValueType.Numeric, + EventName = eventName, + MetricPeriod = period, + MonthReset = monthReset, + Usage = initialUsage, + }; + } + + private static RulesengineCompanyMetric Metric(string eventSubtype, long value, RulesengineMetricPeriod? period = null, RulesengineMetricPeriodMonthReset? monthReset = null) + { + return new RulesengineCompanyMetric + { + AccountId = "acc-1", + EnvironmentId = "env-1", + CompanyId = "co-1", + EventSubtype = eventSubtype, + Period = period ?? RulesengineMetricPeriod.AllTime, + MonthReset = monthReset ?? RulesengineMetricPeriodMonthReset.FirstOfMonth, + Value = value, + CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }; + } + + [Test] + public void PartialCompany_SyncsCreditRemainingFromCreditBalances() + { + var existing = CompanyWithEntitlements(new List + { + CreditEntitlement("feat-credit", "credit-1", initialRemaining: 100.0) + }); + // Existing balance for credit-1 = 100.0 (from BaseCompany) + + var partial = @"{""credit_balances"":{""credit-1"":42.0}}"; + + var merged = Merge.PartialCompany(existing, partial); + + var ent = merged.Entitlements!.Single(); + Assert.That(ent.CreditRemaining, Is.EqualTo(42.0)); + // credit_used / credit_total are not derivable from partials and must stay put + Assert.That(ent.CreditTotal, Is.EqualTo(500.0)); + Assert.That(ent.CreditUsed, Is.EqualTo(100.0)); + } + + [Test] + public void PartialCompany_SyncsCreditRemainingForAllMatchingEntitlements() + { + // Spec: "A single credit type can fund multiple entitlements; all matching + // entitlements must be updated." + var existing = CompanyWithEntitlements(new List + { + CreditEntitlement("feat-a", "credit-1"), + CreditEntitlement("feat-b", "credit-1"), + CreditEntitlement("feat-c", "credit-2"), + }); + + var partial = @"{""credit_balances"":{""credit-1"":99.0}}"; + + var merged = Merge.PartialCompany(existing, partial); + + var ents = merged.Entitlements!.ToList(); + Assert.That(ents[0].CreditRemaining, Is.EqualTo(99.0)); + Assert.That(ents[1].CreditRemaining, Is.EqualTo(99.0)); + // credit-2 not in the partial: untouched + Assert.That(ents[2].CreditRemaining, Is.Null); + } + + [Test] + public void PartialCompany_SyncsUsageFromMetrics() + { + var existing = CompanyWithEntitlements(new List + { + EventEntitlement( + "feat-event", + "api_calls", + period: RulesengineMetricPeriod.CurrentMonth, + monthReset: RulesengineMetricPeriodMonthReset.BillingCycle, + initialUsage: 10) + }); + + // Metric matches on (event_subtype, period, month_reset) triple + var partial = @"{""metrics"":[ + {""account_id"":""acc-1"",""environment_id"":""env-1"",""company_id"":""co-1"",""event_subtype"":""api_calls"",""period"":""current_month"",""month_reset"":""billing_cycle"",""value"":250,""created_at"":""2026-01-01T00:00:00Z""} + ]}"; + + var merged = Merge.PartialCompany(existing, partial); + + Assert.That(merged.Entitlements!.Single().Usage, Is.EqualTo(250)); + } + + [Test] + public void PartialCompany_UsageSyncDefaultsToAllTimeAndFirstOfMonthWhenEntitlementUnset() + { + // Spec: "Default metric_period to all_time and month_reset to first_of_month + // when the entitlement leaves them unset." + var existing = CompanyWithEntitlements(new List + { + // No MetricPeriod / MonthReset set + EventEntitlement("feat-event", "logins") + }); + + // Metric uses the defaults — should match + var partial = @"{""metrics"":[ + {""account_id"":""acc-1"",""environment_id"":""env-1"",""company_id"":""co-1"",""event_subtype"":""logins"",""period"":""all_time"",""month_reset"":""first_of_month"",""value"":77,""created_at"":""2026-01-01T00:00:00Z""} + ]}"; + + var merged = Merge.PartialCompany(existing, partial); + + Assert.That(merged.Entitlements!.Single().Usage, Is.EqualTo(77)); + } + + [Test] + public void PartialCompany_UsageSyncIgnoresMetricsWithMismatchedTriple() + { + var existing = CompanyWithEntitlements(new List + { + EventEntitlement( + "feat-event", + "logins", + period: RulesengineMetricPeriod.CurrentMonth, + monthReset: RulesengineMetricPeriodMonthReset.FirstOfMonth, + initialUsage: 1) + }); + + // Same event_subtype, but period differs (current_day vs entitlement's current_month) + var partial = @"{""metrics"":[ + {""account_id"":""acc-1"",""environment_id"":""env-1"",""company_id"":""co-1"",""event_subtype"":""logins"",""period"":""current_day"",""month_reset"":""first_of_month"",""value"":555,""created_at"":""2026-01-01T00:00:00Z""} + ]}"; + + var merged = Merge.PartialCompany(existing, partial); + + // No match → usage stays at the initial value + Assert.That(merged.Entitlements!.Single().Usage, Is.EqualTo(1)); + } + + [Test] + public void PartialCompany_SkipsDerivedFieldSyncWhenPartialIncludesEntitlements() + { + // Spec: "Skip the sync entirely when the partial also includes entitlements + // — the server-precomputed list wins." + var existing = CompanyWithEntitlements(new List + { + CreditEntitlement("feat-credit", "credit-1", initialRemaining: 100.0) + }); + + var partial = @"{ + ""credit_balances"": {""credit-1"": 42.0}, + ""entitlements"": [ + {""feature_id"":""feat-credit"",""feature_key"":""feat-credit"",""value_type"":""numeric"",""credit_id"":""credit-1"",""credit_remaining"":7.0} + ] + }"; + + var merged = Merge.PartialCompany(existing, partial); + + // The partial's entitlements list wins; the SDK does not overwrite + // credit_remaining (which would be 42.0 from the balance) with the + // server-supplied value of 7.0. + Assert.That(merged.Entitlements!.Single().CreditRemaining, Is.EqualTo(7.0)); + } + + [Test] + public void PartialCompany_NoSyncWhenPartialDoesNotTouchBalancesOrMetrics() + { + var existing = CompanyWithEntitlements(new List + { + CreditEntitlement("feat-credit", "credit-1", initialRemaining: 12.0) + }); + + var partial = @"{""keys"":{""slug"":""new-slug""}}"; + + var merged = Merge.PartialCompany(existing, partial); + + // Untouched + Assert.That(merged.Entitlements!.Single().CreditRemaining, Is.EqualTo(12.0)); + } + + [Test] + public void PartialCompany_DoesNotMutateCachedEntitlementInstances() + { + var cachedEnt = CreditEntitlement("feat-credit", "credit-1", initialRemaining: 100.0); + var existing = CompanyWithEntitlements(new List { cachedEnt }); + + var partial = @"{""credit_balances"":{""credit-1"":42.0}}"; + + var merged = Merge.PartialCompany(existing, partial); + + // The cached entitlement instance must not be touched — `record with` returns a + // new instance and our sync must use it instead of writing on the original. + Assert.That(cachedEnt.CreditRemaining, Is.EqualTo(100.0)); + Assert.That(merged.Entitlements!.Single().CreditRemaining, Is.EqualTo(42.0)); + Assert.That(ReferenceEquals(merged.Entitlements!.Single(), cachedEnt), Is.False); + } + + [Test] + public void PartialCompany_CreditAndUsageSyncTogether() + { + var existing = CompanyWithEntitlements(new List + { + CreditEntitlement("feat-credit", "credit-1", initialRemaining: 100.0), + EventEntitlement( + "feat-event", + "api_calls", + period: RulesengineMetricPeriod.AllTime, + monthReset: RulesengineMetricPeriodMonthReset.FirstOfMonth, + initialUsage: 10) + }); + + var partial = @"{ + ""credit_balances"": {""credit-1"": 33.0}, + ""metrics"": [ + {""account_id"":""acc-1"",""environment_id"":""env-1"",""company_id"":""co-1"",""event_subtype"":""api_calls"",""period"":""all_time"",""month_reset"":""first_of_month"",""value"":99,""created_at"":""2026-01-01T00:00:00Z""} + ] + }"; + + var merged = Merge.PartialCompany(existing, partial); + + var ents = merged.Entitlements!.ToList(); + Assert.That(ents[0].CreditRemaining, Is.EqualTo(33.0)); + Assert.That(ents[1].Usage, Is.EqualTo(99)); + } } } diff --git a/src/SchematicHQ.Client/Datastream/Merge.cs b/src/SchematicHQ.Client/Datastream/Merge.cs index e3a65df3..b7423110 100644 --- a/src/SchematicHQ.Client/Datastream/Merge.cs +++ b/src/SchematicHQ.Client/Datastream/Merge.cs @@ -27,6 +27,10 @@ public static RulesengineCompany PartialCompany(RulesengineCompany existing, str var merged = DeepCopyCompany(existing); + Dictionary? partialBalances = null; + bool metricsUpdated = false; + bool entitlementsInPartial = false; + foreach (var prop in root.EnumerateObject()) { var raw = prop.Value.GetRawText(); @@ -56,9 +60,11 @@ public static RulesengineCompany PartialCompany(RulesengineCompany existing, str { merged.CreditBalances[kvp.Key] = kvp.Value; } + partialBalances = cb; break; case "entitlements": merged.Entitlements = JsonSerializer.Deserialize>(raw, JsonOptions)!; + entitlementsInPartial = true; break; case "keys": var keys = JsonSerializer.Deserialize>(raw, JsonOptions)!; @@ -71,6 +77,7 @@ public static RulesengineCompany PartialCompany(RulesengineCompany existing, str case "metrics": var incoming = JsonSerializer.Deserialize>(raw, JsonOptions)!; merged.Metrics = UpsertMetrics(merged.Metrics, incoming); + metricsUpdated = true; break; case "plan_ids": merged.PlanIds = JsonSerializer.Deserialize>(raw, JsonOptions)!; @@ -90,9 +97,74 @@ public static RulesengineCompany PartialCompany(RulesengineCompany existing, str } } + // Partials don't ship a refreshed entitlements list, so when credit_balances + // or metrics change we re-derive entitlement.credit_remaining and usage to + // match what the server computes for full company messages. Skip the sync + // when the partial itself sent entitlements — that list wins. + // See sdk-spec.md → Message Types → Partial. + if ((partialBalances != null || metricsUpdated) && !entitlementsInPartial && merged.Entitlements != null) + { + var entitlementsList = merged.Entitlements as IList ?? merged.Entitlements.ToList(); + if (entitlementsList.Count > 0) + { + merged.Entitlements = SyncEntitlementDerivedFields( + entitlementsList, + partialBalances, + metricsUpdated ? merged.Metrics : null); + } + } + return merged; } + /// + /// Re-derives credit_remaining (from credit_balances) and usage (from metrics) + /// on each entitlement. credit_used and credit_total are intentionally left alone: + /// they aggregate across a grant ledger the SDK can't see, and grant lifecycle + /// events trigger a full company message that refreshes them. + /// + private static List SyncEntitlementDerivedFields( + IList entitlements, + Dictionary? partialBalances, + IEnumerable? mergedMetrics) + { + Dictionary<(string, string, string), long>? metricLookup = null; + if (mergedMetrics != null) + { + metricLookup = new Dictionary<(string, string, string), long>(); + foreach (var m in mergedMetrics) + { + metricLookup[(m.EventSubtype, m.Period.Value, m.MonthReset.Value)] = m.Value; + } + } + + var result = new List(entitlements.Count); + foreach (var ent in entitlements) + { + var updated = ent; + + if (partialBalances != null + && !string.IsNullOrEmpty(ent.CreditId) + && partialBalances.TryGetValue(ent.CreditId, out var balance)) + { + updated = updated with { CreditRemaining = balance }; + } + + if (metricLookup != null && !string.IsNullOrEmpty(ent.EventName)) + { + var period = ent.MetricPeriod?.Value ?? RulesengineMetricPeriod.Values.AllTime; + var monthReset = ent.MonthReset?.Value ?? RulesengineMetricPeriodMonthReset.Values.FirstOfMonth; + if (metricLookup.TryGetValue((ent.EventName, period, monthReset), out var usage)) + { + updated = updated with { Usage = usage }; + } + } + + result.Add(updated); + } + return result; + } + /// /// Merges a partial JSON update into an existing User. /// Deep-copies the existing user, then applies only the fields From b5ebbd9873cdaef4c852b0f82bc8fbc9533df5d3 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Fri, 22 May 2026 08:49:09 -0600 Subject: [PATCH 2/3] add optional fields for identify and track events --- src/SchematicHQ.Client/EventCaptureClient.cs | 20 ++++- src/SchematicHQ.Client/Schematic.cs | 92 +++++++++++++++++--- 2 files changed, 96 insertions(+), 16 deletions(-) diff --git a/src/SchematicHQ.Client/EventCaptureClient.cs b/src/SchematicHQ.Client/EventCaptureClient.cs index 04efbc64..c5a7e60d 100644 --- a/src/SchematicHQ.Client/EventCaptureClient.cs +++ b/src/SchematicHQ.Client/EventCaptureClient.cs @@ -10,7 +10,11 @@ namespace SchematicHQ.Client; /// -/// Represents an event in the format expected by the capture service +/// Represents an event in the format expected by the capture service. +/// The optional metadata fields (idempotency_key, sent_at, +/// trusted_client_clock, backfill) map directly to the equivalent fields +/// on and are omitted from the wire +/// payload when null (per JsonIgnoreCondition.WhenWritingNull). /// internal class CaptureEventPayload { @@ -23,8 +27,17 @@ internal class CaptureEventPayload [JsonPropertyName("type")] public required EventType EventType { get; set; } + [JsonPropertyName("idempotency_key")] + public string? IdempotencyKey { get; set; } + [JsonPropertyName("sent_at")] public DateTime? SentAt { get; set; } + + [JsonPropertyName("trusted_client_clock")] + public bool? TrustedClientClock { get; set; } + + [JsonPropertyName("backfill")] + public bool? Backfill { get; set; } } /// @@ -75,7 +88,10 @@ public async Task SendBatchAsync(List events) ApiKey = _apiKey, Body = evt.Body, EventType = evt.EventType, - SentAt = evt.SentAt + IdempotencyKey = evt.IdempotencyKey, + SentAt = evt.SentAt, + TrustedClientClock = evt.TrustedClientClock, + Backfill = evt.Backfill }); } diff --git a/src/SchematicHQ.Client/Schematic.cs b/src/SchematicHQ.Client/Schematic.cs index 525da6a5..7c9c77e7 100644 --- a/src/SchematicHQ.Client/Schematic.cs +++ b/src/SchematicHQ.Client/Schematic.cs @@ -597,18 +597,21 @@ private string BuildFlagCacheKey(string flagKey, Dictionary? com return cacheKey; } - public void Identify(Dictionary keys, EventBodyIdentifyCompany? company = null, string? name = null, Dictionary? traits = null) + public void Identify(Dictionary keys, EventBodyIdentifyCompany? company = null, string? name = null, Dictionary? traits = null, IdentifyOptions? options = null) { - EnqueueEvent(EventType.Identify, new EventBodyIdentify - { - Company = company, - Keys = keys, - Name = name, - Traits = traits - }); + EnqueueEvent( + EventType.Identify, + new EventBodyIdentify + { + Company = company, + Keys = keys, + Name = name, + Traits = traits + }, + idempotencyKey: options?.IdempotencyKey); } - public void Track(string eventName, Dictionary? company = null, Dictionary? user = null, Dictionary? traits = null, int? quantity = null) + public void Track(string eventName, Dictionary? company = null, Dictionary? user = null, Dictionary? traits = null, int? quantity = null, TrackOptions? options = null) { var eventBody = new EventBodyTrack { @@ -618,9 +621,15 @@ public void Track(string eventName, Dictionary? company = null, User = user, Quantity = quantity }; - - EnqueueEvent(EventType.Track, eventBody); - + + EnqueueEvent( + EventType.Track, + eventBody, + idempotencyKey: options?.IdempotencyKey, + sentAt: options?.SentAt, + trustedClientClock: options?.TrustedClientClock, + backfill: options?.Backfill); + // Update company metrics in datastream if available and connected if (company != null && UseDatastream() && _datastreamClient != null && _datastreamConnected) { @@ -639,7 +648,13 @@ public void Track(string eventName, Dictionary? company = null, } } - private void EnqueueEvent(EventType eventType, OneOf body) + private void EnqueueEvent( + EventType eventType, + OneOf body, + string? idempotencyKey = null, + DateTime? sentAt = null, + bool? trustedClientClock = null, + bool? backfill = null) { if (_offline) return; @@ -650,7 +665,10 @@ private void EnqueueEvent(EventType eventType, OneOf +/// Optional metadata for a track event. Fields map directly to the +/// corresponding properties; unset +/// fields are omitted from the wire payload. +/// +public class TrackOptions +{ + /// + /// Client-supplied dedupe key. Duplicate events with the same key + /// (scoped to the environment) are dropped server-side for 24 hours. + /// + public string? IdempotencyKey { get; set; } + + /// + /// Timestamp the event was sent. Required when + /// is true. When set, overrides the SDK's default of UtcNow at enqueue time. + /// + public DateTime? SentAt { get; set; } + + /// + /// When true, use as the effective event timestamp + /// instead of server receipt time. Requires a secret API key and SentAt. + /// + public bool? TrustedClientClock { get; set; } + + /// + /// Import historical data without affecting billing. Requires a secret + /// API key and . + /// + public bool? Backfill { get; set; } +} + +/// +/// Optional metadata for an identify event. Fields map directly to the +/// corresponding properties; unset +/// fields are omitted from the wire payload. +/// +public class IdentifyOptions +{ + /// + /// Client-supplied dedupe key. Duplicate events with the same key + /// (scoped to the environment) are dropped server-side for 24 hours. + /// + public string? IdempotencyKey { get; set; } +} + public class OfflineHttpMessageHandler : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) From bf47be5972da7abd695cbab845c8c375999bbf6e Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Fri, 22 May 2026 08:58:40 -0600 Subject: [PATCH 3/3] make log level configurable and set default log level to warn --- .../Datastream/Mocks/MockSchematicLogger.cs | 12 +-- src/SchematicHQ.Client.Test/TestLogger.cs | 91 ++++++++++++++++++- .../Core/Public/ClientOptionsCustom.cs | 20 +++- .../Datastream/DatastreamClientAdapter.cs | 19 ++-- src/SchematicHQ.Client/Logger.cs | 32 +++++++ src/SchematicHQ.Client/Schematic.cs | 2 +- 6 files changed, 156 insertions(+), 20 deletions(-) diff --git a/src/SchematicHQ.Client.Test/Datastream/Mocks/MockSchematicLogger.cs b/src/SchematicHQ.Client.Test/Datastream/Mocks/MockSchematicLogger.cs index 4bcf96dc..4609e68d 100644 --- a/src/SchematicHQ.Client.Test/Datastream/Mocks/MockSchematicLogger.cs +++ b/src/SchematicHQ.Client.Test/Datastream/Mocks/MockSchematicLogger.cs @@ -1,7 +1,9 @@ namespace SchematicHQ.Client.Test.Datastream.Mocks { /// - /// Mock implementation of ISchematicLogger for testing + /// 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 { @@ -33,14 +35,6 @@ public bool HasLogEntry(LogLevel level, string messageContains) } } - public enum LogLevel - { - Debug, - Info, - Warn, - Error - } - public class LogEntry { public LogEntry(LogLevel level, string message, object[] args) diff --git a/src/SchematicHQ.Client.Test/TestLogger.cs b/src/SchematicHQ.Client.Test/TestLogger.cs index c9a6ad9a..8195b630 100644 --- a/src/SchematicHQ.Client.Test/TestLogger.cs +++ b/src/SchematicHQ.Client.Test/TestLogger.cs @@ -14,7 +14,9 @@ public void SetUp() //redirect console output _stringWriter = new StringWriter(); Console.SetOut(_stringWriter); - _logger = new ConsoleLogger(); + // 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] @@ -148,5 +150,92 @@ public void Test_Debug_ShouldFormatMessageWithArgs() 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 ac0261a9..988d35f4 100644 --- a/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs +++ b/src/SchematicHQ.Client/Core/Public/ClientOptionsCustom.cs @@ -10,7 +10,24 @@ namespace SchematicHQ.Client; public partial class ClientOptions { public Dictionary FlagDefaults { get; set; } = new Dictionary(); - public ISchematicLogger Logger { get; set; } = new ConsoleLogger(); + + /// + /// 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; + public List> CacheProviders { get; set; } = new List>(); public CacheConfiguration? CacheConfiguration { get; set; } public bool Offline { get; set; } @@ -53,6 +70,7 @@ public static ClientOptions WithHttpClient(this ClientOptions options, HttpClien Headers = new Headers(new Dictionary(options.Headers)), HttpClient = httpClient, Logger = options.Logger, + LogLevel = options.LogLevel, MaxRetries = options.MaxRetries, Offline = options.Offline, ReplicatorMode = options.ReplicatorMode, diff --git a/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs b/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs index 477986dc..28a6e75a 100644 --- a/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs +++ b/src/SchematicHQ.Client/Datastream/DatastreamClientAdapter.cs @@ -7,6 +7,9 @@ using SchematicHQ.Client; using SchematicHQ.Client.Core; using System.Net.WebSockets; +// Disambiguate from SchematicHQ.Client.LogLevel — this file's adapter +// implements Microsoft.Extensions.Logging.ILogger and needs MS's enum. +using MsLogLevel = Microsoft.Extensions.Logging.LogLevel; namespace SchematicHQ.Client.Datastream { @@ -438,29 +441,29 @@ public IDisposable BeginScope(TState state) return new NoopDisposable(); } - public bool IsEnabled(LogLevel logLevel) + public bool IsEnabled(MsLogLevel logLevel) { return true; } - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + public void Log(MsLogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { var message = formatter(state, exception); switch (logLevel) { - case LogLevel.Critical: - case LogLevel.Error: + case MsLogLevel.Critical: + case MsLogLevel.Error: _logger.Error(message); break; - case LogLevel.Warning: + case MsLogLevel.Warning: _logger.Warn(message); break; - case LogLevel.Information: + case MsLogLevel.Information: _logger.Info(message); break; - case LogLevel.Debug: - case LogLevel.Trace: + case MsLogLevel.Debug: + case MsLogLevel.Trace: _logger.Debug(message); break; default: diff --git a/src/SchematicHQ.Client/Logger.cs b/src/SchematicHQ.Client/Logger.cs index 8f9e6fdb..dab8888d 100644 --- a/src/SchematicHQ.Client/Logger.cs +++ b/src/SchematicHQ.Client/Logger.cs @@ -2,6 +2,19 @@ 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); @@ -10,25 +23,44 @@ public interface ISchematicLogger 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/Schematic.cs b/src/SchematicHQ.Client/Schematic.cs index 7c9c77e7..05f682dd 100644 --- a/src/SchematicHQ.Client/Schematic.cs +++ b/src/SchematicHQ.Client/Schematic.cs @@ -50,7 +50,7 @@ public Schematic(string apiKey, ClientOptions? options = null) _options = options ?? new ClientOptions(); _offline = _options.Offline; _replicatorMode = _options.ReplicatorMode; - _logger = _options.Logger ?? new ConsoleLogger(); + _logger = _options.Logger ?? new ConsoleLogger(_options.LogLevel); // Validate replicator mode configuration if (_replicatorMode && string.IsNullOrWhiteSpace(_options.ReplicatorHealthUrl))