diff --git a/src/PostHog/Features/LocalFeatureFlagsLoader.cs b/src/PostHog/Features/LocalFeatureFlagsLoader.cs index 06c92f12..e79303f4 100644 --- a/src/PostHog/Features/LocalFeatureFlagsLoader.cs +++ b/src/PostHog/Features/LocalFeatureFlagsLoader.cs @@ -50,7 +50,7 @@ void StartPollingIfNotStarted() /// Thrown when the API returns a quota_limited error. public async ValueTask GetFeatureFlagsForLocalEvaluationAsync(CancellationToken cancellationToken) { - if (options.Value.PersonalApiKey is null) + if (string.IsNullOrWhiteSpace(options.Value.PersonalApiKey)) { // Local evaluation is not enabled since it requires a personal api key. return null; @@ -70,7 +70,7 @@ void StartPollingIfNotStarted() /// The local evaluator with the feature flags. public async ValueTask RefreshAsync(CancellationToken cancellationToken) { - if (options.Value.PersonalApiKey is null) + if (string.IsNullOrWhiteSpace(options.Value.PersonalApiKey)) { return null; } diff --git a/src/PostHog/Library/StringExtensions.cs b/src/PostHog/Library/StringExtensions.cs index d85df209..af95ecf2 100644 --- a/src/PostHog/Library/StringExtensions.cs +++ b/src/PostHog/Library/StringExtensions.cs @@ -6,6 +6,17 @@ namespace PostHog.Library; internal static class StringExtensions { + /// + /// Trims and returns null when the result is empty. + /// + /// The string to normalize. + /// The trimmed string, or null if the input is null, empty, or whitespace-only. + public static string? NullIfEmpty(this string? value) + { + var trimmed = value?.Trim(); + return string.IsNullOrEmpty(trimmed) ? null : trimmed; + } + /// /// Truncates so its UTF-8 byte length is at most . /// Appends if truncation occurs. Respects UTF-8 code point boundaries, but it can split graphemes. diff --git a/src/PostHog/Library/UriExtensions.cs b/src/PostHog/Library/UriExtensions.cs new file mode 100644 index 00000000..4a2ae9c5 --- /dev/null +++ b/src/PostHog/Library/UriExtensions.cs @@ -0,0 +1,23 @@ +namespace PostHog.Library; + +internal static class UriExtensions +{ + static readonly Uri DefaultHostUrl = new("https://us.i.posthog.com"); + + public static Uri NormalizeHostUrl(this Uri? value) + { + if (value is null) + { + return DefaultHostUrl; + } + + var rawValue = value.OriginalString.Trim(); + if (!Uri.TryCreate(rawValue, UriKind.Absolute, out var normalized) + || (normalized.Scheme != Uri.UriSchemeHttp && normalized.Scheme != Uri.UriSchemeHttps)) + { + return DefaultHostUrl; + } + + return normalized; + } +} diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index 53686fb6..81556139 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -50,6 +50,9 @@ public PostHogClient( _taskScheduler = taskScheduler ?? new TaskRunTaskScheduler(); _timeProvider = timeProvider ?? TimeProvider.System; loggerFactory ??= NullLoggerFactory.Instance; + _logger = loggerFactory.CreateLogger(); + + NormalizeOptions(_options.Value, _logger); _apiClient = new PostHogApiClient( httpClientFactory.CreateClient(nameof(PostHogClient)), @@ -82,10 +85,21 @@ public PostHogClient( CompactionPercentage = options.Value.FeatureFlagSentCacheCompactionPercentage }); - _logger = loggerFactory.CreateLogger(); _logger.LogInfoClientCreated(options.Value.MaxBatchSize, options.Value.FlushInterval, options.Value.FlushAt); } + static void NormalizeOptions(PostHogOptions options, ILogger logger) + { + options.ProjectApiKey = options.ProjectApiKey?.Trim(); + options.PersonalApiKey = options.PersonalApiKey.NullIfEmpty(); + options.HostUrl = options.HostUrl.NormalizeHostUrl(); + + if (string.IsNullOrEmpty(options.ProjectApiKey)) + { + logger.LogErrorProjectApiKeyBlankAfterTrim(); + } + } + /// /// 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 @@ -811,24 +825,30 @@ public static partial void LogErrorUnableToGetRemoteConfigPayload( [LoggerMessage( EventId = 14, + Level = LogLevel.Error, + Message = "ProjectApiKey is empty after trimming whitespace; check your project API key")] + public static partial void LogErrorProjectApiKeyBlankAfterTrim(this ILogger logger); + + [LoggerMessage( + EventId = 15, Level = LogLevel.Information, Message = "[FEATURE FLAGS] Loading feature flags for local evaluation")] public static partial void LogInfoLoadFeatureFlags(this ILogger logger); [LoggerMessage( - EventId = 15, + EventId = 16, 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 = 16, + EventId = 17, Level = LogLevel.Debug, Message = "[FEATURE FLAGS] Feature flags loaded successfully, polling {PollingStatus}")] public static partial void LogDebugFeatureFlagsLoaded(this ILogger logger, string pollingStatus); [LoggerMessage( - EventId = 17, + EventId = 18, Level = LogLevel.Error, Message = "[FEATURE FLAGS] Failed to load feature flags")] public static partial void LogErrorFailedToLoadFeatureFlags(this ILogger logger, Exception exception); diff --git a/tests/UnitTests/PostHogClientTests.cs b/tests/UnitTests/PostHogClientTests.cs index 4ecf3556..612771af 100644 --- a/tests/UnitTests/PostHogClientTests.cs +++ b/tests/UnitTests/PostHogClientTests.cs @@ -1438,6 +1438,59 @@ public async Task LogsWarningWhenPersonalApiKeyIsNull() log.Message?.Contains("You have to specify a personal_api_key to use feature flags", StringComparison.Ordinal) == true); } + [Fact] + public async Task LogsWarningWhenPersonalApiKeyIsBlankAfterTrimmingWhitespace() + { + var container = new TestContainer(personalApiKey: " \n\t "); + var client = container.Activate(); + + await client.LoadFeatureFlagsAsync(); + + 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); + } + + [Fact] + public void LogsErrorWhenProjectApiKeyIsBlankAfterTrimmingWhitespace() + { + var container = new TestContainer(services => + { + services.Configure(options => options.ProjectApiKey = " \n\t "); + }); + + _ = container.Activate(); + + var errorLogs = container.FakeLoggerProvider.GetAllEvents(minimumLevel: LogLevel.Error); + Assert.Contains(errorLogs, log => + log.Message?.Contains("ProjectApiKey is empty after trimming whitespace", StringComparison.Ordinal) == true); + } + + [Fact] + public void DefaultsHostUrlWhenConfiguredHostIsNullOrInvalid() + { + var nullHostContainer = new TestContainer(services => + { + services.Configure(options => options.HostUrl = null!); + }); + + _ = nullHostContainer.Activate(); + + var nullHostOptions = ((IOptions)((IServiceProvider)nullHostContainer).GetService(typeof(IOptions))!).Value; + Assert.Equal(new Uri("https://us.i.posthog.com"), nullHostOptions.HostUrl); + + var invalidHostContainer = new TestContainer(services => + { + services.Configure(options => + options.HostUrl = new Uri(" \n\t ", UriKind.RelativeOrAbsolute)); + }); + + _ = invalidHostContainer.Activate(); + + var invalidHostOptions = ((IOptions)((IServiceProvider)invalidHostContainer).GetService(typeof(IOptions))!).Value; + Assert.Equal(new Uri("https://us.i.posthog.com"), invalidHostOptions.HostUrl); + } + [Fact] public async Task LogsDebugWhenFlagsLoadedSuccessfully() {