Skip to content

feat: Add fractional evaluation for segments#45

Merged
caitlynstocker merged 30 commits intomainfrom
cat/DEVEX-79/add-fractional-evaluation
Mar 31, 2026
Merged

feat: Add fractional evaluation for segments#45
caitlynstocker merged 30 commits intomainfrom
cat/DEVEX-79/add-fractional-evaluation

Conversation

@caitlynstocker
Copy link
Copy Markdown
Contributor

@caitlynstocker caitlynstocker commented Mar 25, 2026

👥 This PR is a team effort combo move by @liamhughes and myself.

Background 🌇

OctoToggle allows customers to rollout to a given percentage of allowed Tenants. To do this, we compare the rollout percentage to an integer from 0 to 100 generated from each tenant's ID and evaluation key.

Our OpenFeature provider implementations allow customers to rollout to a list of allowed Segments. We would now like to add the functionality to rollout to a given percentage of allowed Segments following the same logic used for Tenant rollout in OctoToggle.

As Segment rollout is happening in the provider, rather than OctoToggle, it is important that each provider gives a consistent toggle status for each Segment.

What's this? 🌵

The primary purpose of this PR is to enable us to rollout to a given percentage of Segments.

To set up for this we updated the FeatureToggleEvaluation class to contain an EvaluationKey field and ClientRolloutPercentage field.

We are determining whether a Segment is within the rollout percentage by:
〰️ Creating a string from the EvaluationKey and the evaluation context's TargetingKey
〰️ Computing a hash from that string
〰️ Converting the hash to an unsigned integer
〰️ Converting the integer to a value between 1 and 100
〰️ Comparing that value to the rollout percentage specified on the feature toggle.

This follows the same logic that we use for tenant rollout evaluation in OctoToggle.

If a segment is within the rollout percentage, we continue on with the existing logic which compares it against the segment key-values required by the toggle.

What else is in here? 🎈

As I was adding Segment rollout, Liam was also adding a new evaluations endpoint to OctoToggle. This PR integrates those changes:
〰️ We now call /api/toggles/evaluations/v3/ in place of /api/toggles/evaluations/v3/
〰️ The FeatureToggleEvaluation class has been updated to match the new endpoint's response shape. I.e. the Name field is removed, the EvaluationKey and ClientRolloutPercentage fields are added, and a nullable Segment field can now be handled.

How to review? 🔍

☑️ Apologies that this PR has become a bit tricky to follow! We thought that adding the two sets of changes together would be simpler than rebasing my changes onto Liam's.
💬 Check out the comments on the code.
🧪You can run the accompanying tests by pointing the specifications submodule to the latest commit on this PR.

  • TODO - Update spec submodule to latest once test fixtures are completed and merged

Part of DEVEX-79
Part of DEVEX-105

<ItemGroup>
<None Include="..\..\specification\Fixtures\*.json" Link="Fixtures\%(RecursiveDir)%(Filename)%(Extension)">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
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.

I was having trouble running the tests locally, but that may have been because Caitlyn and I were editing the submodule files at the same time?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fine with me 👍

@caitlynstocker caitlynstocker changed the title Cat/devex 79/add fractional evaluation feat: Add fractional evaluation for segments Mar 25, 2026
@caitlynstocker caitlynstocker requested a review from Copilot March 25, 2026 07:06
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds client-side fractional rollout evaluation for feature toggles (based on targeting key hashing) and updates the toggle evaluations API/fixtures accordingly.

Changes:

  • Implement fractional (percentage-based) client-side evaluation using MurmurHash-derived bucketing.
  • Update feature toggle evaluation models and switch API endpoint to /api/toggles/evaluations/v3/.
  • Update specification fixtures/submodule handling to ensure the latest fixtures are used during tests.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs Adds rollout percentage + targeting key hashing logic for evaluation.
src/Octopus.OpenFeature.Provider/OctopusFeatureClient.cs Updates evaluation DTOs and switches to new evaluations API route.
src/Octopus.OpenFeature.Provider/Octopus.OpenFeature.Provider.csproj Adds MurmurHash dependency for rollout bucketing.
src/Octopus.OpenFeature.Provider/IsExternalInit.cs Adds init-only support shim for records on older target frameworks.
src/Octopus.OpenFeature.Provider.Tests/OctopusFeatureContextTests.cs Updates unit tests to new evaluation DTO shape.
src/Octopus.OpenFeature.Provider.Tests/OctopusFeatureContextProviderTests.cs Updates provider tests to new evaluation DTO shape.
src/Octopus.OpenFeature.Provider.SpecificationTests/Server.cs Updates WireMock route to the new evaluations endpoint.
src/Octopus.OpenFeature.Provider.SpecificationTests/Octopus.OpenFeature.Provider.SpecificationTests.csproj Forces fixture copy for spec tests to always use latest JSON fixtures.
specification Bumps spec submodule pointer.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}

/// <summary>
/// Produces a normalized number between 1 and 100 for a given TargetingKey, with less than 1% variance
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

The docstring is inaccurate/misleading as written: the method returns an integer bucket in the inclusive range 1..100, and modulo-based mapping introduces a very small bias (not 'less than 1% variance'). Update the comment to describe the bucketing approach and range precisely (and, if needed, note that any bias is negligible).

Suggested change
/// Produces a normalized number between 1 and 100 for a given TargetingKey, with less than 1% variance
/// Computes a deterministic integer bucket in the inclusive range 1–100 for the given evaluation and targeting keys.
/// The bucket is derived by hashing "evaluationKey:targetingKey" with Murmur32 and mapping the result via modulo 100;
/// this yields an approximately uniform distribution across buckets with a very small, negligible bias.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

What do you think, @dylanlerch? I'm copying your comment on the same function in the OctoToggle code.

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.

I think it's probably a more accurate description of it.

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.

The first line probably will do though.

Comment on lines +104 to +105
using var algorithm = MurmurHash.Create32();
var hash = algorithm.ComputeHash(bytes);
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

Creating and disposing a hash algorithm instance on every evaluation can add avoidable overhead if flags are evaluated frequently. Consider using a static/thread-local approach (if the MurmurHash implementation is not thread-safe) or a reusable API provided by the library to compute the 32-bit hash without per-call allocations.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Is this really a problem? I don't think that creating the hash algorithm instance will take a lot of time, right?

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.

Quick poke around the Murmur code and it doesn't look like it allocates a lot, but also it couldn't hurt to make it a ThreadLocal?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@liamhughes Does this look like what you would expect?
842a037

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.

Yep.

Don't think you need the null suppression !?


using var algorithm = MurmurHash.Create32();
var hash = algorithm.ComputeHash(bytes);
var value = BitConverter.ToUInt32(hash, 0);
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

BitConverter.ToUInt32 is endianness-dependent. While most current .NET runtimes are little-endian, using an explicit endianness read (e.g., via System.Buffers.Binary.BinaryPrimitives) makes rollout bucketing deterministic across any platform/runtime and avoids hard-to-debug rollout inconsistencies.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great point. I've changed this to do an explicitly little-endian read. We should repeat this across all the client libraries.

return false; // return false if hash number is larger than rollout percentage
}
}

Copy link
Copy Markdown
Contributor Author

@caitlynstocker caitlynstocker Mar 26, 2026

Choose a reason for hiding this comment

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

I considered placing the rollout percentage evaluation logic below the call to MatchesSegment, but I thought that MatchesSegment might actually be the most time intensive of the two 👇

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.

We may end up switching this around once we add reasons. For example, we need the reason that it does (or doesn't) get a toggle to be consistent between all libraries. For now we don't see this, so it's fine.

@caitlynstocker caitlynstocker force-pushed the cat/DEVEX-79/add-fractional-evaluation branch 2 times, most recently from 41ca6f4 to 964a3cf Compare March 26, 2026 02:25
{
return false;
}
if (evaluation.EvaluationKey == null)
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.

This is checked in MissingRequiredPropertiesForClientSideEvaluation, but I suppose the compiler might not be smart enough to track that through to here?

Comment on lines +104 to +105
using var algorithm = MurmurHash.Create32();
var hash = algorithm.ComputeHash(bytes);
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.

Quick poke around the Murmur code and it doesn't look like it allocates a lot, but also it couldn't hurt to make it a ThreadLocal?

@caitlynstocker caitlynstocker changed the title feat: Add fractional evaluation for segments feat!: Add fractional evaluation for segments Mar 27, 2026
}

var targetingKey = context?.TargetingKey;
if (targetingKey == null || targetingKey == "")
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.

string.IsNullOrEmpty?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I swapped that out for this as I was getting a null reference warning on targetingKey in the else block below.

@caitlynstocker caitlynstocker force-pushed the cat/DEVEX-79/add-fractional-evaluation branch from 3ce6f2a to 1f16d35 Compare March 30, 2026 02:14
@caitlynstocker caitlynstocker force-pushed the cat/DEVEX-79/add-fractional-evaluation branch from 1f16d35 to 7faef02 Compare March 30, 2026 02:17
partial class OctopusFeatureContext(FeatureToggles toggles, ILoggerFactory loggerFactory)
{
public byte[] ContentHash => toggles.ContentHash;
static readonly ThreadLocal<HashAlgorithm> RolloutHashAlgorithm = new(() => MurmurHash.Create32());
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.

I'm curious about this. Is there any more information on why this is necessary? The need for the ! in RolloutHashAlgorithm.Value!.ComputeHash(bytes); makes me a little suspect.

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.

FWIW, I don't think the null forgiving operator ! is actually required.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Liam's right. I'll bin the ! 🚮
image

@caitlynstocker caitlynstocker changed the title feat!: Add fractional evaluation for segments feat: Add fractional evaluation for segments Mar 31, 2026
public void WhenTargetingKeyFallsWithinRolloutPercentage_AndSegmentValueDoesNotMatchRequiredSegment_EvaluatesToFalse()
{
var featureToggles = new FeatureToggles([
new FeatureToggleEvaluation("test-feature", true, "evaluation-key", [new("license", "enterprise")], 13)
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.

Could go with something higher (like 99) as the percentage here to make sure it is really because of the segment (but still also evaluating the percentage).

Copy link
Copy Markdown
Contributor

@dylanlerch dylanlerch left a comment

Choose a reason for hiding this comment

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

Looks good. I've tested this against live toggle service and all works as expected.

@caitlynstocker caitlynstocker merged commit ce08dee into main Mar 31, 2026
7 checks passed
@caitlynstocker caitlynstocker deleted the cat/DEVEX-79/add-fractional-evaluation branch March 31, 2026 05:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants