From d3e39371d94de967b49436dfb09d33eac9b5d5eb Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 12:53:00 -0700 Subject: [PATCH 1/6] feat: add EvaluateFlagsAsync() API for single-call flag evaluation Adds a new EvaluateFlagsAsync(distinctId, options) method on the client that returns a FeatureFlagEvaluations snapshot. The snapshot powers IsEnabled / GetFlag / GetFlagPayload calls, fires $feature_flag_called lazily (deduped against the existing per-distinct-id cache), and can be forwarded to a new Capture(..., flags: snapshot) overload to attach $feature/ and $active_feature_flags to events without a second /flags request. Mirrors PostHog/posthog-js#3476 and PostHog/posthog-python#539. Also fixes a long-standing bug where the legacy single-flag path hard-coded locally_evaluated=false on every $feature_flag_called event. Locally-evaluated flags now correctly carry locally_evaluated=true, $feature_flag_reason="Evaluated locally", and a new $feature_flag_definitions_loaded_at timestamp surfaced via LocalFeatureFlagsLoader. The existing IsFeatureEnabledAsync / GetFeatureFlagAsync / Capture(..., sendFeatureFlags, ...) APIs are unchanged in this PR; a follow-up minor will mark them deprecated in favor of the snapshot API. Generated-By: PostHog Code Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40 --- Directory.Build.props | 2 +- src/PostHog/Config/PostHogOptions.cs | 9 + src/PostHog/Features/EvaluatedFlagRecord.cs | 35 ++ .../Features/FeatureFlagEvaluations.cs | 227 +++++++++++ src/PostHog/Features/FeatureFlagExtensions.cs | 34 ++ .../Features/IFeatureFlagEvaluationsHost.cs | 30 ++ .../Features/LocalFeatureFlagsLoader.cs | 16 + src/PostHog/Generated/VersionConstants.cs | 2 +- src/PostHog/IPostHogClient.cs | 60 +++ src/PostHog/PostHogClient.cs | 380 ++++++++++++++++-- .../Features/FeatureFlagEvaluationsTests.cs | 367 +++++++++++++++++ tests/UnitTests/Features/FeatureFlagsTests.cs | 16 +- 12 files changed, 1131 insertions(+), 47 deletions(-) create mode 100644 src/PostHog/Features/EvaluatedFlagRecord.cs create mode 100644 src/PostHog/Features/FeatureFlagEvaluations.cs create mode 100644 src/PostHog/Features/IFeatureFlagEvaluationsHost.cs create mode 100644 tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 10a90c9a..45d1c236 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.5.0 + 2.6.0 13.0 enable enable diff --git a/src/PostHog/Config/PostHogOptions.cs b/src/PostHog/Config/PostHogOptions.cs index 25f25502..1d9fd14a 100644 --- a/src/PostHog/Config/PostHogOptions.cs +++ b/src/PostHog/Config/PostHogOptions.cs @@ -88,6 +88,15 @@ public string? ProjectApiKey /// public TimeSpan FeatureFlagSentCacheSlidingExpiration { get; set; } = TimeSpan.FromMinutes(10); + /// + /// When true (default), the SDK emits warning logs from the + /// snapshot helpers — specifically when + /// is called before any flags have been accessed, + /// or when + /// is given keys that are not present in the snapshot. Set to false to silence these warnings. + /// + public bool FeatureFlagsLogWarnings { get; set; } = true; + /// /// The maximum number of messages to send in a batch. (Default: 100) /// diff --git a/src/PostHog/Features/EvaluatedFlagRecord.cs b/src/PostHog/Features/EvaluatedFlagRecord.cs new file mode 100644 index 00000000..e3dc455e --- /dev/null +++ b/src/PostHog/Features/EvaluatedFlagRecord.cs @@ -0,0 +1,35 @@ +namespace PostHog.Features; + +/// +/// The internal per-flag record stored on a snapshot. Captures +/// everything required to (a) attach event properties when the snapshot is forwarded to Capture +/// and (b) fire a fully-populated $feature_flag_called event on first access. +/// +internal sealed record EvaluatedFlagRecord +{ + public required string Key { get; init; } + + /// + /// The underlying as exposed to callers via + /// . + /// + public required FeatureFlag Flag { get; init; } + + /// + /// Whether the flag is enabled. Mirrors Flag.IsEnabled but stored explicitly so the snapshot + /// can compute $active_feature_flags without re-traversing . + /// + public required bool Enabled { get; init; } + + /// + /// The string-form value used as the dedup-cache key for $feature_flag_called. Derived + /// from the implicit -to- conversion so the legacy + /// single-flag path and the snapshot path produce byte-identical cache keys. + /// + public required string CacheKeyValue { get; init; } + + public int? Id { get; init; } + public int? Version { get; init; } + public string? Reason { get; init; } + public bool LocallyEvaluated { get; init; } +} diff --git a/src/PostHog/Features/FeatureFlagEvaluations.cs b/src/PostHog/Features/FeatureFlagEvaluations.cs new file mode 100644 index 00000000..5e5a4a1c --- /dev/null +++ b/src/PostHog/Features/FeatureFlagEvaluations.cs @@ -0,0 +1,227 @@ +using System.Text.Json; +using static PostHog.Library.Ensure; + +namespace PostHog.Features; + +/// +/// A point-in-time snapshot of feature flag evaluations for a single distinct id, returned by +/// 's EvaluateFlagsAsync method. Reading flags from the snapshot +/// records access and lazily fires the $feature_flag_called event (deduplicated against the +/// SDK's per-distinct-id cache) so callers can branch on flags and then forward the snapshot to +/// Capture(..., flags: snapshot, ...) to attach $feature/<key> and +/// $active_feature_flags properties without a second /flags request. +/// +public sealed class FeatureFlagEvaluations +{ + readonly IFeatureFlagEvaluationsHost _host; + readonly IReadOnlyDictionary _records; + readonly HashSet _accessed; + readonly GroupCollection? _groups; + readonly IReadOnlyCollection _errors; + + internal FeatureFlagEvaluations( + IFeatureFlagEvaluationsHost host, + string distinctId, + IReadOnlyDictionary records, + string? requestId, + long? evaluatedAt, + long? flagDefinitionsLoadedAt, + GroupCollection? groups, + IReadOnlyCollection? errors, + HashSet? accessed = null) + { + _host = NotNull(host); + DistinctId = distinctId ?? string.Empty; + _records = records; + RequestId = requestId; + EvaluatedAt = evaluatedAt; + FlagDefinitionsLoadedAt = flagDefinitionsLoadedAt; + _groups = groups; + _errors = errors ?? Array.Empty(); + _accessed = accessed ?? new HashSet(StringComparer.Ordinal); + } + + /// + /// The distinct id this snapshot was evaluated for. Empty when the snapshot was created + /// as a safety fallback (e.g. an empty distinct id was passed to EvaluateFlagsAsync). + /// + public string DistinctId { get; } + + /// + /// The request id reported by the /flags response, or null if the snapshot was + /// fully resolved via local evaluation. + /// + public string? RequestId { get; } + + /// + /// The timestamp (Unix milliseconds) reported by the /flags response, or null + /// if the snapshot was fully resolved via local evaluation. + /// + public long? EvaluatedAt { get; } + + /// + /// The Unix-millisecond timestamp at which the local flag definitions used by the snapshot + /// were loaded, or null if no flag in the snapshot was locally evaluated. + /// + public long? FlagDefinitionsLoadedAt { get; } + + /// + /// The set of flag keys present in this snapshot. + /// + public IReadOnlyCollection Keys => (IReadOnlyCollection)_records.Keys; + + /// + /// Returns true when the named flag is present in the snapshot and enabled. Records + /// access on the snapshot and fires $feature_flag_called on first access for a given + /// (distinct id, key, value) tuple. + /// + /// The feature flag key. + public bool IsEnabled(string key) + { + var record = RecordAccess(key); + return record is { Enabled: true }; + } + + /// + /// Returns the named flag from the snapshot, or null if it is not present. Records access + /// on the snapshot and fires $feature_flag_called on first access for a given + /// (distinct id, key, value) tuple. + /// + /// The feature flag key. + public FeatureFlag? GetFlag(string key) + { + var record = RecordAccess(key); + return record?.Flag; + } + + /// + /// Returns the payload for the named flag, or null if it is not present or has no payload. + /// Does NOT record access and does NOT fire $feature_flag_called. + /// + /// The feature flag key. + public JsonDocument? GetFlagPayload(string key) + => _records.TryGetValue(NotNull(key), out var record) ? record.Flag.Payload : null; + + /// + /// Returns a new snapshot containing only the flags that have been accessed via + /// or . If no flags have been accessed yet, + /// logs a warning and returns a snapshot containing all flags so callers do not silently + /// drop exposure data. + /// + public FeatureFlagEvaluations OnlyAccessed() + { + if (_accessed.Count == 0) + { + _host.LogFilterWarning( + "FeatureFlagEvaluations.OnlyAccessed() was called before any flags were accessed; " + + "attaching all evaluated flags as a fallback."); + return CloneWith(_records); + } + + var filtered = new Dictionary(StringComparer.Ordinal); + foreach (var key in _accessed) + { + if (_records.TryGetValue(key, out var record)) + { + filtered[key] = record; + } + } + return CloneWith(filtered); + } + + /// + /// Returns a new snapshot containing only the named flags. Unknown keys are dropped silently + /// (a warning is logged for each missing key). + /// + /// The flag keys to retain. + public FeatureFlagEvaluations Only(IEnumerable keys) + { + var filtered = new Dictionary(StringComparer.Ordinal); + var missing = new List(); + foreach (var key in NotNull(keys)) + { + if (_records.TryGetValue(key, out var record)) + { + filtered[key] = record; + } + else + { + missing.Add(key); + } + } + + if (missing.Count > 0) + { + _host.LogFilterWarning( + "FeatureFlagEvaluations.Only(...) requested keys that are not in the snapshot and will be dropped: " + + string.Join(", ", missing)); + } + + return CloneWith(filtered); + } + + /// + public FeatureFlagEvaluations Only(params string[] keys) + => Only((IEnumerable)NotNull(keys)); + + /// + /// The internal per-flag records. Used by 's capture path to attach + /// $feature/<key> properties. + /// + internal IReadOnlyDictionary Records => _records; + + /// + /// Constructs an empty snapshot with no flags and no events. Used as the safety fallback when + /// EvaluateFlagsAsync is called without a usable distinct id, or when remote evaluation + /// is quota-limited. + /// + internal static FeatureFlagEvaluations Empty(IFeatureFlagEvaluationsHost host, string distinctId) + => new( + host, + distinctId, + new Dictionary(StringComparer.Ordinal), + requestId: null, + evaluatedAt: null, + flagDefinitionsLoadedAt: null, + groups: null, + errors: null); + + EvaluatedFlagRecord? RecordAccess(string key) + { + var keyChecked = NotNull(key); + _accessed.Add(keyChecked); + + if (string.IsNullOrEmpty(DistinctId)) + { + // Empty-distinct-id snapshots are a safety fallback. Do not emit $feature_flag_called + // events with an empty distinct id, since they would pollute analytics. + return _records.TryGetValue(keyChecked, out var emptyRecord) ? emptyRecord : null; + } + + _records.TryGetValue(keyChecked, out var record); + + _host.TryCaptureFeatureFlagCalledEventIfNeeded( + distinctId: DistinctId, + featureKey: keyChecked, + record: record, + groups: _groups, + requestId: RequestId, + evaluatedAt: EvaluatedAt, + flagDefinitionsLoadedAt: FlagDefinitionsLoadedAt, + errors: _errors); + + return record; + } + + FeatureFlagEvaluations CloneWith(IReadOnlyDictionary records) + => new( + _host, + DistinctId, + records, + RequestId, + EvaluatedAt, + FlagDefinitionsLoadedAt, + _groups, + _errors, + accessed: new HashSet(_accessed, StringComparer.Ordinal)); +} diff --git a/src/PostHog/Features/FeatureFlagExtensions.cs b/src/PostHog/Features/FeatureFlagExtensions.cs index b9e03ad8..e5f4d93b 100644 --- a/src/PostHog/Features/FeatureFlagExtensions.cs +++ b/src/PostHog/Features/FeatureFlagExtensions.cs @@ -228,6 +228,40 @@ public static async Task> GetAllFeature .GetAllFeatureFlagsAsync(distinctId, options: new AllFeatureFlagsOptions(), CancellationToken.None); } + /// + /// Evaluates all feature flags for the user and returns a snapshot. + /// + /// The . + /// The identifier you use for the user. + public static Task EvaluateFlagsAsync( + this IPostHogClient client, + string distinctId) + => NotNull(client).EvaluateFlagsAsync(distinctId, options: null, CancellationToken.None); + + /// + /// Evaluates all feature flags for the user and returns a snapshot. + /// + /// The . + /// The identifier you use for the user. + /// The cancellation token that can be used to cancel the operation. + public static Task EvaluateFlagsAsync( + this IPostHogClient client, + string distinctId, + CancellationToken cancellationToken) + => NotNull(client).EvaluateFlagsAsync(distinctId, options: null, cancellationToken); + + /// + /// Evaluates all feature flags for the user and returns a snapshot. + /// + /// The . + /// The identifier you use for the user. + /// Options used to control feature flag evaluation. scopes the underlying /flags request body. + public static Task EvaluateFlagsAsync( + this IPostHogClient client, + string distinctId, + AllFeatureFlagsOptions options) + => NotNull(client).EvaluateFlagsAsync(distinctId, options, CancellationToken.None); + /// /// Loads (or reloads) feature flag definitions for local evaluation. /// diff --git a/src/PostHog/Features/IFeatureFlagEvaluationsHost.cs b/src/PostHog/Features/IFeatureFlagEvaluationsHost.cs new file mode 100644 index 00000000..85679d60 --- /dev/null +++ b/src/PostHog/Features/IFeatureFlagEvaluationsHost.cs @@ -0,0 +1,30 @@ +namespace PostHog.Features; + +/// +/// The narrow seam between and the SDK client that owns the +/// dedup cache and logger. The snapshot only needs these two operations, so it does not depend on +/// the full surface — keeping the snapshot simple and easy to test. +/// +internal interface IFeatureFlagEvaluationsHost +{ + /// + /// Fires a $feature_flag_called event for the given access, deduplicated against the + /// per-distinct-id cache that the legacy single-flag path also writes to. + /// + void TryCaptureFeatureFlagCalledEventIfNeeded( + string distinctId, + string featureKey, + EvaluatedFlagRecord? record, + GroupCollection? groups, + string? requestId, + long? evaluatedAt, + long? flagDefinitionsLoadedAt, + IReadOnlyCollection errors); + + /// + /// Logs a warning from or + /// . + /// Implementations should respect . + /// + void LogFilterWarning(string message); +} diff --git a/src/PostHog/Features/LocalFeatureFlagsLoader.cs b/src/PostHog/Features/LocalFeatureFlagsLoader.cs index e79303f4..5e46840b 100644 --- a/src/PostHog/Features/LocalFeatureFlagsLoader.cs +++ b/src/PostHog/Features/LocalFeatureFlagsLoader.cs @@ -26,6 +26,7 @@ internal sealed class LocalFeatureFlagsLoader( volatile int _disposed; volatile Task? _pollingTask; LocalEvaluator? _localEvaluator; + long _flagDefinitionsLoadedAtMs; // Unix milliseconds; 0 means not yet loaded. volatile string? _etag; // ETag for conditional requests to reduce bandwidth readonly CancellationTokenSource _cancellationTokenSource = new(); readonly PeriodicTimer _timer = new(options.Value.FeatureFlagPollInterval, timeProvider); @@ -112,6 +113,7 @@ void StartPollingIfNotStarted() var localEvaluator = new LocalEvaluator(response.Result, timeProvider, _localEvaluatorLogger); Interlocked.Exchange(ref _localEvaluator, localEvaluator); + Interlocked.Exchange(ref _flagDefinitionsLoadedAtMs, timeProvider.GetUtcNow().ToUnixTimeMilliseconds()); return localEvaluator; } @@ -145,6 +147,19 @@ async Task PollForFeatureFlagsAsync(CancellationToken cancellationToken) public bool IsLoaded => _localEvaluator is not null; + /// + /// The Unix-millisecond timestamp at which the local flag definitions were last successfully loaded, + /// or null if they have not yet been loaded. + /// + public long? FlagDefinitionsLoadedAt + { + get + { + var value = Interlocked.Read(ref _flagDefinitionsLoadedAtMs); + return value == 0 ? null : value; + } + } + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); public async ValueTask DisposeAsync() @@ -172,6 +187,7 @@ public void Clear() { Interlocked.Exchange(ref _localEvaluator, null); Interlocked.Exchange(ref _etag, null); + Interlocked.Exchange(ref _flagDefinitionsLoadedAtMs, 0); } } diff --git a/src/PostHog/Generated/VersionConstants.cs b/src/PostHog/Generated/VersionConstants.cs index 71661910..919e0023 100644 --- a/src/PostHog/Generated/VersionConstants.cs +++ b/src/PostHog/Generated/VersionConstants.cs @@ -7,5 +7,5 @@ namespace PostHog.Versioning; public static class VersionConstants { - public const string Version = "2.5.0"; + public const string Version = "2.6.0"; } diff --git a/src/PostHog/IPostHogClient.cs b/src/PostHog/IPostHogClient.cs index aa2bdbe5..aacdb872 100644 --- a/src/PostHog/IPostHogClient.cs +++ b/src/PostHog/IPostHogClient.cs @@ -103,6 +103,27 @@ bool Capture( bool sendFeatureFlags, DateTimeOffset? timestamp = null); + /// + /// Captures an event and attaches feature flag properties from a previously-evaluated snapshot. + /// Prefer this over sendFeatureFlags: true when you have already called + /// ; it avoids a second /flags request and guarantees the + /// event reflects the same flag values the caller branched on. + /// + /// The identifier you use for the user. + /// Human friendly name of the event. Recommended format [object] [verb] such as "Project created" or "User signed up". + /// Optional: The properties to send along with the event. + /// Optional: Context of what groups are related to this event, example: { ["company"] = "id:5" }. Can be used to analyze companies instead of users. + /// A snapshot of feature flag evaluations. When non-null, $feature/<key> and $active_feature_flags are attached from the snapshot — no /flags call is made. + /// Optional: Custom timestamp when the event occurred. If not provided, uses current time. + /// true if the event was successfully enqueued. Otherwise false. + bool Capture( + string distinctId, + string eventName, + Dictionary? properties, + GroupCollection? groups, + FeatureFlagEvaluations? flags, + DateTimeOffset? timestamp = null); + /// /// Capture an exception as an event. /// @@ -121,6 +142,24 @@ bool CaptureException( bool sendFeatureFlags, DateTimeOffset? timestamp = null); + /// + /// Capture an exception as an event, attaching feature flag properties from a snapshot. + /// + /// The exception to capture. + /// The identifier you use for the user. + /// Optional: The properties to send along with the event. + /// Optional: Context of what groups are related to this event. + /// A snapshot of feature flag evaluations. When non-null, $feature/<key> and $active_feature_flags are attached from the snapshot — no /flags call is made. + /// Optional: Custom timestamp when the event occurred. + /// true if the exception event was successfully enqueued. Otherwise false. + bool CaptureException( + Exception exception, + string distinctId, + Dictionary? properties, + GroupCollection? groups, + FeatureFlagEvaluations? flags, + DateTimeOffset? timestamp = null); + /// /// Determines whether a feature is enabled for the specified user. /// @@ -173,6 +212,27 @@ Task> GetAllFeatureFlagsAsync( AllFeatureFlagsOptions? options, CancellationToken cancellationToken); + /// + /// Evaluates all feature flags for the given user and returns a + /// snapshot. The snapshot can be used for branching (IsEnabled, GetFlag) and + /// forwarded to Capture(..., flags: snapshot, ...) to attach flag properties to events + /// without a second /flags request. $feature_flag_called events are fired lazily + /// on first access of each flag, deduplicated against the SDK's per-distinct-id cache. + /// + /// The identifier you use for the user. + /// + /// Optional: Options used to control feature flag evaluation. + /// scopes the underlying /flags request body — distinct from + /// , which + /// filters in memory. + /// + /// The cancellation token that can be used to cancel the operation. + /// A snapshot of feature flag evaluations. + Task EvaluateFlagsAsync( + string distinctId, + AllFeatureFlagsOptions? options, + CancellationToken cancellationToken); + /// /// Loads (or reloads) feature flag definitions for local evaluation. /// diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index 564cc11b..66f95cf5 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -26,6 +26,7 @@ public sealed class PostHogClient : IPostHogClient readonly IOptions _options; readonly ITaskScheduler _taskScheduler; readonly ILogger _logger; + readonly IFeatureFlagEvaluationsHost _evaluationsHost; /// /// Constructs a . This is the main class used to interact with PostHog. @@ -85,6 +86,7 @@ public PostHogClient( CompactionPercentage = options.Value.FeatureFlagSentCacheCompactionPercentage }); + _evaluationsHost = new EvaluationsHost(this); _logger.LogInfoClientCreated(options.Value.MaxBatchSize, options.Value.FlushInterval, options.Value.FlushAt); } @@ -153,6 +155,16 @@ public Task GroupIdentifyAsync( CancellationToken cancellationToken) => _apiClient.GroupIdentifyAsync(type, key, properties, cancellationToken, distinctId); + /// + public bool Capture( + string distinctId, + string eventName, + Dictionary? properties, + GroupCollection? groups, + FeatureFlagEvaluations? flags, + DateTimeOffset? timestamp = null) + => CaptureCore(distinctId, eventName, properties, groups, sendFeatureFlags: false, flags, timestamp); + /// public bool Capture( string distinctId, @@ -161,6 +173,16 @@ public bool Capture( GroupCollection? groups, bool sendFeatureFlags, DateTimeOffset? timestamp = null) + => CaptureCore(distinctId, eventName, properties, groups, sendFeatureFlags, flags: null, timestamp); + + bool CaptureCore( + string distinctId, + string eventName, + Dictionary? properties, + GroupCollection? groups, + bool sendFeatureFlags, + FeatureFlagEvaluations? flags, + DateTimeOffset? timestamp) { // If custom timestamp provided, add it to properties if (timestamp.HasValue) @@ -193,6 +215,12 @@ public bool Capture( Task BatchTask(CapturedEventBatchContext context) { + if (flags is not null) + { + AddFeatureFlagsToCapturedEvent(capturedEvent, flags); + return Task.FromResult(capturedEvent); + } + if (!sendFeatureFlags) { return Task.FromResult(capturedEvent); @@ -217,6 +245,26 @@ public bool CaptureException( GroupCollection? groups, bool sendFeatureFlags, DateTimeOffset? timestamp = null) + => CaptureExceptionCore(exception, distinctId, properties, groups, sendFeatureFlags, flags: null, timestamp); + + /// + public bool CaptureException( + Exception exception, + string distinctId, + Dictionary? properties, + GroupCollection? groups, + FeatureFlagEvaluations? flags, + DateTimeOffset? timestamp = null) + => CaptureExceptionCore(exception, distinctId, properties, groups, sendFeatureFlags: false, flags, timestamp); + + bool CaptureExceptionCore( + Exception exception, + string distinctId, + Dictionary? properties, + GroupCollection? groups, + bool sendFeatureFlags, + FeatureFlagEvaluations? flags, + DateTimeOffset? timestamp) { if (exception == null) { @@ -232,7 +280,7 @@ public bool CaptureException( properties["$exception_personURL"] = $"{host}/project/{_options.Value.ProjectToken}/person/{distinctId}"; properties = ExceptionPropertiesBuilder.Build(properties, exception); - return Capture(distinctId, "$exception", properties, groups, sendFeatureFlags, timestamp); + return CaptureCore(distinctId, "$exception", properties, groups, sendFeatureFlags, flags, timestamp); } #pragma warning disable CA1031 // Do not catch general exception types catch (Exception e) @@ -298,6 +346,21 @@ static CapturedEvent AddFeatureFlagsToCapturedEvent( return capturedEvent; } + static CapturedEvent AddFeatureFlagsToCapturedEvent( + CapturedEvent capturedEvent, + FeatureFlagEvaluations flags) + { + foreach (var (key, record) in flags.Records) + { + capturedEvent.Properties[$"$feature/{key}"] = record.Flag.ToResponseObject(); + } + capturedEvent.Properties["$active_feature_flags"] = flags.Records + .Where(kvp => kvp.Value.Enabled) + .Select(kvp => kvp.Key) + .ToArray(); + return capturedEvent; + } + /// public async Task IsFeatureEnabledAsync( string featureKey, @@ -432,26 +495,23 @@ void HandleRemoteError(Exception ex, string errorType) if (options.SendFeatureFlagEvents) { - _featureFlagCalledEventCache.GetOrCreate( - key: (distinctId, featureKey, (string)response), - // This is only called if the key doesn't exist in the cache. - factory: cacheEntry => CaptureFeatureFlagCalledEvent( - distinctId, - featureKey, - cacheEntry, - response, - requestId, - evaluatedAt, - options.Groups, - errors)); - } - - if (_featureFlagCalledEventCache.Count >= _options.Value.FeatureFlagSentCacheSizeLimit) - { - // We need to fire and forget the compaction because it can be expensive. - _taskScheduler.Run( - () => _featureFlagCalledEventCache.Compact( - _options.Value.FeatureFlagSentCacheCompactionPercentage), + var properties = BuildFeatureFlagCalledProperties( + featureKey, + response, + requestId, + evaluatedAt, + errors, + locallyEvaluated: flagWasLocallyEvaluated, + flagDefinitionsLoadedAt: flagWasLocallyEvaluated + ? _featureFlagsLoader.FlagDefinitionsLoadedAt + : null); + + TryCaptureDedupedFeatureFlagCalledEvent( + distinctId, + featureKey, + cacheKeyValue: (string)response, + properties, + options.Groups, cancellationToken); } @@ -506,28 +566,31 @@ static bool TryParseJson(string json, out JsonDocument? document) } } - bool CaptureFeatureFlagCalledEvent( - string distinctId, + static Dictionary BuildFeatureFlagCalledProperties( string featureKey, - ICacheEntry cacheEntry, FeatureFlag? flag, string? requestId, long? evaluatedAt, - GroupCollection? groupProperties, - List errors) + List errors, + bool locallyEvaluated, + long? flagDefinitionsLoadedAt) { - cacheEntry.SetSize(1); // Each entry has a size of 1 - cacheEntry.SetPriority(CacheItemPriority.Low); - cacheEntry.SetSlidingExpiration(_options.Value.FeatureFlagSentCacheSlidingExpiration); - var properties = new Dictionary { ["$feature_flag"] = featureKey, ["$feature_flag_response"] = flag.ToResponseObject(), - ["locally_evaluated"] = false, + ["locally_evaluated"] = locallyEvaluated, [$"$feature/{featureKey}"] = flag.ToResponseObject() }; - if (flag is FeatureFlagWithMetadata featureFlag) + if (locallyEvaluated) + { + properties["$feature_flag_reason"] = "Evaluated locally"; + if (flagDefinitionsLoadedAt is not null) + { + properties["$feature_flag_definitions_loaded_at"] = flagDefinitionsLoadedAt; + } + } + else if (flag is FeatureFlagWithMetadata featureFlag) { properties["$feature_flag_id"] = featureFlag.Id; properties["$feature_flag_version"] = featureFlag.Version; @@ -549,14 +612,243 @@ bool CaptureFeatureFlagCalledEvent( properties["$feature_flag_error"] = string.Join(",", errors); } - Capture( + return properties; + } + + void TryCaptureDedupedFeatureFlagCalledEvent( + string distinctId, + string featureKey, + string cacheKeyValue, + Dictionary properties, + GroupCollection? groups, + CancellationToken cancellationToken) + { + _featureFlagCalledEventCache.GetOrCreate( + key: (distinctId, featureKey, cacheKeyValue), + // This factory only runs when the (distinct id, key, value) tuple is not yet cached. + factory: cacheEntry => + { + cacheEntry.SetSize(1); + cacheEntry.SetPriority(CacheItemPriority.Low); + cacheEntry.SetSlidingExpiration(_options.Value.FeatureFlagSentCacheSlidingExpiration); + + CaptureCore( + distinctId, + eventName: "$feature_flag_called", + properties: properties, + groups: groups, + sendFeatureFlags: false, + flags: null, + timestamp: null); + return true; + }); + + if (_featureFlagCalledEventCache.Count >= _options.Value.FeatureFlagSentCacheSizeLimit) + { + // Fire-and-forget the compaction because it can be expensive. + _taskScheduler.Run( + () => _featureFlagCalledEventCache.Compact( + _options.Value.FeatureFlagSentCacheCompactionPercentage), + cancellationToken); + } + } + + sealed class EvaluationsHost : IFeatureFlagEvaluationsHost + { + readonly PostHogClient _client; + + public EvaluationsHost(PostHogClient client) => _client = client; + + public void TryCaptureFeatureFlagCalledEventIfNeeded( + string distinctId, + string featureKey, + EvaluatedFlagRecord? record, + GroupCollection? groups, + string? requestId, + long? evaluatedAt, + long? flagDefinitionsLoadedAt, + IReadOnlyCollection errors) + { + // Mirror the legacy path's "missing flag" handling: append the FlagMissing error + // and use a synthetic disabled FeatureFlag so the response shape is consistent. + var snapshotErrors = new List(errors); + if (record is null) + { + snapshotErrors.Add(FeatureFlagError.FlagMissing); + } + + var flag = record?.Flag ?? new FeatureFlag { Key = featureKey, IsEnabled = false }; + var cacheKeyValue = record?.CacheKeyValue ?? (string)flag; + + var properties = BuildFeatureFlagCalledProperties( + featureKey, + flag, + requestId, + evaluatedAt, + snapshotErrors, + locallyEvaluated: record?.LocallyEvaluated ?? false, + flagDefinitionsLoadedAt: record?.LocallyEvaluated == true ? flagDefinitionsLoadedAt : null); + + // For locally-evaluated flags from a snapshot we still want id/version/reason metadata + // when it is present on the record (the snapshot may carry the full FeatureFlagWithMetadata). + if (record is { Id: { } id }) + { + properties["$feature_flag_id"] = id; + } + if (record is { Version: { } version }) + { + properties["$feature_flag_version"] = version; + } + if (record is { Reason: { } reason } && !record.LocallyEvaluated) + { + properties["$feature_flag_reason"] = reason; + } + + _client.TryCaptureDedupedFeatureFlagCalledEvent( + distinctId, + featureKey, + cacheKeyValue, + properties, + groups, + CancellationToken.None); + } + + public void LogFilterWarning(string message) + { + if (!_client._options.Value.FeatureFlagsLogWarnings) + { + return; + } + _client._logger.LogWarningFeatureFlagFilter(message); + } + } + + /// + public async Task EvaluateFlagsAsync( + string distinctId, + AllFeatureFlagsOptions? options, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(distinctId)) + { + // Empty distinct id is a safety fallback. Returning an empty snapshot avoids leaking + // events with empty distinct ids when the caller forgot to resolve one. + return FeatureFlagEvaluations.Empty(_evaluationsHost, distinctId ?? string.Empty); + } + + var records = new Dictionary(StringComparer.Ordinal); + var errors = new List(); + string? requestId = null; + long? evaluatedAt = null; + long? flagDefinitionsLoadedAt = null; + + // 1. Local pass. + var fallbackToRemote = true; + if (_options.Value.PersonalApiKey is not null) + { + try + { + var localEvaluator = + await _featureFlagsLoader.GetFeatureFlagsForLocalEvaluationAsync(cancellationToken); + if (localEvaluator is not null) + { + var (locallyEvaluated, needsRemote) = localEvaluator.EvaluateAllFlags( + distinctId, + options?.Groups, + options?.PersonProperties, + warnOnUnknownGroups: false); + + foreach (var (key, flag) in locallyEvaluated) + { + records[key] = ToRecord(key, flag, locallyEvaluated: true); + } + + if (locallyEvaluated.Count > 0) + { + flagDefinitionsLoadedAt = _featureFlagsLoader.FlagDefinitionsLoadedAt; + } + + fallbackToRemote = needsRemote && options is not { OnlyEvaluateLocally: true }; + } + } + catch (ApiException e) when (e.ErrorType is "quota_limited") + { + _logger.LogWarningQuotaExceeded(e); + return FeatureFlagEvaluations.Empty(_evaluationsHost, distinctId); + } + } + + // 2. Remote pass — only if we still need it. + if (fallbackToRemote && options is not { OnlyEvaluateLocally: true }) + { + try + { + var flagsResult = await FetchFlagsAsync(distinctId, options, cancellationToken); + requestId = flagsResult.RequestId; + evaluatedAt = flagsResult.EvaluatedAt; + + if (flagsResult.ErrorsWhileComputingFlags) + { + errors.Add(FeatureFlagError.ErrorsWhileComputingFlags); + } + + if (flagsResult.QuotaLimited.Contains("feature_flags")) + { + errors.Add(FeatureFlagError.QuotaLimited); + } + + foreach (var (key, flag) in flagsResult.Flags) + { + if (!records.ContainsKey(key)) + { + records[key] = ToRecord(key, flag, locallyEvaluated: false); + } + } + } + catch (Exception e) when (e is not ArgumentException and not NullReferenceException) + { + _logger.LogErrorUnableToGetFeatureFlagsAndPayloads(e); + errors.Add(FeatureFlagError.UnknownError); + } + } + + return new FeatureFlagEvaluations( + _evaluationsHost, distinctId, - eventName: "$feature_flag_called", - properties: properties, - groups: groupProperties, - sendFeatureFlags: false); + records, + requestId, + evaluatedAt, + flagDefinitionsLoadedAt, + options?.Groups, + errors); + + static EvaluatedFlagRecord ToRecord(string key, FeatureFlag flag, bool locallyEvaluated) + { + int? id = null; + int? version = null; + string? reason = locallyEvaluated ? "Evaluated locally" : null; + if (flag is FeatureFlagWithMetadata withMetadata) + { + id = withMetadata.Id; + version = withMetadata.Version; + if (!locallyEvaluated) + { + reason = withMetadata.Reason; + } + } - return true; + return new EvaluatedFlagRecord + { + Key = key, + Flag = flag, + Enabled = flag.IsEnabled, + CacheKeyValue = (string)flag, + Id = id, + Version = version, + Reason = reason, + LocallyEvaluated = locallyEvaluated, + }; + } } /// @@ -859,20 +1151,26 @@ public static partial void LogErrorUnableToGetRemoteConfigPayload( public static partial void LogDebugFeatureFlagsLoaded(this ILogger logger, string pollingStatus); [LoggerMessage( - EventId = 18, + EventId = 19, 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 = 20, Level = LogLevel.Error, Message = "CaptureException called with null exception")] public static partial void LogErrorCaptureExceptionNull(this ILogger logger); [LoggerMessage( - EventId = 20, + EventId = 21, Level = LogLevel.Error, Message = "CaptureException failed with an exception")] public static partial void LogErrorCaptureExceptionFailed(this ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 22, + Level = LogLevel.Warning, + Message = "[FEATURE FLAGS] {Message}")] + public static partial void LogWarningFeatureFlagFilter(this ILogger logger, string message); } diff --git a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs new file mode 100644 index 00000000..fbb95fba --- /dev/null +++ b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs @@ -0,0 +1,367 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PostHog; +using PostHog.Features; +using UnitTests.Fakes; + +namespace FeatureFlagEvaluationsTests; + +public class TheEvaluateFlagsAsyncMethod +{ + [Fact] + public async Task ReturnsSnapshotWithRichMetadataFromOneFlagsRequest() + { + var container = new TestContainer(); + var flagsHandler = container.FakeHttpMessageHandler.AddFlagsResponse( + """ + { + "featureFlags": {"flag-a": true, "flag-b": "variant-x"}, + "featureFlagPayloads": {"flag-a": "{\"hello\":\"world\"}"}, + "flags": { + "flag-a": { + "key": "flag-a", + "metadata": {"id": 42, "version": 7}, + "reason": {"description": "matched condition set 1"} + }, + "flag-b": { + "key": "flag-b", + "metadata": {"id": 43, "version": 2}, + "reason": {"description": "variant assignment"} + } + }, + "requestId": "the-request-id", + "evaluatedAt": 1705862903000 + } + """); + var client = container.Activate(); + + var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); + + Assert.Equal(2, snapshot.Keys.Count); + Assert.Equal("the-request-id", snapshot.RequestId); + Assert.Equal(1705862903000, snapshot.EvaluatedAt); + Assert.Single(flagsHandler.ReceivedRequests); + } + + [Fact] + public async Task EmptyDistinctIdReturnsEmptySnapshotWithNoHttpCall() + { + var container = new TestContainer(); + var flagsHandler = container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {"flag-a": true}}"""); + var client = container.Activate(); + + var snapshot = await client.EvaluateFlagsAsync(string.Empty, options: null, CancellationToken.None); + + Assert.Empty(snapshot.Keys); + Assert.Empty(flagsHandler.ReceivedRequests); + } + + [Fact] + public async Task ForwardsFlagKeysToFlagsRequestBody() + { + var container = new TestContainer(); + var flagsHandler = container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {"flag-a": true}}"""); + var client = container.Activate(); + + await client.EvaluateFlagsAsync( + "user-1", + new AllFeatureFlagsOptions { FlagKeysToEvaluate = ["flag-a", "flag-b"] }, + CancellationToken.None); + + var request = flagsHandler.ReceivedRequests.Single(); + var body = await request.Content!.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var flagKeys = doc.RootElement.GetProperty("flag_keys_to_evaluate") + .EnumerateArray() + .Select(e => e.GetString() ?? string.Empty) + .ToArray(); + Assert.Equal(new[] { "flag-a", "flag-b" }, flagKeys); + } + + [Fact] + public async Task OnlyEvaluateLocallyDoesNotHitRemote() + { + var container = new TestContainer(personalApiKey: "fake-personal-api-key"); + container.FakeHttpMessageHandler.AddLocalEvaluationResponse( + """ + {"flags": [{"id": 1, "key": "flag-a", "active": true, "rollout_percentage": 100, "filters": {"groups": [{"properties": [], "rollout_percentage": 100}]}}]} + """); + var flagsHandler = container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {}}"""); + var client = container.Activate(); + + var snapshot = await client.EvaluateFlagsAsync( + "user-1", + new AllFeatureFlagsOptions { OnlyEvaluateLocally = true }, + CancellationToken.None); + + Assert.True(snapshot.IsEnabled("flag-a")); + Assert.Empty(flagsHandler.ReceivedRequests); + } +} + +public class TheSnapshotAccessMethods +{ + [Fact] + public async Task IsEnabledReturnsFalseForUnknownKey() + { + var snapshot = await EvaluateAsync("""{"featureFlags": {"known": true}}"""); + Assert.False(snapshot.IsEnabled("unknown")); + } + + [Fact] + public async Task GetFlagReturnsNullForUnknownKey() + { + var snapshot = await EvaluateAsync("""{"featureFlags": {"known": true}}"""); + Assert.Null(snapshot.GetFlag("unknown")); + } + + [Fact] + public async Task GetFlagPayloadDoesNotFireFeatureFlagCalledEvent() + { + var (snapshot, batchHandler, client) = await EvaluateWithBatchAsync( + """{"featureFlags": {"flag-a": true}, "featureFlagPayloads": {"flag-a": "\"hello\""}}"""); + + var payload = snapshot.GetFlagPayload("flag-a"); + Assert.NotNull(payload); + + await client.FlushAsync(); + Assert.Empty(batchHandler.ReceivedRequests); + } + + [Fact] + public async Task IsEnabledFiresFeatureFlagCalledEventOncePerDistinctIdKeyResponse() + { + var (snapshot, batchHandler, client) = await EvaluateWithBatchAsync( + """{"featureFlags": {"flag-a": true}}"""); + + Assert.True(snapshot.IsEnabled("flag-a")); + Assert.True(snapshot.IsEnabled("flag-a")); // dedup + Assert.True(snapshot.IsEnabled("flag-a")); // dedup + + await client.FlushAsync(); + var body = batchHandler.GetReceivedRequestBody(indented: false); + var matches = System.Text.RegularExpressions.Regex.Matches(body, "\\$feature_flag_called"); + Assert.Single(matches); + } + + [Fact] + public async Task EmptyDistinctIdSnapshotDoesNotFireEvents() + { + var container = new TestContainer(); + container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {"flag-a": true}}"""); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var client = container.Activate(); + + var snapshot = await client.EvaluateFlagsAsync(string.Empty, options: null, CancellationToken.None); + snapshot.IsEnabled("anything"); + snapshot.GetFlag("anything"); + + await client.FlushAsync(); + Assert.Empty(batchHandler.ReceivedRequests); + } + + [Fact] + public async Task LocallyEvaluatedFlagSnapshotTagsLocallyEvaluatedAndReasonAndDefinitionsLoadedAt() + { + var container = new TestContainer(personalApiKey: "fake-personal-api-key"); + container.FakeHttpMessageHandler.AddLocalEvaluationResponse( + """ + {"flags": [{"id": 1, "key": "flag-a", "active": true, "rollout_percentage": 100, "filters": {"groups": [{"properties": [], "rollout_percentage": 100}]}}]} + """); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var client = container.Activate(); + + var snapshot = await client.EvaluateFlagsAsync( + "user-1", + new AllFeatureFlagsOptions { OnlyEvaluateLocally = true }, + CancellationToken.None); + Assert.True(snapshot.IsEnabled("flag-a")); + + await client.FlushAsync(); + var body = batchHandler.GetReceivedRequestBody(indented: false); + Assert.Contains("\"locally_evaluated\":true", body, StringComparison.Ordinal); + Assert.Contains("\"$feature_flag_reason\":\"Evaluated locally\"", body, StringComparison.Ordinal); + Assert.Contains("\"$feature_flag_definitions_loaded_at\":1705864103000", body, StringComparison.Ordinal); + } + + static async Task EvaluateAsync(string flagsResponseBody) + { + var container = new TestContainer(); + container.FakeHttpMessageHandler.AddFlagsResponse(flagsResponseBody); + var client = container.Activate(); + return await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); + } + + static async Task<(FeatureFlagEvaluations snapshot, FakeHttpMessageHandler.RequestHandler batchHandler, PostHogClient client)> + EvaluateWithBatchAsync(string flagsResponseBody) + { + var container = new TestContainer(); + container.FakeHttpMessageHandler.AddFlagsResponse(flagsResponseBody); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var client = container.Activate(); + var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); + return (snapshot, batchHandler, client); + } +} + +public class TheSnapshotFilterMethods +{ + [Fact] + public async Task OnlyAccessedReturnsAccessedFlagsOnly() + { + var snapshot = await EvaluateAsync("""{"featureFlags": {"a": true, "b": true, "c": true}}"""); + + snapshot.IsEnabled("a"); + snapshot.GetFlag("c"); + + var accessed = snapshot.OnlyAccessed(); + Assert.Equal(2, accessed.Keys.Count); + Assert.Contains("a", accessed.Keys); + Assert.Contains("c", accessed.Keys); + } + + [Fact] + public async Task OnlyAccessedFallsBackToAllFlagsAndWarnsWhenNothingAccessed() + { + var (snapshot, container) = await EvaluateAsyncWithContainer("""{"featureFlags": {"a": true, "b": true}}"""); + + var fallback = snapshot.OnlyAccessed(); + + Assert.Equal(2, fallback.Keys.Count); + Assert.Contains( + container.FakeLoggerProvider.GetAllEvents(), + e => e.LogLevel == LogLevel.Warning + && (e.Message ?? string.Empty).Contains("OnlyAccessed", StringComparison.Ordinal)); + } + + [Fact] + public async Task OnlyAccessedDoesNotWarnWhenLogWarningsDisabled() + { + var container = new TestContainer(services => + { + services.Configure(options => + { + options.ProjectToken = "fake-project-token"; + options.FeatureFlagsLogWarnings = false; + }); + }); + container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {"a": true}}"""); + var client = container.Activate(); + var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); + + snapshot.OnlyAccessed(); + + Assert.DoesNotContain( + container.FakeLoggerProvider.GetAllEvents(), + e => e.LogLevel == LogLevel.Warning + && (e.Message ?? string.Empty).Contains("OnlyAccessed", StringComparison.Ordinal)); + } + + [Fact] + public async Task OnlyDropsUnknownKeysWithWarning() + { + var (snapshot, container) = await EvaluateAsyncWithContainer("""{"featureFlags": {"a": true, "b": true}}"""); + + var only = snapshot.Only("a", "missing-1", "missing-2"); + + Assert.Single(only.Keys); + Assert.Contains("a", only.Keys); + Assert.Contains( + container.FakeLoggerProvider.GetAllEvents(), + e => e.LogLevel == LogLevel.Warning + && (e.Message ?? string.Empty).Contains("missing-1", StringComparison.Ordinal) + && (e.Message ?? string.Empty).Contains("missing-2", StringComparison.Ordinal)); + } + + [Fact] + public async Task FilteredSnapshotDoesNotBackPropagateAccessToParent() + { + var snapshot = await EvaluateAsync("""{"featureFlags": {"a": true, "b": true}}"""); + snapshot.IsEnabled("a"); // parent has accessed "a" + + var child = snapshot.OnlyAccessed(); + child.IsEnabled("b" /* will be missing in child but still records access on the child */); + + var parentAccessed = snapshot.OnlyAccessed(); + // Parent should still only have "a" accessed; the child's access of "b" should not leak. + Assert.Single(parentAccessed.Keys); + Assert.Contains("a", parentAccessed.Keys); + } + + static async Task EvaluateAsync(string flagsResponseBody) + { + var (snapshot, _) = await EvaluateAsyncWithContainer(flagsResponseBody); + return snapshot; + } + + static async Task<(FeatureFlagEvaluations snapshot, TestContainer container)> EvaluateAsyncWithContainer(string flagsResponseBody) + { + var container = new TestContainer(); + container.FakeHttpMessageHandler.AddFlagsResponse(flagsResponseBody); + container.FakeHttpMessageHandler.AddBatchResponse(); + var client = container.Activate(); + var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); + return (snapshot, container); + } +} + +public class TheCaptureWithFlagsSnapshotMethod +{ + [Fact] + public async Task AttachesFeatureFlagPropertiesAndActiveFeatureFlagsFromSnapshot() + { + var container = new TestContainer(); + container.FakeHttpMessageHandler.AddFlagsResponse( + """{"featureFlags": {"flag-a": true, "flag-b": false, "flag-c": "variant-x"}}"""); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var client = container.Activate(); + + var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); + client.Capture("user-1", "page_viewed", properties: null, groups: null, flags: snapshot); + await client.FlushAsync(); + + var body = batchHandler.GetReceivedRequestBody(indented: false); + Assert.Contains("\"$feature/flag-a\":true", body, StringComparison.Ordinal); + Assert.Contains("\"$feature/flag-b\":false", body, StringComparison.Ordinal); + Assert.Contains("\"$feature/flag-c\":\"variant-x\"", body, StringComparison.Ordinal); + Assert.Contains("\"$active_feature_flags\":[\"flag-a\",\"flag-c\"]", body, StringComparison.Ordinal); + } + + [Fact] + public async Task DoesNotMakeAdditionalFlagsHttpRequest() + { + var container = new TestContainer(); + var flagsHandler = container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {"flag-a": true}}"""); + container.FakeHttpMessageHandler.AddBatchResponse(); + var client = container.Activate(); + + var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); + client.Capture("user-1", "page_viewed", properties: null, groups: null, flags: snapshot); + await client.FlushAsync(); + + Assert.Single(flagsHandler.ReceivedRequests); + } + + [Fact] + public async Task SharesDedupCacheWithLegacySingleFlagPath() + { + var container = new TestContainer(); + container.FakeHttpMessageHandler.AddRepeatedFlagsResponse(2, """{"featureFlags": {"flag-a": true}}"""); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var client = container.Activate(); + + // Legacy path fires $feature_flag_called for ("user-1", "flag-a", true). + Assert.True(await client.IsFeatureEnabledAsync("flag-a", "user-1")); + + // Snapshot path accesses the same flag — should hit the existing cache and NOT fire again. + var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); + snapshot.IsEnabled("flag-a"); + + await client.FlushAsync(); + var body = batchHandler.GetReceivedRequestBody(indented: false); + var matches = System.Text.RegularExpressions.Regex.Matches(body, "\\$feature_flag_called"); + Assert.Single(matches); + } +} diff --git a/tests/UnitTests/Features/FeatureFlagsTests.cs b/tests/UnitTests/Features/FeatureFlagsTests.cs index ad0c3836..312ab982 100644 --- a/tests/UnitTests/Features/FeatureFlagsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagsTests.cs @@ -151,8 +151,10 @@ public async Task CapturesFeatureFlagCalledEventOnlyOncePerDistinctIdFlagKeyAndR "properties": { "$feature_flag": "flag-key", "$feature_flag_response": true, - "locally_evaluated": false, + "locally_evaluated": true, "$feature/flag-key": true, + "$feature_flag_reason": "Evaluated locally", + "$feature_flag_definitions_loaded_at": 1705864103000, "distinct_id": "a-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", @@ -167,8 +169,10 @@ public async Task CapturesFeatureFlagCalledEventOnlyOncePerDistinctIdFlagKeyAndR "properties": { "$feature_flag": "flag-key", "$feature_flag_response": true, - "locally_evaluated": false, + "locally_evaluated": true, "$feature/flag-key": true, + "$feature_flag_reason": "Evaluated locally", + "$feature_flag_definitions_loaded_at": 1705864103000, "distinct_id": "another-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", @@ -183,8 +187,10 @@ public async Task CapturesFeatureFlagCalledEventOnlyOncePerDistinctIdFlagKeyAndR "properties": { "$feature_flag": "flag-key", "$feature_flag_response": false, - "locally_evaluated": false, + "locally_evaluated": true, "$feature/flag-key": false, + "$feature_flag_reason": "Evaluated locally", + "$feature_flag_definitions_loaded_at": 1705864103000, "distinct_id": "another-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", @@ -389,8 +395,10 @@ await client.IsFeatureEnabledAsync( "properties": { "$feature_flag": "complex-flag", "$feature_flag_response": true, - "locally_evaluated": false, + "locally_evaluated": true, "$feature/complex-flag": true, + "$feature_flag_reason": "Evaluated locally", + "$feature_flag_definitions_loaded_at": 1705864103000, "distinct_id": "659df793-429a-4517-84ff-747dfc103e6c", "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", From 95cd6d5797e29447b939462d129bf09c35d8b2bf Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 15:27:34 -0700 Subject: [PATCH 2/6] review: thread-safety, drop dead reason, parameterize tests, parse JSON - FeatureFlagEvaluations._accessed: HashSet -> ConcurrentDictionary so callers may share a snapshot across parallel branches without corrupting it. - ToRecord: leave EvaluatedFlagRecord.Reason null for locally-evaluated flags; the "Evaluated locally" string is hardcoded inside BuildFeatureFlagCalledProperties and the host gates record.Reason with !LocallyEvaluated, so it was unread. - Collapse IsEnabledReturnsFalseForUnknownKey + GetFlagReturnsNullForUnknownKey into a parameterized [Theory] over the accessor under test. - Replace the brittle substring match on $active_feature_flags with a parsed, order-independent comparison; Dictionary iteration order isn't a guarantee. Generated-By: PostHog Code Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40 --- .../Features/FeatureFlagEvaluations.cs | 18 ++++---- src/PostHog/PostHogClient.cs | 4 +- .../Features/FeatureFlagEvaluationsTests.cs | 41 ++++++++++++++----- 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/PostHog/Features/FeatureFlagEvaluations.cs b/src/PostHog/Features/FeatureFlagEvaluations.cs index 5e5a4a1c..3ae731f8 100644 --- a/src/PostHog/Features/FeatureFlagEvaluations.cs +++ b/src/PostHog/Features/FeatureFlagEvaluations.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text.Json; using static PostHog.Library.Ensure; @@ -15,7 +16,10 @@ public sealed class FeatureFlagEvaluations { readonly IFeatureFlagEvaluationsHost _host; readonly IReadOnlyDictionary _records; - readonly HashSet _accessed; + // Tracks which flags have been read via IsEnabled / GetFlag. Used as a set; the byte value is unused. + // ConcurrentDictionary because the snapshot is a public type with no documented thread-safety + // constraint, so callers may read it from parallel branches. + readonly ConcurrentDictionary _accessed; readonly GroupCollection? _groups; readonly IReadOnlyCollection _errors; @@ -28,7 +32,7 @@ internal FeatureFlagEvaluations( long? flagDefinitionsLoadedAt, GroupCollection? groups, IReadOnlyCollection? errors, - HashSet? accessed = null) + ConcurrentDictionary? accessed = null) { _host = NotNull(host); DistinctId = distinctId ?? string.Empty; @@ -38,7 +42,7 @@ internal FeatureFlagEvaluations( FlagDefinitionsLoadedAt = flagDefinitionsLoadedAt; _groups = groups; _errors = errors ?? Array.Empty(); - _accessed = accessed ?? new HashSet(StringComparer.Ordinal); + _accessed = accessed ?? new ConcurrentDictionary(StringComparer.Ordinal); } /// @@ -110,7 +114,7 @@ public bool IsEnabled(string key) /// public FeatureFlagEvaluations OnlyAccessed() { - if (_accessed.Count == 0) + if (_accessed.IsEmpty) { _host.LogFilterWarning( "FeatureFlagEvaluations.OnlyAccessed() was called before any flags were accessed; " + @@ -119,7 +123,7 @@ public FeatureFlagEvaluations OnlyAccessed() } var filtered = new Dictionary(StringComparer.Ordinal); - foreach (var key in _accessed) + foreach (var key in _accessed.Keys) { if (_records.TryGetValue(key, out var record)) { @@ -189,7 +193,7 @@ internal static FeatureFlagEvaluations Empty(IFeatureFlagEvaluationsHost host, s EvaluatedFlagRecord? RecordAccess(string key) { var keyChecked = NotNull(key); - _accessed.Add(keyChecked); + _accessed.TryAdd(keyChecked, 0); if (string.IsNullOrEmpty(DistinctId)) { @@ -223,5 +227,5 @@ FeatureFlagEvaluations CloneWith(IReadOnlyDictionary(_accessed, StringComparer.Ordinal)); + accessed: new ConcurrentDictionary(_accessed, StringComparer.Ordinal)); } diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index 66f95cf5..677f0606 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -826,7 +826,9 @@ static EvaluatedFlagRecord ToRecord(string key, FeatureFlag flag, bool locallyEv { int? id = null; int? version = null; - string? reason = locallyEvaluated ? "Evaluated locally" : null; + // Reason for locally-evaluated flags is hardcoded to "Evaluated locally" inside + // BuildFeatureFlagCalledProperties, so leave the record's Reason null here. + string? reason = null; if (flag is FeatureFlagWithMetadata withMetadata) { id = withMetadata.Id; diff --git a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs index fbb95fba..78cb390f 100644 --- a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs @@ -103,18 +103,23 @@ public async Task OnlyEvaluateLocallyDoesNotHitRemote() public class TheSnapshotAccessMethods { - [Fact] - public async Task IsEnabledReturnsFalseForUnknownKey() + [Theory] + [InlineData("IsEnabled")] + [InlineData("GetFlag")] + public async Task UnknownKeyReturnsFalsyValue(string accessor) { var snapshot = await EvaluateAsync("""{"featureFlags": {"known": true}}"""); - Assert.False(snapshot.IsEnabled("unknown")); - } - - [Fact] - public async Task GetFlagReturnsNullForUnknownKey() - { - var snapshot = await EvaluateAsync("""{"featureFlags": {"known": true}}"""); - Assert.Null(snapshot.GetFlag("unknown")); + switch (accessor) + { + case "IsEnabled": + Assert.False(snapshot.IsEnabled("unknown")); + break; + case "GetFlag": + Assert.Null(snapshot.GetFlag("unknown")); + break; + default: + throw new ArgumentOutOfRangeException(nameof(accessor), accessor, "Unknown accessor"); + } } [Fact] @@ -326,7 +331,21 @@ public async Task AttachesFeatureFlagPropertiesAndActiveFeatureFlagsFromSnapshot Assert.Contains("\"$feature/flag-a\":true", body, StringComparison.Ordinal); Assert.Contains("\"$feature/flag-b\":false", body, StringComparison.Ordinal); Assert.Contains("\"$feature/flag-c\":\"variant-x\"", body, StringComparison.Ordinal); - Assert.Contains("\"$active_feature_flags\":[\"flag-a\",\"flag-c\"]", body, StringComparison.Ordinal); + + // Parse and assert order-independently — Dictionary iteration order is preserved in + // practice but is not a language guarantee. + using var doc = JsonDocument.Parse(body); + var properties = doc.RootElement + .GetProperty("batch") + .EnumerateArray() + .Single() + .GetProperty("properties"); + var activeFeatureFlags = properties.GetProperty("$active_feature_flags") + .EnumerateArray() + .Select(e => e.GetString() ?? string.Empty) + .OrderBy(s => s, StringComparer.Ordinal) + .ToArray(); + Assert.Equal(new[] { "flag-a", "flag-c" }, activeFeatureFlags); } [Fact] From 60aa53d6d7bfe5690141dfcba28b10f5075b399c Mon Sep 17 00:00:00 2001 From: dylan Date: Tue, 28 Apr 2026 12:54:37 -0700 Subject: [PATCH 3/6] review: DIMs, dedup fast-path, perf, coverage gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR feedback: - IPostHogClient: add default interface implementations for the new Capture(flags:), CaptureException(flags:), and EvaluateFlagsAsync members so external implementers don't see a source break. Conditionally compiled — DIMs only on netstandard2.1+ (the runtime requirement); netstandard2.0 keeps abstract members. - FeatureFlagEvaluations.RecordAccess: early-return on repeat access, dropping per-call dedup-cache lookups + property allocation when a key has already been seen by this snapshot. Cross-snapshot dedup still flows through the MemoryCache. - AddFeatureFlagsToCapturedEvent (snapshot path): single-pass enumeration over Records, skip the LINQ Where/Select/ToArray for $active_feature_flags. - FeatureFlagEvaluations._records: tighten field type to Dictionary so Keys is a clean expression-bodied getter (no IReadOnlyDictionary cast). - FeatureFlagEvaluations.Only(...): lazy missing-keys list — no allocation when every requested key is present. - EvaluationsHost: drop the redundant id/version/reason copy block — the values it would write are already populated by BuildFeatureFlagCalledProperties via the FeatureFlagWithMetadata pattern match. - EvaluatedFlagRecord: remove the now-unused Id/Version/Reason fields. The property dict is built from record.Flag (typed as FeatureFlagWithMetadata when present) rather than from duplicated record-level state. - EvaluateFlagsAsync: local-pass quota_limited preserves locally-evaluated records and surfaces FeatureFlagError.QuotaLimited (matches remote-pass behavior); previously it discarded local results entirely. Add a comment on the local-wins merge clarifying the divergence from GetAllFeatureFlagsAsync. - IPostHogClient.EvaluateFlagsAsync: contrasting FlagKeysToEvaluate (request-body scoping) with FeatureFlagEvaluations.Only(...) (in-memory). - IFeatureFlagEvaluationsHost.TryCaptureFeatureFlagCalledEventIfNeeded -> CaptureFeatureFlagCalled (no return value, no try semantics). Tests added: - MixedLocalAndRemoteEvaluationMergesRecordsAndTagsSourceCorrectly: pins the local-wins merge with locally_evaluated tagged correctly per source. - UnknownKeyAccessAppendsFlagMissingErrorOnFeatureFlagCalled: pins the $feature_flag_error wiring through to the emitted event. - CaptureExceptionAttachesFeatureFlagsFromSnapshot: pins the new CaptureException(flags:) overload so a CaptureExceptionCore wiring mistake would be caught. Generated-By: PostHog Code Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40 --- src/PostHog/Features/EvaluatedFlagRecord.cs | 11 ++- .../Features/FeatureFlagEvaluations.cs | 45 +++++----- .../Features/IFeatureFlagEvaluationsHost.cs | 2 +- src/PostHog/IPostHogClient.cs | 25 +++++- src/PostHog/PostHogClient.cs | 61 +++++--------- .../Features/FeatureFlagEvaluationsTests.cs | 82 +++++++++++++++++++ 6 files changed, 157 insertions(+), 69 deletions(-) diff --git a/src/PostHog/Features/EvaluatedFlagRecord.cs b/src/PostHog/Features/EvaluatedFlagRecord.cs index e3dc455e..a70f193d 100644 --- a/src/PostHog/Features/EvaluatedFlagRecord.cs +++ b/src/PostHog/Features/EvaluatedFlagRecord.cs @@ -11,7 +11,8 @@ internal sealed record EvaluatedFlagRecord /// /// The underlying as exposed to callers via - /// . + /// . May be a FeatureFlagWithMetadata; the + /// id/version/reason fields are read off it directly inside the property-building helper. /// public required FeatureFlag Flag { get; init; } @@ -28,8 +29,10 @@ internal sealed record EvaluatedFlagRecord /// public required string CacheKeyValue { get; init; } - public int? Id { get; init; } - public int? Version { get; init; } - public string? Reason { get; init; } + /// + /// Whether the flag was resolved by the local poller. Drives locally_evaluated=true, + /// $feature_flag_reason="Evaluated locally", and $feature_flag_definitions_loaded_at + /// on the emitted $feature_flag_called event. + /// public bool LocallyEvaluated { get; init; } } diff --git a/src/PostHog/Features/FeatureFlagEvaluations.cs b/src/PostHog/Features/FeatureFlagEvaluations.cs index 3ae731f8..f6d214c0 100644 --- a/src/PostHog/Features/FeatureFlagEvaluations.cs +++ b/src/PostHog/Features/FeatureFlagEvaluations.cs @@ -15,7 +15,7 @@ namespace PostHog.Features; public sealed class FeatureFlagEvaluations { readonly IFeatureFlagEvaluationsHost _host; - readonly IReadOnlyDictionary _records; + readonly Dictionary _records; // Tracks which flags have been read via IsEnabled / GetFlag. Used as a set; the byte value is unused. // ConcurrentDictionary because the snapshot is a public type with no documented thread-safety // constraint, so callers may read it from parallel branches. @@ -26,7 +26,7 @@ public sealed class FeatureFlagEvaluations internal FeatureFlagEvaluations( IFeatureFlagEvaluationsHost host, string distinctId, - IReadOnlyDictionary records, + Dictionary records, string? requestId, long? evaluatedAt, long? flagDefinitionsLoadedAt, @@ -72,7 +72,7 @@ internal FeatureFlagEvaluations( /// /// The set of flag keys present in this snapshot. /// - public IReadOnlyCollection Keys => (IReadOnlyCollection)_records.Keys; + public IReadOnlyCollection Keys => _records.Keys; /// /// Returns true when the named flag is present in the snapshot and enabled. Records @@ -108,10 +108,16 @@ public bool IsEnabled(string key) /// /// Returns a new snapshot containing only the flags that have been accessed via - /// or . If no flags have been accessed yet, - /// logs a warning and returns a snapshot containing all flags so callers do not silently - /// drop exposure data. + /// or . /// + /// + /// + /// Fallback behavior: if no flags have been accessed yet, this method logs a warning and + /// returns a snapshot containing all flags. This avoids silently dropping exposure data + /// when callers wire up OnlyAccessed() before any branching logic runs. Set + /// to false to suppress the warning. + /// + /// public FeatureFlagEvaluations OnlyAccessed() { if (_accessed.IsEmpty) @@ -141,7 +147,7 @@ public FeatureFlagEvaluations OnlyAccessed() public FeatureFlagEvaluations Only(IEnumerable keys) { var filtered = new Dictionary(StringComparer.Ordinal); - var missing = new List(); + List? missing = null; foreach (var key in NotNull(keys)) { if (_records.TryGetValue(key, out var record)) @@ -150,11 +156,11 @@ public FeatureFlagEvaluations Only(IEnumerable keys) } else { - missing.Add(key); + (missing ??= new List()).Add(key); } } - if (missing.Count > 0) + if (missing is { Count: > 0 }) { _host.LogFilterWarning( "FeatureFlagEvaluations.Only(...) requested keys that are not in the snapshot and will be dropped: " + @@ -172,7 +178,7 @@ public FeatureFlagEvaluations Only(params string[] keys) /// The internal per-flag records. Used by 's capture path to attach /// $feature/<key> properties. /// - internal IReadOnlyDictionary Records => _records; + internal Dictionary Records => _records; /// /// Constructs an empty snapshot with no flags and no events. Used as the safety fallback when @@ -193,18 +199,19 @@ internal static FeatureFlagEvaluations Empty(IFeatureFlagEvaluationsHost host, s EvaluatedFlagRecord? RecordAccess(string key) { var keyChecked = NotNull(key); - _accessed.TryAdd(keyChecked, 0); + var firstAccess = _accessed.TryAdd(keyChecked, 0); - if (string.IsNullOrEmpty(DistinctId)) + _records.TryGetValue(keyChecked, out var record); + + if (!firstAccess || string.IsNullOrEmpty(DistinctId)) { - // Empty-distinct-id snapshots are a safety fallback. Do not emit $feature_flag_called - // events with an empty distinct id, since they would pollute analytics. - return _records.TryGetValue(keyChecked, out var emptyRecord) ? emptyRecord : null; + // Repeat access in this snapshot, or the empty-distinct-id safety fallback: skip the + // dedup-cache lookup and property allocation. Cross-snapshot dedup is still handled by + // the per-distinct-id MemoryCache when the host runs. + return record; } - _records.TryGetValue(keyChecked, out var record); - - _host.TryCaptureFeatureFlagCalledEventIfNeeded( + _host.CaptureFeatureFlagCalled( distinctId: DistinctId, featureKey: keyChecked, record: record, @@ -217,7 +224,7 @@ internal static FeatureFlagEvaluations Empty(IFeatureFlagEvaluationsHost host, s return record; } - FeatureFlagEvaluations CloneWith(IReadOnlyDictionary records) + FeatureFlagEvaluations CloneWith(Dictionary records) => new( _host, DistinctId, diff --git a/src/PostHog/Features/IFeatureFlagEvaluationsHost.cs b/src/PostHog/Features/IFeatureFlagEvaluationsHost.cs index 85679d60..2581fba5 100644 --- a/src/PostHog/Features/IFeatureFlagEvaluationsHost.cs +++ b/src/PostHog/Features/IFeatureFlagEvaluationsHost.cs @@ -11,7 +11,7 @@ internal interface IFeatureFlagEvaluationsHost /// Fires a $feature_flag_called event for the given access, deduplicated against the /// per-distinct-id cache that the legacy single-flag path also writes to. /// - void TryCaptureFeatureFlagCalledEventIfNeeded( + void CaptureFeatureFlagCalled( string distinctId, string featureKey, EvaluatedFlagRecord? record, diff --git a/src/PostHog/IPostHogClient.cs b/src/PostHog/IPostHogClient.cs index aacdb872..29978b54 100644 --- a/src/PostHog/IPostHogClient.cs +++ b/src/PostHog/IPostHogClient.cs @@ -122,7 +122,11 @@ bool Capture( Dictionary? properties, GroupCollection? groups, FeatureFlagEvaluations? flags, - DateTimeOffset? timestamp = null); + DateTimeOffset? timestamp = null) +#if !NETSTANDARD2_0 + => Capture(distinctId, eventName, properties, groups, sendFeatureFlags: false, timestamp) +#endif + ; /// /// Capture an exception as an event. @@ -158,7 +162,11 @@ bool CaptureException( Dictionary? properties, GroupCollection? groups, FeatureFlagEvaluations? flags, - DateTimeOffset? timestamp = null); + DateTimeOffset? timestamp = null) +#if !NETSTANDARD2_0 + => CaptureException(exception, distinctId, properties, groups, sendFeatureFlags: false, timestamp) +#endif + ; /// /// Determines whether a feature is enabled for the specified user. @@ -228,10 +236,21 @@ Task> GetAllFeatureFlagsAsync( /// /// The cancellation token that can be used to cancel the operation. /// A snapshot of feature flag evaluations. + /// + /// scopes the underlying /flags + /// request body. + /// filters an already-evaluated snapshot in memory. Use FlagKeysToEvaluate to reduce + /// network and server work; use Only(...) to scope an existing snapshot for capture. + /// Task EvaluateFlagsAsync( string distinctId, AllFeatureFlagsOptions? options, - CancellationToken cancellationToken); + CancellationToken cancellationToken) +#if !NETSTANDARD2_0 + => throw new NotSupportedException( + "EvaluateFlagsAsync requires PostHogClient. Custom IPostHogClient implementers must override this member.") +#endif + ; /// /// Loads (or reloads) feature flag definitions for local evaluation. diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index 677f0606..8ac64f96 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -350,14 +350,18 @@ static CapturedEvent AddFeatureFlagsToCapturedEvent( CapturedEvent capturedEvent, FeatureFlagEvaluations flags) { + // Single-pass: per-flag $feature/ property + $active_feature_flags collection in one + // enumeration of the records dictionary. Runs per captured event, so worth keeping tight. + var active = new List(flags.Records.Count); foreach (var (key, record) in flags.Records) { capturedEvent.Properties[$"$feature/{key}"] = record.Flag.ToResponseObject(); + if (record.Enabled) + { + active.Add(key); + } } - capturedEvent.Properties["$active_feature_flags"] = flags.Records - .Where(kvp => kvp.Value.Enabled) - .Select(kvp => kvp.Key) - .ToArray(); + capturedEvent.Properties["$active_feature_flags"] = active.ToArray(); return capturedEvent; } @@ -659,7 +663,7 @@ sealed class EvaluationsHost : IFeatureFlagEvaluationsHost public EvaluationsHost(PostHogClient client) => _client = client; - public void TryCaptureFeatureFlagCalledEventIfNeeded( + public void CaptureFeatureFlagCalled( string distinctId, string featureKey, EvaluatedFlagRecord? record, @@ -689,21 +693,6 @@ public void TryCaptureFeatureFlagCalledEventIfNeeded( locallyEvaluated: record?.LocallyEvaluated ?? false, flagDefinitionsLoadedAt: record?.LocallyEvaluated == true ? flagDefinitionsLoadedAt : null); - // For locally-evaluated flags from a snapshot we still want id/version/reason metadata - // when it is present on the record (the snapshot may carry the full FeatureFlagWithMetadata). - if (record is { Id: { } id }) - { - properties["$feature_flag_id"] = id; - } - if (record is { Version: { } version }) - { - properties["$feature_flag_version"] = version; - } - if (record is { Reason: { } reason } && !record.LocallyEvaluated) - { - properties["$feature_flag_reason"] = reason; - } - _client.TryCaptureDedupedFeatureFlagCalledEvent( distinctId, featureKey, @@ -773,8 +762,12 @@ public async Task EvaluateFlagsAsync( } catch (ApiException e) when (e.ErrorType is "quota_limited") { + // Quota-limited at the local-evaluation endpoint: keep whatever local records we + // computed before the failure, surface the error on $feature_flag_called, and skip + // the remote pass. Matches the behavior of remote-pass quota_limited below. _logger.LogWarningQuotaExceeded(e); - return FeatureFlagEvaluations.Empty(_evaluationsHost, distinctId); + errors.Add(FeatureFlagError.QuotaLimited); + fallbackToRemote = false; } } @@ -799,6 +792,10 @@ public async Task EvaluateFlagsAsync( foreach (var (key, flag) in flagsResult.Flags) { + // Local-wins merge: keep the locally-evaluated record (which carries + // locally_evaluated=true and $feature_flag_definitions_loaded_at) and only fill + // in keys the local pass couldn't resolve. Differs from GetAllFeatureFlagsAsync, + // which discards local results entirely on remote fallback. if (!records.ContainsKey(key)) { records[key] = ToRecord(key, flag, locallyEvaluated: false); @@ -823,34 +820,14 @@ public async Task EvaluateFlagsAsync( errors); static EvaluatedFlagRecord ToRecord(string key, FeatureFlag flag, bool locallyEvaluated) - { - int? id = null; - int? version = null; - // Reason for locally-evaluated flags is hardcoded to "Evaluated locally" inside - // BuildFeatureFlagCalledProperties, so leave the record's Reason null here. - string? reason = null; - if (flag is FeatureFlagWithMetadata withMetadata) - { - id = withMetadata.Id; - version = withMetadata.Version; - if (!locallyEvaluated) - { - reason = withMetadata.Reason; - } - } - - return new EvaluatedFlagRecord + => new() { Key = key, Flag = flag, Enabled = flag.IsEnabled, CacheKeyValue = (string)flag, - Id = id, - Version = version, - Reason = reason, LocallyEvaluated = locallyEvaluated, }; - } } /// diff --git a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs index 78cb390f..f881c615 100644 --- a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs @@ -99,6 +99,61 @@ public async Task OnlyEvaluateLocallyDoesNotHitRemote() Assert.True(snapshot.IsEnabled("flag-a")); Assert.Empty(flagsHandler.ReceivedRequests); } + + [Fact] + public async Task MixedLocalAndRemoteEvaluationMergesRecordsAndTagsSourceCorrectly() + { + var container = new TestContainer(personalApiKey: "fake-personal-api-key"); + // Local-only flag resolves locally; needs-remote requires a property the local pass doesn't have + // and falls back to the remote /flags response. + container.FakeHttpMessageHandler.AddLocalEvaluationResponse( + """ + {"flags": [ + {"id": 1, "key": "local-only", "active": true, "rollout_percentage": 100, + "filters": {"groups": [{"properties": [], "rollout_percentage": 100}]}}, + {"id": 2, "key": "needs-remote", "active": true, "rollout_percentage": 100, + "filters": {"groups": [{"properties": [{"key": "country", "type": "person", "value": "US", "operator": "exact"}], + "rollout_percentage": 100}]}} + ]} + """); + container.FakeHttpMessageHandler.AddFlagsResponse( + """{"featureFlags": {"needs-remote": "variant-x", "remote-only": true}}"""); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var client = container.Activate(); + + var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); + + Assert.True(snapshot.IsEnabled("local-only")); + Assert.Equal("variant-x", snapshot.GetFlag("needs-remote")?.VariantKey); + Assert.True(snapshot.IsEnabled("remote-only")); + + await client.FlushAsync(); + using var doc = JsonDocument.Parse(batchHandler.GetReceivedRequestBody(indented: false)); + var byFlag = doc.RootElement.GetProperty("batch").EnumerateArray() + .ToDictionary( + e => e.GetProperty("properties").GetProperty("$feature_flag").GetString() ?? string.Empty, + e => e.GetProperty("properties").GetProperty("locally_evaluated").GetBoolean()); + Assert.True(byFlag["local-only"]); + Assert.False(byFlag["needs-remote"]); + Assert.False(byFlag["remote-only"]); + } + + [Fact] + public async Task UnknownKeyAccessAppendsFlagMissingErrorOnFeatureFlagCalled() + { + var container = new TestContainer(); + container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {"known": true}}"""); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var client = container.Activate(); + + var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); + snapshot.IsEnabled("does-not-exist"); + + await client.FlushAsync(); + using var doc = JsonDocument.Parse(batchHandler.GetReceivedRequestBody(indented: false)); + var props = doc.RootElement.GetProperty("batch").EnumerateArray().Single().GetProperty("properties"); + Assert.Equal("flag_missing", props.GetProperty("$feature_flag_error").GetString()); + } } public class TheSnapshotAccessMethods @@ -383,4 +438,31 @@ public async Task SharesDedupCacheWithLegacySingleFlagPath() var matches = System.Text.RegularExpressions.Regex.Matches(body, "\\$feature_flag_called"); Assert.Single(matches); } + + [Fact] + public async Task CaptureExceptionAttachesFeatureFlagsFromSnapshot() + { + var container = new TestContainer(); + container.FakeHttpMessageHandler.AddFlagsResponse( + """{"featureFlags": {"flag-a": true, "flag-b": "variant-x"}}"""); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var client = container.Activate(); + + var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); + client.CaptureException( + new InvalidOperationException("boom"), + "user-1", + properties: null, + groups: null, + flags: snapshot); + + await client.FlushAsync(); + using var doc = JsonDocument.Parse(batchHandler.GetReceivedRequestBody(indented: false)); + var exceptionEvent = doc.RootElement.GetProperty("batch") + .EnumerateArray() + .Single(e => e.GetProperty("event").GetString() == "$exception"); + var props = exceptionEvent.GetProperty("properties"); + Assert.True(props.GetProperty("$feature/flag-a").GetBoolean()); + Assert.Equal("variant-x", props.GetProperty("$feature/flag-b").GetString()); + } } From 7e8e1b15f743383adaad934f5d52731b899dd022 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 29 Apr 2026 14:29:05 -0700 Subject: [PATCH 4/6] chore: deprecate single-flag and sendFeatureFlags APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark the four legacy paths replaced by EvaluateFlagsAsync + snapshot [Obsolete(error: false)] so users see migration guidance the moment they update the package: - IPostHogClient.IsFeatureEnabledAsync / .GetFeatureFlagAsync - IPostHogClient.Capture(..., bool sendFeatureFlags, ...) - IPostHogClient.CaptureException(..., bool sendFeatureFlags, ...) Cascade [Obsolete] to wrapper extensions: - FeatureFlagExtensions: 5 IsFeatureEnabledAsync + 5 GetFeatureFlagAsync overloads - CaptureExtensions: bool-sendFeatureFlags overloads of Capture / CapturePageView / CaptureScreenView - CaptureExceptionExtensions: bool-sendFeatureFlags overloads Each extension delegates internally; suppress CS0618 inside the body so the warning surfaces at the user call site, not at the SDK call into itself. Internal call sites that always passed sendFeatureFlags: false migrate to the new Capture(..., flags: null, ...) overload — no behavioral change, but stops the SDK from internally calling its own deprecated path. Tests and samples that intentionally exercise the deprecated surface get a file-level #pragma warning disable CS0618. The new FeatureFlagEvaluationsTests cross-path dedup test wraps a single IsFeatureEnabledAsync call in a per-call pragma so the rest of the file still catches accidental new uses. PostHog.AI's OpenAI handler keeps the legacy Capture(..., sendFeatureFlags: false, ...) call with a #pragma + TODO; its tests assert the legacy mock shape and migrating them is its own change. PostHog.AspNetCore's PostHogVariantFeatureManager suppresses with a #pragma + TODO; the FeatureManager API is per-flag so a snapshot rewrite is non-trivial. All 781 unit tests, 26 AspNetCore tests, and 19 AI tests pass. Generated-By: PostHog Code Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40 --- .../Filters/PostHogPageViewFilter.cs | 1 + samples/HogTied.Web/Pages/Index.cshtml.cs | 3 +- samples/PostHog.Example.Console/Program.cs | 3 +- src/PostHog.AI/PostHogOpenAIHandler.cs | 5 ++- .../PostHogVariantFeatureManager.cs | 6 ++- src/PostHog/Capture/CaptureExtensions.cs | 45 ++++++++++++++----- .../CaptureExceptionExtensions.cs | 21 ++++++--- src/PostHog/Examples.cs | 4 ++ src/PostHog/Features/FeatureFlagExtensions.cs | 30 +++++++++++++ src/PostHog/IPostHogClient.cs | 8 ++++ src/PostHog/PostHogClient.cs | 6 +++ .../PostHogOpenAIHandlerTests.cs | 3 +- .../HttpContextFeatureFlagCacheTests.cs | 1 + tests/UnitTests/Features/ETagSupportTests.cs | 3 +- .../Features/FeatureFlagEvaluationsTests.cs | 3 ++ .../Features/FeatureFlagExtensionsTests.cs | 3 +- tests/UnitTests/Features/FeatureFlagsTests.cs | 3 +- tests/UnitTests/PostHogClientTests.cs | 3 +- 18 files changed, 126 insertions(+), 25 deletions(-) diff --git a/samples/HogTied.Web/Filters/PostHogPageViewFilter.cs b/samples/HogTied.Web/Filters/PostHogPageViewFilter.cs index 73c0f3c4..bafb59aa 100644 --- a/samples/HogTied.Web/Filters/PostHogPageViewFilter.cs +++ b/samples/HogTied.Web/Filters/PostHogPageViewFilter.cs @@ -1,3 +1,4 @@ +#pragma warning disable CS0618 // Tests/samples retain coverage of the deprecated single-flag API surface. using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; using PostHog; diff --git a/samples/HogTied.Web/Pages/Index.cshtml.cs b/samples/HogTied.Web/Pages/Index.cshtml.cs index 52287200..ca27a680 100644 --- a/samples/HogTied.Web/Pages/Index.cshtml.cs +++ b/samples/HogTied.Web/Pages/Index.cshtml.cs @@ -1,3 +1,4 @@ +#pragma warning disable CS0618 // Tests/samples retain coverage of the deprecated single-flag API surface. using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Security.Claims; @@ -295,4 +296,4 @@ public class CacheDemoData public double SecondCallMs { get; set; } public bool? FirstResult { get; set; } public bool? SecondResult { get; set; } -} +} \ No newline at end of file diff --git a/samples/PostHog.Example.Console/Program.cs b/samples/PostHog.Example.Console/Program.cs index f0243678..31db61a2 100644 --- a/samples/PostHog.Example.Console/Program.cs +++ b/samples/PostHog.Example.Console/Program.cs @@ -1,3 +1,4 @@ +#pragma warning disable CS0618 // Tests/samples retain coverage of the deprecated single-flag API surface. // PostHog .NET SDK Console Example // // This demonstrates basic PostHog SDK usage including: @@ -379,4 +380,4 @@ static async Task RunAllExamples(PostHogClient posthog, bool hasPersonalApiKey) await RunIdentifyExamples(posthog); await RunFeatureFlagExamples(posthog); await RunLocalEvaluationExample(posthog, hasPersonalApiKey); -} +} \ No newline at end of file diff --git a/src/PostHog.AI/PostHogOpenAIHandler.cs b/src/PostHog.AI/PostHogOpenAIHandler.cs index d0a79655..b706c3aa 100644 --- a/src/PostHog.AI/PostHogOpenAIHandler.cs +++ b/src/PostHog.AI/PostHogOpenAIHandler.cs @@ -476,7 +476,9 @@ out var streamBool groups.Add(kvp.Key, kvp.Value?.ToString() ?? string.Empty); } } - // Create captured event + // Create captured event. + // TODO: migrate to Capture(..., flags: null, ...) once tests are updated. +#pragma warning disable CS0618 _postHogClient.Capture( distinctId, eventName, @@ -485,6 +487,7 @@ out var streamBool false, // sendFeatureFlags DateTimeOffset.UtcNow ); +#pragma warning restore CS0618 } #pragma warning disable CA1031 // Do not catch general exception types catch (Exception ex) diff --git a/src/PostHog.AspNetCore/FeatureManagement/PostHogVariantFeatureManager.cs b/src/PostHog.AspNetCore/FeatureManagement/PostHogVariantFeatureManager.cs index 38a33e89..6ebfdda0 100644 --- a/src/PostHog.AspNetCore/FeatureManagement/PostHogVariantFeatureManager.cs +++ b/src/PostHog.AspNetCore/FeatureManagement/PostHogVariantFeatureManager.cs @@ -85,7 +85,10 @@ public async ValueTask GetVariantAsync( ? (postHogTargetingContext.PersonProperties, GroupsAndProperties: postHogTargetingContext.GroupCollection) : (null, null); - // Call PostHog's API to check if the feature is enabled for this user + // Call PostHog's API to check if the feature is enabled for this user. + // TODO: migrate to EvaluateFlagsAsync + snapshot per request to align with the SDK's + // Phase 1 RFC; the feature manager API is per-flag so the change isn't trivial. +#pragma warning disable CS0618 return await posthog.GetFeatureFlagAsync( featureKey: feature, distinctId: context.UserId, @@ -96,6 +99,7 @@ public async ValueTask GetVariantAsync( }, cancellationToken ); +#pragma warning restore CS0618 } } diff --git a/src/PostHog/Capture/CaptureExtensions.cs b/src/PostHog/Capture/CaptureExtensions.cs index ced545e1..931df438 100644 --- a/src/PostHog/Capture/CaptureExtensions.cs +++ b/src/PostHog/Capture/CaptureExtensions.cs @@ -25,7 +25,7 @@ public static bool Capture( eventName, properties: null, groups: null, - sendFeatureFlags: false); + flags: null); /// /// Captures an event. @@ -35,17 +35,20 @@ public static bool Capture( /// Human friendly name of the event. Recommended format [object] [verb] such as "Project created" or "User signed up". /// Default: false. If true, feature flags are sent with the captured event. /// true if the event was successfully enqueued. Otherwise false. + [Obsolete("Prefer Capture(..., flags: snapshot) using a FeatureFlagEvaluations snapshot from EvaluateFlagsAsync. This overload will be removed in a future major version.", error: false)] public static bool Capture( this IPostHogClient client, string distinctId, string eventName, bool sendFeatureFlags) +#pragma warning disable CS0618 => NotNull(client).Capture( distinctId, eventName, properties: null, groups: null, sendFeatureFlags: sendFeatureFlags); +#pragma warning restore CS0618 /// /// Captures an event with additional properties to add to the event. @@ -65,7 +68,7 @@ public static bool Capture( eventName, properties, groups: null, - sendFeatureFlags: false); + flags: null); /// /// Captures an event with a custom timestamp. @@ -85,7 +88,7 @@ public static bool Capture( eventName, properties: null, groups: null, - sendFeatureFlags: false, + flags: null, timestamp: timestamp); /// @@ -108,7 +111,7 @@ public static bool Capture( eventName, properties: properties, groups: null, - sendFeatureFlags: false, + flags: null, timestamp: timestamp); /// @@ -131,7 +134,7 @@ public static bool Capture( eventName, properties: null, groups: groups, - sendFeatureFlags: false, + flags: null, timestamp: timestamp); /// @@ -156,7 +159,7 @@ public static bool Capture( eventName, properties: properties, groups: groups, - sendFeatureFlags: false, + flags: null, timestamp: timestamp); /// @@ -168,12 +171,14 @@ public static bool Capture( /// The timestamp when the event occurred. /// If true, feature flags are sent with the captured event. /// true if the event was successfully enqueued. Otherwise false. + [Obsolete("Prefer Capture(..., flags: snapshot, timestamp: timestamp) using a FeatureFlagEvaluations snapshot from EvaluateFlagsAsync. This overload will be removed in a future major version.", error: false)] public static bool Capture( this IPostHogClient client, string distinctId, string eventName, DateTimeOffset timestamp, bool sendFeatureFlags) +#pragma warning disable CS0618 => NotNull(client).Capture( distinctId, eventName, @@ -181,6 +186,7 @@ public static bool Capture( groups: null, sendFeatureFlags: sendFeatureFlags, timestamp: timestamp); +#pragma warning restore CS0618 /// /// Captures an event with a custom timestamp, properties, groups, and feature flags. @@ -193,6 +199,7 @@ public static bool Capture( /// Optional: Context of what groups are related to this event, example: { ["company"] = "id:5" }. Can be used to analyze companies instead of users. /// Default: false. If true, feature flags are sent with the captured event. /// true if the event was successfully enqueued. Otherwise false. + [Obsolete("Prefer Capture(..., flags: snapshot, ...) using a FeatureFlagEvaluations snapshot from EvaluateFlagsAsync. This overload will be removed in a future major version.", error: false)] public static bool Capture( this IPostHogClient client, string distinctId, @@ -201,6 +208,7 @@ public static bool Capture( Dictionary? properties, GroupCollection? groups, bool sendFeatureFlags) +#pragma warning disable CS0618 => NotNull(client).Capture( distinctId, eventName, @@ -208,6 +216,7 @@ public static bool Capture( groups: groups, sendFeatureFlags: sendFeatureFlags, timestamp: timestamp); +#pragma warning restore CS0618 /// /// Captures an event with properties to set on the user. @@ -263,7 +272,7 @@ public static bool Capture( eventName, properties, groups: null, - sendFeatureFlags: false); + flags: null); } /// @@ -284,7 +293,7 @@ public static bool Capture( eventName, properties: null, groups: groups, - sendFeatureFlags: false); + flags: null); /// /// Captures a Page View ($pageview) event. @@ -315,12 +324,14 @@ public static bool CapturePageView( /// Additional context to save with the event. /// Default: false. If true, feature flags are sent with the captured event. /// true if the event was successfully enqueued. Otherwise false. + [Obsolete("Prefer CapturePageView(distinctId, pagePath, properties) and forward a FeatureFlagEvaluations snapshot via Capture(..., flags: snapshot, ...) when needed. This overload will be removed in a future major version.", error: false)] public static bool CapturePageView( this IPostHogClient client, string distinctId, string pagePath, Dictionary? properties, bool sendFeatureFlags) +#pragma warning disable CS0618 => NotNull(client).CaptureSpecialEvent( distinctId, eventName: "$pageview", @@ -328,6 +339,7 @@ public static bool CapturePageView( eventPropertyValue: pagePath, properties, sendFeatureFlags); +#pragma warning restore CS0618 /// /// Captures a Page View ($pageview) event. @@ -347,11 +359,15 @@ public static bool CapturePageView( /// The identifier you use for the user. /// The URL or path of the page to capture. /// Default: false. If true, feature flags are sent with the captured event. + [Obsolete("Prefer CapturePageView(distinctId, pagePath) and forward a FeatureFlagEvaluations snapshot via Capture(..., flags: snapshot, ...) when needed. This overload will be removed in a future major version.", error: false)] public static bool CapturePageView( this IPostHogClient client, string distinctId, string pagePath, - bool sendFeatureFlags) => NotNull(client).CapturePageView(distinctId, pagePath, properties: null, sendFeatureFlags); + bool sendFeatureFlags) +#pragma warning disable CS0618 + => NotNull(client).CapturePageView(distinctId, pagePath, properties: null, sendFeatureFlags); +#pragma warning restore CS0618 /// @@ -383,12 +399,14 @@ public static bool CaptureScreenView( /// Additional context to save with the event. /// Default: false. If true, feature flags are sent with the captured event. /// true if the event was successfully enqueued. Otherwise false. + [Obsolete("Prefer CaptureScreenView(distinctId, screenName, properties) and forward a FeatureFlagEvaluations snapshot via Capture(..., flags: snapshot, ...) when needed. This overload will be removed in a future major version.", error: false)] public static bool CaptureScreenView( this IPostHogClient client, string distinctId, string screenName, Dictionary? properties, bool sendFeatureFlags) +#pragma warning disable CS0618 => NotNull(client).CaptureSpecialEvent( distinctId, eventName: "$screen", @@ -396,6 +414,7 @@ public static bool CaptureScreenView( eventPropertyValue: screenName, properties, sendFeatureFlags); +#pragma warning restore CS0618 /// /// Captures a Screen View ($screen) event. @@ -459,7 +478,7 @@ public static bool CaptureSurveyResponses( properties[$"survey_response_{i}"] = surveyResponses[i]; } - return NotNull(client).Capture(distinctId, "survey sent", properties, groups: null, sendFeatureFlags: false); + return NotNull(client).Capture(distinctId, "survey sent", properties, groups: null, flags: null); } /// @@ -518,6 +537,12 @@ static bool CaptureSpecialEvent( { properties ??= new Dictionary(); properties[eventPropertyName] = eventPropertyValue; + if (!sendFeatureFlags) + { + return NotNull(client).Capture(distinctId, eventName, properties, groups: null, flags: null); + } +#pragma warning disable CS0618 return NotNull(client).Capture(distinctId, eventName, properties, null, sendFeatureFlags); +#pragma warning restore CS0618 } } diff --git a/src/PostHog/ErrorTracking/CaptureExceptionExtensions.cs b/src/PostHog/ErrorTracking/CaptureExceptionExtensions.cs index f2c48d7e..74b726b6 100644 --- a/src/PostHog/ErrorTracking/CaptureExceptionExtensions.cs +++ b/src/PostHog/ErrorTracking/CaptureExceptionExtensions.cs @@ -25,7 +25,7 @@ public static bool CaptureException( distinctId, properties: null, groups: null, - sendFeatureFlags: false); + flags: null); /// /// Captures an exception event with feature flags. @@ -35,17 +35,20 @@ public static bool CaptureException( /// The identifier you use for the user. /// Default: false. If true, feature flags are sent with the captured event. /// true if the exception event was successfully enqueued. Otherwise false. + [Obsolete("Prefer CaptureException(..., flags: snapshot) using a FeatureFlagEvaluations snapshot from EvaluateFlagsAsync. This overload will be removed in a future major version.", error: false)] public static bool CaptureException( this IPostHogClient client, Exception exception, string distinctId, bool sendFeatureFlags) +#pragma warning disable CS0618 => NotNull(client).CaptureException( exception, distinctId, properties: null, groups: null, sendFeatureFlags: sendFeatureFlags); +#pragma warning restore CS0618 /// /// Captures an exception event with additional properties. @@ -65,7 +68,7 @@ public static bool CaptureException( distinctId, properties, groups: null, - sendFeatureFlags: false); + flags: null); /// /// Captures an exception event with a custom timestamp. @@ -85,7 +88,7 @@ public static bool CaptureException( distinctId, properties: null, groups: null, - sendFeatureFlags: false, + flags: null, timestamp: timestamp); /// @@ -108,7 +111,7 @@ public static bool CaptureException( distinctId, properties, groups: null, - sendFeatureFlags: false, + flags: null, timestamp: timestamp); /// @@ -131,7 +134,7 @@ public static bool CaptureException( distinctId, properties: null, groups, - sendFeatureFlags: false, + flags: null, timestamp: timestamp); /// @@ -156,7 +159,7 @@ public static bool CaptureException( distinctId, properties, groups, - sendFeatureFlags: false, + flags: null, timestamp: timestamp); /// @@ -168,12 +171,14 @@ public static bool CaptureException( /// The timestamp when the event occurred. /// Default: false. If true, feature flags are sent with the captured event. /// true if the exception event was successfully enqueued. Otherwise false. + [Obsolete("Prefer CaptureException(..., flags: snapshot, timestamp: timestamp) using a FeatureFlagEvaluations snapshot from EvaluateFlagsAsync. This overload will be removed in a future major version.", error: false)] public static bool CaptureException( this IPostHogClient client, Exception exception, string distinctId, DateTimeOffset timestamp, bool sendFeatureFlags) +#pragma warning disable CS0618 => NotNull(client).CaptureException( exception, distinctId, @@ -181,6 +186,7 @@ public static bool CaptureException( groups: null, sendFeatureFlags: sendFeatureFlags, timestamp: timestamp); +#pragma warning restore CS0618 /// /// Captures an exception event with a custom timestamp, properties, groups, and feature flags. @@ -193,6 +199,7 @@ public static bool CaptureException( /// Optional: A set of groups to send with the event. The groups are identified by their group_type and group_key. /// Default: false. If true, feature flags are sent with the captured event. /// true if the exception event was successfully enqueued. Otherwise false. + [Obsolete("Prefer CaptureException(..., flags: snapshot, ...) using a FeatureFlagEvaluations snapshot from EvaluateFlagsAsync. This overload will be removed in a future major version.", error: false)] public static bool CaptureException( this IPostHogClient client, Exception exception, @@ -201,6 +208,7 @@ public static bool CaptureException( Dictionary? properties, GroupCollection? groups, bool sendFeatureFlags) +#pragma warning disable CS0618 => NotNull(client).CaptureException( exception, distinctId, @@ -208,4 +216,5 @@ public static bool CaptureException( groups, sendFeatureFlags, timestamp); +#pragma warning restore CS0618 } diff --git a/src/PostHog/Examples.cs b/src/PostHog/Examples.cs index d7466ac7..c69b3817 100644 --- a/src/PostHog/Examples.cs +++ b/src/PostHog/Examples.cs @@ -1,3 +1,7 @@ +// Examples intentionally still demonstrate the legacy single-flag APIs alongside the new +// EvaluateFlagsAsync surface. Updating these to the snapshot pattern is tracked separately. +#pragma warning disable CS0618 // Type or member is obsolete + namespace PostHog; /// diff --git a/src/PostHog/Features/FeatureFlagExtensions.cs b/src/PostHog/Features/FeatureFlagExtensions.cs index e5f4d93b..f11a3fe8 100644 --- a/src/PostHog/Features/FeatureFlagExtensions.cs +++ b/src/PostHog/Features/FeatureFlagExtensions.cs @@ -20,14 +20,17 @@ public static class FeatureFlagExtensions /// true if the feature is enabled for the user. false if not. null if the feature does not /// exist. /// + [Obsolete("Prefer EvaluateFlagsAsync(distinctId).IsEnabled(featureKey). This method will be removed in a future major version.", error: false)] public static Task IsFeatureEnabledAsync( this IPostHogClient client, string featureKey, string distinctId, CancellationToken cancellationToken) +#pragma warning disable CS0618 => NotNull(client).IsFeatureEnabledAsync(featureKey, distinctId, options: null, cancellationToken: cancellationToken); +#pragma warning restore CS0618 /// /// Determines whether a feature is enabled for the specified user. @@ -38,13 +41,16 @@ public static Task IsFeatureEnabledAsync( /// /// true if the feature is enabled for the user. false if not. null if the feature is undefined. /// + [Obsolete("Prefer EvaluateFlagsAsync(distinctId).IsEnabled(featureKey). This method will be removed in a future major version.", error: false)] public static Task IsFeatureEnabledAsync( this IPostHogClient client, string featureKey, string distinctId) +#pragma warning disable CS0618 => NotNull(client).IsFeatureEnabledAsync(featureKey, distinctId, options: null, cancellationToken: CancellationToken.None); +#pragma warning restore CS0618 /// /// Determines whether a feature is enabled for the specified user. @@ -56,14 +62,17 @@ public static Task IsFeatureEnabledAsync( /// /// true if the feature is enabled for the user. false if not. null if the feature is undefined. /// + [Obsolete("Prefer EvaluateFlagsAsync(distinctId).IsEnabled(featureKey). This method will be removed in a future major version.", error: false)] public static Task IsFeatureEnabledAsync( this IPostHogClient client, string featureKey, string distinctId, FeatureFlagOptions? options) +#pragma warning disable CS0618 => NotNull(client).IsFeatureEnabledAsync(featureKey, distinctId, options, cancellationToken: CancellationToken.None); +#pragma warning restore CS0618 /// /// Retrieves a feature flag. @@ -74,17 +83,20 @@ public static Task IsFeatureEnabledAsync( /// Optional: What person properties are known. Used to compute flags locally, if personalApiKey is present. Not needed if using remote evaluation, but can be used to override remote values for the purposes of feature flag evaluation. /// The cancellation token that can be used to cancel the operation. /// The feature flag or null if it does not exist or is not enabled. + [Obsolete("Prefer EvaluateFlagsAsync(distinctId, options).IsEnabled(featureKey). This method will be removed in a future major version.", error: false)] public static async Task IsFeatureEnabledAsync( this IPostHogClient client, string featureKey, string distinctId, Dictionary personProperties, CancellationToken cancellationToken) +#pragma warning disable CS0618 => await NotNull(client).IsFeatureEnabledAsync( featureKey, distinctId, new FeatureFlagOptions { PersonProperties = new Dictionary(personProperties) }, cancellationToken); +#pragma warning restore CS0618 /// /// Retrieves a feature flag. @@ -94,16 +106,19 @@ public static Task IsFeatureEnabledAsync( /// The identifier you use for the user. /// Optional: What person properties are known. Used to compute flags locally, if personalApiKey is present. Not needed if using remote evaluation, but can be used to override remote values for the purposes of feature flag evaluation. /// The feature flag or null if it does not exist or is not enabled. + [Obsolete("Prefer EvaluateFlagsAsync(distinctId, options).IsEnabled(featureKey). This method will be removed in a future major version.", error: false)] public static async Task IsFeatureEnabledAsync( this IPostHogClient client, string featureKey, string distinctId, Dictionary personProperties) +#pragma warning disable CS0618 => await NotNull(client).IsFeatureEnabledAsync( featureKey, distinctId, personProperties, CancellationToken.None); +#pragma warning restore CS0618 /// /// Retrieves a feature flag. @@ -113,14 +128,17 @@ public static Task IsFeatureEnabledAsync( /// The identifier you use for the user. /// The cancellation token that can be used to cancel the operation. /// The feature flag or null if it does not exist or is not enabled. + [Obsolete("Prefer EvaluateFlagsAsync(distinctId).GetFlag(featureKey). This method will be removed in a future major version.", error: false)] public static async Task GetFeatureFlagAsync( this IPostHogClient client, string featureKey, string distinctId, CancellationToken cancellationToken) +#pragma warning disable CS0618 => await NotNull(client).GetFeatureFlagAsync(featureKey, distinctId, options: null, cancellationToken: cancellationToken); +#pragma warning restore CS0618 /// /// Retrieves a feature flag. @@ -129,15 +147,18 @@ public static Task IsFeatureEnabledAsync( /// The name of the feature flag. /// The identifier you use for the user. /// The feature flag or null if it does not exist or is not enabled. + [Obsolete("Prefer EvaluateFlagsAsync(distinctId).GetFlag(featureKey). This method will be removed in a future major version.", error: false)] public static async Task GetFeatureFlagAsync( this IPostHogClient client, string featureKey, string distinctId) +#pragma warning disable CS0618 => await NotNull(client).GetFeatureFlagAsync( featureKey, distinctId, options: null, cancellationToken: CancellationToken.None); +#pragma warning restore CS0618 /// /// Retrieves a feature flag. @@ -147,14 +168,17 @@ public static Task IsFeatureEnabledAsync( /// The identifier you use for the user. /// Optional: Options used to control feature flag evaluation. /// The feature flag or null if it does not exist or is not enabled. + [Obsolete("Prefer EvaluateFlagsAsync(distinctId, options).GetFlag(featureKey). This method will be removed in a future major version.", error: false)] public static async Task GetFeatureFlagAsync( this IPostHogClient client, string featureKey, string distinctId, FeatureFlagOptions options) +#pragma warning disable CS0618 => await NotNull(client).GetFeatureFlagAsync(featureKey, distinctId, options, cancellationToken: CancellationToken.None); +#pragma warning restore CS0618 /// /// Retrieves a feature flag. @@ -165,17 +189,20 @@ public static Task IsFeatureEnabledAsync( /// Optional: What person properties are known. Used to compute flags locally, if personalApiKey is present. Not needed if using remote evaluation, but can be used to override remote values for the purposes of feature flag evaluation. /// The cancellation token that can be used to cancel the operation. /// The feature flag or null if it does not exist or is not enabled. + [Obsolete("Prefer EvaluateFlagsAsync(distinctId, options).GetFlag(featureKey). This method will be removed in a future major version.", error: false)] public static async Task GetFeatureFlagAsync( this IPostHogClient client, string featureKey, string distinctId, Dictionary personProperties, CancellationToken cancellationToken) +#pragma warning disable CS0618 => await NotNull(client).GetFeatureFlagAsync( featureKey, distinctId, new FeatureFlagOptions { PersonProperties = new Dictionary(personProperties) }, cancellationToken); +#pragma warning restore CS0618 /// /// Retrieves a feature flag. @@ -185,16 +212,19 @@ public static Task IsFeatureEnabledAsync( /// The identifier you use for the user. /// Optional: What person properties are known. Used to compute flags locally, if personalApiKey is present. Not needed if using remote evaluation, but can be used to override remote values for the purposes of feature flag evaluation. /// The feature flag or null if it does not exist or is not enabled. + [Obsolete("Prefer EvaluateFlagsAsync(distinctId, options).GetFlag(featureKey). This method will be removed in a future major version.", error: false)] public static async Task GetFeatureFlagAsync( this IPostHogClient client, string featureKey, string distinctId, Dictionary personProperties) +#pragma warning disable CS0618 => await NotNull(client).GetFeatureFlagAsync( featureKey, distinctId, personProperties, CancellationToken.None); +#pragma warning restore CS0618 /// /// Retrieves all the feature flags. diff --git a/src/PostHog/IPostHogClient.cs b/src/PostHog/IPostHogClient.cs index 29978b54..f832ecbc 100644 --- a/src/PostHog/IPostHogClient.cs +++ b/src/PostHog/IPostHogClient.cs @@ -95,6 +95,7 @@ Task GroupIdentifyAsync( /// Default: false. If true, feature flags are sent with the captured event. /// Optional: Custom timestamp when the event occurred. If not provided, uses current time. /// true if the event was successfully enqueued. Otherwise false. + [Obsolete("Prefer Capture(..., flags: snapshot, ...) using a FeatureFlagEvaluations snapshot from EvaluateFlagsAsync — same payload, no extra /flags request. This overload will be removed in a future major version.", error: false)] bool Capture( string distinctId, string eventName, @@ -124,7 +125,9 @@ bool Capture( FeatureFlagEvaluations? flags, DateTimeOffset? timestamp = null) #if !NETSTANDARD2_0 +#pragma warning disable CS0618 // Default delegates to the legacy overload; concrete PostHogClient overrides this with a snapshot-aware path. => Capture(distinctId, eventName, properties, groups, sendFeatureFlags: false, timestamp) +#pragma warning restore CS0618 #endif ; @@ -138,6 +141,7 @@ bool Capture( /// Default: false. If true, feature flags are sent with the captured event. /// Optional: Custom timestamp when the event occurred. If not provided, uses current time /// true if the exception event was successfully enqueued. Otherwise false. + [Obsolete("Prefer CaptureException(..., flags: snapshot, ...) using a FeatureFlagEvaluations snapshot from EvaluateFlagsAsync — same payload, no extra /flags request. This overload will be removed in a future major version.", error: false)] bool CaptureException( Exception exception, string distinctId, @@ -164,7 +168,9 @@ bool CaptureException( FeatureFlagEvaluations? flags, DateTimeOffset? timestamp = null) #if !NETSTANDARD2_0 +#pragma warning disable CS0618 // Default delegates to the legacy overload; concrete PostHogClient overrides this with a snapshot-aware path. => CaptureException(exception, distinctId, properties, groups, sendFeatureFlags: false, timestamp) +#pragma warning restore CS0618 #endif ; @@ -178,6 +184,7 @@ bool CaptureException( /// /// true if the feature is enabled for the user. false if not. null if the feature is undefined. /// + [Obsolete("Prefer EvaluateFlagsAsync(distinctId).IsEnabled(featureKey) — one /flags request powers all flag branching for the request. This method will be removed in a future major version.", error: false)] Task IsFeatureEnabledAsync( string featureKey, string distinctId, @@ -192,6 +199,7 @@ Task IsFeatureEnabledAsync( /// Optional: Options used to control feature flag evaluation. /// The cancellation token that can be used to cancel the operation. /// The feature flag or null if it does not exist or is not enabled. + [Obsolete("Prefer EvaluateFlagsAsync(distinctId).GetFlag(featureKey) — one /flags request powers all flag branching for the request. This method will be removed in a future major version.", error: false)] Task GetFeatureFlagAsync( string featureKey, string distinctId, diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index 8ac64f96..fc2765bd 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -166,6 +166,7 @@ public bool Capture( => CaptureCore(distinctId, eventName, properties, groups, sendFeatureFlags: false, flags, timestamp); /// + [Obsolete("Prefer Capture(..., flags: snapshot, ...) using a FeatureFlagEvaluations snapshot from EvaluateFlagsAsync — same payload, no extra /flags request. This overload will be removed in a future major version.", error: false)] public bool Capture( string distinctId, string eventName, @@ -238,6 +239,7 @@ Task BatchTask(CapturedEventBatchContext context) } /// + [Obsolete("Prefer CaptureException(..., flags: snapshot, ...) using a FeatureFlagEvaluations snapshot from EvaluateFlagsAsync — same payload, no extra /flags request. This overload will be removed in a future major version.", error: false)] public bool CaptureException( Exception exception, string distinctId, @@ -366,22 +368,26 @@ static CapturedEvent AddFeatureFlagsToCapturedEvent( } /// + [Obsolete("Prefer EvaluateFlagsAsync(distinctId).IsEnabled(featureKey) — one /flags request powers all flag branching for the request. This method will be removed in a future major version.", error: false)] public async Task IsFeatureEnabledAsync( string featureKey, string distinctId, FeatureFlagOptions? options, CancellationToken cancellationToken) { +#pragma warning disable CS0618 // Internal call into the deprecated path; see method docstring for the preferred API. var result = await GetFeatureFlagAsync( featureKey, distinctId, options, cancellationToken); +#pragma warning restore CS0618 return result is { IsEnabled: true }; } /// + [Obsolete("Prefer EvaluateFlagsAsync(distinctId).GetFlag(featureKey) — one /flags request powers all flag branching for the request. This method will be removed in a future major version.", error: false)] public async Task GetFeatureFlagAsync( string featureKey, string distinctId, diff --git a/tests/PostHog.AI.Tests/PostHogOpenAIHandlerTests.cs b/tests/PostHog.AI.Tests/PostHogOpenAIHandlerTests.cs index ec42ee38..587c658d 100644 --- a/tests/PostHog.AI.Tests/PostHogOpenAIHandlerTests.cs +++ b/tests/PostHog.AI.Tests/PostHogOpenAIHandlerTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable CS0618 // Tests/samples retain coverage of the deprecated single-flag API surface. using System.Net; using System.Net.Http.Headers; using System.Text; @@ -1119,4 +1120,4 @@ CancellationToken cancellationToken return Task.FromResult(Response); } } -} +} \ No newline at end of file diff --git a/tests/UnitTests.AspNetCore/HttpContextFeatureFlagCacheTests.cs b/tests/UnitTests.AspNetCore/HttpContextFeatureFlagCacheTests.cs index 0315d4e9..c65e24e1 100644 --- a/tests/UnitTests.AspNetCore/HttpContextFeatureFlagCacheTests.cs +++ b/tests/UnitTests.AspNetCore/HttpContextFeatureFlagCacheTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable CS0618 // Tests/samples retain coverage of the deprecated single-flag API surface. using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using NSubstitute; diff --git a/tests/UnitTests/Features/ETagSupportTests.cs b/tests/UnitTests/Features/ETagSupportTests.cs index e28b20ec..07b014c2 100644 --- a/tests/UnitTests/Features/ETagSupportTests.cs +++ b/tests/UnitTests/Features/ETagSupportTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable CS0618 // Tests/samples retain coverage of the deprecated single-flag API surface. using PostHog; using PostHog.Library; using UnitTests.Fakes; @@ -228,4 +229,4 @@ public async Task ResponseWithoutETagStillReturnsFlags() var result = await client.IsFeatureEnabledAsync("test-flag", "user-123"); Assert.True(result); } -} +} \ No newline at end of file diff --git a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs index f881c615..5608eb86 100644 --- a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs @@ -427,7 +427,10 @@ public async Task SharesDedupCacheWithLegacySingleFlagPath() var client = container.Activate(); // Legacy path fires $feature_flag_called for ("user-1", "flag-a", true). + // The legacy single-flag method is deprecated but still must dedup against the snapshot path. +#pragma warning disable CS0618 Assert.True(await client.IsFeatureEnabledAsync("flag-a", "user-1")); +#pragma warning restore CS0618 // Snapshot path accesses the same flag — should hit the existing cache and NOT fire again. var snapshot = await client.EvaluateFlagsAsync("user-1", options: null, CancellationToken.None); diff --git a/tests/UnitTests/Features/FeatureFlagExtensionsTests.cs b/tests/UnitTests/Features/FeatureFlagExtensionsTests.cs index d03f0109..b422984e 100644 --- a/tests/UnitTests/Features/FeatureFlagExtensionsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagExtensionsTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable CS0618 // Tests/samples retain coverage of the deprecated single-flag API surface. using PostHog; using PostHog.Features; using UnitTests.Fakes; @@ -122,4 +123,4 @@ await posthog.GetFeatureFlagAsync( personProperties: new() { ["region"] = "Canada" }) ); } -} +} \ No newline at end of file diff --git a/tests/UnitTests/Features/FeatureFlagsTests.cs b/tests/UnitTests/Features/FeatureFlagsTests.cs index 312ab982..3e41f5b5 100644 --- a/tests/UnitTests/Features/FeatureFlagsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagsTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable CS0618 // Tests/samples retain coverage of the deprecated single-flag API surface. using System.Net; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; @@ -4119,4 +4120,4 @@ public async Task PropagatesCancellationWhenUserCancelsRequest() await Assert.ThrowsAsync(() => client.GetFeatureFlagAsync("flag", "id", null, cts.Token)); } -} +} \ No newline at end of file diff --git a/tests/UnitTests/PostHogClientTests.cs b/tests/UnitTests/PostHogClientTests.cs index 9dbc2876..d3a568b3 100644 --- a/tests/UnitTests/PostHogClientTests.cs +++ b/tests/UnitTests/PostHogClientTests.cs @@ -1,3 +1,4 @@ +#pragma warning disable CS0618 // Tests/samples retain coverage of the deprecated single-flag API surface. using System.Reflection; using System.Runtime.InteropServices; using System.Runtime.Loader; @@ -1563,4 +1564,4 @@ await Assert.ThrowsAsync(() => Assert.DoesNotContain(errorLogs, log => log.Message?.Contains("Failed to load feature flags", StringComparison.Ordinal) == true); } -} +} \ No newline at end of file From 3aad2c4f85d0744730a201ca8ef85ea3da198d87 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 29 Apr 2026 14:40:39 -0700 Subject: [PATCH 5/6] review: fix CI, propagate cancellation, tighten Records, polish comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sdk_compliance_adapter Program.cs: switch the lone Capture(..., sendFeatureFlags: false, ...) call to the new flags: null overload so the Docker publish in the SDK-compliance CI job no longer hits CS0618 → exit 1. - EvaluateFlagsAsync: exclude OperationCanceledException from the catch-all so cancellation propagates instead of being logged as UnknownError. Matches GetFeatureFlagAsync's filter. - FeatureFlagEvaluations.Records: typed as IReadOnlyDictionary so the one consumer (PostHogClient.AddFeatureFlagsToCapturedEvent) can iterate but cannot mutate the snapshot's underlying state. - Local-quota comment in EvaluateFlagsAsync: clarify that `records` is always empty when the catch fires (the throwing call is the first inside the try). - Capture / CaptureException / DIM bodies: name every trailing argument (timestamp:, flags:) so non-trailing-named-argument call sites don't trip future IDE/bot warnings even though the C# 7.2+ rules accept them. - FeatureFlagEvaluationsTests: drop the unused Microsoft.Extensions.Options using directive. Generated-By: PostHog Code Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40 --- sdk_compliance_adapter/Program.cs | 2 +- .../Features/FeatureFlagEvaluations.cs | 5 +++-- src/PostHog/IPostHogClient.cs | 4 ++-- src/PostHog/PostHogClient.cs | 19 +++++++++++-------- .../Features/FeatureFlagEvaluationsTests.cs | 1 - 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/sdk_compliance_adapter/Program.cs b/sdk_compliance_adapter/Program.cs index 18414b0f..d07b883c 100644 --- a/sdk_compliance_adapter/Program.cs +++ b/sdk_compliance_adapter/Program.cs @@ -86,7 +86,7 @@ request.Event, request.Properties, groups: null, - sendFeatureFlags: false, + flags: null, timestamp: timestamp ); diff --git a/src/PostHog/Features/FeatureFlagEvaluations.cs b/src/PostHog/Features/FeatureFlagEvaluations.cs index f6d214c0..7cfc148e 100644 --- a/src/PostHog/Features/FeatureFlagEvaluations.cs +++ b/src/PostHog/Features/FeatureFlagEvaluations.cs @@ -176,9 +176,10 @@ public FeatureFlagEvaluations Only(params string[] keys) /// /// The internal per-flag records. Used by 's capture path to attach - /// $feature/<key> properties. + /// $feature/<key> properties. Exposed as + /// so the caller cannot mutate the snapshot's underlying state. /// - internal Dictionary Records => _records; + internal IReadOnlyDictionary Records => _records; /// /// Constructs an empty snapshot with no flags and no events. Used as the safety fallback when diff --git a/src/PostHog/IPostHogClient.cs b/src/PostHog/IPostHogClient.cs index f832ecbc..9237aae6 100644 --- a/src/PostHog/IPostHogClient.cs +++ b/src/PostHog/IPostHogClient.cs @@ -126,7 +126,7 @@ bool Capture( DateTimeOffset? timestamp = null) #if !NETSTANDARD2_0 #pragma warning disable CS0618 // Default delegates to the legacy overload; concrete PostHogClient overrides this with a snapshot-aware path. - => Capture(distinctId, eventName, properties, groups, sendFeatureFlags: false, timestamp) + => Capture(distinctId, eventName, properties, groups, sendFeatureFlags: false, timestamp: timestamp) #pragma warning restore CS0618 #endif ; @@ -169,7 +169,7 @@ bool CaptureException( DateTimeOffset? timestamp = null) #if !NETSTANDARD2_0 #pragma warning disable CS0618 // Default delegates to the legacy overload; concrete PostHogClient overrides this with a snapshot-aware path. - => CaptureException(exception, distinctId, properties, groups, sendFeatureFlags: false, timestamp) + => CaptureException(exception, distinctId, properties, groups, sendFeatureFlags: false, timestamp: timestamp) #pragma warning restore CS0618 #endif ; diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index fc2765bd..f253fe8e 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -163,7 +163,7 @@ public bool Capture( GroupCollection? groups, FeatureFlagEvaluations? flags, DateTimeOffset? timestamp = null) - => CaptureCore(distinctId, eventName, properties, groups, sendFeatureFlags: false, flags, timestamp); + => CaptureCore(distinctId, eventName, properties, groups, sendFeatureFlags: false, flags: flags, timestamp: timestamp); /// [Obsolete("Prefer Capture(..., flags: snapshot, ...) using a FeatureFlagEvaluations snapshot from EvaluateFlagsAsync — same payload, no extra /flags request. This overload will be removed in a future major version.", error: false)] @@ -174,7 +174,7 @@ public bool Capture( GroupCollection? groups, bool sendFeatureFlags, DateTimeOffset? timestamp = null) - => CaptureCore(distinctId, eventName, properties, groups, sendFeatureFlags, flags: null, timestamp); + => CaptureCore(distinctId, eventName, properties, groups, sendFeatureFlags, flags: null, timestamp: timestamp); bool CaptureCore( string distinctId, @@ -247,7 +247,7 @@ public bool CaptureException( GroupCollection? groups, bool sendFeatureFlags, DateTimeOffset? timestamp = null) - => CaptureExceptionCore(exception, distinctId, properties, groups, sendFeatureFlags, flags: null, timestamp); + => CaptureExceptionCore(exception, distinctId, properties, groups, sendFeatureFlags, flags: null, timestamp: timestamp); /// public bool CaptureException( @@ -257,7 +257,7 @@ public bool CaptureException( GroupCollection? groups, FeatureFlagEvaluations? flags, DateTimeOffset? timestamp = null) - => CaptureExceptionCore(exception, distinctId, properties, groups, sendFeatureFlags: false, flags, timestamp); + => CaptureExceptionCore(exception, distinctId, properties, groups, sendFeatureFlags: false, flags: flags, timestamp: timestamp); bool CaptureExceptionCore( Exception exception, @@ -768,9 +768,10 @@ public async Task EvaluateFlagsAsync( } catch (ApiException e) when (e.ErrorType is "quota_limited") { - // Quota-limited at the local-evaluation endpoint: keep whatever local records we - // computed before the failure, surface the error on $feature_flag_called, and skip - // the remote pass. Matches the behavior of remote-pass quota_limited below. + // Quota-limited from the local-evaluation endpoint. In practice this fires from + // GetFeatureFlagsForLocalEvaluationAsync — the first call in the try, before any + // flag has been evaluated — so `records` is empty here. We surface the error on + // $feature_flag_called and skip the remote pass to mirror the remote-pass behavior. _logger.LogWarningQuotaExceeded(e); errors.Add(FeatureFlagError.QuotaLimited); fallbackToRemote = false; @@ -808,7 +809,9 @@ public async Task EvaluateFlagsAsync( } } } - catch (Exception e) when (e is not ArgumentException and not NullReferenceException) + catch (Exception e) when (e is not ArgumentException + and not NullReferenceException + and not OperationCanceledException) { _logger.LogErrorUnableToGetFeatureFlagsAndPayloads(e); errors.Add(FeatureFlagError.UnknownError); diff --git a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs index 5608eb86..b82ce0a8 100644 --- a/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using PostHog; using PostHog.Features; using UnitTests.Fakes; From 406b85930aeaf3a20a821a8e2d7d29bb25ac88d5 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 29 Apr 2026 15:15:14 -0700 Subject: [PATCH 6/6] chore: drop stray BOM after pragma in FeatureFlagExtensionsTests Generated-By: PostHog Code Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40 --- tests/UnitTests/Features/FeatureFlagExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/UnitTests/Features/FeatureFlagExtensionsTests.cs b/tests/UnitTests/Features/FeatureFlagExtensionsTests.cs index b422984e..503844f8 100644 --- a/tests/UnitTests/Features/FeatureFlagExtensionsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagExtensionsTests.cs @@ -1,5 +1,5 @@ #pragma warning disable CS0618 // Tests/samples retain coverage of the deprecated single-flag API surface. -using PostHog; +using PostHog; using PostHog.Features; using UnitTests.Fakes;