diff --git a/.changeset/quiet-masks-jump.md b/.changeset/quiet-masks-jump.md
new file mode 100644
index 00000000..8200b0f6
--- /dev/null
+++ b/.changeset/quiet-masks-jump.md
@@ -0,0 +1,5 @@
+---
+"PostHog": patch
+---
+
+Disable the client without logging a project token error when the SDK is explicitly disabled or the project token is missing.
diff --git a/src/PostHog/Config/PostHogOptions.cs b/src/PostHog/Config/PostHogOptions.cs
index 1d9fd14a..feca6a7e 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,14 @@ 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();
+ }
+
///
/// Obsolete alias for .
///
@@ -49,6 +58,15 @@ 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)
+ ///
+ ///
+ /// Read once during construction. Mutating this after the client is
+ /// activated has no effect. Re-create the client to change the disabled state.
+ ///
+ 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 f253fe8e..82412c27 100644
--- a/src/PostHog/PostHogClient.cs
+++ b/src/PostHog/PostHogClient.cs
@@ -22,10 +22,15 @@ public sealed class PostHogClient : IPostHogClient
readonly LocalFeatureFlagsLoader _featureFlagsLoader;
readonly IFeatureFlagCache _featureFlagsCache;
readonly MemoryCache _featureFlagCalledEventCache;
+ static readonly ApiResult NoOpApiResult = new(0);
+ static readonly Task NoOpApiResultTask = Task.FromResult(NoOpApiResult);
+ static readonly IReadOnlyDictionary EmptyFeatureFlags = new Dictionary(0);
+
readonly TimeProvider _timeProvider;
readonly IOptions _options;
readonly ITaskScheduler _taskScheduler;
readonly ILogger _logger;
+ readonly bool _isDisabled;
readonly IFeatureFlagEvaluationsHost _evaluationsHost;
///
@@ -53,7 +58,8 @@ public PostHogClient(
loggerFactory ??= NullLoggerFactory.Instance;
_logger = loggerFactory.CreateLogger();
- NormalizeOptions(_options.Value, _logger);
+ var projectTokenMissing = NormalizeOptions(_options.Value, _logger);
+ _isDisabled = _options.Value.Disabled || projectTokenMissing;
_apiClient = new PostHogApiClient(
httpClientFactory.CreateClient(nameof(PostHogClient)),
@@ -90,21 +96,49 @@ public PostHogClient(
_logger.LogInfoClientCreated(options.Value.MaxBatchSize, options.Value.FlushInterval, options.Value.FlushAt);
}
- static void NormalizeOptions(PostHogOptions options, ILogger logger)
+ static bool NormalizeOptions(PostHogOptions options, ILogger logger)
{
if (options.HasLegacyProjectApiKey)
{
logger.LogWarningProjectApiKeyDeprecated();
}
- options.ProjectToken = options.ProjectToken?.Trim();
- options.PersonalApiKey = options.PersonalApiKey.NullIfEmpty();
- options.HostUrl = options.HostUrl.NormalizeHostUrl();
+ options.Normalize();
- if (string.IsNullOrEmpty(options.ProjectToken))
+ var projectTokenMissing = options.ProjectToken is null;
+ if (projectTokenMissing && !options.Disabled)
{
logger.LogErrorProjectTokenRequired();
}
+ return projectTokenMissing;
+ }
+
+ bool CheckDisabledAndLog(string methodName)
+ {
+ if (!_isDisabled)
+ {
+ 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 } && CheckPersonalApiKeyMissingAndLog(methodName);
+
+ bool CheckPersonalApiKeyMissingAndLog(string methodName)
+ {
+ if (_options.Value.PersonalApiKey is not null)
+ {
+ return false;
+ }
+
+ _logger.LogWarningPersonalApiKeyMissing(methodName);
+ return true;
}
///
@@ -124,7 +158,14 @@ public async Task AliasAsync(
string previousId,
string newId,
CancellationToken cancellationToken)
- => await _apiClient.AliasAsync(previousId, newId, cancellationToken);
+ {
+ if (CheckDisabledAndLog(nameof(AliasAsync)))
+ {
+ return NoOpApiResult;
+ }
+
+ return await _apiClient.AliasAsync(previousId, newId, cancellationToken);
+ }
///
public async Task IdentifyAsync(
@@ -132,11 +173,18 @@ public async Task IdentifyAsync(
Dictionary? personPropertiesToSet,
Dictionary? personPropertiesToSetOnce,
CancellationToken cancellationToken)
- => await _apiClient.IdentifyAsync(
+ {
+ if (CheckDisabledAndLog(nameof(IdentifyAsync)))
+ {
+ return NoOpApiResult;
+ }
+
+ return await _apiClient.IdentifyAsync(
distinctId,
personPropertiesToSet,
personPropertiesToSetOnce,
cancellationToken);
+ }
///
public Task GroupIdentifyAsync(
@@ -144,7 +192,14 @@ public Task GroupIdentifyAsync(
StringOrValue key,
Dictionary? properties,
CancellationToken cancellationToken)
- => _apiClient.GroupIdentifyAsync(type, key, properties, cancellationToken);
+ {
+ if (CheckDisabledAndLog(nameof(GroupIdentifyAsync)))
+ {
+ return NoOpApiResultTask;
+ }
+
+ return _apiClient.GroupIdentifyAsync(type, key, properties, cancellationToken);
+ }
///
public Task GroupIdentifyAsync(
@@ -153,7 +208,14 @@ public Task GroupIdentifyAsync(
StringOrValue key,
Dictionary? properties,
CancellationToken cancellationToken)
- => _apiClient.GroupIdentifyAsync(type, key, properties, cancellationToken, distinctId);
+ {
+ if (CheckDisabledAndLog(nameof(GroupIdentifyAsync)))
+ {
+ return NoOpApiResultTask;
+ }
+
+ return _apiClient.GroupIdentifyAsync(type, key, properties, cancellationToken, distinctId);
+ }
///
public bool Capture(
@@ -185,6 +247,11 @@ bool CaptureCore(
FeatureFlagEvaluations? flags,
DateTimeOffset? timestamp)
{
+ if (CheckDisabledAndLog(nameof(Capture)))
+ {
+ return false;
+ }
+
// If custom timestamp provided, add it to properties
if (timestamp.HasValue)
{
@@ -268,6 +335,11 @@ bool CaptureExceptionCore(
FeatureFlagEvaluations? flags,
DateTimeOffset? timestamp)
{
+ if (CheckDisabledAndLog(nameof(CaptureException)))
+ {
+ return false;
+ }
+
if (exception == null)
{
_logger.LogErrorCaptureExceptionNull();
@@ -375,6 +447,16 @@ public async Task IsFeatureEnabledAsync(
FeatureFlagOptions? options,
CancellationToken cancellationToken)
{
+ if (CheckDisabledAndLog(nameof(IsFeatureEnabledAsync)))
+ {
+ return false;
+ }
+
+ if (RequiresMissingPersonalApiKey(options, nameof(IsFeatureEnabledAsync)))
+ {
+ return false;
+ }
+
#pragma warning disable CS0618 // Internal call into the deprecated path; see method docstring for the preferred API.
var result = await GetFeatureFlagAsync(
featureKey,
@@ -394,6 +476,16 @@ public async Task IsFeatureEnabledAsync(
FeatureFlagOptions? options,
CancellationToken cancellationToken)
{
+ if (CheckDisabledAndLog(nameof(GetFeatureFlagAsync)))
+ {
+ return null;
+ }
+
+ if (RequiresMissingPersonalApiKey(options, nameof(GetFeatureFlagAsync)))
+ {
+ return null;
+ }
+
LocalEvaluator? localEvaluator;
try
{
@@ -531,9 +623,13 @@ void HandleRemoteError(Exception ex, string errorType)
///
public async Task GetRemoteConfigPayloadAsync(string key, CancellationToken cancellationToken)
{
- if (_options.Value.PersonalApiKey is null)
+ if (CheckDisabledAndLog(nameof(GetRemoteConfigPayloadAsync)))
+ {
+ return null;
+ }
+
+ if (CheckPersonalApiKeyMissingAndLog(nameof(GetRemoteConfigPayloadAsync)))
{
- _logger.LogWarningPersonalApiKeyRequiredForRemoteConfigPayload();
return null;
}
@@ -724,6 +820,16 @@ public async Task EvaluateFlagsAsync(
AllFeatureFlagsOptions? options,
CancellationToken cancellationToken)
{
+ if (CheckDisabledAndLog(nameof(EvaluateFlagsAsync)))
+ {
+ return FeatureFlagEvaluations.Empty(_evaluationsHost, distinctId ?? string.Empty);
+ }
+
+ if (RequiresMissingPersonalApiKey(options, nameof(EvaluateFlagsAsync)))
+ {
+ return FeatureFlagEvaluations.Empty(_evaluationsHost, distinctId ?? string.Empty);
+ }
+
if (string.IsNullOrEmpty(distinctId))
{
// Empty distinct id is a safety fallback. Returning an empty snapshot avoids leaking
@@ -845,6 +951,16 @@ public async Task> GetAllFeatureFlagsAs
AllFeatureFlagsOptions? options,
CancellationToken cancellationToken)
{
+ if (CheckDisabledAndLog(nameof(GetAllFeatureFlagsAsync)))
+ {
+ return EmptyFeatureFlags;
+ }
+
+ if (RequiresMissingPersonalApiKey(options, nameof(GetAllFeatureFlagsAsync)))
+ {
+ return EmptyFeatureFlags;
+ }
+
if (_options.Value.PersonalApiKey is not null)
{
// Attempt to load local feature flags.
@@ -869,7 +985,7 @@ public async Task> GetAllFeatureFlagsAs
catch (ApiException e) when (e.ErrorType is "quota_limited")
{
_logger.LogWarningQuotaExceeded(e);
- return new Dictionary();
+ return EmptyFeatureFlags;
}
}
@@ -881,7 +997,7 @@ public async Task> GetAllFeatureFlagsAs
catch (Exception e) when (e is not ArgumentException and not NullReferenceException)
{
_logger.LogErrorUnableToGetFeatureFlagsAndPayloads(e);
- return new Dictionary();
+ return EmptyFeatureFlags;
}
}
@@ -930,9 +1046,13 @@ public async Task LoadFeatureFlagsAsync(CancellationToken cancellationToken)
{
_logger.LogInfoLoadFeatureFlags();
- if (_options.Value.PersonalApiKey is null)
+ if (CheckDisabledAndLog(nameof(LoadFeatureFlagsAsync)))
+ {
+ return;
+ }
+
+ if (CheckPersonalApiKeyMissingAndLog(nameof(LoadFeatureFlagsAsync)))
{
- _logger.LogWarningPersonalApiKeyRequired();
return;
}
@@ -958,7 +1078,15 @@ public async Task LoadFeatureFlagsAsync(CancellationToken cancellationToken)
}
///
- public async Task FlushAsync() => await _asyncBatchHandler.FlushAsync();
+ public async Task FlushAsync()
+ {
+ if (CheckDisabledAndLog(nameof(FlushAsync)))
+ {
+ return;
+ }
+
+ await _asyncBatchHandler.FlushAsync();
+ }
///
public string Version => VersionConstants.Version;
@@ -968,6 +1096,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 (CheckDisabledAndLog(nameof(GetLocalEvaluatorAsync)))
+ {
+ return null;
+ }
+
try
{
return await _featureFlagsLoader.GetFeatureFlagsForLocalEvaluationAsync(cancellationToken);
@@ -985,7 +1118,15 @@ public async Task LoadFeatureFlagsAsync(CancellationToken cancellationToken)
///
/// Clears the local flags cache.
///
- public void ClearLocalFlagsCache() => _featureFlagsLoader.Clear();
+ public void ClearLocalFlagsCache()
+ {
+ if (CheckDisabledAndLog(nameof(ClearLocalFlagsCache)))
+ {
+ return;
+ }
+
+ _featureFlagsLoader.Clear();
+ }
///
public async ValueTask DisposeAsync()
@@ -1078,12 +1219,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(
@@ -1091,71 +1226,77 @@ 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 = 19,
+ 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 = 20,
+ EventId = 18,
Level = LogLevel.Error,
Message = "CaptureException called with null exception")]
public static partial void LogErrorCaptureExceptionNull(this ILogger logger);
[LoggerMessage(
- EventId = 21,
+ 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);
+
[LoggerMessage(
EventId = 22,
Level = LogLevel.Warning,
diff --git a/tests/UnitTests/PostHogClientTests.cs b/tests/UnitTests/PostHogClientTests.cs
index d3a568b3..6752e245 100644
--- a/tests/UnitTests/PostHogClientTests.cs
+++ b/tests/UnitTests/PostHogClientTests.cs
@@ -1405,6 +1405,248 @@ private static void AssertContextEmpty(JsonElement frame)
}
}
+public class TheDisabledClient
+{
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" \n\t ")]
+ // NullIfEmpty uses string.Trim(), so char.IsWhiteSpace characters like NBSP are treated as missing.
+ // ZWSP (U+200B) is intentionally treated as a real token because it is not whitespace for string.Trim().
+ [InlineData("\u00A0")]
+ 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();
+#pragma warning disable CS0618
+ var isFeatureEnabled = await client.IsFeatureEnabledAsync("flag-key", "distinct-id");
+ var featureFlag = await client.GetFeatureFlagAsync("flag-key", "distinct-id", null, CancellationToken.None);
+#pragma warning restore CS0618
+ var remoteConfigPayload = await client.GetRemoteConfigPayloadAsync("config-key", CancellationToken.None);
+ var flagEvaluations = await client.EvaluateFlagsAsync("distinct-id", null, 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(flagEvaluations.Keys);
+ 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), expectedCount: 2);
+ 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.EvaluateFlagsAsync));
+ AssertDisabledLog(container, nameof(PostHogClient.GetAllFeatureFlagsAsync));
+ AssertDisabledLog(container, nameof(PostHogClient.LoadFeatureFlagsAsync));
+ AssertProjectTokenRequiredLog(container);
+
+ var options = ((IOptions)((IServiceProvider)container).GetService(typeof(IOptions))!).Value;
+ Assert.Null(options.ProjectToken);
+ Assert.False(options.Disabled);
+ }
+
+ [Fact]
+ public async Task FlushAsyncNoOpsWhenExplicitlyDisabled()
+ {
+ var container = new TestContainer(services =>
+ {
+ services.Configure(options =>
+ {
+ options.ProjectToken = "fake-project-token";
+ options.Disabled = true;
+ });
+ });
+ var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse();
+ var client = container.Activate();
+
+ await client.FlushAsync();
+
+ Assert.Empty(batchHandler.ReceivedRequests);
+ AssertDisabledLog(container, nameof(PostHogClient.FlushAsync));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" \n\t ")]
+ public void DoesNotLogProjectTokenRequiredWhenExplicitlyDisabledAndProjectTokenIsMissing(string? projectToken)
+ {
+ var container = new TestContainer(services =>
+ {
+ services.Configure(options =>
+ {
+ options.ProjectToken = projectToken;
+ options.Disabled = true;
+ });
+ });
+ var client = container.Activate();
+
+ var captured = client.Capture("distinct-id", "some-event");
+
+ Assert.False(captured);
+ AssertDisabledLog(container, nameof(PostHogClient.Capture));
+ AssertNoProjectTokenRequiredLog(container);
+
+ 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();
+#pragma warning disable CS0618
+ var isFeatureEnabled = await client.IsFeatureEnabledAsync("flag-key", "distinct-id");
+#pragma warning restore CS0618
+ 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.IdentifyAsync));
+ AssertDisabledLog(container, nameof(PostHogClient.Capture));
+ AssertDisabledLog(container, nameof(PostHogClient.FlushAsync));
+ AssertDisabledLog(container, nameof(PostHogClient.IsFeatureEnabledAsync));
+ AssertDisabledLog(container, nameof(PostHogClient.GetAllFeatureFlagsAsync));
+ AssertDisabledLog(container, nameof(PostHogClient.LoadFeatureFlagsAsync));
+
+ var options = ((IOptions)((IServiceProvider)container).GetService(typeof(IOptions))!).Value;
+ Assert.Null(options.ProjectToken);
+ Assert.False(options.Disabled);
+ }
+
+ static void AssertDisabledLog(TestContainer container, string methodName, int expectedCount = 1)
+ {
+ var warningLogs = container.FakeLoggerProvider.GetAllEvents(minimumLevel: LogLevel.Warning);
+ var matches = warningLogs.Count(log =>
+ log.EventId.Id == 20
+ && log.Message == $"PostHog SDK is disabled; {methodName} is a no-op.");
+ Assert.Equal(expectedCount, matches);
+ }
+
+ static void AssertProjectTokenRequiredLog(TestContainer container)
+ {
+ var errorLogs = container.FakeLoggerProvider.GetAllEvents(minimumLevel: LogLevel.Error);
+ Assert.Contains(errorLogs, log => log.EventId.Id == 14);
+ }
+
+ static void AssertNoProjectTokenRequiredLog(TestContainer container)
+ {
+ var errorLogs = container.FakeLoggerProvider.GetAllEvents(minimumLevel: LogLevel.Error);
+ Assert.DoesNotContain(errorLogs, log => log.EventId.Id == 14);
+ }
+}
+
+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 };
+#pragma warning disable CS0618
+ var isFeatureEnabled = await client.IsFeatureEnabledAsync("flag-key", "distinct-id", onlyEvaluateLocally, CancellationToken.None);
+ var featureFlag = await client.GetFeatureFlagAsync("flag-key", "distinct-id", onlyEvaluateLocally, CancellationToken.None);
+#pragma warning restore CS0618
+ var flagEvaluations = await client.EvaluateFlagsAsync("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(flagEvaluations.Keys);
+ 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.EvaluateFlagsAsync));
+ 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);
+ var matches = warningLogs.Count(log =>
+ log.EventId.Id == 21
+ && log.Message == $"PostHog personal_api_key is not configured; {methodName} is a no-op.");
+ Assert.Equal(1, matches);
+ }
+}
+
public class TheLoadFeatureFlagsAsyncMethod
{
[Fact]
@@ -1433,10 +1675,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]
@@ -1449,7 +1691,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]