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/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/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.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/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/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/EvaluatedFlagRecord.cs b/src/PostHog/Features/EvaluatedFlagRecord.cs new file mode 100644 index 00000000..a70f193d --- /dev/null +++ b/src/PostHog/Features/EvaluatedFlagRecord.cs @@ -0,0 +1,38 @@ +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 + /// . 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; } + + /// + /// 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; } + + /// + /// 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 new file mode 100644 index 00000000..7cfc148e --- /dev/null +++ b/src/PostHog/Features/FeatureFlagEvaluations.cs @@ -0,0 +1,239 @@ +using System.Collections.Concurrent; +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 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. + readonly ConcurrentDictionary _accessed; + readonly GroupCollection? _groups; + readonly IReadOnlyCollection _errors; + + internal FeatureFlagEvaluations( + IFeatureFlagEvaluationsHost host, + string distinctId, + Dictionary records, + string? requestId, + long? evaluatedAt, + long? flagDefinitionsLoadedAt, + GroupCollection? groups, + IReadOnlyCollection? errors, + ConcurrentDictionary? 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 ConcurrentDictionary(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 => _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 . + /// + /// + /// + /// 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) + { + _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.Keys) + { + 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); + List? missing = null; + foreach (var key in NotNull(keys)) + { + if (_records.TryGetValue(key, out var record)) + { + filtered[key] = record; + } + else + { + (missing ??= new List()).Add(key); + } + } + + if (missing is { 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. Exposed as + /// so the caller cannot mutate the snapshot's underlying state. + /// + 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); + var firstAccess = _accessed.TryAdd(keyChecked, 0); + + _records.TryGetValue(keyChecked, out var record); + + if (!firstAccess || string.IsNullOrEmpty(DistinctId)) + { + // 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; + } + + _host.CaptureFeatureFlagCalled( + distinctId: DistinctId, + featureKey: keyChecked, + record: record, + groups: _groups, + requestId: RequestId, + evaluatedAt: EvaluatedAt, + flagDefinitionsLoadedAt: FlagDefinitionsLoadedAt, + errors: _errors); + + return record; + } + + FeatureFlagEvaluations CloneWith(Dictionary records) + => new( + _host, + DistinctId, + records, + RequestId, + EvaluatedAt, + FlagDefinitionsLoadedAt, + _groups, + _errors, + accessed: new ConcurrentDictionary(_accessed, StringComparer.Ordinal)); +} diff --git a/src/PostHog/Features/FeatureFlagExtensions.cs b/src/PostHog/Features/FeatureFlagExtensions.cs index b9e03ad8..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. @@ -228,6 +258,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..2581fba5 --- /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 CaptureFeatureFlagCalled( + 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..9237aae6 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, @@ -103,6 +104,33 @@ 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) +#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: timestamp) +#pragma warning restore CS0618 +#endif + ; + /// /// Capture an exception as an event. /// @@ -113,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, @@ -121,6 +150,30 @@ 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) +#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: timestamp) +#pragma warning restore CS0618 +#endif + ; + /// /// Determines whether a feature is enabled for the specified user. /// @@ -131,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, @@ -145,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, @@ -173,6 +228,38 @@ 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. + /// + /// 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) +#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 564cc11b..f253fe8e 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); } @@ -154,6 +156,17 @@ public Task GroupIdentifyAsync( => _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: 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)] public bool Capture( string distinctId, string eventName, @@ -161,6 +174,16 @@ public bool Capture( GroupCollection? groups, bool sendFeatureFlags, DateTimeOffset? timestamp = null) + => CaptureCore(distinctId, eventName, properties, groups, sendFeatureFlags, flags: null, timestamp: 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 +216,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); @@ -210,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, @@ -217,6 +247,26 @@ public bool CaptureException( GroupCollection? groups, bool sendFeatureFlags, DateTimeOffset? timestamp = null) + => CaptureExceptionCore(exception, distinctId, properties, groups, sendFeatureFlags, flags: null, timestamp: 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: flags, timestamp: timestamp); + + bool CaptureExceptionCore( + Exception exception, + string distinctId, + Dictionary? properties, + GroupCollection? groups, + bool sendFeatureFlags, + FeatureFlagEvaluations? flags, + DateTimeOffset? timestamp) { if (exception == null) { @@ -232,7 +282,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,23 +348,46 @@ static CapturedEvent AddFeatureFlagsToCapturedEvent( return capturedEvent; } + 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"] = active.ToArray(); + return capturedEvent; + } + /// + [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, @@ -432,26 +505,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 +576,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 +622,221 @@ bool CaptureFeatureFlagCalledEvent( properties["$feature_flag_error"] = string.Join(",", errors); } - Capture( - distinctId, - eventName: "$feature_flag_called", - properties: properties, - groups: groupProperties, - sendFeatureFlags: false); + return properties; + } - return true; + 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 CaptureFeatureFlagCalled( + 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); + + _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") + { + // 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; + } + } + + // 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) + { + // 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); + } + } + } + catch (Exception e) when (e is not ArgumentException + and not NullReferenceException + and not OperationCanceledException) + { + _logger.LogErrorUnableToGetFeatureFlagsAndPayloads(e); + errors.Add(FeatureFlagError.UnknownError); + } + } + + return new FeatureFlagEvaluations( + _evaluationsHost, + distinctId, + records, + requestId, + evaluatedAt, + flagDefinitionsLoadedAt, + options?.Groups, + errors); + + static EvaluatedFlagRecord ToRecord(string key, FeatureFlag flag, bool locallyEvaluated) + => new() + { + Key = key, + Flag = flag, + Enabled = flag.IsEnabled, + CacheKeyValue = (string)flag, + LocallyEvaluated = locallyEvaluated, + }; } /// @@ -859,20 +1139,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/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 new file mode 100644 index 00000000..b82ce0a8 --- /dev/null +++ b/tests/UnitTests/Features/FeatureFlagEvaluationsTests.cs @@ -0,0 +1,470 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +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); + } + + [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 +{ + [Theory] + [InlineData("IsEnabled")] + [InlineData("GetFlag")] + public async Task UnknownKeyReturnsFalsyValue(string accessor) + { + var snapshot = await EvaluateAsync("""{"featureFlags": {"known": true}}"""); + 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] + 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); + + // 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] + 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). + // 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); + 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); + } + + [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()); + } +} diff --git a/tests/UnitTests/Features/FeatureFlagExtensionsTests.cs b/tests/UnitTests/Features/FeatureFlagExtensionsTests.cs index d03f0109..503844f8 100644 --- a/tests/UnitTests/Features/FeatureFlagExtensionsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagExtensionsTests.cs @@ -1,4 +1,5 @@ -using PostHog; +#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 ad0c3836..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; @@ -151,8 +152,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 +170,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 +188,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 +396,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}}", @@ -4111,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