diff --git a/src/PostHog/Config/PostHogOptions.cs b/src/PostHog/Config/PostHogOptions.cs index 25f2550..92819a4 100644 --- a/src/PostHog/Config/PostHogOptions.cs +++ b/src/PostHog/Config/PostHogOptions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Options; +using PostHog.Library; namespace PostHog; @@ -26,6 +27,15 @@ public string? ProjectToken internal bool HasLegacyProjectApiKey => _projectApiKey is not null; + internal void Normalize() + { + _projectToken = _projectToken.NullIfEmpty(); + _projectApiKey = _projectApiKey.NullIfEmpty(); + PersonalApiKey = PersonalApiKey.NullIfEmpty(); + HostUrl = HostUrl.NormalizeHostUrl(); + Disabled = Disabled || ProjectToken is null; + } + /// /// Obsolete alias for . /// @@ -49,6 +59,11 @@ public string? ProjectApiKey /// public string? PersonalApiKey { get; set; } + /// + /// Whether this client is disabled and should no-op instead of sending data to PostHog. (Default: false) + /// + public bool Disabled { get; set; } + /// /// PostHog API host, usually 'https://us.i.posthog.com' (default) or 'https://eu.i.posthog.com' /// diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index 564cc11..d0d2299 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -95,16 +95,42 @@ static void NormalizeOptions(PostHogOptions options, ILogger logg logger.LogWarningProjectApiKeyDeprecated(); } - options.ProjectToken = options.ProjectToken?.Trim(); - options.PersonalApiKey = options.PersonalApiKey.NullIfEmpty(); - options.HostUrl = options.HostUrl.NormalizeHostUrl(); + options.Normalize(); - if (string.IsNullOrEmpty(options.ProjectToken)) + if (options.ProjectToken is null) { logger.LogErrorProjectTokenRequired(); } } + bool IsDisabled(string methodName) + { + if (!_options.Value.Disabled) + { + return false; + } + + _logger.LogWarningClientDisabled(methodName); + return true; + } + + // A personal_api_key is only required for feature flag calls when callers explicitly request + // local-only evaluation. Without it we cannot download local flag definitions, and the + // local-only option means we must not fall back to remote /flags evaluation. + bool RequiresMissingPersonalApiKey(AllFeatureFlagsOptions? options, string methodName) + => options is { OnlyEvaluateLocally: true } && IsPersonalApiKeyMissing(methodName); + + bool IsPersonalApiKeyMissing(string methodName) + { + if (_options.Value.PersonalApiKey is not null) + { + return false; + } + + _logger.LogWarningPersonalApiKeyMissing(methodName); + return true; + } + /// /// To marry up whatever a user does before they sign up or log in with what they do after you need to make an /// alias call. This will allow you to answer questions like "Which marketing channels leads to users churning @@ -122,7 +148,14 @@ public async Task AliasAsync( string previousId, string newId, CancellationToken cancellationToken) - => await _apiClient.AliasAsync(previousId, newId, cancellationToken); + { + if (IsDisabled(nameof(AliasAsync))) + { + return new ApiResult(0); + } + + return await _apiClient.AliasAsync(previousId, newId, cancellationToken); + } /// public async Task IdentifyAsync( @@ -130,11 +163,18 @@ public async Task IdentifyAsync( Dictionary? personPropertiesToSet, Dictionary? personPropertiesToSetOnce, CancellationToken cancellationToken) - => await _apiClient.IdentifyAsync( + { + if (IsDisabled(nameof(IdentifyAsync))) + { + return new ApiResult(0); + } + + return await _apiClient.IdentifyAsync( distinctId, personPropertiesToSet, personPropertiesToSetOnce, cancellationToken); + } /// public Task GroupIdentifyAsync( @@ -142,7 +182,14 @@ public Task GroupIdentifyAsync( StringOrValue key, Dictionary? properties, CancellationToken cancellationToken) - => _apiClient.GroupIdentifyAsync(type, key, properties, cancellationToken); + { + if (IsDisabled(nameof(GroupIdentifyAsync))) + { + return Task.FromResult(new ApiResult(0)); + } + + return _apiClient.GroupIdentifyAsync(type, key, properties, cancellationToken); + } /// public Task GroupIdentifyAsync( @@ -151,7 +198,14 @@ public Task GroupIdentifyAsync( StringOrValue key, Dictionary? properties, CancellationToken cancellationToken) - => _apiClient.GroupIdentifyAsync(type, key, properties, cancellationToken, distinctId); + { + if (IsDisabled(nameof(GroupIdentifyAsync))) + { + return Task.FromResult(new ApiResult(0)); + } + + return _apiClient.GroupIdentifyAsync(type, key, properties, cancellationToken, distinctId); + } /// public bool Capture( @@ -162,6 +216,11 @@ public bool Capture( bool sendFeatureFlags, DateTimeOffset? timestamp = null) { + if (IsDisabled(nameof(Capture))) + { + return false; + } + // If custom timestamp provided, add it to properties if (timestamp.HasValue) { @@ -218,6 +277,11 @@ public bool CaptureException( bool sendFeatureFlags, DateTimeOffset? timestamp = null) { + if (IsDisabled(nameof(CaptureException))) + { + return false; + } + if (exception == null) { _logger.LogErrorCaptureExceptionNull(); @@ -305,6 +369,16 @@ public async Task IsFeatureEnabledAsync( FeatureFlagOptions? options, CancellationToken cancellationToken) { + if (IsDisabled(nameof(IsFeatureEnabledAsync))) + { + return false; + } + + if (RequiresMissingPersonalApiKey(options, nameof(IsFeatureEnabledAsync))) + { + return false; + } + var result = await GetFeatureFlagAsync( featureKey, distinctId, @@ -321,6 +395,16 @@ public async Task IsFeatureEnabledAsync( FeatureFlagOptions? options, CancellationToken cancellationToken) { + if (IsDisabled(nameof(GetFeatureFlagAsync))) + { + return null; + } + + if (RequiresMissingPersonalApiKey(options, nameof(GetFeatureFlagAsync))) + { + return null; + } + LocalEvaluator? localEvaluator; try { @@ -461,9 +545,13 @@ void HandleRemoteError(Exception ex, string errorType) /// public async Task GetRemoteConfigPayloadAsync(string key, CancellationToken cancellationToken) { - if (_options.Value.PersonalApiKey is null) + if (IsDisabled(nameof(GetRemoteConfigPayloadAsync))) + { + return null; + } + + if (IsPersonalApiKeyMissing(nameof(GetRemoteConfigPayloadAsync))) { - _logger.LogWarningPersonalApiKeyRequiredForRemoteConfigPayload(); return null; } @@ -565,6 +653,16 @@ public async Task> GetAllFeatureFlagsAs AllFeatureFlagsOptions? options, CancellationToken cancellationToken) { + if (IsDisabled(nameof(GetAllFeatureFlagsAsync))) + { + return new Dictionary(); + } + + if (RequiresMissingPersonalApiKey(options, nameof(GetAllFeatureFlagsAsync))) + { + return new Dictionary(); + } + if (_options.Value.PersonalApiKey is not null) { // Attempt to load local feature flags. @@ -650,9 +748,13 @@ public async Task LoadFeatureFlagsAsync(CancellationToken cancellationToken) { _logger.LogInfoLoadFeatureFlags(); - if (_options.Value.PersonalApiKey is null) + if (IsDisabled(nameof(LoadFeatureFlagsAsync))) + { + return; + } + + if (IsPersonalApiKeyMissing(nameof(LoadFeatureFlagsAsync))) { - _logger.LogWarningPersonalApiKeyRequired(); return; } @@ -678,7 +780,15 @@ public async Task LoadFeatureFlagsAsync(CancellationToken cancellationToken) } /// - public async Task FlushAsync() => await _asyncBatchHandler.FlushAsync(); + public async Task FlushAsync() + { + if (IsDisabled(nameof(FlushAsync))) + { + return; + } + + await _asyncBatchHandler.FlushAsync(); + } /// public string Version => VersionConstants.Version; @@ -688,6 +798,11 @@ public async Task LoadFeatureFlagsAsync(CancellationToken cancellationToken) [Obsolete("This method is for internal use only and may go away soon.")] internal async Task GetLocalEvaluatorAsync(CancellationToken cancellationToken) { + if (IsDisabled(nameof(GetLocalEvaluatorAsync))) + { + return null; + } + try { return await _featureFlagsLoader.GetFeatureFlagsForLocalEvaluationAsync(cancellationToken); @@ -705,7 +820,15 @@ public async Task LoadFeatureFlagsAsync(CancellationToken cancellationToken) /// /// Clears the local flags cache. /// - public void ClearLocalFlagsCache() => _featureFlagsLoader.Clear(); + public void ClearLocalFlagsCache() + { + if (IsDisabled(nameof(ClearLocalFlagsCache))) + { + return; + } + + _featureFlagsLoader.Clear(); + } /// public async ValueTask DisposeAsync() @@ -798,12 +921,6 @@ public static partial void LogWarnCaptureFailed( [LoggerMessage( EventId = 9, - Level = LogLevel.Warning, - Message = "[FEATURE FLAGS] You have to specify a personal_api_key to fetch remote config payloads.")] - public static partial void LogWarningPersonalApiKeyRequiredForRemoteConfigPayload(this ILogger logger); - - [LoggerMessage( - EventId = 10, Level = LogLevel.Error, Message = "[FEATURE FLAGS] Error while fetching remote config payload.")] public static partial void LogErrorUnableToGetRemoteConfigPayload( @@ -811,68 +928,74 @@ public static partial void LogErrorUnableToGetRemoteConfigPayload( Exception exception); [LoggerMessage( - EventId = 11, + EventId = 10, Level = LogLevel.Error, Message = "[FEATURE FLAGS] Unable to get feature flags and payloads")] public static partial void LogErrorUnableToGetFeatureFlagsAndPayloads(this ILogger logger, Exception exception); [LoggerMessage( - EventId = 12, + EventId = 11, Level = LogLevel.Warning, Message = "[FEATURE FLAGS] Quota exceeded, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts")] public static partial void LogWarningQuotaExceeded(this ILogger logger); [LoggerMessage( - EventId = 13, + EventId = 12, Level = LogLevel.Warning, Message = "[FEATURE FLAGS] Quota exceeded, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts")] public static partial void LogWarningQuotaExceeded(this ILogger logger, Exception e); [LoggerMessage( - EventId = 14, + EventId = 13, Level = LogLevel.Warning, Message = "ProjectApiKey is deprecated and will be removed in the next major version. Use ProjectToken instead.")] public static partial void LogWarningProjectApiKeyDeprecated(this ILogger logger); [LoggerMessage( - EventId = 15, + EventId = 14, Level = LogLevel.Error, Message = "Either ProjectToken or ProjectApiKey must be provided.")] public static partial void LogErrorProjectTokenRequired(this ILogger logger); [LoggerMessage( - EventId = 16, + EventId = 15, Level = LogLevel.Information, Message = "[FEATURE FLAGS] Loading feature flags for local evaluation")] public static partial void LogInfoLoadFeatureFlags(this ILogger logger); [LoggerMessage( - EventId = 17, - Level = LogLevel.Warning, - Message = "[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags.")] - public static partial void LogWarningPersonalApiKeyRequired(this ILogger logger); - - [LoggerMessage( - EventId = 18, + EventId = 16, Level = LogLevel.Debug, Message = "[FEATURE FLAGS] Feature flags loaded successfully, polling {PollingStatus}")] public static partial void LogDebugFeatureFlagsLoaded(this ILogger logger, string pollingStatus); [LoggerMessage( - EventId = 18, + EventId = 17, Level = LogLevel.Error, Message = "[FEATURE FLAGS] Failed to load feature flags")] public static partial void LogErrorFailedToLoadFeatureFlags(this ILogger logger, Exception exception); [LoggerMessage( - EventId = 19, + EventId = 18, Level = LogLevel.Error, Message = "CaptureException called with null exception")] public static partial void LogErrorCaptureExceptionNull(this ILogger logger); [LoggerMessage( - EventId = 20, + EventId = 19, Level = LogLevel.Error, Message = "CaptureException failed with an exception")] public static partial void LogErrorCaptureExceptionFailed(this ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 20, + Level = LogLevel.Warning, + Message = "PostHog SDK is disabled; {MethodName} is a no-op.")] + public static partial void LogWarningClientDisabled(this ILogger logger, string methodName); + + [LoggerMessage( + EventId = 21, + Level = LogLevel.Warning, + Message = "PostHog personal_api_key is not configured; {MethodName} is a no-op.")] + public static partial void LogWarningPersonalApiKeyMissing(this ILogger logger, string methodName); } diff --git a/tests/UnitTests/PostHogClientTests.cs b/tests/UnitTests/PostHogClientTests.cs index 9dbc287..c2896d8 100644 --- a/tests/UnitTests/PostHogClientTests.cs +++ b/tests/UnitTests/PostHogClientTests.cs @@ -1404,6 +1404,166 @@ private static void AssertContextEmpty(JsonElement frame) } } +public class TheDisabledClient +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" \n\t ")] + public async Task NoOpsWhenProjectTokenIsMissingEmptyOrWhitespace(string? projectToken) + { + var container = new TestContainer(services => + { + services.Configure(options => + { + options.ProjectToken = projectToken; + options.PersonalApiKey = "fake-personal-api-key"; + }); + }); + var captureHandler = container.FakeHttpMessageHandler.AddCaptureResponse(); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var flagsHandler = container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {"flag-key": true}}"""); + var localEvaluationHandler = container.FakeHttpMessageHandler.AddLocalEvaluationResponse("""{"flags": []}"""); + var client = container.Activate(); + + var aliasResult = await client.AliasAsync("previous-id", "new-id", CancellationToken.None); + var identifyResult = await client.IdentifyAsync("distinct-id"); + var groupResult = await client.GroupIdentifyAsync("organization", "id:5", null, CancellationToken.None); + var groupWithDistinctIdResult = await client.GroupIdentifyAsync("distinct-id", "organization", "id:5", null, CancellationToken.None); + var captured = client.Capture("distinct-id", "some-event"); + var capturedException = client.CaptureException(new InvalidOperationException("boom"), "distinct-id"); + await client.FlushAsync(); + var isFeatureEnabled = await client.IsFeatureEnabledAsync("flag-key", "distinct-id"); + var featureFlag = await client.GetFeatureFlagAsync("flag-key", "distinct-id", null, CancellationToken.None); + var remoteConfigPayload = await client.GetRemoteConfigPayloadAsync("config-key", CancellationToken.None); + var allFlags = await client.GetAllFeatureFlagsAsync("distinct-id", null, CancellationToken.None); + await client.LoadFeatureFlagsAsync(); + + Assert.Equal(0, aliasResult.Status); + Assert.Equal(0, identifyResult.Status); + Assert.Equal(0, groupResult.Status); + Assert.Equal(0, groupWithDistinctIdResult.Status); + Assert.False(captured); + Assert.False(capturedException); + Assert.False(isFeatureEnabled); + Assert.Null(featureFlag); + Assert.Null(remoteConfigPayload); + Assert.Empty(allFlags); + Assert.Empty(captureHandler.ReceivedRequests); + Assert.Empty(batchHandler.ReceivedRequests); + Assert.Empty(flagsHandler.ReceivedRequests); + Assert.Empty(localEvaluationHandler.ReceivedRequests); + AssertDisabledLog(container, nameof(PostHogClient.AliasAsync)); + AssertDisabledLog(container, nameof(PostHogClient.IdentifyAsync)); + AssertDisabledLog(container, nameof(PostHogClient.GroupIdentifyAsync)); + AssertDisabledLog(container, nameof(PostHogClient.Capture)); + AssertDisabledLog(container, nameof(PostHogClient.CaptureException)); + AssertDisabledLog(container, nameof(PostHogClient.FlushAsync)); + AssertDisabledLog(container, nameof(PostHogClient.IsFeatureEnabledAsync)); + AssertDisabledLog(container, nameof(PostHogClient.GetFeatureFlagAsync)); + AssertDisabledLog(container, nameof(PostHogClient.GetRemoteConfigPayloadAsync)); + AssertDisabledLog(container, nameof(PostHogClient.GetAllFeatureFlagsAsync)); + AssertDisabledLog(container, nameof(PostHogClient.LoadFeatureFlagsAsync)); + + var options = ((IOptions)((IServiceProvider)container).GetService(typeof(IOptions))!).Value; + Assert.Null(options.ProjectToken); + Assert.True(options.Disabled); + } + + [Theory] + [InlineData("")] + [InlineData(" \n\t ")] + public async Task NoOpsWhenLegacyProjectApiKeyIsEmptyOrWhitespace(string projectApiKey) + { + var container = new TestContainer(services => + { + services.Configure(options => + { + options.ProjectToken = null; + options.PersonalApiKey = "fake-personal-api-key"; +#pragma warning disable CS0618 + options.ProjectApiKey = projectApiKey; +#pragma warning restore CS0618 + }); + }); + var captureHandler = container.FakeHttpMessageHandler.AddCaptureResponse(); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var flagsHandler = container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {"flag-key": true}}"""); + var localEvaluationHandler = container.FakeHttpMessageHandler.AddLocalEvaluationResponse("""{"flags": []}"""); + var client = container.Activate(); + + var identifyResult = await client.IdentifyAsync("distinct-id"); + var captured = client.Capture("distinct-id", "some-event"); + await client.FlushAsync(); + var isFeatureEnabled = await client.IsFeatureEnabledAsync("flag-key", "distinct-id"); + var allFlags = await client.GetAllFeatureFlagsAsync("distinct-id", null, CancellationToken.None); + await client.LoadFeatureFlagsAsync(); + + Assert.Equal(0, identifyResult.Status); + Assert.False(captured); + Assert.False(isFeatureEnabled); + Assert.Empty(allFlags); + Assert.Empty(captureHandler.ReceivedRequests); + Assert.Empty(batchHandler.ReceivedRequests); + Assert.Empty(flagsHandler.ReceivedRequests); + Assert.Empty(localEvaluationHandler.ReceivedRequests); + AssertDisabledLog(container, nameof(PostHogClient.Capture)); + + var options = ((IOptions)((IServiceProvider)container).GetService(typeof(IOptions))!).Value; + Assert.Null(options.ProjectToken); + Assert.True(options.Disabled); + } + + static void AssertDisabledLog(TestContainer container, string methodName) + { + var warningLogs = container.FakeLoggerProvider.GetAllEvents(minimumLevel: LogLevel.Warning); + Assert.Contains(warningLogs, log => + log.Message?.Contains("PostHog SDK is disabled", StringComparison.Ordinal) == true + && log.Message.Contains(methodName, StringComparison.Ordinal)); + } +} + +public class ThePersonalApiKeyProtectedMethods +{ + [Fact] + public async Task NoOpWhenPersonalApiKeyIsMissing() + { + var container = new TestContainer(); + var flagsHandler = container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {"flag-key": true}}"""); + var localEvaluationHandler = container.FakeHttpMessageHandler.AddLocalEvaluationResponse("""{"flags": []}"""); + var remoteConfigHandler = container.FakeHttpMessageHandler.AddRemoteConfigResponse("config-key", """{"enabled": true}"""); + var client = container.Activate(); + + var onlyEvaluateLocally = new FeatureFlagOptions { OnlyEvaluateLocally = true }; + var isFeatureEnabled = await client.IsFeatureEnabledAsync("flag-key", "distinct-id", onlyEvaluateLocally, CancellationToken.None); + var featureFlag = await client.GetFeatureFlagAsync("flag-key", "distinct-id", onlyEvaluateLocally, CancellationToken.None); + var allFlags = await client.GetAllFeatureFlagsAsync("distinct-id", onlyEvaluateLocally, CancellationToken.None); + var remoteConfigPayload = await client.GetRemoteConfigPayloadAsync("config-key", CancellationToken.None); + await client.LoadFeatureFlagsAsync(); + + Assert.False(isFeatureEnabled); + Assert.Null(featureFlag); + Assert.Empty(allFlags); + Assert.Null(remoteConfigPayload); + Assert.Empty(flagsHandler.ReceivedRequests); + Assert.Empty(localEvaluationHandler.ReceivedRequests); + Assert.Empty(remoteConfigHandler.ReceivedRequests); + AssertPersonalApiKeyMissingLog(container, nameof(PostHogClient.IsFeatureEnabledAsync)); + AssertPersonalApiKeyMissingLog(container, nameof(PostHogClient.GetFeatureFlagAsync)); + AssertPersonalApiKeyMissingLog(container, nameof(PostHogClient.GetAllFeatureFlagsAsync)); + AssertPersonalApiKeyMissingLog(container, nameof(PostHogClient.GetRemoteConfigPayloadAsync)); + AssertPersonalApiKeyMissingLog(container, nameof(PostHogClient.LoadFeatureFlagsAsync)); + } + + static void AssertPersonalApiKeyMissingLog(TestContainer container, string methodName) + { + var warningLogs = container.FakeLoggerProvider.GetAllEvents(minimumLevel: LogLevel.Warning); + Assert.Contains(warningLogs, log => + log.Message?.Contains("personal_api_key is not configured", StringComparison.Ordinal) == true + && log.Message.Contains(methodName, StringComparison.Ordinal)); + } +} + public class TheLoadFeatureFlagsAsyncMethod { [Fact] @@ -1432,10 +1592,10 @@ public async Task LogsWarningWhenPersonalApiKeyIsNull() await client.LoadFeatureFlagsAsync(); - // Verify warning was logged var warningLogs = container.FakeLoggerProvider.GetAllEvents(minimumLevel: LogLevel.Warning); Assert.Contains(warningLogs, log => - log.Message?.Contains("You have to specify a personal_api_key to use feature flags", StringComparison.Ordinal) == true); + log.Message?.Contains("personal_api_key is not configured", StringComparison.Ordinal) == true + && log.Message.Contains(nameof(PostHogClient.LoadFeatureFlagsAsync), StringComparison.Ordinal)); } [Fact] @@ -1448,7 +1608,8 @@ public async Task LogsWarningWhenPersonalApiKeyIsBlankAfterTrimmingWhitespace() var warningLogs = container.FakeLoggerProvider.GetAllEvents(minimumLevel: LogLevel.Warning); Assert.Contains(warningLogs, log => - log.Message?.Contains("You have to specify a personal_api_key to use feature flags", StringComparison.Ordinal) == true); + log.Message?.Contains("personal_api_key is not configured", StringComparison.Ordinal) == true + && log.Message.Contains(nameof(PostHogClient.LoadFeatureFlagsAsync), StringComparison.Ordinal)); } [Fact]