Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updating SDK to 0.5.2 spec #23

Merged
merged 19 commits into from
Jan 29, 2024
Merged

Updating SDK to 0.5.2 spec #23

merged 19 commits into from
Jan 29, 2024

Conversation

Norhaven
Copy link
Collaborator

This implements the C# SDK to be compliant with the 0.5.2 version of the GrowthBook SDK spec. Things that are a part of this include:

  • New unit test structure for easier use of the standard cases.json tests (all tests are currently passing).
  • New feature repository/cache with support for Server Sent Events, background retrieval, and encrypted features.
  • Extensive logging which can be enabled by log level (or overridden with a custom implementation).
  • More robust error handling.

Please let me know if you have any questions.

Thanks!

@darklordzw
Copy link
Collaborator

@Norhaven, looks great, thanks for contributing such as substantial update! My one question is why you changed all of the double's to floats for things experiment weights? I'm not necessarily opposed to it, but I'm curious as to the reason.

@darklordzw
Copy link
Collaborator

@Mastermindzh, @pascal89 any thoughts on this PR before I merge this in?

@Mastermindzh
Copy link
Contributor

@darklordzw I'll have a look tomorrow morning (CEST) and let you know 😊

@Norhaven
Copy link
Collaborator Author

Norhaven commented Jan 22, 2024

@darklordzw Thanks for taking a look! The use of floats instead of doubles was primarily because it seemed like the typical floating point values needed by things like weights didn't require more than ~7 digits of precision to be effective, but I'm open to either way if there's a preference for doubles in particular. Thoughts?

@pascal89
Copy link
Contributor

@darklordzw I'll schedule a review moment for tomorrow

@darklordzw
Copy link
Collaborator

@Norhaven, gotcha. My original implementation used floats, but I ended up switching to doubles to be consistent with the other Growthbook sdks (this was originally a port of the Python version). I don't have a strong opinion either way, but I lean towards staying with doubles to stay consistent with what's out there already.

@Norhaven
Copy link
Collaborator Author

@darklordzw Sure, makes sense to me. I've replaced the float usages with doubles now. The precision change caused a minor rounding difference in the GetEqualWeights test that differs from the spec, so I've called that out in there with a little more info and adjusted it slightly. Let me know if you have more questions on anything, thanks!

Copy link
Contributor

@Mastermindzh Mastermindzh left a comment

Choose a reason for hiding this comment

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

Overall it looks good, I made several recommendations though.

continue;
}

json = json.Substring(5).Trim();
Copy link
Contributor

Choose a reason for hiding this comment

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

I feel this statement should be inside of the if statement.
Now you'll trim 5 characters no matter the result of the statement :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hmm... not sure I follow, but added some more comments to hopefully clarify the reasoning behind doing it this way. Let me know if I missed the point. :)

{
public class HttpClientFactory : IHttpClientFactory
{
public static class ConfiguredClients
Copy link
Contributor

Choose a reason for hiding this comment

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

I would extract this and move it to a separate file since the scope isn't limited to just this file.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good idea, moved it out now.


private readonly TestHttpClientFactory _httpClientFactory;
private readonly GrowthBookConfigurationOptions _config;
private readonly FeatureRefreshWorker _worker;
Copy link
Contributor

Choose a reason for hiding this comment

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

use the new keyword to get rid of the "hiding" errors

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Whoops, forgot to remove that from the base class. Thanks!

{
private const string ApiKey = "<AN API KEY GOES HERE>";

[Fact(Skip = "This is here as a shortcut for the purpose of debugging this SDK against the actual API and should not be used otherwise")]
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't like including these things t.b.h

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, I was a little on the fence about that as well. Removed.


public class LoadFeaturesTests
{
private const string ApiKey = "<AN API KEY GOES HERE>";
Copy link
Contributor

Choose a reason for hiding this comment

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

If we're including this file I'd recommend using IConfiguration for these things:
See, this for an example.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

File is removed now.

@@ -0,0 +1,6 @@
using System.Runtime.CompilerServices;
Copy link
Contributor

Choose a reason for hiding this comment

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

I suggest removing this file altogether and stick with the portable configuration in csproj:

   <ItemGroup>
    <InternalsVisibleTo Include="GrowthBook.Tests" />
  </ItemGroup>

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good idea, forgot about that. Done.

/// </remarks>
/// <param name="value">The string to verify.</param>
/// <returns>True if null, empty, or whitespace, false otherwise.</returns>
public static bool IsMissing(this string value) => string.IsNullOrWhiteSpace(value);
Copy link
Contributor

Choose a reason for hiding this comment

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

To me, this obfuscated what we're doing when calling IsMissing instead of providing clarity.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ahh, gotcha. Was trying to remove some of the boilerplate and focus more on the semantics of what the caller is trying to achieve, but looks like it missed the mark. Will take another look at that, thanks.

/// <summary>
/// Represents possible values for the source of a feature result.
/// </summary>
public static class SourceIs
Copy link
Contributor

Choose a reason for hiding this comment

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

I would recommend removing since it isn't used.
Also, I think you meant "SourceIDs"

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's actually used when calling GetFeatureResult and was intended to be a more fluent-property kind of thing so it reads FeatureResult.SourceIs.DefaultValue or similar. Do you feel that it's more confusing that way?

Copy link
Collaborator Author

@Norhaven Norhaven Jan 25, 2024

Choose a reason for hiding this comment

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

Actually, the more I think about it, we don't currently have much of a fluent-syntax usage in this SDK, would add to the confusion. Renamed.

@@ -2,7 +2,7 @@

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Authors>Ben Whatley</Authors>
<Authors>Ben Whatley,Norhaven</Authors>
Copy link
Contributor

Choose a reason for hiding this comment

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

I would usually only put recognized contributors of the Github repository here

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fair enough, removed. :)

@@ -11,11 +11,17 @@
<RepositoryUrl>https://github.com/growthbook/growthbook-csharp.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageTags>GrowthBook,A/B test,feature toggle</PackageTags>
Copy link
Contributor

Choose a reason for hiding this comment

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

Would recommend to include "flag"

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Added now.

@Norhaven
Copy link
Collaborator Author

@Mastermindzh Thanks for the review, updated the PR and had a few questions.

Copy link
Contributor

@pascal89 pascal89 left a comment

Choose a reason for hiding this comment

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

I didn't review the test cases due to a lack of time.
I really like the infrastructure for keeping the features up-to-date (with the server side events) 👍 👍. It would be nice to have some usage documentation in the README.md, as any local implementation of this package will probably be impacted because of these changes. Also, I'm not sure how to work with attributes. We are using this package in a web API and for each HTTP request I need to set the attributes in order to target features to a certain audience (user or user role). Could you shed some light on this?, preferably in the form of documentation.

}
}

public async Task RefreshWith(IDictionary<string, Feature> features, CancellationToken? cancellationToken = null)
Copy link
Contributor

Choose a reason for hiding this comment

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

This doesn't look like an async function, or am I missing something?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I made that call return a Task in the IGrowthBookFeatureCache interface that this implements because, assuming usage is backend in a distributed environment (e.g. cloud), people may want to provide their own cache that works better for their situation, like a Redis-backed one or similar, instead of just an in-memory one. That kind of thing is likely to be more appropriately accessed in an async manner, so I preemptively went that direction.

And you're right, the InMemoryFeatureCache itself has no async needs, but it should probably conform to that pattern anyway. I made a quick change now to remove the 'async' keyword and just explicitly return a Task.CompletedTask to make things a little more clear and avoid the warning, hopefully that all helps. :)

@@ -26,7 +26,7 @@ public class Experiment
/// <summary>
/// How to weight traffic between variations. Must add to 1.
/// </summary>
public IList<double> Weights { get; set; }
public List<double> Weights { get; set; }
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd always go for an interface (such as IEnumerable<> or IList<>) over a concrete type. That way you're more flexible in your implementation. I also doubt you need a List for this, as these properties come from GrowthBook you probably only need to iterate over this collection.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'd agree. Hmm... from my perspective, I would tend to prefer an array in here on data objects just so we can both have explicit length checks/indexing available as well as enforce the readonly semantics a little more to discourage adding/removing items, but with the existing SDK implementation using lists I wasn't sure if there was a use case for people wanting to add their own values on the fly to these objects. Taking a breaking change to that might cause problems.

@darklordzw Just wondering, did you have a particular design decision in mind choosing lists over something else for the existing implementation? Might help inform the directions we can go here. :)

Copy link
Collaborator

Choose a reason for hiding this comment

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

@Norhaven, I tend to prefer ILists over arrays in most cases unless there's a reason to go with arrays. I forget why I went with a list over IEnumerable originally, I suspect it was another case of wanting to stay syntactically consistent with the other SDKs, which also use indexable lists.

/// <param name="experiment">The experiment to evaluate.</param>
/// <returns>The experiment result.</returns>
ExperimentResult RunExperiment(Experiment experiment)
public async Task LoadFeatures(GrowthBookRetrievalOptions options = null, CancellationToken? cancellationToken = null)
Copy link
Contributor

Choose a reason for hiding this comment

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

/// <inheritdoc /> You want documentation on your public methods ;-)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good point, added. :)


if (_config.PreferServerSentEvents)
{
_isServerSentEventsEnabled = response.Headers.TryGetValues("x-sse-support", out var values) && values.Contains("enabled");
Copy link
Contributor

Choose a reason for hiding this comment

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

I saw this constant more than once in the code base. Maybe you could make it a constant.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sounds good, done.

var hostEndpoint = config.ApiHost;
var trimmedHostEndpoint = new string(hostEndpoint?.Reverse().SkipWhile(x => x == '/').Reverse().ToArray());

_featuresApiEndpoint = $"{trimmedHostEndpoint}/api/features/{config.ClientKey}";
Copy link
Contributor

Choose a reason for hiding this comment

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

If you'd used a typed http client, you could have contained all the logic related to communicating with the GrowthBook Api. Then this service worker would only contain the logic in keeping the GrowthBookFeatureCache up to date.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ahh, gotcha. Will look into that, thanks!

var featureCache = new InMemoryFeatureCache(cacheExpirationInSeconds: 60);
var httpClientFactory = new HttpClientFactory(requestTimeoutInSeconds: 60);

var loggerFactory = context.DefaultLoggerFactory ?? LoggerFactory.Create(builder =>
Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't this a concern of the consumer of this package to configure?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, I think you're right. I'd added in the console/debug logging for testing and provided the DefaultLogLevel property on the Context that could modify this, but looking at it some more I think that's an extraneous property and this can be condensed and not force a particular logging provider on the user. Will fix that. Thanks!

@Norhaven
Copy link
Collaborator Author

@darklordzw @Mastermindzh @pascal89 Okay, the PR is ready for another look over, please let me know if you have questions. I've left the existing comments open for any further discussion necessary, feel free to resolve them if it works for you. Thanks!

@Mastermindzh
Copy link
Contributor

@Norhaven @darklordzw

All my feedback has been implemented and then some. I don't see a reason not to merge this 😄.
Great job @Norhaven !

@pascal89
Copy link
Contributor

pascal89 commented Jan 29, 2024

@Norhaven Great work! Still have a little remark though. It would be nice if you could include two additions:

  1. Update the README.md because setting up GrowthBook in any implementation has changed a bit because of the infrastructure for fetching the features and keeping it up-to-date is now included in the package.
  2. Update the CHANGELOG.md to include the changes you've made.

@Norhaven
Copy link
Collaborator Author

Norhaven commented Jan 29, 2024

@pascal89 Just updated the changelog now, thanks! For the readme and documentation, I also have an extensive ASP.Net Core backend example demonstrating the basic use cases and how to set things up with this version of the SDK that will go into the https://github.com/growthbook/examples repo once this PR is complete and out to NuGet.

I'm thinking that once the example PR is approved and merged over there then I can link it in the readme over here, would that be sufficient or do you think that it would still help to put some basic 'getting started' info here as well?

@pascal89
Copy link
Contributor

I'm glad you've worked on some examples as well 😄 As long as we'll link to the example(s) from the readme it's fine for me. If you want my opinion on the examples you'll be adding, feel free to request me as a reviewer. I think that accounts for @Mastermindzh as well.
For this PR, IMO It's ready to go.

@darklordzw darklordzw merged commit 57b6b0b into main Jan 29, 2024
3 checks passed
@darklordzw
Copy link
Collaborator

PR has been merged into main. Thanks again for your contributions @Norhaven! I'll have a version up on NuGet in a few.

@darklordzw
Copy link
Collaborator

v1.0.0 has is live on NuGet now.

@Norhaven Norhaven deleted the fully-updating-sdk branch February 2, 2024 23:14
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