Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e0a08c9
Add eval key and rollout percentage to FeatureToggleEvaluation
caitlynstocker Mar 23, 2026
549f5fb
Add murmur hash package
caitlynstocker Mar 23, 2026
785e444
Add rollout evaluation logic
caitlynstocker Mar 23, 2026
9aef084
Drafting tests
caitlynstocker Mar 24, 2026
ceb14bc
Aahah these are not the tests we need! Saving anyway
caitlynstocker Mar 24, 2026
9133f77
Notes on tests
caitlynstocker Mar 25, 2026
c241eba
Update API endpoint URI
liamhughes Mar 25, 2026
4dc8e58
Change FeatureToggleEvaluation to internal record
liamhughes Mar 25, 2026
99c165a
Remove `Name`
liamhughes Mar 25, 2026
51f91f7
Move `IsEnabled`
liamhughes Mar 25, 2026
4cb0733
Rename `RolloutPercentage` and make required constructor argument
liamhughes Mar 25, 2026
8bcc64f
Throw if client evaluation values are not provided
liamhughes Mar 25, 2026
aa94272
Remove unused test
caitlynstocker Mar 25, 2026
ed05e0c
dotnet format
liamhughes Mar 25, 2026
40c5663
Remove comment
caitlynstocker Mar 25, 2026
a47b127
Return ParseError with defaultValue rather than throw on missing fields
liamhughes Mar 26, 2026
614e38f
dotnet format
liamhughes Mar 26, 2026
964a3cf
Fix endian side on get normalized number
caitlynstocker Mar 26, 2026
2f1482d
Fix null checking for optional key and segments
caitlynstocker Mar 26, 2026
842a037
Use shared MurmurHash to calculate rollout
caitlynstocker Mar 27, 2026
a1d0119
Undo potentially breaking internal and record usage
liamhughes Mar 27, 2026
ca67a04
Fix imports
caitlynstocker Mar 27, 2026
27dfc2a
Update to latest specification
caitlynstocker Mar 27, 2026
7faef02
Add unit tests back in
caitlynstocker Mar 30, 2026
f49164e
Fix test whitespace
caitlynstocker Mar 30, 2026
2f247b8
make rollout tests more specific
caitlynstocker Mar 30, 2026
365fa0c
Fix test names
caitlynstocker Mar 30, 2026
c5a3be5
Bin null operator
caitlynstocker Mar 30, 2026
0eb73d4
Go back to one murmur hash algo per normalized number
caitlynstocker Mar 31, 2026
48b98de
Small fix to test
caitlynstocker Mar 31, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

<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 👍

</None>
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public Server()
{
_server = WireMockServer.Start();
_server
.Given(Request.Create().WithPath("/api/featuretoggles/v3/").UsingGet())
.Given(Request.Create().WithPath("/api/toggles/evaluations/v3/").UsingGet())
.RespondWith(Response.Create()
.WithTransformer()
.WithCallback(req =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public async Task WhenInitialized_ProvidesRetrievedEvaluationContext()
byte[] contentHash = [0x01, 0x02, 0x03, 0x04];

var client = new MockOctopusFeatureClient(new FeatureToggles(
[new FeatureToggleEvaluation("Test Feature", "test-feature", true, [])],
[new FeatureToggleEvaluation("test-feature", true, "evaluation-key", [], 100)],
contentHash));

var provider = new OctopusFeatureContextProvider(configuration, client, NullLogger.Instance);
Expand All @@ -70,7 +70,7 @@ public async Task WhenInitialized_RefreshesCacheAfterCacheDurationExpires()
byte[] contentHash = [0x01, 0x02, 0x03, 0x04];

var client = new MockOctopusFeatureClient(new FeatureToggles(
[new FeatureToggleEvaluation("Test Feature", "test-feature", true, [])],
[new FeatureToggleEvaluation("test-feature", true, "evaluation-key", [], 100)],
contentHash));

// Initialize the provider
Expand All @@ -85,7 +85,7 @@ [new FeatureToggleEvaluation("Test Feature", "test-feature", true, [])],

// Simulate a change in the available feature toggles
client.ChangeToggles(new FeatureToggles(
[new FeatureToggleEvaluation("Test Feature", "test-feature", false, [])],
[new FeatureToggleEvaluation("test-feature", false, "evaluation-key", [], 100)],
[0x01, 0x02, 0x03, 0x05]));

// Wait for the cache to expire
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using System.Text;
using FluentAssertions;
using FluentAssertions.Execution;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.VisualBasic;
using Murmur;
using OpenFeature.Constant;
using OpenFeature.Model;

Expand All @@ -12,7 +15,7 @@ public class OctopusFeatureContextTests
public void EvaluatesToTrue_IfFeatureIsContainedWithinTheSet_AndFeatureIsEnabled()
{
var featureToggles = new FeatureToggles([
new FeatureToggleEvaluation("testfeature", "test-feature", true, [])
new FeatureToggleEvaluation("test-feature", true, "evaluation-key", [], 100)
], []);

var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance);
Expand All @@ -26,7 +29,7 @@ public void EvaluatesToTrue_IfFeatureIsContainedWithinTheSet_AndFeatureIsEnabled
public void WhenEvaluatedWithCasingDifferences_EvaluationIsInsensitiveToCase()
{
var featureToggles = new FeatureToggles([
new FeatureToggleEvaluation("testfeature", "test-feature", true, [])
new FeatureToggleEvaluation("test-feature", true, "evaluation-key", [], 100)
], []);

var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance);
Expand All @@ -40,7 +43,7 @@ public void WhenEvaluatedWithCasingDifferences_EvaluationIsInsensitiveToCase()
public void EvaluatesToFalse_IfFeatureIsContainedWithinTheSet_AndFeatureIsNotEnabled()
{
var featureToggles = new FeatureToggles([
new FeatureToggleEvaluation("testfeature", "test-feature", false, [])
new FeatureToggleEvaluation("test-feature", false, "evaluation-key", [], 100)
], []);

var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance);
Expand All @@ -54,7 +57,7 @@ public void EvaluatesToFalse_IfFeatureIsContainedWithinTheSet_AndFeatureIsNotEna
public void GivenAFlagKeyThatIsNotASlug_ReturnsFlagNotFound_AndEvaluatesToDefaultValue()
{
var featureToggles = new FeatureToggles([
new FeatureToggleEvaluation("This is clearly not a slug!", "this-is-clearly-not-a-slug", true, [])
new FeatureToggleEvaluation("this-is-clearly-not-a-slug", true, "evaluation-key", [], 100)
], []);

var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance);
Expand All @@ -69,7 +72,7 @@ public void GivenAFlagKeyThatIsNotASlug_ReturnsFlagNotFound_AndEvaluatesToDefaul
public void EvaluatesToDefaultValue_IfFeatureIsNotContainedWithinSet()
{
var featureToggles = new FeatureToggles([
new FeatureToggleEvaluation("testfeature", "testfeature", true, [])
new FeatureToggleEvaluation("testfeature", true, "evaluation-key", [], 100)
], []);

var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance);
Expand All @@ -80,14 +83,17 @@ public void EvaluatesToDefaultValue_IfFeatureIsNotContainedWithinSet()
result.Value.Should().BeTrue();
}

EvaluationContext BuildContext(IEnumerable<(string key, string value)> values)
EvaluationContext BuildContext(IEnumerable<(string key, string value)> values, string? targetingKey = null)
{
var builder = EvaluationContext.Builder();
foreach (var (key, value) in values)
{
builder.Set(key, value);
}

if (targetingKey != null)
{
builder.SetTargetingKey(targetingKey);
}
return builder.Build();
}

Expand All @@ -96,7 +102,7 @@ public void
WhenAFeatureIsToggledOnForASpecificSegment_EvaluatesToTrueWhenSegmentIsSpecified()
{
var featureToggles = new FeatureToggles([
new FeatureToggleEvaluation("testfeature", "testfeature", true, [new("license", "trial")])
new FeatureToggleEvaluation("testfeature", true, "evaluation-key", [new("license", "trial")], 100)
], []);

var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance);
Expand All @@ -112,7 +118,7 @@ public void
WhenFeatureIsNotToggledOnForSpecificSegments_EvaluatesToTrueRegardlessOfSegmentSpecified()
{
var featureToggles = new FeatureToggles([
new FeatureToggleEvaluation("testfeature", "testfeature", true, [])
new FeatureToggleEvaluation("testfeature", true, "evaluation-key", [], 100)
], []);

var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance);
Expand All @@ -126,11 +132,14 @@ public void
public void WhenAFeatureIsToggledOnForMultipleSegments_EvaluatesCorrectly()
{
var featureToggles = new FeatureToggles([
new FeatureToggleEvaluation("testfeature", "testfeature", true, [
new("license", "trial"),
new("region", "au"),
new("region", "us"),
])
new FeatureToggleEvaluation(
"testfeature", true, "evaluation-key", [
new("license", "trial"),
new("region", "au"),
new("region", "us"),
],
100
)
], []);

var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance);
Expand Down Expand Up @@ -169,7 +178,7 @@ public void
WhenAFeatureIsToggledOnForASpecificSegment_ToleratesNullValuesInContext()
{
var featureToggles = new FeatureToggles([
new FeatureToggleEvaluation("testfeature", "testfeature", true, [new("license", "trial")])
new FeatureToggleEvaluation("testfeature", true, "evaluation-key", [new("license", "trial")], 100)
], []);

var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance);
Expand Down
3 changes: 3 additions & 0 deletions src/Octopus.OpenFeature.Provider/IsExternalInit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace System.Runtime.CompilerServices;

internal static class IsExternalInit;
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="murmurhash" Version="1.0.3" />
<PackageReference Include="OpenFeature" Version="2.11.1" />
<PackageReference Include="System.Text.Json" Version="9.0.12" />
</ItemGroup>
Expand Down
21 changes: 9 additions & 12 deletions src/Octopus.OpenFeature.Provider/OctopusFeatureClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,20 @@

namespace Octopus.OpenFeature.Provider;

public class FeatureToggles(FeatureToggleEvaluation[] evaluations, byte[] contentHash)
internal class FeatureToggles(FeatureToggleEvaluation[] evaluations, byte[] contentHash)
{
public FeatureToggleEvaluation[] Evaluations { get; } = evaluations;

public byte[] ContentHash { get; } = contentHash;
}

public class FeatureToggleEvaluation(string name, string slug, bool isEnabled, KeyValuePair<string, string>[] segments)
{
public string Name { get; } = name;

public string Slug { get; } = slug;

public bool IsEnabled { get; } = isEnabled;

public KeyValuePair<string, string>[] Segments { get; } = segments;
}
internal record FeatureToggleEvaluation(
string Slug,
bool IsEnabled,
string? EvaluationKey,
KeyValuePair<string, string>[]? Segments,
int? ClientRolloutPercentage
);

interface IOctopusFeatureClient
{
Expand Down Expand Up @@ -97,7 +94,7 @@ class FeatureCheck(byte[] contentHash)

client.DefaultRequestHeaders.Add("Authorization", $"Bearer {configuration.ClientIdentifier}");

var response = await ExecuteWithRetry(async ct => await client.GetAsync("api/featuretoggles/v3/", ct), cancellationToken);
var response = await ExecuteWithRetry(async ct => await client.GetAsync("api/toggles/evaluations/v3/", ct), cancellationToken);

if (response is null or { StatusCode: HttpStatusCode.NotFound })
{
Expand Down
60 changes: 57 additions & 3 deletions src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using System.Text.RegularExpressions;
using System.Buffers.Binary;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Murmur;
using OpenFeature.Constant;
using OpenFeature.Model;

Expand Down Expand Up @@ -41,6 +44,12 @@
"The slug provided did not match any of your Octopus Feature Toggles. Please double check your slug and try again.");
}

if (MissingRequiredPropertiesForClientSideEvaluation(feature))
{
return new ResolutionDetails<bool>(slug, defaultValue, ErrorType.ParseError,
$"Feature toggle {slug} is missing necessary information for client-side evaluation.");
}

return new ResolutionDetails<bool>(slug, Evaluate(feature, context));
}

Expand All @@ -63,7 +72,52 @@

bool Evaluate(FeatureToggleEvaluation evaluation, EvaluationContext? context = null)
{
return evaluation.IsEnabled &&
(evaluation.Segments.Length == 0 || MatchesSegment(context, evaluation.Segments));
if (!evaluation.IsEnabled)
{
return false;
}

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.

{
if (evaluation.ClientRolloutPercentage < 100)
{
return false;
}
}
else
{
if (GetNormalizedNumber(evaluation.EvaluationKey, targetingKey) > evaluation.ClientRolloutPercentage)

Check warning on line 90 in src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs

View workflow job for this annotation

GitHub Actions / test-build-and-publish

Possible null reference argument for parameter 'evaluationKey' in 'int OctopusFeatureContext.GetNormalizedNumber(string evaluationKey, string targetingKey)'.

Check warning on line 90 in src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs

View workflow job for this annotation

GitHub Actions / test-build-and-publish

Possible null reference argument for parameter 'evaluationKey' in 'int OctopusFeatureContext.GetNormalizedNumber(string evaluationKey, string targetingKey)'.

Check warning on line 90 in src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs

View workflow job for this annotation

GitHub Actions / test-build-and-publish

Possible null reference argument for parameter 'evaluationKey' in 'int OctopusFeatureContext.GetNormalizedNumber(string evaluationKey, string targetingKey)'.

Check warning on line 90 in src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs

View workflow job for this annotation

GitHub Actions / test-build-and-publish

Possible null reference argument for parameter 'evaluationKey' in 'int OctopusFeatureContext.GetNormalizedNumber(string evaluationKey, string targetingKey)'.
{
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.

return evaluation.Segments.Length == 0 || MatchesSegment(context, evaluation.Segments);

Check warning on line 96 in src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs

View workflow job for this annotation

GitHub Actions / test-build-and-publish

Dereference of a possibly null reference.

Check warning on line 96 in src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs

View workflow job for this annotation

GitHub Actions / test-build-and-publish

Dereference of a possibly null reference.

Check warning on line 96 in src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs

View workflow job for this annotation

GitHub Actions / test-build-and-publish

Dereference of a possibly null reference.

Check warning on line 96 in src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs

View workflow job for this annotation

GitHub Actions / test-build-and-publish

Dereference of a possibly null reference.
}

/// <summary>
/// Computes a deterministic integer bucket in the inclusive range 1–100 for the given evaluation and targeting keys.
/// </summary>
private int GetNormalizedNumber(string evaluationKey, string targetingKey)
{
var bytes = Encoding.UTF8.GetBytes(string.Concat(evaluationKey, ":", targetingKey));

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

// Explicitly little-endian to ensure consistent int values across all client libraries.
var value = BinaryPrimitives.ReadUInt32LittleEndian(hash);
return (int)(value % 100 + 1);
}


private static bool MissingRequiredPropertiesForClientSideEvaluation(FeatureToggleEvaluation evaluation)
{
if (!evaluation.IsEnabled)
{
return false;
}

return evaluation.ClientRolloutPercentage is null || evaluation.EvaluationKey is null || evaluation.Segments is null;
}
}
Loading