Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/versioned-property-matching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"PostHog": patch
---

Honor definition snapshot `property_matching_version` during local feature flag evaluation. Version 2 uses normalized scalar and list-member equality instead of aggregate boolean coercion, including known null properties; missing and other versions retain legacy matching. Empty filter lists keep recursive truthiness in both modes. Matching semantics remain tied to cached definitions across refreshes and 304 responses.
12 changes: 10 additions & 2 deletions src/PostHog/Api/LocalEvaluationApiResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ internal record LocalEvaluationApiResult
/// </summary>
public required IReadOnlyList<LocalFeatureFlag> Flags { get; init; }

/// <summary>
/// Property matching semantics for this definition snapshot. Only version 2 enables explicit matching;
/// missing and other versions retain legacy matching.
/// </summary>
[JsonPropertyName("property_matching_version")]
public int? PropertyMatchingVersion { get; init; }

/// <summary>
/// Mappings of group IDs to group type.
/// </summary>
Expand Down Expand Up @@ -59,14 +66,15 @@ public virtual bool Equals(LocalEvaluationApiResult? other)
return Flags.ListsAreEqual(other.Flags)
&& GroupTypeMapping.DictionariesAreEqual(other.GroupTypeMapping)
&& Cohorts.DictionariesAreEqual(other.Cohorts)
&& MinimalFlagCalledEvents == other.MinimalFlagCalledEvents;
&& MinimalFlagCalledEvents == other.MinimalFlagCalledEvents
&& PropertyMatchingVersion == other.PropertyMatchingVersion;
}

/// <summary>
/// Serves as the default hash function.
/// </summary>
/// <returns>A hash code for the current object.</returns>
public override int GetHashCode() => HashCode.Combine(Flags, GroupTypeMapping, Cohorts, MinimalFlagCalledEvents);
public override int GetHashCode() => HashCode.Combine(Flags, GroupTypeMapping, Cohorts, MinimalFlagCalledEvents, PropertyMatchingVersion);
}

/// <summary>
Expand Down
4 changes: 2 additions & 2 deletions src/PostHog/Features/LocalEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -678,8 +678,8 @@ or ComparisonOperator.Regex

return propertyFilter.Operator switch
{
ComparisonOperator.Exact => value.IsExactMatch(overrideValue),
ComparisonOperator.IsNot => !value.IsExactMatch(overrideValue),
ComparisonOperator.Exact => value.IsExactMatch(overrideValue, LocalEvaluationApiResult.PropertyMatchingVersion),
ComparisonOperator.IsNot => !value.IsExactMatch(overrideValue, LocalEvaluationApiResult.PropertyMatchingVersion),
ComparisonOperator.GreaterThan => value < overrideValue,
ComparisonOperator.GreaterThanOrEquals => value <= overrideValue,
ComparisonOperator.LessThan => value > overrideValue,
Expand Down
32 changes: 31 additions & 1 deletion src/PostHog/Json/PropertyFilterValue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,31 @@ internal bool IsSuffixOfAsciiIgnoreCase(object? other) =>
/// </summary>
/// <param name="overrideValue">The override value.</param>
/// <returns><c>true</c> if the override value is an "exact" match for this value.</returns>
public bool IsExactMatch(object? overrideValue)
public bool IsExactMatch(object? overrideValue) => IsExactMatch(overrideValue, propertyMatchingVersion: null);

internal bool IsExactMatch(object? overrideValue, int? propertyMatchingVersion)
{
if (propertyMatchingVersion == 2)
{
// Empty filters retain recursive legacy truthiness, not empty ANY membership.
if (ListOfStrings is { Count: 0 })
{
return IsTruthyPropertyValue(overrideValue);
}

var comparand = overrideValue is decimal decimalValue
? StringifyDecimal(decimalValue)
: ToInvariantString(overrideValue);
Comment on lines +205 to +207

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Nested decimals normalize differently

Version 2 applies StringifyDecimal only when the property itself is a decimal. A decimal nested in a CLR collection still uses the generic invariant conversion, so [1.00m] becomes "[1.00]" while the wire-equivalent JSON [1.00] becomes "[1.0]". As a result, local exact and is_not evaluations can return different flag values depending on whether the same property arrived as CLR objects or JSON.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/PostHog/Json/PropertyFilterValue.cs
Line: 205-207

Comment:
**Nested decimals normalize differently**

Version 2 applies `StringifyDecimal` only when the property itself is a `decimal`. A decimal nested in a CLR collection still uses the generic invariant conversion, so `[1.00m]` becomes `"[1.00]"` while the wire-equivalent JSON `[1.00]` becomes `"[1.0]"`. As a result, local `exact` and `is_not` evaluations can return different flag values depending on whether the same property arrived as CLR objects or JSON.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

return this switch
{
{ ListOfStrings: { } values } => values.Any(value => UnicodeLowercaseEquals(value, comparand)),
{ StringValue: { } value } => UnicodeLowercaseEquals(value, comparand),
{ BooleanValue: { } value } => UnicodeLowercaseEquals(value ? "true" : "false", comparand),
{ CohortId: { } value } => UnicodeLowercaseEquals(value.ToString(CultureInfo.InvariantCulture), comparand),
_ => false
};
}

if (TryGetBooleanValue(out var booleanValue))
{
return booleanValue == IsTruthyPropertyValue(overrideValue);
Expand All @@ -205,6 +228,13 @@ public bool IsExactMatch(object? overrideValue)
};
}

static string StringifyDecimal(decimal value)
{
// Preserve the wire number's integer/float distinction and normalize its scale and precision like JSON filters.
using var document = JsonDocument.Parse(JsonSerializer.Serialize(value));
return StringifyJsonElement(document.RootElement);
Comment on lines +233 to +235

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Large decimals lose precision

StringifyDecimal reparses the serialized number through StringifyJsonElement, which converts values outside the 64-bit integer ranges to double. Distinct high-precision decimals near decimal.MaxValue can therefore collapse to the same string and incorrectly satisfy version-2 exact matching. The maximum-value test does not catch this because both operands undergo the same lossy conversion.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/PostHog/Json/PropertyFilterValue.cs
Line: 233-235

Comment:
**Large decimals lose precision**

`StringifyDecimal` reparses the serialized number through `StringifyJsonElement`, which converts values outside the 64-bit integer ranges to `double`. Distinct high-precision decimals near `decimal.MaxValue` can therefore collapse to the same string and incorrectly satisfy version-2 `exact` matching. The maximum-value test does not catch this because both operands undergo the same lossy conversion.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}

bool TryGetBooleanValue(out bool value)
{
if (BooleanValue is { } booleanValue)
Expand Down
82 changes: 82 additions & 0 deletions tests/UnitTests/Features/LocalFeatureFlagsLoaderTests.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,94 @@
using System.Net;
using PostHog;
using PostHog.Api;
using PostHog.Features;
using PostHog.Json;
using UnitTests.Fakes;
using static LocalEvaluatorTests.VersionedPropertyMatchingTests;
#if NETCOREAPP3_1
using TestLibrary.Fakes.Polyfills;
#endif

namespace LocalFeatureFlagsLoaderTests;

public class VersionedDefinitionSnapshots
{
[Fact]
public async Task PreservesCachedSnapshotOn304AndFailureAndResetsOnVersionOnlyRefresh()
{
var container = new TestContainer("fake-personal-api-key");
using var httpClient = new HttpClient(container.FakeHttpMessageHandler);
using var apiClient = container.Activate<PostHogApiClient>(httpClient);
await using var loader = container.Activate<LocalFeatureFlagsLoader>(apiClient);
var handler = container.FakeHttpMessageHandler;
handler.AddLocalEvaluationResponseWithETag(DefinitionsJson("false", "exact", 1), "\"legacy\"");
var legacy = await loader.GetFeatureFlagsForLocalEvaluationAsync(CancellationToken.None);
Assert.NotNull(legacy);
Assert.Equal(true, Evaluate(legacy));

handler.AddLocalEvaluationResponseWithETag(DefinitionsJson("false", "exact", 2), "\"explicit\"");
var explicitEvaluator = await loader.RefreshAsync(CancellationToken.None);
Assert.NotNull(explicitEvaluator);
Assert.NotSame(legacy, explicitEvaluator);
Assert.Equal(2, explicitEvaluator.LocalEvaluationApiResult.PropertyMatchingVersion);
Assert.Equal(false, Evaluate(explicitEvaluator));
Assert.Same(explicitEvaluator, await loader.GetFeatureFlagsForLocalEvaluationAsync(CancellationToken.None));
Assert.Equal(true, Evaluate(legacy)); // An in-flight reader keeps its original semantics.

var notModified = handler.AddLocalEvaluationNotModifiedResponse();
Assert.Same(explicitEvaluator, await loader.RefreshAsync(CancellationToken.None));
Assert.Equal("\"explicit\"", notModified.ReceivedRequest!.Headers.IfNoneMatch.Single().Tag);
Assert.Equal(false, Evaluate(explicitEvaluator));

handler.AddResponse(FakeHttpMessageHandlerExtensions.LocalEvaluationUrl, HttpMethod.Get, new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("{\"type\":\"server_error\",\"detail\":\"unavailable\"}", System.Text.Encoding.UTF8, "application/json")
});
Assert.Same(explicitEvaluator, await loader.RefreshAsync(CancellationToken.None));
Assert.Equal(false, Evaluate(explicitEvaluator));

foreach (var version in new int?[] { 1, 2, null })
{
handler.AddLocalEvaluationResponseWithETag(DefinitionsJson("false", "exact", version), "\"refresh\"");
var refreshed = await loader.RefreshAsync(CancellationToken.None);
Assert.NotNull(refreshed);
Assert.Equal(version, refreshed.LocalEvaluationApiResult.PropertyMatchingVersion);
Assert.Equal(version != 2, Evaluate(refreshed));
}
Assert.Equal(false, Evaluate(explicitEvaluator));
}

[Fact]
public async Task PublicSingleBulkAndFullResultsFollowVersionOnlyReloadWithoutRemoteFallback()
{
var container = new TestContainer("fake-personal-api-key");
await using var client = container.Activate<PostHogClient>();
var remote = container.FakeHttpMessageHandler.AddFlagsResponse("""{"flags": {}}""");
var properties = new Dictionary<string, object?> { ["value"] = "banana" };
var options = new FeatureFlagOptions { PersonProperties = properties, OnlyEvaluateLocally = true };
var allOptions = new AllFeatureFlagsOptions { PersonProperties = properties, OnlyEvaluateLocally = true };
foreach (var version in new int?[] { 1, 2, 1, 2, null })
{
container.FakeHttpMessageHandler.AddLocalEvaluationResponseWithETag(DefinitionsJson("false", "exact", version), "\"reload\"");
await client.LoadFeatureFlagsAsync(CancellationToken.None);
#pragma warning disable CS0618 // Verify compatibility of the legacy single-flag API too.
var single = await client.GetFeatureFlagAsync("test", "person", options, CancellationToken.None);
#pragma warning restore CS0618
Assert.NotNull(single);
Assert.Equal(version != 2, single.IsEnabled);
var bulk = await client.GetAllFeatureFlagsAsync("person", allOptions, CancellationToken.None);
Assert.Equal(version != 2, bulk["test"].IsEnabled);
var full = await client.EvaluateFlagsAsync("person", options, CancellationToken.None);
Assert.Contains("test", full.Keys);
Assert.Equal(version != 2, full.IsEnabled("test"));
}
Assert.Empty(remote.ReceivedRequests);
}

static StringOrValue<bool> Evaluate(LocalEvaluator evaluator) => evaluator.EvaluateFeatureFlag(
"test", "person", personProperties: new() { ["value"] = "banana" });
}

public class TheDisposeAsyncMethod
{
const string LocalEvaluationResponse = """
Expand Down
Loading
Loading