diff --git a/Directory.Build.props b/Directory.Build.props index f8855cad..e1b53ec9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.3.2 + 2.4.0 13.0 enable enable diff --git a/src/PostHog/Config/PostHogOptions.cs b/src/PostHog/Config/PostHogOptions.cs index 303de67f..7b7c2aa0 100644 --- a/src/PostHog/Config/PostHogOptions.cs +++ b/src/PostHog/Config/PostHogOptions.cs @@ -67,6 +67,15 @@ public sealed class PostHogOptions : IOptions /// 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 06c92f12..0a18f667 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 b94161e2..7c9353bd 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.3.2"; + public const string Version = "2.4.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 53686fb6..1e78eaa6 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. @@ -83,6 +84,7 @@ public PostHogClient( }); _logger = loggerFactory.CreateLogger(); + _evaluationsHost = new EvaluationsHost(this); _logger.LogInfoClientCreated(options.Value.MaxBatchSize, options.Value.FlushInterval, options.Value.FlushAt); } @@ -134,6 +136,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, @@ -142,6 +154,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) @@ -174,6 +196,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); @@ -198,6 +226,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) { @@ -213,7 +261,7 @@ public bool CaptureException( properties["$exception_personURL"] = $"{host}/project/{_options.Value.ProjectApiKey}/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) @@ -279,6 +327,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, @@ -413,26 +476,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); } @@ -487,28 +547,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; @@ -530,14 +593,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, + }; + } } /// @@ -844,4 +1136,10 @@ public static partial void LogErrorUnableToGetRemoteConfigPayload( Level = LogLevel.Error, Message = "CaptureException failed with an exception")] public static partial void LogErrorCaptureExceptionFailed(this ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 20, + Level = LogLevel.Warning, + Message = "[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..59e3b7e1 --- /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.ProjectApiKey = "fake-project-api-key"; + 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 ec84763d..4671db67 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}}",