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()
{