Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/unresolved-local-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"PostHog": minor
---

Add `FeatureFlagEvaluations.UnresolvedFlags`, which lists the flags in an `EvaluateFlagsAsync` snapshot that have a loaded local definition but that neither local evaluation nor the remote fallback resolved, each with an `UnresolvedFlagReason`. Log a warning once for each flag whose definition local evaluation can never resolve.

Behavior changes:

- A condition with a filter that local evaluation cannot check and a filter that does not match now resolves locally as no match, instead of falling back to a server request or staying unresolved. A static cohort forces the server fallback only when it is the first filter in its condition that local evaluation cannot check.
- An inactive flag with experience continuity enabled now resolves to `false` locally instead of making a server request.
- A condition that depends on a flag that needs the server, such as a flag with a static cohort, no longer stops local evaluation at that condition, so a later condition can resolve the flag locally.
- `$feature_flag_called` events from snapshot reads report `local_evaluation_inconclusive` instead of `flag_missing` for unresolved flags.
- When definitions load, the SDK logs a warning that names each flag with experience continuity (`experience_continuity`) or with something local evaluation does not support, such as a static cohort, an unknown operator, or a missing or circular dependency (`unsupported_definition`). It logs the warning again only when the definition of that flag changes. A static cohort no longer logs an error on each evaluation.
7 changes: 7 additions & 0 deletions src/PostHog/Exceptions/RequiresServerEvaluationException.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using PostHog.Features;

namespace PostHog.Exceptions
{
Expand Down Expand Up @@ -35,5 +36,11 @@ public RequiresServerEvaluationException(string message, Exception innerExceptio
: base(message, innerException)
{
}

/// <summary>
/// Why local evaluation could not resolve the flag. Only <see cref="UnresolvedFlagReason.ExperienceContinuity"/>
/// outranks it, and that reason is found before any condition can raise this exception.
/// </summary>
internal UnresolvedFlagReason Reason { get; } = UnresolvedFlagReason.UnsupportedDefinition;
}
}
5 changes: 5 additions & 0 deletions src/PostHog/Features/FeatureFlagError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ internal static class FeatureFlagError
/// </summary>
public const string FlagMissing = "flag_missing";

/// <summary>
/// Flag has a loaded local definition but local evaluation could not resolve it
/// </summary>
public const string LocalEvaluationInconclusive = "local_evaluation_inconclusive";

/// <summary>
/// Rate/quota limit exceeded (when quotaLimited contains "feature_flags")
/// </summary>
Expand Down
26 changes: 24 additions & 2 deletions src/PostHog/Features/FeatureFlagEvaluations.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using System.Text.Json;
using static PostHog.Library.Ensure;

Expand All @@ -23,6 +24,9 @@ public sealed class FeatureFlagEvaluations
readonly GroupCollection? _groups;
readonly IReadOnlyCollection<string> _errors;

static readonly IReadOnlyDictionary<string, UnresolvedFlagReason> NoUnresolvedFlags =
new ReadOnlyDictionary<string, UnresolvedFlagReason>(new Dictionary<string, UnresolvedFlagReason>());

internal FeatureFlagEvaluations(
IFeatureFlagEvaluationsHost host,
string distinctId,
Expand All @@ -32,7 +36,8 @@ internal FeatureFlagEvaluations(
long? flagDefinitionsLoadedAt,
GroupCollection? groups,
IReadOnlyCollection<string>? errors,
ConcurrentDictionary<string, byte>? accessed = null)
ConcurrentDictionary<string, byte>? accessed = null,
Dictionary<string, UnresolvedFlagReason>? unresolvedFlags = null)
{
_host = NotNull(host);
DistinctId = distinctId ?? string.Empty;
Expand All @@ -43,6 +48,9 @@ internal FeatureFlagEvaluations(
_groups = groups;
_errors = errors ?? Array.Empty<string>();
_accessed = accessed ?? new ConcurrentDictionary<string, byte>(StringComparer.Ordinal);
UnresolvedFlags = unresolvedFlags is { Count: > 0 }
? new ReadOnlyDictionary<string, UnresolvedFlagReason>(unresolvedFlags)
: NoUnresolvedFlags;
}

/// <summary>
Expand Down Expand Up @@ -74,6 +82,18 @@ internal FeatureFlagEvaluations(
/// </summary>
public IReadOnlyCollection<string> Keys => _records.Keys;

/// <summary>
/// The flags that have a loaded local definition but that neither local evaluation nor a remote fallback
/// resolved, mapped to the reason local evaluation could not resolve them. These flags are absent from
/// <see cref="Keys"/>, and their accessors return the missing-flag values. Reading this property does not
/// record access and does not fire <c>$feature_flag_called</c>.
/// </summary>
/// <remarks>
/// Always empty on snapshots returned by <see cref="Only(System.Collections.Generic.IEnumerable{string})"/>
/// and <see cref="OnlyAccessed"/>.
/// </remarks>
public IReadOnlyDictionary<string, UnresolvedFlagReason> UnresolvedFlags { get; }

/// <summary>
/// Returns <c>true</c> when the named flag is present in the snapshot and enabled. Records
/// access on the snapshot and fires <c>$feature_flag_called</c> on first access for a given
Expand Down Expand Up @@ -238,11 +258,13 @@ internal static FeatureFlagEvaluations Empty(
requestId: RequestId,
evaluatedAt: EvaluatedAt,
flagDefinitionsLoadedAt: FlagDefinitionsLoadedAt,
errors: _errors);
errors: _errors,
unresolved: UnresolvedFlags.ContainsKey(keyChecked));

return record;
}

// Filtered snapshots carry no unresolved flags.
FeatureFlagEvaluations CloneWith(Dictionary<string, EvaluatedFlagRecord> records)
=> new(
_host,
Expand Down
7 changes: 5 additions & 2 deletions src/PostHog/Features/IFeatureFlagEvaluationsHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ internal interface IFeatureFlagEvaluationsHost
{
/// <summary>
/// Fires a <c>$feature_flag_called</c> event for the given access, deduplicated against the
/// per-distinct-id cache that the legacy single-flag path also writes to.
/// per-distinct-id cache that the legacy single-flag path also writes to. <paramref name="unresolved"/>
/// is <c>true</c> when the key is in <see cref="FeatureFlagEvaluations.UnresolvedFlags"/>, so a missing
/// record reports <c>local_evaluation_inconclusive</c> instead of <c>flag_missing</c>.
/// </summary>
void CaptureFeatureFlagCalled(
string distinctId,
Expand All @@ -19,7 +21,8 @@ void CaptureFeatureFlagCalled(
string? requestId,
long? evaluatedAt,
long? flagDefinitionsLoadedAt,
IReadOnlyCollection<string> errors);
IReadOnlyCollection<string> errors,
bool unresolved);

/// <summary>
/// Logs a warning from <see cref="FeatureFlagEvaluations.OnlyAccessed"/> or
Expand Down
Loading
Loading